mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-31 03:44:59 +00:00
feat: publish stable core integration metadata
This commit is contained in:
parent
cc4352532b
commit
e04302a60e
7 changed files with 293 additions and 20 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
81
packages/core/src/providers/registry.ts
Normal file
81
packages/core/src/providers/registry.ts
Normal 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)
|
||||
}
|
||||
83
packages/core/tests/provider-registry.test.ts
Normal file
83
packages/core/tests/provider-registry.test.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue