mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 13:55:50 +00:00
删除未使用的文件(BuiltinStatusLine.tsx、4 个重复的 .ts stub)、 移除约 55 个文件中未使用的 React 导入、 清理约 50 处未使用的导入/变量/参数。 净减少 ~296 行代码,precheck 4077 测试全部通过。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
313 lines
9.5 KiB
TypeScript
313 lines
9.5 KiB
TypeScript
import { randomUUID } from 'node:crypto'
|
|
import {
|
|
appendObservation,
|
|
type StoredSkillObservation,
|
|
} from './observationStore.js'
|
|
import type {
|
|
SkillLearningProjectContext,
|
|
SkillObservationOutcome,
|
|
} from './types.js'
|
|
import { logForDebugging } from '../../utils/debug.js'
|
|
import { logError } from '../../utils/log.js'
|
|
|
|
/**
|
|
* Tool event hook layer.
|
|
*
|
|
* Preferred observation pathway: consumers (tool dispatcher, REPL turn loop,
|
|
* or integration tests) call `recordToolStart` / `recordToolComplete` /
|
|
* `recordToolError` / `recordUserCorrection` as tool-level events happen,
|
|
* producing deterministic observations with `source: 'tool-hook'`.
|
|
*
|
|
* Post-sampling reconstruction (runtimeObserver.observationsFromMessages)
|
|
* is retained as a fallback for environments where the caller cannot emit
|
|
* tool events directly.
|
|
*
|
|
* @todo Wire these functions into `src/Tool.ts`'s public dispatch so the
|
|
* main REPL tool loop produces tool-hook observations automatically.
|
|
* Until then, callers that do have tool-level signal (integration
|
|
* tests, custom harness code, future tool middleware) can use the
|
|
* functions here directly.
|
|
*/
|
|
|
|
export type ToolHookContext = {
|
|
sessionId: string
|
|
turn: number
|
|
projectId: string
|
|
projectName: string
|
|
cwd: string
|
|
project?: SkillLearningProjectContext
|
|
}
|
|
|
|
/** Maximum number of turns tracked per session before pruning. */
|
|
const EMITTED_TURNS_SET_MAX = 500
|
|
/** How many turns to retain after pruning a session Set. */
|
|
const EMITTED_TURNS_SET_KEEP = 250
|
|
/** Maximum number of sessions tracked in the Map before pruning. */
|
|
const EMITTED_TURNS_MAP_MAX = 50
|
|
/** How many sessions to retain after pruning the Map. */
|
|
const EMITTED_TURNS_MAP_KEEP = 25
|
|
|
|
const emittedTurns = new Map<string, Set<number>>()
|
|
|
|
/**
|
|
* Prune `emittedTurns` to stay within memory bounds.
|
|
*
|
|
* - If any session's Set exceeds `EMITTED_TURNS_SET_MAX` entries, retain only
|
|
* the most recent `EMITTED_TURNS_SET_KEEP` turn numbers (FIFO trim).
|
|
* - If the Map itself exceeds `EMITTED_TURNS_MAP_MAX` entries, delete the
|
|
* oldest `EMITTED_TURNS_MAP_MAX - EMITTED_TURNS_MAP_KEEP` sessions
|
|
* (insertion-order LRU).
|
|
*
|
|
* Exported so tests and `resetToolHookBookkeeping` callers can invoke it
|
|
* directly.
|
|
*/
|
|
export function pruneEmittedTurns(): void {
|
|
// Prune over-sized Sets first. FIFO by insertion order — NOT by turn
|
|
// number magnitude. Non-monotonic turn ordering (e.g. replayed transcripts
|
|
// or nested tool chains) should not cause us to evict the wrong entries.
|
|
for (const [_sessionId, turns] of emittedTurns) {
|
|
if (turns.size > EMITTED_TURNS_SET_MAX) {
|
|
const iter = turns.values()
|
|
const toDrop = turns.size - EMITTED_TURNS_SET_KEEP
|
|
for (let i = 0; i < toDrop; i++) {
|
|
const next = iter.next()
|
|
if (next.done) break
|
|
turns.delete(next.value)
|
|
}
|
|
}
|
|
}
|
|
// Prune over-sized Map (delete oldest insertion-order entries).
|
|
if (emittedTurns.size > EMITTED_TURNS_MAP_MAX) {
|
|
const toDelete = emittedTurns.size - EMITTED_TURNS_MAP_KEEP
|
|
let deleted = 0
|
|
for (const key of emittedTurns.keys()) {
|
|
if (deleted >= toDelete) break
|
|
emittedTurns.delete(key)
|
|
deleted++
|
|
}
|
|
}
|
|
}
|
|
|
|
function markTurn(sessionId: string, turn: number): void {
|
|
// Refresh Map insertion order: delete + re-set so a recently-touched
|
|
// session is treated as "youngest" for the LRU-ish Map eviction.
|
|
const seen = emittedTurns.get(sessionId) ?? new Set<number>()
|
|
seen.add(turn)
|
|
emittedTurns.delete(sessionId)
|
|
emittedTurns.set(sessionId, seen)
|
|
pruneEmittedTurns()
|
|
}
|
|
|
|
export function hasToolHookObservationsForTurn(
|
|
sessionId: string,
|
|
turn: number,
|
|
): boolean {
|
|
return emittedTurns.get(sessionId)?.has(turn) ?? false
|
|
}
|
|
|
|
export function resetToolHookBookkeeping(): void {
|
|
emittedTurns.clear()
|
|
}
|
|
|
|
function baseObservation(
|
|
ctx: ToolHookContext,
|
|
): Pick<
|
|
StoredSkillObservation,
|
|
| 'id'
|
|
| 'sessionId'
|
|
| 'projectId'
|
|
| 'projectName'
|
|
| 'cwd'
|
|
| 'timestamp'
|
|
| 'source'
|
|
| 'turn'
|
|
> {
|
|
return {
|
|
id: randomUUID(),
|
|
sessionId: ctx.sessionId,
|
|
projectId: ctx.projectId,
|
|
projectName: ctx.projectName,
|
|
cwd: ctx.cwd,
|
|
timestamp: new Date().toISOString(),
|
|
source: 'tool-hook',
|
|
// Persist turn so runtimeObserver can filter tool-hook observations by
|
|
// the current turn rather than sweeping all historical tool-hook data
|
|
// (codex review Q1).
|
|
turn: ctx.turn,
|
|
}
|
|
}
|
|
|
|
// Cached import promise — resolved once so the hot path pays no repeated
|
|
// dynamic-import overhead after the first invocation.
|
|
let _depImportCache:
|
|
| Promise<{
|
|
resolveProjectContext: (cwd: string) => SkillLearningProjectContext
|
|
isSkillLearningEnabled: () => boolean
|
|
RUNTIME_SESSION_ID: string
|
|
getRuntimeTurn: () => number
|
|
}>
|
|
| undefined
|
|
|
|
function _getDeps() {
|
|
if (!_depImportCache) {
|
|
_depImportCache = Promise.all([
|
|
import('./projectContext.js'),
|
|
import('./featureCheck.js'),
|
|
import('./runtimeObserver.js'),
|
|
]).then(([pc, fc, ro]) => ({
|
|
resolveProjectContext: pc.resolveProjectContext,
|
|
isSkillLearningEnabled: fc.isSkillLearningEnabled,
|
|
RUNTIME_SESSION_ID: ro.RUNTIME_SESSION_ID,
|
|
getRuntimeTurn: ro.getRuntimeTurn,
|
|
}))
|
|
}
|
|
return _depImportCache
|
|
}
|
|
|
|
/** Reset the cached dep import (for test isolation). */
|
|
export function resetToolHookDepsCache(): void {
|
|
_depImportCache = undefined
|
|
}
|
|
|
|
/**
|
|
* Wrap a tool.call invocation with deterministic tool-event observation.
|
|
*
|
|
* Designed for the single call site in `toolExecution.ts`. The hook calls
|
|
* (`recordToolStart`, `recordToolComplete`, `recordToolError`) are true
|
|
* fire-and-forget: the tool invoke result is returned immediately without
|
|
* waiting for the observation to persist. Errors in observation are caught
|
|
* and logged so they never surface to the caller.
|
|
*/
|
|
export async function runToolCallWithSkillLearningHooks<T>(
|
|
toolName: string,
|
|
input: unknown,
|
|
callContext: { sessionId?: string; turn?: number },
|
|
invoke: () => Promise<T>,
|
|
): Promise<T> {
|
|
let ctx: ToolHookContext | undefined
|
|
try {
|
|
const {
|
|
resolveProjectContext,
|
|
isSkillLearningEnabled,
|
|
RUNTIME_SESSION_ID,
|
|
getRuntimeTurn,
|
|
} = await _getDeps()
|
|
if (!isSkillLearningEnabled()) {
|
|
return invoke()
|
|
}
|
|
const project = resolveProjectContext(process.cwd())
|
|
// Always emit under the runtime observer's sessionId so the post-sampling
|
|
// consumer can find our records. The prior default `'cli'` fell outside
|
|
// the observer's sessionId filter and made tool-hook observations
|
|
// structurally unconsumable (codex second-pass audit AC1).
|
|
ctx = {
|
|
sessionId: callContext.sessionId ?? RUNTIME_SESSION_ID,
|
|
turn: callContext.turn ?? getRuntimeTurn(),
|
|
projectId: project.projectId,
|
|
projectName: project.projectName,
|
|
cwd: project.cwd,
|
|
project,
|
|
}
|
|
// Fire-and-forget: do NOT await — tool invoke must not be blocked.
|
|
void recordToolStart(ctx, toolName, input).catch(e => {
|
|
logForDebugging('skill-learning: recordToolStart error')
|
|
logError(e)
|
|
})
|
|
} catch (e) {
|
|
// Never let observation setup errors affect tool execution.
|
|
logForDebugging('skill-learning: hook setup error')
|
|
logError(e)
|
|
}
|
|
try {
|
|
const result = await invoke()
|
|
if (ctx) {
|
|
// Fire-and-forget: do NOT await.
|
|
void recordToolComplete(ctx, toolName, result, 'success').catch(e => {
|
|
logForDebugging('skill-learning: recordToolComplete error')
|
|
logError(e)
|
|
})
|
|
}
|
|
return result
|
|
} catch (error) {
|
|
if (ctx) {
|
|
// Fire-and-forget: do NOT await.
|
|
void recordToolError(ctx, toolName, error).catch(e => {
|
|
logForDebugging('skill-learning: recordToolError error')
|
|
logError(e)
|
|
})
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
export async function recordToolStart(
|
|
ctx: ToolHookContext,
|
|
toolName: string,
|
|
input?: unknown,
|
|
): Promise<StoredSkillObservation> {
|
|
markTurn(ctx.sessionId, ctx.turn)
|
|
const observation: StoredSkillObservation = {
|
|
...baseObservation(ctx),
|
|
event: 'tool_start',
|
|
toolName,
|
|
toolInput: stringify(input),
|
|
}
|
|
return appendObservation(observation, { project: ctx.project })
|
|
}
|
|
|
|
export async function recordToolComplete(
|
|
ctx: ToolHookContext,
|
|
toolName: string,
|
|
output?: unknown,
|
|
outcome: SkillObservationOutcome = 'success',
|
|
): Promise<StoredSkillObservation> {
|
|
markTurn(ctx.sessionId, ctx.turn)
|
|
const observation: StoredSkillObservation = {
|
|
...baseObservation(ctx),
|
|
event: 'tool_complete',
|
|
toolName,
|
|
toolOutput: stringify(output),
|
|
outcome,
|
|
}
|
|
return appendObservation(observation, { project: ctx.project })
|
|
}
|
|
|
|
export async function recordToolError(
|
|
ctx: ToolHookContext,
|
|
toolName: string,
|
|
error: unknown,
|
|
): Promise<StoredSkillObservation> {
|
|
markTurn(ctx.sessionId, ctx.turn)
|
|
const observation: StoredSkillObservation = {
|
|
...baseObservation(ctx),
|
|
event: 'tool_complete',
|
|
toolName,
|
|
toolOutput: stringify(error),
|
|
outcome: 'failure',
|
|
}
|
|
return appendObservation(observation, { project: ctx.project })
|
|
}
|
|
|
|
export async function recordUserCorrection(
|
|
ctx: ToolHookContext,
|
|
messageText: string,
|
|
): Promise<StoredSkillObservation> {
|
|
markTurn(ctx.sessionId, ctx.turn)
|
|
const observation: StoredSkillObservation = {
|
|
...baseObservation(ctx),
|
|
event: 'user_message',
|
|
messageText,
|
|
}
|
|
return appendObservation(observation, { project: ctx.project })
|
|
}
|
|
|
|
function stringify(value: unknown): string | undefined {
|
|
if (value === undefined || value === null) return undefined
|
|
if (typeof value === 'string') return value
|
|
try {
|
|
return JSON.stringify(value)
|
|
} catch {
|
|
return String(value)
|
|
}
|
|
}
|