From 19e08b1ccfe7d04eb81d2ac3197e030b3af68b33 Mon Sep 17 00:00:00 2001 From: KooshaPari Date: Sat, 29 Aug 2026 17:25:20 -0700 Subject: [PATCH 1/5] test(sl-viewer): correct timeline max-width property --- crates/sl-viewer/tests/properties_viewer_timeline.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/sl-viewer/tests/properties_viewer_timeline.rs b/crates/sl-viewer/tests/properties_viewer_timeline.rs index 25c755c2..92c64073 100644 --- a/crates/sl-viewer/tests/properties_viewer_timeline.rs +++ b/crates/sl-viewer/tests/properties_viewer_timeline.rs @@ -244,7 +244,7 @@ proptest! { /// `MAX_PX` (the bar scale is anchored at the maximum). #[test] fn normalize_widths_max_token_renders_max_px( - // First entry: heavy. Rest: light. + // First entry has an arbitrary count. Rest may be heavier. heavy_tokens in 1u64..1_000_000, light_tokens in 0u64..1000, rest in 0usize..6, @@ -275,7 +275,12 @@ proptest! { }); } let widths = normalize_widths(&entries); - prop_assert_eq!(widths[0], MAX_PX, "the heavy entry must render at MAX_PX"); + let max_tokens = entries.iter().map(|entry| entry.token_count).max().unwrap_or_default(); + for (entry, width) in entries.iter().zip(widths) { + if entry.token_count == max_tokens { + prop_assert_eq!(width, MAX_PX, "a maximum entry must render at MAX_PX"); + } + } } } From 3cedeecd58d169bb62357831f0aa26edf6136905 Mon Sep 17 00:00:00 2001 From: KooshaPari Date: Sat, 29 Aug 2026 21:36:36 -0700 Subject: [PATCH 2/5] feat(sl-daemon): project graph bundle metadata --- crates/sl-daemon/src/export.rs | 64 ++++++++++++++++++++++++++++++++-- crates/sl-daemon/src/filter.rs | 1 + 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/crates/sl-daemon/src/export.rs b/crates/sl-daemon/src/export.rs index b2a62e61..bdba3d55 100644 --- a/crates/sl-daemon/src/export.rs +++ b/crates/sl-daemon/src/export.rs @@ -22,6 +22,9 @@ pub struct BundleMeta { pub token_count: u64, #[serde(default)] pub message_count: u64, + /// User-authored turns projected from graph-native intent entities. + #[serde(default)] + pub user_turn_count: u64, #[serde(default)] pub duration_ms: u64, /// Free-form tags array, if present. @@ -57,7 +60,16 @@ impl BundleMeta { let session_id = { let s = get_str("session_id"); if s.is_empty() { - get_str("id") + let id = get_str("id"); + if id.is_empty() { + v.get("source_id") + .or_else(|| v.pointer("/provenance/source_id")) + .and_then(|x| x.as_str()) + .unwrap_or_default() + .to_owned() + } else { + id + } } else { s } @@ -90,6 +102,17 @@ impl BundleMeta { } }; let message_count = get_u64("message_count"); + let user_turn_count = v + .get("entities") + .and_then(|entities| entities.as_array()) + .map(|entities| { + entities + .iter() + .filter(|entity| entity.get("type").and_then(|kind| kind.as_str()) == Some("intent")) + .filter_map(|entity| entity.pointer("/properties/user_turn_count").and_then(|count| count.as_u64())) + .sum() + }) + .unwrap_or_default(); let duration_ms = get_u64("duration_ms"); let tags = v .get("tags") @@ -97,7 +120,16 @@ impl BundleMeta { .map(|arr| arr.iter().filter_map(|t| t.as_str().map(str::to_owned)).collect()) .unwrap_or_default(); - Self { session_id, created_at, model, token_count, message_count, duration_ms, tags } + Self { + session_id, + created_at, + model, + token_count, + message_count, + user_turn_count, + duration_ms, + tags, + } } } @@ -299,6 +331,7 @@ mod tests { model: "claude-3-sonnet".into(), token_count: 1000, message_count: 10, + user_turn_count: 0, duration_ms: 500, tags: vec!["rust".into(), "test".into()], }, @@ -308,6 +341,7 @@ mod tests { model: "claude-3-sonnet".into(), token_count: 2000, message_count: 20, + user_turn_count: 0, duration_ms: 1000, tags: vec![], }, @@ -513,4 +547,30 @@ mod tests { let m = BundleMeta::from_value(&v); assert_eq!(m.token_count, 999); } + + #[test] + fn from_value_projects_graph_okf_identity_and_user_turns() { + let v = serde_json::json!({ + "okf": "1.0", + "source_id": "codex-session-1", + "entities": [ + { + "id": "intent-0", + "type": "intent", + "label": "ship the metadata contract", + "properties": { "user_turn_count": 3 } + } + ], + "relations": [], + "provenance": { "corpus": "codex", "source_id": "codex-session-1" } + }); + + let m = BundleMeta::from_value(&v); + assert_eq!(m.session_id, "codex-session-1"); + assert_eq!(m.user_turn_count, 3); + assert_eq!(m.message_count, 0); + assert!(m.model.is_empty()); + assert!(m.created_at.is_empty()); + assert_eq!(m.token_count, 0); + } } diff --git a/crates/sl-daemon/src/filter.rs b/crates/sl-daemon/src/filter.rs index 5b026ae1..57bbb7d0 100644 --- a/crates/sl-daemon/src/filter.rs +++ b/crates/sl-daemon/src/filter.rs @@ -118,6 +118,7 @@ mod tests { model: model.into(), token_count, message_count: 1, + user_turn_count: 0, duration_ms: 0, tags: tags.iter().map(|s| s.to_string()).collect(), } From 4e0276dc25b0ccbcd7d30377ea592581e12bc512 Mon Sep 17 00:00:00 2001 From: KooshaPari Date: Sat, 29 Aug 2026 21:38:58 -0700 Subject: [PATCH 3/5] feat(sl-daemon): report graph user-turn totals --- crates/sl-daemon/src/metrics.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/crates/sl-daemon/src/metrics.rs b/crates/sl-daemon/src/metrics.rs index 17183f6e..6b96c4d1 100644 --- a/crates/sl-daemon/src/metrics.rs +++ b/crates/sl-daemon/src/metrics.rs @@ -8,6 +8,8 @@ use std::sync::RwLock; use serde::{Deserialize, Serialize}; use tracing::info; +use crate::export::BundleMeta; + /// Prometheus histogram bucket upper bounds in seconds. pub const HTTP_DURATION_BUCKETS: &[f64] = &[0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0]; @@ -240,6 +242,7 @@ fn linux_open_fds() -> Option { pub struct MetricsSummary { pub total_bundles: u64, pub total_tokens: u64, + pub total_user_turns: u64, pub avg_tokens: u64, pub model_counts: HashMap, pub daily_counts: HashMap, @@ -257,6 +260,7 @@ pub fn compute_metrics(out_dir: &Path) -> MetricsSummary { let Ok(content) = std::fs::read_to_string(&path) else { continue }; let Ok(val) = serde_json::from_str::(&content) else { continue }; s.total_bundles += 1; + s.total_user_turns += BundleMeta::from_value(&val).user_turn_count; if let Some(t) = val.get("total_tokens").and_then(|v| v.as_u64()) { s.total_tokens += t; } @@ -358,6 +362,21 @@ mod tests { assert_eq!(m.total_tokens, 0); } + #[test] + fn graph_okf_contributes_user_turns_without_fabricating_tokens() { + let d = tempfile::TempDir::new().unwrap(); + std::fs::write( + d.path().join("graph.okf.json"), + r#"{"okf":"1.0","source_id":"graph-1","entities":[{"id":"intent-0","type":"intent","label":"ship it","properties":{"user_turn_count":4}}],"relations":[],"provenance":{"corpus":"codex","source_id":"graph-1"}}"#, + ) + .unwrap(); + + let m = compute_metrics(d.path()); + assert_eq!(m.total_bundles, 1); + assert_eq!(m.total_user_turns, 4); + assert_eq!(m.total_tokens, 0); + } + #[test] fn normalize_http_route_collapses_replay_paths() { assert_eq!(normalize_http_route("/healthz"), "healthz"); From d314baf51fcd55a2f677014de718b20189c72826 Mon Sep 17 00:00:00 2001 From: KooshaPari Date: Sat, 29 Aug 2026 23:29:42 -0700 Subject: [PATCH 4/5] feat(sl-viewer): show graph user turns in search --- crates/sl-viewer/src/search_view.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/sl-viewer/src/search_view.rs b/crates/sl-viewer/src/search_view.rs index 07c60da2..3c8fb712 100644 --- a/crates/sl-viewer/src/search_view.rs +++ b/crates/sl-viewer/src/search_view.rs @@ -24,6 +24,8 @@ pub struct SearchResult { #[serde(default)] pub message_count: u64, #[serde(default)] + pub user_turn_count: u64, + #[serde(default)] pub duration_ms: u64, #[serde(default)] pub tags: Vec, @@ -468,6 +470,9 @@ pub fn SearchView() -> Element { div { class: "session-goal", "model: {r.model}" } div { class: "session-meta", span { class: "meta-bundles", "{r.token_count} tokens" } + if r.user_turn_count > 0 { + span { class: "session-meta-muted", "{r.user_turn_count} user turns" } + } span { class: "session-meta-muted", "{r.created_at}" } if !tags_display.is_empty() { span { class: "badge badge-ok", "{tags_display}" } From dff01874053c0523a3e711c76c091677a9a2bca4 Mon Sep 17 00:00:00 2001 From: KooshaPari Date: Sat, 29 Aug 2026 23:54:43 -0700 Subject: [PATCH 5/5] fix(sessionledger): complete graph metadata review --- crates/sl-daemon/src/export.rs | 29 ++++++++++++++++--- crates/sl-daemon/src/metrics.rs | 11 ++++++- crates/sl-viewer/src/search_view.rs | 17 ++++++++++- .../tests/properties_viewer_timeline.rs | 1 + 4 files changed, 52 insertions(+), 6 deletions(-) diff --git a/crates/sl-daemon/src/export.rs b/crates/sl-daemon/src/export.rs index bdba3d55..b055ff3f 100644 --- a/crates/sl-daemon/src/export.rs +++ b/crates/sl-daemon/src/export.rs @@ -110,7 +110,7 @@ impl BundleMeta { .iter() .filter(|entity| entity.get("type").and_then(|kind| kind.as_str()) == Some("intent")) .filter_map(|entity| entity.pointer("/properties/user_turn_count").and_then(|count| count.as_u64())) - .sum() + .fold(0u64, |total, count| total.saturating_add(count)) }) .unwrap_or_default(); let duration_ms = get_u64("duration_ms"); @@ -163,7 +163,7 @@ impl std::str::FromStr for ExportFormat { /// commas or quotes are properly quoted per RFC 4180. pub fn render_csv(metas: &[BundleMeta]) -> String { let mut out = String::new(); - out.push_str("session_id,created_at,model,token_count,message_count,duration_ms,tags\n"); + out.push_str("session_id,created_at,model,token_count,message_count,user_turn_count,duration_ms,tags\n"); for m in metas { let tags = m.tags.join(";"); out.push_str(&csv_field(&m.session_id)); @@ -176,6 +176,8 @@ pub fn render_csv(metas: &[BundleMeta]) -> String { out.push(','); out.push_str(&m.message_count.to_string()); out.push(','); + out.push_str(&m.user_turn_count.to_string()); + out.push(','); out.push_str(&m.duration_ms.to_string()); out.push(','); out.push_str(&csv_field(&tags)); @@ -209,6 +211,7 @@ pub fn render_markdown(metas: &[BundleMeta]) -> String { md_row(&mut out, "model", &m.model); md_row(&mut out, "token_count", &m.token_count.to_string()); md_row(&mut out, "message_count", &m.message_count.to_string()); + md_row(&mut out, "user_turn_count", &m.user_turn_count.to_string()); md_row(&mut out, "duration_ms", &m.duration_ms.to_string()); md_row(&mut out, "tags", &m.tags.join(", ")); out.push('\n'); @@ -232,6 +235,7 @@ pub fn render_json(metas: &[BundleMeta]) -> String { "model": m.model, "token_count": m.token_count, "message_count": m.message_count, + "user_turn_count": m.user_turn_count, "duration_ms": m.duration_ms, "tags": m.tags, }) @@ -331,7 +335,7 @@ mod tests { model: "claude-3-sonnet".into(), token_count: 1000, message_count: 10, - user_turn_count: 0, + user_turn_count: 3, duration_ms: 500, tags: vec!["rust".into(), "test".into()], }, @@ -341,7 +345,7 @@ mod tests { model: "claude-3-sonnet".into(), token_count: 2000, message_count: 20, - user_turn_count: 0, + user_turn_count: 1, duration_ms: 1000, tags: vec![], }, @@ -371,6 +375,8 @@ mod tests { assert!(out.contains("claude-3-sonnet")); assert!(out.contains("1000")); assert!(out.contains("rust;test")); + assert!(out.contains("user_turn_count")); + assert!(out.contains(",3,")); } #[test] @@ -426,6 +432,7 @@ mod tests { fn markdown_contains_tags() { let out = render_markdown(&sample_metas()); assert!(out.contains("rust, test")); + assert!(out.contains("| user_turn_count | 3 |")); } #[test] @@ -456,6 +463,8 @@ mod tests { assert!(out.contains("\"session_id\"")); assert!(out.contains("\"token_count\"")); assert!(out.contains("\"tags\"")); + assert!(out.contains("\"user_turn_count\"")); + assert!(out.contains("\"user_turn_count\": 3")); } #[test] @@ -573,4 +582,16 @@ mod tests { assert!(m.created_at.is_empty()); assert_eq!(m.token_count, 0); } + + #[test] + fn from_value_saturates_graph_user_turn_counts() { + let v = serde_json::json!({ + "entities": [ + { "type": "intent", "properties": { "user_turn_count": u64::MAX } }, + { "type": "intent", "properties": { "user_turn_count": 1 } } + ] + }); + + assert_eq!(BundleMeta::from_value(&v).user_turn_count, u64::MAX); + } } diff --git a/crates/sl-daemon/src/metrics.rs b/crates/sl-daemon/src/metrics.rs index 6b96c4d1..4e48e563 100644 --- a/crates/sl-daemon/src/metrics.rs +++ b/crates/sl-daemon/src/metrics.rs @@ -242,6 +242,7 @@ fn linux_open_fds() -> Option { pub struct MetricsSummary { pub total_bundles: u64, pub total_tokens: u64, + #[serde(default)] pub total_user_turns: u64, pub avg_tokens: u64, pub model_counts: HashMap, @@ -260,7 +261,7 @@ pub fn compute_metrics(out_dir: &Path) -> MetricsSummary { let Ok(content) = std::fs::read_to_string(&path) else { continue }; let Ok(val) = serde_json::from_str::(&content) else { continue }; s.total_bundles += 1; - s.total_user_turns += BundleMeta::from_value(&val).user_turn_count; + s.total_user_turns = s.total_user_turns.saturating_add(BundleMeta::from_value(&val).user_turn_count); if let Some(t) = val.get("total_tokens").and_then(|v| v.as_u64()) { s.total_tokens += t; } @@ -377,6 +378,14 @@ mod tests { assert_eq!(m.total_tokens, 0); } + #[test] + fn metrics_deserializes_legacy_payload_without_user_turns() { + let legacy = r#"{"total_bundles":1,"total_tokens":42,"avg_tokens":42,"model_counts":{},"daily_counts":{}}"#; + + let metrics: MetricsSummary = serde_json::from_str(legacy).expect("legacy metrics payload"); + assert_eq!(metrics.total_user_turns, 0); + } + #[test] fn normalize_http_route_collapses_replay_paths() { assert_eq!(normalize_http_route("/healthz"), "healthz"); diff --git a/crates/sl-viewer/src/search_view.rs b/crates/sl-viewer/src/search_view.rs index 3c8fb712..bde48096 100644 --- a/crates/sl-viewer/src/search_view.rs +++ b/crates/sl-viewer/src/search_view.rs @@ -88,6 +88,14 @@ fn urlencoding(s: &str) -> String { .collect() } +fn user_turn_label(user_turn_count: u64) -> &'static str { + if user_turn_count == 1 { + "user turn" + } else { + "user turns" + } +} + /// Stable id for search fetch errors — paired with `aria-errormessage` on fields. const SEARCH_ERROR_ID: &str = "search-error-message"; @@ -461,6 +469,7 @@ pub fn SearchView() -> Element { let cls = if is_selected { "session-item selected" } else { "session-item" }; let r = result.clone(); let tags_display = r.tags.join(", "); + let user_turn_label = user_turn_label(r.user_turn_count); rsx! { div { key: "{r.session_id}-{idx}", @@ -471,7 +480,7 @@ pub fn SearchView() -> Element { div { class: "session-meta", span { class: "meta-bundles", "{r.token_count} tokens" } if r.user_turn_count > 0 { - span { class: "session-meta-muted", "{r.user_turn_count} user turns" } + span { class: "session-meta-muted", "{r.user_turn_count} {user_turn_label}" } } span { class: "session-meta-muted", "{r.created_at}" } if !tags_display.is_empty() { @@ -614,4 +623,10 @@ mod tests { assert_eq!(advanced_filter_active_count("", "rust", "50"), 1); assert_eq!(advanced_filter_active_count(" ", " ", "50"), 0); } + + #[test] + fn user_turn_label_handles_singular_and_plural() { + assert_eq!(user_turn_label(1), "user turn"); + assert_eq!(user_turn_label(2), "user turns"); + } } diff --git a/crates/sl-viewer/tests/properties_viewer_timeline.rs b/crates/sl-viewer/tests/properties_viewer_timeline.rs index 92c64073..ac3f63be 100644 --- a/crates/sl-viewer/tests/properties_viewer_timeline.rs +++ b/crates/sl-viewer/tests/properties_viewer_timeline.rs @@ -275,6 +275,7 @@ proptest! { }); } let widths = normalize_widths(&entries); + prop_assert_eq!(entries.len(), widths.len()); let max_tokens = entries.iter().map(|entry| entry.token_count).max().unwrap_or_default(); for (entry, width) in entries.iter().zip(widths) { if entry.token_count == max_tokens {