diff --git a/packages/core/README.md b/packages/core/README.md index 5dcc50d..9b2d9ea 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -1,23 +1,30 @@ # @codeburn/core The pure decode/detect engine behind [CodeBurn](https://github.com/getagentseal/codeburn): -provider session-log decoding for 36 AI coding tools, content-minimized observation -envelopes, and detector contracts. +provider session-log decoding, content-minimized observation envelopes, and detector +contracts. -**Status: 0.x.** The engine is complete and battle-tested (it is the same code the -CodeBurn CLI runs, proven byte-identical to the pre-extraction implementation on a -frozen real-world corpus), but the public API may still change between 0.x minor -versions. Pin accordingly. +It ships 34 provider modules. Rather than trusting that number, ask the package: + +```ts +import { supportedProviders } from '@codeburn/core/providers/registry' + +supportedProviders() // [{ id: 'claude', exportPath: './providers/claude', ... }, ...] +``` + +The registry and `package.json#exports` are checked against each other in CI, so the +list cannot drift from what is actually importable. ## What it does - **Decode**: each provider module (`@codeburn/core/providers/`) turns that tool's raw session records into structured call data — tokens, models, timing, - tool usage — with the provider's exact dedup and skip semantics. -- **Observations**: `toObservations` maps rich decode output into a strict, - content-minimized envelope: only fingerprints, enums, numbers, timestamps, - dedup keys, and canonical tool names cross the boundary. Enforced by an - architecture gate and per-provider content-smuggling tests. + tool usage — with the provider's exact dedup and skip semantics. Output is + rich and host-side: it holds paths, messages, and tool arguments. +- **Observations**: `toObservations` maps that rich decode into a strict, + content-minimized envelope. Only fingerprints, enums, numbers, timestamps, and + capped canonical labels cross the boundary. Enforced by an architecture gate + and per-provider content-smuggling tests. - **Detectors**: contracts for waste/optimization findings over fingerprinted data. ## What it deliberately does NOT do @@ -28,16 +35,70 @@ their own pricing. The only runtime dependency is `zod`. ## Usage -```ts -import { decodeQwen } from '@codeburn/core/providers/qwen' -import { toObservations } from '@codeburn/core/providers/qwen' -import { OBSERVATION_SCHEMA_VERSION } from '@codeburn/core/schema' +Decoding is two stages. Stage 1 is provider-specific and rich; stage 2 minimizes. -const { calls, diagnostics } = decodeQwen({ records, seenKeys }) +```ts +import { decodeQwen, toObservations } from '@codeburn/core/providers/qwen' +import type { DecodeContext } from '@codeburn/core/contracts' + +// The privacy key is yours to generate and persist — core never invents one. +// Every fingerprint is scoped to it, so refs are comparable only within one key. +const context: DecodeContext = { + privacyKey: myHostKey, + providerId: 'qwen', + sourceRef: myOpaqueSourceRef, +} + +const { calls, diagnostics } = decodeQwen({ records, context }) + +const { sessions } = toObservations( + { sessionId, projectPath, calls }, + { privacyKey: myHostKey, provider: 'qwen' }, +) ``` -Each provider is its own subpath export; see `package.json#exports` for the full -list. JSON Schemas for the observation envelope ship under `schemas/`. +`context` is required — omitting it is a type error. JSON Schemas for every +shipped envelope version live under `schemas/`. + +## Versioning + +Four things version independently. Do not infer one from another. + +| What | Where | Notes | +|---|---|---| +| Package version | `package.json` | npm semver | +| Observation schema | `OBSERVATION_SCHEMA_VERSION` | currently `0.3.0` | +| Finding schema | `FINDING_SCHEMA_VERSION` | currently `0.2.0` | +| Detector algorithm | `algorithmVersion` on each finding | per detector | + +**All four are 0.x, and a 0.x MINOR bump may break you.** That is not the usual +semver reading, so pin exact versions and read the changelog before upgrading. + +Superseded schema files stay in `schemas/` unchanged so a consumer validating +against a published version keeps getting the shape that version promised. + +### Breaking changes in observation schema 0.3.0 + +- Every fingerprint widened from 16 to 32 hex characters (64 → 128 bits). +- The raw per-call `dedupKey` became `callRef`, a fingerprint. Provider dedup + keys embedded session, message, and record ids; they no longer ship. Compare + `callRef` values to de-duplicate — the mapping is stable per privacy key. +- Envelopes now carry `fingerprints: { algorithm, keyId }`. Refs are comparable + only across envelopes sharing a `keyId`; joining across key ids fabricates + sessions. The key id is derived from the key one-way and is not the key. +- `provider`, `providerId`, `model`, and `pricingModel` are now length- and + charset-capped. A non-canonical label degrades to `'unknown'` rather than + invalidating the whole envelope. + +Finding schema 0.2.0 carries the same widened refs; its shape is otherwise 0.1.0. + +## A note on what this package can and cannot prove + +Core normalizes records and produces evidence. It cannot prove all usage was +observed — an application developer can remove, bypass, or forge in-process +instrumentation. Treat detector estimates as estimates, not as metered spend or +realized savings. Authoritative usage accounting needs a controlled gateway or a +provider billing feed, neither of which lives here. Part of the CodeBurn core extraction ([RFC #796](https://github.com/getagentseal/codeburn/issues/796), tracking [#809](https://github.com/getagentseal/codeburn/issues/809)). MIT. diff --git a/packages/core/package.json b/packages/core/package.json index 16fc433..e5e78f8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -32,6 +32,10 @@ "types": "./dist/contracts.d.ts", "import": "./dist/contracts.js" }, + "./providers/registry": { + "types": "./dist/providers/registry.d.ts", + "import": "./dist/providers/registry.js" + }, "./detectors": { "types": "./dist/detectors/index.d.ts", "import": "./dist/detectors/index.js" diff --git a/packages/core/src/contracts.ts b/packages/core/src/contracts.ts index 1a60892..67f3f6c 100644 --- a/packages/core/src/contracts.ts +++ b/packages/core/src/contracts.ts @@ -30,8 +30,18 @@ export interface DecodeContext { } /** - * A decoder turns a batch of raw provider records into observations plus - * diagnostics, threading optional streaming `state` between batches. + * @deprecated No shipped provider implements this shape, and none ever has. + * + * It describes a single step from raw records straight to observations. Every + * real provider runs TWO steps: a provider-specific rich decode + * (`decodeClaude`, `decodeCodex`, …) that returns a provider-shaped result, + * then an observation adapter (`toObservations`) that minimizes it. A consumer + * who typed against `Decoder` and reached for a provider function got a + * compile error and no explanation. + * + * Kept as an alias for one release so existing imports still resolve. Type + * against `RichDecoder` and `ObservationAdapter` instead, or — better — import + * the provider's own exported function types, which are precise. */ export type Decoder = (input: { records: unknown[] @@ -43,6 +53,38 @@ export type Decoder = (input: { state?: TState } +/** + * Stage 1 output: a provider-shaped rich decode plus per-record diagnostics. + * + * The rich value is deliberately unconstrained — it holds the provider's own + * vocabulary (user messages, paths, tool arguments) and is HOST-SIDE ONLY. It + * must never be serialized onto the wire; that is what stage 2 is for. + */ +export interface RichDecodeResult { + value: TRich + diagnostics: RecordDiagnostic[] +} + +/** + * Stage 1: raw provider records -> rich, provider-shaped decode. + * + * `TInput` is per-provider (each declares its own `*DecodeInput`), which is why + * this is a two-parameter type rather than one fixed signature. Providers whose + * descriptor reports `supportsIncrementalState` accept a caller-owned dedup set + * on that input so an incremental scan does not re-emit known calls. + */ +export type RichDecoder = (input: TInput) => RichDecodeResult + +/** + * Stage 2: rich decode -> minimized observations. This is the boundary the + * content-smuggling suite guards; nothing but fingerprints, enums, numbers, + * timestamps, and canonical labels may cross it. + */ +export type ObservationAdapter = ( + rich: TRich, + options: TOptions, +) => { sessions: SessionObservation[] } + /** A detector inspects a full envelope and emits findings. */ export type Detector = (envelope: ObservationEnvelope) => Finding[] diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ddfa165..5a4f124 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,3 +3,4 @@ export * from './observations.js' export * from './diagnostics.js' export * from './fingerprint.js' export * from './contracts.js' +export * from './providers/registry.js' diff --git a/packages/core/src/providers/registry.ts b/packages/core/src/providers/registry.ts new file mode 100644 index 0000000..b48ce84 --- /dev/null +++ b/packages/core/src/providers/registry.ts @@ -0,0 +1,81 @@ +// Machine-readable inventory of the provider subpaths this package exports. +// +// Consumers (the CodeBurn CLI, the menubar, Prost, anything else) previously +// had to hard-code a provider list or scrape `package.json`. Both drift. This +// registry is the single declared source, and provider-registry.test.ts fails +// if it and the exports map ever disagree in either direction. + +/** What a consumer can know about one provider subpath without importing it. */ +export interface ProviderDescriptor { + /** Stable provider id. Matches the last segment of `exportPath`. */ + readonly id: string + /** The package subpath that exports this provider's decoder and adapter. */ + readonly exportPath: `./providers/${string}` + /** + * Whether the decoder threads de-duplication state across batches — it + * accepts a caller-owned `seenKeys` set (and, for some providers, a `state` + * object) so an incremental scan does not re-emit calls it already reported. + * + * Derived from the decoder's actual signature, not declared by hand. + */ + readonly supportsIncrementalState: boolean +} + +// NOTE ON A DELIBERATE OMISSION. +// +// An earlier draft of this registry carried an `inputKinds` field +// ('jsonl' | 'json' | 'rows' | 'bytes' | 'report'). It is not here because the +// value could not be established for all 34 providers without per-provider +// investigation of the host's discovery layer, and a registry that guesses is +// worse than one that stays silent — a consumer would route real data on it. +// Add it per provider, backed by evidence, rather than in one sweep. + +const PROVIDERS: readonly ProviderDescriptor[] = Object.freeze([ + { id: 'antigravity', exportPath: './providers/antigravity', supportsIncrementalState: true }, + { id: 'claude', exportPath: './providers/claude', supportsIncrementalState: false }, + { id: 'codebuff', exportPath: './providers/codebuff', supportsIncrementalState: true }, + { id: 'codewhale', exportPath: './providers/codewhale', supportsIncrementalState: true }, + { id: 'codex', exportPath: './providers/codex', supportsIncrementalState: true }, + { id: 'copilot', exportPath: './providers/copilot', supportsIncrementalState: true }, + { id: 'crush', exportPath: './providers/crush', supportsIncrementalState: true }, + { id: 'cursor', exportPath: './providers/cursor', supportsIncrementalState: true }, + { id: 'cursor-agent', exportPath: './providers/cursor-agent', supportsIncrementalState: true }, + { id: 'devin', exportPath: './providers/devin', supportsIncrementalState: true }, + { id: 'droid', exportPath: './providers/droid', supportsIncrementalState: true }, + { id: 'forge', exportPath: './providers/forge', supportsIncrementalState: true }, + { id: 'gemini', exportPath: './providers/gemini', supportsIncrementalState: true }, + { id: 'goose', exportPath: './providers/goose', supportsIncrementalState: true }, + { id: 'grok', exportPath: './providers/grok', supportsIncrementalState: true }, + { id: 'hermes', exportPath: './providers/hermes', supportsIncrementalState: true }, + { id: 'kimi', exportPath: './providers/kimi', supportsIncrementalState: true }, + { id: 'kimicode', exportPath: './providers/kimicode', supportsIncrementalState: true }, + { id: 'kiro', exportPath: './providers/kiro', supportsIncrementalState: true }, + { id: 'lingtai-tui', exportPath: './providers/lingtai-tui', supportsIncrementalState: true }, + { id: 'mistral-vibe', exportPath: './providers/mistral-vibe', supportsIncrementalState: true }, + { id: 'mux', exportPath: './providers/mux', supportsIncrementalState: true }, + { id: 'open-design', exportPath: './providers/open-design', supportsIncrementalState: true }, + { id: 'openclaw', exportPath: './providers/openclaw', supportsIncrementalState: true }, + { id: 'opencode-session', exportPath: './providers/opencode-session', supportsIncrementalState: true }, + { id: 'pi', exportPath: './providers/pi', supportsIncrementalState: true }, + { id: 'quickdesk', exportPath: './providers/quickdesk', supportsIncrementalState: true }, + { id: 'qwen', exportPath: './providers/qwen', supportsIncrementalState: true }, + { id: 'vercel-gateway', exportPath: './providers/vercel-gateway', supportsIncrementalState: true }, + { id: 'vscode-cline', exportPath: './providers/vscode-cline', supportsIncrementalState: true }, + { id: 'warp', exportPath: './providers/warp', supportsIncrementalState: true }, + { id: 'zcode', exportPath: './providers/zcode', supportsIncrementalState: true }, + { id: 'zed', exportPath: './providers/zed', supportsIncrementalState: true }, + { id: 'zerostack', exportPath: './providers/zerostack', supportsIncrementalState: true }, +].map((p) => Object.freeze(p)) as ProviderDescriptor[]) + +/** + * Every provider subpath this package exports. Frozen; the returned array and + * its entries cannot be mutated by a consumer. + */ +export function supportedProviders(): readonly ProviderDescriptor[] { + return PROVIDERS +} + +/** Look up one provider by id, or `undefined` if this build does not ship it. */ +export function findProvider(id: string): ProviderDescriptor | undefined { + return PROVIDERS.find((p) => p.id === id) +} diff --git a/packages/core/tests/provider-registry.test.ts b/packages/core/tests/provider-registry.test.ts new file mode 100644 index 0000000..fc3b322 --- /dev/null +++ b/packages/core/tests/provider-registry.test.ts @@ -0,0 +1,83 @@ +import { readFileSync, readdirSync, statSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { describe, expect, it } from 'vitest' + +import { findProvider, supportedProviders } from '../src/providers/registry.js' + +/** + * DRIFT GUARD. The registry, the package exports map, and the source tree must + * agree. Any one of them changing alone fails this test — which is the point: + * a provider added to `package.json` but not the registry is invisible to + * consumers, and a registry entry with no export is a broken import waiting to + * happen. + */ +const here = dirname(fileURLToPath(import.meta.url)) +const pkgRoot = resolve(here, '..') + +const pkg = JSON.parse(readFileSync(resolve(pkgRoot, 'package.json'), 'utf8')) + +// Every `./providers/*` export EXCEPT the registry itself, which is metadata +// about providers rather than a provider. +const exportedProviderPaths = Object.keys(pkg.exports) + .filter((s) => s.startsWith('./providers/') && s !== './providers/registry') + .sort() + +const sourceProviderDirs = readdirSync(resolve(pkgRoot, 'src/providers')) + .filter((name) => statSync(resolve(pkgRoot, 'src/providers', name)).isDirectory()) + .sort() + +describe('provider registry', () => { + it('is non-empty and frozen', () => { + const providers = supportedProviders() + expect(providers.length).toBeGreaterThan(0) + expect(Object.isFrozen(providers)).toBe(true) + expect(Object.isFrozen(providers[0])).toBe(true) + }) + + it('lists exactly the exported provider subpaths', () => { + const fromRegistry = supportedProviders() + .map((p) => p.exportPath) + .sort() + expect(fromRegistry).toEqual(exportedProviderPaths) + }) + + it('lists exactly the provider source directories', () => { + const fromRegistry = supportedProviders() + .map((p) => p.id) + .sort() + expect(fromRegistry).toEqual(sourceProviderDirs) + }) + + it('derives each exportPath from its id', () => { + for (const p of supportedProviders()) { + expect(p.exportPath).toBe(`./providers/${p.id}`) + } + }) + + it('has no duplicate ids', () => { + const ids = supportedProviders().map((p) => p.id) + expect(new Set(ids).size).toBe(ids.length) + }) + + it('resolves a known provider and rejects an unknown one', () => { + expect(findProvider('claude')?.exportPath).toBe('./providers/claude') + expect(findProvider('not-a-provider')).toBeUndefined() + }) + + it('reports incremental-state support matching the decoder signature', () => { + // claude exposes per-entry decode helpers with no caller-owned dedup set; + // every other provider threads one. Pinned so a signature change that + // silently drops incremental support is caught here. + expect(findProvider('claude')?.supportsIncrementalState).toBe(false) + const others = supportedProviders().filter((p) => p.id !== 'claude') + expect(others.every((p) => p.supportsIncrementalState)).toBe(true) + }) + + it('every declared export target exists in package.json', () => { + for (const p of supportedProviders()) { + expect(pkg.exports[p.exportPath], `missing exports entry for ${p.id}`).toBeTruthy() + } + }) +}) diff --git a/packages/core/tsup.config.ts b/packages/core/tsup.config.ts index 396313a..e642051 100644 --- a/packages/core/tsup.config.ts +++ b/packages/core/tsup.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ 'src/fingerprint.ts', 'src/contracts.ts', 'src/detectors/index.ts', + 'src/providers/registry.ts', 'src/providers/claude/index.ts', 'src/providers/codebuff/index.ts', 'src/providers/codewhale/index.ts',