mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-22 15:05:16 +00:00
Merge pull request #824 from getagentseal/phase5/detectors
feat(core): duplicate-reads, junk-reads, context-bloat detectors over fingerprints (phase 5)
This commit is contained in:
commit
ae624c45b2
24 changed files with 1095 additions and 41 deletions
|
|
@ -63,7 +63,7 @@ export type CodeburnConfig = {
|
|||
proxyPaths?: string[]
|
||||
}
|
||||
|
||||
function getConfigDir(): string {
|
||||
export function getConfigDir(): string {
|
||||
return join(homedir(), '.config', 'codeburn')
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,18 @@ import { existsSync, statSync } from 'fs'
|
|||
import { basename, join } from 'path'
|
||||
import { homedir } from 'os'
|
||||
|
||||
import { projectRef as fingerprintProjectRef, resourceFingerprint, sessionRef as fingerprintSessionRef } from '@codeburn/core/fingerprint'
|
||||
import { OBSERVATION_SCHEMA_VERSION } from '@codeburn/core/schema'
|
||||
import type { CallObservation, ObservationEnvelope, SessionObservation } from '@codeburn/core/observations'
|
||||
import type { Finding } from '@codeburn/core/contracts'
|
||||
import {
|
||||
contextBloatDetector,
|
||||
duplicateReadsDetector,
|
||||
junkReadsDetector,
|
||||
} from '@codeburn/core/detectors'
|
||||
|
||||
import { readSessionLines, readSessionFileSync } from './fs-utils.js'
|
||||
import { getHostPrivacyKey } from './privacy-key.js'
|
||||
import { discoverAllSessions } from './providers/index.js'
|
||||
import { parseJsonlLine, shouldSkipLine } from './parser.js'
|
||||
import type { DateRange, ProjectSummary, SessionSummary } from './types.js'
|
||||
|
|
@ -43,10 +54,10 @@ const BASH_TOKENS_PER_CHAR = 0.25
|
|||
|
||||
const CLAUDEMD_HEALTHY_LINES = 200
|
||||
const CLAUDEMD_HIGH_THRESHOLD_LINES = 400
|
||||
const MIN_JUNK_READS_TO_FLAG = 3
|
||||
// junk-reads / duplicate-reads flag thresholds now live in @codeburn/core; the
|
||||
// host keeps only the impact-tier cutoffs it needs to map a core Finding.
|
||||
const JUNK_READS_HIGH_THRESHOLD = 20
|
||||
const JUNK_READS_MEDIUM_THRESHOLD = 5
|
||||
const MIN_DUPLICATE_READS_TO_FLAG = 5
|
||||
const DUPLICATE_READS_HIGH_THRESHOLD = 30
|
||||
const DUPLICATE_READS_MEDIUM_THRESHOLD = 10
|
||||
const MIN_EDITS_FOR_RATIO = 10
|
||||
|
|
@ -645,20 +656,103 @@ export function loadMcpConfigs(projectCwds: Iterable<string>, homeDir = homedir(
|
|||
return servers
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Fingerprint-envelope bridge
|
||||
// ============================================================================
|
||||
//
|
||||
// The junk-reads, duplicate-reads and read-to-edit-ratio detectors live in
|
||||
// @codeburn/core and operate over an ObservationEnvelope of fingerprinted
|
||||
// resource refs — never raw paths. The host builds that envelope from its own
|
||||
// ToolCall[] (fingerprinting each read/edit's file path with the persistent host
|
||||
// privacy key), runs the core detector, and maps the returned Finding onto the
|
||||
// existing WasteFinding display shape. Display strings, fix payloads and trend
|
||||
// stay host-derived from the CLI's own path data (D5-A); the core Finding
|
||||
// supplies the decision and the authoritative counts.
|
||||
|
||||
const READ_RESOURCE_TOOLS = new Set(['Read', 'FileReadTool'])
|
||||
const SESSION_KEY_SEP = ''
|
||||
|
||||
// Fields the detectors never read, filled with valid placeholders so the object
|
||||
// satisfies the CallObservation/SessionObservation types. This envelope is
|
||||
// consumed in-process by the pure detectors and is never schema-parsed or
|
||||
// emitted, so these placeholders never reach a payload.
|
||||
function bridgeCall(name: string, refs: { resourceReads?: CallObservation['resourceReads']; resourceEdits?: CallObservation['resourceEdits'] }): CallObservation {
|
||||
return {
|
||||
provider: 'claude',
|
||||
model: 'host',
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheCreate: 0 },
|
||||
webSearchRequests: 0,
|
||||
speed: 'standard',
|
||||
costBasis: 'estimated',
|
||||
timestamp: '1970-01-01T00:00:00.000Z',
|
||||
dedupKey: 'host',
|
||||
toolNames: [name],
|
||||
turnIndex: 0,
|
||||
...refs,
|
||||
}
|
||||
}
|
||||
|
||||
function buildObservationEnvelope(calls: ToolCall[]): ObservationEnvelope {
|
||||
const privacyKey = getHostPrivacyKey()
|
||||
|
||||
const order: string[] = []
|
||||
const groups = new Map<string, ToolCall[]>()
|
||||
for (const c of calls) {
|
||||
const key = `${c.project}${SESSION_KEY_SEP}${c.sessionId}`
|
||||
let g = groups.get(key)
|
||||
if (!g) { g = []; groups.set(key, g); order.push(key) }
|
||||
g.push(c)
|
||||
}
|
||||
|
||||
const sessions: SessionObservation[] = order.map(key => {
|
||||
const groupCalls = groups.get(key)!
|
||||
const obs = groupCalls.map(c => {
|
||||
const filePath = typeof c.input.file_path === 'string' ? c.input.file_path : undefined
|
||||
if (!filePath) return bridgeCall(c.name, {})
|
||||
const fp = resourceFingerprint(privacyKey, filePath)
|
||||
const ref = { resourceId: fp.resourceId, resourceClass: fp.resourceClass }
|
||||
if (READ_RESOURCE_TOOLS.has(c.name)) return bridgeCall(c.name, { resourceReads: [ref] })
|
||||
if (EDIT_TOOL_NAMES.has(c.name)) return bridgeCall(c.name, { resourceEdits: [ref] })
|
||||
return bridgeCall(c.name, {})
|
||||
})
|
||||
return {
|
||||
sessionRef: fingerprintSessionRef(privacyKey, 'claude', key),
|
||||
projectRef: fingerprintProjectRef(privacyKey, key),
|
||||
providerId: 'claude',
|
||||
startedAt: '1970-01-01T00:00:00.000Z',
|
||||
calls: obs,
|
||||
turnCount: 1,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
schemaVersion: OBSERVATION_SCHEMA_VERSION,
|
||||
generator: { name: '@codeburn/core', version: 'host' },
|
||||
sessions,
|
||||
}
|
||||
}
|
||||
|
||||
function evidenceCount(finding: Finding, kind: string): number {
|
||||
return finding.evidence.find(e => e.kind === kind)?.count ?? 0
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Detectors
|
||||
// ============================================================================
|
||||
|
||||
export function detectJunkReads(calls: ToolCall[], dateRange?: DateRange): WasteFinding | null {
|
||||
const dirCounts = new Map<string, number>()
|
||||
let totalJunkReads = 0
|
||||
let recentJunkReads = 0
|
||||
const findings = junkReadsDetector(buildObservationEnvelope(calls))
|
||||
if (findings.length === 0) return null
|
||||
const totalJunkReads = evidenceCount(findings[0], 'junk-reads')
|
||||
const tokensSaved = evidenceCount(findings[0], 'tokens-saved')
|
||||
|
||||
// Display + trend stay host-derived from the raw path data (D5-A).
|
||||
const dirCounts = new Map<string, number>()
|
||||
let recentJunkReads = 0
|
||||
for (const call of calls) {
|
||||
if (!isReadTool(call.name)) continue
|
||||
const filePath = call.input.file_path as string | undefined
|
||||
if (!filePath || !JUNK_PATTERN.test(filePath)) continue
|
||||
totalJunkReads++
|
||||
if (call.recent) recentJunkReads++
|
||||
for (const dir of JUNK_DIRS) {
|
||||
if (filePath.includes(`/${dir}/`)) {
|
||||
|
|
@ -668,15 +762,12 @@ export function detectJunkReads(calls: ToolCall[], dateRange?: DateRange): Waste
|
|||
}
|
||||
}
|
||||
|
||||
if (totalJunkReads < MIN_JUNK_READS_TO_FLAG) return null
|
||||
|
||||
const hasRecentActivity = calls.some(c => c.recent)
|
||||
const trend = sessionTrend(recentJunkReads, totalJunkReads, dateRange, hasRecentActivity)
|
||||
if (trend === 'resolved') return null
|
||||
|
||||
const sorted = [...dirCounts.entries()].sort((a, b) => b[1] - a[1])
|
||||
const dirList = sorted.slice(0, TOP_ITEMS_PREVIEW).map(([d, n]) => `${d}/ (${n}x)`).join(', ')
|
||||
const tokensSaved = totalJunkReads * AVG_TOKENS_PER_READ
|
||||
|
||||
const detected = sorted.map(([d]) => d)
|
||||
const commonDefaults = ['node_modules', '.git', 'dist', '__pycache__']
|
||||
|
|
@ -700,8 +791,13 @@ export function detectJunkReads(calls: ToolCall[], dateRange?: DateRange): Waste
|
|||
}
|
||||
|
||||
export function detectDuplicateReads(calls: ToolCall[], dateRange?: DateRange): WasteFinding | null {
|
||||
const sessionFiles = new Map<string, Map<string, { count: number; recent: number }>>()
|
||||
const findings = duplicateReadsDetector(buildObservationEnvelope(calls))
|
||||
if (findings.length === 0) return null
|
||||
const totalDuplicates = evidenceCount(findings[0], 'duplicate-reads')
|
||||
const tokensSaved = evidenceCount(findings[0], 'tokens-saved')
|
||||
|
||||
// Per-file breakdown + trend stay host-derived from the raw path data (D5-A).
|
||||
const sessionFiles = new Map<string, Map<string, { count: number; recent: number }>>()
|
||||
for (const call of calls) {
|
||||
if (!isReadTool(call.name)) continue
|
||||
const filePath = call.input.file_path as string | undefined
|
||||
|
|
@ -715,23 +811,18 @@ export function detectDuplicateReads(calls: ToolCall[], dateRange?: DateRange):
|
|||
fm.set(filePath, entry)
|
||||
}
|
||||
|
||||
let totalDuplicates = 0
|
||||
let recentDuplicates = 0
|
||||
const fileDupes = new Map<string, number>()
|
||||
|
||||
for (const fm of sessionFiles.values()) {
|
||||
for (const [file, entry] of fm) {
|
||||
if (entry.count <= 1) continue
|
||||
const extra = entry.count - 1
|
||||
totalDuplicates += extra
|
||||
if (entry.recent > 1) recentDuplicates += entry.recent - 1
|
||||
const name = basename(file)
|
||||
fileDupes.set(name, (fileDupes.get(name) ?? 0) + extra)
|
||||
}
|
||||
}
|
||||
|
||||
if (totalDuplicates < MIN_DUPLICATE_READS_TO_FLAG) return null
|
||||
|
||||
const hasRecentActivity = calls.some(c => c.recent)
|
||||
const trend = sessionTrend(recentDuplicates, totalDuplicates, dateRange, hasRecentActivity)
|
||||
if (trend === 'resolved') return null
|
||||
|
|
@ -742,8 +833,6 @@ export function detectDuplicateReads(calls: ToolCall[], dateRange?: DateRange):
|
|||
.map(([name, n]) => `${name} (${n + 1}x)`)
|
||||
.join(', ')
|
||||
|
||||
const tokensSaved = totalDuplicates * AVG_TOKENS_PER_READ
|
||||
|
||||
return {
|
||||
id: 'redundant-rereads',
|
||||
title: 'Claude is re-reading the same files',
|
||||
|
|
@ -2191,27 +2280,25 @@ export const READ_TOOL_NAMES = new Set(['Read', 'Grep', 'Glob', 'FileReadTool',
|
|||
export const EDIT_TOOL_NAMES = new Set(['Edit', 'Write', 'FileEditTool', 'FileWriteTool', 'NotebookEdit'])
|
||||
|
||||
export function detectLowReadEditRatio(calls: ToolCall[]): WasteFinding | null {
|
||||
let reads = 0
|
||||
let edits = 0
|
||||
const findings = contextBloatDetector(buildObservationEnvelope(calls))
|
||||
if (findings.length === 0) return null
|
||||
const reads = evidenceCount(findings[0], 'reads')
|
||||
const edits = evidenceCount(findings[0], 'edits')
|
||||
const tokensSaved = evidenceCount(findings[0], 'tokens-saved')
|
||||
const ratio = reads / edits
|
||||
|
||||
// Recency (for trend) stays host-derived from the raw calls.
|
||||
let recentEdits = 0
|
||||
let recentReads = 0
|
||||
for (const call of calls) {
|
||||
if (READ_TOOL_NAMES.has(call.name)) {
|
||||
reads++
|
||||
if (call.recent) recentReads++
|
||||
} else if (EDIT_TOOL_NAMES.has(call.name)) {
|
||||
edits++
|
||||
if (call.recent) recentEdits++
|
||||
}
|
||||
}
|
||||
|
||||
if (edits < MIN_EDITS_FOR_RATIO) return null
|
||||
const ratio = reads / edits
|
||||
if (ratio >= HEALTHY_READ_EDIT_RATIO) return null
|
||||
|
||||
const impact: Impact = ratio < LOW_RATIO_HIGH_THRESHOLD ? 'high' : ratio < LOW_RATIO_MEDIUM_THRESHOLD ? 'medium' : 'low'
|
||||
const extraReadsNeeded = Math.max(Math.round(edits * HEALTHY_READ_EDIT_RATIO) - reads, 0)
|
||||
const tokensSaved = extraReadsNeeded * AVG_TOKENS_PER_READ
|
||||
|
||||
let trend: Trend | 'resolved' = 'active'
|
||||
if (recentEdits >= MIN_EDITS_FOR_RATIO) {
|
||||
|
|
|
|||
58
packages/cli/src/privacy-key.ts
Normal file
58
packages/cli/src/privacy-key.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
// Host privacy key (decision D1). A random 32-byte key, generated once and
|
||||
// persisted in the codeburn config dir alongside config.json, that scopes every
|
||||
// resource fingerprint. Keeping it stable across runs makes resourceIds stable
|
||||
// (so the same file always fingerprints the same way); regenerating it would
|
||||
// scramble them. The key is NEVER printed and NEVER leaves the host — only the
|
||||
// HMAC fingerprints it produces cross into any payload.
|
||||
//
|
||||
// Read synchronously (and cached) because the optimize detectors that need it
|
||||
// are synchronous. This mirrors config.ts's storage location while staying on
|
||||
// the sync fs API those detectors require.
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { randomBytes } from 'crypto'
|
||||
|
||||
import { getConfigDir } from './config.js'
|
||||
|
||||
const KEY_FILE = 'privacy-key'
|
||||
const KEY_HEX = /^[0-9a-f]{64}$/
|
||||
|
||||
let cached: string | undefined
|
||||
|
||||
function keyPath(): string {
|
||||
return join(getConfigDir(), KEY_FILE)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the host privacy key, generating and persisting one on first use.
|
||||
* Falls back to an in-memory ephemeral key if the config dir is unwritable, so
|
||||
* a read-only environment still gets stable (per-process) fingerprints rather
|
||||
* than throwing.
|
||||
*/
|
||||
export function getHostPrivacyKey(): string {
|
||||
if (cached) return cached
|
||||
|
||||
const path = keyPath()
|
||||
if (existsSync(path)) {
|
||||
try {
|
||||
const raw = readFileSync(path, 'utf-8').trim()
|
||||
if (KEY_HEX.test(raw)) {
|
||||
cached = raw
|
||||
return cached
|
||||
}
|
||||
} catch {
|
||||
// fall through to regenerate
|
||||
}
|
||||
}
|
||||
|
||||
const key = randomBytes(32).toString('hex')
|
||||
try {
|
||||
mkdirSync(getConfigDir(), { recursive: true })
|
||||
writeFileSync(path, key + '\n', { mode: 0o600 })
|
||||
} catch {
|
||||
// Config dir unwritable — keep the key in memory for this process only.
|
||||
}
|
||||
cached = key
|
||||
return cached
|
||||
}
|
||||
|
|
@ -31,6 +31,10 @@
|
|||
"types": "./dist/contracts.d.ts",
|
||||
"import": "./dist/contracts.js"
|
||||
},
|
||||
"./detectors": {
|
||||
"types": "./dist/detectors/index.d.ts",
|
||||
"import": "./dist/detectors/index.js"
|
||||
},
|
||||
"./providers/claude": {
|
||||
"types": "./dist/providers/claude/index.d.ts",
|
||||
"import": "./dist/providers/claude/index.js"
|
||||
|
|
|
|||
280
packages/core/schemas/observation-0.2.0.json
Normal file
280
packages/core/schemas/observation-0.2.0.json
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
{
|
||||
"$ref": "#/definitions/ObservationEnvelope",
|
||||
"definitions": {
|
||||
"ObservationEnvelope": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"type": "string",
|
||||
"const": "0.2.0"
|
||||
},
|
||||
"generator": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"const": "@codeburn/core"
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"version"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"sessions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sessionRef": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{16}$"
|
||||
},
|
||||
"projectRef": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{16}$"
|
||||
},
|
||||
"providerId": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"startedAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"endedAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"gitBranchRef": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{16}$"
|
||||
},
|
||||
"isSidechain": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"calls": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"provider": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"pricingModel": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"tokens": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"input": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"output": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"reasoning": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"cacheRead": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"cacheCreate": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"input",
|
||||
"output",
|
||||
"reasoning",
|
||||
"cacheRead",
|
||||
"cacheCreate"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"webSearchRequests": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"speed": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"standard",
|
||||
"fast"
|
||||
]
|
||||
},
|
||||
"costBasis": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"measured",
|
||||
"estimated"
|
||||
]
|
||||
},
|
||||
"measuredCostUSD": {
|
||||
"type": "number",
|
||||
"minimum": 0
|
||||
},
|
||||
"fallbackCostUSD": {
|
||||
"type": "number",
|
||||
"minimum": 0
|
||||
},
|
||||
"timestamp": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"dedupKey": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"toolNames": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64,
|
||||
"pattern": "^[A-Za-z0-9_.-]+$"
|
||||
}
|
||||
},
|
||||
"turnIndex": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"resourceReads": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"resourceId": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{16}$"
|
||||
},
|
||||
"resourceClass": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"dependency",
|
||||
"build",
|
||||
"vcs",
|
||||
"config",
|
||||
"source",
|
||||
"doc",
|
||||
"other"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"resourceId",
|
||||
"resourceClass"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"resourceEdits": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"resourceId": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{16}$"
|
||||
},
|
||||
"resourceClass": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"dependency",
|
||||
"build",
|
||||
"vcs",
|
||||
"config",
|
||||
"source",
|
||||
"doc",
|
||||
"other"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"resourceId",
|
||||
"resourceClass"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"locAdded": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"locRemoved": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"interrupted": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"userModified": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"toolErrors": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"editFailed": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"provider",
|
||||
"model",
|
||||
"tokens",
|
||||
"webSearchRequests",
|
||||
"speed",
|
||||
"costBasis",
|
||||
"timestamp",
|
||||
"dedupKey",
|
||||
"toolNames",
|
||||
"turnIndex"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"turnCount": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"sessionRef",
|
||||
"projectRef",
|
||||
"providerId",
|
||||
"startedAt",
|
||||
"calls",
|
||||
"turnCount"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"generator",
|
||||
"sessions"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"$schema": "http://json-schema.org/draft-07/schema#"
|
||||
}
|
||||
61
packages/core/src/detectors/context-bloat.ts
Normal file
61
packages/core/src/detectors/context-bloat.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
// Context-bloat / read-to-edit-ratio detector: editing far more than reading
|
||||
// leads to retries and wasted tokens. Reproduces the host CLI's
|
||||
// detectLowReadEditRatio.
|
||||
//
|
||||
// Counting is by canonical tool name, NOT by resource ref: pattern search tools
|
||||
// (Grep/Glob) count as reads even though they touch no single file, and an edit
|
||||
// with no file target still counts as an edit. Each tool-use occurrence in a
|
||||
// call's toolNames is one read or one edit (the two name sets are disjoint), so
|
||||
// on the host's one-tool-per-call envelope this equals the legacy per-ToolCall
|
||||
// tally, and generalises cleanly to a rich multi-tool call.
|
||||
//
|
||||
// Savings math mirrors the host: the tokens for the extra reads a healthy ratio
|
||||
// would have required.
|
||||
|
||||
import type { Detector, Finding } from '../contracts.js'
|
||||
import { AVG_TOKENS_PER_READ, EDIT_TOOL_NAMES, READ_TOOL_NAMES, clamp01, forEachCall } from './shared.js'
|
||||
|
||||
export const CONTEXT_BLOAT_DETECTOR_ID = 'context-bloat'
|
||||
export const CONTEXT_BLOAT_ALGORITHM_VERSION = '1.0.0'
|
||||
|
||||
const MIN_EDITS_FOR_RATIO = 10
|
||||
const HEALTHY_READ_EDIT_RATIO = 4
|
||||
|
||||
export const contextBloatDetector: Detector = (envelope): Finding[] => {
|
||||
let reads = 0
|
||||
let edits = 0
|
||||
const sessionRefs = new Set<string>()
|
||||
|
||||
forEachCall(envelope, (call, session) => {
|
||||
let touched = false
|
||||
for (const name of call.toolNames) {
|
||||
if (READ_TOOL_NAMES.has(name)) { reads++; touched = true }
|
||||
else if (EDIT_TOOL_NAMES.has(name)) { edits++; touched = true }
|
||||
}
|
||||
if (touched) sessionRefs.add(session.sessionRef)
|
||||
})
|
||||
|
||||
if (edits < MIN_EDITS_FOR_RATIO) return []
|
||||
const ratio = reads / edits
|
||||
if (ratio >= HEALTHY_READ_EDIT_RATIO) return []
|
||||
|
||||
const extraReadsNeeded = Math.max(Math.round(edits * HEALTHY_READ_EDIT_RATIO) - reads, 0)
|
||||
const tokensSaved = extraReadsNeeded * AVG_TOKENS_PER_READ
|
||||
// Lower ratio (further below healthy) = stronger signal.
|
||||
const score = clamp01(1 - ratio / HEALTHY_READ_EDIT_RATIO)
|
||||
|
||||
const finding: Finding = {
|
||||
detectorId: CONTEXT_BLOAT_DETECTOR_ID,
|
||||
algorithmVersion: CONTEXT_BLOAT_ALGORITHM_VERSION,
|
||||
confidence: {
|
||||
score,
|
||||
basis: `read-to-edit ratio ${ratio.toFixed(2)}:1 over ${reads} reads / ${edits} edits; healthy is >=${HEALTHY_READ_EDIT_RATIO}`,
|
||||
},
|
||||
evidence: [
|
||||
{ kind: 'reads', count: reads, sessionRefs: [...sessionRefs] },
|
||||
{ kind: 'edits', count: edits },
|
||||
{ kind: 'tokens-saved', count: tokensSaved },
|
||||
],
|
||||
}
|
||||
return [finding]
|
||||
}
|
||||
72
packages/core/src/detectors/duplicate-reads.ts
Normal file
72
packages/core/src/detectors/duplicate-reads.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
// Duplicate-reads detector: the same file read more than once within a single
|
||||
// session loads its content into context again for no new information.
|
||||
//
|
||||
// Reproduces the host CLI's detectDuplicateReads: group whole-file reads by
|
||||
// (session, file), exclude junk resources, and sum the extra reads (count - 1)
|
||||
// per file. Flags at >= MIN_DUPLICATE_READS_TO_FLAG total extras.
|
||||
//
|
||||
// Identity semantics: files are keyed by resourceId (fingerprint of the
|
||||
// normalised path) rather than the raw path, and sessions by sessionRef. Two raw
|
||||
// paths that normalise to one fingerprint (e.g. a trailing slash, or a
|
||||
// case-only difference on a Windows-style path) count as the SAME file here — on
|
||||
// a POSIX corpus with canonical read paths this is a 1:1 relabelling, so counts
|
||||
// match the legacy detector. Junk exclusion uses resourceClass (see junk-reads
|
||||
// for how that differs from the legacy JUNK_DIRS regex at the edges).
|
||||
|
||||
import type { Detector, Finding } from '../contracts.js'
|
||||
import { AVG_TOKENS_PER_READ, JUNK_RESOURCE_CLASSES, clamp01, forEachCall } from './shared.js'
|
||||
|
||||
export const DUPLICATE_READS_DETECTOR_ID = 'duplicate-reads'
|
||||
export const DUPLICATE_READS_ALGORITHM_VERSION = '1.0.0'
|
||||
|
||||
const MIN_DUPLICATE_READS_TO_FLAG = 5
|
||||
const DUPLICATE_READS_HIGH_THRESHOLD = 30
|
||||
|
||||
export const duplicateReadsDetector: Detector = (envelope): Finding[] => {
|
||||
// sessionRef -> resourceId -> read count.
|
||||
const perSession = new Map<string, Map<string, number>>()
|
||||
|
||||
forEachCall(envelope, (call, session) => {
|
||||
for (const ref of call.resourceReads ?? []) {
|
||||
if (JUNK_RESOURCE_CLASSES.has(ref.resourceClass)) continue
|
||||
let files = perSession.get(session.sessionRef)
|
||||
if (!files) {
|
||||
files = new Map()
|
||||
perSession.set(session.sessionRef, files)
|
||||
}
|
||||
files.set(ref.resourceId, (files.get(ref.resourceId) ?? 0) + 1)
|
||||
}
|
||||
})
|
||||
|
||||
let totalDuplicates = 0
|
||||
const dupRefs = new Set<string>()
|
||||
const dupSessions = new Set<string>()
|
||||
|
||||
for (const [sessionRef, files] of perSession) {
|
||||
for (const [resourceId, count] of files) {
|
||||
if (count <= 1) continue
|
||||
totalDuplicates += count - 1
|
||||
dupRefs.add(resourceId)
|
||||
dupSessions.add(sessionRef)
|
||||
}
|
||||
}
|
||||
|
||||
if (totalDuplicates < MIN_DUPLICATE_READS_TO_FLAG) return []
|
||||
|
||||
const tokensSaved = totalDuplicates * AVG_TOKENS_PER_READ
|
||||
const score = clamp01(totalDuplicates / DUPLICATE_READS_HIGH_THRESHOLD)
|
||||
|
||||
const finding: Finding = {
|
||||
detectorId: DUPLICATE_READS_DETECTOR_ID,
|
||||
algorithmVersion: DUPLICATE_READS_ALGORITHM_VERSION,
|
||||
confidence: {
|
||||
score,
|
||||
basis: `${totalDuplicates} redundant re-reads of ${dupRefs.size} file(s) across ${dupSessions.size} session(s); flags at >=${MIN_DUPLICATE_READS_TO_FLAG}`,
|
||||
},
|
||||
evidence: [
|
||||
{ kind: 'duplicate-reads', count: totalDuplicates, refs: [...dupRefs], sessionRefs: [...dupSessions] },
|
||||
{ kind: 'tokens-saved', count: tokensSaved },
|
||||
],
|
||||
}
|
||||
return [finding]
|
||||
}
|
||||
21
packages/core/src/detectors/index.ts
Normal file
21
packages/core/src/detectors/index.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
// Pure, fingerprint-based detectors. Each consumes an ObservationEnvelope and
|
||||
// emits Finding[] with zero fs/env/clock access — the detector-purity property
|
||||
// the import-smoke guardrail enforces over this subpath.
|
||||
|
||||
import type { Detector } from '../contracts.js'
|
||||
|
||||
export * from './shared.js'
|
||||
export * from './duplicate-reads.js'
|
||||
export * from './junk-reads.js'
|
||||
export * from './context-bloat.js'
|
||||
|
||||
import { duplicateReadsDetector } from './duplicate-reads.js'
|
||||
import { junkReadsDetector } from './junk-reads.js'
|
||||
import { contextBloatDetector } from './context-bloat.js'
|
||||
|
||||
/** All fingerprint-based detectors, in a stable order. */
|
||||
export const detectors: readonly Detector[] = [
|
||||
junkReadsDetector,
|
||||
duplicateReadsDetector,
|
||||
contextBloatDetector,
|
||||
]
|
||||
60
packages/core/src/detectors/junk-reads.ts
Normal file
60
packages/core/src/detectors/junk-reads.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
// Junk-reads detector: flags reads into dependency/build/vcs resources (the
|
||||
// path-free redesign of the host CLI's JUNK_DIRS check).
|
||||
//
|
||||
// Identity semantics vs the legacy host detector: the CLI matched a raw-path
|
||||
// regex over JUNK_DIRS. Here "junk" is resourceClass ∈ {dependency, build, vcs}
|
||||
// (see JUNK_RESOURCE_CLASSES). classifyResource's segment tables are kept a
|
||||
// STRICT SUPERSET of that regex: every directory the regex named is now a junk
|
||||
// class — including the extras that used to map to 'other' and are folded in as
|
||||
// of this phase: '__pycache__', 'coverage', '.cache', '.nuxt', '.output' (build),
|
||||
// bare 'venv' (dependency), '.svn' and '.hg' (vcs). The class set ALSO catches
|
||||
// vendor / site-packages / out / target, which the old regex missed — the
|
||||
// intended superset improvement, so the redesign never flags FEWER reads than
|
||||
// the legacy detector, only the same or (deliberately) more. The one legacy
|
||||
// token not added is '.tsbuildinfo', which names a file (tsconfig.tsbuildinfo),
|
||||
// never a directory segment, so `/.tsbuildinfo/` can match no real read path.
|
||||
// Grouping is by resourceId, so two raw paths that normalise to one fingerprint
|
||||
// count once.
|
||||
|
||||
import type { Detector, Finding } from '../contracts.js'
|
||||
import { AVG_TOKENS_PER_READ, JUNK_RESOURCE_CLASSES, clamp01, forEachCall } from './shared.js'
|
||||
|
||||
export const JUNK_READS_DETECTOR_ID = 'junk-reads'
|
||||
export const JUNK_READS_ALGORITHM_VERSION = '1.0.0'
|
||||
|
||||
const MIN_JUNK_READS_TO_FLAG = 3
|
||||
const JUNK_READS_HIGH_THRESHOLD = 20
|
||||
|
||||
export const junkReadsDetector: Detector = (envelope): Finding[] => {
|
||||
let total = 0
|
||||
const junkRefs = new Set<string>()
|
||||
const sessionRefs = new Set<string>()
|
||||
|
||||
forEachCall(envelope, (call, session) => {
|
||||
for (const ref of call.resourceReads ?? []) {
|
||||
if (!JUNK_RESOURCE_CLASSES.has(ref.resourceClass)) continue
|
||||
total++
|
||||
junkRefs.add(ref.resourceId)
|
||||
sessionRefs.add(session.sessionRef)
|
||||
}
|
||||
})
|
||||
|
||||
if (total < MIN_JUNK_READS_TO_FLAG) return []
|
||||
|
||||
const tokensSaved = total * AVG_TOKENS_PER_READ
|
||||
const score = clamp01(total / JUNK_READS_HIGH_THRESHOLD)
|
||||
|
||||
const finding: Finding = {
|
||||
detectorId: JUNK_READS_DETECTOR_ID,
|
||||
algorithmVersion: JUNK_READS_ALGORITHM_VERSION,
|
||||
confidence: {
|
||||
score,
|
||||
basis: `${total} reads into dependency/build/vcs resources across ${sessionRefs.size} session(s); flags at >=${MIN_JUNK_READS_TO_FLAG}`,
|
||||
},
|
||||
evidence: [
|
||||
{ kind: 'junk-reads', count: total, refs: [...junkRefs], sessionRefs: [...sessionRefs] },
|
||||
{ kind: 'tokens-saved', count: tokensSaved },
|
||||
],
|
||||
}
|
||||
return [finding]
|
||||
}
|
||||
50
packages/core/src/detectors/shared.ts
Normal file
50
packages/core/src/detectors/shared.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
// Shared, pure helpers for the fingerprint-based detectors. A detector sees ONLY
|
||||
// an ObservationEnvelope — no fs, no env, no clock — and returns Finding[]. Any
|
||||
// value it emits is a number, an enum, or a 16-hex fingerprint (never a path).
|
||||
|
||||
import type { ResourceClassName } from '../schema.js'
|
||||
import type { CallObservation, ObservationEnvelope, SessionObservation } from '../observations.js'
|
||||
|
||||
/** One read/edit charged at this many tokens — mirrors the host's AVG_TOKENS_PER_READ. */
|
||||
export const AVG_TOKENS_PER_READ = 600
|
||||
|
||||
/**
|
||||
* Resource classes treated as "junk" (generated or third-party): a read into one
|
||||
* is not a read of the user's own code. This is the redesigned, path-free basis
|
||||
* for what the host CLI historically matched with a JUNK_DIRS regex. See the
|
||||
* junk-reads detector for the identity-semantics note on where the two differ.
|
||||
*/
|
||||
export const JUNK_RESOURCE_CLASSES: ReadonlySet<ResourceClassName> = new Set<ResourceClassName>([
|
||||
'dependency',
|
||||
'build',
|
||||
'vcs',
|
||||
])
|
||||
|
||||
/**
|
||||
* Tool names counted as reads / edits for the read-to-edit ratio. Kept in
|
||||
* lockstep with the host CLI's READ_TOOL_NAMES / EDIT_TOOL_NAMES. Pattern search
|
||||
* tools (Grep/Glob) count as reads here even though they target no single file,
|
||||
* so they contribute to the ratio without ever producing a resource ref.
|
||||
*/
|
||||
export const READ_TOOL_NAMES: ReadonlySet<string> = new Set([
|
||||
'Read', 'Grep', 'Glob', 'FileReadTool', 'GrepTool', 'GlobTool',
|
||||
])
|
||||
export const EDIT_TOOL_NAMES: ReadonlySet<string> = new Set([
|
||||
'Edit', 'Write', 'FileEditTool', 'FileWriteTool', 'NotebookEdit',
|
||||
])
|
||||
|
||||
export function clamp01(n: number): number {
|
||||
if (n < 0) return 0
|
||||
if (n > 1) return 1
|
||||
return n
|
||||
}
|
||||
|
||||
/** Every (session, call) pair in the envelope, in order. */
|
||||
export function forEachCall(
|
||||
envelope: ObservationEnvelope,
|
||||
fn: (call: CallObservation, session: SessionObservation) => void,
|
||||
): void {
|
||||
for (const session of envelope.sessions) {
|
||||
for (const call of session.calls) fn(call, session)
|
||||
}
|
||||
}
|
||||
|
|
@ -40,8 +40,18 @@ export interface ResourceFingerprint {
|
|||
resourceId: string
|
||||
}
|
||||
|
||||
const DEPENDENCY_SEGMENTS = new Set(['node_modules', 'vendor', '.venv', 'site-packages'])
|
||||
const BUILD_SEGMENTS = new Set(['dist', 'build', 'out', 'target', '.next'])
|
||||
// The dependency / build / vcs segment tables are the source of truth for what
|
||||
// the junk-reads detector treats as junk. They are kept a strict SUPERSET of the
|
||||
// CLI's legacy JUNK_DIRS regex: every directory that regex named classifies here
|
||||
// as junk too (the extras — 'venv', '__pycache__', 'coverage', '.cache',
|
||||
// '.nuxt', '.output', '.svn', '.hg' — are added below), plus vendor /
|
||||
// site-packages / out / target, which the old regex missed.
|
||||
const DEPENDENCY_SEGMENTS = new Set(['node_modules', 'vendor', '.venv', 'venv', 'site-packages'])
|
||||
const BUILD_SEGMENTS = new Set([
|
||||
'dist', 'build', 'out', 'target', '.next', '.nuxt', '.output',
|
||||
'__pycache__', 'coverage', '.cache',
|
||||
])
|
||||
const VCS_SEGMENTS = new Set(['.git', '.svn', '.hg'])
|
||||
const CONFIG_EXTENSIONS = new Set(['json', 'yaml', 'yml', 'toml'])
|
||||
const DOC_EXTENSIONS = new Set(['md', 'txt', 'rst'])
|
||||
const SOURCE_EXTENSIONS = new Set([
|
||||
|
|
@ -92,7 +102,7 @@ export function classifyResource(absolutePath: string): ResourceClass {
|
|||
if (BUILD_SEGMENTS.has(seg)) return 'build'
|
||||
}
|
||||
for (const seg of segments) {
|
||||
if (seg === '.git') return 'vcs'
|
||||
if (VCS_SEGMENTS.has(seg)) return 'vcs'
|
||||
}
|
||||
|
||||
const basename = segments[segments.length - 1] ?? ''
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
NonNegInt,
|
||||
NonNegUSD,
|
||||
OBSERVATION_SCHEMA_VERSION,
|
||||
ResourceRef,
|
||||
Speed,
|
||||
TokenBuckets,
|
||||
} from './schema.js'
|
||||
|
|
@ -44,6 +45,14 @@ export const CallObservation = z
|
|||
toolNames: z.array(CanonicalToolName),
|
||||
turnIndex: NonNegInt,
|
||||
|
||||
// Resource refs (schema 0.2.0): the fingerprinted files this call read /
|
||||
// edited. Populated from the host's rich decode; each entry is an opaque
|
||||
// fingerprint + coarse class, never a raw path. `resourceReads` carries only
|
||||
// whole-file reads (Read/FileReadTool); pattern tools (Grep/Glob) target no
|
||||
// single file and contribute nothing here.
|
||||
resourceReads: z.array(ResourceRef).optional(),
|
||||
resourceEdits: z.array(ResourceRef).optional(),
|
||||
|
||||
// Rich-capture numerics: first-class optional fields.
|
||||
locAdded: NonNegInt.optional(),
|
||||
locRemoved: NonNegInt.optional(),
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
import { branchRef, projectRef, sessionRef } from '../../fingerprint.js'
|
||||
import type { RecordDiagnostic } from '../../diagnostics.js'
|
||||
import type { CallObservation, SessionObservation } from '../../observations.js'
|
||||
import { extractResourceRefs } from '../resource-refs.js'
|
||||
import type { DecodedCall, DecodedTurn } from './types.js'
|
||||
|
||||
/** One session's rich decode, as the host holds it before minimization. */
|
||||
|
|
@ -34,7 +35,7 @@ export interface ToObservationsContext {
|
|||
// enforced at the source, so the output also survives strict schema parse.
|
||||
const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/
|
||||
|
||||
function toCallObservation(call: DecodedCall, turnIndex: number): CallObservation {
|
||||
function toCallObservation(call: DecodedCall, turnIndex: number, privacyKey: string): CallObservation {
|
||||
return {
|
||||
provider: call.provider,
|
||||
model: call.model,
|
||||
|
|
@ -59,6 +60,7 @@ function toCallObservation(call: DecodedCall, turnIndex: number): CallObservatio
|
|||
...(call.interrupted !== undefined ? { interrupted: call.interrupted } : {}),
|
||||
...(call.userModified !== undefined ? { userModified: call.userModified } : {}),
|
||||
...(call.toolErrors !== undefined ? { toolErrors: call.toolErrors } : {}),
|
||||
...extractResourceRefs(privacyKey, call.toolSequence),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -66,7 +68,7 @@ function toSessionObservation(decode: RichSessionDecode, ctx: ToObservationsCont
|
|||
const provider = ctx.provider ?? 'claude'
|
||||
const calls: CallObservation[] = []
|
||||
decode.turns.forEach((turn, turnIndex) => {
|
||||
for (const call of turn.assistantCalls) calls.push(toCallObservation(call, turnIndex))
|
||||
for (const call of turn.assistantCalls) calls.push(toCallObservation(call, turnIndex, ctx.privacyKey))
|
||||
})
|
||||
|
||||
const timestamps = calls.map(c => c.timestamp).filter(t => t.length > 0).sort()
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
import { projectRef, sessionRef } from '../../fingerprint.js'
|
||||
import type { RecordDiagnostic } from '../../diagnostics.js'
|
||||
import type { CallObservation, SessionObservation } from '../../observations.js'
|
||||
import { extractResourceRefs } from '../resource-refs.js'
|
||||
import type { CodexDecodedCall } from './types.js'
|
||||
|
||||
/** One Codex session's rich decode, as the host holds it before minimization. */
|
||||
|
|
@ -31,7 +32,7 @@ export interface CodexToObservationsContext {
|
|||
// is dropped rather than emitted.
|
||||
const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/
|
||||
|
||||
function toCallObservation(call: CodexDecodedCall, turnIndex: number): CallObservation {
|
||||
function toCallObservation(call: CodexDecodedCall, turnIndex: number, privacyKey: string): CallObservation {
|
||||
return {
|
||||
provider: call.provider,
|
||||
model: call.model,
|
||||
|
|
@ -54,6 +55,7 @@ function toCallObservation(call: CodexDecodedCall, turnIndex: number): CallObser
|
|||
...(call.locAdded !== undefined ? { locAdded: call.locAdded } : {}),
|
||||
...(call.locRemoved !== undefined ? { locRemoved: call.locRemoved } : {}),
|
||||
...(call.editFailed !== undefined ? { editFailed: call.editFailed } : {}),
|
||||
...extractResourceRefs(privacyKey, call.toolSequence),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -71,7 +73,7 @@ function toSessionObservation(decode: RichCodexSessionDecode, ctx: CodexToObserv
|
|||
turnCount++
|
||||
lastTurnId = call.turnId
|
||||
}
|
||||
calls.push(toCallObservation(call, turnIndex))
|
||||
calls.push(toCallObservation(call, turnIndex, ctx.privacyKey))
|
||||
}
|
||||
|
||||
const timestamps = calls.map(c => c.timestamp).filter(t => t.length > 0).sort()
|
||||
|
|
|
|||
46
packages/core/src/providers/resource-refs.ts
Normal file
46
packages/core/src/providers/resource-refs.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// Shared minimizing step: turn a rich decode's toolSequence (which carries raw
|
||||
// file paths host-side) into fingerprinted ResourceRefs for the observation
|
||||
// envelope. The RAW path is HMAC'd via resourceFingerprint and never emitted —
|
||||
// only its 16-hex id and coarse class cross the boundary.
|
||||
|
||||
import { resourceFingerprint } from '../fingerprint.js'
|
||||
import type { ResourceRef } from '../schema.js'
|
||||
import { EDIT_TOOLS } from './claude/tool-vocab.js'
|
||||
|
||||
/** Whole-file read tools. Pattern tools (Grep/Glob) target no single file. */
|
||||
const READ_RESOURCE_TOOLS = new Set(['Read', 'FileReadTool'])
|
||||
|
||||
/** One tool invocation as the rich decoders record it (tool name + optional file). */
|
||||
export interface ToolSequenceEntry {
|
||||
tool: string
|
||||
file?: string
|
||||
command?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Fingerprint the file paths in a call's toolSequence into resourceReads /
|
||||
* resourceEdits. Read-family reads become `resourceReads`; edit-family writes
|
||||
* become `resourceEdits`. Absent arrays are omitted so a call with no file
|
||||
* touches stays byte-identical to schema 0.1.0.
|
||||
*/
|
||||
export function extractResourceRefs(
|
||||
privacyKey: string,
|
||||
toolSequence: ToolSequenceEntry[][] | undefined,
|
||||
): { resourceReads?: ResourceRef[]; resourceEdits?: ResourceRef[] } {
|
||||
if (!toolSequence) return {}
|
||||
const reads: ResourceRef[] = []
|
||||
const edits: ResourceRef[] = []
|
||||
for (const group of toolSequence) {
|
||||
for (const tc of group) {
|
||||
if (!tc.file) continue
|
||||
const fp = resourceFingerprint(privacyKey, tc.file)
|
||||
const ref: ResourceRef = { resourceId: fp.resourceId, resourceClass: fp.resourceClass }
|
||||
if (READ_RESOURCE_TOOLS.has(tc.tool)) reads.push(ref)
|
||||
else if (EDIT_TOOLS.has(tc.tool)) edits.push(ref)
|
||||
}
|
||||
}
|
||||
const out: { resourceReads?: ResourceRef[]; resourceEdits?: ResourceRef[] } = {}
|
||||
if (reads.length > 0) out.resourceReads = reads
|
||||
if (edits.length > 0) out.resourceEdits = edits
|
||||
return out
|
||||
}
|
||||
|
|
@ -3,8 +3,12 @@ import { z } from 'zod'
|
|||
/**
|
||||
* ObservationEnvelope schema version. 0.x per decision D8: the observation
|
||||
* contract is pre-stability, so consumers must treat minor bumps as breaking.
|
||||
*
|
||||
* 0.2.0 adds the optional per-call `resourceReads` / `resourceEdits` arrays
|
||||
* (ResourceRef). Strictness rules are unchanged: every added field is either a
|
||||
* fingerprint or a coarse enum, so the anti-smuggling property still holds.
|
||||
*/
|
||||
export const OBSERVATION_SCHEMA_VERSION = '0.1.0'
|
||||
export const OBSERVATION_SCHEMA_VERSION = '0.2.0'
|
||||
|
||||
/**
|
||||
* A privacy-preserving fingerprint: the first 16 hex chars of an HMAC-SHA256.
|
||||
|
|
@ -54,6 +58,36 @@ export type TokenBuckets = z.infer<typeof TokenBuckets>
|
|||
export const Speed = z.enum(['standard', 'fast'])
|
||||
export type Speed = z.infer<typeof Speed>
|
||||
|
||||
/**
|
||||
* Coarse, non-identifying bucket for a filesystem resource. Mirrors the
|
||||
* `ResourceClass` union produced by `classifyResource` in fingerprint.ts. It is
|
||||
* a small closed enum so it can never carry a raw path or free text.
|
||||
*/
|
||||
export const ResourceClassName = z.enum([
|
||||
'dependency',
|
||||
'build',
|
||||
'vcs',
|
||||
'config',
|
||||
'source',
|
||||
'doc',
|
||||
'other',
|
||||
])
|
||||
export type ResourceClassName = z.infer<typeof ResourceClassName>
|
||||
|
||||
/**
|
||||
* A reference to a filesystem resource a call touched: the opaque 16-hex
|
||||
* fingerprint of its normalised path plus its coarse class. `.strict()` blocks
|
||||
* any extra field, so the RAW path can never ride along — the structural
|
||||
* anti-smuggling property extended to resource refs.
|
||||
*/
|
||||
export const ResourceRef = z
|
||||
.object({
|
||||
resourceId: FingerprintHex,
|
||||
resourceClass: ResourceClassName,
|
||||
})
|
||||
.strict()
|
||||
export type ResourceRef = z.infer<typeof ResourceRef>
|
||||
|
||||
/**
|
||||
* How a call's cost was determined.
|
||||
* - 'measured' : a provider-reported dollar figure is authoritative.
|
||||
|
|
|
|||
|
|
@ -200,6 +200,18 @@ describe('content-smuggling guardrail: real claude decode -> toObservations is s
|
|||
expect(allToolNames).toContain('Read')
|
||||
expect(allToolNames).not.toContain(SECRETS.commandLine)
|
||||
})
|
||||
|
||||
it('fingerprints the tool-sequence Read path into a 16-hex resourceRead, never the raw path', () => {
|
||||
const env = buildEnvelope()
|
||||
const reads = env.sessions.flatMap(s => s.calls.flatMap(c => c.resourceReads ?? []))
|
||||
expect(reads.length).toBeGreaterThan(0)
|
||||
for (const ref of reads) {
|
||||
expect(ref.resourceId).toMatch(/^[0-9a-f]{16}$/)
|
||||
expect(typeof ref.resourceClass).toBe('string')
|
||||
}
|
||||
// The planted absolute path must appear nowhere inside the refs.
|
||||
expect(allStrings(reads)).not.toContain(SECRETS.absPath)
|
||||
})
|
||||
})
|
||||
|
||||
describe('content-smuggling guardrail: real codex decode -> toObservations is secret-free', () => {
|
||||
|
|
@ -260,6 +272,17 @@ describe('content-smuggling guardrail: real codex decode -> toObservations is se
|
|||
expect(allToolNames).toContain('Read')
|
||||
expect(allToolNames).not.toContain(SECRETS.commandLine)
|
||||
})
|
||||
|
||||
it('fingerprints the read_file path into a 16-hex resourceRead, never the raw path', () => {
|
||||
const env = decodeAndMinimize()
|
||||
const reads = env.sessions.flatMap(s => s.calls.flatMap(c => c.resourceReads ?? []))
|
||||
expect(reads.length).toBeGreaterThan(0)
|
||||
for (const ref of reads) {
|
||||
expect(ref.resourceId).toMatch(/^[0-9a-f]{16}$/)
|
||||
expect(typeof ref.resourceClass).toBe('string')
|
||||
}
|
||||
expect(allStrings(reads)).not.toContain(SECRETS.absPath)
|
||||
})
|
||||
})
|
||||
|
||||
describe('content-smuggling guardrail: diagnostic detail rejects paths', () => {
|
||||
|
|
|
|||
209
packages/core/tests/detectors.test.ts
Normal file
209
packages/core/tests/detectors.test.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { Finding } from '../src/contracts.js'
|
||||
import type { CallObservation, ObservationEnvelope, SessionObservation } from '../src/observations.js'
|
||||
import type { ResourceClassName, ResourceRef } from '../src/schema.js'
|
||||
import {
|
||||
contextBloatDetector,
|
||||
detectors,
|
||||
duplicateReadsDetector,
|
||||
junkReadsDetector,
|
||||
} from '../src/detectors/index.js'
|
||||
|
||||
// ── Envelope builders ──────────────────────────────────────────────────────
|
||||
|
||||
let idSeq = 0
|
||||
function hex16(): string {
|
||||
idSeq++
|
||||
return idSeq.toString(16).padStart(16, '0')
|
||||
}
|
||||
|
||||
function ref(resourceClass: ResourceClassName, resourceId = hex16()): ResourceRef {
|
||||
return { resourceId, resourceClass }
|
||||
}
|
||||
|
||||
function callWith(fields: Partial<CallObservation>): CallObservation {
|
||||
return {
|
||||
provider: 'claude',
|
||||
model: 'claude-opus-4-8',
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheCreate: 0 },
|
||||
webSearchRequests: 0,
|
||||
speed: 'standard',
|
||||
costBasis: 'estimated',
|
||||
timestamp: '2026-07-17T10:00:00.000Z',
|
||||
dedupKey: `d${idSeq++}`,
|
||||
toolNames: [],
|
||||
turnIndex: 0,
|
||||
...fields,
|
||||
}
|
||||
}
|
||||
|
||||
// Map a short test label to a stable 16-hex sessionRef (evidence.sessionRefs
|
||||
// must be fingerprints, so a bare 's1' would fail Finding validation).
|
||||
const srefs = new Map<string, string>()
|
||||
function srefFor(label: string): string {
|
||||
let v = srefs.get(label)
|
||||
if (!v) { v = hex16(); srefs.set(label, v) }
|
||||
return v
|
||||
}
|
||||
|
||||
function session(label: string, calls: CallObservation[]): SessionObservation {
|
||||
return {
|
||||
sessionRef: srefFor(label),
|
||||
projectRef: 'aaaaaaaaaaaaaaaa',
|
||||
providerId: 'claude',
|
||||
startedAt: '2026-07-17T10:00:00.000Z',
|
||||
calls,
|
||||
turnCount: 1,
|
||||
}
|
||||
}
|
||||
|
||||
function envelope(sessions: SessionObservation[]): ObservationEnvelope {
|
||||
return {
|
||||
schemaVersion: '0.2.0',
|
||||
generator: { name: '@codeburn/core', version: '0.0.0-test' },
|
||||
sessions,
|
||||
}
|
||||
}
|
||||
|
||||
/** Every finding must satisfy the wire contract and the gate-4 invariants. */
|
||||
function assertWellFormed(f: unknown) {
|
||||
expect(Finding.safeParse(f).success).toBe(true)
|
||||
const finding = f as import('../src/contracts.js').Finding
|
||||
expect(finding.evidence.length).toBeGreaterThan(0)
|
||||
expect(finding.confidence.basis.length).toBeGreaterThan(0)
|
||||
expect(finding.confidence.score).toBeGreaterThanOrEqual(0)
|
||||
expect(finding.confidence.score).toBeLessThanOrEqual(1)
|
||||
expect(finding.algorithmVersion).toBe('1.0.0')
|
||||
}
|
||||
|
||||
// ── junk-reads ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('junkReadsDetector', () => {
|
||||
const junkCall = (n: number, cls: ResourceClassName = 'dependency') =>
|
||||
callWith({ toolNames: ['Read'], resourceReads: Array.from({ length: n }, () => ref(cls)) })
|
||||
|
||||
it('returns nothing below the 3-read threshold', () => {
|
||||
expect(junkReadsDetector(envelope([session('s1', [junkCall(2)])]))).toEqual([])
|
||||
})
|
||||
|
||||
it('flags at exactly the threshold (boundary)', () => {
|
||||
const findings = junkReadsDetector(envelope([session('s1', [junkCall(3)])]))
|
||||
expect(findings).toHaveLength(1)
|
||||
assertWellFormed(findings[0])
|
||||
const ev = findings[0].evidence.find(e => e.kind === 'junk-reads')!
|
||||
expect(ev.count).toBe(3)
|
||||
expect(findings[0].evidence.find(e => e.kind === 'tokens-saved')!.count).toBe(1800)
|
||||
})
|
||||
|
||||
it('counts dependency, build and vcs classes as junk', () => {
|
||||
const call = callWith({
|
||||
toolNames: ['Read'],
|
||||
resourceReads: [ref('dependency'), ref('build'), ref('vcs')],
|
||||
})
|
||||
expect(junkReadsDetector(envelope([session('s1', [call])]))).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('ignores non-junk resource classes (source/config/doc/other)', () => {
|
||||
const call = callWith({
|
||||
toolNames: ['Read'],
|
||||
resourceReads: [ref('source'), ref('config'), ref('doc'), ref('other')],
|
||||
})
|
||||
expect(junkReadsDetector(envelope([session('s1', [call])]))).toEqual([])
|
||||
})
|
||||
|
||||
it('scales confidence with read count', () => {
|
||||
const low = junkReadsDetector(envelope([session('s1', [junkCall(3)])]))[0]
|
||||
const high = junkReadsDetector(envelope([session('s1', [junkCall(20)])]))[0]
|
||||
expect(high.confidence.score).toBeGreaterThan(low.confidence.score)
|
||||
})
|
||||
})
|
||||
|
||||
// ── duplicate-reads ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('duplicateReadsDetector', () => {
|
||||
it('sums extra reads of the same file within a session', () => {
|
||||
const id = hex16()
|
||||
const call = callWith({ toolNames: ['Read'], resourceReads: Array.from({ length: 6 }, () => ref('source', id)) })
|
||||
const findings = duplicateReadsDetector(envelope([session('s1', [call])]))
|
||||
expect(findings).toHaveLength(1)
|
||||
assertWellFormed(findings[0])
|
||||
expect(findings[0].evidence.find(e => e.kind === 'duplicate-reads')!.count).toBe(5)
|
||||
})
|
||||
|
||||
it('does not count the same file across different sessions', () => {
|
||||
const id = hex16()
|
||||
const mk = (s: string) => session(s, [callWith({ toolNames: ['Read'], resourceReads: [ref('source', id)] })])
|
||||
expect(duplicateReadsDetector(envelope([mk('s1'), mk('s2'), mk('s3')]))).toEqual([])
|
||||
})
|
||||
|
||||
it('excludes junk-class re-reads', () => {
|
||||
const id = hex16()
|
||||
const call = callWith({ toolNames: ['Read'], resourceReads: Array.from({ length: 10 }, () => ref('dependency', id)) })
|
||||
expect(duplicateReadsDetector(envelope([session('s1', [call])]))).toEqual([])
|
||||
})
|
||||
|
||||
it('is null just below the 5-extra threshold and flags at it (boundary)', () => {
|
||||
const idA = hex16()
|
||||
const four = callWith({ toolNames: ['Read'], resourceReads: Array.from({ length: 5 }, () => ref('source', idA)) })
|
||||
expect(duplicateReadsDetector(envelope([session('s1', [four])]))).toEqual([]) // 4 extras
|
||||
const idB = hex16()
|
||||
const five = callWith({ toolNames: ['Read'], resourceReads: Array.from({ length: 6 }, () => ref('source', idB)) })
|
||||
expect(duplicateReadsDetector(envelope([session('s1', [five])]))).toHaveLength(1) // 5 extras
|
||||
})
|
||||
})
|
||||
|
||||
// ── context-bloat (read-to-edit ratio) ────────────────────────────────────────
|
||||
|
||||
describe('contextBloatDetector', () => {
|
||||
const reads = (n: number, name = 'Read') => Array.from({ length: n }, () => callWith({ toolNames: [name] }))
|
||||
const edits = (n: number, name = 'Edit') => Array.from({ length: n }, () => callWith({ toolNames: [name] }))
|
||||
|
||||
it('returns nothing below the minimum edit count', () => {
|
||||
expect(contextBloatDetector(envelope([session('s1', [...reads(1), ...edits(2)])]))).toEqual([])
|
||||
})
|
||||
|
||||
it('returns nothing when the ratio is healthy (boundary at 4:1)', () => {
|
||||
expect(contextBloatDetector(envelope([session('s1', [...reads(40), ...edits(10)])]))).toEqual([])
|
||||
})
|
||||
|
||||
it('flags when edits outpace reads', () => {
|
||||
const findings = contextBloatDetector(envelope([session('s1', [...reads(5), ...edits(10)])]))
|
||||
expect(findings).toHaveLength(1)
|
||||
assertWellFormed(findings[0])
|
||||
expect(findings[0].evidence.find(e => e.kind === 'reads')!.count).toBe(5)
|
||||
expect(findings[0].evidence.find(e => e.kind === 'edits')!.count).toBe(10)
|
||||
// extraReadsNeeded = round(10*4) - 5 = 35 -> 35 * 600
|
||||
expect(findings[0].evidence.find(e => e.kind === 'tokens-saved')!.count).toBe(21000)
|
||||
})
|
||||
|
||||
it('counts Grep and Glob as reads', () => {
|
||||
// 40 Grep reads / 10 edits = healthy -> no finding
|
||||
expect(contextBloatDetector(envelope([session('s1', [...reads(40, 'Grep'), ...edits(10)])]))).toEqual([])
|
||||
})
|
||||
|
||||
it('counts Write and NotebookEdit as edits', () => {
|
||||
const findings = contextBloatDetector(envelope([session('s1', [...reads(15), ...edits(6, 'Write'), ...edits(4, 'NotebookEdit')])]))
|
||||
expect(findings).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectors registry', () => {
|
||||
it('exports all three detectors', () => {
|
||||
expect(detectors).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('every emitted finding across detectors is well-formed', () => {
|
||||
const env = envelope([
|
||||
session('s1', [
|
||||
callWith({ toolNames: ['Read'], resourceReads: [ref('dependency'), ref('build'), ref('vcs')] }),
|
||||
callWith({ toolNames: ['Read'], resourceReads: Array.from({ length: 6 }, () => ref('source', 'cccccccccccccccc')) }),
|
||||
...Array.from({ length: 5 }, () => callWith({ toolNames: ['Read'] })),
|
||||
...Array.from({ length: 10 }, () => callWith({ toolNames: ['Edit'] })),
|
||||
]),
|
||||
])
|
||||
const all = detectors.flatMap(d => d(env))
|
||||
expect(all.length).toBe(3)
|
||||
for (const f of all) assertWellFormed(f)
|
||||
})
|
||||
})
|
||||
|
|
@ -96,11 +96,19 @@ describe('resource classification', () => {
|
|||
const cases: Array<[string, string]> = [
|
||||
['/repo/node_modules/lodash/index.js', 'dependency'],
|
||||
['/repo/.venv/lib/site.py', 'dependency'],
|
||||
['/repo/venv/lib/site.py', 'dependency'],
|
||||
['/repo/backend/site-packages/x.py', 'dependency'],
|
||||
['/repo/dist/index.js', 'build'],
|
||||
['/repo/.next/server/page.js', 'build'],
|
||||
['/repo/.nuxt/dist/app.js', 'build'],
|
||||
['/repo/.output/server/index.mjs', 'build'],
|
||||
['/repo/target/debug/app', 'build'],
|
||||
['/repo/pkg/__pycache__/mod.cpython-312.pyc', 'build'],
|
||||
['/repo/coverage/lcov.info', 'build'],
|
||||
['/repo/.cache/webpack/index.pack', 'build'],
|
||||
['/repo/.git/HEAD', 'vcs'],
|
||||
['/repo/.svn/entries', 'vcs'],
|
||||
['/repo/.hg/store/data.i', 'vcs'],
|
||||
['/repo/.eslintrc', 'config'],
|
||||
['/repo/tsconfig.json', 'config'],
|
||||
['/repo/config/app.yaml', 'config'],
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"schemaVersion": "0.1.0",
|
||||
"schemaVersion": "0.2.0",
|
||||
"generator": {
|
||||
"name": "@codeburn/core",
|
||||
"version": "0.9.19"
|
||||
|
|
@ -37,7 +37,13 @@
|
|||
"locAdded": 12,
|
||||
"locRemoved": 3,
|
||||
"interrupted": false,
|
||||
"toolErrors": 0
|
||||
"toolErrors": 0,
|
||||
"resourceReads": [
|
||||
{ "resourceId": "1122334455667788", "resourceClass": "source" }
|
||||
],
|
||||
"resourceEdits": [
|
||||
{ "resourceId": "8877665544332211", "resourceClass": "source" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"provider": "claude",
|
||||
|
|
|
|||
|
|
@ -36,11 +36,11 @@ if (!/^[0-9a-f]{16}$/.test(ref)) {
|
|||
|
||||
// Trivial schema parse.
|
||||
const env = barrel.parseObservationEnvelope({
|
||||
schemaVersion: '0.1.0',
|
||||
schemaVersion: '0.2.0',
|
||||
generator: { name: '@codeburn/core', version: '0.0.0-smoke' },
|
||||
sessions: [],
|
||||
})
|
||||
if (env.schemaVersion !== '0.1.0') {
|
||||
if (env.schemaVersion !== '0.2.0') {
|
||||
console.error('import-smoke: parse returned unexpected envelope')
|
||||
process.exit(5)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,4 +23,15 @@ describe('JSON Schema drift', () => {
|
|||
expect(onDisk).toEqual(fresh[name])
|
||||
})
|
||||
}
|
||||
|
||||
// The superseded 0.1.0 observation schema is kept as a FROZEN historical
|
||||
// artifact: it is no longer emitted from the current zod (which is 0.2.0), so
|
||||
// it has no fresh counterpart. Assert it stays pinned at its own version so a
|
||||
// careless re-emit can never overwrite it with 0.2.0 content.
|
||||
it('observation-0.1.0.json remains frozen at schemaVersion 0.1.0', () => {
|
||||
const onDisk = JSON.parse(readFileSync(resolve(schemasDir, 'observation-0.1.0.json'), 'utf8'))
|
||||
const root = onDisk?.definitions?.ObservationEnvelope ?? onDisk
|
||||
expect(root?.properties?.schemaVersion?.const).toBe('0.1.0')
|
||||
expect(Object.keys(fresh)).not.toContain('observation-0.1.0')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ function readJson(rel: string): unknown {
|
|||
|
||||
const goldenEnvelope = readJson('tests/fixtures/golden-envelope.json')
|
||||
const goldenFinding = readJson('tests/fixtures/golden-finding.json')
|
||||
const observationSchema = readJson('schemas/observation-0.1.0.json') as object
|
||||
const observationSchema = readJson('schemas/observation-0.2.0.json') as object
|
||||
const findingSchema = readJson('schemas/finding-0.1.0.json') as object
|
||||
|
||||
// strict:false so unknown string formats (date-time) are ignored rather than
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export default defineConfig({
|
|||
'src/diagnostics.ts',
|
||||
'src/fingerprint.ts',
|
||||
'src/contracts.ts',
|
||||
'src/detectors/index.ts',
|
||||
'src/providers/claude/index.ts',
|
||||
'src/providers/codex/index.ts',
|
||||
],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue