From 1b17f61f9bdafe2b5726814717a5f9a74b5c9e20 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Sun, 26 Jul 2026 13:26:36 -0700 Subject: [PATCH] feat(core): duplicate-reads, junk-reads, context-bloat detectors over fingerprints (phase 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redesign the three token-waste detectors as pure @codeburn/core detectors that consume an ObservationEnvelope of fingerprinted resource refs (never raw paths): - Envelope schema 0.2.0: CallObservation gains optional resourceReads / resourceEdits (ResourceRef = {resourceId 16-hex, resourceClass}). claude/codex toObservations fingerprint toolSequence file paths into these; raw paths never cross the boundary. Keep observation-0.1.0.json frozen; emit 0.2.0. - Host privacy key (D1): sync keystore under the codeburn config dir, random 32-byte key generated on first use, stable across runs, never emitted. - core/detectors: junk-reads, duplicate-reads, context-bloat — pure, zero fs/env, each emitting Finding[] with confidence(basis), machine-readable evidence, algorithmVersion. - optimize.ts delegates the three to core, mapping Finding -> WasteFinding; display strings, fix payloads and trend stay host-derived. Numbers parity: all optimize tests pass unchanged; frozen-corpus optimize JSON (3552 sessions) is byte-identical PRE vs POST. --- packages/cli/src/config.ts | 2 +- packages/cli/src/optimize.ts | 141 +++++++-- packages/cli/src/privacy-key.ts | 58 ++++ packages/core/package.json | 4 + packages/core/schemas/observation-0.2.0.json | 280 ++++++++++++++++++ packages/core/src/detectors/context-bloat.ts | 61 ++++ .../core/src/detectors/duplicate-reads.ts | 72 +++++ packages/core/src/detectors/index.ts | 21 ++ packages/core/src/detectors/junk-reads.ts | 60 ++++ packages/core/src/detectors/shared.ts | 50 ++++ packages/core/src/fingerprint.ts | 16 +- packages/core/src/observations.ts | 9 + .../core/src/providers/claude/observations.ts | 6 +- .../core/src/providers/codex/observations.ts | 6 +- packages/core/src/providers/resource-refs.ts | 46 +++ packages/core/src/schema.ts | 36 ++- packages/core/tests/content-smuggling.test.ts | 23 ++ packages/core/tests/detectors.test.ts | 209 +++++++++++++ packages/core/tests/fingerprint.test.ts | 8 + .../core/tests/fixtures/golden-envelope.json | 10 +- .../core/tests/harness/import-smoke-child.mjs | 4 +- packages/core/tests/schema-drift.test.ts | 11 + packages/core/tests/schema.test.ts | 2 +- packages/core/tsup.config.ts | 1 + 24 files changed, 1095 insertions(+), 41 deletions(-) create mode 100644 packages/cli/src/privacy-key.ts create mode 100644 packages/core/schemas/observation-0.2.0.json create mode 100644 packages/core/src/detectors/context-bloat.ts create mode 100644 packages/core/src/detectors/duplicate-reads.ts create mode 100644 packages/core/src/detectors/index.ts create mode 100644 packages/core/src/detectors/junk-reads.ts create mode 100644 packages/core/src/detectors/shared.ts create mode 100644 packages/core/src/providers/resource-refs.ts create mode 100644 packages/core/tests/detectors.test.ts diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts index b042749e..84060d70 100644 --- a/packages/cli/src/config.ts +++ b/packages/cli/src/config.ts @@ -63,7 +63,7 @@ export type CodeburnConfig = { proxyPaths?: string[] } -function getConfigDir(): string { +export function getConfigDir(): string { return join(homedir(), '.config', 'codeburn') } diff --git a/packages/cli/src/optimize.ts b/packages/cli/src/optimize.ts index 79009984..4d9bfef2 100644 --- a/packages/cli/src/optimize.ts +++ b/packages/cli/src/optimize.ts @@ -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, 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() + 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() - 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() + 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>() + 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>() 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() - 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) { diff --git a/packages/cli/src/privacy-key.ts b/packages/cli/src/privacy-key.ts new file mode 100644 index 00000000..44580ffa --- /dev/null +++ b/packages/cli/src/privacy-key.ts @@ -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 +} diff --git a/packages/core/package.json b/packages/core/package.json index 9b98d833..5168feaa 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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" diff --git a/packages/core/schemas/observation-0.2.0.json b/packages/core/schemas/observation-0.2.0.json new file mode 100644 index 00000000..e8d32875 --- /dev/null +++ b/packages/core/schemas/observation-0.2.0.json @@ -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#" +} diff --git a/packages/core/src/detectors/context-bloat.ts b/packages/core/src/detectors/context-bloat.ts new file mode 100644 index 00000000..7c5a8f94 --- /dev/null +++ b/packages/core/src/detectors/context-bloat.ts @@ -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() + + 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] +} diff --git a/packages/core/src/detectors/duplicate-reads.ts b/packages/core/src/detectors/duplicate-reads.ts new file mode 100644 index 00000000..34902bf0 --- /dev/null +++ b/packages/core/src/detectors/duplicate-reads.ts @@ -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>() + + 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() + const dupSessions = new Set() + + 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] +} diff --git a/packages/core/src/detectors/index.ts b/packages/core/src/detectors/index.ts new file mode 100644 index 00000000..b495aec1 --- /dev/null +++ b/packages/core/src/detectors/index.ts @@ -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, +] diff --git a/packages/core/src/detectors/junk-reads.ts b/packages/core/src/detectors/junk-reads.ts new file mode 100644 index 00000000..ee200e1f --- /dev/null +++ b/packages/core/src/detectors/junk-reads.ts @@ -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() + const sessionRefs = new Set() + + 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] +} diff --git a/packages/core/src/detectors/shared.ts b/packages/core/src/detectors/shared.ts new file mode 100644 index 00000000..5738150f --- /dev/null +++ b/packages/core/src/detectors/shared.ts @@ -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 = new Set([ + '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 = new Set([ + 'Read', 'Grep', 'Glob', 'FileReadTool', 'GrepTool', 'GlobTool', +]) +export const EDIT_TOOL_NAMES: ReadonlySet = 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) + } +} diff --git a/packages/core/src/fingerprint.ts b/packages/core/src/fingerprint.ts index d1655f66..b0343fa5 100644 --- a/packages/core/src/fingerprint.ts +++ b/packages/core/src/fingerprint.ts @@ -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] ?? '' diff --git a/packages/core/src/observations.ts b/packages/core/src/observations.ts index a27c7a9a..7124c044 100644 --- a/packages/core/src/observations.ts +++ b/packages/core/src/observations.ts @@ -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(), diff --git a/packages/core/src/providers/claude/observations.ts b/packages/core/src/providers/claude/observations.ts index f76d15f5..582af21d 100644 --- a/packages/core/src/providers/claude/observations.ts +++ b/packages/core/src/providers/claude/observations.ts @@ -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() diff --git a/packages/core/src/providers/codex/observations.ts b/packages/core/src/providers/codex/observations.ts index f9813218..5beacbbb 100644 --- a/packages/core/src/providers/codex/observations.ts +++ b/packages/core/src/providers/codex/observations.ts @@ -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() diff --git a/packages/core/src/providers/resource-refs.ts b/packages/core/src/providers/resource-refs.ts new file mode 100644 index 00000000..d8eeae3f --- /dev/null +++ b/packages/core/src/providers/resource-refs.ts @@ -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 +} diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 34c424a1..41d2a503 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -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 export const Speed = z.enum(['standard', 'fast']) export type Speed = z.infer +/** + * 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 + +/** + * 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 + /** * How a call's cost was determined. * - 'measured' : a provider-reported dollar figure is authoritative. diff --git a/packages/core/tests/content-smuggling.test.ts b/packages/core/tests/content-smuggling.test.ts index f530bc1a..89dbbadd 100644 --- a/packages/core/tests/content-smuggling.test.ts +++ b/packages/core/tests/content-smuggling.test.ts @@ -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', () => { diff --git a/packages/core/tests/detectors.test.ts b/packages/core/tests/detectors.test.ts new file mode 100644 index 00000000..0410b120 --- /dev/null +++ b/packages/core/tests/detectors.test.ts @@ -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 { + 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() +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) + }) +}) diff --git a/packages/core/tests/fingerprint.test.ts b/packages/core/tests/fingerprint.test.ts index 4238facb..2b71a206 100644 --- a/packages/core/tests/fingerprint.test.ts +++ b/packages/core/tests/fingerprint.test.ts @@ -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'], diff --git a/packages/core/tests/fixtures/golden-envelope.json b/packages/core/tests/fixtures/golden-envelope.json index 24f12544..4905541b 100644 --- a/packages/core/tests/fixtures/golden-envelope.json +++ b/packages/core/tests/fixtures/golden-envelope.json @@ -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", diff --git a/packages/core/tests/harness/import-smoke-child.mjs b/packages/core/tests/harness/import-smoke-child.mjs index edf8eadf..5a0fbed1 100644 --- a/packages/core/tests/harness/import-smoke-child.mjs +++ b/packages/core/tests/harness/import-smoke-child.mjs @@ -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) } diff --git a/packages/core/tests/schema-drift.test.ts b/packages/core/tests/schema-drift.test.ts index 8e58a1c3..ea0867c2 100644 --- a/packages/core/tests/schema-drift.test.ts +++ b/packages/core/tests/schema-drift.test.ts @@ -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') + }) }) diff --git a/packages/core/tests/schema.test.ts b/packages/core/tests/schema.test.ts index cdeb4e16..6219a92a 100644 --- a/packages/core/tests/schema.test.ts +++ b/packages/core/tests/schema.test.ts @@ -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 diff --git a/packages/core/tsup.config.ts b/packages/core/tsup.config.ts index 34c6a459..7a6acb5d 100644 --- a/packages/core/tsup.config.ts +++ b/packages/core/tsup.config.ts @@ -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', ],