feat: publish stable core integration metadata

This commit is contained in:
Paul Logan 2026-07-27 13:09:22 -07:00
parent cc4352532b
commit e04302a60e
7 changed files with 293 additions and 20 deletions

View file

@ -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/<name>`) 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.

View file

@ -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"

View file

@ -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<TState = unknown> = (input: {
records: unknown[]
@ -43,6 +53,38 @@ export type Decoder<TState = unknown> = (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<TRich> {
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<TInput, TRich> = (input: TInput) => RichDecodeResult<TRich>
/**
* 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<TRich, TOptions> = (
rich: TRich,
options: TOptions,
) => { sessions: SessionObservation[] }
/** A detector inspects a full envelope and emits findings. */
export type Detector = (envelope: ObservationEnvelope) => Finding[]

View file

@ -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'

View file

@ -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)
}

View file

@ -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()
}
})
})

View file

@ -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',