Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ Cross-package release notes for relayburn. Package changelogs contain package-le

## [Unreleased]

- `relayburn-sdk` now exposes SDK-owned summary report/time-series envelopes with schema/version, capability, and normalized window metadata for app and presenter consumers.
- `burn hotspots --findings` ghost-surface results now account for user-text slash-command invocations, so active Claude/Codex commands and prompts are no longer flagged as unused when they do not appear as tool calls.

## [4.0.0] - 2026-06-23

- **BREAKING (`relayburn-sdk`):** the published Rust SDK no longer re-exports its low-level `analyze`-layer internals (detector/aggregator functions and helper types such as `PricingTable`, `CompareTable`, `CompareCell`) — these were never the intended embedding surface. Embed through the verb layer instead: `LedgerHandle` methods / `summary_report` / `hotspots` / `compare`. CLI, MCP, and `@relayburn/sdk` behavior is unchanged.
Expand Down
1 change: 1 addition & 0 deletions crates/relayburn-cli/src/commands/summary/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,7 @@ fn run_inner(globals: &GlobalArgs, args: SummaryArgs) -> anyhow::Result<i32> {
session: args.session,
project: args.project,
since: args.since,
until: None,
workflow: args.workflow,
tags: if tag_filter.is_empty() {
None
Expand Down
131 changes: 131 additions & 0 deletions crates/relayburn-sdk-node/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH};
use napi::bindgen_prelude::{BigInt, Error as NapiError, Result as NapiResult, ToNapiValue};
use napi::sys;
use napi_derive::napi;
use serde::Deserialize;
use serde_json::Value as JsonValue;

use relayburn_sdk as sdk;
Expand Down Expand Up @@ -221,6 +222,27 @@ const BIGINT_FIELDS: &[&str] = &[
"filesWithRecommendations",
"totalRecommendations",
"tokensPerSession",
// SDK-owned summary report envelopes
"bucketSeconds",
"seconds",
"turnCount",
"totalTokens",
"unpricedTurns",
"turns",
"known",
"missing",
"count",
"calls",
"invocations",
"estimatedTokensSaved",
"endTurn",
"maxTokens",
"pauseTurn",
"stopSequence",
"toolUse",
"refusal",
"silent",
"none",
// hotspots aggregations
"callCount",
"distinctCommands",
Expand Down Expand Up @@ -644,6 +666,8 @@ pub struct SummaryOptions {
/// ISO timestamp (e.g. `2026-04-01T00:00:00Z`) or relative range
/// (`24h`, `7d`, `4w`, `2m`).
pub since: Option<String>,
/// Inclusive upper bound. Accepts the same ISO/relative grammar as `since`.
pub until: Option<String>,
pub tags: Option<HashMap<String, String>>,
pub group_by_tag: Option<String>,
pub ledger_home: Option<String>,
Expand Down Expand Up @@ -767,6 +791,7 @@ pub fn summary(opts: Option<SummaryOptions>) -> Result<Summary, BurnError> {
session: None,
project: None,
since: None,
until: None,
tags: None,
group_by_tag: None,
ledger_home: None,
Expand All @@ -775,6 +800,7 @@ pub fn summary(opts: Option<SummaryOptions>) -> Result<Summary, BurnError> {
session: opts.session,
project: opts.project,
since: opts.since,
until: opts.until,
tags: opts
.tags
.map(|tags| tags.into_iter().collect::<BTreeMap<_, _>>()),
Expand All @@ -784,6 +810,111 @@ pub fn summary(opts: Option<SummaryOptions>) -> Result<Summary, BurnError> {
sdk::summary(raw).map(Summary::from).map_err(sdk_err)
}

#[derive(Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SummaryTimeseriesEnvelopeOptions {
#[serde(default)]
bucket_seconds: Option<u64>,
#[serde(default)]
session: Option<String>,
#[serde(default)]
project: Option<String>,
#[serde(default)]
since: Option<String>,
#[serde(default)]
until: Option<String>,
#[serde(default)]
workflow: Option<String>,
#[serde(default)]
tags: Option<BTreeMap<String, String>>,
#[serde(default)]
group_by_tag: Option<String>,
#[serde(default)]
agent: Option<String>,
#[serde(default)]
providers: Option<Vec<String>>,
#[serde(default)]
mode: sdk::SummaryReportMode,
#[serde(default)]
include_quality: bool,
#[serde(default)]
ledger_home: Option<PathBuf>,
}

impl SummaryTimeseriesEnvelopeOptions {
fn into_parts(self) -> Result<(sdk::SummaryReportOptions, u64), BurnError> {
let bucket_seconds = self
.bucket_seconds
.filter(|n| *n > 0)
.ok_or_else(|| invalid_arg("summaryTimeseries requires a positive bucketSeconds"))?;
Ok((
sdk::SummaryReportOptions {
session: self.session,
project: self.project,
since: self.since,
until: self.until,
workflow: self.workflow,
tags: self.tags,
group_by_tag: self.group_by_tag,
agent: self.agent,
providers: self.providers,
mode: self.mode,
include_quality: self.include_quality,
ledger_home: self.ledger_home,
},
bucket_seconds,
))
}
}

fn summary_report_options_from_value(
opts: Option<JsonValue>,
) -> Result<sdk::SummaryReportOptions, BurnError> {
opts.map(serde_json::from_value)
.transpose()
.map_err(|e| invalid_arg(format!("invalid summaryReport options: {e}")))?
.map(Ok)
.unwrap_or_else(|| Ok(sdk::SummaryReportOptions::default()))
}

/// Version/capability handshake for SDK-owned report contracts.
#[napi(ts_return_type = "import('./index').ReportCapabilities")]
pub fn capabilities() -> Result<BigIntPromoting, BurnError> {
let value = serde_json::to_value(sdk::report_capabilities())
.map_err(|e| NapiError::new(SDK_ERROR_CODE, format!("serialize capabilities: {e}")))?;
Ok(BigIntPromoting(value))
}

/// SDK-owned summary report envelope. The result shape is documented as
/// `SummaryReportEnvelope` in the Node facade types.
#[napi(
js_name = "summaryReport",
ts_return_type = "import('./index').SummaryReportEnvelope"
)]
pub fn summary_report(opts: Option<JsonValue>) -> Result<BigIntPromoting, BurnError> {
let raw = summary_report_options_from_value(opts)?;
let result = sdk::summary_report_envelope(raw).map_err(sdk_err)?;
let value = serde_json::to_value(&result)
.map_err(|e| NapiError::new(SDK_ERROR_CODE, format!("serialize summary_report: {e}")))?;
Ok(BigIntPromoting(value))
}

/// SDK-owned bucketed summary time-series envelope.
#[napi(
js_name = "summaryTimeseries",
ts_return_type = "import('./index').SummaryTimeseriesEnvelope"
)]
pub fn summary_timeseries(opts: JsonValue) -> Result<BigIntPromoting, BurnError> {
let parsed: SummaryTimeseriesEnvelopeOptions = serde_json::from_value(opts)
.map_err(|e| invalid_arg(format!("invalid summaryTimeseries options: {e}")))?;
let (raw, bucket_seconds) = parsed.into_parts()?;
let result = sdk::summary_timeseries_envelope(raw, bucket_seconds).map_err(sdk_err)?;
let value = serde_json::to_value(&result).map_err(|e| {
NapiError::new(SDK_ERROR_CODE, format!("serialize summary_timeseries: {e}"))
})?;
Ok(BigIntPromoting(value))
}

