diff --git a/crates/app/src/acp/acpx.rs b/crates/app/src/acp/acpx.rs index ca6443555..3d82e1d36 100644 --- a/crates/app/src/acp/acpx.rs +++ b/crates/app/src/acp/acpx.rs @@ -2137,18 +2137,6 @@ exit 0 #[test] fn build_mcp_proxy_agent_command_preserves_server_cwd() { - fn decode_quoted_command_part(value: &str) -> String { - let trimmed = value.trim(); - let quoted = trimmed.starts_with('"') && trimmed.ends_with('"') && trimmed.len() >= 2; - if !quoted { - return trimmed.to_owned(); - } - - let inner = &trimmed[1..trimmed.len() - 1]; - let unescaped_backslashes = inner.replace("\\\\", "\\"); - unescaped_backslashes.replace("\\\"", "\"") - } - let server = AcpxMcpServerEntry { name: "docs".to_owned(), command: "uvx".to_owned(), @@ -2164,9 +2152,9 @@ exit 0 .expect("proxy command"); let payload_marker = "--payload-file "; let payload_index = command.find(payload_marker).expect("payload marker"); - let payload_path = &command[payload_index + payload_marker.len()..]; - let payload_path = decode_quoted_command_part(payload_path); - let payload_bytes = std::fs::read(payload_path).expect("read payload file"); + let payload_argument = &command[payload_index + payload_marker.len()..]; + let payload_path = decode_test_command_argument(payload_argument); + let payload_bytes = std::fs::read(&payload_path).expect("read payload file"); let payload: Value = serde_json::from_slice(&payload_bytes).expect("parse payload"); assert_eq!( @@ -2175,6 +2163,29 @@ exit 0 ); } + fn decode_test_command_argument(argument: &str) -> String { + let trimmed_argument = argument.trim(); + let is_quoted = trimmed_argument.starts_with('"') && trimmed_argument.ends_with('"'); + if !is_quoted { + return trimmed_argument.to_owned(); + } + + let quoted_argument = &trimmed_argument[1..trimmed_argument.len() - 1]; + let mut decoded = String::new(); + let mut chars = quoted_argument.chars(); + while let Some(character) = chars.next() { + if character == '\\' { + match chars.next() { + Some(escaped_character) => decoded.push(escaped_character), + None => decoded.push('\\'), + } + continue; + } + decoded.push(character); + } + decoded + } + #[tokio::test] #[cfg(unix)] #[allow(clippy::await_holding_lock)] diff --git a/crates/app/src/conversation/tests.rs b/crates/app/src/conversation/tests.rs index d91391a4a..9596a4ddb 100644 --- a/crates/app/src/conversation/tests.rs +++ b/crates/app/src/conversation/tests.rs @@ -16,7 +16,7 @@ use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use super::super::config::{ - AutonomyProfile, LoongClawConfig, MemoryProfile, MemorySystemKind, ProviderConfig, + AuditMode, AutonomyProfile, LoongClawConfig, MemoryProfile, MemorySystemKind, ProviderConfig, }; use super::persistence::format_provider_error_reply; use super::runtime::DefaultConversationRuntime; @@ -1662,10 +1662,12 @@ impl ConversationRuntime for FakeRuntime { } fn test_config() -> LoongClawConfig { - LoongClawConfig { + let mut config = LoongClawConfig { provider: ProviderConfig::default(), ..LoongClawConfig::default() - } + }; + config.audit.mode = AuditMode::InMemory; + config } fn enable_guided_autonomy(config: &mut LoongClawConfig) { diff --git a/crates/app/src/tools/mod.rs b/crates/app/src/tools/mod.rs index fb21f330a..0dccb474b 100644 --- a/crates/app/src/tools/mod.rs +++ b/crates/app/src/tools/mod.rs @@ -2017,7 +2017,6 @@ mod tests { "web.fetch", "web.search", ]); - assert_eq!(names, expected); } diff --git a/crates/daemon/src/browser_companion_diagnostics.rs b/crates/daemon/src/browser_companion_diagnostics.rs index 5d025a389..6416233d6 100644 --- a/crates/daemon/src/browser_companion_diagnostics.rs +++ b/crates/daemon/src/browser_companion_diagnostics.rs @@ -507,4 +507,12 @@ mod tests { "1.5.0" )); } + + #[test] + fn observed_version_matches_expected_rejects_partial_numeric_matches() { + assert!(!observed_version_matches_expected( + "loongclaw-browser-companion 11.5.0", + "1.5.0" + )); + } } diff --git a/crates/daemon/src/command_kind.rs b/crates/daemon/src/command_kind.rs new file mode 100644 index 000000000..8610a90d4 --- /dev/null +++ b/crates/daemon/src/command_kind.rs @@ -0,0 +1,110 @@ +use crate::Commands; + +impl Commands { + pub fn command_kind_for_logging(&self) -> &'static str { + match self { + Self::Welcome => "welcome", + Self::Demo => "demo", + Self::RunTask { .. } => "run_task", + Self::InvokeConnector { .. } => "invoke_connector", + Self::AuditDemo => "audit_demo", + Self::InitSpec { .. } => "init_spec", + Self::RunSpec { .. } => "run_spec", + Self::BenchmarkProgrammaticPressure { .. } => "benchmark_programmatic_pressure", + Self::BenchmarkProgrammaticPressureLint { .. } => { + "benchmark_programmatic_pressure_lint" + } + Self::BenchmarkWasmCache { .. } => "benchmark_wasm_cache", + Self::BenchmarkMemoryContext { .. } => "benchmark_memory_context", + Self::ValidateConfig { .. } => "validate_config", + Self::Onboard { .. } => "onboard", + Self::Personalize { .. } => "personalize", + Self::Import { .. } => "import", + Self::Migrate { .. } => "migrate", + Self::Doctor { .. } => "doctor", + Self::Audit { .. } => "audit", + Self::Skills { .. } => "skills", + Self::Tasks { .. } => "tasks", + Self::Sessions { .. } => "sessions", + Self::Plugins { .. } => "plugins", + Self::Channels { .. } => "channels", + Self::ListModels { .. } => "list_models", + Self::RuntimeSnapshot { .. } => "runtime_snapshot", + Self::RuntimeRestore { .. } => "runtime_restore", + Self::RuntimeExperiment { .. } => "runtime_experiment", + Self::RuntimeCapability { .. } => "runtime_capability", + Self::ListContextEngines { .. } => "list_context_engines", + Self::ListMemorySystems { .. } => "list_memory_systems", + Self::ListMcpServers { .. } => "list_mcp_servers", + Self::ShowMcpServer { .. } => "show_mcp_server", + Self::ListAcpBackends { .. } => "list_acp_backends", + Self::ListAcpSessions { .. } => "list_acp_sessions", + Self::AcpStatus { .. } => "acp_status", + Self::AcpObservability { .. } => "acp_observability", + Self::AcpEventSummary { .. } => "acp_event_summary", + Self::AcpDispatch { .. } => "acp_dispatch", + Self::AcpDoctor { .. } => "acp_doctor", + Self::ControlPlaneServe { .. } => "control_plane_serve", + Self::Ask { .. } => "ask", + Self::Chat { .. } => "chat", + Self::SafeLaneSummary { .. } => "safe_lane_summary", + Self::SessionSearch { .. } => "session_search", + Self::SessionSearchInspect { .. } => "session_search_inspect", + Self::TrajectoryExport { .. } => "trajectory_export", + Self::TrajectoryInspect { .. } => "trajectory_inspect", + Self::TelegramSend { .. } => "telegram_send", + Self::TelegramServe { .. } => "telegram_serve", + Self::FeishuSend { .. } => "feishu_send", + Self::FeishuServe { .. } => "feishu_serve", + Self::MatrixSend { .. } => "matrix_send", + Self::MatrixServe { .. } => "matrix_serve", + Self::WecomSend { .. } => "wecom_send", + Self::WecomServe { .. } => "wecom_serve", + Self::WhatsappServe { .. } => "whatsapp_serve", + Self::DiscordSend { .. } => "discord_send", + Self::DingtalkSend { .. } => "dingtalk_send", + Self::SlackSend { .. } => "slack_send", + Self::LineSend { .. } => "line_send", + Self::WhatsappSend { .. } => "whatsapp_send", + Self::EmailSend { .. } => "email_send", + Self::WebhookSend { .. } => "webhook_send", + Self::GoogleChatSend { .. } => "google_chat_send", + Self::TeamsSend { .. } => "teams_send", + Self::TlonSend { .. } => "tlon_send", + Self::SignalSend { .. } => "signal_send", + Self::TwitchSend { .. } => "twitch_send", + Self::MattermostSend { .. } => "mattermost_send", + Self::NextcloudTalkSend { .. } => "nextcloud_talk_send", + Self::SynologyChatSend { .. } => "synology_chat_send", + Self::IrcSend { .. } => "irc_send", + Self::ImessageSend { .. } => "imessage_send", + Self::NostrSend { .. } => "nostr_send", + Self::MultiChannelServe { .. } => "multi_channel_serve", + Self::Gateway { .. } => "gateway", + Self::Feishu { .. } => "feishu", + Self::Completions { .. } => "completions", + } + } +} + +#[cfg(test)] +mod tests { + use crate::Commands; + + #[test] + fn command_kind_for_logging_uses_stable_variant_names() { + assert_eq!(Commands::Welcome.command_kind_for_logging(), "welcome"); + assert_eq!(Commands::AuditDemo.command_kind_for_logging(), "audit_demo"); + assert_eq!( + Commands::ValidateConfig { + config: None, + output: None, + locale: "en".to_owned(), + json: false, + fail_on_diagnostics: false, + } + .command_kind_for_logging(), + "validate_config" + ); + } +} diff --git a/crates/daemon/src/lib.rs b/crates/daemon/src/lib.rs index 9b97b3626..d6e2be20d 100644 --- a/crates/daemon/src/lib.rs +++ b/crates/daemon/src/lib.rs @@ -8,9 +8,12 @@ use std::{ collections::{BTreeMap, BTreeSet}, fs, future::Future, + io::Write, path::{Path, PathBuf}, pin::Pin, + process, sync::Arc, + time::{SystemTime, UNIX_EPOCH}, }; use clap::{CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum}; @@ -88,8 +91,7 @@ mod channel_send_cli_tests; mod channel_send_target_kind; mod cli_handoff; mod cli_json; -#[cfg(test)] -mod command_kind_tests; +mod command_kind; pub mod completions_cli; mod control_plane_server; pub mod doctor_cli; @@ -124,6 +126,7 @@ mod provider_route_diagnostics; pub mod runtime_capability_cli; pub mod runtime_experiment_cli; pub mod runtime_restore_cli; +pub mod session_cli; pub mod sessions_cli; pub mod skills_cli; pub mod source_presentation; @@ -131,6 +134,7 @@ pub mod supervisor; mod task_execution; pub mod tasks_cli; mod tlon_cli; +pub mod trajectory_cli; use channel_bridge_render::{ push_channel_surface_managed_plugin_bridge_discovery, @@ -144,10 +148,23 @@ pub use loongclaw_spec::programmatic::{ acquire_programmatic_circuit_slot, record_programmatic_circuit_outcome, }; pub use observability::{debug_variant_name, init_tracing, summarize_error}; +pub use session_cli::{ + SESSION_SEARCH_ARTIFACT_JSON_SCHEMA_VERSION, SessionSearchArtifactDocument, + SessionSearchArtifactResult, SessionSearchArtifactSchema, collect_session_search_artifact, + format_session_search_inspect_text, format_session_search_text, load_session_search_artifact, + run_session_search_cli, run_session_search_inspect_cli, +}; use task_execution::execute_daemon_task_with_supervisor; pub use task_execution::{DaemonTaskExecution, run_demo, run_task_cli}; pub use tlon_cli::TLON_SEND_CLI_SPEC; use tlon_cli::{default_tlon_send_target_kind, parse_tlon_send_target_kind}; +pub use trajectory_cli::{ + TRAJECTORY_EXPORT_ARTIFACT_JSON_SCHEMA_VERSION, TrajectoryExportArtifactDocument, + TrajectoryExportArtifactSchema, TrajectoryExportEvent, TrajectoryExportSessionSummary, + TrajectoryExportTurn, collect_trajectory_export_artifact, format_trajectory_export_text, + format_trajectory_inspect_text, load_trajectory_export_artifact, run_trajectory_export_cli, + run_trajectory_inspect_cli, +}; #[allow( clippy::expect_used, @@ -889,6 +906,48 @@ pub enum Commands { #[arg(long, default_value_t = false)] json: bool, }, + /// Search transcript turns across visible sessions + SessionSearch { + #[arg(long)] + config: Option, + #[arg(long)] + session: Option, + #[arg(long)] + query: String, + #[arg(long, default_value_t = 20)] + limit: usize, + #[arg(long)] + output: Option, + #[arg(long, default_value_t = false)] + include_archived: bool, + #[arg(long, default_value_t = false)] + json: bool, + }, + /// Inspect one exported session-search artifact + SessionSearchInspect { + #[arg(long)] + artifact: String, + #[arg(long, default_value_t = false)] + json: bool, + }, + /// Export one session trajectory artifact with transcript turns and session events + TrajectoryExport { + #[arg(long)] + config: Option, + #[arg(long)] + session: Option, + #[arg(long)] + output: Option, + #[arg(long, default_value_t = false)] + json: bool, + }, + /// Inspect one exported trajectory artifact + TrajectoryInspect { + #[arg(long)] + artifact: String, + #[arg(long, default_value_t = false)] + json: bool, + }, /// Send one Telegram message TelegramSend { #[arg(long)] @@ -1354,89 +1413,6 @@ pub enum Commands { }, } -impl Commands { - pub fn command_kind_for_logging(&self) -> &'static str { - match self { - Self::Welcome => "welcome", - Self::Demo => "demo", - Self::RunTask { .. } => "run_task", - Self::InvokeConnector { .. } => "invoke_connector", - Self::AuditDemo => "audit_demo", - Self::InitSpec { .. } => "init_spec", - Self::RunSpec { .. } => "run_spec", - Self::BenchmarkProgrammaticPressure { .. } => "benchmark_programmatic_pressure", - Self::BenchmarkProgrammaticPressureLint { .. } => { - "benchmark_programmatic_pressure_lint" - } - Self::BenchmarkWasmCache { .. } => "benchmark_wasm_cache", - Self::BenchmarkMemoryContext { .. } => "benchmark_memory_context", - Self::ValidateConfig { .. } => "validate_config", - Self::Onboard { .. } => "onboard", - Self::Personalize { .. } => "personalize", - Self::Import { .. } => "import", - Self::Migrate { .. } => "migrate", - Self::Doctor { .. } => "doctor", - Self::Audit { .. } => "audit", - Self::Skills { .. } => "skills", - Self::Tasks { .. } => "tasks", - Self::Sessions { .. } => "sessions", - Self::Plugins { .. } => "plugins", - Self::Channels { .. } => "channels", - Self::ListModels { .. } => "list_models", - Self::RuntimeSnapshot { .. } => "runtime_snapshot", - Self::RuntimeRestore { .. } => "runtime_restore", - Self::RuntimeExperiment { .. } => "runtime_experiment", - Self::RuntimeCapability { .. } => "runtime_capability", - Self::ListContextEngines { .. } => "list_context_engines", - Self::ListMemorySystems { .. } => "list_memory_systems", - Self::ListMcpServers { .. } => "list_mcp_servers", - Self::ShowMcpServer { .. } => "show_mcp_server", - Self::ListAcpBackends { .. } => "list_acp_backends", - Self::ListAcpSessions { .. } => "list_acp_sessions", - Self::AcpStatus { .. } => "acp_status", - Self::AcpObservability { .. } => "acp_observability", - Self::AcpEventSummary { .. } => "acp_event_summary", - Self::AcpDispatch { .. } => "acp_dispatch", - Self::AcpDoctor { .. } => "acp_doctor", - Self::ControlPlaneServe { .. } => "control_plane_serve", - Self::Ask { .. } => "ask", - Self::Chat { .. } => "chat", - Self::SafeLaneSummary { .. } => "safe_lane_summary", - Self::TelegramSend { .. } => "telegram_send", - Self::TelegramServe { .. } => "telegram_serve", - Self::FeishuSend { .. } => "feishu_send", - Self::FeishuServe { .. } => "feishu_serve", - Self::MatrixSend { .. } => "matrix_send", - Self::MatrixServe { .. } => "matrix_serve", - Self::WecomSend { .. } => "wecom_send", - Self::WecomServe { .. } => "wecom_serve", - Self::DiscordSend { .. } => "discord_send", - Self::DingtalkSend { .. } => "dingtalk_send", - Self::SlackSend { .. } => "slack_send", - Self::LineSend { .. } => "line_send", - Self::WhatsappSend { .. } => "whatsapp_send", - Self::WhatsappServe { .. } => "whatsapp_serve", - Self::EmailSend { .. } => "email_send", - Self::WebhookSend { .. } => "webhook_send", - Self::GoogleChatSend { .. } => "google_chat_send", - Self::TeamsSend { .. } => "teams_send", - Self::TlonSend { .. } => "tlon_send", - Self::SignalSend { .. } => "signal_send", - Self::TwitchSend { .. } => "twitch_send", - Self::MattermostSend { .. } => "mattermost_send", - Self::NextcloudTalkSend { .. } => "nextcloud_talk_send", - Self::SynologyChatSend { .. } => "synology_chat_send", - Self::IrcSend { .. } => "irc_send", - Self::ImessageSend { .. } => "imessage_send", - Self::NostrSend { .. } => "nostr_send", - Self::MultiChannelServe { .. } => "multi_channel_serve", - Self::Gateway { .. } => "gateway", - Self::Feishu { .. } => "feishu", - Self::Completions { .. } => "completions", - } - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] pub enum ValidateConfigOutput { Text, @@ -1657,6 +1633,7 @@ mod first_run_entry_tests { let home = unique_temp_dir(prefix); fs::create_dir_all(&home).expect("create isolated home"); env.set("HOME", &home); + env.remove("LOONGCLAW_HOME"); env.remove("LOONGCLAW_CONFIG_PATH"); (env, home) } @@ -2472,7 +2449,7 @@ pub fn run_runtime_snapshot_cli( let artifact_payload = build_runtime_snapshot_artifact_json_payload(&snapshot, &metadata)?; if let Some(output_path) = output_path { - persist_runtime_snapshot_artifact(output_path, &artifact_payload)?; + persist_json_artifact(output_path, &artifact_payload, "runtime snapshot artifact")?; } if as_json { @@ -3393,26 +3370,69 @@ fn runtime_snapshot_optional_arg(raw: Option<&str>) -> Option { .map(str::to_owned) } -fn persist_runtime_snapshot_artifact(output_path: &str, payload: &Value) -> CliResult<()> { +pub(crate) fn persist_json_artifact( + output_path: &str, + payload: &Value, + artifact_label: &str, +) -> CliResult<()> { let output_path = PathBuf::from(output_path); - if let Some(parent) = output_path.parent() - && !parent.as_os_str().is_empty() - { - fs::create_dir_all(parent).map_err(|error| { - format!( - "create runtime snapshot artifact directory {} failed: {error}", - parent.display() - ) - })?; - } + let parent_path = output_path + .parent() + .filter(|path| !path.as_os_str().is_empty()) + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(".")); + fs::create_dir_all(&parent_path).map_err(|error| { + format!( + "create {artifact_label} directory {} failed: {error}", + parent_path.display() + ) + })?; let encoded = serde_json::to_string_pretty(payload) - .map_err(|error| format!("serialize runtime snapshot artifact failed: {error}"))?; - fs::write(&output_path, encoded).map_err(|error| { + .map_err(|error| format!("serialize {artifact_label} failed: {error}"))?; + let file_name = output_path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("artifact"); + let process_id = process::id(); + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|error| format!("build {artifact_label} temp path failed: {error}"))? + .as_nanos(); + let temp_file_name = format!(".{file_name}.{process_id}.{timestamp}.tmp"); + let temp_path = parent_path.join(temp_file_name); + + let open_result = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&temp_path); + let mut temp_file = open_result.map_err(|error| { format!( - "write runtime snapshot artifact {} failed: {error}", - output_path.display() + "create {artifact_label} temp file {} failed: {error}", + temp_path.display() + ) + })?; + temp_file.write_all(encoded.as_bytes()).map_err(|error| { + format!( + "write {artifact_label} temp file {} failed: {error}", + temp_path.display() ) })?; + temp_file.sync_all().map_err(|error| { + format!( + "sync {artifact_label} temp file {} failed: {error}", + temp_path.display() + ) + })?; + drop(temp_file); + + let rename_result = fs::rename(&temp_path, &output_path); + if let Err(error) = rename_result { + let _ = fs::remove_file(&temp_path); + return Err(format!( + "replace {artifact_label} {} failed: {error}", + output_path.display() + )); + } Ok(()) } diff --git a/crates/daemon/src/main.rs b/crates/daemon/src/main.rs index ed78f36ff..eaee8560b 100644 --- a/crates/daemon/src/main.rs +++ b/crates/daemon/src/main.rs @@ -483,6 +483,40 @@ async fn main() { limit, json, } => run_safe_lane_summary_cli(config.as_deref(), session.as_deref(), limit, json), + Commands::SessionSearch { + config, + session, + query, + limit, + output, + include_archived, + json, + } => run_session_search_cli( + config.as_deref(), + session.as_deref(), + &query, + limit, + output.as_deref(), + include_archived, + json, + ), + Commands::SessionSearchInspect { artifact, json } => { + run_session_search_inspect_cli(&artifact, json) + } + Commands::TrajectoryExport { + config, + session, + output, + json, + } => run_trajectory_export_cli( + config.as_deref(), + session.as_deref(), + output.as_deref(), + json, + ), + Commands::TrajectoryInspect { artifact, json } => { + run_trajectory_inspect_cli(&artifact, json) + } Commands::TelegramSend { config, account, diff --git a/crates/daemon/src/session_cli.rs b/crates/daemon/src/session_cli.rs new file mode 100644 index 000000000..788dd9a2a --- /dev/null +++ b/crates/daemon/src/session_cli.rs @@ -0,0 +1,445 @@ +use std::path::PathBuf; + +use kernel::ToolCoreRequest; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use time::{OffsetDateTime, format_description::well_known::Rfc3339}; + +use crate::{CliResult, mvp, persist_json_artifact}; + +pub const SESSION_SEARCH_ARTIFACT_JSON_SCHEMA_VERSION: u32 = 1; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionSearchArtifactSchema { + pub version: u32, + pub surface: String, + pub purpose: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionSearchArtifactResult { + pub session_id: String, + pub label: Option, + pub session_state: String, + pub archived: bool, + pub source: String, + pub source_id: i64, + pub role: Option, + pub event_kind: Option, + pub ts: i64, + pub snippet: String, + pub score: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionSearchArtifactDocument { + pub schema: SessionSearchArtifactSchema, + pub exported_at: String, + pub scope_session_id: String, + pub query: String, + pub limit: usize, + pub include_archived: bool, + pub include_turns: bool, + pub include_events: bool, + pub returned_count: usize, + pub matched_session_count: usize, + pub searched_session_count: usize, + pub results: Vec, +} + +pub fn run_session_search_cli( + config_path: Option<&str>, + session: Option<&str>, + query: &str, + limit: usize, + output_path: Option<&str>, + include_archived: bool, + as_json: bool, +) -> CliResult<()> { + if limit == 0 { + return Err("session-search limit must be >= 1".to_owned()); + } + + let query = query.trim(); + let query_is_empty = query.is_empty(); + if query_is_empty { + return Err("session-search requires a non-empty --query value".to_owned()); + } + + let (resolved_path, artifact) = + collect_session_search_artifact(config_path, session, query, limit, include_archived)?; + + let payload = serde_json::to_value(&artifact) + .map_err(|error| format!("serialize session-search artifact failed: {error}"))?; + + if let Some(output_path) = output_path { + persist_json_artifact(output_path, &payload, "session-search artifact")?; + } + + if as_json { + let pretty = serde_json::to_string_pretty(&payload) + .map_err(|error| format!("serialize session-search output failed: {error}"))?; + println!("{pretty}"); + return Ok(()); + } + + let resolved_config_path = resolved_path.display().to_string(); + let rendered = format_session_search_text(&resolved_config_path, output_path, &artifact); + print!("{rendered}"); + Ok(()) +} + +pub fn collect_session_search_artifact( + config_path: Option<&str>, + session: Option<&str>, + query: &str, + limit: usize, + include_archived: bool, +) -> CliResult<(PathBuf, SessionSearchArtifactDocument)> { + let (resolved_path, config) = mvp::config::load(config_path)?; + + let scope_session_id = session + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("default") + .to_owned(); + + let memory_config = + mvp::memory::runtime_config::MemoryRuntimeConfig::from_memory_config(&config.memory); + + let request = ToolCoreRequest { + tool_name: "session_search".to_owned(), + payload: serde_json::json!({ + "query": query, + "max_results": limit, + "include_archived": include_archived, + }), + }; + + let payload = mvp::tools::execute_app_tool_with_config( + request, + &scope_session_id, + &memory_config, + &config.tools, + )? + .payload; + + let returned_count = payload + .get("returned_count") + .and_then(Value::as_u64) + .ok_or_else(|| "session-search tool payload is missing `returned_count`".to_owned())? + as usize; + + let matched_session_count = payload + .get("matched_session_count") + .and_then(Value::as_u64) + .ok_or_else(|| { + "session-search tool payload is missing `matched_session_count`".to_owned() + })? as usize; + + let searched_session_count = payload + .get("searched_session_count") + .and_then(Value::as_u64) + .ok_or_else(|| { + "session-search tool payload is missing `searched_session_count`".to_owned() + })? as usize; + + let include_turns = payload + .get("include_turns") + .and_then(Value::as_bool) + .ok_or_else(|| "session-search tool payload is missing `include_turns`".to_owned())?; + + let include_events = payload + .get("include_events") + .and_then(Value::as_bool) + .ok_or_else(|| "session-search tool payload is missing `include_events`".to_owned())?; + + let results_value = payload + .get("results") + .and_then(Value::as_array) + .ok_or_else(|| "session-search tool payload is missing `results`".to_owned())?; + + let results = results_value + .iter() + .map(parse_session_search_result) + .collect::>>()?; + let result_count = results.len(); + if returned_count != result_count { + return Err(format!( + "session-search tool payload returned_count={returned_count} but parsed {result_count} result(s)" + )); + } + + let exported_at = OffsetDateTime::now_utc() + .format(&Rfc3339) + .map_err(|error| format!("format session-search artifact timestamp failed: {error}"))?; + + let artifact = SessionSearchArtifactDocument { + schema: SessionSearchArtifactSchema { + version: SESSION_SEARCH_ARTIFACT_JSON_SCHEMA_VERSION, + surface: "session_search".to_owned(), + purpose: "session_recall_evidence".to_owned(), + }, + exported_at, + scope_session_id, + query: query.to_owned(), + limit, + include_archived, + include_turns, + include_events, + returned_count, + matched_session_count, + searched_session_count, + results, + }; + + Ok((resolved_path, artifact)) +} + +fn parse_session_search_result(value: &Value) -> CliResult { + let session_id = value + .get("session_id") + .and_then(Value::as_str) + .ok_or_else(|| "session-search artifact result is missing `session_id`".to_owned())? + .to_owned(); + + let label = value + .get("label") + .and_then(Value::as_str) + .map(str::to_owned); + + let session_state = value + .get("session_state") + .and_then(Value::as_str) + .ok_or_else(|| "session-search artifact result is missing `session_state`".to_owned())? + .to_owned(); + + let archived = value + .get("archived") + .and_then(Value::as_bool) + .ok_or_else(|| "session-search artifact result is missing `archived`".to_owned())?; + + let source = value + .get("source") + .and_then(Value::as_str) + .ok_or_else(|| "session-search artifact result is missing `source`".to_owned())? + .to_owned(); + validate_session_search_source(source.as_str())?; + + let source_id = value + .get("source_id") + .and_then(Value::as_i64) + .ok_or_else(|| "session-search artifact result is missing `source_id`".to_owned())?; + + let role = value.get("role").and_then(Value::as_str).map(str::to_owned); + + let event_kind = value + .get("event_kind") + .and_then(Value::as_str) + .map(str::to_owned); + + let ts = value + .get("ts") + .and_then(Value::as_i64) + .ok_or_else(|| "session-search artifact result is missing `ts`".to_owned())?; + + let snippet = value + .get("snippet") + .and_then(Value::as_str) + .ok_or_else(|| "session-search artifact result is missing `snippet`".to_owned())? + .to_owned(); + + let score = value + .get("score") + .and_then(Value::as_u64) + .ok_or_else(|| "session-search artifact result is missing `score`".to_owned())? + as u32; + + let result = SessionSearchArtifactResult { + session_id, + label, + session_state, + archived, + source, + source_id, + role, + event_kind, + ts, + snippet, + score, + }; + + Ok(result) +} + +fn validate_session_search_source(source: &str) -> CliResult<()> { + let supported_source = matches!(source, "turn" | "event"); + if supported_source { + return Ok(()); + } + + Err(format!("uses unsupported source {source}")) +} + +pub fn format_session_search_text( + resolved_config_path: &str, + output_path: Option<&str>, + artifact: &SessionSearchArtifactDocument, +) -> String { + let output_label = output_path + .map(str::to_owned) + .unwrap_or_else(|| "(stdout)".to_owned()); + + let mut lines = vec![ + format!("config={resolved_config_path}"), + format!( + "session_search session={} query={} limit={} include_archived={} returned_count={} output={}", + artifact.scope_session_id, + artifact.query, + artifact.limit, + artifact.include_archived, + artifact.returned_count, + output_label + ), + format!( + "matched_session_count={} searched_session_count={} include_turns={} include_events={}", + artifact.matched_session_count, + artifact.searched_session_count, + artifact.include_turns, + artifact.include_events + ), + ]; + + if artifact.results.is_empty() { + lines.push("results: -".to_owned()); + return lines.join("\n") + "\n"; + } + + for result in &artifact.results { + let source_role = result.role.as_deref().unwrap_or("-"); + let source_event_kind = result.event_kind.as_deref().unwrap_or("-"); + let line = format!( + "- session={} source={} role={} event_kind={} score={} snippet={}", + result.session_id, + result.source, + source_role, + source_event_kind, + result.score, + result.snippet + ); + lines.push(line); + } + + lines.join("\n") + "\n" +} + +pub fn run_session_search_inspect_cli(artifact_path: &str, as_json: bool) -> CliResult<()> { + let artifact = load_session_search_artifact(artifact_path)?; + + if as_json { + let pretty_result = serde_json::to_string_pretty(&artifact); + let pretty = pretty_result + .map_err(|error| format!("serialize session-search inspect output failed: {error}"))?; + println!("{pretty}"); + return Ok(()); + } + + let rendered = format_session_search_inspect_text(artifact_path, &artifact); + print!("{rendered}"); + Ok(()) +} + +pub fn load_session_search_artifact( + artifact_path: &str, +) -> CliResult { + let raw_path = PathBuf::from(artifact_path); + let raw = std::fs::read_to_string(&raw_path).map_err(|error| { + format!( + "read session-search artifact {} failed: {error}", + raw_path.display() + ) + })?; + + let decoded = serde_json::from_str::(&raw); + let artifact = decoded.map_err(|error| { + format!( + "decode session-search artifact {} failed: {error}", + raw_path.display() + ) + })?; + + if artifact.schema.version != SESSION_SEARCH_ARTIFACT_JSON_SCHEMA_VERSION { + return Err(format!( + "session-search artifact {} uses unsupported schema version {}; expected {}", + raw_path.display(), + artifact.schema.version, + SESSION_SEARCH_ARTIFACT_JSON_SCHEMA_VERSION + )); + } + + if artifact.schema.surface != "session_search" { + return Err(format!( + "session-search artifact {} uses unsupported schema surface {}", + raw_path.display(), + artifact.schema.surface + )); + } + + let result_count = artifact.results.len(); + if artifact.returned_count != result_count { + return Err(format!( + "session-search artifact {} says returned_count={} but contains {} result(s)", + raw_path.display(), + artifact.returned_count, + result_count + )); + } + + for result in &artifact.results { + validate_session_search_source(result.source.as_str()).map_err(|error| { + format!( + "session-search artifact {} result `{}` {error}", + raw_path.display(), + result.source_id + ) + })?; + } + + Ok(artifact) +} + +pub fn format_session_search_inspect_text( + artifact_path: &str, + artifact: &SessionSearchArtifactDocument, +) -> String { + let first_result = artifact.results.first(); + let first_result_session_id = match first_result { + Some(result) => result.session_id.as_str(), + None => "-", + }; + let first_result_source = match first_result { + Some(result) => result.source.as_str(), + None => "-", + }; + let first_result_role = match first_result { + Some(result) => result.role.as_deref().unwrap_or("-"), + None => "-", + }; + + let lines = [ + format!("schema.version={}", artifact.schema.version), + format!("artifact={artifact_path}"), + format!("scope_session_id={}", artifact.scope_session_id), + format!("query={}", artifact.query), + format!("returned_count={}", artifact.returned_count), + format!("matched_session_count={}", artifact.matched_session_count), + format!("searched_session_count={}", artifact.searched_session_count), + format!("include_turns={}", artifact.include_turns), + format!("include_events={}", artifact.include_events), + format!("first_result_session_id={first_result_session_id}"), + format!("first_result_source={first_result_source}"), + format!("first_result_role={first_result_role}"), + ]; + let rendered = lines.join("\n"); + rendered + "\n" +} diff --git a/crates/daemon/src/trajectory_cli.rs b/crates/daemon/src/trajectory_cli.rs new file mode 100644 index 000000000..1f29b7b42 --- /dev/null +++ b/crates/daemon/src/trajectory_cli.rs @@ -0,0 +1,284 @@ +use std::{fs, path::PathBuf}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use time::{OffsetDateTime, format_description::well_known::Rfc3339}; + +use crate::{CliResult, mvp, persist_json_artifact}; + +pub const TRAJECTORY_EXPORT_ARTIFACT_JSON_SCHEMA_VERSION: u32 = 1; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TrajectoryExportArtifactSchema { + pub version: u32, + pub surface: String, + pub purpose: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TrajectoryExportSessionSummary { + pub session_id: String, + pub kind: String, + pub parent_session_id: Option, + pub label: Option, + pub state: String, + pub created_at: i64, + pub updated_at: i64, + pub archived_at: Option, + pub turn_count: usize, + pub last_turn_at: Option, + pub last_error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TrajectoryExportTurn { + pub role: String, + pub content: String, + pub ts: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TrajectoryExportEvent { + pub id: i64, + pub session_id: String, + pub event_kind: String, + pub actor_session_id: Option, + pub payload_json: Value, + pub ts: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TrajectoryExportArtifactDocument { + pub schema: TrajectoryExportArtifactSchema, + pub exported_at: String, + pub session: TrajectoryExportSessionSummary, + pub turns: Vec, + pub events: Vec, +} + +pub fn run_trajectory_export_cli( + config_path: Option<&str>, + session: Option<&str>, + output_path: Option<&str>, + as_json: bool, +) -> CliResult<()> { + let (resolved_path, artifact) = collect_trajectory_export_artifact(config_path, session)?; + let payload = serde_json::to_value(&artifact) + .map_err(|error| format!("serialize trajectory export artifact failed: {error}"))?; + + if let Some(output_path) = output_path { + persist_json_artifact(output_path, &payload, "trajectory export artifact")?; + } + + if as_json { + let pretty = serde_json::to_string_pretty(&payload) + .map_err(|error| format!("serialize trajectory export output failed: {error}"))?; + println!("{pretty}"); + return Ok(()); + } + + let resolved_config_path = resolved_path.display().to_string(); + let rendered = format_trajectory_export_text(&resolved_config_path, output_path, &artifact); + print!("{rendered}"); + Ok(()) +} + +pub fn collect_trajectory_export_artifact( + config_path: Option<&str>, + session: Option<&str>, +) -> CliResult<(PathBuf, TrajectoryExportArtifactDocument)> { + let (resolved_path, config) = mvp::config::load(config_path)?; + let session_id = session + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("default") + .to_owned(); + let memory_config = + mvp::memory::runtime_config::MemoryRuntimeConfig::from_memory_config(&config.memory); + let repo = mvp::session::repository::SessionRepository::new(&memory_config)?; + let session_summary = repo + .load_session_summary_with_legacy_fallback(&session_id)? + .ok_or_else(|| format!("session `{session_id}` not found"))?; + let turn_limit = session_summary.turn_count.max(1); + let turns = mvp::memory::window_direct(&session_id, turn_limit, &memory_config) + .map_err(|error| format!("load trajectory transcript failed: {error}"))?; + let events = collect_trajectory_events(&repo, &session_id)?; + let exported_at = OffsetDateTime::now_utc() + .format(&Rfc3339) + .map_err(|error| format!("format trajectory export timestamp failed: {error}"))?; + + let artifact = TrajectoryExportArtifactDocument { + schema: TrajectoryExportArtifactSchema { + version: TRAJECTORY_EXPORT_ARTIFACT_JSON_SCHEMA_VERSION, + surface: "trajectory_export".to_owned(), + purpose: "session_replay_evidence".to_owned(), + }, + exported_at, + session: TrajectoryExportSessionSummary { + session_id: session_summary.session_id, + kind: session_summary.kind.as_str().to_owned(), + parent_session_id: session_summary.parent_session_id, + label: session_summary.label, + state: session_summary.state.as_str().to_owned(), + created_at: session_summary.created_at, + updated_at: session_summary.updated_at, + archived_at: session_summary.archived_at, + turn_count: session_summary.turn_count, + last_turn_at: session_summary.last_turn_at, + last_error: session_summary.last_error, + }, + turns: turns + .into_iter() + .map(|turn| TrajectoryExportTurn { + role: turn.role, + content: turn.content, + ts: turn.ts, + }) + .collect(), + events: events + .into_iter() + .map(|event| TrajectoryExportEvent { + id: event.id, + session_id: event.session_id, + event_kind: event.event_kind, + actor_session_id: event.actor_session_id, + payload_json: event.payload_json, + ts: event.ts, + }) + .collect(), + }; + + Ok((resolved_path, artifact)) +} + +fn collect_trajectory_events( + repo: &mvp::session::repository::SessionRepository, + session_id: &str, +) -> CliResult> { + let mut after_id = 0i64; + let mut events = Vec::new(); + + loop { + let page = repo.list_events_after(session_id, after_id, 200)?; + let page_is_empty = page.is_empty(); + if page_is_empty { + break; + } + + let next_after_id = page.last().map(|event| event.id).unwrap_or(after_id); + after_id = next_after_id; + events.extend(page); + } + + Ok(events) +} + +pub fn format_trajectory_export_text( + resolved_config_path: &str, + output_path: Option<&str>, + artifact: &TrajectoryExportArtifactDocument, +) -> String { + let output_label = output_path + .map(str::to_owned) + .unwrap_or_else(|| "(stdout)".to_owned()); + + [ + format!("schema.version={}", artifact.schema.version), + format!("config={resolved_config_path}"), + format!("session_id={}", artifact.session.session_id), + format!("state={}", artifact.session.state), + format!("turns={}", artifact.turns.len()), + format!("events={}", artifact.events.len()), + format!("output={output_label}"), + ] + .join("\n") + + "\n" +} + +pub fn run_trajectory_inspect_cli(artifact_path: &str, as_json: bool) -> CliResult<()> { + let artifact = load_trajectory_export_artifact(artifact_path)?; + + if as_json { + let pretty = serde_json::to_string_pretty(&artifact) + .map_err(|error| format!("serialize trajectory inspect output failed: {error}"))?; + println!("{pretty}"); + return Ok(()); + } + + let rendered = format_trajectory_inspect_text(artifact_path, &artifact); + print!("{rendered}"); + Ok(()) +} + +pub fn load_trajectory_export_artifact( + artifact_path: &str, +) -> CliResult { + let raw_path = PathBuf::from(artifact_path); + let raw = fs::read_to_string(&raw_path).map_err(|error| { + format!( + "read trajectory export artifact {} failed: {error}", + raw_path.display() + ) + })?; + let artifact = + serde_json::from_str::(&raw).map_err(|error| { + format!( + "decode trajectory export artifact {} failed: {error}", + raw_path.display() + ) + })?; + + if artifact.schema.version != TRAJECTORY_EXPORT_ARTIFACT_JSON_SCHEMA_VERSION { + return Err(format!( + "trajectory export artifact {} uses unsupported schema version {}; expected {}", + raw_path.display(), + artifact.schema.version, + TRAJECTORY_EXPORT_ARTIFACT_JSON_SCHEMA_VERSION + )); + } + + if artifact.schema.surface != "trajectory_export" { + return Err(format!( + "trajectory export artifact {} uses unsupported schema surface {}", + raw_path.display(), + artifact.schema.surface + )); + } + + Ok(artifact) +} + +pub fn format_trajectory_inspect_text( + artifact_path: &str, + artifact: &TrajectoryExportArtifactDocument, +) -> String { + let first_turn_role = artifact + .turns + .first() + .map(|turn| turn.role.as_str()) + .unwrap_or("-"); + let last_turn_role = artifact + .turns + .last() + .map(|turn| turn.role.as_str()) + .unwrap_or("-"); + let latest_event_kind = artifact + .events + .last() + .map(|event| event.event_kind.as_str()) + .unwrap_or("-"); + + [ + format!("schema.version={}", artifact.schema.version), + format!("artifact={artifact_path}"), + format!("session_id={}", artifact.session.session_id), + format!("state={}", artifact.session.state), + format!("turns={}", artifact.turns.len()), + format!("events={}", artifact.events.len()), + format!("first_turn_role={first_turn_role}"), + format!("last_turn_role={last_turn_role}"), + format!("latest_event_kind={latest_event_kind}"), + ] + .join("\n") + + "\n" +} diff --git a/crates/daemon/tests/integration/cli_tests.rs b/crates/daemon/tests/integration/cli_tests.rs index ad99ae80e..d1547d1e5 100644 --- a/crates/daemon/tests/integration/cli_tests.rs +++ b/crates/daemon/tests/integration/cli_tests.rs @@ -355,6 +355,385 @@ fn safe_lane_summary_cli_rejects_zero_limit() { assert!(error.contains(">= 1")); } +#[test] +fn session_search_cli_rejects_zero_limit() { + let error = run_session_search_cli( + None, + Some("session-a"), + "deploy freeze", + 0, + None, + false, + false, + ) + .expect_err("zero limit must be rejected"); + assert!(error.contains(">= 1")); +} + +#[test] +fn session_search_cli_parses_flags() { + let cli = try_parse_cli([ + "loongclaw", + "session-search", + "--session", + "root-session", + "--query", + "deploy freeze", + "--limit", + "7", + "--output", + "/tmp/session-search.json", + "--include-archived", + "--json", + ]) + .expect("`session-search` should parse"); + + match cli.command { + Some(Commands::SessionSearch { + config, + session, + query, + limit, + output, + include_archived, + json, + }) => { + assert!(config.is_none()); + assert_eq!(session.as_deref(), Some("root-session")); + assert_eq!(query, "deploy freeze"); + assert_eq!(limit, 7); + assert_eq!(output.as_deref(), Some("/tmp/session-search.json")); + assert!(include_archived); + assert!(json); + } + other => panic!("unexpected command parsed: {other:?}"), + } +} + +#[test] +fn session_search_inspect_cli_parses_flags() { + let cli = try_parse_cli([ + "loongclaw", + "session-search-inspect", + "--artifact", + "/tmp/session-search.json", + "--json", + ]) + .expect("`session-search-inspect` should parse"); + + match cli.command { + Some(Commands::SessionSearchInspect { artifact, json }) => { + assert_eq!(artifact, "/tmp/session-search.json"); + assert!(json); + } + other => panic!("unexpected command parsed: {other:?}"), + } +} + +#[test] +fn format_session_search_text_includes_hit_summary() { + let rendered = format_session_search_text( + "/tmp/loongclaw.toml", + Some("/tmp/session-search.json"), + &SessionSearchArtifactDocument { + schema: SessionSearchArtifactSchema { + version: SESSION_SEARCH_ARTIFACT_JSON_SCHEMA_VERSION, + surface: "session_search".to_owned(), + purpose: "session_recall_evidence".to_owned(), + }, + exported_at: "2026-04-05T00:00:00Z".to_owned(), + scope_session_id: "root-session".to_owned(), + query: "deploy freeze".to_owned(), + limit: 5, + include_archived: false, + include_turns: true, + include_events: true, + returned_count: 1, + matched_session_count: 1, + searched_session_count: 2, + results: vec![SessionSearchArtifactResult { + session_id: "child-session".to_owned(), + label: Some("Child".to_owned()), + session_state: "running".to_owned(), + archived: false, + source: "turn".to_owned(), + source_id: 12, + role: Some("assistant".to_owned()), + event_kind: None, + ts: 123, + snippet: "deploy freeze checklist updated".to_owned(), + score: 140, + }], + }, + ); + + assert!(rendered.contains("session_search session=root-session")); + assert!(rendered.contains("returned_count=1")); + assert!(rendered.contains("output=/tmp/session-search.json")); + assert!(rendered.contains("session=child-session")); + assert!(rendered.contains("source=turn")); + assert!(rendered.contains("role=assistant")); + assert!(rendered.contains("deploy freeze checklist updated")); +} + +#[test] +fn format_session_search_inspect_text_summarizes_first_hit() { + let rendered = format_session_search_inspect_text( + "/tmp/session-search.json", + &SessionSearchArtifactDocument { + schema: SessionSearchArtifactSchema { + version: SESSION_SEARCH_ARTIFACT_JSON_SCHEMA_VERSION, + surface: "session_search".to_owned(), + purpose: "session_recall_evidence".to_owned(), + }, + exported_at: "2026-04-05T00:00:00Z".to_owned(), + scope_session_id: "root-session".to_owned(), + query: "deploy freeze".to_owned(), + limit: 5, + include_archived: false, + include_turns: true, + include_events: true, + returned_count: 1, + matched_session_count: 1, + searched_session_count: 2, + results: vec![SessionSearchArtifactResult { + session_id: "child-session".to_owned(), + label: Some("Child".to_owned()), + session_state: "running".to_owned(), + archived: false, + source: "turn".to_owned(), + source_id: 12, + role: Some("assistant".to_owned()), + event_kind: None, + ts: 123, + snippet: "deploy freeze checklist updated".to_owned(), + score: 140, + }], + }, + ); + + assert!(rendered.contains("artifact=/tmp/session-search.json")); + assert!(rendered.contains("scope_session_id=root-session")); + assert!(rendered.contains("query=deploy freeze")); + assert!(rendered.contains("first_result_session_id=child-session")); + assert!(rendered.contains("first_result_source=turn")); + assert!(rendered.contains("first_result_role=assistant")); +} + +#[test] +fn trajectory_export_cli_parses_flags() { + let cli = try_parse_cli([ + "loongclaw", + "trajectory-export", + "--session", + "root-session", + "--output", + "/tmp/trajectory.json", + "--json", + ]) + .expect("`trajectory-export` should parse"); + + match cli.command { + Some(Commands::TrajectoryExport { + config, + session, + output, + json, + }) => { + assert!(config.is_none()); + assert_eq!(session.as_deref(), Some("root-session")); + assert_eq!(output.as_deref(), Some("/tmp/trajectory.json")); + assert!(json); + } + other => panic!("unexpected command parsed: {other:?}"), + } +} + +#[test] +fn format_trajectory_export_text_summarizes_counts() { + let rendered = format_trajectory_export_text( + "/tmp/loongclaw.toml", + Some("/tmp/trajectory.json"), + &TrajectoryExportArtifactDocument { + schema: TrajectoryExportArtifactSchema { + version: TRAJECTORY_EXPORT_ARTIFACT_JSON_SCHEMA_VERSION, + surface: "trajectory_export".to_owned(), + purpose: "session_replay_evidence".to_owned(), + }, + exported_at: "2026-04-04T00:00:00Z".to_owned(), + session: TrajectoryExportSessionSummary { + session_id: "root-session".to_owned(), + kind: "root".to_owned(), + parent_session_id: None, + label: Some("Root".to_owned()), + state: "completed".to_owned(), + created_at: 1, + updated_at: 2, + archived_at: None, + turn_count: 2, + last_turn_at: Some(2), + last_error: None, + }, + turns: vec![ + TrajectoryExportTurn { + role: "user".to_owned(), + content: "hello".to_owned(), + ts: 1, + }, + TrajectoryExportTurn { + role: "assistant".to_owned(), + content: "world".to_owned(), + ts: 2, + }, + ], + events: vec![TrajectoryExportEvent { + id: 7, + session_id: "root-session".to_owned(), + event_kind: "delegate_started".to_owned(), + actor_session_id: Some("root-session".to_owned()), + payload_json: json!({"mode": "async"}), + ts: 2, + }], + }, + ); + + assert!(rendered.contains("schema.version=1")); + assert!(rendered.contains("session_id=root-session")); + assert!(rendered.contains("turns=2")); + assert!(rendered.contains("events=1")); + assert!(rendered.contains("output=/tmp/trajectory.json")); +} + +#[test] +fn trajectory_inspect_cli_parses_flags() { + let cli = try_parse_cli([ + "loongclaw", + "trajectory-inspect", + "--artifact", + "/tmp/trajectory.json", + "--json", + ]) + .expect("`trajectory-inspect` should parse"); + + match cli.command { + Some(Commands::TrajectoryInspect { artifact, json }) => { + assert_eq!(artifact, "/tmp/trajectory.json"); + assert!(json); + } + other => panic!("unexpected command parsed: {other:?}"), + } +} + +#[test] +fn format_trajectory_inspect_text_summarizes_counts() { + let rendered = format_trajectory_inspect_text( + "/tmp/trajectory.json", + &TrajectoryExportArtifactDocument { + schema: TrajectoryExportArtifactSchema { + version: TRAJECTORY_EXPORT_ARTIFACT_JSON_SCHEMA_VERSION, + surface: "trajectory_export".to_owned(), + purpose: "session_replay_evidence".to_owned(), + }, + exported_at: "2026-04-04T00:00:00Z".to_owned(), + session: TrajectoryExportSessionSummary { + session_id: "root-session".to_owned(), + kind: "root".to_owned(), + parent_session_id: None, + label: Some("Root".to_owned()), + state: "completed".to_owned(), + created_at: 1, + updated_at: 2, + archived_at: None, + turn_count: 2, + last_turn_at: Some(2), + last_error: None, + }, + turns: vec![ + TrajectoryExportTurn { + role: "user".to_owned(), + content: "hello".to_owned(), + ts: 1, + }, + TrajectoryExportTurn { + role: "assistant".to_owned(), + content: "world".to_owned(), + ts: 2, + }, + ], + events: vec![TrajectoryExportEvent { + id: 7, + session_id: "root-session".to_owned(), + event_kind: "delegate_started".to_owned(), + actor_session_id: Some("root-session".to_owned()), + payload_json: json!({"mode": "async"}), + ts: 2, + }], + }, + ); + + assert!(rendered.contains("schema.version=1")); + assert!(rendered.contains("artifact=/tmp/trajectory.json")); + assert!(rendered.contains("session_id=root-session")); + assert!(rendered.contains("turns=2")); + assert!(rendered.contains("events=1")); + assert!(rendered.contains("first_turn_role=user")); + assert!(rendered.contains("last_turn_role=assistant")); + assert!(rendered.contains("latest_event_kind=delegate_started")); +} + +#[test] +fn format_trajectory_inspect_text_summarizes_roles_and_events() { + let rendered = format_trajectory_inspect_text( + "/tmp/trajectory.json", + &TrajectoryExportArtifactDocument { + schema: TrajectoryExportArtifactSchema { + version: TRAJECTORY_EXPORT_ARTIFACT_JSON_SCHEMA_VERSION, + surface: "trajectory_export".to_owned(), + purpose: "session_replay_evidence".to_owned(), + }, + exported_at: "2026-04-04T00:00:00Z".to_owned(), + session: TrajectoryExportSessionSummary { + session_id: "root-session".to_owned(), + kind: "root".to_owned(), + parent_session_id: None, + label: Some("Root".to_owned()), + state: "completed".to_owned(), + created_at: 1, + updated_at: 2, + archived_at: None, + turn_count: 2, + last_turn_at: Some(2), + last_error: None, + }, + turns: vec![ + TrajectoryExportTurn { + role: "user".to_owned(), + content: "hello".to_owned(), + ts: 1, + }, + TrajectoryExportTurn { + role: "assistant".to_owned(), + content: "world".to_owned(), + ts: 2, + }, + ], + events: vec![TrajectoryExportEvent { + id: 7, + session_id: "root-session".to_owned(), + event_kind: "delegate_started".to_owned(), + actor_session_id: Some("root-session".to_owned()), + payload_json: json!({"mode": "async"}), + ts: 2, + }], + }, + ); + + assert!(rendered.contains("artifact=/tmp/trajectory.json")); + assert!(rendered.contains("first_turn_role=user")); + assert!(rendered.contains("last_turn_role=assistant")); + assert!(rendered.contains("latest_event_kind=delegate_started")); +} + #[test] fn onboard_cli_accepts_generic_api_key_flag() { let cli = try_parse_cli([ diff --git a/crates/daemon/tests/integration/mod.rs b/crates/daemon/tests/integration/mod.rs index 33eea42d7..ad3f26483 100644 --- a/crates/daemon/tests/integration/mod.rs +++ b/crates/daemon/tests/integration/mod.rs @@ -134,13 +134,14 @@ mod runtime_capability_cli; mod runtime_experiment_cli; mod runtime_restore_cli; mod runtime_snapshot_cli; +mod session_search_cli; mod sessions_cli; mod skills_cli; mod spec_runtime; mod spec_runtime_bridge; mod tasks_cli; - pub(crate) use managed_bridge_fixtures::*; +mod trajectory_export_cli; #[test] fn cli_uses_loong_program_name() { diff --git a/crates/daemon/tests/integration/session_search_cli.rs b/crates/daemon/tests/integration/session_search_cli.rs new file mode 100644 index 000000000..1f9daf917 --- /dev/null +++ b/crates/daemon/tests/integration/session_search_cli.rs @@ -0,0 +1,246 @@ +#![allow(unsafe_code)] +#![allow( + clippy::disallowed_methods, + clippy::multiple_unsafe_ops_per_block, + clippy::undocumented_unsafe_blocks +)] + +use super::*; +use serde_json::json; +use std::{ + fs, + path::{Path, PathBuf}, + process, + time::{SystemTime, UNIX_EPOCH}, +}; + +fn unique_temp_dir(prefix: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should be after epoch") + .as_nanos(); + let process_id = process::id(); + std::env::temp_dir().join(format!("{prefix}-{process_id}-{nanos}")) +} + +fn isolated_memory_env() -> loongclaw_daemon::test_support::ScopedEnv { + let mut env = loongclaw_daemon::test_support::ScopedEnv::new(); + for key in [ + "LOONGCLAW_CONFIG_PATH", + "LOONGCLAW_FILE_ROOT", + "LOONGCLAW_MEMORY_BACKEND", + "LOONGCLAW_MEMORY_PROFILE", + "LOONGCLAW_MEMORY_SUMMARY_MAX_CHARS", + "LOONGCLAW_SLIDING_WINDOW", + "LOONGCLAW_SQLITE_PATH", + ] { + env.remove(key); + } + env +} + +fn write_session_search_config(root: &Path) -> PathBuf { + fs::create_dir_all(root).expect("create fixture root"); + + let mut config = mvp::config::LoongClawConfig::default(); + config.tools.file_root = Some(root.display().to_string()); + config.memory.sqlite_path = root.join("memory.sqlite3").display().to_string(); + + let config_path = root.join("loongclaw.toml"); + mvp::config::write(Some(config_path.to_string_lossy().as_ref()), &config, true) + .expect("write config fixture"); + config_path +} + +#[test] +fn collect_session_search_artifact_includes_visible_hits() { + let _env = isolated_memory_env(); + let root = unique_temp_dir("loongclaw-session-search-artifact"); + let config_path = write_session_search_config(&root); + let (_, config) = mvp::config::load(Some( + config_path + .to_str() + .expect("config path should be valid utf-8"), + )) + .expect("load config fixture"); + + let memory_config = + mvp::memory::runtime_config::MemoryRuntimeConfig::from_memory_config(&config.memory); + let repo = mvp::session::repository::SessionRepository::new(&memory_config) + .expect("session repository"); + + repo.create_session(mvp::session::repository::NewSessionRecord { + session_id: "root-session".to_owned(), + kind: mvp::session::repository::SessionKind::Root, + parent_session_id: None, + label: Some("Root".to_owned()), + state: mvp::session::repository::SessionState::Ready, + }) + .expect("create root session"); + repo.create_session(mvp::session::repository::NewSessionRecord { + session_id: "child-session".to_owned(), + kind: mvp::session::repository::SessionKind::DelegateChild, + parent_session_id: Some("root-session".to_owned()), + label: Some("Child".to_owned()), + state: mvp::session::repository::SessionState::Running, + }) + .expect("create child session"); + repo.create_session(mvp::session::repository::NewSessionRecord { + session_id: "other-session".to_owned(), + kind: mvp::session::repository::SessionKind::Root, + parent_session_id: None, + label: Some("Other".to_owned()), + state: mvp::session::repository::SessionState::Ready, + }) + .expect("create other session"); + + mvp::memory::append_turn_direct( + "root-session", + "user", + "deploy freeze starts Friday", + &memory_config, + ) + .expect("append root turn"); + mvp::memory::append_turn_direct( + "child-session", + "assistant", + "deploy freeze checklist updated", + &memory_config, + ) + .expect("append child turn"); + mvp::memory::append_turn_direct( + "other-session", + "user", + "deploy freeze hidden", + &memory_config, + ) + .expect("append hidden turn"); + + let (_resolved_path, artifact) = collect_session_search_artifact( + Some(config_path.to_string_lossy().as_ref()), + Some("root-session"), + "deploy freeze", + 10, + false, + ) + .expect("collect session-search artifact"); + + assert_eq!( + artifact.schema.version, + SESSION_SEARCH_ARTIFACT_JSON_SCHEMA_VERSION + ); + assert_eq!(artifact.scope_session_id, "root-session"); + assert_eq!(artifact.query, "deploy freeze"); + assert_eq!(artifact.returned_count, 2); + assert_eq!(artifact.matched_session_count, 2); + assert_eq!(artifact.results.len(), 2); + assert_eq!(artifact.results[0].session_id, "child-session"); + assert_eq!(artifact.results[1].session_id, "root-session"); +} + +#[test] +fn load_session_search_artifact_round_trips_written_json() { + let _env = isolated_memory_env(); + let root = unique_temp_dir("loongclaw-session-search-inspect"); + let config_path = write_session_search_config(&root); + let config_path_str = config_path + .to_str() + .expect("config path should be valid utf-8"); + let loaded_config = mvp::config::load(Some(config_path_str)); + let (_, config) = loaded_config.expect("load config fixture"); + + let memory_config = + mvp::memory::runtime_config::MemoryRuntimeConfig::from_memory_config(&config.memory); + let repo = mvp::session::repository::SessionRepository::new(&memory_config) + .expect("session repository"); + + repo.create_session(mvp::session::repository::NewSessionRecord { + session_id: "root-session".to_owned(), + kind: mvp::session::repository::SessionKind::Root, + parent_session_id: None, + label: Some("Root".to_owned()), + state: mvp::session::repository::SessionState::Ready, + }) + .expect("create root session"); + mvp::memory::append_turn_direct( + "root-session", + "user", + "deploy freeze starts Friday", + &memory_config, + ) + .expect("append root turn"); + + let artifact_path = root.join("artifacts").join("session-search.json"); + let artifact_path_str = artifact_path + .to_str() + .expect("artifact path should be valid utf-8"); + run_session_search_cli( + Some(config_path_str), + Some("root-session"), + "deploy freeze", + 5, + Some(artifact_path_str), + false, + false, + ) + .expect("run session-search cli"); + + let loaded_artifact = load_session_search_artifact(artifact_path_str); + let loaded = loaded_artifact.expect("load session-search artifact"); + + assert_eq!(loaded.scope_session_id, "root-session"); + assert_eq!(loaded.returned_count, 1); + assert_eq!(loaded.results.len(), 1); +} + +#[test] +fn load_session_search_artifact_rejects_inconsistent_counts() { + let _env = isolated_memory_env(); + let root = unique_temp_dir("loongclaw-session-search-invalid-counts"); + fs::create_dir_all(&root).expect("create fixture root"); + let artifact_path = root.join("session-search.json"); + fs::write( + &artifact_path, + serde_json::to_string_pretty(&json!({ + "schema": { + "version": SESSION_SEARCH_ARTIFACT_JSON_SCHEMA_VERSION, + "surface": "session_search", + "purpose": "session_recall_evidence" + }, + "exported_at": "2026-04-05T00:00:00Z", + "scope_session_id": "root-session", + "query": "deploy freeze", + "limit": 5, + "include_archived": false, + "include_turns": true, + "include_events": true, + "returned_count": 2, + "matched_session_count": 1, + "searched_session_count": 2, + "results": [{ + "session_id": "child-session", + "label": "Child", + "session_state": "running", + "archived": false, + "source": "turn", + "source_id": 12, + "role": "assistant", + "event_kind": null, + "ts": 123, + "snippet": "deploy freeze checklist updated", + "score": 140 + }] + })) + .expect("encode invalid artifact"), + ) + .expect("write invalid artifact"); + + let artifact_path_str = artifact_path + .to_str() + .expect("artifact path should be valid utf-8"); + let error = load_session_search_artifact(artifact_path_str) + .expect_err("inconsistent returned_count should fail"); + + assert!(error.contains("returned_count=2")); + assert!(error.contains("contains 1 result")); +} diff --git a/crates/daemon/tests/integration/trajectory_export_cli.rs b/crates/daemon/tests/integration/trajectory_export_cli.rs new file mode 100644 index 000000000..d2cbbca7a --- /dev/null +++ b/crates/daemon/tests/integration/trajectory_export_cli.rs @@ -0,0 +1,196 @@ +#![allow(unsafe_code)] +#![allow( + clippy::disallowed_methods, + clippy::multiple_unsafe_ops_per_block, + clippy::undocumented_unsafe_blocks +)] + +use super::*; +use serde_json::json; +use std::{ + fs, + path::{Path, PathBuf}, + process, + time::{SystemTime, UNIX_EPOCH}, +}; + +fn unique_temp_dir(prefix: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should be after epoch") + .as_nanos(); + let process_id = process::id(); + std::env::temp_dir().join(format!("{prefix}-{process_id}-{nanos}")) +} + +fn isolated_memory_env() -> loongclaw_daemon::test_support::ScopedEnv { + let mut env = loongclaw_daemon::test_support::ScopedEnv::new(); + for key in [ + "LOONGCLAW_CONFIG_PATH", + "LOONGCLAW_FILE_ROOT", + "LOONGCLAW_MEMORY_BACKEND", + "LOONGCLAW_MEMORY_PROFILE", + "LOONGCLAW_MEMORY_SUMMARY_MAX_CHARS", + "LOONGCLAW_SLIDING_WINDOW", + "LOONGCLAW_SQLITE_PATH", + ] { + env.remove(key); + } + env +} + +fn write_trajectory_export_config(root: &Path) -> PathBuf { + fs::create_dir_all(root).expect("create fixture root"); + + let mut config = mvp::config::LoongClawConfig::default(); + config.tools.file_root = Some(root.display().to_string()); + config.memory.sqlite_path = root.join("memory.sqlite3").display().to_string(); + let config_path = root.join("loongclaw.toml"); + mvp::config::write(Some(config_path.to_string_lossy().as_ref()), &config, true) + .expect("write config fixture"); + config_path +} + +#[test] +fn collect_trajectory_export_artifact_includes_turns_and_events() { + let _env = isolated_memory_env(); + let root = unique_temp_dir("loongclaw-trajectory-export"); + let config_path = write_trajectory_export_config(&root); + let (_, config) = mvp::config::load(Some( + config_path + .to_str() + .expect("config path should be valid utf-8"), + )) + .expect("load config fixture"); + let memory_config = + mvp::memory::runtime_config::MemoryRuntimeConfig::from_memory_config(&config.memory); + let repo = mvp::session::repository::SessionRepository::new(&memory_config) + .expect("session repository"); + + repo.create_session(mvp::session::repository::NewSessionRecord { + session_id: "root-session".to_owned(), + kind: mvp::session::repository::SessionKind::Root, + parent_session_id: None, + label: Some("Root".to_owned()), + state: mvp::session::repository::SessionState::Completed, + }) + .expect("create root session"); + mvp::memory::append_turn_direct("root-session", "user", "hello", &memory_config) + .expect("append user turn"); + mvp::memory::append_turn_direct("root-session", "assistant", "world", &memory_config) + .expect("append assistant turn"); + repo.append_event(mvp::session::repository::NewSessionEvent { + session_id: "root-session".to_owned(), + event_kind: "delegate_started".to_owned(), + actor_session_id: Some("root-session".to_owned()), + payload_json: serde_json::json!({"mode": "async"}), + }) + .expect("append session event"); + + let (_resolved_path, artifact) = collect_trajectory_export_artifact( + Some(config_path.to_string_lossy().as_ref()), + Some("root-session"), + ) + .expect("collect trajectory artifact"); + + assert_eq!( + artifact.schema.version, + TRAJECTORY_EXPORT_ARTIFACT_JSON_SCHEMA_VERSION + ); + assert_eq!(artifact.session.session_id, "root-session"); + assert_eq!(artifact.turns.len(), 2); + assert_eq!(artifact.turns[0].role, "user"); + assert_eq!(artifact.turns[1].role, "assistant"); + assert_eq!(artifact.events.len(), 1); + assert_eq!(artifact.events[0].event_kind, "delegate_started"); +} + +#[test] +fn load_trajectory_export_artifact_rejects_wrong_schema_surface() { + let _env = isolated_memory_env(); + let root = unique_temp_dir("loongclaw-trajectory-inspect-invalid"); + fs::create_dir_all(&root).expect("create fixture root"); + let artifact_path = root.join("trajectory.json"); + fs::write( + &artifact_path, + serde_json::to_string_pretty(&json!({ + "schema": { + "version": TRAJECTORY_EXPORT_ARTIFACT_JSON_SCHEMA_VERSION, + "surface": "wrong_surface", + "purpose": "session_replay_evidence" + }, + "exported_at": "2026-04-05T00:00:00Z", + "session": { + "session_id": "root-session", + "kind": "root", + "parent_session_id": null, + "label": "Root", + "state": "completed", + "created_at": 1, + "updated_at": 2, + "archived_at": null, + "turn_count": 1, + "last_turn_at": 2, + "last_error": null + }, + "turns": [], + "events": [] + })) + .expect("encode invalid artifact"), + ) + .expect("write invalid artifact"); + + let error = load_trajectory_export_artifact( + artifact_path + .to_str() + .expect("artifact path should be valid utf-8"), + ) + .expect_err("wrong schema surface should fail"); + + assert!(error.contains("unsupported schema surface")); +} + +#[test] +fn load_trajectory_export_artifact_round_trips_written_json() { + let _env = isolated_memory_env(); + let root = unique_temp_dir("loongclaw-trajectory-inspect"); + let config_path = write_trajectory_export_config(&root); + let (_, config) = mvp::config::load(Some( + config_path + .to_str() + .expect("config path should be valid utf-8"), + )) + .expect("load config fixture"); + let memory_config = + mvp::memory::runtime_config::MemoryRuntimeConfig::from_memory_config(&config.memory); + let repo = mvp::session::repository::SessionRepository::new(&memory_config) + .expect("session repository"); + + repo.create_session(mvp::session::repository::NewSessionRecord { + session_id: "root-session".to_owned(), + kind: mvp::session::repository::SessionKind::Root, + parent_session_id: None, + label: Some("Root".to_owned()), + state: mvp::session::repository::SessionState::Completed, + }) + .expect("create root session"); + mvp::memory::append_turn_direct("root-session", "user", "hello", &memory_config) + .expect("append user turn"); + + let artifact_path = root.join("artifacts").join("trajectory.json"); + let artifact_path_str = artifact_path + .to_str() + .expect("artifact path should be valid utf-8"); + run_trajectory_export_cli( + Some(config_path.to_string_lossy().as_ref()), + Some("root-session"), + Some(artifact_path_str), + false, + ) + .expect("run trajectory export cli"); + + let loaded = + load_trajectory_export_artifact(artifact_path_str).expect("load trajectory artifact"); + assert_eq!(loaded.session.session_id, "root-session"); + assert_eq!(loaded.turns.len(), 1); +} diff --git a/docs/releases/architecture-drift-2026-04.md b/docs/releases/architecture-drift-2026-04.md index 9dc681ac7..865845a6d 100644 --- a/docs/releases/architecture-drift-2026-04.md +++ b/docs/releases/architecture-drift-2026-04.md @@ -1,7 +1,7 @@ # Architecture Drift Report 2026-04 ## Summary -- Generated at: 2026-04-05T18:29:16Z +- Generated at: 2026-04-06T08:54:53Z - Report month: `2026-04` - Baseline report: docs/releases/architecture-drift-2026-03.md - Hotspots tracked: 14 @@ -17,19 +17,19 @@ | provider_mod | `foundation` | `crates/app/src/provider/mod.rs` | 376 | 1000 | 624 | 10 | 20 | 10 | 50.0% | HEALTHY | 375 | 0.3% | PASS | 10 | | memory_mod | `foundation` | `crates/app/src/memory/mod.rs` | 355 | 650 | 295 | 14 | 16 | 2 | 87.5% | WATCH | 356 | -0.3% | PASS | 14 | | acp_manager | `operational_density` | `crates/app/src/acp/manager.rs` | 3391 | 3600 | 209 | 8 | 12 | 4 | 94.2% | WATCH | 3383 | 0.2% | PASS | 8 | -| acpx_runtime | `operational_density` | `crates/app/src/acp/acpx.rs` | 2784 | 2800 | 16 | 48 | 65 | 17 | 99.4% | TIGHT | 2698 | 3.2% | PASS | 56 | +| acpx_runtime | `operational_density` | `crates/app/src/acp/acpx.rs` | 2795 | 2800 | 5 | 48 | 65 | 17 | 99.8% | TIGHT | 2698 | 3.6% | PASS | 56 | | channel_registry | `structural_size` | `crates/app/src/channel/registry.rs` | 10222 | 10500 | 278 | 72 | 90 | 18 | 97.4% | TIGHT | 9922 | 3.0% | PASS | 88 | | channel_config | `structural_size` | `crates/app/src/config/channels.rs` | 9716 | 9800 | 84 | 90 | 90 | 0 | 100.0% | TIGHT | 9796 | -0.8% | PASS | 90 | | chat_runtime | `structural_size,operational_density` | `crates/app/src/chat.rs` | 6976 | 7300 | 324 | 147 | 160 | 13 | 95.6% | TIGHT | 6936 | 0.6% | PASS | 146 | | channel_mod | `structural_size,operational_density` | `crates/app/src/channel/mod.rs` | 1785 | 6400 | 4615 | 0 | 110 | 110 | 27.9% | HEALTHY | 1779 | 0.3% | PASS | 0 | | turn_coordinator | `structural_size,operational_density` | `crates/app/src/conversation/turn_coordinator.rs` | 10833 | 11200 | 367 | 97 | 120 | 23 | 96.7% | TIGHT | 10831 | 0.0% | PASS | 98 | -| tools_mod | `structural_size` | `crates/app/src/tools/mod.rs` | 14858 | 15000 | 142 | 53 | 70 | 17 | 99.1% | TIGHT | 14472 | 2.7% | PASS | 54 | -| daemon_lib | `structural_size` | `crates/daemon/src/lib.rs` | 6444 | 6500 | 56 | 206 | 210 | 4 | 99.1% | TIGHT | 6324 | 1.9% | PASS | 210 | +| tools_mod | `structural_size` | `crates/app/src/tools/mod.rs` | 14857 | 15000 | 143 | 53 | 70 | 17 | 99.0% | TIGHT | 14472 | 2.7% | PASS | 54 | +| daemon_lib | `structural_size` | `crates/daemon/src/lib.rs` | 6464 | 6500 | 36 | 205 | 210 | 5 | 99.4% | TIGHT | 6324 | 2.2% | PASS | 210 | | onboard_cli | `structural_size` | `crates/daemon/src/onboard_cli.rs` | 9723 | 9800 | 77 | 235 | 250 | 15 | 99.2% | TIGHT | 9519 | 2.1% | PASS | 228 | ## Prioritization Signals - BREACH hotspots (>100% of any tracked budget): none -- TIGHT hotspots (>=95% of any tracked budget): spec_runtime (100.0%), spec_execution (96.6%), acpx_runtime (99.4%), channel_registry (97.4%), channel_config (100.0%), chat_runtime (95.6%), turn_coordinator (96.7%), tools_mod (99.1%), daemon_lib (99.1%), onboard_cli (99.2%) +- TIGHT hotspots (>=95% of any tracked budget): spec_runtime (100.0%), spec_execution (96.6%), acpx_runtime (99.8%), channel_registry (97.4%), channel_config (100.0%), chat_runtime (95.6%), turn_coordinator (96.7%), tools_mod (99.0%), daemon_lib (99.4%), onboard_cli (99.2%) - WATCH hotspots (>=85% and <95% of any tracked budget): memory_mod (87.5%), acp_manager (94.2%) - Mixed-class hotspots (size plus operational density): chat_runtime, channel_mod, turn_coordinator @@ -63,14 +63,14 @@ - + - - + +