diff --git a/.github/workflows/napi-build.yml b/.github/workflows/napi-build.yml new file mode 100644 index 00000000..268e8d93 --- /dev/null +++ b/.github/workflows/napi-build.yml @@ -0,0 +1,209 @@ +name: napi build + +# Build prebuilt napi-rs `.node` artifacts for every platform we publish to +# npm under `@relayburn/sdk-*`. PRs validate the matrix; tags / manual +# dispatch *would* publish, but the publish step is stubbed off for now — +# wired up properly in #249 (cutover release workflow). +# +# Caches Cargo registry + git + target dir, plus pnpm store, so the matrix +# stays fast on the hot path. + +on: + pull_request: + paths: + - 'crates/relayburn-sdk/**' + - 'crates/relayburn-sdk-node/**' + - 'packages/sdk-node/**' + - 'Cargo.toml' + - 'Cargo.lock' + - 'rust-toolchain.toml' + - '.github/workflows/napi-build.yml' + push: + branches: [main] + paths: + - 'crates/relayburn-sdk/**' + - 'crates/relayburn-sdk-node/**' + - 'packages/sdk-node/**' + - 'Cargo.toml' + - 'Cargo.lock' + - 'rust-toolchain.toml' + - '.github/workflows/napi-build.yml' + workflow_dispatch: + inputs: + publish: + description: 'Publish prebuilt artifacts to npm under the `next` tag (stubbed; full wiring in #249)' + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: napi-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: build ${{ matrix.target }} + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-apple-darwin + os: macos-15-intel + short: darwin-x64 + - target: aarch64-apple-darwin + os: macos-14 + short: darwin-arm64 + - target: x86_64-unknown-linux-gnu + os: ubuntu-latest + short: linux-x64-gnu + - target: aarch64-unknown-linux-gnu + os: ubuntu-latest + short: linux-arm64-gnu + runs-on: ${{ matrix.os }} + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup pnpm + uses: pnpm/action-setup@v5 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: '22.14.0' + cache: 'pnpm' + + - name: Setup Rust toolchain + # rust-toolchain.toml at the repo root pins channel + components. + # `cross` handles the aarch64 linux target via container, so we + # only register the host target by default and add the matrix + # target explicitly when it's a supported native cross. + run: | + rustup toolchain install + rustup target add ${{ matrix.target }} + + - name: Install aarch64-linux cross toolchain (linux-arm64 only) + if: matrix.target == 'aarch64-unknown-linux-gnu' + # `napi build` calls `cargo build --target aarch64-unknown-linux-gnu` + # directly (it does not auto-route through `cross`), so we need a real + # aarch64 cross-compiler available on the runner. `libsqlite3-sys` (a + # transitive dep via `rusqlite`) compiles bundled SQLite C source via + # `cc-rs`, which probes for `aarch64-linux-gnu-gcc`. + run: | + sudo apt-get update + sudo apt-get install -y gcc-aarch64-linux-gnu + + - name: Cache cargo registry + target + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: cargo-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock', '**/Cargo.toml', 'rust-toolchain.toml') }} + restore-keys: | + cargo-${{ runner.os }}-${{ matrix.target }}- + cargo-${{ runner.os }}- + + - name: Install workspace deps + run: pnpm install --frozen-lockfile + + - name: Install packages/sdk-node deps + # `packages/sdk-node` is intentionally excluded from the pnpm + # workspace until the 2.0 cutover (see pnpm-workspace.yaml), so + # `pnpm install` at the root won't pull its devDependencies + # (`@napi-rs/cli`, `esbuild`). `--ignore-workspace` makes pnpm + # treat this directory as a standalone project and install into + # `packages/sdk-node/node_modules/` directly. + working-directory: packages/sdk-node + run: pnpm install --ignore-workspace --no-frozen-lockfile + + - name: Build napi binding for ${{ matrix.target }} + # `napi build` reads `packages/sdk-node/package.json`'s `napi` + # block to learn the binary name, then dispatches to `cargo build` + # under the hood. The output is a per-target `.node` file and a + # generated `binding.cjs` / `binding.d.ts` co-located with it. + # `--cargo-cwd` points at the binding crate (which lives under + # `crates/relayburn-sdk-node/`, not co-located with the npm package) + # so the `cargo metadata` step that resolves the cdylib succeeds. + # The CC_/linker env vars only take effect for the aarch64-linux leg + # (cargo ignores per-target vars when the host matches the target); + # native legs are unaffected. + working-directory: packages/sdk-node + env: + CC_aarch64_unknown_linux_gnu: aarch64-linux-gnu-gcc + CXX_aarch64_unknown_linux_gnu: aarch64-linux-gnu-g++ + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc + run: | + pnpm exec napi build \ + --platform \ + --release \ + --target ${{ matrix.target }} \ + --cargo-cwd ../../crates/relayburn-sdk-node \ + --js src/binding.cjs \ + --dts src/binding.d.ts + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: relayburn-sdk-${{ matrix.short }} + path: packages/sdk-node/*.node + if-no-files-found: warn + retention-days: 7 + + - name: Bundle smoke test (esbuild) + # Confirms the umbrella facade resolves + bundles cleanly. Runs on + # every matrix leg so we catch platform-specific resolution issues + # (path casing, native-binding aliases) close to where they'd + # surface for an embedder. + working-directory: packages/sdk-node + run: node --test 'test/esbuild-smoke.test.js' + + - name: Conformance test (skipped until #247-a binding lands) + # While #247-a is in flight the binding crate has no exports, so + # the conformance suite skips itself. Once those bindings land, + # flipping `RELAYBURN_SDK_NAPI_BUILT=1` here turns the skip into a + # real deep-equal gate against TS @relayburn/sdk@1.x. + working-directory: packages/sdk-node + env: + RELAYBURN_SDK_NAPI_BUILT: '0' + run: node --test 'test/conformance.test.js' + + publish: + # Stub. Real publish wiring lands in #249 (Wave 3 cutover) — at that + # point this job downloads the matrix artifacts, drops each into the + # right `npm//` directory, and runs `npm publish --tag=next` + # for the umbrella + each per-platform package via OIDC trusted + # publisher. Until then, calling `workflow_dispatch` with + # `publish: true` exercises the artifact download path so we catch + # wiring errors early without actually pushing to npm. + needs: build + if: github.event_name == 'workflow_dispatch' && github.event.inputs.publish == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup pnpm + uses: pnpm/action-setup@v5 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: '22.14.0' + cache: 'pnpm' + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: packages/sdk-node/artifacts + + - name: Inspect artifacts (publish stub) + run: | + echo 'Publish step is currently a stub. Full wiring lands in #249.' + ls -lah packages/sdk-node/artifacts || true diff --git a/CHANGELOG.md b/CHANGELOG.md index 5864ce37..599e68c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Cross-package release notes for relayburn. Package changelogs contain package-le - `relayburn-cli` (Rust): introduce the harness substrate — `HarnessAdapter` trait, lazy compile-time `phf` registry (`lookup` / `list_harness_names`), and the shared `pending_stamp::adapter` factory codex + opencode will reuse. Adapter slots in the registry are reserved but empty pending the Wave 2 PRs (#248-d/e/f). `relayburn-sdk` re-exports `start_watch_loop`, `WatchController`, `write_pending_stamp`, `PendingStampHarness`, and friends so the CLI doesn't have to reach into private SDK modules. (#248) - `relayburn-cli` (Rust): scaffold the clap v4 derive root with global `--json` / `--ledger-path` / `--no-color` flags, eight stub subcommands (`summary`, `hotspots`, `overhead`, `compare`, `run`, `state`, `ingest`, `mcp-server`), and shared `render::{table,json,error}` helpers. Stubs exit `1` with a `not yet implemented` message (or a `{"error": …}` envelope under `--json`); Wave 2 fan-out PRs replace each stub with a thin presenter over `relayburn-sdk`. (#248 part a) - `relayburn-cli` (Rust): add the CLI golden-output test rig — synthetic fixture ledger under `tests/fixtures/cli-golden/`, a node script that captures TS-CLI stdout/stderr across 16 invocations (summary / hotspots / overhead / overhead-trim / compare / state-status in TTY + `--json`, plus help text for ingest / run / mcp-server / top-level), and `crates/relayburn-cli/tests/golden.rs` — a `BURN_GOLDEN=1`-gated diff runner Wave 2 PRs flip on per-command via `enabled: true` in `invocations.json`. (#248) +- `@relayburn/sdk` (npm 2.x): scaffold the `packages/sdk-node/` umbrella + four per-platform packages (`@relayburn/sdk-{darwin-arm64,darwin-x64,linux-arm64-gnu,linux-x64-gnu}`) resolved via `optionalDependencies`. Adds the napi-rs build matrix in `.github/workflows/napi-build.yml`, an esbuild bundle smoke test, and a deep-equal conformance test gate against the TS 1.x SDK across the six verbs (`ingest`, `summary`, `sessionCost`, `overhead`, `overheadTrim`, `hotspots`). Conformance test is skipped until `crates/relayburn-sdk-node` lands its bindings (#247 part a). (#247 part b) - `relayburn-ingest` (Rust): port the per-process gap-warning state machine (`gap` module — `record_session_gap`, `emit_gap_warning`, `count_tool_call_gaps`, `reset_ingest_gap_warnings`, `set_ingest_gap_writer`) and `reingest_missing_content` (`reingest` module). Suppression mirrors the TS surface: one warning per fresh affected session, silent on steady-state, re-fires after the affected set decays back to empty. `relayburn-ledger` adds `Ledger::list_user_turn_session_ids` to power the `reingest_missing_content` skip filter alongside `list_content_session_ids`. (#278) - `relayburn-analyze` (Rust): port the behavioral-pattern detectors (`patterns` module). `detect_patterns` runs retry-loop, failure-run, cancellation-run, compaction-loss, edit-revert, OpenCode skill-recall-dup, OpenCode skill-pruning-protection, OpenCode system-prompt-tax, and edit-heavy detectors against an ordered turn stream, with optional content-sidecar / tool-result-event / user-turn enrichment. Public surface: `detect_patterns`, `DetectPatternsOptions`; per-pattern result structs are re-exported from `findings` (`RetryLoop`, `FailureRun`, `CancellationRun`, `CompactionLoss`, `EditRevertCycle`, `SkillRecallDup`, `SkillPruningProtection`, `SystemPromptTax`, `EditHeavySession`, `SessionPatternSummary`, `PatternsResult`, `PatternEventSource`). (#275) - `relayburn-analyze` (Rust): port the tool-output-bloat detector — Signal A's `BASH_MAX_OUTPUT_LENGTH` static-config check (with `~/.claude/settings.json` + `/.claude/settings.json` loader) and Signal B's cross-harness observed-bloat aggregation, plus the `WasteFinding` adapter. Public surface mirrors `@relayburn/analyze`: `BASH_MAX_OUTPUT_ENV_KEY`, `DEFAULT_BLOAT_TOKEN_THRESHOLD`, `detect_observed_bloat`, `detect_static_config_bloat`, `detect_tool_output_bloat`, `load_claude_settings`, `project_claude_settings_path`, `user_claude_settings_path`, `tool_output_bloat_to_finding`. (#271) diff --git a/packages/sdk-node/CHANGELOG.md b/packages/sdk-node/CHANGELOG.md new file mode 100644 index 00000000..5edded4e --- /dev/null +++ b/packages/sdk-node/CHANGELOG.md @@ -0,0 +1,9 @@ +# @relayburn/sdk (2.x) + +## [Unreleased] + +- Initial scaffolding: umbrella package layout (`@relayburn/sdk`) + + per-platform packages (`@relayburn/sdk-{darwin-arm64,darwin-x64,linux-arm64-gnu,linux-x64-gnu}`) + resolved via `optionalDependencies`, TS facade re-exporting the napi-rs + binding, conformance scaffold against the TS 1.x SDK, esbuild bundle + smoke test. (#247 part b) diff --git a/packages/sdk-node/README.md b/packages/sdk-node/README.md new file mode 100644 index 00000000..a7d5c5df --- /dev/null +++ b/packages/sdk-node/README.md @@ -0,0 +1,36 @@ +# @relayburn/sdk (2.x) + +Embeddable Relayburn SDK — napi-rs bindings over the Rust `relayburn-sdk` +crate. Drop-in replacement for the TS `@relayburn/sdk@1.x` published from +`packages/sdk/`. + +The 2.x umbrella resolves the right native binary for your platform via +`optionalDependencies`: + +| Platform | Package | +|---|---| +| darwin-arm64 (Apple Silicon) | `@relayburn/sdk-darwin-arm64` | +| darwin-x64 (Intel Mac) | `@relayburn/sdk-darwin-x64` | +| linux-arm64-gnu | `@relayburn/sdk-linux-arm64-gnu` | +| linux-x64-gnu | `@relayburn/sdk-linux-x64-gnu` | + +Windows (`win32-x64-msvc`) is not yet shipped — see #247 follow-up. + +## Migration from 1.x + +Same imports, same option shapes, same return shapes — except: + +- **u64 token counts are `bigint`.** napi-rs maps Rust `u64` to JavaScript + `BigInt`. Code that does arithmetic on `summary().totalTokens` (and + similar fields on `hotspots`, `overhead`, `sessionCost`) needs to either + use `BigInt` literals (`100n`) or coerce with `Number(x)`. The TS + declarations widen these fields to `number | bigint` to keep existing + callers compiling. +- Otherwise byte-for-byte compatible. Run your test suite — the conformance + test in `test/conformance.test.js` is what we use to validate. + +## Status + +This is a `2.0.0-pre` build published to npm under the `next` tag while +the rest of the Rust port lands. Until the lockstep 2.0 cutover ships, the +1.x TS SDK at `packages/sdk/` is still the source of truth. diff --git a/packages/sdk-node/npm/darwin-arm64/README.md b/packages/sdk-node/npm/darwin-arm64/README.md new file mode 100644 index 00000000..fecb3588 --- /dev/null +++ b/packages/sdk-node/npm/darwin-arm64/README.md @@ -0,0 +1,7 @@ +# @relayburn/sdk-darwin-arm64 + +Prebuilt native binding for `@relayburn/sdk` on `darwin-arm64`. You should +not depend on this package directly — install `@relayburn/sdk` and the right +platform binding is resolved via `optionalDependencies`. + +See . diff --git a/packages/sdk-node/npm/darwin-arm64/package.json b/packages/sdk-node/npm/darwin-arm64/package.json new file mode 100644 index 00000000..5ac27f63 --- /dev/null +++ b/packages/sdk-node/npm/darwin-arm64/package.json @@ -0,0 +1,29 @@ +{ + "name": "@relayburn/sdk-darwin-arm64", + "version": "2.0.0-pre.0", + "description": "Prebuilt napi-rs binding for @relayburn/sdk on darwin-arm64.", + "main": "relayburn-sdk.darwin-arm64.node", + "files": [ + "relayburn-sdk.darwin-arm64.node", + "package.json", + "README.md" + ], + "os": [ + "darwin" + ], + "cpu": [ + "arm64" + ], + "engines": { + "node": ">=22" + }, + "repository": { + "type": "git", + "url": "https://github.com/AgentWorkforce/burn", + "directory": "packages/sdk-node/npm/darwin-arm64" + }, + "publishConfig": { + "access": "public", + "tag": "next" + } +} diff --git a/packages/sdk-node/npm/darwin-x64/README.md b/packages/sdk-node/npm/darwin-x64/README.md new file mode 100644 index 00000000..9b937cf4 --- /dev/null +++ b/packages/sdk-node/npm/darwin-x64/README.md @@ -0,0 +1,7 @@ +# @relayburn/sdk-darwin-x64 + +Prebuilt native binding for `@relayburn/sdk` on `darwin-x64`. You should +not depend on this package directly — install `@relayburn/sdk` and the right +platform binding is resolved via `optionalDependencies`. + +See . diff --git a/packages/sdk-node/npm/darwin-x64/package.json b/packages/sdk-node/npm/darwin-x64/package.json new file mode 100644 index 00000000..f6d92532 --- /dev/null +++ b/packages/sdk-node/npm/darwin-x64/package.json @@ -0,0 +1,29 @@ +{ + "name": "@relayburn/sdk-darwin-x64", + "version": "2.0.0-pre.0", + "description": "Prebuilt napi-rs binding for @relayburn/sdk on darwin-x64.", + "main": "relayburn-sdk.darwin-x64.node", + "files": [ + "relayburn-sdk.darwin-x64.node", + "package.json", + "README.md" + ], + "os": [ + "darwin" + ], + "cpu": [ + "x64" + ], + "engines": { + "node": ">=22" + }, + "repository": { + "type": "git", + "url": "https://github.com/AgentWorkforce/burn", + "directory": "packages/sdk-node/npm/darwin-x64" + }, + "publishConfig": { + "access": "public", + "tag": "next" + } +} diff --git a/packages/sdk-node/npm/linux-arm64-gnu/README.md b/packages/sdk-node/npm/linux-arm64-gnu/README.md new file mode 100644 index 00000000..e770fe67 --- /dev/null +++ b/packages/sdk-node/npm/linux-arm64-gnu/README.md @@ -0,0 +1,7 @@ +# @relayburn/sdk-linux-arm64-gnu + +Prebuilt native binding for `@relayburn/sdk` on `linux-arm64-gnu` (glibc). +You should not depend on this package directly — install `@relayburn/sdk` +and the right platform binding is resolved via `optionalDependencies`. + +See . diff --git a/packages/sdk-node/npm/linux-arm64-gnu/package.json b/packages/sdk-node/npm/linux-arm64-gnu/package.json new file mode 100644 index 00000000..6260cc89 --- /dev/null +++ b/packages/sdk-node/npm/linux-arm64-gnu/package.json @@ -0,0 +1,32 @@ +{ + "name": "@relayburn/sdk-linux-arm64-gnu", + "version": "2.0.0-pre.0", + "description": "Prebuilt napi-rs binding for @relayburn/sdk on linux-arm64-gnu (glibc).", + "main": "relayburn-sdk.linux-arm64-gnu.node", + "files": [ + "relayburn-sdk.linux-arm64-gnu.node", + "package.json", + "README.md" + ], + "os": [ + "linux" + ], + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "engines": { + "node": ">=22" + }, + "repository": { + "type": "git", + "url": "https://github.com/AgentWorkforce/burn", + "directory": "packages/sdk-node/npm/linux-arm64-gnu" + }, + "publishConfig": { + "access": "public", + "tag": "next" + } +} diff --git a/packages/sdk-node/npm/linux-x64-gnu/README.md b/packages/sdk-node/npm/linux-x64-gnu/README.md new file mode 100644 index 00000000..4b35ff8d --- /dev/null +++ b/packages/sdk-node/npm/linux-x64-gnu/README.md @@ -0,0 +1,7 @@ +# @relayburn/sdk-linux-x64-gnu + +Prebuilt native binding for `@relayburn/sdk` on `linux-x64-gnu` (glibc). +You should not depend on this package directly — install `@relayburn/sdk` +and the right platform binding is resolved via `optionalDependencies`. + +See . diff --git a/packages/sdk-node/npm/linux-x64-gnu/package.json b/packages/sdk-node/npm/linux-x64-gnu/package.json new file mode 100644 index 00000000..3b3a2e68 --- /dev/null +++ b/packages/sdk-node/npm/linux-x64-gnu/package.json @@ -0,0 +1,32 @@ +{ + "name": "@relayburn/sdk-linux-x64-gnu", + "version": "2.0.0-pre.0", + "description": "Prebuilt napi-rs binding for @relayburn/sdk on linux-x64-gnu (glibc).", + "main": "relayburn-sdk.linux-x64-gnu.node", + "files": [ + "relayburn-sdk.linux-x64-gnu.node", + "package.json", + "README.md" + ], + "os": [ + "linux" + ], + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "engines": { + "node": ">=22" + }, + "repository": { + "type": "git", + "url": "https://github.com/AgentWorkforce/burn", + "directory": "packages/sdk-node/npm/linux-x64-gnu" + }, + "publishConfig": { + "access": "public", + "tag": "next" + } +} diff --git a/packages/sdk-node/package.json b/packages/sdk-node/package.json new file mode 100644 index 00000000..4f979a6c --- /dev/null +++ b/packages/sdk-node/package.json @@ -0,0 +1,64 @@ +{ + "name": "@relayburn/sdk", + "version": "2.0.0-pre.0", + "description": "Embeddable Relayburn SDK — napi-rs bindings over the Rust relayburn-sdk crate (2.x). Drop-in replacement for the TS @relayburn/sdk@1.x.", + "type": "module", + "main": "./src/index.js", + "types": "./src/index.d.ts", + "exports": { + ".": { + "types": "./src/index.d.ts", + "import": "./src/index.js", + "require": "./src/index.cjs" + } + }, + "files": [ + "src/index.js", + "src/index.cjs", + "src/index.d.ts", + "src/binding.cjs", + "src/binding.d.ts", + "README.md", + "CHANGELOG.md", + "package.json" + ], + "scripts": { + "build": "node -e \"process.exit(0)\"", + "build:napi": "napi build --platform --release --cargo-cwd ../../crates/relayburn-sdk-node --js src/binding.cjs --dts src/binding.d.ts", + "build:napi:debug": "napi build --platform --cargo-cwd ../../crates/relayburn-sdk-node --js src/binding.cjs --dts src/binding.d.ts", + "test": "node --test 'test/**/*.test.js'", + "test:bundle": "node test/esbuild-smoke.test.js" + }, + "engines": { + "node": ">=22" + }, + "napi": { + "binaryName": "relayburn-sdk", + "packageName": "@relayburn/sdk", + "targets": [ + "x86_64-apple-darwin", + "aarch64-apple-darwin", + "x86_64-unknown-linux-gnu", + "aarch64-unknown-linux-gnu" + ] + }, + "optionalDependencies": { + "@relayburn/sdk-darwin-arm64": "2.0.0-pre.0", + "@relayburn/sdk-darwin-x64": "2.0.0-pre.0", + "@relayburn/sdk-linux-arm64-gnu": "2.0.0-pre.0", + "@relayburn/sdk-linux-x64-gnu": "2.0.0-pre.0" + }, + "devDependencies": { + "@napi-rs/cli": "^2.18.4", + "esbuild": "^0.25.0" + }, + "repository": { + "type": "git", + "url": "https://github.com/AgentWorkforce/burn", + "directory": "packages/sdk-node" + }, + "publishConfig": { + "access": "public", + "tag": "next" + } +} diff --git a/packages/sdk-node/pnpm-lock.yaml b/packages/sdk-node/pnpm-lock.yaml new file mode 100644 index 00000000..335c04a4 --- /dev/null +++ b/packages/sdk-node/pnpm-lock.yaml @@ -0,0 +1,295 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@napi-rs/cli': + specifier: ^2.18.4 + version: 2.18.4 + esbuild: + specifier: ^0.25.0 + version: 0.25.12 + +packages: + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@napi-rs/cli@2.18.4': + resolution: {integrity: sha512-SgJeA4df9DE2iAEpr3M2H0OKl/yjtg1BnRI5/JyowS71tUWhrfSu2LT0V3vlHET+g1hBVlrO60PmEXwUEKp8Mg==} + engines: {node: '>= 10'} + hasBin: true + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + +snapshots: + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@napi-rs/cli@2.18.4': {} + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 diff --git a/packages/sdk-node/src/binding.cjs b/packages/sdk-node/src/binding.cjs new file mode 100644 index 00000000..61afac5d --- /dev/null +++ b/packages/sdk-node/src/binding.cjs @@ -0,0 +1,91 @@ +// Native-binding loader. At publish time, `napi build` (via `@napi-rs/cli`) +// regenerates this file to dispatch to the right per-platform package +// (`@relayburn/sdk-darwin-arm64`, `@relayburn/sdk-linux-x64-gnu`, etc.) based +// on `process.platform` + `process.arch` + libc detection. The generated +// version pulls the prebuilt `.node` file out of `optionalDependencies` so +// installs don't need a Rust toolchain. +// +// **File extension note:** this file is `.cjs` (not `.js`) because the +// umbrella package is `"type": "module"`, which would make Node treat a +// bare `.js` as ESM and reject the `module.exports` below at load time. +// `napi build` is invoked with `--js src/binding.cjs` (see +// `package.json` scripts + `.github/workflows/napi-build.yml`) so the +// regeneration writes back to the `.cjs` path; both `src/index.js` +// (ESM facade) and `src/index.cjs` (CJS facade) `require('./binding.cjs')`. +// +// This stub matches the napi-rs-generated dispatcher *shape* so the umbrella +// package's TS facade (`src/index.js`) can import from it during local dev / +// CI conformance scaffolding before the prebuilt binaries exist. While +// #247-a is in flight, we throw a clear "binding not built" error instead of +// requiring `*.node` artifacts that don't exist yet. +// +// Once `napi build` runs in CI for the first time, this file is overwritten; +// see `.github/workflows/napi-build.yml`. + +const { existsSync, readFileSync } = require('node:fs'); +const { join } = require('node:path'); +const { platform, arch } = process; + +// Detect glibc vs musl on Linux. napi-rs generates this with `detect-libc` +// at build time; we keep a minimal fallback so `require('./binding.cjs')` +// doesn't crash when run before the binary build. +function isMusl() { + if (!process.report) return false; + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { glibcVersionRuntime } = (process.report.getReport() || {}).header || {}; + return !glibcVersionRuntime; + } catch (_) { + return false; + } +} + +let nativeBinding = null; +let loadError = null; + +function tryRequire(specifier, localFile) { + // Prefer the optional-dep platform package; fall back to a sibling .node + // that `napi build --release` drops next to this loader during local dev. + const localPath = localFile ? join(__dirname, '..', localFile) : null; + if (localPath && existsSync(localPath)) { + try { + return require(localPath); + } catch (e) { + loadError = e; + } + } + try { + return require(specifier); + } catch (e) { + loadError = e; + return null; + } +} + +if (platform === 'darwin' && arch === 'arm64') { + nativeBinding = tryRequire('@relayburn/sdk-darwin-arm64', 'relayburn-sdk.darwin-arm64.node'); +} else if (platform === 'darwin' && arch === 'x64') { + nativeBinding = tryRequire('@relayburn/sdk-darwin-x64', 'relayburn-sdk.darwin-x64.node'); +} else if (platform === 'linux' && arch === 'arm64' && !isMusl()) { + nativeBinding = tryRequire('@relayburn/sdk-linux-arm64-gnu', 'relayburn-sdk.linux-arm64-gnu.node'); +} else if (platform === 'linux' && arch === 'x64' && !isMusl()) { + nativeBinding = tryRequire('@relayburn/sdk-linux-x64-gnu', 'relayburn-sdk.linux-x64-gnu.node'); +} + +if (!nativeBinding) { + // Surface a clear actionable error. While #247-a is still merging, this is + // the failure mode CI / dev machines will hit; the conformance test + // `test/conformance.test.js` checks for it and skips so the suite stays + // green until bindings land. + const detail = loadError + ? `\nUnderlying error: ${loadError.message}` + : ''; + throw new Error( + `@relayburn/sdk: native binding not found for ${platform}-${arch}.\n` + + `Expected one of @relayburn/sdk-{darwin-arm64,darwin-x64,linux-arm64-gnu,linux-x64-gnu} ` + + `to be installed via optionalDependencies, or a sibling .node prebuilt by ` + + `\`pnpm --filter @relayburn/sdk run build:napi\`.${detail}`, + ); +} + +module.exports = nativeBinding; diff --git a/packages/sdk-node/src/binding.d.ts b/packages/sdk-node/src/binding.d.ts new file mode 100644 index 00000000..7c9b02e3 --- /dev/null +++ b/packages/sdk-node/src/binding.d.ts @@ -0,0 +1,21 @@ +// Generated by `napi build --dts` at publish time. This stub matches the +// expected exported function names from `crates/relayburn-sdk-node` (#247-a). +// Once the bindings land, `napi build` overwrites this file with the real +// generated declarations. +// +// The umbrella's `src/index.js` re-exports these directly. The runtime +// shape is the discriminated unions / option objects defined in +// `src/index.d.ts`; this file just declares "the binding exposes these +// names as functions." + +export declare class Ledger { + static open(opts?: { home?: string }): Promise; +} + +export declare function ingest(opts?: unknown): Promise; +export declare function summary(opts?: unknown): Promise; +export declare function sessionCost(opts?: unknown): Promise; +export declare function overhead(opts?: unknown): Promise; +export declare function overheadTrim(opts?: unknown): Promise; +export declare function hotspots(opts?: unknown): Promise; +export declare function compare(opts: unknown): Promise; diff --git a/packages/sdk-node/src/index.cjs b/packages/sdk-node/src/index.cjs new file mode 100644 index 00000000..ecc01078 --- /dev/null +++ b/packages/sdk-node/src/index.cjs @@ -0,0 +1,19 @@ +// CommonJS variant of the umbrella facade. Required for tools that resolve +// `require('@relayburn/sdk')` through CJS — the ESM `src/index.js` is the +// canonical entry point but pnpm / Node falls back to this when a consumer's +// package is `"type": "commonjs"` and the `exports.require` map is honored. + +'use strict'; + +const binding = require('./binding.cjs'); + +module.exports = { + Ledger: binding.Ledger, + ingest: (opts) => binding.ingest(opts), + summary: (opts) => binding.summary(opts), + sessionCost: (opts) => binding.sessionCost(opts), + overhead: (opts) => binding.overhead(opts), + overheadTrim: (opts) => binding.overheadTrim(opts), + hotspots: (opts) => binding.hotspots(opts), + compare: (opts) => binding.compare(opts), +}; diff --git a/packages/sdk-node/src/index.d.ts b/packages/sdk-node/src/index.d.ts new file mode 100644 index 00000000..ea4a1ea8 --- /dev/null +++ b/packages/sdk-node/src/index.d.ts @@ -0,0 +1,329 @@ +// Type surface for `@relayburn/sdk@2.x`. +// +// Mirrors `packages/sdk/index.d.ts` (the TS 1.x SDK) byte-for-byte modulo: +// - `bigint` is allowed alongside `number` for u64-typed token counts (the +// napi-rs binding emits `BigInt` for `u64`; the TS shape is widened so +// existing callers that pass through `number` keep type-checking once +// bound to the Rust impl). +// - Async fns return `Promise` — the napi-rs binding uses `async fn` +// where the Rust SDK does, which is everywhere except the `Ledger.open` +// constructor. +// +// Source-of-truth comment: track `packages/sdk/index.d.ts`. Whenever a verb +// shape changes in TS, mirror it here AND in the Rust napi-rs binding (#247-a). + +export interface LedgerOpenOptions { home?: string } +export declare class Ledger { static open(opts?: LedgerOpenOptions): Promise } + +export interface IngestOptions { sessionId?: string; harness?: 'claude-code'|'codex'|'opencode'; ledgerHome?: string } +export declare function ingest(opts?: IngestOptions): Promise + +export interface SummaryOptions { + session?: string; + project?: string; + /** ISO timestamp (e.g. `2026-04-01T00:00:00Z`) or relative range (`24h`, `7d`, `4w`, `2m`). */ + since?: string; + ledgerHome?: string; + /** Optional logger invoked when the SQLite archive read fails and the SDK falls back to a full ledger walk. */ + onLog?: (msg: string) => void; +} +export declare function summary(opts?: SummaryOptions): Promise<{ + totalTokens: number | bigint; + totalCost: number; + turnCount: number; + byTool: Array<{ tool: string; tokens: number | bigint; cost: number; count: number }>; + byModel: Array<{ model: string; tokens: number | bigint; cost: number }>; +}> + +export interface SessionCostOptions { + /** Session id to total. Omit for `{ note: 'no session id provided' }`. */ + session?: string; + ledgerHome?: string; + onLog?: (msg: string) => void; +} +export interface SessionCostResult { + sessionId: string | null; + totalUSD: number; + totalTokens: number | bigint; + turnCount: number; + models: string[]; + note?: string; +} +/** Compact session-scoped cost shape; powers the MCP `burn__sessionCost` tool. */ +export declare function sessionCost(opts?: SessionCostOptions): Promise + +export type OverheadFileKind = 'claude-md' | 'agents-md'; +export type OverheadHarness = 'claude-code' | 'codex' | 'opencode'; + +export interface OverheadOptions { + project?: string; + since?: string; + kind?: OverheadFileKind; + ledgerHome?: string; + onLog?: (msg: string) => void; +} + +export interface OverheadSection { + heading: string; + startLine: number; + endLine: number; + tokens: number | bigint; +} + +export interface OverheadSectionCost { + filePath: string; + section: OverheadSection; + tokenShare: number; + costPerSession: number; + totalCost: number; +} + +export interface OverheadAttributionDetail { + sessionCount: number; + perSessionAvg: number; + perSessionP95: number; + totalCost: number; + sectionCosts: OverheadSectionCost[]; +} + +export interface OverheadFileSummary { + kind: OverheadFileKind; + path: string; + appliesTo: OverheadHarness[]; + totalLines: number; + bytes: number | bigint; + tokens: number | bigint; + sections: OverheadSection[]; + groupingLevel: number; +} + +export interface OverheadPerFileEntry { + path: string; + kind: OverheadFileKind; + appliesTo: OverheadHarness[]; + attribution: OverheadAttributionDetail; +} + +export interface OverheadResult { + project: string; + files: OverheadFileSummary[]; + perFile: OverheadPerFileEntry[]; + grandTotal: number; +} + +/** Per-file + per-section overhead cost attribution. Powers `burn overhead`. */ +export declare function overhead(opts?: OverheadOptions): Promise + +export interface OverheadTrimOptions extends OverheadOptions { + top?: number; + includeDiff?: boolean; +} + +export interface OverheadTrimRecommendation { + file: string; + kind: OverheadFileKind; + appliesTo: OverheadHarness[]; + section: { heading: string; startLine: number; endLine: number; tokens: number | bigint }; + projectedSavings: { + perSessionUsd: number; + acrossWindowUsd: number; + tokens: number | bigint; + tokenShare: number; + }; + diff?: string; +} + +export interface OverheadTrimResult { + project: string; + since: string; + recommendations: OverheadTrimRecommendation[]; + summary: { + filesAnalyzed: number; + filesWithRecommendations: number; + totalRecommendations: number; + totalProjectedSavingsPerSession: number; + totalProjectedSavingsAcrossWindow: number; + }; +} + +/** Trim recommendations for high-cost overhead-file sections. Powers `burn overhead trim`. */ +export declare function overheadTrim(opts?: OverheadTrimOptions): Promise + +export type HotspotsGroupBy = 'attribution' | 'bash' | 'bash-verb' | 'file' | 'subagent'; + +export interface HotspotsOptions { + session?: string; + project?: string; + since?: string; + groupBy?: HotspotsGroupBy; + patterns?: string[]; + ledgerHome?: string; + onLog?: (msg: string) => void; +} + +export interface HotspotsFileRow { + path: string; + firstEmitTurnIndex: number; + initialTokens: number | bigint; + persistenceTokens: number | bigint; + ridingTurns: number; + totalCost: number; +} + +export interface HotspotsBashRow { + command: string | undefined; + argsHash: string; + callCount: number; + initialTokens: number | bigint; + persistenceTokens: number | bigint; + totalCost: number; +} + +export interface HotspotsBashVerbRow { + verb: string; + callCount: number; + distinctCommands: number; + initialTokens: number | bigint; + persistenceTokens: number | bigint; + avgPersistenceTurns: number; + totalCost: number; + topExamples: string[]; +} + +export interface HotspotsSubagentRow { + subagentType: string; + callCount: number; + initialTokens: number | bigint; + persistenceTokens: number | bigint; + totalCost: number; +} + +export interface HotspotsSessionTotal { + sessionId: string; + grandCost: number; + attributedCost: number; + unattributedCost: number; + attributionMethod: 'sized' | 'even-split'; +} + +export interface HotspotsFidelityBlock { + analyzed: number; + excluded: number; + summary: unknown; + refused: boolean; +} + +export interface HotspotsAttributionResult { + kind: 'attribution'; + turnsAnalyzed: number; + grandTotal: number; + attributedTotal: number; + unattributedTotal: number; + attributionDegraded: boolean; + sessions: HotspotsSessionTotal[]; + files: HotspotsFileRow[]; + bashVerbs: HotspotsBashVerbRow[]; + bash: HotspotsBashRow[]; + subagents: HotspotsSubagentRow[]; + fidelity: HotspotsFidelityBlock; + refused?: boolean; + refusalReason?: string; +} + +export interface HotspotsBashResult { kind: 'bash'; rows: HotspotsBashRow[]; refused?: boolean; refusalReason?: string } +export interface HotspotsBashVerbResult { kind: 'bash-verb'; rows: HotspotsBashVerbRow[]; refused?: boolean; refusalReason?: string } +export interface HotspotsFileResult { kind: 'file'; rows: HotspotsFileRow[]; refused?: boolean; refusalReason?: string } +export interface HotspotsSubagentResult { kind: 'subagent'; rows: HotspotsSubagentRow[]; refused?: boolean; refusalReason?: string } + +export interface HotspotsFinding { + kind: string; + severity: string; + sessionId: string; + title: string; + estimatedSavings: { usdPerSession?: number; [k: string]: unknown }; + [k: string]: unknown; +} + +export interface HotspotsFindingsResult { + kind: 'findings'; + findings: HotspotsFinding[]; + summary: unknown; +} + +export type HotspotsResult = + | HotspotsAttributionResult + | HotspotsBashResult + | HotspotsBashVerbResult + | HotspotsFileResult + | HotspotsSubagentResult + | HotspotsFindingsResult; + +/** + * Per-axis hotspot attribution + pattern-finding queries. Returns a + * discriminated union — see `HotspotsResult`. + */ +export declare function hotspots(opts?: HotspotsOptions): Promise + +export type FidelityClass = 'full' | 'usage-only' | 'aggregate-only' | 'cost-only' | 'partial'; + +export interface FidelitySummaryShape { + total: number; + byClass: Record; + unknown: number; + missingCoverage: Record; +} + +export interface CompareExcludedBreakdown { + total: number; + aggregateOnly: number; + costOnly: number; + partial: number; + usageOnly: number; +} + +export interface CompareCellResult { + model: string; + category: string; + turns: number; + editTurns: number; + oneShotTurns: number; + pricedTurns: number; + totalCost: number; + costPerTurn: number | null; + oneShotRate: number | null; + cacheHitRate: number | null; + medianRetries: number | null; + noData: boolean; + insufficientSample: boolean; +} + +export interface CompareOptions { + models: string[]; + session?: string; + project?: string; + since?: string; + workflow?: string; + agent?: string; + provider?: string[]; + minSample?: number; + minFidelity?: FidelityClass; + ledgerHome?: string; + onLog?: (msg: string) => void; +} + +export interface CompareResult { + analyzedTurns: number; + minSample: number; + models: string[]; + categories: string[]; + totals: Record; + cells: CompareCellResult[]; + fidelity: { + minimum: FidelityClass; + excluded: CompareExcludedBreakdown; + summary: FidelitySummaryShape; + }; +} + +/** Per-(model, activity) comparison shape. Powers `burn compare`. */ +export declare function compare(opts: CompareOptions): Promise diff --git a/packages/sdk-node/src/index.js b/packages/sdk-node/src/index.js new file mode 100644 index 00000000..c54a92bb --- /dev/null +++ b/packages/sdk-node/src/index.js @@ -0,0 +1,44 @@ +// Thin ESM facade over the napi-rs binding. No behavior — every function +// here re-exports the matching `#[napi]` export from the platform package +// resolved by `./binding.cjs`. +// +// All query / compute logic lives in the Rust SDK (`crates/relayburn-sdk`); +// the binding crate (`crates/relayburn-sdk-node`) wraps it for napi-rs. +// This file exists so the published TS surface stays identical to the +// 1.x SDK (`packages/sdk/index.js`) — same import names, same option shapes, +// same return types — while the runtime is Rust. + +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const binding = require('./binding.cjs'); + +export const Ledger = binding.Ledger; + +export function ingest(opts) { + return binding.ingest(opts); +} + +export function summary(opts) { + return binding.summary(opts); +} + +export function sessionCost(opts) { + return binding.sessionCost(opts); +} + +export function overhead(opts) { + return binding.overhead(opts); +} + +export function overheadTrim(opts) { + return binding.overheadTrim(opts); +} + +export function hotspots(opts) { + return binding.hotspots(opts); +} + +export function compare(opts) { + return binding.compare(opts); +} diff --git a/packages/sdk-node/test/conformance.test.js b/packages/sdk-node/test/conformance.test.js new file mode 100644 index 00000000..042291d9 --- /dev/null +++ b/packages/sdk-node/test/conformance.test.js @@ -0,0 +1,266 @@ +// Conformance test: TS @relayburn/sdk@1.x vs napi-rs @relayburn/sdk@2.0.0-pre. +// +// For each of the 7 verbs (`ingest`, `summary`, `sessionCost`, `overhead`, +// `overheadTrim`, `hotspots`, `compare`), run both implementations against +// the existing fixture ledger and assert `deepStrictEqual` on the return +// value. This is the gate that says "the Rust port is behavior-equivalent +// to the TS port" — it's the test #247 cites as the acceptance criterion. +// +// **Status (2026-05-06): scaffolded but skipped.** The napi-rs bindings +// land in #247-a (parallel agent worktree). Until that crate is published +// and the umbrella's `src/binding.cjs` resolves a real native package, the +// `loadNapiSdk()` helper below throws and the suite skips. Flip the +// `RELAYBURN_SDK_NAPI_BUILT=1` env var (CI sets this once #247-a's bindings +// produce a valid `*.node` artifact) to enable the comparison. +// +// What's stubbed and why: +// - The local `@relayburn/sdk` npm package in this directory is the 2.x +// umbrella; its facade resolves the binding lazily on import. So +// `await import('@relayburn/sdk')` succeeds, but the first verb call +// throws "binding not found" until #247-a ships. We catch that +// specific error and skip rather than fail. +// - We fix `RELAYBURN_HOME` to a tmp dir for both runs so they share +// state. The TS 1.x `@relayburn/sdk` is loaded via a relative file: +// reference (`packages/sdk`) to avoid name collision with the 2.x +// umbrella in this package's `node_modules`. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, cpSync, existsSync, readFileSync, statSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(__dirname, '../../..'); + +// Fixture ledger location. CI seeds `tests/fixtures/ledger/` from the +// reader corpus during the `prepare-fixture-ledger` step. When +// `RELAYBURN_SDK_NAPI_BUILT=1` is set this directory MUST exist — +// `ensureFixtureLedger()` below throws if it's missing so the gate can't +// silently pass on empty homes. +const FIXTURE_LEDGER_DIR = join(REPO_ROOT, 'tests', 'fixtures', 'ledger'); + +const NAPI_READY = process.env.RELAYBURN_SDK_NAPI_BUILT === '1'; + +async function loadTsSdk() { + // The 1.x SDK lives at `packages/sdk` — load it via a fully-resolved + // relative path so we don't accidentally resolve back into the 2.x + // umbrella we're testing from. + const tsSdkPath = join(REPO_ROOT, 'packages', 'sdk', 'index.js'); + return import(tsSdkPath); +} + +async function loadNapiSdk() { + // Resolve the in-tree umbrella facade. Throws if the binding is missing — + // the per-test guard below converts that to a skip so the suite stays + // green while #247-a is in flight. + const napiSdkPath = join(__dirname, '..', 'src', 'index.js'); + return import(napiSdkPath); +} + +function bindingMissing(err) { + return /native binding not found/i.test(String(err && err.message)); +} + +// When the gate is on (`RELAYBURN_SDK_NAPI_BUILT=1`) the fixture ledger MUST +// exist AND be well-formed enough to actually exercise the verbs — otherwise +// both implementations would compare against empty/garbage homes and the +// deep-equality checks would tautologically pass on noise (both sides see the +// same nothing). Throwing here turns "fixture missing or malformed" into a +// loud failure instead of a silent green. +// +// The fixture lives at `tests/fixtures/ledger/` and mirrors the on-disk shape +// of `~/.relayburn/`: a canonical `ledger.jsonl` (see +// `packages/ledger/src/paths.ts::ledgerPath`) plus a `content/` sidecar. CI's +// `prepare-fixture-ledger` step seeds it from the reader corpus before +// flipping the gate. +// +// Preconditions checked: +// 1. The fixture directory exists. +// 2. `ledger.jsonl` exists and is non-empty. +// 3. The first line of `ledger.jsonl` parses as JSON and matches the +// `LedgerLine` shape (`v: 1` + `kind: `) defined in +// `packages/ledger/src/schema.ts`. This catches the easy "I copied the +// wrong thing" failure mode (e.g. a stray text file) without doing a +// full schema sweep. +// 4. At least one `kind: 'turn'` line is present anywhere in the file — +// verbs like `summary` / `hotspots` / `compare` need turns to produce +// non-trivial output, so a stamp-only fixture would still let the +// conformance gate pass on empty rows. +const KNOWN_LEDGER_KINDS = new Set([ + 'turn', + 'stamp', + 'compaction', + 'relationship', + 'tool_result_event', + 'user_turn', +]); + +function fixtureSeedHint() { + return ( + `Seed it (e.g. cp -R ~/.relayburn tests/fixtures/ledger) before ` + + `running with RELAYBURN_SDK_NAPI_BUILT=1, or unset the env var to ` + + `skip the conformance suite.` + ); +} + +function ensureFixtureLedger() { + if (!existsSync(FIXTURE_LEDGER_DIR)) { + throw new Error( + `conformance fixture ledger missing at ${FIXTURE_LEDGER_DIR}. ` + + fixtureSeedHint(), + ); + } + const ledgerJsonl = join(FIXTURE_LEDGER_DIR, 'ledger.jsonl'); + if (!existsSync(ledgerJsonl)) { + throw new Error( + `conformance fixture ledger malformed: expected ${ledgerJsonl} ` + + `(canonical ledger filename per packages/ledger/src/paths.ts). ` + + fixtureSeedHint(), + ); + } + if (statSync(ledgerJsonl).size === 0) { + throw new Error( + `conformance fixture ledger malformed: ${ledgerJsonl} is empty. ` + + fixtureSeedHint(), + ); + } + // Cheap sanity sweep: confirm the file looks like a JSONL ledger and + // contains at least one turn line. We read the whole file because real + // fixtures are small (kilobytes); if that ever stops being true, switch to + // a streaming scan. + const lines = readFileSync(ledgerJsonl, 'utf8').split('\n').filter((l) => l.length > 0); + if (lines.length === 0) { + throw new Error( + `conformance fixture ledger malformed: ${ledgerJsonl} has no JSONL ` + + `lines. ` + + fixtureSeedHint(), + ); + } + let firstLine; + try { + firstLine = JSON.parse(lines[0]); + } catch (err) { + throw new Error( + `conformance fixture ledger malformed: ${ledgerJsonl} first line is ` + + `not valid JSON (${err && err.message}). ` + + fixtureSeedHint(), + ); + } + if ( + !firstLine || + typeof firstLine !== 'object' || + firstLine.v !== 1 || + typeof firstLine.kind !== 'string' || + !KNOWN_LEDGER_KINDS.has(firstLine.kind) + ) { + throw new Error( + `conformance fixture ledger malformed: ${ledgerJsonl} first line ` + + `does not match the LedgerLine shape (expected v:1 + kind:, ` + + `got ${JSON.stringify({ v: firstLine && firstLine.v, kind: firstLine && firstLine.kind })}). ` + + fixtureSeedHint(), + ); + } + // Require at least one turn so summary/hotspots/compare have something to + // diff against. A pure stamp/relationship-only fixture would still let + // both impls return empty rows and pass on noise. + const hasTurn = lines.some((line) => { + try { + const parsed = JSON.parse(line); + return parsed && parsed.v === 1 && parsed.kind === 'turn'; + } catch { + return false; + } + }); + if (!hasTurn) { + throw new Error( + `conformance fixture ledger malformed: ${ledgerJsonl} contains no ` + + `kind:'turn' lines, so verbs like summary/hotspots/compare would ` + + `compare empty results on both sides. ` + + fixtureSeedHint(), + ); + } +} + +function makeHome() { + const home = mkdtempSync(join(tmpdir(), 'relayburn-conformance-')); + cpSync(FIXTURE_LEDGER_DIR, home, { recursive: true }); + return home; +} + +async function callBoth(verb, opts) { + ensureFixtureLedger(); + const ts = await loadTsSdk(); + const napi = await loadNapiSdk(); + const tsHome = makeHome(); + const napiHome = makeHome(); + try { + const tsResult = await ts[verb]({ ...opts, ledgerHome: tsHome }); + let napiResult; + try { + napiResult = await napi[verb]({ ...opts, ledgerHome: napiHome }); + } catch (err) { + if (bindingMissing(err)) return { skipped: true }; + throw err; + } + return { tsResult, napiResult }; + } finally { + rmSync(tsHome, { recursive: true, force: true }); + rmSync(napiHome, { recursive: true, force: true }); + } +} + +const VERBS = [ + { name: 'summary', opts: {} }, + { name: 'sessionCost', opts: { session: 'fixture-session-1' } }, + { name: 'overhead', opts: { project: REPO_ROOT } }, + { name: 'overheadTrim', opts: { project: REPO_ROOT, includeDiff: false } }, + { name: 'hotspots', opts: {} }, + // compare() requires >=2 models; pick a stable pair that may or may not + // appear in the fixture — both implementations see the same input so + // missing models surface as identical empty/no-data rows on each side. + // The deep-equality check still catches drift in cell shape, fidelity + // accounting, and totals. + { + name: 'compare', + opts: { + models: ['claude-sonnet-4-5', 'claude-opus-4-7'], + minFidelity: 'partial', + }, + }, + // ingest is exercised separately because both impls mutate the ledger; + // we run it last in its own block. +]; + +for (const { name, opts } of VERBS) { + test(`conformance: ${name}() matches TS 1.x`, async (t) => { + if (!NAPI_READY) { + t.skip('napi-rs binding not built — set RELAYBURN_SDK_NAPI_BUILT=1 once #247-a lands'); + return; + } + const out = await callBoth(name, opts); + if (out.skipped) { + t.skip('napi-rs binding load failed — #247-a binding artifact missing'); + return; + } + assert.deepStrictEqual(out.napiResult, out.tsResult); + }); +} + +test('conformance: ingest() matches TS 1.x', async (t) => { + if (!NAPI_READY) { + t.skip('napi-rs binding not built — set RELAYBURN_SDK_NAPI_BUILT=1 once #247-a lands'); + return; + } + // ingest mutates the ledger, so we deep-equal the *returned report*. + // Once the napi binding is wired up we may also want to compare a + // follow-up summary() across both homes to confirm the two + // implementations wrote the same rows; that's tracked separately. + const out = await callBoth('ingest', {}); + if (out.skipped) { + t.skip('napi-rs binding load failed — #247-a binding artifact missing'); + return; + } + assert.deepStrictEqual(out.napiResult, out.tsResult); +}); diff --git a/packages/sdk-node/test/esbuild-smoke.test.js b/packages/sdk-node/test/esbuild-smoke.test.js new file mode 100644 index 00000000..dd76fce0 --- /dev/null +++ b/packages/sdk-node/test/esbuild-smoke.test.js @@ -0,0 +1,96 @@ +// esbuild bundle smoke test — confirms the umbrella's TS facade bundles +// cleanly when an embedder pulls `@relayburn/sdk` into a downstream build. +// +// Strategy: +// 1. Write a tiny fixture script that imports the umbrella (named +// exports — `summary`, `ingest`, etc.) and references each verb. +// 2. Run esbuild with the same options a real consumer would (Node ESM +// target, externals for native + Node builtins). +// 3. Assert exit code 0 and no error output. We do *not* execute the +// bundle — that requires a real native binding (#247-a). The success +// condition here is "esbuild can resolve and bundle the facade" +// since that's where 99 % of bundling regressions surface. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SDK_NODE_ROOT = resolve(__dirname, '..'); + +const FIXTURE_SOURCE = ` +import { + Ledger, + ingest, + summary, + sessionCost, + overhead, + overheadTrim, + hotspots, + compare, +} from '@relayburn/sdk'; + +// Reference each export so esbuild can't tree-shake them away — keeps the +// smoke test honest about what's actually reachable through the facade. +export const refs = { + Ledger, + ingest, + summary, + sessionCost, + overhead, + overheadTrim, + hotspots, + compare, +}; +`; + +test('esbuild bundles the @relayburn/sdk umbrella facade cleanly', async (t) => { + // Prefer the locally hoisted esbuild (devDep on this package) so the test + // works without a global install. Skip if it's not yet on disk — the + // workspace install during CI puts it there. + let esbuild; + try { + esbuild = await import('esbuild'); + } catch (_) { + t.skip('esbuild not installed — run `pnpm install` first'); + return; + } + + const work = mkdtempSync(join(tmpdir(), 'relayburn-sdk-bundle-')); + try { + const fixturePath = join(work, 'entry.js'); + writeFileSync(fixturePath, FIXTURE_SOURCE); + + const outFile = join(work, 'bundle.mjs'); + const result = await esbuild.build({ + entryPoints: [fixturePath], + bundle: true, + format: 'esm', + platform: 'node', + target: 'node22', + outfile: outFile, + // The native .node addon and Node builtins should stay external — that + // mirrors what a downstream embedder's bundler config would do. + external: [ + '@relayburn/sdk-darwin-arm64', + '@relayburn/sdk-darwin-x64', + '@relayburn/sdk-linux-arm64-gnu', + '@relayburn/sdk-linux-x64-gnu', + ], + // Resolve `@relayburn/sdk` to the in-tree umbrella, bypassing the + // possible 1.x SDK in workspace `node_modules`. + alias: { + '@relayburn/sdk': resolve(SDK_NODE_ROOT, 'src/index.js'), + }, + logLevel: 'silent', + }); + + assert.equal(result.errors.length, 0, `esbuild errors: ${JSON.stringify(result.errors)}`); + assert.equal(result.warnings.length, 0, `esbuild warnings: ${JSON.stringify(result.warnings)}`); + } finally { + rmSync(work, { recursive: true, force: true }); + } +}); diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 924b55f4..3a7c5b0f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,11 @@ packages: - packages/* + # Exclude `packages/sdk-node` until the 2.0 cutover (#249). It declares + # `@relayburn/sdk` at version `2.0.0-pre.x`, which pnpm would prefer + # over the 1.x package at `packages/sdk` when resolving `workspace:*` + # dependencies in `cli` / `mcp` — flipping every consumer onto the + # not-yet-built napi-rs umbrella mid-port. Keeping it out of the + # workspace means the 1.x SDK stays the source of truth for in-repo + # consumers; the napi-rs CI matrix and conformance test scaffolding + # operate on `packages/sdk-node` directly via path. + - '!packages/sdk-node'