// ---------------------------------------------------------------------------
// session_cost
// ---------------------------------------------------------------------------
Expand Down
4 changes: 2 additions & 2 deletions crates/relayburn-sdk/src/analyze/ghost_surface_inputs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
//!
//! The optional `userTurnTextBySession` map (used by Claude /
//! Codex slash-command miners) is sourced from the ledger's content
//! sidecar in the production CLI; loading it lives in the CLI layer
//! rather than here so the analyze crate stays I/O-free.
//! sidecar in the query layer; loading it lives outside this module so the
//! analyze crate stays I/O-free.

use std::collections::{HashMap, HashSet};

Expand Down
11 changes: 9 additions & 2 deletions crates/relayburn-sdk/src/query_verbs/compare.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ impl LedgerHandle {
opts.session.as_deref(),
opts.project.as_deref(),
opts.since.as_deref(),
None,
)?;
let mut enrichment = BTreeMap::new();
if let Some(workflow) = opts.workflow {
Expand Down Expand Up @@ -164,6 +165,7 @@ impl LedgerHandle {
opts.session.as_deref(),
opts.project.as_deref(),
opts.since.as_deref(),
None,
)?;
let mut enrichment = BTreeMap::new();
if let Some(workflow) = opts.workflow {
Expand All @@ -185,8 +187,13 @@ impl LedgerHandle {
}
let pricing = load_pricing(None);

let Some((buckets, per_bucket)) =
super::partition_into_buckets(turns, q.since.as_deref(), bucket_secs, |t| &t.turn.ts)?
let Some((buckets, per_bucket)) = super::partition_into_buckets(
turns,
q.since.as_deref(),
q.until.as_deref(),
bucket_secs,
|t| &t.turn.ts,
)?
else {
return Ok(CompareTimeseries {
bucket_secs,
Expand Down
49 changes: 48 additions & 1 deletion crates/relayburn-sdk/src/query_verbs/hotspots.rs
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ impl LedgerHandle {
opts.session.as_deref(),
opts.project.as_deref(),
opts.since.as_deref(),
None,
)?;
if let Some(workflow) = opts.workflow.as_ref() {
let mut enrichment = q.enrichment.unwrap_or_default();
Expand Down Expand Up @@ -282,6 +283,7 @@ fn run_hotspots_attribution(
let side_q = Query {
session_id: q.session_id.clone(),
since: q.since.clone(),
until: q.until.clone(),
enrichment: q.enrichment.clone(),
..Default::default()
};
Expand Down Expand Up @@ -491,6 +493,7 @@ fn run_hotspots_findings(
let side_q = Query {
session_id: q.session_id.clone(),
since: q.since.clone(),
until: q.until.clone(),
enrichment: q.enrichment.clone(),
..Default::default()
};
Expand Down Expand Up @@ -545,7 +548,7 @@ fn run_hotspots_findings(
}

if wanted_set.contains("ghost-surface") {
let inputs = build_ghost_surface_inputs(turns, pricing, None);
let inputs = build_hotspots_ghost_surface_inputs(handle, turns, pricing, &side_q);
let ghosts = detect_ghost_surface(&inputs);
let options = GhostSurfaceFindingOptions::default();
for g in ghosts {
Expand All @@ -572,6 +575,50 @@ fn run_hotspots_findings(
})
}

pub(super) fn build_hotspots_ghost_surface_inputs(
handle: &LedgerHandle,
turns: &[TurnRecord],
pricing: &PricingTable,
q: &Query,
) -> crate::analyze::ghost_surface::GhostSurfaceInputs {
let user_turn_text_by_session = load_ghost_surface_user_text_by_session(handle, turns, q);
build_ghost_surface_inputs(turns, pricing, Some(user_turn_text_by_session))
}

fn load_ghost_surface_user_text_by_session(
handle: &LedgerHandle,
turns: &[TurnRecord],
q: &Query,
) -> HashMap<SourceKind, HashMap<String, Vec<String>>> {
let session_sources: HashSet<(SourceKind, String)> = turns
.iter()
.map(|turn| (turn.source, turn.session_id.clone()))
.collect();
let mut out: HashMap<SourceKind, HashMap<String, Vec<String>>> = HashMap::new();
for (source, session_id) in session_sources {
let records = match handle.inner.query_content(&Query {
session_id: Some(session_id.clone()),
source: Some(source),
since: q.since.clone(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid filtering prompt text by assistant-turn lower bound

When hotspots/ghost-surface is run with a since window that falls between a user slash-command prompt and the assistant turn it triggered, the turn is included by the outer query but this content lookup drops the preceding user ContentRecord because content_passes applies Query.since to content timestamps. That makes active commands like /openspec-apply still appear as ghost surface in recent-window reports; load user text for the selected sessions without the lower bound, or explicitly include the prompt predecessor for each selected turn.

Useful? React with 👍 / 👎.

until: q.until.clone(),
..Default::default()
}) {
Ok(records) => records,
Err(_) => continue,
};
let texts: Vec<String> = records
.into_iter()
.filter(|record| record.role == ContentRole::User && record.kind == ContentKind::Text)
.filter_map(|record| record.text)
.filter(|text| !text.is_empty())
.collect();
if !texts.is_empty() {
out.entry(source).or_default().insert(session_id, texts);
}
}
out
}

fn fidelity_summary_to_value(s: &FidelitySummary) -> serde_json::Value {
// Mirror the TS shape: { total, byClass, byGranularity, missingCoverage,
// unknown }. The analyze type doesn't derive Serialize so build it here.
Expand Down
Loading
Loading