diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index 302fc84c5..a33229f19 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -115,12 +115,10 @@ steps: command: | scripts/build-public-cli-artifact.sh macos-arm64 agents: - queue: "ctx-mac-gui-shared-arm64" - os: "darwin" - arch: "arm64" - concurrency: 1 - concurrency_group: "ctx-mac-gui-shared-arm64" - concurrency_method: "eager" + queue: "release-linux-managed" + ctx-runner-class: "release-linux-control" + os: "linux" + arch: "x86_64" timeout_in_minutes: 60 artifact_paths: - "target/public-cli-artifacts/ctx-macos-arm64" @@ -133,12 +131,10 @@ steps: command: | scripts/build-public-cli-artifact.sh macos-x64 agents: - queue: "ctx-mac-gui-shared-arm64" - os: "darwin" - arch: "arm64" - concurrency: 1 - concurrency_group: "ctx-mac-gui-shared-arm64" - concurrency_method: "eager" + queue: "release-linux-managed" + ctx-runner-class: "release-linux-control" + os: "linux" + arch: "x86_64" timeout_in_minutes: 60 artifact_paths: - "target/public-cli-artifacts/ctx-macos-x64" diff --git a/Cargo.lock b/Cargo.lock index c8557a3d0..36d52de21 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,15 +29,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - [[package]] name = "anstream" version = "1.0.0" @@ -121,6 +112,21 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "2.13.0" @@ -175,12 +181,8 @@ version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ - "iana-time-zone", - "js-sys", "num-traits", "serde", - "wasm-bindgen", - "windows-link", ] [[package]] @@ -239,12 +241,6 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - [[package]] name = "cpufeatures" version = "0.2.17" @@ -275,7 +271,7 @@ dependencies = [ [[package]] name = "ctx" -version = "0.12.0" +version = "0.19.0" dependencies = [ "anyhow", "assert_cmd", @@ -289,8 +285,10 @@ dependencies = [ "ctx-history-store", "libc", "predicates", + "proptest", "ring", "rusqlite", + "serde", "serde_json", "sha2", "tempfile", @@ -300,7 +298,7 @@ dependencies = [ [[package]] name = "ctx-history-capture" -version = "0.12.0" +version = "0.19.0" dependencies = [ "chrono", "ctx-history-core", @@ -315,7 +313,7 @@ dependencies = [ [[package]] name = "ctx-history-core" -version = "0.12.0" +version = "0.19.0" dependencies = [ "chrono", "directories", @@ -328,7 +326,7 @@ dependencies = [ [[package]] name = "ctx-history-search" -version = "0.12.0" +version = "0.19.0" dependencies = [ "chrono", "ctx-history-core", @@ -343,7 +341,7 @@ dependencies = [ [[package]] name = "ctx-history-store" -version = "0.12.0" +version = "0.19.0" dependencies = [ "chrono", "ctx-history-core", @@ -475,6 +473,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -529,6 +533,18 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -537,7 +553,7 @@ checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", ] [[package]] @@ -564,30 +580,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - [[package]] name = "icu_collections" version = "2.2.0" @@ -834,6 +826,15 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "predicates" version = "3.1.4" @@ -873,6 +874,31 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quote" version = "1.0.46" @@ -882,12 +908,56 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core", +] + [[package]] name = "redox_users" version = "0.4.6" @@ -1016,6 +1086,18 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "serde" version = "1.0.228" @@ -1189,6 +1271,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1280,6 +1368,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.125" @@ -1343,65 +1440,12 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - [[package]] name = "windows-sys" version = "0.48.0" @@ -1550,6 +1594,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "writeable" version = "0.6.3" diff --git a/Cargo.toml b/Cargo.toml index fa7d76624..ae516e886 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,13 +21,14 @@ rust-version = "1.81" anyhow = "1.0" assert_cmd = "2.0" base64 = "0.22" -chrono = { version = "0.4", features = ["serde"] } +chrono = { version = "0.4", default-features = false, features = ["std", "serde"] } clap = { version = "4.5", features = ["derive", "env"] } clap_mangen = "0.2" directories = "5.0" ed25519-dalek = "2.1" libc = "0.2" predicates = "3.1" +proptest = "1" regex = "1.10" ring = "0.17" rusqlite = { version = "0.32", features = ["bundled", "hooks", "limits"] } diff --git a/MODULE.bazel b/MODULE.bazel index 3801ee356..1e4e30683 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -1 +1 @@ -module(name = "ctx_search", version = "0.12.0") +module(name = "ctx_search", version = "0.18.0") diff --git a/README.md b/README.md index f639899b2..206afb2fa 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -ctx is a CLI for searching past agent sessions. +You have months of coding agent history on your machine. Search it with ctx. ctx is an open-source CLI for fast local search across your past coding agent sessions. @@ -87,6 +87,26 @@ ctx upgrade check Source builds and package-manager installs remain unmanaged and do not self-upgrade. +### Building from source + +If you prefer to compile `ctx` locally, please note that the project relies on a bundled SQLite database. This means a C/C++ compiler is strictly required during the build process to compile the underlying C code. + +**Windows Prerequisites:** +Windows does not come with a C compiler by default. If you run `cargo build` without one, you will encounter `link.exe not found` or `gcc.exe not found` errors. + +To fix this, you must install the [Microsoft C++ Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/): +1. Download and run the Visual Studio Build Tools installer. +2. Select the **"Desktop development with C++"** workload. +3. Complete the installation and restart your terminal. + +```mermaid +graph LR + A[cargo build] --> B{C++ Compiler installed?} + B -- Yes --> C[Compiles SQLite C code] + B -- No --> D[Fails: link.exe / gcc.exe missing] + C --> E[Builds ctx binary successfully] +``` + For the full pipeline, see [How ctx works](https://ctx.rs/concepts/how-it-works). For a quick first run, see [Quickstart](https://ctx.rs/first-search). ## Supported agent histories @@ -121,6 +141,7 @@ ctx keeps retrieval tied to sessions and events, so another agent can inspect th | [Install the ctx skill](https://ctx.rs/skill) | Install the agent-history search skill with the open skills installer. | | [Agent plugin installs](docs/agent-skill-install.md) | Install the ctx skill through Codex, Claude Code, Cursor, or a raw skill folder. | | [SDKs](docs/sdks.md) | Use ctx agent history search from TypeScript, Python, Rust, Go, JVM, Swift, or .NET code. | +| [Custom history plugins](docs/history-source-plugins.md) | Build an advanced local adapter for agent formats ctx does not support natively. | | [Cursor](https://ctx.rs/agents/cursor) | Import Cursor agent transcripts and ask Cursor to cite retrieved local history before editing. | | [How it works](https://ctx.rs/concepts/how-it-works) | Understand discovery, import, SQLite storage, search refresh, and cited retrieval. | | [Supported agents](https://ctx.rs/concepts/supported-agents) | See which agent histories ctx can discover, import, and search today. | diff --git a/contracts/agent-history-v1/README.md b/contracts/agent-history-v1/README.md index 34626b1d3..3c8bea823 100644 --- a/contracts/agent-history-v1/README.md +++ b/contracts/agent-history-v1/README.md @@ -70,7 +70,7 @@ them into `agent-history-v1` wrappers: - `ctx setup --json` - `ctx sources --json` - `ctx import --json` -- `ctx search ... --json` +- `ctx search |--term |--file --json` - `ctx show event ... --format json` - `ctx show session ... --format json` - `ctx locate event ... --format json` diff --git a/crates/ctx-cli/Cargo.toml b/crates/ctx-cli/Cargo.toml index ca2fdf9e9..64aba4c2a 100644 --- a/crates/ctx-cli/Cargo.toml +++ b/crates/ctx-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ctx" -version = "0.12.0" +version = "0.19.0" description = "Local CLI for indexing and searching agent session history" edition.workspace = true autobins = false @@ -22,6 +22,7 @@ chrono.workspace = true clap.workspace = true clap_mangen.workspace = true ring.workspace = true +serde.workspace = true serde_json.workspace = true sha2.workspace = true ureq = { version = "2.10", default-features = true } @@ -37,5 +38,6 @@ libc.workspace = true [dev-dependencies] assert_cmd.workspace = true predicates.workspace = true +proptest.workspace = true rusqlite.workspace = true tempfile.workspace = true diff --git a/crates/ctx-cli/src/analytics.rs b/crates/ctx-cli/src/analytics.rs index 642dcbbcc..e63930f52 100644 --- a/crates/ctx-cli/src/analytics.rs +++ b/crates/ctx-cli/src/analytics.rs @@ -1,11 +1,11 @@ use std::{env, path::Path, time::Duration}; use anyhow::Result; -use chrono::Utc; +use ctx_history_core::utc_now; use serde_json::{json, Map, Value}; use uuid::Uuid; -use crate::{config::AppConfig, identity, net}; +use crate::{config::AppConfig, identity, install_marker, net}; pub type AnalyticsProperties = Map; @@ -34,9 +34,11 @@ fn send_cli_event_inner( config: &AppConfig, event: AnalyticsEvent<'_>, ) -> Result<()> { + let device_id = identity::device_id(data_root)?; let install_id = identity::install_id(data_root)?; let status = if event.success { "ok" } else { "error" }; let duration_ms = event.duration.as_millis().min(i64::MAX as u128) as i64; + let install_marker = install_marker::current_exe_install_marker(); let mut properties = event.properties; properties.insert("action".to_owned(), Value::String(event.action.to_owned())); properties.insert("json_output".to_owned(), Value::Bool(event.json_output)); @@ -44,38 +46,55 @@ fn send_cli_event_inner( "analytics_client".to_owned(), Value::String("ctx-cli".to_owned()), ); + if install_marker.is_some() { + properties.insert( + "install_manager".to_owned(), + Value::String("ctx-hosted-installer".to_owned()), + ); + } if !event.success { properties.insert( "failure_kind".to_owned(), Value::String("command_error".to_owned()), ); } + let mut cli_event = json!({ + "event_id": Uuid::now_v7().to_string(), + "event_name": "cli_invocation", + "event_version": 1, + "occurred_at": utc_now(), + "plane": "product", + "delivery": "remote", + "origin_runtime": "cli", + "origin_install_id": install_id, + "origin_device_id": device_id, + "app_version": env!("CARGO_PKG_VERSION"), + "os": std::env::consts::OS, + "arch": std::env::consts::ARCH, + "surface": "cli", + "source": "ctx-cli", + "duration_ms": duration_ms, + "duration_bucket": duration_bucket(event.duration), + "status": status, + "success": event.success, + "properties": properties + }); + if let Some(marker) = install_marker { + if let Some(object) = cli_event.as_object_mut() { + object.insert( + "install_attempt_id".to_owned(), + Value::String(marker.install_attempt_id), + ); + } + } let payload = json!({ "broker_install_id": install_id, + "broker_device_id": device_id, "broker_runtime": "cli", "broker_app_version": env!("CARGO_PKG_VERSION"), "broker_os": std::env::consts::OS, "broker_arch": std::env::consts::ARCH, - "events": [{ - "event_id": Uuid::now_v7().to_string(), - "event_name": "cli_invocation", - "event_version": 1, - "occurred_at": Utc::now(), - "plane": "product", - "delivery": "remote", - "origin_runtime": "cli", - "origin_install_id": install_id, - "app_version": env!("CARGO_PKG_VERSION"), - "os": std::env::consts::OS, - "arch": std::env::consts::ARCH, - "surface": "cli", - "source": "ctx-cli", - "duration_ms": duration_ms, - "duration_bucket": duration_bucket(event.duration), - "status": status, - "success": event.success, - "properties": properties - }] + "events": [cli_event] }); let body = serde_json::to_vec(&payload)?; net::post_json(&config.analytics.endpoint, &body) @@ -101,6 +120,18 @@ pub fn insert_bytes_bucket(properties: &mut AnalyticsProperties, key: &str, byte insert_str(properties, key, bytes_bucket(bytes)); } +pub fn insert_duration(properties: &mut AnalyticsProperties, prefix: &str, duration: Duration) { + insert_str( + properties, + &format!("{prefix}_bucket"), + duration_bucket(duration), + ); +} + +pub fn insert_text_length_bucket(properties: &mut AnalyticsProperties, key: &str, chars: usize) { + insert_str(properties, key, text_length_bucket(chars)); +} + pub fn count_bucket(count: u64) -> &'static str { match count { 0 => "0", @@ -125,6 +156,16 @@ pub fn bytes_bucket(bytes: u64) -> &'static str { } } +pub fn text_length_bucket(chars: usize) -> &'static str { + match chars { + 0 => "0", + 1..=20 => "1-20", + 21..=100 => "21-100", + 101..=500 => "101-500", + _ => "500+", + } +} + fn duration_bucket(duration: Duration) -> &'static str { let ms = duration.as_millis(); match ms { diff --git a/crates/ctx-cli/src/config.rs b/crates/ctx-cli/src/config.rs index 0fbe6b14a..7d1ed4f3e 100644 --- a/crates/ctx-cli/src/config.rs +++ b/crates/ctx-cli/src/config.rs @@ -1,12 +1,12 @@ use std::{ collections::BTreeMap, env, fs, - io::Write, + io::{self, Write}, path::{Path, PathBuf}, time::Duration, }; -use anyhow::{Context, Result}; +use anyhow::{bail, Context, Result}; pub const CONFIG_FILE: &str = "config.toml"; @@ -51,49 +51,61 @@ impl AppConfig { pub fn load(data_root: &Path) -> Result { let mut config = Self::default(); let path = data_root.join(CONFIG_FILE); - if path.exists() { - let text = - fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; - let parsed = parse_toml_subset(&text); - config.apply_values(&parsed); + match fs::read_to_string(&path) { + Ok(text) => { + let parsed = parse_toml_subset(&text) + .with_context(|| format!("parse {}", path.display()))?; + config + .apply_values(&parsed) + .with_context(|| format!("load {}", path.display()))?; + } + Err(err) if err.kind() == io::ErrorKind::NotFound => {} + Err(err) => return Err(err).with_context(|| format!("read {}", path.display())), } config.apply_env(); Ok(config) } - fn apply_values(&mut self, values: &BTreeMap) { - if let Some(enabled) = parse_bool(values.get("analytics.enabled")) { - self.analytics.enabled = enabled; - } - if let Some(endpoint) = parse_string(values.get("analytics.endpoint")) { - self.analytics.endpoint = endpoint; - } - if let Some(auto) = parse_string(values.get("upgrade.auto")) { - self.upgrade.auto = auto; - } - if let Some(channel) = parse_string(values.get("upgrade.channel")) { - self.upgrade.channel = channel; - } - if let Some(hours) = parse_u64(values.get("upgrade.interval_hours")) { - self.upgrade.interval = Duration::from_secs(hours.saturating_mul(60 * 60)); - } - if let Some(seconds) = parse_u64(values.get("upgrade.interval_seconds")) { - self.upgrade.interval = Duration::from_secs(seconds); - } - if let Some(functions_base) = parse_string(values.get("upgrade.functions_base")) { - self.upgrade.functions_base = functions_base; + fn apply_values(&mut self, values: &BTreeMap) -> Result<()> { + for (key, value) in values { + match key.as_str() { + "analytics.enabled" => { + self.analytics.enabled = parse_config_bool(key, value)?; + } + "analytics.endpoint" => { + self.analytics.endpoint = parse_non_empty_string(key, value)?; + } + "upgrade.auto" => { + self.upgrade.auto = parse_upgrade_auto(value)?; + } + "upgrade.channel" => { + self.upgrade.channel = parse_non_empty_string(key, value)?; + } + "upgrade.interval_hours" => { + let hours = parse_config_u64(key, value)?; + self.upgrade.interval = Duration::from_secs(hours.saturating_mul(60 * 60)); + } + "upgrade.interval_seconds" => { + self.upgrade.interval = Duration::from_secs(parse_config_u64(key, value)?); + } + "upgrade.functions_base" => { + self.upgrade.functions_base = parse_non_empty_string(key, value)?; + } + _ => bail!("unknown config key `{key}` at line {}", value.line), + } } + Ok(()) } fn apply_env(&mut self) { - if env_flag("CTX_ANALYTICS_OFF") || env_flag("CTX_DISABLE_ANALYTICS") { - self.analytics.enabled = false; - } if let Ok(value) = env::var("CTX_ANALYTICS_ENABLED") { if let Some(enabled) = parse_bool_value(&value) { self.analytics.enabled = enabled; } } + if env_flag("CTX_ANALYTICS_OFF") || env_flag("CTX_DISABLE_ANALYTICS") { + self.analytics.enabled = false; + } if let Ok(endpoint) = env::var("CTX_ANALYTICS_ENDPOINT") { if !endpoint.trim().is_empty() { self.analytics.endpoint = endpoint; @@ -144,54 +156,134 @@ interval_hours = 24\n", Ok(()) } -fn parse_toml_subset(text: &str) -> BTreeMap { +#[derive(Debug, Clone)] +struct ConfigValue { + raw: String, + line: usize, +} + +fn parse_toml_subset(text: &str) -> Result> { let mut section = String::new(); let mut values = BTreeMap::new(); - for raw_line in text.lines() { - let line = raw_line.split('#').next().unwrap_or_default().trim(); + for (index, raw_line) in text.lines().enumerate() { + let line_number = index + 1; + let line = strip_comment(raw_line).trim(); if line.is_empty() { continue; } - if line.starts_with('[') && line.ends_with(']') { - section = line - .trim_start_matches('[') - .trim_end_matches(']') - .trim() - .to_owned(); + if line.starts_with('[') { + if !line.ends_with(']') { + bail!("invalid config section header at line {line_number}: {line}"); + } + section = line[1..line.len() - 1].trim().to_owned(); + if section.is_empty() { + bail!("empty config section header at line {line_number}"); + } continue; } let Some((key, value)) = line.split_once('=') else { - continue; + bail!("invalid config line {line_number}: expected `[section]` or `key = value`"); }; let key = key.trim(); if key.is_empty() { - continue; + bail!("empty config key at line {line_number}"); } let full_key = if section.is_empty() { key.to_owned() } else { format!("{section}.{key}") }; - values.insert( - full_key, - value.trim().trim_end_matches(',').trim().to_owned(), - ); + let value = ConfigValue { + raw: value.trim().to_owned(), + line: line_number, + }; + if let Some(previous) = values.insert(full_key.clone(), value) { + bail!( + "duplicate config key `{full_key}` at line {line_number}; first set at line {}", + previous.line + ); + } } - values + Ok(values) } -fn parse_string(value: Option<&String>) -> Option { - value - .map(|value| value.trim().trim_matches('"').trim_matches('\'').to_owned()) - .filter(|value| !value.is_empty()) +fn strip_comment(line: &str) -> &str { + let mut in_single_quote = false; + let mut in_double_quote = false; + let mut escaped = false; + for (index, ch) in line.char_indices() { + if in_double_quote { + if escaped { + escaped = false; + continue; + } + match ch { + '\\' => escaped = true, + '"' => in_double_quote = false, + _ => {} + } + continue; + } + if in_single_quote { + if ch == '\'' { + in_single_quote = false; + } + continue; + } + match ch { + '#' => return &line[..index], + '"' => in_double_quote = true, + '\'' => in_single_quote = true, + _ => {} + } + } + line +} + +fn parse_non_empty_string(key: &str, value: &ConfigValue) -> Result { + let parsed = parse_config_string(key, value)?; + if parsed.trim().is_empty() { + bail!("{key} at line {} must not be empty", value.line); + } + Ok(parsed) +} + +fn parse_config_string(key: &str, value: &ConfigValue) -> Result { + let raw = value.raw.trim(); + if raw.len() >= 2 + && ((raw.starts_with('"') && raw.ends_with('"')) + || (raw.starts_with('\'') && raw.ends_with('\''))) + { + return Ok(raw[1..raw.len() - 1].to_owned()); + } + bail!("{key} at line {} must be a quoted string", value.line); } -fn parse_bool(value: Option<&String>) -> Option { - value.and_then(|value| parse_bool_value(value)) +fn parse_config_bool(key: &str, value: &ConfigValue) -> Result { + match value.raw.trim() { + "true" => Ok(true), + "false" => Ok(false), + _ => bail!("{key} at line {} must be a boolean", value.line), + } } -fn parse_u64(value: Option<&String>) -> Option { - value.and_then(|value| value.trim().trim_matches('"').parse::().ok()) +fn parse_config_u64(key: &str, value: &ConfigValue) -> Result { + value + .raw + .trim() + .parse::() + .with_context(|| format!("{key} at line {} must be an unsigned integer", value.line)) +} + +fn parse_upgrade_auto(value: &ConfigValue) -> Result { + let auto = parse_non_empty_string("upgrade.auto", value)?; + match auto.to_ascii_lowercase().as_str() { + "apply" | "off" => Ok(auto.to_ascii_lowercase()), + _ => bail!( + "upgrade.auto at line {} must be either \"apply\" or \"off\"", + value.line + ), + } } fn parse_bool_value(value: &str) -> Option { @@ -228,7 +320,8 @@ auto = "off" channel = "beta" interval_seconds = 60 "#, - ); + ) + .unwrap(); let mut config = AppConfig::default(); assert_eq!( config.analytics.endpoint, @@ -236,10 +329,133 @@ interval_seconds = 60 ); assert!(config.analytics.enabled); assert_eq!(config.upgrade.auto, "apply"); - config.apply_values(&values); + config.apply_values(&values).unwrap(); assert!(!config.analytics.enabled); assert_eq!(config.upgrade.auto, "off"); assert_eq!(config.upgrade.channel, "beta"); assert_eq!(config.upgrade.interval, Duration::from_secs(60)); } + + #[test] + fn load_without_config_file_uses_defaults() { + let temp = tempfile::tempdir().unwrap(); + + let config = AppConfig::load(temp.path()).unwrap(); + + assert!(config.analytics.enabled); + assert_eq!(config.upgrade.auto, "apply"); + assert_eq!(config.upgrade.channel, "stable"); + assert_eq!(config.upgrade.interval, Duration::from_secs(24 * 60 * 60)); + } + + #[test] + fn load_valid_config_file_applies_values() { + let temp = tempfile::tempdir().unwrap(); + fs::write( + temp.path().join(CONFIG_FILE), + r#" +[analytics] +enabled = false +endpoint = "file:///tmp/ctx-analytics.jsonl" + +[upgrade] +auto = "off" +channel = "beta" +interval_hours = 2 +functions_base = "https://example.test/functions/v1" +"#, + ) + .unwrap(); + + let config = AppConfig::load(temp.path()).unwrap(); + + assert!(!config.analytics.enabled); + assert_eq!(config.analytics.endpoint, "file:///tmp/ctx-analytics.jsonl"); + assert_eq!(config.upgrade.auto, "off"); + assert_eq!(config.upgrade.channel, "beta"); + assert_eq!(config.upgrade.interval, Duration::from_secs(2 * 60 * 60)); + assert_eq!( + config.upgrade.functions_base, + "https://example.test/functions/v1" + ); + } + + #[test] + fn rejects_invalid_config_booleans() { + let temp = tempfile::tempdir().unwrap(); + fs::write( + temp.path().join(CONFIG_FILE), + "[analytics]\nenabled = flase\n", + ) + .unwrap(); + + let error = format!("{:#}", AppConfig::load(temp.path()).unwrap_err()); + + assert!(error.contains("analytics.enabled"), "{error}"); + assert!(error.contains("boolean"), "{error}"); + } + + #[test] + fn rejects_invalid_upgrade_auto_values() { + let temp = tempfile::tempdir().unwrap(); + fs::write( + temp.path().join(CONFIG_FILE), + "[upgrade]\nauto = \"offf\"\n", + ) + .unwrap(); + + let error = format!("{:#}", AppConfig::load(temp.path()).unwrap_err()); + + assert!(error.contains("upgrade.auto"), "{error}"); + assert!(error.contains("\"apply\" or \"off\""), "{error}"); + } + + #[test] + fn rejects_unquoted_upgrade_auto_values() { + let temp = tempfile::tempdir().unwrap(); + fs::write(temp.path().join(CONFIG_FILE), "[upgrade]\nauto = offf\n").unwrap(); + + let error = format!("{:#}", AppConfig::load(temp.path()).unwrap_err()); + + assert!(error.contains("upgrade.auto"), "{error}"); + assert!(error.contains("quoted string"), "{error}"); + } + + #[test] + fn rejects_invalid_config_numbers() { + let temp = tempfile::tempdir().unwrap(); + fs::write( + temp.path().join(CONFIG_FILE), + "[upgrade]\ninterval_seconds = nope\n", + ) + .unwrap(); + + let error = format!("{:#}", AppConfig::load(temp.path()).unwrap_err()); + + assert!(error.contains("upgrade.interval_seconds"), "{error}"); + assert!(error.contains("unsigned integer"), "{error}"); + } + + #[test] + fn rejects_malformed_config_lines() { + let error = parse_toml_subset("[upgrade]\nthis is not valid\n").unwrap_err(); + let error = error.to_string(); + + assert!(error.contains("invalid config line 2"), "{error}"); + } + + #[test] + fn rejects_unknown_config_keys() { + let temp = tempfile::tempdir().unwrap(); + fs::write( + temp.path().join(CONFIG_FILE), + "[analytics]\nenabld = false\n", + ) + .unwrap(); + + let error = format!("{:#}", AppConfig::load(temp.path()).unwrap_err()); + + assert!(error.contains("unknown config key"), "{error}"); + assert!(error.contains("analytics.enabld"), "{error}"); + } } diff --git a/crates/ctx-cli/src/docs.rs b/crates/ctx-cli/src/docs.rs index 7754d8ba2..55aae6e26 100644 --- a/crates/ctx-cli/src/docs.rs +++ b/crates/ctx-cli/src/docs.rs @@ -115,6 +115,15 @@ const TOPICS: &[DocTopic] = &[ source_path: "docs/cli-reference.md", body: include_str!("../../../docs/cli-reference.md"), }, + DocTopic { + id: "docs", + title: "Docs", + audience: "human-agent", + summary: "Use embedded ctx docs, local documentation search, and generated man pages.", + tags: &["docs", "help", "man"], + source_path: "docs/docs.md", + body: include_str!("../../../docs/docs.md"), + }, DocTopic { id: "search", title: "Search", @@ -124,6 +133,33 @@ const TOPICS: &[DocTopic] = &[ source_path: "docs/search.md", body: include_str!("../../../docs/search.md"), }, + DocTopic { + id: "sql", + title: "SQL", + audience: "agent", + summary: "Read-only SQL usage, stable view schemas, limits, and examples.", + tags: &["sql", "sqlite", "views", "advanced"], + source_path: "docs/sql.md", + body: include_str!("../../../docs/sql.md"), + }, + DocTopic { + id: "mcp", + title: "MCP", + audience: "agent", + summary: "Read-only MCP server tools, behavior, and privacy expectations.", + tags: &["mcp", "tools", "agents"], + source_path: "docs/mcp.md", + body: include_str!("../../../docs/mcp.md"), + }, + DocTopic { + id: "upgrade", + title: "Upgrade", + audience: "human-agent", + summary: "Managed upgrades, background auto-upgrade behavior, and local state.", + tags: &["upgrade", "auto-upgrade", "install"], + source_path: "docs/upgrade.md", + body: include_str!("../../../docs/upgrade.md"), + }, DocTopic { id: "agent-usage", title: "Agent Usage", @@ -178,6 +214,24 @@ const TOPICS: &[DocTopic] = &[ source_path: "docs/providers.md", body: include_str!("../../../docs/providers.md"), }, + DocTopic { + id: "custom-history-import-format", + title: "Custom History Import Format", + audience: "integrator-agent", + summary: "ctx-history-jsonl-v1 records, transport, identity, cursors, and import rules.", + tags: &["providers", "imports", "jsonl", "custom"], + source_path: "docs/custom-history-import-format.md", + body: include_str!("../../../docs/custom-history-import-format.md"), + }, + DocTopic { + id: "history-source-plugins", + title: "History Source Plugins", + audience: "integrator-agent", + summary: "Local plugin manifests, stdout import, cursor handoff, and adapter shapes.", + tags: &["providers", "plugins", "imports", "custom"], + source_path: "docs/history-source-plugins.md", + body: include_str!("../../../docs/history-source-plugins.md"), + }, DocTopic { id: "provider-support", title: "Provider Support", @@ -240,24 +294,12 @@ fn list_docs(json_output: bool) -> Result<()> { } fn search_docs(query: &str, limit: usize, json_output: bool) -> Result<()> { - let terms: Vec = query - .split_whitespace() - .map(|term| term.to_ascii_lowercase()) - .filter(|term| !term.is_empty()) - .collect(); + let terms = docs_query_terms(query); let mut results: Vec<(usize, &DocTopic)> = TOPICS .iter() .filter_map(|topic| { - let haystack = format!( - "{} {} {} {}", - topic.id, topic.title, topic.summary, topic.body - ) - .to_ascii_lowercase(); - let score = terms - .iter() - .map(|term| haystack.matches(term).count()) - .sum::(); - (score > 0).then_some((score, topic)) + let score = score_doc_topic(topic, &terms); + (score >= docs_min_score(&terms)).then_some((score, topic)) }) .collect(); results.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| left.1.id.cmp(right.1.id))); @@ -276,11 +318,15 @@ fn search_docs(query: &str, limit: usize, json_output: bool) -> Result<()> { serde_json::to_string_pretty(&json!({ "schema_version": 1, "query": query, - "results": rows + "results": rows, + "suggested_next_commands": docs_search_suggestions(query, rows.is_empty()) }))? ); } else if results.is_empty() { println!("no docs matched"); + for command in docs_search_suggestions(query, true) { + println!("next: {command}"); + } } else { for (index, (score, topic)) in results.iter().enumerate() { println!("{}. {} - {}", index + 1, topic.id, topic.title); @@ -291,11 +337,81 @@ fn search_docs(query: &str, limit: usize, json_output: bool) -> Result<()> { Ok(()) } +fn docs_query_terms(query: &str) -> Vec { + query + .split_whitespace() + .map(|term| term.trim().to_ascii_lowercase()) + .filter(|term| !term.is_empty()) + .collect() +} + +fn docs_min_score(terms: &[String]) -> usize { + if terms.is_empty() { + usize::MAX + } else { + terms.len().max(2) + } +} + +fn score_doc_topic(topic: &DocTopic, terms: &[String]) -> usize { + let haystack = format!( + "{} {} {} {}", + topic.id, topic.title, topic.summary, topic.body + ) + .to_ascii_lowercase(); + let title = topic.title.to_ascii_lowercase(); + terms + .iter() + .map(|term| { + let exact_topic_match = topic.id == term + || title == *term + || topic.tags.iter().any(|tag| tag.eq_ignore_ascii_case(term)); + let text_matches = if term.len() >= 3 { + haystack.matches(term).count() + } else { + 0 + }; + text_matches + usize::from(exact_topic_match) * 1_000 + }) + .sum() +} + +fn docs_search_suggestions(query: &str, no_results: bool) -> Vec { + if no_results { + let mut suggestions = vec!["ctx docs list".to_owned()]; + let trimmed = query.trim(); + if !trimmed.is_empty() { + suggestions.push(format!( + "ctx docs search {}", + docs_shell_quote_arg(first_docs_search_term(trimmed)) + )); + } + suggestions + } else { + Vec::new() + } +} + +fn first_docs_search_term(query: &str) -> &str { + query.split_whitespace().next().unwrap_or(query) +} + +fn docs_shell_quote_arg(value: &str) -> String { + if value + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '/' | ':')) + { + value.to_owned() + } else { + format!("'{}'", value.replace('\'', "'\"'\"'")) + } +} + fn show_doc(args: DocsShowArgs) -> Result<()> { let topic = TOPICS .iter() .find(|topic| topic.id == args.id) - .ok_or_else(|| anyhow!("unknown ctx docs topic: {}", args.id))?; + .ok_or_else(|| unknown_doc_topic_error(&args.id))?; let body = if args.json || args.format == DocsFormat::Json { serde_json::to_string_pretty(&topic_json_with_body(topic))? } else { @@ -339,7 +455,54 @@ fn man_page(name: &str) -> Result<(String, Command)> { man_pages() .into_iter() .find(|(candidate, _)| candidate == name) - .ok_or_else(|| anyhow!("unknown ctx man page: {name}")) + .ok_or_else(|| unknown_man_page_error(name)) +} + +fn unknown_doc_topic_error(id: &str) -> anyhow::Error { + let mut message = format!("unknown ctx docs topic: {id}"); + let suggestions = suggested_doc_topics(id); + if !suggestions.is_empty() { + message.push_str("\nnearest topics:"); + for topic in suggestions { + message.push_str(&format!(" {topic}")); + } + } + message.push_str("\ntry: ctx docs list"); + message.push_str(&format!( + "\ntry: ctx docs search {}", + docs_shell_quote_arg(first_docs_search_term(id)) + )); + anyhow!(message) +} + +fn suggested_doc_topics(id: &str) -> Vec<&'static str> { + let query = id.to_ascii_lowercase(); + let terms = docs_query_terms(id); + let mut scored: Vec<(usize, &'static str)> = TOPICS + .iter() + .filter_map(|topic| { + let score = score_doc_topic(topic, &terms) + + common_prefix_len(&query, topic.id) + + usize::from(topic.id.contains(&query)) * 20; + (score > 0).then_some((score, topic.id)) + }) + .collect(); + scored.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| left.1.cmp(right.1))); + scored.truncate(3); + scored.into_iter().map(|(_, id)| id).collect() +} + +fn common_prefix_len(left: &str, right: &str) -> usize { + left.chars() + .zip(right.chars()) + .take_while(|(left, right)| left == right) + .count() +} + +fn unknown_man_page_error(name: &str) -> anyhow::Error { + anyhow!( + "unknown ctx man page: {name}\ntry: ctx docs man --print ctx\ntry: ctx docs man --out ./man" + ) } fn man_pages() -> Vec<(String, Command)> { diff --git a/crates/ctx-cli/src/history_source_plugins.rs b/crates/ctx-cli/src/history_source_plugins.rs new file mode 100644 index 000000000..24357b6e1 --- /dev/null +++ b/crates/ctx-cli/src/history_source_plugins.rs @@ -0,0 +1,778 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + env, + fs::{self, OpenOptions}, + io::{Read, Write}, + path::{Path, PathBuf}, + process::{Child, ChildStderr, ChildStdout, Command, ExitStatus, Stdio}, + thread, + time::{Duration, Instant}, +}; + +#[cfg(unix)] +use std::os::unix::{fs::OpenOptionsExt, io::AsRawFd}; +#[cfg(not(unix))] +use std::sync::mpsc; + +use anyhow::{anyhow, Context, Result}; +use serde::Deserialize; +use uuid::Uuid; + +const PLUGIN_MANIFEST_FILE: &str = "ctx-history-plugin.json"; +const MAX_PLUGIN_MANIFEST_BYTES: usize = 1024 * 1024; +const DEFAULT_PLUGIN_TIMEOUT_SECONDS: u64 = 300; +const MAX_PLUGIN_STDOUT_BYTES: usize = 64 * 1024 * 1024; +const MAX_PLUGIN_STDERR_BYTES: usize = 256 * 1024; +const MAX_PLUGIN_STDERR_SNIPPET_BYTES: usize = 4096; +const MAX_INLINE_CURSOR_ENV_BYTES: usize = 8192; +const SAFE_PLUGIN_ENV: &[&str] = &[ + "PATH", + "HOME", + "USER", + "LOGNAME", + "LANG", + "LC_ALL", + "LC_CTYPE", + "TMPDIR", + "TEMP", + "TMP", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + "XDG_CACHE_HOME", + "XDG_STATE_HOME", +]; + +#[derive(Debug, Clone)] +pub struct HistorySourcePluginSource { + pub plugin_name: String, + pub plugin_display_name: Option, + pub plugin_version: Option, + pub manifest_path: PathBuf, + pub manifest_dir: PathBuf, + pub id: String, + pub display_name: Option, + pub provider_key: String, + pub source_id: String, + pub source_format: String, + pub command: Vec, + pub working_dir: Option, + pub env: BTreeMap, + pub enabled: bool, + pub refresh: HistorySourcePluginRefresh, + pub timeout: Duration, +} + +impl HistorySourcePluginSource { + pub fn label(&self) -> String { + format!("{}/{}", self.plugin_name, self.id) + } + + pub fn cursor_stream(&self) -> String { + ctx_history_capture::custom_history_jsonl_v1_cursor_stream( + &self.provider_key, + &self.source_id, + &self.source_format, + ) + } + + pub fn matches_selector(&self, selector: &str) -> bool { + selector == self.label() || selector == format!("{}/{}", self.provider_key, self.source_id) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum HistorySourcePluginRefresh { + #[default] + Manual, + Auto, +} + +#[derive(Debug, Clone)] +pub struct HistorySourcePluginRun { + pub stdout: Vec, + pub stderr: String, +} + +#[derive(Debug, Clone)] +pub struct HistorySourcePluginRunOptions<'a> { + pub data_root: &'a Path, + pub machine_id: &'a str, + pub cursor: Option<&'a str>, + pub cursor_stream: &'a str, + pub full_rescan: bool, +} + +#[derive(Debug, Clone, Default)] +pub struct HistorySourcePluginDiscovery { + pub sources: Vec, + pub failures: Vec, +} + +#[derive(Debug, Clone)] +pub struct HistorySourcePluginManifestFailure { + pub manifest_path: PathBuf, + pub error: String, +} + +#[derive(Debug, Deserialize)] +struct HistorySourcePluginManifest { + schema_version: u32, + name: String, + #[serde(default)] + display_name: Option, + #[serde(default)] + version: Option, + #[serde(default)] + history_sources: Vec, +} + +#[derive(Debug, Deserialize)] +struct HistorySourcePluginSourceManifest { + id: String, + #[serde(default)] + display_name: Option, + #[serde(default)] + provider_key: Option, + #[serde(default)] + source_id: Option, + source_format: String, + command: Vec, + #[serde(default)] + working_dir: Option, + #[serde(default)] + env: BTreeMap, + #[serde(default)] + enabled: bool, + #[serde(default)] + refresh: HistorySourcePluginRefresh, + #[serde(default)] + timeout_seconds: Option, +} + +pub fn discover_history_source_plugins( + data_root: &Path, + extra_manifests: &[PathBuf], +) -> Result> { + let discovery = discover_history_source_plugins_with_diagnostics(data_root, extra_manifests)?; + Ok(discovery.sources) +} + +pub fn discover_history_source_plugins_with_diagnostics( + data_root: &Path, + extra_manifests: &[PathBuf], +) -> Result { + let mut sources = Vec::new(); + let mut failures = Vec::new(); + for manifest_path in plugin_manifest_paths(data_root) { + match read_plugin_manifest(&manifest_path) { + Ok(mut manifest_sources) => sources.append(&mut manifest_sources), + Err(error) => failures.push(HistorySourcePluginManifestFailure { + manifest_path, + error: error.to_string(), + }), + } + } + for manifest_path in explicit_plugin_manifest_paths(extra_manifests)? { + let mut manifest_sources = read_plugin_manifest(&manifest_path)?; + sources.append(&mut manifest_sources); + } + sources.sort_by_key(|source| source.label()); + Ok(HistorySourcePluginDiscovery { sources, failures }) +} + +pub fn run_history_source_plugin( + source: &HistorySourcePluginSource, + options: HistorySourcePluginRunOptions<'_>, +) -> Result { + let (program, args) = source.command.split_first().ok_or_else(|| { + anyhow!( + "history source plugin {} has an empty command", + source.label() + ) + })?; + let mut command = Command::new(program); + command.env_clear(); + inherit_safe_plugin_env(&mut command); + command.args(args); + command.stdin(Stdio::null()); + command.stdout(Stdio::piped()); + command.stderr(Stdio::piped()); + if let Some(working_dir) = &source.working_dir { + command.current_dir(resolve_manifest_path(&source.manifest_dir, working_dir)); + } + for (key, value) in &source.env { + command.env(key, value); + } + command.env("CTX_DATA_ROOT", options.data_root); + command.env("CTX_HISTORY_PLUGIN", "1"); + command.env("CTX_HISTORY_PLUGIN_NAME", &source.plugin_name); + command.env("CTX_HISTORY_PLUGIN_MANIFEST", &source.manifest_path); + command.env("CTX_HISTORY_SOURCE", source.label()); + command.env("CTX_HISTORY_SOURCE_ID", &source.source_id); + command.env("CTX_HISTORY_PROVIDER_KEY", &source.provider_key); + command.env("CTX_HISTORY_SOURCE_FORMAT", &source.source_format); + command.env("CTX_HISTORY_CURSOR_STREAM", options.cursor_stream); + command.env("CTX_HISTORY_MACHINE_ID", options.machine_id); + command.env( + "CTX_HISTORY_FULL_RESCAN", + if options.full_rescan { "1" } else { "0" }, + ); + let cursor_file = if let Some(cursor) = options.cursor { + let path = write_private_temp_file("ctx-history-cursor", cursor).with_context(|| { + format!("write history source plugin {} cursor file", source.label()) + })?; + if cursor.len() <= MAX_INLINE_CURSOR_ENV_BYTES { + command.env("CTX_HISTORY_CURSOR", cursor); + } else { + command.env_remove("CTX_HISTORY_CURSOR"); + } + command.env("CTX_HISTORY_CURSOR_FILE", &path); + Some(path) + } else { + command.env_remove("CTX_HISTORY_CURSOR"); + command.env_remove("CTX_HISTORY_CURSOR_FILE"); + None + }; + let mut child = match command.spawn() { + Ok(child) => child, + Err(err) => { + cleanup_cursor_file(cursor_file.as_ref()); + return Err(err).with_context(|| { + format!( + "spawn history source plugin {} command {}", + source.label(), + shell_like_command(&source.command) + ) + }); + } + }; + let stdout = child + .stdout + .take() + .context("history source plugin stdout was not piped")?; + let stderr = child + .stderr + .take() + .context("history source plugin stderr was not piped")?; + let run_result = collect_child_output_with_timeout( + &mut child, + stdout, + stderr, + source.timeout, + &source.label(), + ); + cleanup_cursor_file(cursor_file.as_ref()); + let (status, stdout, stderr) = run_result?; + let stderr = String::from_utf8_lossy(&stderr).trim().to_owned(); + if !status.success() { + let detail = if stderr.is_empty() { + format!("exit status {status}") + } else { + format!("exit status {status}: {}", stderr_snippet(&stderr)) + }; + return Err(anyhow!( + "history source plugin {} failed: {detail}", + source.label() + )); + } + Ok(HistorySourcePluginRun { stdout, stderr }) +} + +#[cfg(unix)] +fn collect_child_output_with_timeout( + child: &mut Child, + mut stdout: ChildStdout, + mut stderr: ChildStderr, + timeout: Duration, + source_label: &str, +) -> Result<(ExitStatus, Vec, Vec)> { + set_nonblocking(stdout.as_raw_fd())?; + set_nonblocking(stderr.as_raw_fd())?; + + let started = Instant::now(); + let mut status = None; + let mut stdout_open = true; + let mut stderr_open = true; + let mut stdout_bytes = Vec::new(); + let mut stderr_bytes = Vec::new(); + loop { + if stdout_open { + read_available_with_limit( + &mut stdout, + &mut stdout_bytes, + &mut stdout_open, + MAX_PLUGIN_STDOUT_BYTES, + "stdout", + source_label, + ) + .inspect_err(|_| { + let _ = child.kill(); + let _ = child.wait(); + })?; + } + if stderr_open { + read_available_with_limit( + &mut stderr, + &mut stderr_bytes, + &mut stderr_open, + MAX_PLUGIN_STDERR_BYTES, + "stderr", + source_label, + ) + .inspect_err(|_| { + let _ = child.kill(); + let _ = child.wait(); + })?; + } + if status.is_none() { + status = child.try_wait()?; + } + if let Some(status) = status { + if !stdout_open && !stderr_open { + return Ok((status, stdout_bytes, stderr_bytes)); + } + } + if started.elapsed() >= timeout { + if status.is_none() { + let _ = child.kill(); + let _ = child.wait(); + } + return Err(anyhow!( + "history source plugin {source_label} timed out after {}s", + timeout.as_secs() + )); + } + thread::sleep(Duration::from_millis(25)); + } +} + +#[cfg(not(unix))] +fn collect_child_output_with_timeout( + child: &mut Child, + stdout: ChildStdout, + stderr: ChildStderr, + timeout: Duration, + source_label: &str, +) -> Result<(ExitStatus, Vec, Vec)> { + #[derive(Clone, Copy)] + enum PipeKind { + Stdout, + Stderr, + } + + let (tx, rx) = mpsc::channel(); + let stdout_source = source_label.to_owned(); + let stdout_tx = tx.clone(); + let stdout_handle = thread::spawn(move || { + let _ = stdout_tx.send(( + PipeKind::Stdout, + read_pipe_with_limit(stdout, MAX_PLUGIN_STDOUT_BYTES, "stdout", &stdout_source), + )); + }); + let stderr_source = source_label.to_owned(); + let stderr_tx = tx; + let stderr_handle = thread::spawn(move || { + let _ = stderr_tx.send(( + PipeKind::Stderr, + read_pipe_with_limit(stderr, MAX_PLUGIN_STDERR_BYTES, "stderr", &stderr_source), + )); + }); + + let started = Instant::now(); + let status = loop { + if let Some(status) = child.try_wait()? { + break status; + } + if started.elapsed() >= timeout { + let _ = child.kill(); + let _ = child.wait(); + return Err(anyhow!( + "history source plugin {source_label} timed out after {}s", + timeout.as_secs() + )); + } + thread::sleep(Duration::from_millis(25)); + }; + + let mut stdout = None; + let mut stderr = None; + while stdout.is_none() || stderr.is_none() { + let Some(remaining) = timeout.checked_sub(started.elapsed()) else { + return Err(anyhow!( + "history source plugin {source_label} timed out after {}s", + timeout.as_secs() + )); + }; + if remaining == Duration::ZERO { + return Err(anyhow!( + "history source plugin {source_label} timed out after {}s", + timeout.as_secs() + )); + } + match rx.recv_timeout(remaining) { + Ok((PipeKind::Stdout, result)) => { + stdout = Some(result?); + } + Ok((PipeKind::Stderr, result)) => { + stderr = Some(result?); + } + Err(mpsc::RecvTimeoutError::Timeout) => { + return Err(anyhow!( + "history source plugin {source_label} timed out after {}s", + timeout.as_secs() + )); + } + Err(mpsc::RecvTimeoutError::Disconnected) => { + return Err(anyhow!( + "history source plugin {source_label} output reader stopped before pipes were drained" + )); + } + } + } + + if stdout_handle.join().is_err() { + return Err(anyhow!("history source plugin stdout reader panicked")); + } + if stderr_handle.join().is_err() { + return Err(anyhow!("history source plugin stderr reader panicked")); + } + let stdout = stdout.expect("stdout reader result"); + let stderr = stderr.expect("stderr reader result"); + Ok((status, stdout, stderr)) +} + +#[cfg(unix)] +fn set_nonblocking(fd: std::os::fd::RawFd) -> Result<()> { + let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) }; + if flags < 0 { + return Err(std::io::Error::last_os_error()).context("read plugin pipe flags"); + } + let result = unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) }; + if result < 0 { + return Err(std::io::Error::last_os_error()).context("set plugin pipe nonblocking"); + } + Ok(()) +} + +#[cfg(unix)] +fn read_available_with_limit( + reader: &mut R, + bytes: &mut Vec, + open: &mut bool, + max_bytes: usize, + name: &str, + source_label: &str, +) -> Result<()> { + let mut buffer = [0u8; 8192]; + loop { + match reader.read(&mut buffer) { + Ok(0) => { + *open = false; + return Ok(()); + } + Ok(count) => { + if bytes.len().saturating_add(count) > max_bytes { + return Err(anyhow!( + "history source plugin {source_label} {name} exceeded {max_bytes} byte limit" + )); + } + bytes.extend_from_slice(&buffer[..count]); + } + Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => return Ok(()), + Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue, + Err(err) => { + return Err(err) + .with_context(|| format!("read history source plugin {source_label} {name}")) + } + } + } +} + +#[cfg(any(test, not(unix)))] +fn read_pipe_with_limit( + mut reader: R, + max_bytes: usize, + name: &str, + source_label: &str, +) -> Result> { + let mut bytes = Vec::new(); + let mut buffer = [0u8; 8192]; + loop { + let count = reader.read(&mut buffer)?; + if count == 0 { + return Ok(bytes); + } + if bytes.len().saturating_add(count) > max_bytes { + return Err(anyhow!( + "history source plugin {source_label} {name} exceeded {max_bytes} byte limit" + )); + } + bytes.extend_from_slice(&buffer[..count]); + } +} + +fn inherit_safe_plugin_env(command: &mut Command) { + for key in SAFE_PLUGIN_ENV { + if let Some(value) = env::var_os(key) { + command.env(key, value); + } + } +} + +fn write_private_temp_file(prefix: &str, contents: &str) -> Result { + for _ in 0..16 { + let path = env::temp_dir().join(format!("{prefix}-{}.cursor", Uuid::new_v4())); + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + options.mode(0o600); + match options.open(&path) { + Ok(mut file) => { + file.write_all(contents.as_bytes())?; + return Ok(path); + } + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(err) => { + return Err(err) + .with_context(|| format!("create private temp file {}", path.display())); + } + } + } + Err(anyhow!("failed to allocate unique private temp file")) +} + +fn cleanup_cursor_file(path: Option<&PathBuf>) { + if let Some(path) = path { + let _ = fs::remove_file(path); + } +} + +fn read_plugin_manifest(path: &Path) -> Result> { + let raw = read_plugin_manifest_text(path)?; + let manifest: HistorySourcePluginManifest = serde_json::from_str(&raw) + .with_context(|| format!("parse history source plugin manifest {}", path.display()))?; + validate_plugin_id("plugin name", &manifest.name)?; + if manifest.schema_version != 1 { + return Err(anyhow!( + "history source plugin manifest {} has unsupported schema_version {}; expected 1", + path.display(), + manifest.schema_version + )); + } + let manifest_dir = path + .parent() + .unwrap_or_else(|| Path::new(".")) + .to_path_buf(); + let mut sources = Vec::new(); + for source in manifest.history_sources { + validate_plugin_id("history source id", &source.id)?; + let provider_key = source.provider_key.unwrap_or_else(|| manifest.name.clone()); + validate_plugin_id("provider_key", &provider_key)?; + let source_id = source.source_id.unwrap_or_else(|| source.id.clone()); + validate_plugin_id("source_id", &source_id)?; + validate_source_format(&source.source_format).with_context(|| { + format!( + "history source plugin manifest {} source {} has invalid source_format", + path.display(), + source.id + ) + })?; + if source.command.is_empty() || source.command.iter().any(|part| part.trim().is_empty()) { + return Err(anyhow!( + "history source plugin manifest {} source {} has empty command", + path.display(), + source.id + )); + } + sources.push(HistorySourcePluginSource { + plugin_name: manifest.name.clone(), + plugin_display_name: manifest.display_name.clone(), + plugin_version: manifest.version.clone(), + manifest_path: path.to_path_buf(), + manifest_dir: manifest_dir.clone(), + id: source.id, + display_name: source.display_name, + provider_key, + source_id, + source_format: source.source_format, + command: source.command, + working_dir: source.working_dir, + env: source.env, + enabled: source.enabled, + refresh: source.refresh, + timeout: Duration::from_secs( + source + .timeout_seconds + .unwrap_or(DEFAULT_PLUGIN_TIMEOUT_SECONDS) + .max(1), + ), + }); + } + Ok(sources) +} + +fn read_plugin_manifest_text(path: &Path) -> Result { + let file = fs::File::open(path) + .with_context(|| format!("read history source plugin manifest {}", path.display()))?; + let mut bytes = Vec::new(); + file.take((MAX_PLUGIN_MANIFEST_BYTES as u64).saturating_add(1)) + .read_to_end(&mut bytes) + .with_context(|| format!("read history source plugin manifest {}", path.display()))?; + if bytes.len() > MAX_PLUGIN_MANIFEST_BYTES { + return Err(anyhow!( + "history source plugin manifest {} exceeds max bytes ({MAX_PLUGIN_MANIFEST_BYTES})", + path.display() + )); + } + String::from_utf8(bytes).with_context(|| { + format!( + "history source plugin manifest {} is not UTF-8", + path.display() + ) + }) +} + +fn plugin_manifest_paths(data_root: &Path) -> Vec { + let mut candidates = BTreeSet::new(); + collect_manifest_path_candidates(&data_root.join("plugins"), &mut candidates); + if let Some(paths) = env::var_os("CTX_HISTORY_PLUGIN_PATH") { + for path in env::split_paths(&paths) { + collect_manifest_path_candidates(&path, &mut candidates); + } + } + candidates.into_iter().collect() +} + +fn explicit_plugin_manifest_paths(extra_manifests: &[PathBuf]) -> Result> { + let mut candidates = BTreeSet::new(); + for path in extra_manifests { + let before = candidates.len(); + collect_manifest_path_candidates(path, &mut candidates); + if candidates.len() == before { + return Err(anyhow!( + "history source plugin manifest path {} did not contain {}", + path.display(), + PLUGIN_MANIFEST_FILE + )); + } + } + Ok(candidates.into_iter().collect()) +} + +fn collect_manifest_path_candidates(path: &Path, candidates: &mut BTreeSet) { + if path.is_file() { + candidates.insert(path.to_path_buf()); + return; + } + if !path.is_dir() { + return; + } + let direct = path.join(PLUGIN_MANIFEST_FILE); + if direct.is_file() { + candidates.insert(direct); + } + let Ok(entries) = fs::read_dir(path) else { + return; + }; + for entry in entries.flatten() { + let child = entry.path(); + if child.is_file() + && child + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name == PLUGIN_MANIFEST_FILE) + { + candidates.insert(child); + continue; + } + if child.is_dir() { + let manifest = child.join(PLUGIN_MANIFEST_FILE); + if manifest.is_file() { + candidates.insert(manifest); + } + } + } +} + +fn validate_source_format(value: &str) -> Result<()> { + let valid = + !value.trim().is_empty() && value.len() <= 512 && !value.chars().any(char::is_control); + if valid { + Ok(()) + } else { + Err(anyhow!( + "source_format must be non-empty, at most 512 bytes, and contain no control characters" + )) + } +} + +fn validate_plugin_id(label: &str, value: &str) -> Result<()> { + let valid = !value.is_empty() + && value.len() <= 128 + && value.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-') + }) + && value + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()); + if valid { + Ok(()) + } else { + Err(anyhow!( + "{label} must be 1 to 128 bytes, start with a lowercase ASCII letter or digit, and use only lowercase ASCII letters, digits, '.', '_', or '-'" + )) + } +} + +fn resolve_manifest_path(manifest_dir: &Path, path: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + manifest_dir.join(path) + } +} + +fn shell_like_command(command: &[String]) -> String { + command.join(" ") +} + +fn stderr_snippet(value: &str) -> String { + let mut snippet = value + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .take(12) + .collect::>() + .join(" | "); + if snippet.len() > MAX_PLUGIN_STDERR_SNIPPET_BYTES { + snippet.truncate(MAX_PLUGIN_STDERR_SNIPPET_BYTES); + snippet.push_str("..."); + } + snippet +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + #[test] + fn read_pipe_with_limit_accepts_output_at_limit() { + let bytes = read_pipe_with_limit(Cursor::new(b"abcd"), 4, "stdout", "plugin/default") + .expect("output at limit should pass"); + assert_eq!(bytes, b"abcd"); + } + + #[test] + fn read_pipe_with_limit_rejects_output_over_limit() { + let err = read_pipe_with_limit(Cursor::new(b"abcde"), 4, "stdout", "plugin/default") + .expect_err("output over limit should fail"); + assert!( + err.to_string() + .contains("history source plugin plugin/default stdout exceeded 4 byte limit"), + "{err}" + ); + } +} diff --git a/crates/ctx-cli/src/identity.rs b/crates/ctx-cli/src/identity.rs index 7c2a2ce03..d9d5a0902 100644 --- a/crates/ctx-cli/src/identity.rs +++ b/crates/ctx-cli/src/identity.rs @@ -1,14 +1,15 @@ use std::{ - fs, + env, fs, path::{Path, PathBuf}, }; -use anyhow::{Context, Result}; -use chrono::Utc; +use anyhow::{bail, Context, Result}; +use ctx_history_core::utc_now; use serde_json::json; use uuid::Uuid; const INSTALL_FILE: &str = "install.json"; +const DEVICE_FILE: &str = "device.json"; pub fn install_id(data_root: &Path) -> Result { fs::create_dir_all(data_root)?; @@ -29,7 +30,7 @@ pub fn install_id(data_root: &Path) -> Result { let body = serde_json::to_vec_pretty(&json!({ "schema_version": 1, "install_id": id, - "created_at": Utc::now(), + "created_at": utc_now(), }))?; fs::write(&path, body).with_context(|| format!("write {}", path.display()))?; Ok(id) @@ -38,3 +39,131 @@ pub fn install_id(data_root: &Path) -> Result { pub fn install_path(data_root: &Path) -> PathBuf { data_root.join(INSTALL_FILE) } + +pub fn device_id(data_root: &Path) -> Result { + let path = device_path(data_root)?; + if path.exists() { + let value: serde_json::Value = serde_json::from_slice( + &fs::read(&path).with_context(|| format!("read {}", path.display()))?, + ) + .with_context(|| format!("parse {}", path.display()))?; + if let Some(id) = value.get("device_id").and_then(|value| value.as_str()) { + if Uuid::parse_str(id.trim()).is_ok() { + return Ok(id.trim().to_owned()); + } + } + } + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let id = Uuid::new_v4().to_string(); + let body = serde_json::to_vec_pretty(&json!({ + "schema_version": 1, + "device_id": id, + "created_at": utc_now(), + }))?; + write_private_file(&path, &body).with_context(|| format!("write {}", path.display()))?; + Ok(id) +} + +pub fn device_path(data_root: &Path) -> Result { + let path = device_state_dir()?.join(DEVICE_FILE); + ensure_device_path_outside_data_root(&path, data_root)?; + Ok(path) +} + +fn ensure_device_path_outside_data_root(path: &Path, data_root: &Path) -> Result<()> { + let normalized_path = normalize_for_prefix_check(path); + let normalized_data_root = normalize_for_prefix_check(data_root); + if normalized_path.starts_with(&normalized_data_root) { + bail!( + "refusing to store telemetry device identity under ctx data root: {}", + path.display() + ); + } + Ok(()) +} + +fn normalize_for_prefix_check(path: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + env::current_dir() + .map(|cwd| cwd.join(path)) + .unwrap_or_else(|_| path.to_path_buf()) + } +} + +#[cfg(target_os = "windows")] +fn device_state_dir() -> Result { + if let Some(local_app_data) = non_empty_env_path("LOCALAPPDATA") { + return Ok(local_app_data.join("ctx")); + } + Ok(home_dir() + .context("resolve home directory")? + .join("AppData") + .join("Local") + .join("ctx")) +} + +#[cfg(target_os = "macos")] +fn device_state_dir() -> Result { + Ok(home_dir() + .context("resolve home directory")? + .join("Library") + .join("Application Support") + .join("ctx")) +} + +#[cfg(all(not(target_os = "windows"), not(target_os = "macos")))] +fn device_state_dir() -> Result { + if let Some(xdg_state_home) = non_empty_env_path("XDG_STATE_HOME") { + return Ok(xdg_state_home.join("ctx")); + } + Ok(home_dir() + .context("resolve home directory")? + .join(".local") + .join("state") + .join("ctx")) +} + +fn non_empty_env_path(key: &str) -> Option { + env::var_os(key) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) +} + +fn home_dir() -> Option { + non_empty_env_path("HOME") + .or_else(|| non_empty_env_path("USERPROFILE")) + .or_else(|| { + let drive = env::var_os("HOMEDRIVE")?; + let path = env::var_os("HOMEPATH")?; + Some(PathBuf::from(format!( + "{}{}", + drive.to_string_lossy(), + path.to_string_lossy() + ))) + }) +} + +#[cfg(unix)] +fn write_private_file(path: &Path, body: &[u8]) -> Result<()> { + use std::{fs::OpenOptions, io::Write, os::unix::fs::OpenOptionsExt}; + + let mut file = OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .mode(0o600) + .open(path)?; + file.write_all(body)?; + Ok(()) +} + +#[cfg(not(unix))] +fn write_private_file(path: &Path, body: &[u8]) -> Result<()> { + fs::write(path, body)?; + Ok(()) +} diff --git a/crates/ctx-cli/src/install_marker.rs b/crates/ctx-cli/src/install_marker.rs new file mode 100644 index 000000000..aa371ae78 --- /dev/null +++ b/crates/ctx-cli/src/install_marker.rs @@ -0,0 +1,106 @@ +use std::{ + env, fs, + io::Read, + path::{Path, PathBuf}, +}; + +use serde_json::Value; + +const MAX_MARKER_BYTES: u64 = 16 * 1024; +const MAX_INSTALL_ATTEMPT_ID_CHARS: usize = 128; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InstallMarker { + pub install_attempt_id: String, +} + +pub fn current_exe_install_marker() -> Option { + let exe = env::current_exe().ok()?; + read_install_marker(&install_marker_path(&exe)) +} + +fn read_install_marker(path: &Path) -> Option { + let metadata = fs::metadata(path).ok()?; + if !metadata.is_file() || metadata.len() > MAX_MARKER_BYTES { + return None; + } + let file = fs::File::open(path).ok()?; + let mut reader = file.take(MAX_MARKER_BYTES + 1); + let mut bytes = Vec::new(); + if reader.read_to_end(&mut bytes).is_err() || bytes.len() as u64 > MAX_MARKER_BYTES { + return None; + } + parse_install_marker(&bytes) +} + +fn parse_install_marker(bytes: &[u8]) -> Option { + let value: Value = serde_json::from_slice(bytes).ok()?; + let id = value.get("install_attempt_id")?.as_str()?.trim(); + if is_valid_install_attempt_id(id) { + Some(InstallMarker { + install_attempt_id: id.to_owned(), + }) + } else { + None + } +} + +fn is_valid_install_attempt_id(value: &str) -> bool { + !value.is_empty() + && value.chars().count() <= MAX_INSTALL_ATTEMPT_ID_CHARS + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) +} + +fn install_marker_path(exe: &Path) -> PathBuf { + let mut marker = exe.as_os_str().to_owned(); + marker.push(".install.json"); + PathBuf::from(marker) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_bounded_install_attempt_id() { + let marker = parse_install_marker(br#"{"install_attempt_id":"attempt_01-HOSTED"}"#) + .expect("valid marker"); + + assert_eq!(marker.install_attempt_id, "attempt_01-HOSTED"); + } + + #[test] + fn ignores_malformed_or_unbounded_install_attempt_id() { + assert!(parse_install_marker(b"{not-json").is_none()); + assert!(parse_install_marker(br#"{"install_attempt_id":""}"#).is_none()); + assert!(parse_install_marker(br#"{"install_attempt_id":"contains space"}"#).is_none()); + assert!(parse_install_marker( + format!( + r#"{{"install_attempt_id":"{}"}}"#, + "a".repeat(MAX_INSTALL_ATTEMPT_ID_CHARS + 1) + ) + .as_bytes() + ) + .is_none()); + } + + #[test] + fn appends_marker_suffix_to_full_exe_path() { + assert_eq!( + install_marker_path(Path::new("/tmp/ctx.exe")), + PathBuf::from("/tmp/ctx.exe.install.json") + ); + } + + #[test] + fn ignores_missing_or_oversized_marker_file() { + let temp = tempfile::tempdir().unwrap(); + assert!(read_install_marker(&temp.path().join("missing.install.json")).is_none()); + + let path = temp.path().join("ctx.install.json"); + fs::write(&path, vec![b'a'; MAX_MARKER_BYTES as usize + 1]).unwrap(); + assert!(read_install_marker(&path).is_none()); + } +} diff --git a/crates/ctx-cli/src/main.rs b/crates/ctx-cli/src/main.rs index b7f0838f1..8a8cc5922 100644 --- a/crates/ctx-cli/src/main.rs +++ b/crates/ctx-cli/src/main.rs @@ -1,6 +1,6 @@ use std::{ env, fs, - io::{IsTerminal, Read, Write}, + io::{Cursor, IsTerminal, Read, Write}, path::{Path, PathBuf}, str::FromStr, sync::{Arc, Mutex}, @@ -12,47 +12,68 @@ use anyhow::{anyhow, Context, Result}; use chrono::{Duration, Utc}; use clap::{Args, Parser, Subcommand, ValueEnum}; use serde_json::{json, Number, Value}; +use sha2::{Digest, Sha256}; use uuid::Uuid; mod analytics; mod config; mod docs; +mod history_source_plugins; mod identity; +mod install_marker; mod mcp; mod net; mod upgrade; +#[cfg(test)] +mod parser_prop_tests; + use analytics::{AnalyticsEvent, AnalyticsProperties}; use config::{AppConfig, CONFIG_FILE}; use ctx_history_capture::{ catalog_codex_session_tree, discover_provider_sources, discover_provider_sources_for_provider, - import_antigravity_cli_history, import_claude_projects_jsonl_tree, import_codex_history_jsonl, - import_codex_session_jsonl, import_codex_session_jsonl_tail, import_codex_session_paths, - import_codex_session_tree, import_copilot_cli_session_events, import_cursor_native_history, - import_factory_ai_droid_sessions, import_gemini_cli_history, import_opencode_sqlite, - import_pi_session_jsonl, provider_source_for_path, provider_source_spec, stable_capture_uuid, - AntigravityCliImportOptions, CatalogSummary, ClaudeProjectsImportOptions, CodexEventImportMode, - CodexHistoryImportOptions, CodexSessionCatalogOptions, CodexSessionImportOptions, - CodexSessionImportProgress, CodexSessionImportProgressCallback, CodexToolOutputMode, - CopilotCliImportOptions, CursorNativeImportOptions, FactoryAiDroidImportOptions, - GeminiCliImportOptions, OpenCodeSqliteImportOptions, PiSessionImportOptions, + import_antigravity_cli_history, import_astrbot_sqlite, import_claude_projects_jsonl_tree, + import_codex_history_jsonl, import_codex_session_jsonl, import_codex_session_jsonl_tail, + import_codex_session_paths, import_codex_session_tree, import_copilot_cli_session_events, + import_cursor_native_history, import_custom_history_jsonl_v1, + import_custom_history_jsonl_v1_reader, import_factory_ai_droid_sessions, + import_gemini_cli_history, import_hermes_sqlite, import_nanoclaw_project, + import_openclaw_history, import_opencode_sqlite, import_pi_session_jsonl, + import_shelley_sqlite, provider_source_for_path, provider_source_spec, stable_capture_uuid, + validate_custom_history_jsonl_v1, validate_custom_history_jsonl_v1_reader, + AntigravityCliImportOptions, AstrBotSqliteImportOptions, CatalogSummary, + ClaudeProjectsImportOptions, CodexEventImportMode, CodexHistoryImportOptions, + CodexSessionCatalogOptions, CodexSessionImportOptions, CodexSessionImportProgress, + CodexSessionImportProgressCallback, CodexToolOutputMode, CopilotCliImportOptions, + CursorNativeImportOptions, CustomHistoryJsonlV1ImportOptions, FactoryAiDroidImportOptions, + GeminiCliImportOptions, HermesSqliteImportOptions, NanoClawImportOptions, + OpenClawImportOptions, OpenCodeSqliteImportOptions, PiSessionImportOptions, ProviderImportSummary, ProviderImportSupport, ProviderSource, ProviderSourceStatus, + ShelleySqliteImportOptions, }; use ctx_history_core::{ - database_path, default_data_root, CaptureProvider, ContextCitation, ContextCitationType, Event, - EventRole, EventType, HistoryRecord, ProviderRawRetention, RedactionState, Session, + database_path, default_data_root, utc_now, CaptureProvider, ContextCitation, + ContextCitationType, CtxHistoryJsonlRecord, Event, EventRole, EventType, HistoryRecord, + ProviderRawRetention, RedactionState, Session, }; use ctx_history_store::{ CatalogSession, CatalogSourceIndexUpdate, RawSqlOptions, RawSqlResult, RawSqlValue, SourceImportFile, SourceImportFileIndexUpdate, Store, StoreError, RAW_SQL_DEFAULT_MAX_COLUMNS, RAW_SQL_DEFAULT_MAX_ROWS, RAW_SQL_DEFAULT_MAX_SQL_BYTES, RAW_SQL_DEFAULT_MAX_VALUE_BYTES, - RAW_SQL_MAX_TIMEOUT, + RAW_SQL_MAX_SQL_BYTES_CAP, RAW_SQL_MAX_TIMEOUT, +}; +use history_source_plugins::{ + discover_history_source_plugins, discover_history_source_plugins_with_diagnostics, + run_history_source_plugin, HistorySourcePluginManifestFailure, HistorySourcePluginRefresh, + HistorySourcePluginRunOptions, HistorySourcePluginSource, }; const WAL_TRUNCATE_MIN_BYTES: u64 = 64 * 1024 * 1024; const LARGE_IMPORT_SOURCE_FILES_WARNING: usize = 10_000; const LARGE_IMPORT_SOURCE_BYTES_WARNING: u64 = 1024 * 1024 * 1024; const MAX_SEARCH_LIMIT: usize = 200; +pub(crate) const MAX_EVENT_WINDOW: usize = 50; +const MAX_HISTORY_SOURCE_PLUGIN_JSONL_LINE_BYTES: usize = 16 * 1024 * 1024; #[derive(Debug, Parser)] #[command(name = "ctx", version, about = "Search local agent history")] @@ -88,7 +109,7 @@ enum CommandRoot { #[command(about = "Check or apply signed ctx CLI upgrades")] Upgrade(upgrade::UpgradeArgs), #[command(about = "Check local ctx health")] - Doctor(JsonArgs), + Doctor(DoctorArgs), } #[derive(Debug, Args)] @@ -107,13 +128,40 @@ struct JsonArgs { json: bool, } +#[derive(Debug, Args, Clone)] +struct DoctorArgs { + #[arg(long)] + json: bool, + #[arg(long, value_enum, default_value_t = ProgressArg::Auto)] + progress: ProgressArg, +} + #[derive(Debug, Args)] struct ImportArgs { #[arg(long, value_enum)] - provider: Option, - #[arg(long)] + provider: Option, + #[arg( + long, + help = "Import exactly this path; native provider paths require --provider" + )] path: Option, - #[arg(long, conflicts_with_all = ["provider", "path"])] + #[arg(long = "history-source", conflicts_with_all = ["provider", "path", "format", "all"])] + history_source: Option, + #[arg( + long = "history-source-manifest", + conflicts_with_all = ["provider", "path", "format"] + )] + history_source_manifest: Vec, + #[arg(long = "reset-cursor")] + reset_cursor: bool, + #[arg( + long, + value_enum, + requires = "path", + conflicts_with_all = ["provider", "all", "history_source"] + )] + format: Option, + #[arg(long, conflicts_with_all = ["provider", "path", "format", "history_source"])] all: bool, #[arg(long)] resume: bool, @@ -139,7 +187,8 @@ enum ShowTarget { #[derive(Debug, Args)] struct ShowSessionArgs { - id: Option, + #[arg(help = "ctx session id or unambiguous id prefix")] + id: Option, #[arg(long, value_enum)] provider: Option, #[arg(long = "provider-session")] @@ -156,12 +205,13 @@ struct ShowSessionArgs { #[derive(Debug, Args)] struct ShowEventArgs { - id: Uuid, - #[arg(long, default_value_t = 0)] + #[arg(help = "ctx event id or unambiguous id prefix")] + id: String, + #[arg(long, default_value_t = 0, value_parser = parse_event_window_limit)] before: usize, - #[arg(long, default_value_t = 0)] + #[arg(long, default_value_t = 0, value_parser = parse_event_window_limit)] after: usize, - #[arg(long)] + #[arg(long, value_parser = parse_event_window_limit)] window: Option, #[arg(long, value_enum, default_value_t = OutputFormat::Text)] format: OutputFormat, @@ -185,7 +235,8 @@ enum LocateTarget { #[derive(Debug, Args)] struct LocateSessionArgs { - id: Option, + #[arg(help = "ctx session id or unambiguous id prefix")] + id: Option, #[arg(long, value_enum)] provider: Option, #[arg(long = "provider-session")] @@ -198,7 +249,8 @@ struct LocateSessionArgs { #[derive(Debug, Args)] struct LocateEventArgs { - id: Uuid, + #[arg(help = "ctx event id or unambiguous id prefix")] + id: String, #[arg(long, value_enum, default_value_t = LocateFormat::Text)] format: LocateFormat, #[arg(long)] @@ -211,7 +263,7 @@ struct SearchArgs { query: Option, #[arg( long, - help = "Add another search query or keyword; repeat to broaden and merge results" + help = "Add another search query or keyword; repeat to broaden with OR-style merged results" )] term: Vec, #[arg( @@ -223,26 +275,62 @@ struct SearchArgs { limit: usize, #[arg(long, help = "Search only one provider")] provider: Option, - #[arg(long, help = "Filter by workspace path or name text")] + #[arg( + long = "history-source", + help = "Filter custom history imports by plugin/source or provider_key/source_id" + )] + history_source: Option, + #[arg( + long = "provider-key", + help = "Filter custom history imports by provider_key" + )] + provider_key: Option, + #[arg( + long = "source-id", + help = "Filter custom history imports by source_id" + )] + source_id: Option, + #[arg( + long = "source-format", + help = "Filter custom history imports by source_format" + )] + source_format: Option, + #[arg( + long, + help = "Filter by stored workspace, cwd, source path, or repo-name text" + )] workspace: Option, #[arg( long, help = "Filter to recent history, as RFC3339 or a day window like 30d" )] since: Option, - #[arg(long, help = "Return only primary-agent sessions")] + #[arg( + long, + hide = true, + help = "Deprecated alias for the default primary-agent search scope" + )] primary_only: bool, #[arg( long, - help = "Include subagent sessions; this is the default unless --primary-only is set" + help = "Include subagent sessions in addition to primary-agent sessions" )] include_subagents: bool, - #[arg(long, help = "Filter by event type, such as message or tool_call")] + #[arg( + long, + help = "Filter by event type: message, tool_call, tool_output, command_started, command_output, command_finished, file_touched, vcs_change, artifact, summary, or notice" + )] event_type: Option, - #[arg(long, help = "Filter by file path text")] + #[arg( + long, + help = "Filter by indexed touched-file path metadata, not the current filesystem" + )] file: Option, - #[arg(long, help = "Search event hits within one ctx session id")] - session: Option, + #[arg( + long, + help = "Search event hits within one ctx session id or unambiguous id prefix" + )] + session: Option, #[arg( long, help = "Return dense event-level results instead of diverse session results" @@ -253,7 +341,7 @@ struct SearchArgs { value_enum, default_value_t = RefreshArg::Auto, help = "Pre-search refresh behavior: auto, off, or strict", - long_help = "Pre-search refresh behavior. auto best-effort refreshes discovered native provider sources and serves the existing index if refresh fails; off searches the existing index only; strict fails if the refresh cannot run or import successfully." + long_help = "Pre-search refresh behavior. auto best-effort refreshes discovered native provider sources and enabled auto history-source plugins, then serves the existing index if refresh fails; off searches the existing index only; strict fails if the refresh cannot run or import successfully." )] refresh: RefreshArg, #[arg( @@ -309,8 +397,9 @@ impl SqlArgs { } pub(crate) struct SearchFilterInput { - session: Option, + session: Option, provider: Option, + source_identity: SourceIdentityFilterArgs, workspace: Option, since: Option, primary_only: bool, @@ -320,6 +409,111 @@ pub(crate) struct SearchFilterInput { include_current_session: bool, } +#[derive(Debug, Clone, Default)] +pub(crate) struct SourceIdentityFilterArgs { + history_source: Option, + provider_key: Option, + source_id: Option, + source_format: Option, +} + +#[derive(Debug, Clone, Default)] +struct SourceIdentityFilters { + history_source: Option, + provider_key: Option, + source_id: Option, + source_format: Option, +} + +impl SourceIdentityFilters { + fn is_empty(&self) -> bool { + self.history_source.is_none() + && self.provider_key.is_none() + && self.source_id.is_none() + && self.source_format.is_none() + } + + fn matches_plugin_source(&self, source: &HistorySourcePluginSource) -> bool { + if let Some(selector) = &self.history_source { + if !source.matches_selector(selector) { + return false; + } + } + if let Some(provider_key) = &self.provider_key { + if source.provider_key != *provider_key { + return false; + } + } + if let Some(source_id) = &self.source_id { + if source.source_id != *source_id { + return false; + } + } + if let Some(source_format) = &self.source_format { + if source.source_format != *source_format { + return false; + } + } + true + } +} + +impl From<&SearchArgs> for SourceIdentityFilterArgs { + fn from(args: &SearchArgs) -> Self { + Self { + history_source: args.history_source.clone(), + provider_key: args.provider_key.clone(), + source_id: args.source_id.clone(), + source_format: args.source_format.clone(), + } + } +} + +pub(crate) struct SearchIntentInput<'a> { + query: Option<&'a str>, + terms: &'a [String], + file: Option<&'a Path>, +} + +pub(crate) fn search_has_intent(input: SearchIntentInput<'_>) -> bool { + input.query.is_some_and(has_search_token) + || input.terms.iter().any(|term| has_search_token(term)) + || input + .file + .and_then(|path| path.to_str()) + .is_some_and(|file| !file.trim().is_empty()) +} + +fn has_search_token(value: &str) -> bool { + value.split_whitespace().any(|term| { + term.trim_matches(|ch: char| !ch.is_alphanumeric() && ch != '_' && ch != '-') + .chars() + .any(char::is_alphanumeric) + }) +} + +pub(crate) fn missing_search_intent_error() -> anyhow::Error { + anyhow!( + "search needs a query, --term, or --file\n\nTry:\n ctx search \"failed migration\"\n ctx search --term \"failed migration\" --term rollback\n ctx search --file crates/foo/src/lib.rs" + ) +} + +fn search_no_results_target(query: &str, terms: &[String]) -> String { + if !query.trim().is_empty() { + return shell_quote_arg(query); + } + let rendered_terms = terms + .iter() + .filter(|term| !term.trim().is_empty()) + .map(|term| format!("--term {}", shell_quote_arg(term))) + .collect::>(); + if rendered_terms.is_empty() { + "search".to_owned() + } else { + rendered_terms.join(" ") + } +} + impl CommandRoot { fn name(&self) -> &'static str { match self { @@ -465,6 +659,37 @@ impl OutputFormat { } } +#[derive(Debug, Clone, Copy, ValueEnum)] +enum NativeProviderArg { + Codex, + Pi, + #[value(alias = "claude-code")] + Claude, + #[value(name = "opencode", alias = "open-code")] + OpenCode, + #[value(alias = "antigravity-cli")] + Antigravity, + #[value(alias = "gemini-cli")] + Gemini, + Cursor, + #[value(alias = "copilot", alias = "copilot_cli")] + CopilotCli, + #[value( + alias = "factoryai-droid", + alias = "factory-droid", + alias = "factory_ai_droid" + )] + FactoryAiDroid, + #[value(name = "openclaw", alias = "open-claw", alias = "open_claw")] + OpenClaw, + Hermes, + #[value(name = "nanoclaw", alias = "nano-claw", alias = "nano_claw")] + NanoClaw, + #[value(name = "astrbot", alias = "astr-bot", alias = "astr_bot")] + AstrBot, + Shelley, +} + #[derive(Debug, Clone, Copy, ValueEnum)] enum ProviderArg { Codex, @@ -486,6 +711,29 @@ enum ProviderArg { alias = "factory_ai_droid" )] FactoryAiDroid, + #[value(name = "openclaw", alias = "open-claw", alias = "open_claw")] + OpenClaw, + Hermes, + #[value(name = "nanoclaw", alias = "nano-claw", alias = "nano_claw")] + NanoClaw, + #[value(name = "astrbot", alias = "astr-bot", alias = "astr_bot")] + AstrBot, + Shelley, + Custom, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +enum ImportFormatArg { + #[value(name = "ctx-history-jsonl-v1", alias = "custom-history-jsonl-v1")] + CtxHistoryJsonlV1, +} + +impl ImportFormatArg { + fn as_str(self) -> &'static str { + match self { + Self::CtxHistoryJsonlV1 => "ctx-history-jsonl-v1", + } + } } #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] @@ -496,6 +744,27 @@ enum ProgressArg { None, } +impl NativeProviderArg { + fn capture_provider(self) -> CaptureProvider { + match self { + Self::Codex => CaptureProvider::Codex, + Self::Pi => CaptureProvider::Pi, + Self::Claude => CaptureProvider::Claude, + Self::OpenCode => CaptureProvider::OpenCode, + Self::Antigravity => CaptureProvider::Antigravity, + Self::Gemini => CaptureProvider::Gemini, + Self::Cursor => CaptureProvider::Cursor, + Self::CopilotCli => CaptureProvider::CopilotCli, + Self::FactoryAiDroid => CaptureProvider::FactoryAiDroid, + Self::OpenClaw => CaptureProvider::OpenClaw, + Self::Hermes => CaptureProvider::Hermes, + Self::NanoClaw => CaptureProvider::NanoClaw, + Self::AstrBot => CaptureProvider::AstrBot, + Self::Shelley => CaptureProvider::Shelley, + } + } +} + impl ProviderArg { fn capture_provider(self) -> CaptureProvider { match self { @@ -508,6 +777,12 @@ impl ProviderArg { Self::Cursor => CaptureProvider::Cursor, Self::CopilotCli => CaptureProvider::CopilotCli, Self::FactoryAiDroid => CaptureProvider::FactoryAiDroid, + Self::OpenClaw => CaptureProvider::OpenClaw, + Self::Hermes => CaptureProvider::Hermes, + Self::NanoClaw => CaptureProvider::NanoClaw, + Self::AstrBot => CaptureProvider::AstrBot, + Self::Shelley => CaptureProvider::Shelley, + Self::Custom => CaptureProvider::Custom, } } @@ -522,6 +797,12 @@ impl ProviderArg { Self::Cursor => "cursor", Self::CopilotCli => "copilot-cli", Self::FactoryAiDroid => "factory-ai-droid", + Self::OpenClaw => "openclaw", + Self::Hermes => "hermes", + Self::NanoClaw => "nanoclaw", + Self::AstrBot => "astrbot", + Self::Shelley => "shelley", + Self::Custom => "custom", } } } @@ -568,6 +849,7 @@ struct ImportRunOptions { json: bool, print_human: bool, allow_empty_sources: bool, + include_history_source_plugins: bool, operation: &'static str, } @@ -1119,11 +1401,26 @@ fn main() -> Result<()> { .unwrap_or_else(default_data_root) .context("resolve ctx data root")?; let config = AppConfig::load(&data_root)?; + if matches!(&cli.command, CommandRoot::Setup(_)) && sends_analytics { + analytics::send_cli_event( + &data_root, + &config, + AnalyticsEvent { + action: "setup_started", + json_output, + success: true, + duration: StdDuration::ZERO, + properties: analytics_properties.clone(), + }, + ); + } let result = match cli.command { CommandRoot::Setup(args) => run_setup(args, data_root.clone(), &mut analytics_properties), CommandRoot::Status(args) => run_status(args, data_root.clone(), &mut analytics_properties), - CommandRoot::Sources(args) => run_sources(args, &mut analytics_properties), + CommandRoot::Sources(args) => { + run_sources(args, data_root.clone(), &mut analytics_properties) + } CommandRoot::Import(args) => run_import(args, data_root.clone(), &mut analytics_properties), CommandRoot::Show(args) => run_show(args, data_root.clone(), &mut analytics_properties), CommandRoot::Locate(args) => run_locate(args, data_root.clone(), &mut analytics_properties), @@ -1174,7 +1471,11 @@ fn command_analytics_properties(command: &CommandRoot) -> AnalyticsProperties { analytics::insert_str( &mut properties, "source_mode", - if args.path.is_some() { + if args.format.is_some() { + "explicit_format" + } else if args.history_source.is_some() { + "history_source_plugin" + } else if args.path.is_some() { "explicit_path" } else if args.all { "all_discovered" @@ -1191,6 +1492,7 @@ fn command_analytics_properties(command: &CommandRoot) -> AnalyticsProperties { provider.capture_provider().as_str(), ); } + analytics::insert_bool(&mut properties, "reset_cursor", args.reset_cursor); analytics::insert_str( &mut properties, "progress_mode", @@ -1345,6 +1647,10 @@ fn run_setup( let import_args = ImportArgs { provider: None, path: None, + history_source: None, + history_source_manifest: Vec::new(), + reset_cursor: false, + format: None, all: true, resume: false, json: args.json, @@ -1359,6 +1665,7 @@ fn run_setup( json: args.json, print_human: !args.json, allow_empty_sources: true, + include_history_source_plugins: false, operation: "setup", }, )?) @@ -1492,6 +1799,36 @@ fn indexed_history_item_count(store: &Store) -> Result { Ok(store.indexed_history_item_count()?) } +fn insert_store_analytics_counts( + analytics_properties: &mut AnalyticsProperties, + store: &Store, +) -> Result<()> { + let counts = store.indexed_history_counts()?; + analytics::insert_count_bucket( + analytics_properties, + "indexed_sessions_bucket", + counts.sessions as u64, + ); + analytics::insert_count_bucket( + analytics_properties, + "indexed_events_bucket", + counts.events as u64, + ); + analytics::insert_count_bucket( + analytics_properties, + "indexed_items_bucket", + counts.items() as u64, + ); + Ok(()) +} + +fn insert_db_size_bucket(analytics_properties: &mut AnalyticsProperties, db_path: &Path) { + let bytes = fs::metadata(db_path) + .map(|metadata| metadata.len()) + .unwrap_or(0); + analytics::insert_bytes_bucket(analytics_properties, "db_size_bucket", bytes); +} + fn setup_has_failed_sources(report: Option<&ImportReport>) -> bool { report.is_some_and(|report| report.totals.failed_sources > 0) } @@ -1504,18 +1841,27 @@ fn run_status( let db_path = database_path(data_root.clone()); let initialized = db_path.exists(); let config_path = data_root.join(CONFIG_FILE); - let (records, sources, catalog_counts) = if initialized { + let (records, sessions, events, sources, catalog_counts) = if initialized { let store = Store::open(&db_path)?; + let counts = store.indexed_history_counts()?; ( - indexed_history_item_count(&store)?, + counts.items(), + counts.sessions, + counts.events, store.capture_source_count()?, store.catalog_session_counts()?, ) } else { - (0, 0, Default::default()) + (0, 0, 0, 0, Default::default()) }; analytics::insert_bool(analytics_properties, "initialized", initialized); analytics::insert_count_bucket(analytics_properties, "indexed_items_bucket", records as u64); + analytics::insert_count_bucket( + analytics_properties, + "indexed_sessions_bucket", + sessions as u64, + ); + analytics::insert_count_bucket(analytics_properties, "indexed_events_bucket", events as u64); analytics::insert_count_bucket( analytics_properties, "indexed_sources_bucket", @@ -1526,6 +1872,7 @@ fn run_status( "cataloged_sessions_bucket", catalog_counts.total as u64, ); + insert_db_size_bucket(analytics_properties, &db_path); if args.json { print_json(json!({ @@ -1535,6 +1882,8 @@ fn run_status( "database_path": db_path, "config_path": config_path, "indexed_items": records, + "indexed_sessions": sessions, + "indexed_events": events, "indexed_sources": sources, "cataloged_sessions": catalog_counts.total, "indexed_catalog_sessions": catalog_counts.indexed, @@ -1560,21 +1909,31 @@ fn run_status( Ok(()) } -fn run_sources(args: JsonArgs, analytics_properties: &mut AnalyticsProperties) -> Result<()> { +fn run_sources( + args: JsonArgs, + data_root: PathBuf, + analytics_properties: &mut AnalyticsProperties, +) -> Result<()> { let sources = discovered_sources(); + let plugin_discovery = discover_history_source_plugins_with_diagnostics(&data_root, &[])?; + let plugin_sources = plugin_discovery.sources; + let plugin_failures = plugin_discovery.failures; let existing = sources.iter().filter(|source| source.exists).count(); let importable = sources .iter() .filter(|source| { source.exists - && matches!(source.import_support, ProviderImportSupport::Native) + && source.import_support.is_importable() && source.status == ProviderSourceStatus::Available }) .count(); analytics::insert_count_bucket( analytics_properties, "providers_detected_bucket", - sources.len() as u64, + sources + .len() + .saturating_add(plugin_sources.len()) + .saturating_add(plugin_failures.len()) as u64, ); analytics::insert_count_bucket( analytics_properties, @@ -1587,9 +1946,12 @@ fn run_sources(args: JsonArgs, analytics_properties: &mut AnalyticsProperties) - importable as u64, ); if args.json { + let mut source_values = sources_json(&sources); + source_values.extend(plugin_sources_json(&plugin_sources)); + source_values.extend(plugin_manifest_failures_json(&plugin_failures)); print_json(json!({ "schema_version": 1, - "sources": sources_json(&sources), + "sources": source_values, }))?; } else { for source in sources { @@ -1601,10 +1963,31 @@ fn run_sources(args: JsonArgs, analytics_properties: &mut AnalyticsProperties) - source.source_format ); } + for failure in plugin_failures { + println!( + "custom history-source-plugin invalid: {}: {}", + failure.manifest_path.display(), + failure.error + ); + } + for source in plugin_sources { + println!( + "custom {} available (history-source-plugin:{})", + source.label(), + source.source_format + ); + } } Ok(()) } +pub(crate) fn discovered_plugin_sources_json(data_root: &Path) -> Result> { + let plugin_discovery = discover_history_source_plugins_with_diagnostics(data_root, &[])?; + let mut values = plugin_sources_json(&plugin_discovery.sources); + values.extend(plugin_manifest_failures_json(&plugin_discovery.failures)); + Ok(values) +} + fn catalog_available_sources( store: &Store, sources: &[SourceInfo], @@ -1662,6 +2045,7 @@ fn run_import( json, print_human: !json, allow_empty_sources: false, + include_history_source_plugins: true, operation: "import", }, )?; @@ -1674,20 +2058,37 @@ fn run_import_internal( analytics_properties: &mut AnalyticsProperties, options: ImportRunOptions, ) -> Result { + validate_import_args(args)?; fs::create_dir_all(&data_root)?; config::write_default_config(&data_root)?; - let db_path = database_path(data_root); + let db_path = database_path(data_root.clone()); let mut store = Store::open(&db_path)?; let mut totals = ImportTotals::default(); let mut imported_sources = Vec::new(); + if let Some(format) = args.format { + return run_explicit_format_import( + args, + format, + db_path, + store, + analytics_properties, + options, + ); + } + let requests = import_requests(args)?; - if requests.is_empty() { + let plugin_requests = history_source_plugin_import_requests( + args, + &data_root, + options.include_history_source_plugins, + )?; + if requests.is_empty() && plugin_requests.is_empty() { if options.allow_empty_sources { return Ok(ImportReport::empty(args.resume)); } return Err(anyhow!( - "no importable provider history sources found; use --path or run `ctx sources`" + "no importable provider history sources found; use --path, --history-source, or run `ctx sources`" )); } @@ -1702,7 +2103,7 @@ fn run_import_internal( analytics::insert_count_bucket( analytics_properties, "sources_seen_bucket", - planned_sources.len() as u64, + planned_sources.len().saturating_add(plugin_requests.len()) as u64, ); analytics::insert_bytes_bucket( analytics_properties, @@ -1721,7 +2122,7 @@ fn run_import_internal( "discovering", format!( "found {} import source(s), {}", - planned_sources.len(), + planned_sources.len().saturating_add(plugin_requests.len()), format_bytes(planned_total_bytes) ), ); @@ -1732,6 +2133,65 @@ fn run_import_internal( progress.warning(warning); } + for plugin_source in plugin_requests { + if options.print_human { + progress.finish_line(); + println!("importing history source plugin {}", plugin_source.label()); + } + progress.message( + "indexing", + format!("running history source plugin {}", plugin_source.label()), + ); + match import_history_source_plugin( + &mut store, + &plugin_source, + &data_root, + args.reset_cursor, + ) { + Ok((summary, stats)) => { + totals.add(&summary, &stats); + progress.done( + "indexing", + format!("imported history source plugin {}", plugin_source.label()), + planned_total_bytes, + ); + if options.print_human { + progress.finish_line(); + print_history_source_plugin_imported(&plugin_source, &summary); + } + imported_sources.push(history_source_plugin_import_json( + &plugin_source, + &stats, + &summary, + )); + } + Err(err) => { + let error = error_summary(&err); + if allow_source_failures && !import_error_is_systemic(&error) { + totals.add_source_failure(&SourceStats::default()); + progress.done( + "indexing", + format!( + "skipped history source plugin {}: {}", + plugin_source.label(), + one_line_error(&error) + ), + planned_total_bytes, + ); + if options.print_human { + progress.finish_line(); + print_history_source_plugin_failed(&plugin_source, &error); + } + imported_sources + .push(history_source_plugin_failure_json(&plugin_source, &error)); + } else { + return Err(err); + } + } + } + } + + let native_import_requested = !planned_sources.is_empty(); if should_parallelize_import(&planned_sources) { let final_refresh_required = store.event_search_projection_needs_backfill()? || planned_sources @@ -2006,41 +2466,188 @@ fn run_import_internal( ); analytics::insert_count_bucket(analytics_properties, "failed_bucket", totals.failed as u64); if totals.imported_sources == 0 && totals.failed_sources > 0 { - return Err(anyhow!("all import sources failed")); + let detail = imported_sources + .iter() + .find_map(|source| source.get("error").and_then(Value::as_str)) + .map(|error| format!("; first failure: {error}")) + .unwrap_or_default(); + return Err(anyhow!("all import sources failed{detail}")); } Ok(ImportReport { - resume: args.resume, + resume: args.resume && native_import_requested, totals, sources: imported_sources, }) } -fn print_import_report(report: &ImportReport, json_output: bool) -> Result<()> { - if json_output { - print_json(import_report_json(report)) - } else { - print_import_report_human(report); - Ok(()) +fn run_explicit_format_import( + args: &ImportArgs, + format: ImportFormatArg, + db_path: PathBuf, + mut store: Store, + analytics_properties: &mut AnalyticsProperties, + options: ImportRunOptions, +) -> Result { + let path = args + .path + .as_ref() + .context("--format requires an explicit --path")?; + let stats = + source_stats(path).with_context(|| format!("scan import source {}", path.display()))?; + analytics::insert_count_bucket(analytics_properties, "sources_seen_bucket", 1); + analytics::insert_bytes_bucket(analytics_properties, "source_bytes_bucket", stats.bytes); + + let progress = ProgressReporter::new( + options.progress, + options.json, + options.operation, + stats.bytes, + ); + progress.message( + "discovering", + format!( + "found 1 {} source, {}", + format.as_str(), + format_bytes(stats.bytes) + ), + ); + if let Some(warning) = low_disk_space_warning(&db_path, stats.bytes) { + progress.warning(warning); + } + if (stats.files >= LARGE_IMPORT_SOURCE_FILES_WARNING + || stats.bytes >= LARGE_IMPORT_SOURCE_BYTES_WARNING) + && stats.files > 0 + { + let warning = format!( + "large import: {} source file(s), {}; initial indexing may use sustained CPU and disk", + stats.files, + format_bytes(stats.bytes) + ); + progress.warning(warning); } -} -fn import_report_json(report: &ImportReport) -> Value { - json!({ - "schema_version": 1, - "resume": report.resume, - "resume_mode": report.resume_mode(), - "totals": import_totals_json(&report.totals), - "sources": report.sources.clone(), - }) -} + let validation = match format { + ImportFormatArg::CtxHistoryJsonlV1 => { + validate_custom_history_jsonl_v1(path).map_err(anyhow::Error::from)? + } + }; + if validation.failed > 0 { + return Err(explicit_format_import_failure(format, &validation)); + } -fn import_totals_json(totals: &ImportTotals) -> Value { - json!({ - "source_files": totals.source_files, - "source_bytes": totals.source_bytes, - "imported_sources": totals.imported_sources, - "failed_sources": totals.failed_sources, - "imported_sessions": totals.imported_sessions, + let record = import_record_for_custom_history(path, format); + let record_id = record.id; + store.upsert_record(&record)?; + progress.message("indexing", format!("importing {}", format.as_str())); + let summary = match format { + ImportFormatArg::CtxHistoryJsonlV1 => import_custom_history_jsonl_v1( + path, + &mut store, + CustomHistoryJsonlV1ImportOptions { + source_path: Some(path.clone()), + history_record_id: Some(record_id), + allow_partial_failures: false, + ..CustomHistoryJsonlV1ImportOptions::default() + }, + ) + .map_err(anyhow::Error::from)?, + }; + if summary.failed > 0 { + return Err(explicit_format_import_failure(format, &summary)); + } + + let mut totals = ImportTotals::default(); + totals.add(&summary, &stats); + if totals.imported_sessions > 0 || totals.imported_events > 0 || totals.imported_edges > 0 { + progress.message("finalizing", "optimizing search index"); + Store::open(&db_path)?.optimize_search_index()?; + } + progress.message("finalizing", "checkpointing search database"); + Store::open(&db_path)?.checkpoint_wal_truncate_if_larger_than(WAL_TRUNCATE_MIN_BYTES)?; + if options.print_human { + progress.finish_line(); + } + progress.done( + "finalizing", + format!("indexed 1 {} source file", format.as_str()), + stats.bytes, + ); + analytics::insert_count_bucket( + analytics_properties, + "source_files_bucket", + stats.files as u64, + ); + analytics::insert_count_bucket(analytics_properties, "failed_sources_bucket", 0); + analytics::insert_count_bucket( + analytics_properties, + "sessions_imported_bucket", + totals.imported_sessions as u64, + ); + analytics::insert_count_bucket( + analytics_properties, + "events_imported_bucket", + totals.imported_events as u64, + ); + analytics::insert_count_bucket( + analytics_properties, + "edges_imported_bucket", + totals.imported_edges as u64, + ); + analytics::insert_count_bucket( + analytics_properties, + "skipped_bucket", + totals.skipped as u64, + ); + analytics::insert_count_bucket(analytics_properties, "failed_bucket", totals.failed as u64); + Ok(ImportReport { + resume: args.resume, + totals, + sources: vec![custom_format_import_json(format, path, &stats, &summary)], + }) +} + +fn explicit_format_import_failure( + format: ImportFormatArg, + summary: &ProviderImportSummary, +) -> anyhow::Error { + let detail = summary + .failures + .first() + .map(|failure| format!("line {}: {}", failure.line, failure.error)) + .unwrap_or_else(|| "unknown validation failure".to_owned()); + anyhow!( + "{} import failed with {} failure(s); first failure: {detail}", + format.as_str(), + summary.failed + ) +} + +fn print_import_report(report: &ImportReport, json_output: bool) -> Result<()> { + if json_output { + print_json(import_report_json(report)) + } else { + print_import_report_human(report); + Ok(()) + } +} + +fn import_report_json(report: &ImportReport) -> Value { + json!({ + "schema_version": 1, + "resume": report.resume, + "resume_mode": report.resume_mode(), + "totals": import_totals_json(&report.totals), + "sources": report.sources.clone(), + }) +} + +fn import_totals_json(totals: &ImportTotals) -> Value { + json!({ + "source_files": totals.source_files, + "source_bytes": totals.source_bytes, + "imported_sources": totals.imported_sources, + "failed_sources": totals.failed_sources, + "imported_sessions": totals.imported_sessions, "imported_events": totals.imported_events, "imported_edges": totals.imported_edges, "skipped": totals.skipped, @@ -2139,6 +2746,55 @@ fn source_import_json( }) } +fn custom_format_import_json( + format: ImportFormatArg, + path: &Path, + stats: &SourceStats, + summary: &ProviderImportSummary, +) -> Value { + json!({ + "status": "imported", + "provider": CaptureProvider::Custom.as_str(), + "path": path, + "format": format.as_str(), + "source_format": format.as_str(), + "source_files": stats.files, + "source_bytes": stats.bytes, + "imported_sessions": summary.imported_sessions, + "imported_events": summary.imported_events, + "imported_edges": summary.imported_edges, + "skipped": summary.skipped, + "failed": summary.failed, + "failures": provider_failures_json(summary), + }) +} + +fn history_source_plugin_import_json( + source: &HistorySourcePluginSource, + stats: &SourceStats, + summary: &ProviderImportSummary, +) -> Value { + json!({ + "status": "imported", + "provider": CaptureProvider::Custom.as_str(), + "kind": "history_source_plugin", + "plugin": source.plugin_name, + "history_source": source.label(), + "provider_key": source.provider_key, + "source_id": source.source_id, + "source_format": source.source_format, + "manifest_path": source.manifest_path, + "source_files": stats.files, + "source_bytes": stats.bytes, + "imported_sessions": summary.imported_sessions, + "imported_events": summary.imported_events, + "imported_edges": summary.imported_edges, + "skipped": summary.skipped, + "failed": summary.failed, + "failures": provider_failures_json(summary), + }) +} + fn provider_failures_json(summary: &ProviderImportSummary) -> Vec { summary .failures @@ -2165,6 +2821,23 @@ fn source_failure_json(failure: &ImportSourceFailure) -> Value { }) } +fn history_source_plugin_failure_json(source: &HistorySourcePluginSource, error: &str) -> Value { + json!({ + "status": "failed", + "provider": CaptureProvider::Custom.as_str(), + "kind": "history_source_plugin", + "plugin": source.plugin_name, + "history_source": source.label(), + "provider_key": source.provider_key, + "source_id": source.source_id, + "source_format": source.source_format, + "manifest_path": source.manifest_path, + "source_files": 0, + "source_bytes": 0, + "error": one_line_error(error), + }) +} + fn print_source_imported(source: &SourceInfo, summary: &ProviderImportSummary) { println!( "imported {}: sessions={} events={} edges={} skipped={} failed={}", @@ -2177,6 +2850,21 @@ fn print_source_imported(source: &SourceInfo, summary: &ProviderImportSummary) { ); } +fn print_history_source_plugin_imported( + source: &HistorySourcePluginSource, + summary: &ProviderImportSummary, +) { + println!( + "imported history source plugin {}: sessions={} events={} edges={} skipped={} failed={}", + source.label(), + summary.imported_sessions, + summary.imported_events, + summary.imported_edges, + summary.skipped, + summary.failed + ); +} + fn print_source_failed(failure: &ImportSourceFailure) { println!( "skipped {}: {}", @@ -2186,6 +2874,15 @@ fn print_source_failed(failure: &ImportSourceFailure) { println!(" path: {}", failure.source.path.display()); } +fn print_history_source_plugin_failed(source: &HistorySourcePluginSource, error: &str) { + println!( + "skipped history source plugin {}: {}", + source.label(), + one_line_error(error) + ); + println!(" manifest: {}", source.manifest_path.display()); +} + fn source_error_reason(source: &SourceInfo, error: &str) -> String { let error = one_line_error(error); let prefix = format!( @@ -2213,7 +2910,7 @@ fn error_summary(error: &anyhow::Error) -> String { .map(ToString::to_string) .unwrap_or_else(|| top.clone()); if is_sqlite_busy_text(&top) || is_sqlite_busy_text(&root) { - return "ctx index is busy because another ctx import or search refresh is writing to the local database; retry in a moment, or use `ctx search --refresh off` to search the existing index".to_owned(); + return "ctx index is busy because another ctx import or search refresh is writing to the local database; retry in a moment, or rerun the search with `--refresh off` to use the existing index".to_owned(); } if root == top || top.contains(&root) { top @@ -2306,7 +3003,7 @@ fn run_show( write_rendered_session(&store, &session, &events, args.mode, format, args.out)?; } ShowTarget::Event(args) => { - let event = store.get_event(args.id)?; + let event = resolve_event(&store, &args.id)?; let events = event_window(&store, &event, args.before, args.after, args.window)?; analytics::insert_count_bucket( analytics_properties, @@ -2351,7 +3048,7 @@ fn run_locate( } } LocateTarget::Event(args) => { - let event = store.get_event(args.id)?; + let event = resolve_event(&store, &args.id)?; let value = locate_event_json(&store, &event); if locate_json_output(args.format, args.json) { print_json(value)?; @@ -2377,14 +3074,12 @@ fn locate_json_output(format: LocateFormat, json: bool) -> bool { fn resolve_session( store: &Store, - id: Option, + id: Option, provider: Option, provider_session: Option<&str>, ) -> Result { if let Some(id) = id { - return store.get_session(id).with_context(|| { - format!("session {id} was not found; use `ctx search` or `ctx search --verbose` to get ctx_session_id") - }); + return resolve_session_by_id_text(store, &id); } let provider = provider.ok_or_else(|| { anyhow!( @@ -2397,14 +3092,7 @@ fn resolve_session( .ok_or_else(|| { anyhow!("session lookup requires --provider-session when no ctx session id is provided") })?; - let matches = store - .list_sessions()? - .into_iter() - .filter(|session| { - session.provider == provider - && session.external_session_id.as_deref() == Some(provider_session) - }) - .collect::>(); + let matches = store.sessions_by_external_session_limited(provider, provider_session, 2)?; match matches.as_slice() { [session] => Ok(session.clone()), [] => Err(anyhow!( @@ -2423,19 +3111,10 @@ fn event_window( after: usize, window: Option, ) -> Result> { - let Some(session_id) = event.session_id else { - return Ok(vec![event.clone()]); - }; - let events = store.events_for_session(session_id)?; - let Some(index) = events.iter().position(|candidate| candidate.id == event.id) else { - return Ok(vec![event.clone()]); - }; let (before, after) = window .map(|window| (window, window)) .unwrap_or((before, after)); - let start = index.saturating_sub(before); - let end = (index + after + 1).min(events.len()); - Ok(events[start..end].to_vec()) + Ok(store.events_for_session_window(event, before, after)?) } fn write_rendered_session( @@ -2682,6 +3361,67 @@ fn push_session_metadata_markdown( } } +fn resolve_session_by_id_text(store: &Store, value: &str) -> Result { + if let Ok(id) = Uuid::parse_str(value.trim()) { + return store.get_session(id).with_context(|| { + format!("session {id} was not found; rerun the search that found it with `--verbose` to get ctx_session_id") + }); + } + let prefix = normalize_uuid_prefix(value, "session")?; + match store.sessions_by_id_prefix(&prefix)?.as_slice() { + [session] => Ok(session.clone()), + [] => Err(anyhow!( + "session id prefix {prefix:?} was not found; rerun the search that found it with `--verbose` to get ctx_session_id" + )), + matches => Err(anyhow!( + "session id prefix {prefix:?} is ambiguous; first matches are {} and {}; use a longer ctx_session_id", + matches[0].id, + matches[1].id + )), + } +} + +fn resolve_session_id(store: &Store, value: &str) -> Result { + Ok(resolve_session_by_id_text(store, value)?.id) +} + +fn resolve_event(store: &Store, value: &str) -> Result { + if let Ok(id) = Uuid::parse_str(value.trim()) { + return store.get_event(id).with_context(|| { + format!( + "event {id} was not found; rerun the event search with `--events --verbose` to get ctx_event_id" + ) + }); + } + let prefix = normalize_uuid_prefix(value, "event")?; + match store.events_by_id_prefix(&prefix)?.as_slice() { + [event] => Ok(event.clone()), + [] => Err(anyhow!( + "event id prefix {prefix:?} was not found; rerun the event search with `--events --verbose` to get ctx_event_id" + )), + matches => Err(anyhow!( + "event id prefix {prefix:?} is ambiguous; first matches are {} and {}; use a longer ctx_event_id", + matches[0].id, + matches[1].id + )), + } +} + +fn normalize_uuid_prefix(value: &str, kind: &str) -> Result { + let prefix = value.trim(); + if prefix.len() < 8 { + return Err(anyhow!( + "{kind} id prefix must be at least 8 hex characters, or pass a full ctx UUID" + )); + } + if prefix.contains('-') || !prefix.chars().all(|ch| ch.is_ascii_hexdigit()) { + return Err(anyhow!( + "{kind} id must be a full ctx UUID or an unambiguous hex prefix from verbose search output" + )); + } + Ok(prefix.to_ascii_lowercase()) +} + fn push_event_text_block(out: &mut String, event: &Event) { let role = event.role.map(|role| role.as_str()).unwrap_or("-"); out.push_str(&format!( @@ -3059,6 +3799,11 @@ impl SearchDto { .then_some(result.more_matches_in_session), "provider": result.provider, "provider_session_id": result.provider_session_id, + "history_source": result.history_source, + "history_source_plugin": result.history_source_plugin, + "provider_key": result.provider_key, + "source_id": result.source_id, + "source_format": result.source_format, "timestamp": result.timestamp, "cwd": result.cwd, "source_path": result.raw_source_path, @@ -3245,6 +3990,18 @@ fn parse_search_limit(value: &str) -> std::result::Result { Ok(limit) } +fn parse_event_window_limit(value: &str) -> std::result::Result { + let limit = value + .parse::() + .map_err(|err| format!("invalid event window: {err}"))?; + if limit > MAX_EVENT_WINDOW { + return Err(format!( + "event window must be between 0 and {MAX_EVENT_WINDOW}" + )); + } + Ok(limit) +} + fn parse_sql_timeout(value: &str) -> std::result::Result { let trimmed = value.trim(); if trimmed.is_empty() { @@ -3293,27 +4050,28 @@ fn prune_null_json(value: &mut Value) { } } -fn run_sql(args: SqlArgs, data_root: PathBuf) -> Result<()> { - let sql = read_sql_input(&args)?; - let db_path = database_path(data_root); +fn open_existing_store_read_only(db_path: &Path, command: &str) -> Result { if !db_path.exists() { return Err(anyhow!( "ctx store is not initialized at {}; run `ctx setup` or `ctx import` first", db_path.display() )); } - let store = match Store::open_read_only(&db_path) { - Ok(store) => store, - Err(StoreError::UnsupportedSchemaVersion(version)) => { - return Err(anyhow!( - "ctx store schema version {version} is not supported by this ctx binary; run `ctx status` once to migrate before using `ctx sql`" - )); - } + match Store::open_read_only(db_path) { + Ok(store) => Ok(store), + Err(StoreError::UnsupportedSchemaVersion(version)) => Err(anyhow!( + "ctx store schema version {version} is not supported by this ctx binary; run `ctx status` once to migrate before using `{command}`" + )), Err(err) => { - return Err(err) - .with_context(|| format!("open read-only ctx store {}", db_path.display())); + Err(err).with_context(|| format!("open read-only ctx store {}", db_path.display())) } - }; + } +} + +fn run_sql(args: SqlArgs, data_root: PathBuf) -> Result<()> { + let sql = read_sql_input(&args)?; + let db_path = database_path(data_root); + let store = open_existing_store_read_only(&db_path, "ctx sql")?; let result = store.raw_sql_query( &sql, RawSqlOptions { @@ -3334,17 +4092,16 @@ fn run_sql(args: SqlArgs, data_root: PathBuf) -> Result<()> { } fn read_sql_input(args: &SqlArgs) -> Result { + let max_sql_bytes = args.max_sql_bytes.min(RAW_SQL_MAX_SQL_BYTES_CAP); match (&args.sql, &args.file) { (Some(sql), None) if sql == "-" => { - let mut input = String::new(); - std::io::stdin() - .read_to_string(&mut input) - .context("read SQL from stdin")?; - Ok(input) + read_sql_limited(std::io::stdin().lock(), max_sql_bytes, "stdin") } (Some(sql), None) => Ok(sql.clone()), (None, Some(path)) => { - fs::read_to_string(path).with_context(|| format!("read SQL from {}", path.display())) + let file = fs::File::open(path) + .with_context(|| format!("read SQL from {}", path.display()))?; + read_sql_limited(file, max_sql_bytes, &path.display().to_string()) } (None, None) => Err(anyhow!( "SQL is required; pass a statement, --file , or '-' for stdin" @@ -3353,6 +4110,21 @@ fn read_sql_input(args: &SqlArgs) -> Result { } } +fn read_sql_limited(mut reader: impl Read, max_sql_bytes: usize, label: &str) -> Result { + let mut input = String::new(); + reader + .by_ref() + .take((max_sql_bytes as u64).saturating_add(1)) + .read_to_string(&mut input) + .with_context(|| format!("read SQL from {label}"))?; + if input.len() > max_sql_bytes { + return Err(anyhow!( + "SQL input from {label} exceeds max_sql_bytes ({max_sql_bytes})" + )); + } + Ok(input) +} + fn print_sql_table(result: &RawSqlResult) -> Result<()> { let rows = result .rows @@ -3588,9 +4360,77 @@ fn run_search( data_root: PathBuf, analytics_properties: &mut AnalyticsProperties, ) -> Result<()> { + if !search_has_intent(SearchIntentInput { + query: args.query.as_deref(), + terms: &args.term, + file: args.file.as_deref(), + }) { + return Err(missing_search_intent_error()); + } + + let db_path = database_path(data_root.clone()); + let had_existing_store = db_path.exists(); + let refresh_started = Instant::now(); let refresh = refresh_before_search(&args, &data_root)?; - let store = Store::open(database_path(data_root))?; + analytics::insert_duration( + analytics_properties, + "refresh_duration", + refresh_started.elapsed(), + ); + analytics::insert_str( + analytics_properties, + "search_refresh_mode", + refresh.mode.as_str(), + ); + analytics::insert_str( + analytics_properties, + "search_refresh_status", + refresh.status, + ); + analytics::insert_count_bucket( + analytics_properties, + "search_refresh_source_count_bucket", + refresh.source_count as u64, + ); + insert_db_size_bucket(analytics_properties, &db_path); + if refresh.status == "failed" && args.refresh == RefreshArg::Auto && !had_existing_store { + return Err(anyhow!( + "search refresh failed and no existing ctx index is available; run `ctx import` first or retry with `--refresh strict`: {}", + refresh.error.as_deref().unwrap_or("unknown refresh error") + )); + } + let store = if args.refresh == RefreshArg::Off + || refresh.status == "failed" + || refresh.status == "completed" + || had_existing_store + { + open_existing_store_read_only(&db_path, "ctx search")? + } else { + Store::open(&db_path)? + }; + insert_store_analytics_counts(analytics_properties, &store)?; + let source_identity = SourceIdentityFilterArgs::from(&args); let query = args.query.unwrap_or_default(); + let query_term_count = query + .split_whitespace() + .filter(|term| !term.trim().is_empty()) + .count() + .saturating_add( + args.term + .iter() + .filter(|term| !term.trim().is_empty()) + .count(), + ); + analytics::insert_text_length_bucket( + analytics_properties, + "query_length_bucket", + query.chars().count(), + ); + analytics::insert_count_bucket( + analytics_properties, + "query_term_count_bucket", + query_term_count as u64, + ); let event_results = args.events || args.session.is_some(); let options = ctx_history_search::PacketOptions { limit: args.limit, @@ -3598,6 +4438,7 @@ fn run_search( SearchFilterInput { session: args.session, provider: args.provider, + source_identity, workspace: args.workspace.clone(), since: args.since.clone(), primary_only: args.primary_only, @@ -3616,11 +4457,17 @@ fn run_search( ..ctx_history_search::PacketOptions::default() }; let uses_composed_terms = args.term.iter().any(|term| !term.trim().is_empty()); + let query_started = Instant::now(); let packet = if uses_composed_terms { ctx_history_search::search_packet_terms(&store, &query, &args.term, &options)? } else { ctx_history_search::search_packet(&store, &query, &options)? }; + analytics::insert_duration( + analytics_properties, + "query_duration", + query_started.elapsed(), + ); let result_count = packet.results.len(); let citation_count = packet .results @@ -3637,6 +4484,8 @@ fn run_search( "citation_count_bucket", citation_count as u64, ); + analytics::insert_bool(analytics_properties, "zero_result", result_count == 0); + let render_started = Instant::now(); if args.json { let suggested_next_query = (!uses_composed_terms).then_some(query.as_str()); print_share_safe_value(SearchDto::packet( @@ -3654,15 +4503,31 @@ fn run_search( } } if packet.results.is_empty() { - println!("no results"); - if query.trim().is_empty() && !uses_composed_terms { - println!("next: ctx search \"what changed recently\" --limit 20"); + if let Some(file) = args + .file + .as_deref() + .filter(|_| query.trim().is_empty() && !uses_composed_terms) + { + println!("no indexed events touched {}", file.display()); + let indexed_items = indexed_history_item_count(&store)?; + if indexed_items == 0 { + println!("next: ctx import --all"); + } else { + println!( + "next: ctx search {}", + shell_quote_arg(&file.display().to_string()) + ); + } } else { + println!( + "no results for {}", + search_no_results_target(&query, &args.term) + ); let indexed_items = indexed_history_item_count(&store)?; if indexed_items == 0 { println!("next: ctx import --all"); } else { - println!("next: try broader terms with ctx search --term"); + println!("next: try broader terms with ctx search --term \"\""); } } } @@ -3675,6 +4540,11 @@ fn run_search( } } } + analytics::insert_duration( + analytics_properties, + "render_duration", + render_started.elapsed(), + ); Ok(()) } @@ -3715,6 +4585,18 @@ fn print_search_result_verbose( if let Some(provider_session_id) = &result.provider_session_id { println!(" provider_session_id: {provider_session_id}"); } + if let Some(history_source) = &result.history_source { + println!(" history_source: {history_source}"); + } + if let Some(provider_key) = &result.provider_key { + println!(" provider_key: {provider_key}"); + } + if let Some(source_id) = &result.source_id { + println!(" source_id: {source_id}"); + } + if let Some(source_format) = &result.source_format { + println!(" source_format: {source_format}"); + } println!(" {}", result.snippet); println!(" rank: {:.2}", result.rank); if result.result_scope == ctx_history_search::SearchResultScope::Session { @@ -3746,6 +4628,12 @@ fn search_result_summary(result: &ctx_history_search::SearchPacketResult) -> Vec if let Some(provider) = result.provider { summary.push(provider.as_str().to_owned()); } + if let Some(history_source) = &result.history_source { + summary.push(history_source.clone()); + } else if let (Some(provider_key), Some(source_id)) = (&result.provider_key, &result.source_id) + { + summary.push(format!("{provider_key}/{source_id}")); + } if result.result_scope == ctx_history_search::SearchResultScope::Session { summary.push(format!("importance {:.2}", result.session_importance)); } else { @@ -3782,18 +4670,44 @@ fn refresh_before_search(args: &SearchArgs, data_root: &Path) -> Result Ok(SearchRefreshReport::completed( + let source_identity = normalize_source_identity_filters(SourceIdentityFilterArgs::from(args))?; + if !source_identity.is_empty() + && args + .provider + .is_some_and(|provider| !matches!(provider, ProviderArg::Custom)) + { + return Err(anyhow!( + "custom history source filters can only be combined with --provider custom" + )); + } + let sources = if source_identity.is_empty() { + search_refresh_sources(args.provider) + } else { + Vec::new() + }; + let plugin_sources = + match search_refresh_plugin_sources(data_root, args.provider, &source_identity) { + Ok(sources) => sources, + Err(err) if args.refresh == RefreshArg::Auto => { + return Ok(SearchRefreshReport::failed( + RefreshArg::Auto, + sources.len(), + error_summary(&err), + )); + } + Err(err) => return Err(err.context("search refresh failed")), + }; + if sources.is_empty() && plugin_sources.is_empty() { + if args.refresh == RefreshArg::Strict { + return Err(anyhow!( + "strict search refresh found no supported discovered native provider or enabled auto history-source plugin sources; rerun the search with --refresh off to use the existing index" + )); + } + return Ok(SearchRefreshReport::skipped(args.refresh, "no_sources")); + } + let source_count = sources.len().saturating_add(plugin_sources.len()); + match refresh_sources_for_search(data_root, sources, plugin_sources, args.refresh, args.json) { + Ok(totals) => Ok(SearchRefreshReport::completed( args.refresh, source_count, totals, @@ -3820,16 +4734,35 @@ fn search_refresh_sources(provider: Option) -> Vec { .drain(..) .filter(|source| { source.exists - && matches!(source.import_support, ProviderImportSupport::Native) + && source.import_support.is_auto_importable() && source.status == ProviderSourceStatus::Available && source.source_format != "codex_history_jsonl" }) .collect() } +fn search_refresh_plugin_sources( + data_root: &Path, + provider: Option, + source_identity: &SourceIdentityFilters, +) -> Result> { + if !matches!(provider, None | Some(ProviderArg::Custom)) { + return Ok(Vec::new()); + } + Ok(discover_history_source_plugins(data_root, &[])? + .into_iter() + .filter(|source| { + source.enabled + && source.refresh == HistorySourcePluginRefresh::Auto + && source_identity.matches_plugin_source(source) + }) + .collect()) +} + fn refresh_sources_for_search( data_root: &Path, sources: Vec, + plugin_sources: Vec, refresh: RefreshArg, json_output: bool, ) -> Result { @@ -3840,7 +4773,7 @@ fn refresh_sources_for_search( .into_iter() .map(|source| (source, SourceStats::default())) .collect::>(); - if planned_sources.is_empty() { + if planned_sources.is_empty() && plugin_sources.is_empty() { return Ok(ImportTotals::default()); } @@ -3925,29 +4858,75 @@ fn refresh_sources_for_search( } } + if !plugin_sources.is_empty() { + let mut store = Store::open(&db_path)?; + for plugin_source in plugin_sources { + progress.message( + "refreshing", + format!("running history source plugin {}", plugin_source.label()), + ); + let (summary, stats) = + import_history_source_plugin(&mut store, &plugin_source, data_root, false) + .with_context(|| { + format!("refresh history source plugin {}", plugin_source.label()) + })?; + totals.add(&summary, &stats); + progress.done( + "refreshing", + format!("refreshed history source plugin {}", plugin_source.label()), + 0, + ); + } + } + Store::open(&db_path)?.checkpoint_wal_truncate_if_larger_than(WAL_TRUNCATE_MIN_BYTES)?; Ok(totals) } fn run_doctor( - args: JsonArgs, + args: DoctorArgs, data_root: PathBuf, analytics_properties: &mut AnalyticsProperties, ) -> Result<()> { - let store = Store::open(database_path(data_root.clone()))?; - let mut findings = store.validate()?; + let progress = ProgressReporter::new(args.progress, args.json, "doctor", 0); + progress.message("opening", "opening ctx store"); + let db_path = database_path(data_root.clone()); + let mut findings = Vec::new(); if !data_root.exists() { findings.push(format!("data root does not exist: {}", data_root.display())); } + if !db_path.exists() { + findings.push(format!( + "ctx store is not initialized at {}; run `ctx setup` or `ctx import` first", + db_path.display() + )); + } else { + let store = open_existing_store_read_only(&db_path, "ctx doctor")?; + progress.message( + "checking", + "running sqlite integrity and foreign key checks", + ); + findings.extend(store.validate()?); + } analytics::insert_count_bucket( analytics_properties, "finding_count_bucket", findings.len() as u64, ); + progress.done( + "done", + if findings.is_empty() { + "ctx doctor passed" + } else { + "ctx doctor found issues" + }, + 0, + ); if args.json { print_json(json!({ "schema_version": 1, "ok": findings.is_empty(), + "progress": progress_mode_name(args.progress), "findings": findings, }))?; } else if findings.is_empty() { @@ -3960,13 +4939,35 @@ fn run_doctor( Ok(()) } +fn validate_import_args(args: &ImportArgs) -> Result<()> { + if args.path.is_some() && args.format.is_none() && args.provider.is_none() { + return Err(anyhow!( + "ctx import --path requires --provider for native provider history; use `ctx import --provider codex --path ` or `ctx import --format ctx-history-jsonl-v1 --path `" + )); + } + Ok(()) +} + fn import_requests(args: &ImportArgs) -> Result> { + if args.history_source.is_some() || !args.history_source_manifest.is_empty() { + return Ok(Vec::new()); + } if let Some(path) = &args.path { let provider = args .provider - .unwrap_or(ProviderArg::Codex) + .context("ctx import --path requires --provider for native provider history")? .capture_provider(); let source = explicit_path_source(provider, path.clone()); + if !source + .path + .try_exists() + .with_context(|| format!("check import path {}", source.path.display()))? + { + return Err(anyhow!( + "import path does not exist: {}", + source.path.display() + )); + } validate_source_import_supported(&source)?; return Ok(vec![source]); } @@ -3975,32 +4976,37 @@ fn import_requests(args: &ImportArgs) -> Result> { .into_iter() .filter(|source| { source.exists - && matches!(source.import_support, ProviderImportSupport::Native) + && source.import_support.is_auto_importable() && source.status == ProviderSourceStatus::Available }) .collect()); } let provider = args.provider.expect("checked provider").capture_provider(); - let sources = discovered_sources() - .into_iter() + let discovered = discovered_sources_for_provider(provider); + let sources = discovered + .iter() .filter(|source| { source.provider == provider && source.exists + && source.import_support.is_importable() && source.status == ProviderSourceStatus::Available }) + .cloned() .collect::>(); if sources.is_empty() { let spec = provider_source_spec(provider); - if let Some(reason) = spec.and_then(|spec| spec.unsupported_reason) { + if spec + .is_some_and(|spec| matches!(spec.import_support, ProviderImportSupport::Unsupported)) + { + let reason = spec + .and_then(|spec| spec.unsupported_reason) + .unwrap_or("no native local-history parser is implemented"); return Err(anyhow!( "{} native import is unsupported: {reason}", provider.as_str() )); } - return Err(anyhow!( - "no native {} history found; use `ctx sources` to inspect discovered provider paths", - provider.as_str() - )); + return Err(no_importable_provider_sources_error(provider, &discovered)); } for source in &sources { validate_source_import_supported(source)?; @@ -4008,9 +5014,345 @@ fn import_requests(args: &ImportArgs) -> Result> { Ok(sources) } +fn no_importable_provider_sources_error( + provider: CaptureProvider, + sources: &[SourceInfo], +) -> anyhow::Error { + let mut message = format!("no importable {} history found", provider.as_str()); + if sources.is_empty() { + message.push_str("; no default paths are registered for this provider"); + } else { + message.push_str("\nchecked paths:"); + for source in sources { + message.push_str(&format!( + "\n {} ({})", + source.path.display(), + source.status.as_str() + )); + if let Some(reason) = source.unsupported_reason { + message.push_str(&format!(" - {reason}")); + } + } + } + message.push_str("\nuse `ctx sources` to inspect discovery, or pass --path"); + anyhow!(message) +} + +fn history_source_plugin_import_requests( + args: &ImportArgs, + data_root: &Path, + include_plugins: bool, +) -> Result> { + if !include_plugins { + return Ok(Vec::new()); + } + if !args.all && args.history_source.is_none() && args.history_source_manifest.is_empty() { + return Ok(Vec::new()); + } + let sources = discover_history_source_plugins(data_root, &args.history_source_manifest)?; + if let Some(selector) = &args.history_source { + let matches = sources + .into_iter() + .filter(|source| source.matches_selector(selector)) + .collect::>(); + if matches.is_empty() { + return Err(anyhow!( + "no history source plugin matched `{selector}`; use `ctx sources` to inspect configured plugins" + )); + } + if matches.len() > 1 { + let labels = matches + .iter() + .map(HistorySourcePluginSource::label) + .collect::>() + .join(", "); + return Err(anyhow!( + "history source plugin selector `{selector}` matched multiple sources ({labels}); use plugin/source or provider_key/source_id" + )); + } + return Ok(matches); + } + if args.all { + return Ok(sources + .into_iter() + .filter(|source| source.enabled) + .collect()); + } + Ok(sources + .into_iter() + .filter(|source| { + args.history_source_manifest + .iter() + .any(|path| manifest_arg_matches_source(path, &source.manifest_path)) + }) + .collect()) +} + +fn manifest_arg_matches_source(arg: &Path, manifest_path: &Path) -> bool { + if arg.is_file() { + return same_pathish(arg, manifest_path); + } + if arg.is_dir() { + return manifest_path.starts_with(arg); + } + same_pathish(arg, manifest_path) +} + +fn same_pathish(left: &Path, right: &Path) -> bool { + if left == right { + return true; + } + let left = fs::canonicalize(left).unwrap_or_else(|_| left.to_path_buf()); + let right = fs::canonicalize(right).unwrap_or_else(|_| right.to_path_buf()); + left == right +} + +fn import_history_source_plugin( + store: &mut Store, + source: &HistorySourcePluginSource, + data_root: &Path, + full_rescan: bool, +) -> Result<(ProviderImportSummary, SourceStats)> { + let record = import_record_for_history_source_plugin(source); + let record_id = record.id; + let options = CustomHistoryJsonlV1ImportOptions::default(); + let machine_id = options.machine_id.clone(); + let cursor_stream = source.cursor_stream(); + let previous_cursor = if full_rescan { + None + } else { + store + .get_sync_cursor(None, &machine_id, &cursor_stream)? + .map(|cursor| cursor.cursor) + }; + let run = run_history_source_plugin( + source, + HistorySourcePluginRunOptions { + data_root, + machine_id: &machine_id, + cursor: previous_cursor.as_deref(), + cursor_stream: &cursor_stream, + full_rescan, + }, + )?; + let _plugin_stderr = &run.stderr; + validate_history_source_plugin_output(source, &run.stdout, &machine_id, full_rescan)?; + let stdout = annotate_history_source_plugin_output(source, &run.stdout)?; + let validation = validate_custom_history_jsonl_v1_reader(Cursor::new(stdout.as_slice())) + .map_err(anyhow::Error::from)?; + if validation.failed > 0 { + return Err(history_source_plugin_import_failure(source, &validation)); + } + let stats = SourceStats { + files: 1, + bytes: stdout.len() as u64, + }; + store.upsert_record(&record)?; + let summary = import_custom_history_jsonl_v1_reader( + Cursor::new(stdout), + store, + CustomHistoryJsonlV1ImportOptions { + machine_id, + source_path: Some(source.manifest_path.clone()), + history_record_id: Some(record_id), + allow_partial_failures: false, + ..options + }, + ) + .map_err(anyhow::Error::from)?; + if summary.failed > 0 { + return Err(history_source_plugin_import_failure(source, &summary)); + } + Ok((summary, stats)) +} + +fn annotate_history_source_plugin_output( + source: &HistorySourcePluginSource, + stdout: &[u8], +) -> Result> { + let mut out = Vec::with_capacity(stdout.len()); + for (line_number, line) in history_source_plugin_stdout_lines(source, stdout)? { + if line.trim().is_empty() { + continue; + } + let mut record: CtxHistoryJsonlRecord = serde_json::from_str(line).with_context(|| { + format!( + "history source plugin {} emitted invalid ctx-history-jsonl-v1 at line {line_number}", + source.label() + ) + })?; + if let CtxHistoryJsonlRecord::Source(source_record) = &mut record { + let mut metadata = match std::mem::take(&mut source_record.metadata) { + Value::Object(map) => map, + Value::Null => serde_json::Map::new(), + other => { + let mut map = serde_json::Map::new(); + map.insert("metadata".to_owned(), other); + map + } + }; + metadata.insert( + "ctx_history_plugin".to_owned(), + json!({ + "plugin_name": source.plugin_name, + "plugin_source_id": source.id, + "history_source": source.label(), + "plugin_display_name": source.plugin_display_name, + "plugin_version": source.plugin_version, + "manifest_path": source.manifest_path, + "provider_key": source.provider_key, + "source_id": source.source_id, + "source_format": source.source_format, + }), + ); + source_record.metadata = Value::Object(metadata); + } + serde_json::to_writer(&mut out, &record).with_context(|| { + format!( + "serialize annotated history source plugin {} record at line {line_number}", + source.label() + ) + })?; + out.push(b'\n'); + } + Ok(out) +} + +fn validate_history_source_plugin_output( + source: &HistorySourcePluginSource, + stdout: &[u8], + machine_id: &str, + require_after_cursor: bool, +) -> Result<()> { + let mut saw_source = false; + let mut saw_after_cursor = false; + for (line_number, line) in history_source_plugin_stdout_lines(source, stdout)? { + if line.trim().is_empty() { + continue; + } + let record: CtxHistoryJsonlRecord = serde_json::from_str(line).with_context(|| { + format!( + "history source plugin {} emitted invalid ctx-history-jsonl-v1 at line {line_number}", + source.label() + ) + })?; + let CtxHistoryJsonlRecord::Source(source_record) = record else { + continue; + }; + saw_source = true; + if source_record + .cursor + .as_ref() + .and_then(|cursor| cursor.after.as_ref()) + .is_some() + { + saw_after_cursor = true; + } + if source_record.provider_key != source.provider_key + || source_record.source_id != source.source_id + || source_record.source_format != source.source_format + { + return Err(anyhow!( + "history source plugin {} emitted source identity {}/{}/{} but manifest declares {}/{}/{}", + source.label(), + source_record.provider_key, + source_record.source_id, + source_record.source_format, + source.provider_key, + source.source_id, + source.source_format + )); + } + if let Some(source_machine_id) = source_record.machine_id { + if source_machine_id != machine_id { + return Err(anyhow!( + "history source plugin {} emitted machine_id `{source_machine_id}` but ctx is importing as `{machine_id}`; omit machine_id or set it to CTX_HISTORY_MACHINE_ID", + source.label() + )); + } + } + } + if !saw_source { + return Err(anyhow!( + "history source plugin {} emitted no source record", + source.label() + )); + } + if require_after_cursor && !saw_after_cursor { + return Err(anyhow!( + "history source plugin {} was reset but emitted no source.cursor.after checkpoint; emit a fresh cursor after a full rescan", + source.label() + )); + } + Ok(()) +} + +fn history_source_plugin_stdout_lines<'a>( + source: &HistorySourcePluginSource, + stdout: &'a [u8], +) -> Result> { + let mut lines = Vec::new(); + let mut start = 0usize; + let mut line_number = 1usize; + for (index, byte) in stdout.iter().enumerate() { + let len = index.saturating_add(1).saturating_sub(start); + if len > MAX_HISTORY_SOURCE_PLUGIN_JSONL_LINE_BYTES { + return Err(anyhow!( + "history source plugin {} emitted ctx-history-jsonl-v1 line {line_number} exceeding max bytes ({MAX_HISTORY_SOURCE_PLUGIN_JSONL_LINE_BYTES})", + source.label() + )); + } + if *byte == b'\n' { + let line = std::str::from_utf8(&stdout[start..index]).with_context(|| { + format!( + "history source plugin {} emitted non-UTF-8 ctx-history-jsonl-v1 output at line {line_number}", + source.label() + ) + })?; + lines.push((line_number, line)); + start = index + 1; + line_number += 1; + } + } + if start < stdout.len() { + let len = stdout.len().saturating_sub(start); + if len > MAX_HISTORY_SOURCE_PLUGIN_JSONL_LINE_BYTES { + return Err(anyhow!( + "history source plugin {} emitted ctx-history-jsonl-v1 line {line_number} exceeding max bytes ({MAX_HISTORY_SOURCE_PLUGIN_JSONL_LINE_BYTES})", + source.label() + )); + } + let line = std::str::from_utf8(&stdout[start..]).with_context(|| { + format!( + "history source plugin {} emitted non-UTF-8 ctx-history-jsonl-v1 output at line {line_number}", + source.label() + ) + })?; + lines.push((line_number, line)); + } + Ok(lines) +} + +fn history_source_plugin_import_failure( + source: &HistorySourcePluginSource, + summary: &ProviderImportSummary, +) -> anyhow::Error { + let detail = summary + .failures + .first() + .map(|failure| format!("line {}: {}", failure.line, failure.error)) + .unwrap_or_else(|| "unknown validation failure".to_owned()); + anyhow!( + "history source plugin {} import failed with {} failure(s); first failure: {detail}", + source.label(), + summary.failed + ) +} + fn validate_source_import_supported(source: &SourceInfo) -> Result<()> { match source.import_support { ProviderImportSupport::Native => Ok(()), + ProviderImportSupport::Preview => Ok(()), ProviderImportSupport::Unsupported => { let reason = source .unsupported_reason @@ -4172,6 +5514,61 @@ fn import_one_source_inner( }, ) .map_err(anyhow::Error::from), + CaptureProvider::OpenClaw => import_openclaw_history( + &source.path, + store, + OpenClawImportOptions { + source_path: Some(source.path.clone()), + history_record_id: Some(record_id), + allow_partial_failures: true, + ..OpenClawImportOptions::default() + }, + ) + .map_err(anyhow::Error::from), + CaptureProvider::Hermes => import_hermes_sqlite( + &source.path, + store, + HermesSqliteImportOptions { + source_path: Some(source.path.clone()), + history_record_id: Some(record_id), + allow_partial_failures: true, + ..HermesSqliteImportOptions::default() + }, + ) + .map_err(anyhow::Error::from), + CaptureProvider::NanoClaw => import_nanoclaw_project( + &source.path, + store, + NanoClawImportOptions { + source_path: Some(source.path.clone()), + history_record_id: Some(record_id), + allow_partial_failures: true, + ..NanoClawImportOptions::default() + }, + ) + .map_err(anyhow::Error::from), + CaptureProvider::AstrBot => import_astrbot_sqlite( + &source.path, + store, + AstrBotSqliteImportOptions { + source_path: Some(source.path.clone()), + history_record_id: Some(record_id), + allow_partial_failures: true, + ..AstrBotSqliteImportOptions::default() + }, + ) + .map_err(anyhow::Error::from), + CaptureProvider::Shelley => import_shelley_sqlite( + &source.path, + store, + ShelleySqliteImportOptions { + source_path: Some(source.path.clone()), + history_record_id: Some(record_id), + allow_partial_failures: true, + ..ShelleySqliteImportOptions::default() + }, + ) + .map_err(anyhow::Error::from), CaptureProvider::Gemini => import_gemini_cli_history( &source.path, store, @@ -4261,7 +5658,7 @@ fn import_manifested_source( .iter() .map(|file| file.source_path.clone()) .collect::>(); - let observed_at_ms = Utc::now().timestamp_millis(); + let observed_at_ms = utc_now().timestamp_millis(); store.begin_immediate_batch()?; let persist = (|| -> Result<()> { store.upsert_source_import_files(&files)?; @@ -4302,7 +5699,7 @@ fn import_manifested_source( source_path: &pending_file.source_path, file_size_bytes: pending_file.file_size_bytes, file_modified_at_ms: pending_file.file_modified_at_ms, - indexed_at_ms: Utc::now().timestamp_millis(), + indexed_at_ms: utc_now().timestamp_millis(), }, )?; merge_provider_import_summary(&mut summary, file_summary); @@ -4313,7 +5710,7 @@ fn import_manifested_source( &source_root, &pending_file.source_path, &err.to_string(), - Utc::now().timestamp_millis(), + utc_now().timestamp_millis(), )?; return Err(err); } @@ -4328,7 +5725,15 @@ fn import_manifested_source( } fn source_uses_import_file_manifest(source: &SourceInfo) -> bool { - source.source_format != "codex_session_jsonl_tree" + !matches!( + source.source_format, + "codex_session_jsonl_tree" + | "openclaw_session_jsonl_tree" + | "hermes_state_sqlite" + | "nanoclaw_project" + | "astrbot_data_v4_sqlite" + | "shelley_sqlite" + ) } fn merge_provider_import_summary( @@ -4351,7 +5756,7 @@ fn merge_provider_import_summary( fn collect_source_import_files(source: &SourceInfo) -> Result> { let paths = collect_source_import_paths(source)?; let source_root = source.path.display().to_string(); - let observed_at_ms = Utc::now().timestamp_millis(); + let observed_at_ms = utc_now().timestamp_millis(); let mut files = Vec::with_capacity(paths.len()); for path in paths { let metadata = fs::metadata(&path) @@ -4371,7 +5776,8 @@ fn collect_source_import_files(source: &SourceInfo) -> Result Result> { - let metadata = fs::symlink_metadata(&source.path)?; + let metadata = fs::symlink_metadata(&source.path) + .with_context(|| format!("stat import source {}", source.path.display()))?; if metadata.file_type().is_symlink() { return Err(anyhow!( "symlinked provider transcript roots are rejected: {}", @@ -4392,10 +5798,15 @@ fn collect_source_import_paths(source: &SourceInfo) -> Result> { let mut paths = Vec::new(); let mut stack = vec![source.path.clone()]; while let Some(dir) = stack.pop() { - for entry in fs::read_dir(&dir)? { - let entry = entry?; + for entry in fs::read_dir(&dir) + .with_context(|| format!("read import source directory {}", dir.display()))? + { + let entry = entry + .with_context(|| format!("read import source entry under {}", dir.display()))?; let path = entry.path(); - let file_type = entry.file_type()?; + let file_type = entry + .file_type() + .with_context(|| format!("stat import source entry {}", path.display()))?; if file_type.is_dir() { stack.push(path); } else if file_type.is_file() && source_import_file_matches(source, &path) { @@ -4474,9 +5885,21 @@ fn import_incremental_codex_session_tree( &session.source_path, )?; let tail_start = state - .and_then(|state| state.indexed_file_size_bytes) + .as_ref() + .and_then(|state| state.last_imported_file_size_bytes) .filter(|indexed_size| *indexed_size > 0 && *indexed_size < session.file_size_bytes); if let Some(start_offset) = tail_start { + let checkpoint_hash = state + .as_ref() + .and_then(|state| state.last_imported_file_sha256.as_deref()); + if !catalog_import_checkpoint_matches( + Path::new(&session.source_path), + start_offset, + checkpoint_hash, + )? { + full_import_sessions.push(session.clone()); + continue; + } let tail_summary = match import_codex_session_jsonl_tail( PathBuf::from(&session.source_path), start_offset, @@ -4504,7 +5927,28 @@ fn import_incremental_codex_session_tree( return Err(err); } }; - mark_catalog_sessions_indexed(store, std::slice::from_ref(session), &tail_summary)?; + if tail_summary.failed > 0 { + mark_catalog_sessions_failed( + store, + std::slice::from_ref(session), + "tail import failed for one or more appended events", + )?; + merge_provider_import_summary(&mut summary, tail_summary); + continue; + } + let tail_event_count = tail_summary + .imported_events + .saturating_add(tail_summary.skipped_events) + as u64; + let event_count = state + .and_then(|state| state.last_imported_event_count) + .map(|event_count| event_count.saturating_add(tail_event_count)); + mark_catalog_session_indexed( + store, + session, + event_count, + utc_now().timestamp_millis(), + )?; merge_provider_import_summary(&mut summary, tail_summary); } else { full_import_sessions.push(session.clone()); @@ -4549,36 +5993,84 @@ fn mark_catalog_sessions_indexed( sessions: &[CatalogSession], summary: &ProviderImportSummary, ) -> Result<()> { - let indexed_at_ms = Utc::now().timestamp_millis(); + let indexed_at_ms = utc_now().timestamp_millis(); let event_count = if sessions.len() == 1 { - summary - .imported_events - .saturating_add(summary.skipped_events) as u64 + Some( + summary + .imported_events + .saturating_add(summary.skipped_events) as u64, + ) } else { - 0 + None }; for session in sessions { - store.mark_catalog_source_indexed( - session.provider, - CatalogSourceIndexUpdate { - source_root: &session.source_root, - source_path: &session.source_path, - file_size_bytes: session.file_size_bytes, - file_modified_at_ms: session.file_modified_at_ms, - event_count, - indexed_at_ms, - }, - )?; + mark_catalog_session_indexed(store, session, event_count, indexed_at_ms)?; } Ok(()) } +fn mark_catalog_session_indexed( + store: &Store, + session: &CatalogSession, + event_count: Option, + indexed_at_ms: i64, +) -> Result<()> { + let file_sha256 = + sha256_file_prefix_hex(Path::new(&session.source_path), session.file_size_bytes) + .with_context(|| format!("hash checkpoint prefix for {}", session.source_path))?; + store.mark_catalog_source_indexed( + session.provider, + CatalogSourceIndexUpdate { + source_root: &session.source_root, + source_path: &session.source_path, + file_size_bytes: session.file_size_bytes, + file_modified_at_ms: session.file_modified_at_ms, + file_sha256: Some(&file_sha256), + event_count, + indexed_at_ms, + }, + )?; + Ok(()) +} + +fn catalog_import_checkpoint_matches( + path: &Path, + byte_count: u64, + expected_sha256: Option<&str>, +) -> Result { + let Some(expected_sha256) = expected_sha256 else { + return Ok(true); + }; + let actual_sha256 = sha256_file_prefix_hex(path, byte_count)?; + Ok(actual_sha256 == expected_sha256) +} + +fn sha256_file_prefix_hex(path: &Path, byte_count: u64) -> Result { + let mut file = fs::File::open(path)?; + let mut hasher = Sha256::new(); + let mut remaining = byte_count; + let mut buffer = [0_u8; 8192]; + while remaining > 0 { + let to_read = buffer.len().min(remaining as usize); + let read = file.read(&mut buffer[..to_read])?; + if read == 0 { + return Err(anyhow!( + "file ended before checkpoint byte offset {byte_count}: {}", + path.display() + )); + } + hasher.update(&buffer[..read]); + remaining -= read as u64; + } + Ok(format!("{:x}", hasher.finalize())) +} + fn mark_catalog_sessions_failed( store: &Store, sessions: &[CatalogSession], error: &str, ) -> Result<()> { - let indexed_at_ms = Utc::now().timestamp_millis(); + let indexed_at_ms = utc_now().timestamp_millis(); for session in sessions { store.mark_catalog_source_failed( session.provider, @@ -4644,7 +6136,8 @@ fn codex_include_notices() -> bool { } fn source_stats(path: &Path) -> Result { - let metadata = fs::symlink_metadata(path)?; + let metadata = fs::symlink_metadata(path) + .with_context(|| format!("stat import source {}", path.display()))?; if metadata.file_type().is_file() { return Ok(SourceStats { files: 1, @@ -4658,13 +6151,21 @@ fn source_stats(path: &Path) -> Result { let mut stats = SourceStats::default(); let mut stack = vec![path.to_path_buf()]; while let Some(dir) = stack.pop() { - for entry in fs::read_dir(&dir)? { - let entry = entry?; - let file_type = entry.file_type()?; + for entry in fs::read_dir(&dir) + .with_context(|| format!("read import source directory {}", dir.display()))? + { + let entry = entry + .with_context(|| format!("read import source entry under {}", dir.display()))?; + let entry_path = entry.path(); + let file_type = entry + .file_type() + .with_context(|| format!("stat import source entry {}", entry_path.display()))?; if file_type.is_dir() { - stack.push(entry.path()); + stack.push(entry_path); } else if file_type.is_file() { - let metadata = entry.metadata()?; + let metadata = entry + .metadata() + .with_context(|| format!("stat import source file {}", entry_path.display()))?; stats.files += 1; stats.bytes = stats.bytes.saturating_add(metadata.len()); } @@ -4694,6 +6195,56 @@ fn import_record_for_source(source: &SourceInfo) -> HistoryRecord { record } +fn import_record_for_custom_history(path: &Path, format: ImportFormatArg) -> HistoryRecord { + let key = format!("custom-history:{}:{}", format.as_str(), path.display()); + let mut record = HistoryRecord::new( + "custom agent history".to_owned(), + format!( + "Indexed custom agent history from {} ({})", + path.display(), + format.as_str() + ), + vec![ + "agent-history".into(), + "custom".into(), + format.as_str().into(), + ], + "agent_history", + path.parent().map(|path| path.display().to_string()), + ); + record.id = stable_capture_uuid(&key, "record"); + record +} + +fn import_record_for_history_source_plugin(source: &HistorySourcePluginSource) -> HistoryRecord { + let key = format!( + "history-source-plugin:{}:{}:{}:{}:{}", + source.plugin_name, source.id, source.provider_key, source.source_id, source.source_format + ); + let mut record = HistoryRecord::new( + format!("history source plugin {}", source.label()), + format!( + "Indexed custom agent history from history source plugin {} ({})", + source.label(), + source.source_format + ), + vec![ + "agent-history".into(), + "custom".into(), + "history-source-plugin".into(), + source.provider_key.clone(), + source.source_format.clone(), + ], + "agent_history", + source + .manifest_path + .parent() + .map(|path| path.display().to_string()), + ); + record.id = stable_capture_uuid(&key, "record"); + record +} + fn discovered_sources() -> Vec { home_dir() .as_deref() @@ -4701,6 +6252,13 @@ fn discovered_sources() -> Vec { .unwrap_or_default() } +fn discovered_sources_for_provider(provider: CaptureProvider) -> Vec { + home_dir() + .as_deref() + .map(|home| discover_provider_sources_for_provider(home, provider)) + .unwrap_or_default() +} + fn explicit_path_source(provider: CaptureProvider, path: PathBuf) -> SourceInfo { source_for_path(provider, path) } @@ -4720,9 +6278,9 @@ fn sources_json(sources: &[SourceInfo]) -> Vec { "source_format": source.source_format, "status": source.status.as_str(), "import_support": import_support_json(source.import_support), - "native_import": matches!(source.import_support, ProviderImportSupport::Native), + "native_import": source.import_support.is_auto_importable(), "importable": source.status == ProviderSourceStatus::Available - && matches!(source.import_support, ProviderImportSupport::Native), + && source.import_support.is_importable(), "raw_retention": raw_retention_json(source.raw_retention), "unsupported_reason": source.unsupported_reason, }) @@ -4730,9 +6288,78 @@ fn sources_json(sources: &[SourceInfo]) -> Vec { .collect() } +fn plugin_sources_json(sources: &[HistorySourcePluginSource]) -> Vec { + sources + .iter() + .map(|source| { + json!({ + "provider": CaptureProvider::Custom.as_str(), + "kind": "history_source_plugin", + "plugin": source.plugin_name, + "plugin_display_name": source.plugin_display_name, + "plugin_version": source.plugin_version, + "history_source": source.label(), + "history_source_id": source.id, + "display_name": source.display_name, + "provider_key": source.provider_key, + "source_id": source.source_id, + "source_format": source.source_format, + "manifest_path": source.manifest_path, + "enabled": source.enabled, + "refresh": history_source_plugin_refresh_json(source.refresh), + "status": "available", + "import_support": "history_source_plugin", + "native_import": false, + "importable": true, + "raw_retention": "metadata_only", + "unsupported_reason": null, + }) + }) + .collect() +} + +fn plugin_manifest_failures_json(failures: &[HistorySourcePluginManifestFailure]) -> Vec { + failures + .iter() + .map(|failure| { + json!({ + "provider": CaptureProvider::Custom.as_str(), + "kind": "history_source_plugin", + "plugin": null, + "plugin_display_name": null, + "plugin_version": null, + "history_source": null, + "history_source_id": null, + "display_name": null, + "provider_key": null, + "source_id": null, + "source_format": null, + "manifest_path": failure.manifest_path, + "enabled": false, + "refresh": null, + "status": "invalid", + "import_support": "history_source_plugin", + "native_import": false, + "importable": false, + "raw_retention": "metadata_only", + "unsupported_reason": failure.error, + "error": failure.error, + }) + }) + .collect() +} + +fn history_source_plugin_refresh_json(refresh: HistorySourcePluginRefresh) -> &'static str { + match refresh { + HistorySourcePluginRefresh::Manual => "manual", + HistorySourcePluginRefresh::Auto => "auto", + } +} + fn import_support_json(support: ProviderImportSupport) -> &'static str { match support { ProviderImportSupport::Native => "native", + ProviderImportSupport::Preview => "preview", ProviderImportSupport::Unsupported => "unsupported", } } @@ -4751,18 +6378,47 @@ fn search_filters( input: SearchFilterInput, store: Option<&Store>, ) -> Result { - let exclude_provider_session = if input.include_current_session || input.session.is_some() { + let source_identity = normalize_source_identity_filters(input.source_identity)?; + if !source_identity.is_empty() + && input + .provider + .is_some_and(|provider| !matches!(provider, ProviderArg::Custom)) + { + return Err(anyhow!( + "custom history source filters can only be combined with --provider custom" + )); + } + let provider = if !source_identity.is_empty() { + Some(CaptureProvider::Custom) + } else { + input.provider.map(ProviderArg::capture_provider) + }; + let session = input + .session + .as_deref() + .map(|value| { + let store = store.ok_or_else(|| { + anyhow!("session id prefix resolution requires an open ctx store") + })?; + resolve_session_id(store, value) + }) + .transpose()?; + let exclude_provider_session = if input.include_current_session || session.is_some() { None } else { current_codex_provider_session_filter(store) }; Ok(ctx_history_search::SearchFilters { - session: input.session, - provider: input.provider.map(ProviderArg::capture_provider), + session, + provider, + history_source: source_identity.history_source, + provider_key: source_identity.provider_key, + source_id: source_identity.source_id, + source_format: source_identity.source_format, repo: input.workspace, since: input.since.as_deref().map(parse_since_filter).transpose()?, primary_only: input.primary_only, - include_subagents: input.include_subagents || !input.primary_only, + include_subagents: input.include_subagents && !input.primary_only, event_type: input .event_type .as_deref() @@ -4774,6 +6430,40 @@ fn search_filters( }) } +fn normalize_source_identity_filters( + input: SourceIdentityFilterArgs, +) -> Result { + let history_source = normalize_source_identity_filter("history-source", input.history_source)?; + if history_source + .as_deref() + .is_some_and(|value| !value.contains('/')) + { + return Err(anyhow!( + "--history-source expects plugin/source or provider_key/source_id" + )); + } + Ok(SourceIdentityFilters { + history_source, + provider_key: normalize_source_identity_filter("provider-key", input.provider_key)?, + source_id: normalize_source_identity_filter("source-id", input.source_id)?, + source_format: normalize_source_identity_filter("source-format", input.source_format)?, + }) +} + +fn normalize_source_identity_filter(label: &str, value: Option) -> Result> { + let Some(value) = value else { + return Ok(None); + }; + let value = value.trim(); + if value.is_empty() { + return Err(anyhow!("--{label} cannot be empty")); + } + if value.chars().any(char::is_control) { + return Err(anyhow!("--{label} cannot contain control characters")); + } + Ok(Some(value.to_owned())) +} + fn current_codex_provider_session_filter( store: Option<&Store>, ) -> Option { @@ -4803,7 +6493,12 @@ fn parse_since_filter(value: &str) -> Result> { let days: i64 = days .parse() .with_context(|| format!("invalid --since day window: {value}"))?; - return Ok(Utc::now() - Duration::days(days)); + let duration = Duration::try_days(days) + .ok_or_else(|| anyhow!("invalid --since day window: {value}: value too large"))?; + let since = utc_now() + .checked_sub_signed(duration) + .ok_or_else(|| anyhow!("invalid --since day window: {value}: value too large"))?; + return Ok(since); } Ok(chrono::DateTime::parse_from_rfc3339(trimmed) .with_context(|| format!("invalid --since value: {value}"))? @@ -4832,7 +6527,13 @@ fn home_dir() -> Option { #[cfg(test)] mod tests { - use super::shell_quote_arg; + use super::{ + catalog_import_checkpoint_matches, normalize_uuid_prefix, parse_event_window_limit, + parse_search_limit, parse_since_filter, parse_sql_timeout, sha256_file_prefix_hex, + shell_quote_arg, + }; + use std::{fs, io::Write, panic}; + use tempfile::tempdir; #[test] fn shell_quote_arg_uses_single_quotes_for_shell_metacharacters() { @@ -4842,4 +6543,80 @@ mod tests { "'$(touch /tmp/ctx-owned)'\\''s'" ); } + + #[test] + fn parse_since_filter_rejects_large_day_window() { + let err = parse_since_filter("500000000d").unwrap_err(); + let msg = format!("{err:#}"); + assert!( + msg.contains("invalid --since day window"), + "expected error about invalid day window, got: {msg}" + ); + } + + #[test] + fn cli_value_parsers_do_not_panic_on_adversarial_inputs() { + let inputs = [ + "", + " ", + "0", + "-1", + "1", + "30d", + "500000000d", + "9223372036854775807d", + "-9223372036854775808d", + "999999999999999999999999999999d", + "NaN", + "inf", + "1e309", + "1.5d", + "1970-01-01T00:00:00Z", + "999999-99-99T99:99:99Z", + "zzzzzzzz", + "ffffffff", + "ffffffff-ffff-ffff-ffff-ffffffffffff", + "\0", + "123", + ]; + + for input in inputs { + assert!( + panic::catch_unwind(|| parse_since_filter(input)).is_ok(), + "parse_since_filter panicked for {input:?}" + ); + assert!( + panic::catch_unwind(|| parse_search_limit(input)).is_ok(), + "parse_search_limit panicked for {input:?}" + ); + assert!( + panic::catch_unwind(|| parse_event_window_limit(input)).is_ok(), + "parse_event_window_limit panicked for {input:?}" + ); + assert!( + panic::catch_unwind(|| parse_sql_timeout(input)).is_ok(), + "parse_sql_timeout panicked for {input:?}" + ); + assert!( + panic::catch_unwind(|| normalize_uuid_prefix(input, "test")).is_ok(), + "normalize_uuid_prefix panicked for {input:?}" + ); + } + } + + #[test] + fn catalog_import_checkpoint_requires_matching_hash() { + let temp = tempdir().unwrap(); + let path = temp.path().join("session.jsonl"); + { + let mut file = fs::File::create(&path).unwrap(); + writeln!(file, "prefix").unwrap(); + } + let prefix_hash = sha256_file_prefix_hex(&path, 7).unwrap(); + assert!(catalog_import_checkpoint_matches(&path, 7, Some(&prefix_hash)).unwrap()); + assert!(catalog_import_checkpoint_matches(&path, 7, None).unwrap()); + + fs::write(&path, "mutated\n").unwrap(); + assert!(!catalog_import_checkpoint_matches(&path, 7, Some(&prefix_hash)).unwrap()); + } } diff --git a/crates/ctx-cli/src/mcp.rs b/crates/ctx-cli/src/mcp.rs index 1cf3eb1bb..728176265 100644 --- a/crates/ctx-cli/src/mcp.rs +++ b/crates/ctx-cli/src/mcp.rs @@ -17,14 +17,23 @@ use serde_json::{json, Value}; use uuid::Uuid; use super::{ - compact_json, config::CONFIG_FILE, discovered_sources, event_window, event_window_json, - indexed_history_item_count, mark_share_safe, raw_sql_result_json, search_filters, - session_transcript_json, sources_json, OutputFormat, ProviderArg, RefreshArg, SearchDto, - SearchFilterInput, SearchRefreshReport, TranscriptMode, MAX_SEARCH_LIMIT, + compact_json, config::CONFIG_FILE, discovered_plugin_sources_json, discovered_sources, + event_window, event_window_json, indexed_history_item_count, mark_share_safe, + raw_sql_result_json, search_filters, search_has_intent, session_transcript_json, sources_json, + OutputFormat, ProviderArg, RefreshArg, SearchDto, SearchFilterInput, SearchIntentInput, + SearchRefreshReport, SourceIdentityFilterArgs, TranscriptMode, MAX_EVENT_WINDOW, + MAX_SEARCH_LIMIT, }; const MCP_PROTOCOL_VERSION: &str = "2025-11-25"; -const MCP_MAX_EVENT_WINDOW: usize = 50; +const MCP_MAX_LINE_BYTES: usize = 1024 * 1024; +const MCP_MAX_SESSION_EVENTS: usize = 200; + +enum McpInputLine { + Line(String), + InvalidUtf8, + TooLarge, +} #[derive(Debug, Args)] pub(crate) struct McpArgs { @@ -53,16 +62,35 @@ pub(crate) fn run(args: McpArgs, data_root: PathBuf) -> Result<()> { fn serve_stdio(data_root: PathBuf) -> Result<()> { let stdin = io::stdin(); let stdout = io::stdout(); + let mut stdin = stdin.lock(); let mut stdout = stdout.lock(); let mut initialized = false; - for line in stdin.lock().lines() { - let line = line?; - let line = line.trim(); - if line.is_empty() { - continue; - } - if let Some(response) = handle_line(line, &data_root, &mut initialized) { + while let Some(input) = read_mcp_input_line(&mut stdin)? { + let response = match input { + McpInputLine::Line(line) => { + let line = line.trim(); + if line.is_empty() { + continue; + } + handle_line(line, &data_root, &mut initialized) + } + McpInputLine::InvalidUtf8 => Some(error_response( + Value::Null, + -32700, + "Parse error", + Some(json!({ "error": "MCP message is not valid UTF-8" })), + )), + McpInputLine::TooLarge => Some(error_response( + Value::Null, + -32700, + "Parse error", + Some(json!({ + "error": format!("MCP message exceeds max line bytes ({MCP_MAX_LINE_BYTES})") + })), + )), + }; + if let Some(response) = response { writeln!(stdout, "{}", serde_json::to_string(&response)?)?; stdout.flush()?; } @@ -70,6 +98,65 @@ fn serve_stdio(data_root: PathBuf) -> Result<()> { Ok(()) } +fn read_mcp_input_line(reader: &mut impl BufRead) -> Result> { + let mut buffer = Vec::new(); + loop { + let available = reader.fill_buf()?; + if available.is_empty() { + if buffer.is_empty() { + return Ok(None); + } + break; + } + if let Some(newline_index) = available.iter().position(|byte| *byte == b'\n') { + let bytes_to_consume = newline_index + 1; + if buffer.len().saturating_add(bytes_to_consume) > MCP_MAX_LINE_BYTES { + reader.consume(bytes_to_consume); + return Ok(Some(McpInputLine::TooLarge)); + } + buffer.extend_from_slice(&available[..bytes_to_consume]); + reader.consume(bytes_to_consume); + break; + } + + let bytes_to_consume = available.len(); + if buffer.len().saturating_add(bytes_to_consume) > MCP_MAX_LINE_BYTES { + reader.consume(bytes_to_consume); + discard_until_newline(reader)?; + return Ok(Some(McpInputLine::TooLarge)); + } + buffer.extend_from_slice(available); + reader.consume(bytes_to_consume); + } + + Ok(Some(match String::from_utf8(buffer) { + Ok(line) => McpInputLine::Line(line), + Err(_) => McpInputLine::InvalidUtf8, + })) +} + +fn discard_until_newline(reader: &mut impl BufRead) -> Result<()> { + loop { + let available = reader.fill_buf()?; + if available.is_empty() { + return Ok(()); + } + let bytes_to_consume = available + .iter() + .position(|byte| *byte == b'\n') + .map(|index| index + 1) + .unwrap_or(available.len()); + let found_newline = bytes_to_consume <= available.len() + && available + .get(bytes_to_consume.saturating_sub(1)) + .is_some_and(|byte| *byte == b'\n'); + reader.consume(bytes_to_consume); + if found_newline { + return Ok(()); + } + } +} + fn handle_line(line: &str, data_root: &Path, initialized: &mut bool) -> Option { let message = match serde_json::from_str::(line) { Ok(message) => message, @@ -198,7 +285,7 @@ fn handle_tools_call(params: Value, data_root: &Path) -> Result { } "sources" => { validate_argument_keys(&arguments, &[])?; - tool_sources() + tool_sources(data_root) } "search" => { validate_argument_keys( @@ -207,6 +294,10 @@ fn handle_tools_call(params: Value, data_root: &Path) -> Result { "query", "limit", "provider", + "history_source", + "provider_key", + "source_id", + "source_format", "workspace", "since", "primary_only", @@ -303,30 +394,43 @@ fn tool_status(data_root: &Path) -> Result { })) } -fn tool_sources() -> Result { +fn tool_sources(data_root: &Path) -> Result { let sources = discovered_sources(); + let mut source_values = sources_json(&sources); + source_values.extend(discovered_plugin_sources_json(data_root)?); Ok(json!({ "schema_version": 1, - "sources": sources_json(&sources), + "sources": source_values, "read_only": true, })) } fn tool_search(arguments: &Value, data_root: &Path) -> Result { - let store = open_existing_store(data_root)?; let query = optional_string(arguments, "query")?.unwrap_or_default(); let limit = optional_usize(arguments, "limit")?.unwrap_or(20); if !(1..=MAX_SEARCH_LIMIT).contains(&limit) { return Err(anyhow!("limit must be between 1 and {MAX_SEARCH_LIMIT}")); } let provider = optional_provider(arguments, "provider")?; - let session = optional_uuid(arguments, "session")?; + let history_source = optional_string(arguments, "history_source")?; + let provider_key = optional_string(arguments, "provider_key")?; + let source_id = optional_string(arguments, "source_id")?; + let source_format = optional_string(arguments, "source_format")?; + let session = optional_string(arguments, "session")?; let workspace = optional_string(arguments, "workspace")?; let since = optional_string(arguments, "since")?; let primary_only = optional_bool(arguments, "primary_only")?.unwrap_or(false); - let include_subagents = optional_bool(arguments, "include_subagents")?.unwrap_or(!primary_only); + let include_subagents = optional_bool(arguments, "include_subagents")?.unwrap_or(false); let event_type = optional_string(arguments, "event_type")?; let file = optional_string(arguments, "file")?.map(PathBuf::from); + if !search_has_intent(SearchIntentInput { + query: Some(&query), + terms: &[], + file: file.as_deref(), + }) { + return Err(anyhow!("search needs a query or file")); + } + let store = open_existing_store(data_root)?; let events = optional_bool(arguments, "events")?.unwrap_or(false) || session.is_some(); let include_current_session = optional_bool(arguments, "include_current_session")?.unwrap_or(false); @@ -337,6 +441,12 @@ fn tool_search(arguments: &Value, data_root: &Path) -> Result { SearchFilterInput { session, provider, + source_identity: SourceIdentityFilterArgs { + history_source, + provider_key, + source_id, + source_format, + }, workspace, since, primary_only, @@ -395,14 +505,24 @@ fn tool_show_session(arguments: &Value, data_root: &Path) -> Result { let session_id = required_uuid(arguments, "ctx_session_id")?; let mode = optional_transcript_mode(arguments, "mode")?.unwrap_or(TranscriptMode::Lite); let session = store.get_session(session_id)?; - let events = store.events_for_session(session.id)?; - Ok(session_transcript_json( - &store, - &session, - &events, - mode, - OutputFormat::Json, - )) + let mut events = store.events_for_session_limited(session.id, MCP_MAX_SESSION_EVENTS + 1)?; + let truncated = events.len() > MCP_MAX_SESSION_EVENTS; + if truncated { + events.truncate(MCP_MAX_SESSION_EVENTS); + } + let mut value = session_transcript_json(&store, &session, &events, mode, OutputFormat::Json); + if truncated { + if let Some(object) = value.as_object_mut() { + object.insert( + "truncated".to_owned(), + json!({ + "events": true, + "max_events": MCP_MAX_SESSION_EVENTS, + }), + ); + } + } + Ok(value) } fn tool_show_event(arguments: &Value, data_root: &Path) -> Result { @@ -411,12 +531,12 @@ fn tool_show_event(arguments: &Value, data_root: &Path) -> Result { let before = optional_usize(arguments, "before")?.unwrap_or(0); let after = optional_usize(arguments, "after")?.unwrap_or(0); let window = optional_usize(arguments, "window")?; - if before > MCP_MAX_EVENT_WINDOW - || after > MCP_MAX_EVENT_WINDOW - || window.is_some_and(|window| window > MCP_MAX_EVENT_WINDOW) + if before > MAX_EVENT_WINDOW + || after > MAX_EVENT_WINDOW + || window.is_some_and(|window| window > MAX_EVENT_WINDOW) { return Err(anyhow!( - "show_event before/after/window must be {MCP_MAX_EVENT_WINDOW} or less" + "show_event before/after/window must be {MAX_EVENT_WINDOW} or less" )); } let event = store.get_event(event_id)?; @@ -488,17 +608,20 @@ fn tool_definitions() -> Vec { json!({ "name": "search", "title": "Search", - "description": "Search the existing local ctx index. This does not refresh or import provider history.", + "description": "Search the existing local ctx index by query text or touched-file path. This does not refresh or import provider history.", "inputSchema": object_schema(json!({ - "query": { "type": "string" }, + "query": { "type": "string", "description": "Non-empty text query. Required unless file is provided." }, "limit": { "type": "integer", "minimum": 1, "maximum": MAX_SEARCH_LIMIT, "default": 20 }, "provider": { "type": "string", "enum": provider_names() }, + "history_source": { "type": "string", "description": "Custom history source selector as plugin/source or provider_key/source_id." }, + "provider_key": { "type": "string", "description": "Custom history provider_key." }, + "source_id": { "type": "string", "description": "Custom history source_id." }, + "source_format": { "type": "string", "description": "Custom history source_format." }, "workspace": { "type": "string", "description": "Workspace path or name text." }, "since": { "type": "string", "description": "RFC3339 timestamp or day window such as 30d." }, - "primary_only": { "type": "boolean", "default": false }, - "include_subagents": { "type": "boolean", "default": true }, + "include_subagents": { "type": "boolean", "default": false, "description": "Include subagent sessions in addition to primary-agent sessions." }, "event_type": { "type": "string", "enum": event_type_names() }, - "file": { "type": "string" }, + "file": { "type": "string", "description": "Indexed touched-file path. Required unless query is provided." }, "session": { "type": "string", "description": "ctx session id." }, "events": { "type": "boolean", "default": false }, "include_current_session": { "type": "boolean", "default": false, "description": "Include the active Codex session tree when CODEX_THREAD_ID is set." } @@ -571,6 +694,12 @@ fn provider_names() -> Vec<&'static str> { "copilot_cli", ProviderArg::FactoryAiDroid.cli_name(), "factory_ai_droid", + ProviderArg::OpenClaw.cli_name(), + ProviderArg::Hermes.cli_name(), + ProviderArg::NanoClaw.cli_name(), + ProviderArg::AstrBot.cli_name(), + ProviderArg::Shelley.cli_name(), + ProviderArg::Custom.cli_name(), ]; names.sort_unstable(); names @@ -651,6 +780,12 @@ fn optional_provider(arguments: &Value, key: &str) -> Result "cursor" => Ok(Some(ProviderArg::Cursor)), "copilot-cli" | "copilot_cli" => Ok(Some(ProviderArg::CopilotCli)), "factory-ai-droid" | "factory_ai_droid" => Ok(Some(ProviderArg::FactoryAiDroid)), + "openclaw" => Ok(Some(ProviderArg::OpenClaw)), + "hermes" => Ok(Some(ProviderArg::Hermes)), + "nanoclaw" => Ok(Some(ProviderArg::NanoClaw)), + "astrbot" => Ok(Some(ProviderArg::AstrBot)), + "shelley" => Ok(Some(ProviderArg::Shelley)), + "custom" => Ok(Some(ProviderArg::Custom)), _ => Err(anyhow!( "provider must be one of {}", provider_names().join(", ") diff --git a/crates/ctx-cli/src/net.rs b/crates/ctx-cli/src/net.rs index 3fad54aa5..f228b1dfe 100644 --- a/crates/ctx-cli/src/net.rs +++ b/crates/ctx-cli/src/net.rs @@ -8,7 +8,7 @@ use std::{ use anyhow::{anyhow, Context, Result}; pub fn post_json(endpoint: &str, body: &[u8]) -> Result<()> { - if let Some(path) = file_url_path(endpoint) { + if let Some(path) = file_url_path(endpoint)? { let mut file = OpenOptions::new() .create(true) .append(true) @@ -27,25 +27,44 @@ pub fn post_json(endpoint: &str, body: &[u8]) -> Result<()> { .map_err(|err| anyhow!("POST {endpoint}: {err}")) } -pub fn get_bytes(endpoint: &str) -> Result> { - if let Some(path) = file_url_path(endpoint) { - return fs::read(&path).with_context(|| format!("read {}", path.display())); +pub fn get_bytes_limited(endpoint: &str, max_bytes: usize) -> Result> { + if let Some(path) = file_url_path(endpoint)? { + let file = fs::File::open(&path).with_context(|| format!("read {}", path.display()))?; + return read_limited(file, max_bytes, &format!("read {}", path.display())); } require_https_or_localhost(endpoint)?; let response = ureq::get(endpoint) .timeout(std::time::Duration::from_secs(20)) .call() .map_err(|err| anyhow!("GET {endpoint}: {err}"))?; - let mut reader = response.into_reader(); + read_limited( + response.into_reader(), + max_bytes, + &format!("GET {endpoint}"), + ) +} + +fn read_limited(mut reader: impl Read, max_bytes: usize, label: &str) -> Result> { let mut bytes = Vec::new(); reader + .by_ref() + .take((max_bytes as u64).saturating_add(1)) .read_to_end(&mut bytes) - .map_err(|err| anyhow!("read GET {endpoint}: {err}"))?; + .map_err(|err| anyhow!("{label}: {err}"))?; + if bytes.len() > max_bytes { + return Err(anyhow!("{label} exceeds max bytes ({max_bytes})")); + } Ok(bytes) } -fn file_url_path(url: &str) -> Option { - url.strip_prefix("file://").map(PathBuf::from) +fn file_url_path(url: &str) -> Result> { + let Some(path) = url.strip_prefix("file://") else { + return Ok(None); + }; + if path.is_empty() || !path.starts_with('/') { + return Err(anyhow!("file URL must use an absolute local path: {url}")); + } + Ok(Some(PathBuf::from(path))) } fn require_https_or_localhost(url: &str) -> Result<()> { @@ -54,9 +73,62 @@ fn require_https_or_localhost(url: &str) -> Result<()> { } if let Some(rest) = url.strip_prefix("http://") { let host = rest.split('/').next().unwrap_or_default(); - if matches!(host, "localhost" | "127.0.0.1" | "[::1]") { + if is_localhost_authority(host) { return Ok(()); } } Err(anyhow!("refusing non-HTTPS endpoint: {url}")) } + +fn is_localhost_authority(authority: &str) -> bool { + if authority.contains('@') { + return false; + } + let host = if let Some(rest) = authority.strip_prefix("[::1]") { + if rest.is_empty() || rest.starts_with(':') { + "[::1]" + } else { + return false; + } + } else { + authority.split(':').next().unwrap_or_default() + }; + matches!(host, "localhost" | "127.0.0.1" | "[::1]") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn file_urls_must_be_absolute_local_paths() { + assert_eq!( + file_url_path("file:///tmp/ctx-release-metadata.env") + .unwrap() + .unwrap(), + PathBuf::from("/tmp/ctx-release-metadata.env") + ); + assert!(file_url_path("file://relative/path").is_err()); + assert!(file_url_path("file://").is_err()); + assert!(file_url_path("https://example.com").unwrap().is_none()); + } + + #[test] + fn endpoint_validation_allows_https_and_localhost_http_only() { + require_https_or_localhost("https://example.com/releases").unwrap(); + require_https_or_localhost("http://localhost:8080/events").unwrap(); + require_https_or_localhost("http://127.0.0.1/events").unwrap(); + require_https_or_localhost("http://[::1]:8080/events").unwrap(); + assert!(require_https_or_localhost("http://example.com/events").is_err()); + assert!(require_https_or_localhost("http://example.com@localhost/events").is_err()); + } + + #[test] + fn get_bytes_limited_rejects_oversized_file_urls() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("oversized.bin"); + fs::write(&path, b"12345").unwrap(); + let err = get_bytes_limited(&format!("file://{}", path.display()), 4).unwrap_err(); + assert!(err.to_string().contains("exceeds max bytes (4)")); + } +} diff --git a/crates/ctx-cli/src/parser_prop_tests.rs b/crates/ctx-cli/src/parser_prop_tests.rs new file mode 100644 index 000000000..970bcb92f --- /dev/null +++ b/crates/ctx-cli/src/parser_prop_tests.rs @@ -0,0 +1,48 @@ +use super::{ + normalize_uuid_prefix, parse_event_window_limit, parse_search_limit, parse_since_filter, + parse_sql_timeout, MAX_EVENT_WINDOW, MAX_SEARCH_LIMIT, +}; +use proptest::prelude::*; +use std::panic; + +proptest! { + #[test] + fn cli_value_parsers_never_panic_for_generated_strings(input in ".{0,256}") { + prop_assert!(panic::catch_unwind(|| parse_since_filter(&input)).is_ok()); + prop_assert!(panic::catch_unwind(|| parse_search_limit(&input)).is_ok()); + prop_assert!(panic::catch_unwind(|| parse_event_window_limit(&input)).is_ok()); + prop_assert!(panic::catch_unwind(|| parse_sql_timeout(&input)).is_ok()); + prop_assert!(panic::catch_unwind(|| normalize_uuid_prefix(&input, "test")).is_ok()); + } + + #[test] + fn parse_search_limit_accepts_only_public_limit_range(limit in 1usize..=MAX_SEARCH_LIMIT) { + prop_assert_eq!(parse_search_limit(&limit.to_string()), Ok(limit)); + } + + #[test] + fn parse_search_limit_rejects_values_above_public_limit(limit in (MAX_SEARCH_LIMIT + 1)..=usize::MAX) { + prop_assert!(parse_search_limit(&limit.to_string()).is_err()); + } + + #[test] + fn parse_event_window_limit_accepts_only_public_window_range(limit in 0usize..=MAX_EVENT_WINDOW) { + prop_assert_eq!(parse_event_window_limit(&limit.to_string()), Ok(limit)); + } + + #[test] + fn parse_event_window_limit_rejects_values_above_public_window(limit in (MAX_EVENT_WINDOW + 1)..=usize::MAX) { + prop_assert!(parse_event_window_limit(&limit.to_string()).is_err()); + } + + #[test] + fn parse_since_filter_rejects_unrepresentable_day_windows(days in any::()) { + if chrono::Duration::try_days(days) + .and_then(|duration| crate::utc_now().checked_sub_signed(duration)) + .is_none() + { + let input = format!("{days}d"); + prop_assert!(parse_since_filter(&input).is_err()); + } + } +} diff --git a/crates/ctx-cli/src/upgrade.rs b/crates/ctx-cli/src/upgrade.rs index f36204222..8074f8b5a 100644 --- a/crates/ctx-cli/src/upgrade.rs +++ b/crates/ctx-cli/src/upgrade.rs @@ -1,15 +1,18 @@ use std::{ + collections::BTreeMap, env, fs, - io::Write, + io::{Read, Write}, path::{Path, PathBuf}, process::{Command, Stdio}, - time::{Duration, SystemTime, UNIX_EPOCH}, + sync::mpsc, + thread, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; use anyhow::{anyhow, Context, Result}; use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; -use chrono::Utc; use clap::{Args, Subcommand}; +use ctx_history_core::utc_now; use serde_json::{json, Value}; use sha2::{Digest, Sha256}; @@ -18,6 +21,13 @@ use crate::{config::AppConfig, net}; const STATE_FILE: &str = "upgrade-state.json"; const LOCK_FILE: &str = "upgrade.lock"; const LOG_FILE: &str = "logs/upgrade.log"; +const RELEASE_METADATA_MAX_BYTES: usize = 1024 * 1024; +const RELEASE_METADATA_SIGNATURE_MAX_BYTES: usize = 64 * 1024; +const RELEASE_ARTIFACT_MAX_BYTES: usize = 128 * 1024 * 1024; +const VERSION_PROBE_TIMEOUT: Duration = Duration::from_secs(2); +const VERSION_PROBE_OUTPUT_LIMIT: usize = 4096; +const STALE_UPGRADE_LOCK_AFTER: Duration = Duration::from_secs(30 * 60); +const MAX_INSTALL_ATTEMPT_ID_CHARS: usize = 128; const DEFAULT_METADATA_PUBLIC_KEY_PEM: &str = r#"-----BEGIN RSA PUBLIC KEY----- MIIBigKCAYEAyBPNIx3H/NwWlN9CPHY5kOEe9kQEshOJEMpv3Atq086H1FWqliTm 3BCWiO4s/89wNMn11Pla2JetCWNiWsbxm3BIxCd1o6cq8y9ur6Zk1RGOQBLQgqhF @@ -125,9 +135,24 @@ struct UpgradePlan { update_available: bool, managed: bool, warnings: Vec, + path: PathDiagnostics, metadata: ReleaseMetadata, } +#[derive(Debug, Clone)] +struct PathDiagnostics { + current_exe: PathBuf, + entries: Vec, + warnings: Vec, +} + +#[derive(Debug, Clone)] +struct PathDiagnosticEntry { + path: PathBuf, + version: Option, + current: bool, +} + #[derive(Debug, Clone)] struct UpgradeOutcome { command: &'static str, @@ -163,6 +188,7 @@ impl UpgradeOutcome { "artifact_url": plan.map(|plan| plan.artifact_url.as_str()), "install_path": plan.map(|plan| plan.install_path.display().to_string()), "managed": plan.map(|plan| plan.managed).unwrap_or(false), + "path": plan.map(|plan| plan.path.json()), "applied": self.applied, "dry_run": self.dry_run, "warnings": self.warnings, @@ -170,6 +196,23 @@ impl UpgradeOutcome { } } +impl PathDiagnostics { + fn json(&self) -> Value { + json!({ + "current_exe": self.current_exe.display().to_string(), + "first_ctx": self.entries.first().map(|entry| entry.path.display().to_string()), + "entries": self.entries.iter().map(|entry| { + json!({ + "path": entry.path.display().to_string(), + "version": entry.version.as_deref(), + "current": entry.current, + }) + }).collect::>(), + "warnings": self.warnings, + }) + } +} + pub fn run(args: UpgradeArgs, data_root: PathBuf, config: AppConfig) -> Result<()> { if args.background { return run_background_apply(&data_root, &config); @@ -340,7 +383,7 @@ fn apply_upgrade( warnings, }); } - let bytes = net::get_bytes(&plan.artifact_url) + let bytes = net::get_bytes_limited(&plan.artifact_url, RELEASE_ARTIFACT_MAX_BYTES) .with_context(|| format!("download {}", plan.artifact_url))?; verify_artifact_sha(&bytes, &plan.artifact_sha256)?; let apply_result = apply_artifact(&plan, &bytes)?; @@ -399,12 +442,16 @@ fn build_upgrade_plan( ¤t_version, &mut warnings, )?; + let managed = warnings.is_empty(); + let path = path_diagnostics(&marker.install_path, ¤t_version); + warnings.extend(path.warnings.clone()); let metadata_url = metadata_url(config, &channel); let signature_url = metadata_signature_url(&metadata_url); - let metadata_bytes = net::get_bytes(&metadata_url) + let metadata_bytes = net::get_bytes_limited(&metadata_url, RELEASE_METADATA_MAX_BYTES) .with_context(|| format!("download release metadata {metadata_url}"))?; - let signature_bytes = net::get_bytes(&signature_url) - .with_context(|| format!("download release metadata signature {signature_url}"))?; + let signature_bytes = + net::get_bytes_limited(&signature_url, RELEASE_METADATA_SIGNATURE_MAX_BYTES) + .with_context(|| format!("download release metadata signature {signature_url}"))?; verify_metadata_signature(&metadata_bytes, &signature_bytes)?; let metadata = parse_release_metadata(&metadata_bytes, &platform, &channel)?; let artifact_url = format!( @@ -424,8 +471,9 @@ fn build_upgrade_plan( artifact_sha256: metadata.sha256.clone(), install_path: marker.install_path.clone(), update_available, - managed: warnings.is_empty(), + managed, warnings, + path, metadata, }) } @@ -449,6 +497,11 @@ fn render_status(data_root: &Path, json_output: bool) -> Result<()> { "status": "never_checked" }) }); + let current_version = env!("CARGO_PKG_VERSION"); + let current_exe = current_install_path().ok(); + let path_diagnostics = current_exe + .as_ref() + .map(|path| path_diagnostics(path, current_version)); let marker = read_verified_install_marker_for_current_exe() .map(|marker| { json!({ @@ -469,8 +522,14 @@ fn render_status(data_root: &Path, json_output: bool) -> Result<()> { let value = json!({ "schema_version": 1, "command": "upgrade_status", + "current_version": current_version, "state": state, "install": marker, + "path": path_diagnostics.as_ref().map(PathDiagnostics::json), + "warnings": path_diagnostics + .as_ref() + .map(|diagnostics| diagnostics.warnings.clone()) + .unwrap_or_default(), }); if json_output { println!("{}", serde_json::to_string_pretty(&value)?); @@ -483,11 +542,29 @@ fn render_status(data_root: &Path, json_output: bool) -> Result<()> { if let Some(path) = marker.get("install_path").and_then(Value::as_str) { println!("install: {path}"); } + if let Some(diagnostics) = &path_diagnostics { + println!("current_exe: {}", diagnostics.current_exe.display()); + if let Some(first) = diagnostics.entries.first() { + println!("path_ctx: {}", first.path.display()); + } + for warning in &diagnostics.warnings { + eprintln!("warning: {warning}"); + } + } } else { println!("ctx upgrade status: unmanaged install"); if let Some(reason) = marker.get("reason").and_then(Value::as_str) { println!("{reason}"); } + if let Some(diagnostics) = &path_diagnostics { + println!("current_exe: {}", diagnostics.current_exe.display()); + if let Some(first) = diagnostics.entries.first() { + println!("path_ctx: {}", first.path.display()); + } + for warning in &diagnostics.warnings { + eprintln!("warning: {warning}"); + } + } } Ok(()) } @@ -567,7 +644,8 @@ fn parse_release_metadata( expected_channel: &str, ) -> Result { let text = std::str::from_utf8(bytes).context("release metadata is not UTF-8")?; - let value = |key: &str| metadata_value(text, key); + let metadata = parse_metadata_map(text)?; + let value = |key: &str| metadata_value(&metadata, key); let schema = value("CTX_RELEASE_SCHEMA_VERSION") .ok_or_else(|| anyhow!("metadata missing CTX_RELEASE_SCHEMA_VERSION"))?; if schema != "1" { @@ -596,27 +674,45 @@ fn parse_release_metadata( sha256, source_commit: value("CTX_RELEASE_SOURCE_COMMIT"), published_at: value("CTX_RELEASE_PUBLISHED_AT"), - self_upgrade_allowed: metadata_bool(text, "CTX_RELEASE_SELF_UPGRADE_ALLOWED", false), - auto_upgrade_allowed: metadata_bool(text, "CTX_RELEASE_AUTO_UPGRADE_ALLOWED", false), + self_upgrade_allowed: metadata_bool(&metadata, "CTX_RELEASE_SELF_UPGRADE_ALLOWED", false)?, + auto_upgrade_allowed: metadata_bool(&metadata, "CTX_RELEASE_AUTO_UPGRADE_ALLOWED", false)?, store_schema_version: value("CTX_RELEASE_STORE_SCHEMA_VERSION"), }) } -fn metadata_value(text: &str, key: &str) -> Option { - text.lines().find_map(|line| { +fn parse_metadata_map(text: &str) -> Result> { + let mut metadata = BTreeMap::new(); + for line in text.lines() { let line = line.trim(); if line.starts_with('#') || line.is_empty() { - return None; + continue; } - let (candidate, value) = line.split_once('=')?; - (candidate == key).then(|| value.trim_end_matches('\r').to_owned()) - }) + let Some((key, value)) = line.split_once('=') else { + continue; + }; + if metadata + .insert(key.to_owned(), value.trim_end_matches('\r').to_owned()) + .is_some() + { + return Err(anyhow!("metadata contains duplicate key {key}")); + } + } + Ok(metadata) } -fn metadata_bool(text: &str, key: &str, default: bool) -> bool { - metadata_value(text, key).map_or(default, |value| { - matches!(value.to_ascii_lowercase().as_str(), "1" | "true" | "yes") - }) +fn metadata_value(metadata: &BTreeMap, key: &str) -> Option { + metadata.get(key).cloned() +} + +fn metadata_bool(metadata: &BTreeMap, key: &str, default: bool) -> Result { + let Some(value) = metadata_value(metadata, key) else { + return Ok(default); + }; + match value.to_ascii_lowercase().as_str() { + "1" | "true" | "yes" => Ok(true), + "0" | "false" | "no" => Ok(false), + _ => Err(anyhow!("metadata {key} must be a boolean")), + } } fn verify_metadata_signature(metadata: &[u8], signature: &[u8]) -> Result<()> { @@ -711,19 +807,12 @@ fn apply_artifact(plan: &UpgradePlan, bytes: &[u8]) -> Result { } fn verify_staged_version(staged: &Path, expected_version: &str) -> Result<()> { - let output = Command::new(staged) - .arg("--version") - .stdin(Stdio::null()) - .output() + let version = ctx_binary_version(staged) .with_context(|| format!("run staged ctx {}", staged.display()))?; - if !output.status.success() { - return Err(anyhow!("staged ctx --version failed")); - } - let stdout = String::from_utf8_lossy(&output.stdout); - if !stdout.contains(expected_version) { + if !version.contains(expected_version) { return Err(anyhow!( "staged ctx version mismatch: expected {expected_version}, got {}", - stdout.trim() + version.trim() )); } Ok(()) @@ -764,7 +853,8 @@ fn replace_binary(staged: &Path, plan: &UpgradePlan) -> Result { let script = staged.with_extension("ps1"); let marker_tmp = staged.with_extension("install.json.tmp"); let marker_path = install_marker_path(target); - write_install_marker_to(&marker_tmp, plan)?; + let install_attempt_id = existing_install_attempt_id(&marker_path); + write_install_marker_to(&marker_tmp, plan, install_attempt_id.as_deref())?; let parent = std::process::id(); let body = format!( r#"$ErrorActionPreference = 'Stop' @@ -932,11 +1022,16 @@ fn verify_install_marker(marker: &InstallMarker, platform: &str) -> Result<()> { fn write_install_marker_after_upgrade(plan: &UpgradePlan) -> Result<()> { let marker_path = install_marker_path(&plan.install_path); - write_install_marker_to(&marker_path, plan) + let install_attempt_id = existing_install_attempt_id(&marker_path); + write_install_marker_to(&marker_path, plan, install_attempt_id.as_deref()) } -fn write_install_marker_to(marker_path: &Path, plan: &UpgradePlan) -> Result<()> { - let body = json!({ +fn write_install_marker_to( + marker_path: &Path, + plan: &UpgradePlan, + install_attempt_id: Option<&str>, +) -> Result<()> { + let mut body = json!({ "schema_version": 1, "manager": "ctx-hosted-installer", "install_path": plan.install_path, @@ -949,11 +1044,36 @@ fn write_install_marker_to(marker_path: &Path, plan: &UpgradePlan) -> Result<()> "source_commit": plan.metadata.source_commit, "published_at": plan.metadata.published_at, "store_schema_version": plan.metadata.store_schema_version, - "installed_at": Utc::now(), + "installed_at": utc_now(), }); + if let Some(install_attempt_id) = install_attempt_id { + if let Some(object) = body.as_object_mut() { + object.insert( + "install_attempt_id".to_owned(), + Value::String(install_attempt_id.to_owned()), + ); + } + } atomic_write_json(marker_path, &body) } +fn existing_install_attempt_id(marker_path: &Path) -> Option { + read_json_file(marker_path).and_then(|value| optional_install_attempt_id(&value)) +} + +fn optional_install_attempt_id(value: &Value) -> Option { + let id = value.get("install_attempt_id")?.as_str()?.trim(); + is_valid_install_attempt_id(id).then(|| id.to_owned()) +} + +fn is_valid_install_attempt_id(value: &str) -> bool { + !value.is_empty() + && value.chars().count() <= MAX_INSTALL_ATTEMPT_ID_CHARS + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) +} + fn string_field(value: &Value, key: &str) -> Result { value .get(key) @@ -984,11 +1104,188 @@ fn current_binary_sha() -> Result { Ok(sha256_hex(&bytes)) } +fn path_diagnostics(current_exe: &Path, current_version: &str) -> PathDiagnostics { + let current_identity = path_identity(current_exe); + let current_display = current_exe.display().to_string(); + let binary_name = if cfg!(windows) { "ctx.exe" } else { "ctx" }; + let mut entries = Vec::new(); + for dir in env::var_os("PATH") + .map(|path| env::split_paths(&path).collect::>()) + .unwrap_or_default() + { + let candidate = dir.join(binary_name); + if !candidate.is_file() { + continue; + } + if entries + .iter() + .any(|entry: &PathDiagnosticEntry| same_path(&entry.path, &candidate)) + { + continue; + } + let current = path_identity(&candidate) == current_identity; + entries.push(PathDiagnosticEntry { + version: current.then(|| format!("ctx {current_version}")), + path: candidate, + current, + }); + } + + let mut warnings = Vec::new(); + match entries.first() { + Some(first) if !first.current => warnings.push(format!( + "PATH resolves ctx to {} before the current executable {}; your shell may keep using the earlier binary after upgrade", + first.path.display(), + current_display + )), + None => warnings.push(format!( + "current ctx executable {current_display} is not discoverable on PATH" + )), + _ => {} + } + if entries.len() > 1 { + warnings.push(format!( + "multiple ctx binaries are on PATH; first is {}", + entries[0].path.display() + )); + } + let expected = format!("ctx {current_version}"); + for entry in &entries { + if let Some(version) = &entry.version { + if version != &expected { + warnings.push(format!( + "ctx on PATH at {} reports {version}; current binary reports {expected}", + entry.path.display() + )); + } + } + } + + PathDiagnostics { + current_exe: current_exe.to_path_buf(), + entries, + warnings, + } +} + +fn path_identity(path: &Path) -> PathBuf { + fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) +} + +fn same_path(left: &Path, right: &Path) -> bool { + path_identity(left) == path_identity(right) +} + +fn ctx_binary_version(path: &Path) -> Result { + let output = run_ctx_version_command(path)?; + if !output.status.success() { + return Err(anyhow!("{} --version failed", path.display())); + } + if output.truncated { + return Err(anyhow!( + "{} --version output exceeded {} bytes", + path.display(), + VERSION_PROBE_OUTPUT_LIMIT + )); + } + let stdout = String::from_utf8_lossy(&output.stdout); + Ok(stdout.lines().next().unwrap_or_default().trim().to_owned()) +} + +struct VersionCommandOutput { + status: std::process::ExitStatus, + stdout: Vec, + truncated: bool, +} + +fn run_ctx_version_command(path: &Path) -> Result { + let mut child = Command::new(path) + .arg("--version") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .with_context(|| format!("run {} --version", path.display()))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| anyhow!("capture {} --version output", path.display()))?; + let (output_tx, output_rx) = mpsc::channel(); + thread::spawn(move || { + let _ = output_tx.send(read_capped_output(stdout, VERSION_PROBE_OUTPUT_LIMIT)); + }); + let started = Instant::now(); + let mut status = None; + let mut output = None; + loop { + if status.is_none() { + status = child + .try_wait() + .with_context(|| format!("wait for {} --version", path.display()))?; + } + if output.is_none() { + match output_rx.try_recv() { + Ok(result) => { + output = + Some(result.with_context(|| { + format!("read {} --version output", path.display()) + })?); + } + Err(mpsc::TryRecvError::Empty) => {} + Err(mpsc::TryRecvError::Disconnected) => { + return Err(anyhow!( + "reader thread stopped for {} --version", + path.display() + )); + } + } + } + match (status.take(), output.take()) { + (Some(status), Some((stdout, truncated))) => { + return Ok(VersionCommandOutput { + status, + stdout, + truncated, + }); + } + (next_status, next_output) => { + status = next_status; + output = next_output; + } + } + if started.elapsed() >= VERSION_PROBE_TIMEOUT { + let _ = child.kill(); + let _ = child.wait(); + return Err(anyhow!( + "{} --version timed out after {}ms", + path.display(), + VERSION_PROBE_TIMEOUT.as_millis() + )); + } + thread::sleep(Duration::from_millis(10)); + } +} + +fn read_capped_output(mut reader: impl Read, limit: usize) -> std::io::Result<(Vec, bool)> { + let mut output = Vec::new(); + let mut buffer = [0_u8; 1024]; + while output.len() < limit { + let remaining = limit - output.len(); + let max_read = remaining.min(buffer.len()); + let read = reader.read(&mut buffer[..max_read])?; + if read == 0 { + return Ok((output, false)); + } + output.extend_from_slice(&buffer[..read]); + } + Ok((output, true)) +} + fn write_state_checked(data_root: &Path, plan: &UpgradePlan, status: &str) -> Result<()> { let body = json!({ "schema_version": 1, "status": status, - "checked_at": Utc::now(), + "checked_at": utc_now(), "last_checked_unix_s": now_unix_s(), "current_version": plan.current_version, "latest_version": plan.latest_version, @@ -1007,7 +1304,7 @@ fn write_state_error(data_root: &Path, error: &str) -> Result<()> { let body = json!({ "schema_version": 1, "status": "error", - "checked_at": Utc::now(), + "checked_at": utc_now(), "last_checked_unix_s": now_unix_s(), "error": error, }); @@ -1052,21 +1349,148 @@ impl UpgradeLock { fn acquire(data_root: &Path) -> Result { fs::create_dir_all(data_root)?; let path = data_root.join(LOCK_FILE); - match fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&path) - { - Ok(mut file) => { - writeln!(file, "{} {}", std::process::id(), now_unix_s())?; - Ok(Self { path }) + for _ in 0..2 { + match fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + { + Ok(mut file) => { + writeln!(file, "{} {}", std::process::id(), now_unix_s())?; + return Ok(Self { path }); + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + if stale_upgrade_lock_reason(&path).is_some() { + match fs::remove_file(&path) { + Ok(()) => continue, + Err(remove_error) + if remove_error.kind() == std::io::ErrorKind::NotFound => + { + continue; + } + Err(remove_error) => { + return Err(anyhow!( + "ctx upgrade lock is stale but could not be removed at {}: {remove_error}", + path.display() + )); + } + } + } + return Err(anyhow!( + "ctx upgrade lock is held at {}: {error}", + path.display() + )); + } + Err(error) => { + return Err(anyhow!( + "ctx upgrade lock is held at {}: {error}", + path.display() + )); + } } - Err(error) => Err(anyhow!( - "ctx upgrade lock is held at {}: {error}", - path.display() - )), } + Err(anyhow!( + "ctx upgrade lock could not be acquired at {}", + path.display() + )) + } +} + +fn stale_upgrade_lock_reason(path: &Path) -> Option { + let contents = fs::read_to_string(path).ok(); + let (pid, created_at) = contents + .as_deref() + .map(parse_upgrade_lock) + .unwrap_or((None, None)); + if let Some(pid) = pid { + match process_state(pid) { + ProcessState::Running => return None, + ProcessState::NotRunning => { + return Some(format!( + "recorded upgrade process {pid} is no longer running" + )); + } + ProcessState::Unknown => {} + } + } + if lock_age_seconds(path, created_at) + .is_some_and(|age| age >= STALE_UPGRADE_LOCK_AFTER.as_secs()) + { + return Some(format!( + "upgrade lock is older than {} seconds", + STALE_UPGRADE_LOCK_AFTER.as_secs() + )); } + None +} + +fn parse_upgrade_lock(contents: &str) -> (Option, Option) { + let mut fields = contents.split_whitespace(); + let pid = fields.next().and_then(|value| value.parse::().ok()); + let created_at = fields.next().and_then(|value| value.parse::().ok()); + (pid, created_at) +} + +fn lock_age_seconds(path: &Path, created_at: Option) -> Option { + if let Some(created_at) = created_at { + return Some(now_unix_s().saturating_sub(created_at)); + } + fs::metadata(path) + .ok() + .and_then(|metadata| metadata.modified().ok()) + .and_then(|modified| modified.elapsed().ok()) + .map(|age| age.as_secs()) +} + +enum ProcessState { + Running, + NotRunning, + Unknown, +} + +#[cfg(unix)] +fn process_state(pid: u32) -> ProcessState { + if pid == 0 { + return ProcessState::NotRunning; + } + let result = unsafe { libc::kill(pid as libc::pid_t, 0) }; + if result == 0 { + return ProcessState::Running; + } + match last_errno() { + Some(libc::ESRCH) => ProcessState::NotRunning, + Some(libc::EPERM) => ProcessState::Running, + _ => ProcessState::Unknown, + } +} + +#[cfg(not(unix))] +fn process_state(_pid: u32) -> ProcessState { + ProcessState::Unknown +} + +#[cfg(any(target_os = "linux", target_os = "android"))] +fn last_errno() -> Option { + Some(unsafe { *libc::__errno_location() }) +} + +#[cfg(any(target_os = "macos", target_os = "ios", target_os = "freebsd"))] +fn last_errno() -> Option { + Some(unsafe { *libc::__error() }) +} + +#[cfg(all( + unix, + not(any( + target_os = "linux", + target_os = "android", + target_os = "macos", + target_os = "ios", + target_os = "freebsd" + )) +))] +fn last_errno() -> Option { + None } impl Drop for UpgradeLock { @@ -1081,7 +1505,7 @@ fn append_upgrade_log(data_root: &Path, message: &str) { let _ = fs::create_dir_all(parent); } if let Ok(mut file) = fs::OpenOptions::new().create(true).append(true).open(&path) { - let _ = writeln!(file, "{} {}", Utc::now().to_rfc3339(), message); + let _ = writeln!(file, "{} {}", utc_now().to_rfc3339(), message); } } diff --git a/crates/ctx-cli/tests/cli.rs b/crates/ctx-cli/tests/cli.rs index f9115555a..9f3cf0c55 100644 --- a/crates/ctx-cli/tests/cli.rs +++ b/crates/ctx-cli/tests/cli.rs @@ -8,6 +8,7 @@ use ring::{ use rusqlite::{params, Connection}; use serde_json::{json, Value}; use std::{ + collections::BTreeSet, fs, io::Write, path::{Path, PathBuf}, @@ -27,10 +28,300 @@ fn ctx(temp: &TempDir) -> Command { command } +fn ctx_from_binary(temp: &TempDir, binary: &Path) -> Command { + let mut command = Command::new(binary); + command.env("CTX_DATA_ROOT", temp.path()); + command.env("HOME", temp.path()); + command.env("CTX_ANALYTICS_OFF", "1"); + command +} + +fn copied_ctx_binary(temp: &TempDir) -> PathBuf { + let source = PathBuf::from(Command::cargo_bin("ctx").unwrap().get_program().to_owned()); + let target = temp.path().join(if cfg!(windows) { + "ctx-test-copy.exe" + } else { + "ctx-test-copy" + }); + if fs::hard_link(&source, &target).is_err() { + fs::copy(&source, &target).unwrap(); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + let mut permissions = fs::metadata(&target).unwrap().permissions(); + permissions.set_mode(permissions.mode() | 0o700); + fs::set_permissions(&target, permissions).unwrap(); + } + target +} + +fn hosted_install_marker_path(binary: &Path) -> PathBuf { + let mut marker = binary.as_os_str().to_owned(); + marker.push(".install.json"); + PathBuf::from(marker) +} + +fn initialize_empty_store(temp: &TempDir) { + fs::create_dir_all(temp.path().join(".codex").join("sessions")).unwrap(); + ctx(temp) + .args(["setup", "--catalog-only", "--progress", "none"]) + .assert() + .success(); +} + +fn initialize_empty_store_with_env(temp: &TempDir, data_root: &Path, home: &Path, state: &Path) { + fs::create_dir_all(home.join(".codex").join("sessions")).unwrap(); + ctx(temp) + .args(["setup", "--catalog-only", "--progress", "none"]) + .env("CTX_DATA_ROOT", data_root) + .env("HOME", home) + .env("XDG_STATE_HOME", state) + .env("LOCALAPPDATA", state) + .assert() + .success(); +} + fn provider_history_fixture(name: &str) -> String { materialized_fixture("provider-history", name) } +fn custom_history_fixture(name: &str) -> String { + materialized_fixture("custom-history-jsonl", name) +} + +#[derive(Debug)] +struct HistorySourcePluginFixture { + manifest_dir: PathBuf, + run_marker: PathBuf, +} + +fn write_history_source_plugin( + temp: &TempDir, + provider: &str, + enabled: bool, + cursor_log: Option<&Path>, +) -> HistorySourcePluginFixture { + write_history_source_plugin_with_refresh(temp, provider, enabled, None, cursor_log) +} + +fn write_history_source_plugin_with_refresh( + temp: &TempDir, + provider: &str, + enabled: bool, + refresh: Option<&str>, + cursor_log: Option<&Path>, +) -> HistorySourcePluginFixture { + write_history_source_plugin_at_with_refresh( + &temp.path().join("history-plugins"), + provider, + enabled, + refresh, + cursor_log, + ) +} + +fn write_history_source_plugin_at( + root: &Path, + provider: &str, + enabled: bool, + cursor_log: Option<&Path>, +) -> HistorySourcePluginFixture { + write_history_source_plugin_at_with_refresh(root, provider, enabled, None, cursor_log) +} + +fn write_history_source_plugin_at_with_refresh( + root: &Path, + provider: &str, + enabled: bool, + refresh: Option<&str>, + cursor_log: Option<&Path>, +) -> HistorySourcePluginFixture { + let manifest_dir = root.join(provider); + fs::create_dir_all(&manifest_dir).unwrap(); + let script = manifest_dir.join("export.py"); + let run_marker = manifest_dir.join("ran"); + let run_marker_json = Value::String(run_marker.display().to_string()); + let cursor_log_py = cursor_log + .map(|path| { + serde_json::to_string(&path.display().to_string()) + .expect("cursor log path is JSON-serializable") + }) + .unwrap_or_else(|| "None".to_owned()); + let script_body = format!( + r#"#!/usr/bin/env python3 +import json +import os +import pathlib +import sys + +provider = sys.argv[1] +source_id = os.environ["CTX_HISTORY_SOURCE_ID"] +provider_key = os.environ["CTX_HISTORY_PROVIDER_KEY"] +source_format = os.environ["CTX_HISTORY_SOURCE_FORMAT"] +cursor_stream = os.environ["CTX_HISTORY_CURSOR_STREAM"] +cursor_inline = os.environ.get("CTX_HISTORY_CURSOR") +cursor_file = os.environ.get("CTX_HISTORY_CURSOR_FILE") +pathlib.Path({run_marker_json}).write_text("ran\n") +cursor_log = {cursor_log_py} +cursor_text = cursor_inline +if not cursor_text and cursor_file: + cursor_text = pathlib.Path(cursor_file).read_text() +if cursor_log and cursor_text: + file_text = pathlib.Path(cursor_file).read_text() if cursor_file else "" + with open(cursor_log, "a", encoding="utf-8") as handle: + handle.write(cursor_text + "\n") + handle.write("cursor_file=" + file_text + "\n") + +cursor_shapes = {{ + "dorkos": {{"files": {{"/tmp/dorkos.jsonl": {{"offset": 128, "size": 128, "mtimeMs": 1}}}}}}, + "disabled-dorkos": {{"files": {{"/tmp/disabled-dorkos.jsonl": {{"offset": 128, "size": 128, "mtimeMs": 1}}}}}}, + "openclaw": {{"backend": "openclaw-file", "transcripts": {{"/tmp/openclaw.jsonl": {{"offset": 256, "size": 256, "lastRecordId": "rec-1"}}}}}}, + "hermes": {{"message_id": 7}}, + "nanoclaw": {{"sessions": {{"sess-1": 42}}}}, +}} +next_cursor = cursor_shapes[provider] +if cursor_text: + if provider == "hermes": + next_cursor = {{"message_id": 8}} + elif provider == "nanoclaw": + next_cursor = {{"sessions": {{"sess-1": 44}}}} + elif provider == "openclaw": + next_cursor = {{"backend": "openclaw-file", "transcripts": {{"/tmp/openclaw.jsonl": {{"offset": 512, "size": 512, "lastRecordId": "rec-2"}}}}}} + else: + next_cursor = {{"files": {{"/tmp/" + provider + ".jsonl": {{"offset": 256, "size": 256, "mtimeMs": 2}}}}}} + +event_index = 1 if cursor_text else 0 +phase = "incremental" if cursor_text else "initial" +observed = "2026-07-01T12:00:00Z" +cursor = {{ + "after": {{ + "stream": cursor_stream, + "cursor": json.dumps(next_cursor, separators=(",", ":")), + "observed_at": observed, + }} +}} +if cursor_text: + cursor["before"] = {{ + "stream": cursor_stream, + "cursor": cursor_text, + "observed_at": observed, + }} + +records = [ + {{"record_type": "manifest", "schema_version": "ctx-history-jsonl-v1", "producer": provider + "-fixture"}}, + {{"record_type": "source", "source_id": source_id, "provider_key": provider_key, "source_format": source_format, "observed_at": observed, "cursor": cursor, "metadata": {{"fixture_provider": provider}}}}, + {{"record_type": "session", "source_id": source_id, "session_id": provider + "-session", "started_at": "2026-07-01T11:59:00Z", "cwd": "/workspace/" + provider, "agent_type": "primary", "is_primary": True, "status": "completed"}}, + {{"record_type": "event", "source_id": source_id, "session_id": provider + "-session", "event_index": event_index, "event_id": provider + "-event-" + str(event_index), "native_cursor": phase, "event_type": "message", "role": "assistant", "occurred_at": observed, "payload": {{"text": provider + " plugin " + phase + " marker"}}, "preview": provider + " plugin " + phase + " marker"}}, +] +for record in records: + print(json.dumps(record, separators=(",", ":"))) +"#, + run_marker_json = run_marker_json, + cursor_log_py = cursor_log_py + ); + fs::write(&script, script_body).unwrap(); + let mut source_manifest = json!({ + "id": "default", + "provider_key": provider, + "source_id": "default", + "source_format": format!("{provider}-history-v1"), + "enabled": enabled, + "command": [python_command(), script.display().to_string(), provider], + "timeout_seconds": 10 + }); + if let Some(refresh) = refresh { + source_manifest["refresh"] = json!(refresh); + } + let manifest = json!({ + "schema_version": 1, + "name": provider, + "display_name": format!("{provider} history"), + "version": "0.1.0", + "history_sources": [source_manifest] + }); + fs::write( + manifest_dir.join("ctx-history-plugin.json"), + serde_json::to_vec_pretty(&manifest).unwrap(), + ) + .unwrap(); + HistorySourcePluginFixture { + manifest_dir, + run_marker, + } +} + +fn python_command() -> String { + std::env::var("PYTHON").unwrap_or_else(|_| "python3".to_owned()) +} + +fn write_raw_history_source_plugin( + temp: &TempDir, + provider: &str, + script_body: &str, +) -> HistorySourcePluginFixture { + write_raw_history_source_plugin_with_options(temp, provider, script_body, false, None) +} + +fn write_raw_history_source_plugin_with_options( + temp: &TempDir, + provider: &str, + script_body: &str, + enabled: bool, + refresh: Option<&str>, +) -> HistorySourcePluginFixture { + write_raw_history_source_plugin_with_options_and_timeout( + temp, + provider, + script_body, + enabled, + refresh, + 10, + ) +} + +fn write_raw_history_source_plugin_with_options_and_timeout( + temp: &TempDir, + provider: &str, + script_body: &str, + enabled: bool, + refresh: Option<&str>, + timeout_seconds: u64, +) -> HistorySourcePluginFixture { + let manifest_dir = temp.path().join("history-plugins").join(provider); + fs::create_dir_all(&manifest_dir).unwrap(); + let script = manifest_dir.join("export.py"); + let run_marker = manifest_dir.join("ran"); + fs::write(&script, script_body).unwrap(); + let mut source_manifest = json!({ + "id": "default", + "provider_key": provider, + "source_id": "default", + "source_format": format!("{provider}-history-v1"), + "enabled": enabled, + "command": [python_command(), script.display().to_string()], + "timeout_seconds": timeout_seconds + }); + if let Some(refresh) = refresh { + source_manifest["refresh"] = json!(refresh); + } + let manifest = json!({ + "schema_version": 1, + "name": provider, + "history_sources": [source_manifest] + }); + fs::write( + manifest_dir.join("ctx-history-plugin.json"), + serde_json::to_vec_pretty(&manifest).unwrap(), + ) + .unwrap(); + HistorySourcePluginFixture { + manifest_dir, + run_marker, + } +} + fn redaction_fixture(name: &str) -> String { materialized_fixture("redaction", name) } @@ -40,6 +331,9 @@ fn materialized_fixture(category: &str, name: &str) -> String { "provider-history" => PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../../tests/fixtures/provider-history") .join(name), + "custom-history-jsonl" => PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../tests/fixtures/custom-history-jsonl") + .join(name), "provider" => PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../../tests/fixtures/provider") .join(name), @@ -94,6 +388,22 @@ fn file_url(path: &Path) -> String { format!("file://{}", path.display()) } +fn read_analytics_events(path: &Path) -> Vec { + fs::read_to_string(path) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect() +} + +fn analytics_event_properties(event: &Value) -> &serde_json::Map { + event["events"][0]["properties"].as_object().unwrap() +} + +fn analytics_cli_event(event: &Value) -> &Value { + &event["events"][0] +} + fn sha256_hex(bytes: &[u8]) -> String { use sha2::{Digest, Sha256}; let digest = Sha256::digest(bytes); @@ -201,6 +511,26 @@ fn mcp_roundtrip_with_env(temp: &TempDir, messages: &[Value], envs: &[(&str, &st .collect() } +fn mcp_raw_roundtrip(temp: &TempDir, stdin: String) -> Vec { + mcp_raw_roundtrip_bytes(temp, stdin.into_bytes()) +} + +fn mcp_raw_roundtrip_bytes(temp: &TempDir, stdin: Vec) -> Vec { + let output = ctx(temp) + .args(["mcp", "serve"]) + .write_stdin(stdin) + .assert() + .success() + .get_output() + .stdout + .clone(); + String::from_utf8(output) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect() +} + fn assert_omits_keys(value: &Value, forbidden_keys: &[&str]) { match value { Value::Object(map) => { @@ -587,6 +917,47 @@ fn setup_writes_day_one_config_contract_without_overwriting_existing_config() { ); } +#[test] +fn malformed_present_config_fails_before_setup_and_analytics_side_effects() { + let temp = tempdir(); + let state = temp.path().join("state"); + let events_path = temp.path().join("analytics.jsonl"); + fs::write( + temp.path().join("config.toml"), + "[analytics]\nenabled = flase\n", + ) + .unwrap(); + + ctx(&temp) + .arg("setup") + .env("XDG_STATE_HOME", &state) + .env("LOCALAPPDATA", &state) + .env_remove("CTX_ANALYTICS_OFF") + .env("CTX_ANALYTICS_ENDPOINT", file_url(&events_path)) + .assert() + .failure() + .stderr( + predicate::str::contains("analytics.enabled").and(predicate::str::contains("boolean")), + ); + + assert!( + !temp.path().join("work.sqlite").exists(), + "setup must not create the store after config load fails" + ); + assert!( + !events_path.exists(), + "analytics endpoint should not be touched after config load fails" + ); + assert!( + !temp.path().join("install.json").exists(), + "analytics install identity should not be created after config load fails" + ); + assert!( + !expected_device_path(temp.path(), &state).exists(), + "analytics device identity should not be created after config load fails" + ); +} + #[test] fn setup_catalog_only_catalogs_codex_sessions_without_import() { let temp = tempdir(); @@ -730,283 +1101,1055 @@ fn import_progress_json_goes_to_stderr_without_polluting_stdout() { } #[test] -fn import_all_discovers_and_imports_providers_together() { +fn import_custom_history_jsonl_format_is_searchable_and_idempotent() { let temp = tempdir(); - copy_dir_all( - Path::new(&provider_history_fixture("codex-sessions")), - &temp.path().join(".codex").join("sessions"), - ); - let pi_home = temp.path().join(".pi"); - fs::create_dir_all(&pi_home).unwrap(); - fs::copy( - provider_history_fixture("pi-session.jsonl"), - pi_home.join("sessions.jsonl"), - ) - .unwrap(); + let fixture = custom_history_fixture("basic.jsonl"); - let output = ctx(&temp) - .args(["import", "--all", "--json", "--progress", "json"]) - .assert() - .success() - .get_output() - .clone(); + let first = json_output(ctx(&temp).args([ + "import", + "--format", + "ctx-history-jsonl-v1", + "--path", + &fixture, + "--json", + "--progress", + "none", + ])); + assert_eq!(first["totals"]["imported_sessions"], 2); + assert_eq!(first["totals"]["imported_events"], 2); + assert_eq!(first["totals"]["imported_edges"], 2); + assert_eq!(first["sources"][0]["provider"], "custom"); + assert_eq!(first["sources"][0]["format"], "ctx-history-jsonl-v1"); - let stdout: Value = serde_json::from_slice(&output.stdout).unwrap(); - assert_eq!(stdout["schema_version"], 1); - assert!(stdout["totals"]["imported_sessions"].as_u64().unwrap() >= 3); - let sources = stdout["sources"].as_array().unwrap(); - assert_eq!(sources.len(), 2); - assert!(sources.iter().any(|source| source["provider"] == "codex")); - assert!(sources.iter().any(|source| source["provider"] == "pi")); + let search = json_output(ctx(&temp).args([ + "search", + "parser test", + "--provider", + "custom", + "--refresh", + "off", + "--json", + ])); + assert!( + !search["results"].as_array().unwrap().is_empty(), + "custom import was not searchable: {search:#}" + ); - let stderr = String::from_utf8(output.stderr).unwrap(); - assert!(stderr.contains(r#""type":"ctx_progress""#), "{stderr}"); - assert!(stderr.contains(r#""phase":"finalizing""#), "{stderr}"); + let second = json_output(ctx(&temp).args([ + "import", + "--format", + "ctx-history-jsonl-v1", + "--path", + &fixture, + "--json", + "--progress", + "none", + ])); + assert_eq!(second["totals"]["imported_sessions"], 0); + assert_eq!(second["totals"]["imported_events"], 0); + assert_eq!(second["totals"]["imported_edges"], 0); + assert_eq!(second["totals"]["skipped"], 6); } #[test] -fn import_all_skips_empty_gemini_source() { +fn import_custom_history_jsonl_format_rejects_malformed_atomically() { let temp = tempdir(); - copy_dir_all( - Path::new(&provider_history_fixture("codex-sessions")), - &temp.path().join(".codex").join("sessions"), + let fixture = custom_history_fixture("malformed-partial.jsonl"); + + let stderr = failure_stderr(ctx(&temp).args([ + "import", + "--format", + "ctx-history-jsonl-v1", + "--path", + &fixture, + "--progress", + "none", + ])); + assert!( + stderr.contains("ctx-history-jsonl-v1 import failed"), + "{stderr}" ); - fs::create_dir_all(temp.path().join(".gemini")).unwrap(); - let sources = json_output(ctx(&temp).args(["sources", "--json"])); - let gemini = sources["sources"] + let status = json_output(ctx(&temp).args(["status", "--json"])); + assert_eq!(status["indexed_items"], 0); + let conn = Connection::open(temp.path().join("work.sqlite")).unwrap(); + assert_eq!( + sqlite_count(&conn, "SELECT COUNT(*) FROM history_records"), + 0 + ); + assert_eq!( + sqlite_count(&conn, "SELECT COUNT(*) FROM ctx_history_search"), + 0 + ); + assert_eq!( + sqlite_count(&conn, "SELECT COUNT(*) FROM capture_sources"), + 0 + ); + assert_eq!(sqlite_count(&conn, "SELECT COUNT(*) FROM sessions"), 0); + assert_eq!(sqlite_count(&conn, "SELECT COUNT(*) FROM events"), 0); +} + +#[test] +fn import_custom_history_format_is_not_a_native_provider_importer() { + let temp = tempdir(); + let stderr = failure_stderr(ctx(&temp).args(["import", "--provider", "custom"])); + assert!(stderr.contains("invalid value 'custom'"), "{stderr}"); + + let fixture = custom_history_fixture("basic.jsonl"); + let stderr = failure_stderr(ctx(&temp).args([ + "import", + "--format", + "ctx-history-jsonl-v1", + "--path", + &fixture, + "--all", + ])); + assert!(stderr.contains("--format"), "{stderr}"); + assert!(stderr.contains("--all"), "{stderr}"); +} + +#[test] +fn history_source_plugins_are_listed_without_running() { + let temp = tempdir(); + let plugin = write_history_source_plugin(&temp, "dorkos", false, None); + + let sources = json_output( + ctx(&temp) + .env("CTX_HISTORY_PLUGIN_PATH", &plugin.manifest_dir) + .args(["sources", "--json"]), + ); + let plugin_source = sources["sources"] .as_array() .unwrap() .iter() - .find(|source| source["provider"] == "gemini") + .find(|source| source["history_source"] == "dorkos/default") .unwrap(); - assert_eq!(gemini["status"], "empty"); - assert_eq!(gemini["native_import"], true); - assert_eq!(gemini["importable"], false); + assert_eq!(plugin_source["kind"], "history_source_plugin"); + assert_eq!(plugin_source["provider_key"], "dorkos"); + assert_eq!(plugin_source["enabled"], false); + assert!(!plugin.run_marker.exists()); +} - let imported = - json_output(ctx(&temp).args(["import", "--all", "--json", "--progress", "none"])); - assert_eq!(imported["totals"]["imported_sources"], 1); - assert_eq!(imported["totals"]["failed_sources"], 0); - assert!(imported["sources"] +#[test] +fn invalid_installed_history_source_plugin_is_listed_as_invalid() { + let temp = tempdir(); + let plugin_root = temp.path().join("history-plugins"); + let bad_dir = plugin_root.join("bad"); + fs::create_dir_all(&bad_dir).unwrap(); + fs::write(bad_dir.join("ctx-history-plugin.json"), "{not-json").unwrap(); + + let sources = json_output( + ctx(&temp) + .env("CTX_HISTORY_PLUGIN_PATH", &plugin_root) + .args(["sources", "--json"]), + ); + let invalid = sources["sources"] .as_array() .unwrap() .iter() - .all(|source| source["provider"] != "gemini")); + .find(|source| source["kind"] == "history_source_plugin" && source["status"] == "invalid") + .unwrap(); + assert_eq!(invalid["importable"], false); + assert_eq!(invalid["enabled"], false); + assert!(invalid["error"] + .as_str() + .unwrap() + .contains("parse history source plugin manifest")); } #[test] -fn import_all_reports_source_failure_without_losing_successes() { +fn oversized_installed_history_source_plugin_is_listed_as_invalid() { let temp = tempdir(); - copy_dir_all( - Path::new(&provider_history_fixture("codex-sessions")), - &temp.path().join(".codex").join("sessions"), - ); - let opencode_dir = temp.path().join(".local/share/opencode"); - fs::create_dir_all(&opencode_dir).unwrap(); - fs::write(opencode_dir.join("opencode.db"), b"not sqlite").unwrap(); + let plugin_root = temp.path().join("history-plugins"); + let bad_dir = plugin_root.join("oversized"); + fs::create_dir_all(&bad_dir).unwrap(); + fs::write( + bad_dir.join("ctx-history-plugin.json"), + vec![b' '; 2 * 1024 * 1024], + ) + .unwrap(); - let output = ctx(&temp) - .args(["import", "--all", "--json", "--progress", "none"]) - .assert() - .success() - .get_output() - .clone(); - let stdout: Value = serde_json::from_slice(&output.stdout).unwrap(); - assert_eq!(stdout["schema_version"], 1); - assert_eq!(stdout["totals"]["imported_sources"], 1); - assert_eq!(stdout["totals"]["failed_sources"], 1); - assert!(stdout["totals"]["imported_sessions"].as_u64().unwrap() > 0); - let sources = stdout["sources"].as_array().unwrap(); - assert!(sources - .iter() - .any(|source| source["provider"] == "codex" && source["status"] == "imported")); - assert!(sources - .iter() - .any(|source| source["provider"] == "opencode" && source["status"] == "failed")); - let opencode_failure = sources + let sources = json_output( + ctx(&temp) + .env("CTX_HISTORY_PLUGIN_PATH", &plugin_root) + .args(["sources", "--json"]), + ); + let invalid = sources["sources"] + .as_array() + .unwrap() .iter() - .find(|source| source["provider"] == "opencode") + .find(|source| source["kind"] == "history_source_plugin" && source["status"] == "invalid") .unwrap(); - assert!( - opencode_failure["error"] - .as_str() - .unwrap() - .contains("not a database"), - "{opencode_failure}" + assert_eq!(invalid["importable"], false); + assert!(invalid["error"] + .as_str() + .unwrap() + .contains("exceeds max bytes")); +} + +#[test] +fn invalid_installed_history_source_plugin_does_not_block_valid_import() { + let temp = tempdir(); + let plugin_root = temp.path().join("history-plugins"); + let good = write_history_source_plugin_at(&plugin_root, "dorkos", false, None); + let bad_dir = plugin_root.join("bad"); + fs::create_dir_all(&bad_dir).unwrap(); + fs::write(bad_dir.join("ctx-history-plugin.json"), "{not-json").unwrap(); + + let imported = json_output( + ctx(&temp) + .env("CTX_HISTORY_PLUGIN_PATH", &plugin_root) + .args([ + "import", + "--history-source", + "dorkos/default", + "--json", + "--progress", + "none", + ]), ); + + assert_eq!(imported["totals"]["imported_sources"], 1); + assert!(good.run_marker.exists()); } #[test] -fn failed_import_attempt_does_not_count_as_indexed_history() { +fn removed_history_source_plugin_aliases_and_legacy_discovery_are_ignored() { let temp = tempdir(); - let opencode_dir = temp.path().join(".local/share/opencode"); - fs::create_dir_all(&opencode_dir).unwrap(); - fs::write(opencode_dir.join("opencode.db"), b"not sqlite").unwrap(); + let plugin = write_history_source_plugin(&temp, "dorkos", false, None); - ctx(&temp) - .args(["import", "--all", "--json", "--progress", "none"]) - .assert() - .failure() - .stderr(predicate::str::contains("all import sources failed")); + let stderr = failure_stderr( + ctx(&temp) + .env("CTX_HISTORY_PLUGIN_PATH", &plugin.manifest_dir) + .args(["import", "--plugin", "dorkos/default"]), + ); + assert!(stderr.contains("--plugin"), "{stderr}"); - let status = json_output(ctx(&temp).args(["status", "--json"])); - assert_eq!(status["indexed_items"], 0); - assert_eq!(status["indexed_sources"], 0); + let stderr = failure_stderr( + ctx(&temp) + .env("CTX_HISTORY_PLUGIN_PATH", &plugin.manifest_dir) + .args(["import", "--plugin-manifest", "ctx-history-plugin.json"]), + ); + assert!(stderr.contains("--plugin-manifest"), "{stderr}"); + + let sources = json_output( + ctx(&temp) + .env_remove("CTX_HISTORY_PLUGIN_PATH") + .env("CTX_PLUGIN_PATH", &plugin.manifest_dir) + .args(["sources", "--json"]), + ); + assert!(!sources["sources"] + .as_array() + .unwrap() + .iter() + .any(|source| source["history_source"] == "dorkos/default")); + + let legacy_dir = temp.path().join("legacy-plugin"); + fs::create_dir_all(&legacy_dir).unwrap(); + fs::copy( + plugin.manifest_dir.join("ctx-history-plugin.json"), + legacy_dir.join("plugin.json"), + ) + .unwrap(); + let sources = json_output( + ctx(&temp) + .env_remove("CTX_PLUGIN_PATH") + .env("CTX_HISTORY_PLUGIN_PATH", &legacy_dir) + .args(["sources", "--json"]), + ); + assert!(!sources["sources"] + .as_array() + .unwrap() + .iter() + .any(|source| source["history_source"] == "dorkos/default")); } #[test] -fn provider_help_matches_implemented_importers() { +fn setup_does_not_execute_enabled_history_source_plugins() { let temp = tempdir(); - let output = ctx(&temp) - .args(["import", "--help"]) - .assert() - .success() - .get_output() - .stdout - .clone(); - let help = String::from_utf8(output).unwrap(); + let plugin = write_history_source_plugin(&temp, "dorkos", true, None); - for value in [ - "codex", - "pi", - "claude", - "opencode", - "antigravity", - "gemini", - "cursor", - "copilot-cli", - "factory-ai-droid", - ] { - assert!(help.contains(value), "provider {value} missing in\n{help}"); - } + json_output( + ctx(&temp) + .env("CTX_HISTORY_PLUGIN_PATH", &plugin.manifest_dir) + .args(["setup", "--json", "--progress", "none"]), + ); + + assert!(!plugin.run_marker.exists()); } #[test] -fn provider_json_names_are_accepted_as_cli_filter_aliases() { +fn bare_history_source_plugin_selector_fails_before_execution() { let temp = tempdir(); - for provider in ["copilot_cli", "factory_ai_droid"] { - let search = json_output(ctx(&temp).args([ - "search", - "anything", - "--provider", - provider, - "--refresh", - "off", - "--json", - ])); - assert_eq!(search["filters"]["provider"], provider); - } + let plugin_root = temp.path().join("history-plugins"); + let dorkos = write_history_source_plugin_at(&plugin_root, "dorkos", false, None); + let hermes = write_history_source_plugin_at(&plugin_root, "hermes", false, None); + + let stderr = failure_stderr( + ctx(&temp) + .env("CTX_HISTORY_PLUGIN_PATH", &plugin_root) + .args(["import", "--history-source", "dorkos", "--progress", "none"]), + ); + + assert!( + stderr.contains("no history source plugin matched"), + "{stderr}" + ); + assert!(!dorkos.run_marker.exists()); + assert!(!hermes.run_marker.exists()); } #[test] -fn search_excludes_active_codex_session_by_default_when_available() { +fn explicit_history_source_manifest_reports_parse_errors() { let temp = tempdir(); - let fixture = provider_history_fixture("codex-sessions"); - json_output(ctx(&temp).args([ + let bad_manifest = temp.path().join("bad-plugin.json"); + fs::write(&bad_manifest, "{not-json").unwrap(); + + let stderr = failure_stderr(ctx(&temp).args([ "import", - "--provider", - "codex", - "--path", - &fixture, - "--json", + "--history-source-manifest", + bad_manifest.to_str().unwrap(), "--progress", "none", ])); - let excluded = json_output( + assert!( + stderr.contains("parse history source plugin manifest"), + "{stderr}" + ); +} + +#[test] +fn failed_history_source_plugin_import_does_not_leave_record_metadata() { + let temp = tempdir(); + let script = r#"#!/usr/bin/env python3 +import json +provider = "badplugin" +records = [ + {"record_type":"manifest","schema_version":"ctx-history-jsonl-v1"}, + {"record_type":"source","source_id":"default","provider_key":provider,"source_format":"badplugin-history-v1"}, + {"record_type":"event","source_id":"default","session_id":"missing","event_index":0,"event_type":"message","role":"assistant","occurred_at":"2026-07-01T12:00:00Z","preview":"should not import"} +] +for record in records: + print(json.dumps(record)) +"#; + let plugin = write_raw_history_source_plugin(&temp, "badplugin", script); + + let stderr = failure_stderr( ctx(&temp) - .env("CODEX_THREAD_ID", "codex-session-root") + .env("CTX_HISTORY_PLUGIN_PATH", &plugin.manifest_dir) .args([ - "search", - "onboarding", - "--provider", - "codex", - "--refresh", - "off", - "--json", + "import", + "--history-source", + "badplugin/default", + "--progress", + "none", ]), ); - assert_eq!(excluded["results"].as_array().unwrap().len(), 0); + + assert!(stderr.contains("import failed"), "{stderr}"); + let conn = Connection::open(temp.path().join("work.sqlite")).unwrap(); assert_eq!( - excluded["filters"]["exclude_provider_session"]["provider"], - "codex" + sqlite_count(&conn, "SELECT COUNT(*) FROM history_records"), + 0 + ); + assert_eq!(sqlite_count(&conn, "SELECT COUNT(*) FROM sessions"), 0); + assert_eq!(sqlite_count(&conn, "SELECT COUNT(*) FROM events"), 0); +} + +#[test] +fn history_source_plugin_rejects_mismatched_machine_id_before_import() { + let temp = tempdir(); + let script = r#"#!/usr/bin/env python3 +import json +records = [ + {"record_type":"manifest","schema_version":"ctx-history-jsonl-v1"}, + {"record_type":"source","source_id":"default","provider_key":"machineplugin","source_format":"machineplugin-history-v1","machine_id":"other-machine"}, + {"record_type":"session","source_id":"default","session_id":"run","started_at":"2026-07-01T12:00:00Z"}, +] +for record in records: + print(json.dumps(record)) +"#; + let plugin = write_raw_history_source_plugin(&temp, "machineplugin", script); + + let stderr = failure_stderr( + ctx(&temp) + .env("CTX_HISTORY_PLUGIN_PATH", &plugin.manifest_dir) + .args([ + "import", + "--history-source", + "machineplugin/default", + "--progress", + "none", + ]), ); + + assert!(stderr.contains("machine_id"), "{stderr}"); + let conn = Connection::open(temp.path().join("work.sqlite")).unwrap(); assert_eq!( - excluded["filters"]["exclude_provider_session"]["provider_session_id"], - "codex-session-root" + sqlite_count(&conn, "SELECT COUNT(*) FROM history_records"), + 0 ); - assert!(excluded["filters"]["exclude_provider_session"]["session_id"].is_string()); +} - let excluded_tree = json_output( +#[test] +fn history_source_plugin_rejects_oversized_stdout_line() { + let temp = tempdir(); + let script = r#"#!/usr/bin/env python3 +import sys +sys.stdout.write("x" * (17 * 1024 * 1024) + "\n") +"#; + let plugin = write_raw_history_source_plugin(&temp, "bigline", script); + + let stderr = failure_stderr( ctx(&temp) - .env("CODEX_THREAD_ID", "codex-session-root") + .env("CTX_HISTORY_PLUGIN_PATH", &plugin.manifest_dir) .args([ - "search", - "local history search", - "--provider", - "codex", - "--refresh", - "off", + "import", + "--history-source", + "bigline/default", "--json", + "--progress", + "none", + ]), + ); + + assert!(stderr.contains("line 1 exceeding max bytes"), "{stderr}"); +} + +#[test] +fn history_source_plugin_reset_requires_fresh_after_cursor() { + let temp = tempdir(); + let script = r#"#!/usr/bin/env python3 +import json +records = [ + {"record_type":"manifest","schema_version":"ctx-history-jsonl-v1"}, + {"record_type":"source","source_id":"default","provider_key":"nocursor","source_format":"nocursor-history-v1"}, + {"record_type":"session","source_id":"default","session_id":"run","started_at":"2026-07-01T12:00:00Z"}, +] +for record in records: + print(json.dumps(record)) +"#; + let plugin = write_raw_history_source_plugin(&temp, "nocursor", script); + + let stderr = failure_stderr( + ctx(&temp) + .env("CTX_HISTORY_PLUGIN_PATH", &plugin.manifest_dir) + .args([ + "import", + "--history-source", + "nocursor/default", + "--reset-cursor", + "--progress", + "none", ]), ); + + assert!(stderr.contains("source.cursor.after"), "{stderr}"); + let conn = Connection::open(temp.path().join("work.sqlite")).unwrap(); assert_eq!( - excluded_tree["results"].as_array().unwrap().len(), - 0, - "active session tree was not excluded: {excluded_tree:#}" + sqlite_count(&conn, "SELECT COUNT(*) FROM history_records"), + 0 ); +} - let included = json_output( +#[test] +fn large_history_source_plugin_cursor_uses_cursor_file_without_inline_env() { + let temp = tempdir(); + let log = temp.path().join("large-cursor.log"); + let log_json = serde_json::to_string(&log.display().to_string()).unwrap(); + let script = format!( + r#"#!/usr/bin/env python3 +import json +import os +import pathlib + +cursor_file = os.environ.get("CTX_HISTORY_CURSOR_FILE") +inline = os.environ.get("CTX_HISTORY_CURSOR") +cursor_text = pathlib.Path(cursor_file).read_text() if cursor_file else inline +if cursor_text: + with open({log_json}, "a", encoding="utf-8") as handle: + handle.write("inline=" + ("1" if inline else "0") + "\n") + handle.write("file_len=" + str(len(cursor_text)) + "\n") +next_cursor = "x" * 9000 if not cursor_text else "done" +observed = "2026-07-01T12:00:00Z" +records = [ + {{"record_type":"manifest","schema_version":"ctx-history-jsonl-v1"}}, + {{"record_type":"source","source_id":"default","provider_key":"largecursor","source_format":"largecursor-history-v1","cursor":{{"after":{{"stream":os.environ["CTX_HISTORY_CURSOR_STREAM"],"cursor":next_cursor,"observed_at":observed}}}}}}, + {{"record_type":"session","source_id":"default","session_id":"run","started_at":"2026-07-01T12:00:00Z"}}, + {{"record_type":"event","source_id":"default","session_id":"run","event_index":1 if cursor_text else 0,"event_type":"message","role":"assistant","occurred_at":observed,"preview":"large cursor marker"}}, +] +for record in records: + print(json.dumps(record)) +"# + ); + let plugin = write_raw_history_source_plugin(&temp, "largecursor", &script); + + json_output( ctx(&temp) - .env("CODEX_THREAD_ID", "codex-session-root") + .env("CTX_HISTORY_PLUGIN_PATH", &plugin.manifest_dir) .args([ - "search", - "onboarding", - "--provider", - "codex", - "--refresh", - "off", - "--include-current-session", + "import", + "--history-source", + "largecursor/default", "--json", + "--progress", + "none", ]), ); - assert_search_provider_oracle(&included, "codex", "onboarding", 1, "message"); - assert!(included["filters"]["exclude_provider_session"].is_null()); - - let included_tree = json_output( + json_output( ctx(&temp) - .env("CODEX_THREAD_ID", "codex-session-root") + .env("CTX_HISTORY_PLUGIN_PATH", &plugin.manifest_dir) .args([ - "search", - "local history search", - "--provider", - "codex", - "--refresh", - "off", - "--include-current-session", + "import", + "--history-source", + "largecursor/default", "--json", + "--progress", + "none", ]), ); - assert!(!included_tree["results"].as_array().unwrap().is_empty()); + + let log = fs::read_to_string(log).unwrap(); + assert!(log.contains("inline=0"), "{log}"); + assert!(log.contains("file_len=9000"), "{log}"); } #[test] -fn public_subcommand_help_is_golden_enough_for_session_retrieval() { +fn import_history_source_plugin_is_searchable_and_receives_cursor() { let temp = tempdir(); - for (command, required) in [ - ("setup", vec!["Usage: ctx setup", "--json"]), - ("status", vec!["Usage: ctx status", "--json"]), - ("sources", vec!["Usage: ctx sources", "--json"]), - ( - "import", - vec![ - "Usage: ctx import", - "--provider ", - "[possible values: codex, pi, claude, opencode, antigravity, gemini, cursor, copilot-cli, factory-ai-droid]", - "--path ", - "--resume", - "--json", + let cursor_log = temp.path().join("cursor-log.txt"); + let plugin = write_history_source_plugin(&temp, "hermes", false, Some(&cursor_log)); + + let first = json_output( + ctx(&temp) + .env("CTX_HISTORY_PLUGIN_PATH", &plugin.manifest_dir) + .args([ + "import", + "--history-source", + "hermes/default", + "--resume", + "--json", + "--progress", + "none", + ]), + ); + assert_eq!(first["totals"]["imported_sessions"], 1); + assert_eq!(first["totals"]["imported_events"], 1); + assert_eq!(first["sources"][0]["history_source"], "hermes/default"); + + let initial = json_output(ctx(&temp).args([ + "search", + "hermes plugin initial marker", + "--provider", + "custom", + "--refresh", + "off", + "--json", + ])); + assert!( + !initial["results"].as_array().unwrap().is_empty(), + "initial plugin import was not searchable: {initial:#}" + ); + let initial_by_history_source = json_output(ctx(&temp).args([ + "search", + "hermes plugin initial marker", + "--history-source", + "hermes/default", + "--refresh", + "off", + "--json", + ])); + let source_filtered_result = &initial_by_history_source["results"][0]; + assert_eq!(source_filtered_result["provider"], "custom"); + assert_eq!(source_filtered_result["history_source"], "hermes/default"); + assert_eq!(source_filtered_result["history_source_plugin"], "hermes"); + assert_eq!(source_filtered_result["provider_key"], "hermes"); + assert_eq!(source_filtered_result["source_id"], "default"); + assert_eq!(source_filtered_result["source_format"], "hermes-history-v1"); + + let second = json_output( + ctx(&temp) + .env("CTX_HISTORY_PLUGIN_PATH", &plugin.manifest_dir) + .args([ + "import", + "--history-source", + "hermes/default", + "--json", + "--progress", + "none", + ]), + ); + assert_eq!(second["totals"]["imported_sessions"], 0); + assert_eq!(second["totals"]["imported_events"], 1); + assert_eq!(second["resume"], false); + assert_eq!(second["resume_mode"], "normal_scan"); + + let incremental = json_output(ctx(&temp).args([ + "search", + "hermes plugin incremental marker", + "--provider", + "custom", + "--refresh", + "off", + "--json", + ])); + assert!( + !incremental["results"].as_array().unwrap().is_empty(), + "incremental plugin import was not searchable: {incremental:#}" + ); + let cursor_log = fs::read_to_string(cursor_log).unwrap(); + assert!(cursor_log.contains(r#""message_id":7"#), "{cursor_log}"); + assert!(cursor_log.contains("cursor_file="), "{cursor_log}"); +} + +#[test] +fn import_all_runs_enabled_history_source_plugins_for_external_shapes() { + let temp = tempdir(); + let plugin_root = temp.path().join("history-plugins"); + let providers = ["dorkos", "openclaw", "hermes", "nanoclaw"]; + for provider in providers { + write_history_source_plugin_at(&plugin_root, provider, true, None); + } + write_history_source_plugin_at(&plugin_root, "disabled-dorkos", false, None); + + let imported = json_output( + ctx(&temp) + .env("CTX_HISTORY_PLUGIN_PATH", &plugin_root) + .args(["import", "--all", "--json", "--progress", "none"]), + ); + assert_eq!(imported["totals"]["imported_sources"], 4); + assert_eq!(imported["totals"]["imported_sessions"], 4); + assert_eq!(imported["totals"]["imported_events"], 4); + let sources = imported["sources"].as_array().unwrap(); + for provider in providers { + assert!( + sources + .iter() + .any(|source| source["history_source"] == format!("{provider}/default")), + "missing import source for {provider}: {sources:#?}" + ); + let search = json_output(ctx(&temp).args([ + "search", + &format!("{provider} plugin initial marker"), + "--provider", + "custom", + "--refresh", + "off", + "--json", + ])); + assert!( + !search["results"].as_array().unwrap().is_empty(), + "{provider} plugin result was not searchable: {search:#}" + ); + } + assert!(!sources + .iter() + .any(|source| source["history_source"] == "disabled-dorkos/default")); +} + +#[test] +fn import_all_discovers_and_imports_providers_together() { + let temp = tempdir(); + copy_dir_all( + Path::new(&provider_history_fixture("codex-sessions")), + &temp.path().join(".codex").join("sessions"), + ); + let pi_home = temp.path().join(".pi/agent/sessions/--workspace-example--"); + fs::create_dir_all(&pi_home).unwrap(); + fs::copy( + provider_history_fixture("pi-session.jsonl"), + pi_home.join("2026-06-24T12-00-00-000Z_pi-session-docs-1.jsonl"), + ) + .unwrap(); + + let output = ctx(&temp) + .args(["import", "--all", "--json", "--progress", "json"]) + .assert() + .success() + .get_output() + .clone(); + + let stdout: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(stdout["schema_version"], 1); + assert!(stdout["totals"]["imported_sessions"].as_u64().unwrap() >= 3); + let sources = stdout["sources"].as_array().unwrap(); + assert_eq!(sources.len(), 2); + assert!(sources.iter().any(|source| source["provider"] == "codex")); + assert!(sources.iter().any(|source| source["provider"] == "pi")); + + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.contains(r#""type":"ctx_progress""#), "{stderr}"); + assert!(stderr.contains(r#""phase":"finalizing""#), "{stderr}"); +} + +#[test] +fn import_all_without_sources_does_not_report_missing_explicit_path() { + let temp = tempdir(); + let stderr = failure_stderr(ctx(&temp).args(["import", "--all", "--json"])); + + assert!(stderr.contains("no importable provider history sources found")); + assert!(!stderr.contains("import path does not exist"), "{stderr}"); +} + +#[test] +fn import_all_skips_empty_gemini_source() { + let temp = tempdir(); + copy_dir_all( + Path::new(&provider_history_fixture("codex-sessions")), + &temp.path().join(".codex").join("sessions"), + ); + fs::create_dir_all(temp.path().join(".gemini")).unwrap(); + + let sources = json_output(ctx(&temp).args(["sources", "--json"])); + let gemini = sources["sources"] + .as_array() + .unwrap() + .iter() + .find(|source| source["provider"] == "gemini") + .unwrap(); + assert_eq!(gemini["status"], "empty"); + assert_eq!(gemini["native_import"], true); + assert_eq!(gemini["importable"], false); + + let imported = + json_output(ctx(&temp).args(["import", "--all", "--json", "--progress", "none"])); + assert_eq!(imported["totals"]["imported_sources"], 1); + assert_eq!(imported["totals"]["failed_sources"], 0); + assert!(imported["sources"] + .as_array() + .unwrap() + .iter() + .all(|source| source["provider"] != "gemini")); +} + +#[test] +fn sources_lists_personal_agent_provider_defaults() { + let temp = tempdir(); + install_default_openclaw_fixture(&temp, "openclaw-sources-oracle"); + install_default_hermes_fixture(&temp, "hermes-sources-oracle"); + install_default_astrbot_fixture(&temp, "astrbot-sources-oracle"); + install_default_shelley_fixture(&temp, "shelley-sources-oracle"); + + let sources = json_output(ctx(&temp).args(["sources", "--json"])); + for (provider, source_format, import_support, native_import) in [ + ("openclaw", "openclaw_session_jsonl_tree", "native", true), + ("hermes", "hermes_state_sqlite", "native", true), + ("astrbot", "astrbot_data_v4_sqlite", "preview", false), + ("shelley", "shelley_sqlite", "native", true), + ] { + let source = sources["sources"] + .as_array() + .unwrap() + .iter() + .find(|source| { + source["provider"] == provider && source["source_format"] == source_format + }) + .unwrap_or_else(|| panic!("missing {provider} source in {sources:#}")); + assert_eq!(source["status"], "available"); + assert_eq!(source["import_support"], import_support); + assert_eq!(source["native_import"], native_import); + assert_eq!(source["importable"], true); + assert!(source["unsupported_reason"].is_null()); + } +} + +#[test] +fn sources_discovers_shelley_db_env_override() { + let temp = tempdir(); + let db_path = temp.path().join("custom-shelley.db"); + fs::write(&db_path, b"sqlite fixture marker").unwrap(); + + let sources = json_output( + ctx(&temp) + .env("SHELLEY_DB", &db_path) + .args(["sources", "--json"]), + ); + let source = sources["sources"] + .as_array() + .unwrap() + .iter() + .find(|source| { + source["provider"] == "shelley" && source["path"] == db_path.to_str().unwrap() + }) + .unwrap_or_else(|| panic!("missing Shelley source in {sources:#}")); + assert_eq!(source["source_format"], "shelley_sqlite"); + assert_eq!(source["status"], "available"); + assert_eq!(source["import_support"], "native"); + assert_eq!(source["path"], db_path.to_str().unwrap()); +} + +#[test] +fn preview_native_sources_are_listed_but_not_auto_imported() { + let temp = tempdir(); + let query = "nanoclaw-preview-auto-refresh-oracle"; + let project = PathBuf::from(write_native_nanoclaw_fixture(&temp, query)); + + let mut sources_command = ctx(&temp); + sources_command.current_dir(&project); + let sources = json_output(sources_command.args(["sources", "--json"])); + let nanoclaw = sources["sources"] + .as_array() + .unwrap() + .iter() + .find(|source| source["provider"] == "nanoclaw") + .unwrap(); + assert_eq!(nanoclaw["status"], "available"); + assert_eq!(nanoclaw["import_support"], "preview"); + assert_eq!(nanoclaw["native_import"], false); + assert_eq!(nanoclaw["importable"], true); + assert!(nanoclaw["unsupported_reason"].is_null()); + + let mut search_command = ctx(&temp); + search_command.current_dir(&project); + let search = + json_output(search_command.args(["search", query, "--provider", "nanoclaw", "--json"])); + assert_eq!(search["freshness"]["mode"], "auto"); + assert_eq!(search["freshness"]["status"], "no_sources"); + assert_eq!(search["freshness"]["source_count"], 0); + assert!(search["results"].as_array().unwrap().is_empty()); + + let imported = json_output(ctx(&temp).args([ + "import", + "--provider", + "nanoclaw", + "--path", + project.to_str().unwrap(), + "--json", + ])); + assert_eq!(imported["totals"]["failed"], 0); + assert_eq!(imported["totals"]["imported_sources"], 1); + + let search_after_import = + json_output(ctx(&temp).args(["search", query, "--provider", "nanoclaw", "--json"])); + assert_search_provider_oracle(&search_after_import, "nanoclaw", query, 1, "message"); +} + +#[test] +fn import_all_reports_source_failure_without_losing_successes() { + let temp = tempdir(); + copy_dir_all( + Path::new(&provider_history_fixture("codex-sessions")), + &temp.path().join(".codex").join("sessions"), + ); + let opencode_dir = temp.path().join(".local/share/opencode"); + fs::create_dir_all(&opencode_dir).unwrap(); + fs::write(opencode_dir.join("opencode.db"), b"not sqlite").unwrap(); + + let output = ctx(&temp) + .args(["import", "--all", "--json", "--progress", "none"]) + .assert() + .success() + .get_output() + .clone(); + let stdout: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(stdout["schema_version"], 1); + assert_eq!(stdout["totals"]["imported_sources"], 1); + assert_eq!(stdout["totals"]["failed_sources"], 1); + assert!(stdout["totals"]["imported_sessions"].as_u64().unwrap() > 0); + let sources = stdout["sources"].as_array().unwrap(); + assert!(sources + .iter() + .any(|source| source["provider"] == "codex" && source["status"] == "imported")); + assert!(sources + .iter() + .any(|source| source["provider"] == "opencode" && source["status"] == "failed")); + let opencode_failure = sources + .iter() + .find(|source| source["provider"] == "opencode") + .unwrap(); + assert!( + opencode_failure["error"] + .as_str() + .unwrap() + .contains("not a database"), + "{opencode_failure}" + ); +} + +#[test] +fn failed_import_attempt_does_not_count_as_indexed_history() { + let temp = tempdir(); + let opencode_dir = temp.path().join(".local/share/opencode"); + fs::create_dir_all(&opencode_dir).unwrap(); + fs::write(opencode_dir.join("opencode.db"), b"not sqlite").unwrap(); + + ctx(&temp) + .args(["import", "--all", "--json", "--progress", "none"]) + .assert() + .failure() + .stderr(predicate::str::contains("all import sources failed")); + + let status = json_output(ctx(&temp).args(["status", "--json"])); + assert_eq!(status["indexed_items"], 0); + assert_eq!(status["indexed_sources"], 0); +} + +#[test] +fn provider_help_matches_implemented_importers() { + let temp = tempdir(); + let output = ctx(&temp) + .args(["import", "--help"]) + .assert() + .success() + .get_output() + .stdout + .clone(); + let help = String::from_utf8(output).unwrap(); + + for value in [ + "codex", + "pi", + "claude", + "opencode", + "openclaw", + "hermes", + "nanoclaw", + "astrbot", + "antigravity", + "gemini", + "cursor", + "copilot-cli", + "factory-ai-droid", + ] { + assert!(help.contains(value), "provider {value} missing in\n{help}"); + } +} + +#[test] +fn provider_json_names_are_accepted_as_cli_filter_aliases() { + let temp = tempdir(); + initialize_empty_store(&temp); + + for (provider, expected) in [ + ("copilot_cli", "copilot_cli"), + ("factory_ai_droid", "factory_ai_droid"), + ("open_claw", "openclaw"), + ("nano_claw", "nanoclaw"), + ("astr_bot", "astrbot"), + ] { + let search = json_output(ctx(&temp).args([ + "search", + "anything", + "--provider", + provider, + "--refresh", + "off", + "--json", + ])); + assert_eq!(search["filters"]["provider"], expected); + } +} + +#[test] +fn search_excludes_active_codex_session_by_default_when_available() { + let temp = tempdir(); + let fixture = provider_history_fixture("codex-sessions"); + json_output(ctx(&temp).args([ + "import", + "--provider", + "codex", + "--path", + &fixture, + "--json", + "--progress", + "none", + ])); + + let excluded = json_output( + ctx(&temp) + .env("CODEX_THREAD_ID", "codex-session-root") + .args([ + "search", + "onboarding", + "--provider", + "codex", + "--refresh", + "off", + "--json", + ]), + ); + assert_eq!(excluded["results"].as_array().unwrap().len(), 0); + assert_eq!( + excluded["filters"]["exclude_provider_session"]["provider"], + "codex" + ); + assert_eq!( + excluded["filters"]["exclude_provider_session"]["provider_session_id"], + "codex-session-root" + ); + assert!(excluded["filters"]["exclude_provider_session"]["session_id"].is_string()); + + let excluded_tree = json_output( + ctx(&temp) + .env("CODEX_THREAD_ID", "codex-session-root") + .args([ + "search", + "local history search", + "--provider", + "codex", + "--refresh", + "off", + "--json", + ]), + ); + assert_eq!( + excluded_tree["results"].as_array().unwrap().len(), + 0, + "active session tree was not excluded: {excluded_tree:#}" + ); + + let included = json_output( + ctx(&temp) + .env("CODEX_THREAD_ID", "codex-session-root") + .args([ + "search", + "onboarding", + "--provider", + "codex", + "--refresh", + "off", + "--include-current-session", + "--json", + ]), + ); + assert_search_provider_oracle(&included, "codex", "onboarding", 1, "message"); + assert!(included["filters"]["exclude_provider_session"].is_null()); + + let included_tree = json_output( + ctx(&temp) + .env("CODEX_THREAD_ID", "codex-session-root") + .args([ + "search", + "local history search", + "--provider", + "codex", + "--refresh", + "off", + "--include-current-session", + "--json", + ]), + ); + assert!(!included_tree["results"].as_array().unwrap().is_empty()); +} + +#[test] +fn public_subcommand_help_is_golden_enough_for_session_retrieval() { + let temp = tempdir(); + for (command, required) in [ + ("setup", vec!["Usage: ctx setup", "--json"]), + ("status", vec!["Usage: ctx status", "--json"]), + ("sources", vec!["Usage: ctx sources", "--json"]), + ( + "import", + vec![ + "Usage: ctx import", + "--provider ", + "[possible values: codex, pi, claude, opencode, antigravity, gemini, cursor, copilot-cli, factory-ai-droid, openclaw, hermes, nanoclaw, astrbot, shelley]", + "--path ", + "--format ", + "--resume", + "--json", ], ), ("show", vec!["Usage: ctx show", "session", "event"]), @@ -1054,15 +2197,15 @@ fn public_subcommand_help_is_golden_enough_for_session_retrieval() { "Add another search query or keyword", "--provider ", "--workspace ", - "Filter by workspace path or name text", + "Filter by stored workspace", "--since ", "Filter to recent history, as RFC3339 or a day window like 30d", - "--primary-only", - "Return only primary-agent sessions", "--include-subagents", "Include subagent sessions", "--event-type ", + "Filter by event type:", "--file ", + "indexed touched-file path metadata", "--session ", "--events", "--limit ", @@ -1077,7 +2220,7 @@ fn public_subcommand_help_is_golden_enough_for_session_retrieval() { "Print expanded text details", ], ), - ("doctor", vec!["Usage: ctx doctor", "--json"]), + ("doctor", vec!["Usage: ctx doctor", "--json", "--progress"]), ] { let output = ctx(&temp) .args([command, "--help"]) @@ -1136,6 +2279,32 @@ fn sql_reads_existing_store_and_supports_formats_and_input_sources() { "value,n\n\"a,b\",2\n" ); + let oversized_file_stderr = failure_stderr( + ctx(&temp) + .arg("sql") + .arg("--file") + .arg(&query_file) + .args(["--max-sql-bytes", "4"]), + ); + assert!( + oversized_file_stderr.contains("exceeds max_sql_bytes (4)"), + "{oversized_file_stderr}" + ); + + let oversized_stdin_stderr = ctx(&temp) + .args(["sql", "-", "--max-sql-bytes", "4"]) + .write_stdin("SELECT 1") + .assert() + .failure() + .get_output() + .stderr + .clone(); + let oversized_stdin_stderr = String::from_utf8(oversized_stdin_stderr).unwrap(); + assert!( + oversized_stdin_stderr.contains("exceeds max_sql_bytes (4)"), + "{oversized_stdin_stderr}" + ); + let raw_output = ctx(&temp) .args(["sql", "-", "--format", "raw"]) .write_stdin("SELECT 'abc' AS value") @@ -1185,17 +2354,56 @@ fn docs_commands_expose_embedded_docs_and_man_pages() { .unwrap() .iter() .any(|topic| topic["id"] == "cli-reference")); + for topic_id in ["docs", "mcp", "sql", "upgrade"] { + assert!(list["topics"] + .as_array() + .unwrap() + .iter() + .any(|topic| topic["id"] == topic_id)); + } let search = json_output(ctx(&temp).args(["docs", "search", "upgrade", "--json"])); assert_eq!(search["schema_version"], 1); assert_eq!(search["query"], "upgrade"); assert!(!search["results"].as_array().unwrap().is_empty()); + let sql_search = json_output(ctx(&temp).args(["docs", "search", "sql", "--json"])); + assert_eq!(sql_search["results"][0]["id"], "sql"); + + let mcp_search = json_output(ctx(&temp).args(["docs", "search", "mcp", "--json"])); + assert_eq!(mcp_search["results"][0]["id"], "mcp"); + + let upgrade_search = json_output(ctx(&temp).args(["docs", "search", "upgrade", "--json"])); + assert_eq!(upgrade_search["results"][0]["id"], "upgrade"); + + let weak_search = json_output(ctx(&temp).args(["docs", "search", "a", "--json"])); + assert!(weak_search["results"].as_array().unwrap().is_empty()); + assert!(weak_search["suggested_next_commands"] + .as_array() + .unwrap() + .iter() + .any(|command| command == "ctx docs list")); + let show = json_output(ctx(&temp).args(["docs", "show", "cli-reference", "--format", "json"])); assert_eq!(show["schema_version"], 1); assert_eq!(show["id"], "cli-reference"); assert!(show["body"].as_str().unwrap().contains("ctx search")); + let mcp = json_output(ctx(&temp).args(["docs", "show", "mcp", "--format", "json"])); + assert!(mcp["body"].as_str().unwrap().contains("ctx mcp serve")); + + let upgrade = json_output(ctx(&temp).args(["docs", "show", "upgrade", "--format", "json"])); + assert!(upgrade["body"] + .as_str() + .unwrap() + .contains("ctx upgrade status")); + + let missing_topic = failure_stderr(ctx(&temp).args(["docs", "show", "cli"])); + assert!(missing_topic.contains("unknown ctx docs topic: cli")); + assert!(missing_topic.contains("nearest topics:")); + assert!(missing_topic.contains("ctx docs list")); + assert!(missing_topic.contains("ctx docs search cli")); + let man = ctx(&temp) .args(["docs", "man", "--print", "ctx"]) .assert() @@ -1225,6 +2433,21 @@ fn write_fake_ctx_binary(path: &Path, version: &str) -> Vec { bytes } +#[cfg(unix)] +fn write_hanging_ctx_binary(path: &Path) { + fs::write( + path, + "#!/bin/sh\n\ +if [ -n \"${CTX_SHADOW_MARKER:-}\" ]; then\n\ + touch \"$CTX_SHADOW_MARKER\"\n\ +fi\n\ +sleep 5\n\ +printf 'ctx 0.1.0\\n'\n", + ) + .unwrap(); + make_file_executable(path); +} + #[cfg(unix)] fn make_file_executable(path: &Path) { use std::os::unix::fs::PermissionsExt; @@ -1265,6 +2488,7 @@ fn fake_release(temp: &TempDir, latest_version: &str) -> FakeRelease { let marker = json!({ "schema_version": 1, "manager": "ctx-hosted-installer", + "install_attempt_id": "ia_test_upgrade_attempt", "install_path": target, "platform": test_platform_key().replace('_', "-"), "channel": "stable", @@ -1379,6 +2603,139 @@ fn upgrade_status_check_and_apply_support_managed_installs() { serde_json::from_slice(&fs::read(install_marker_path(&release.target)).unwrap()).unwrap(); assert_eq!(marker["version"], "9.9.9"); assert_eq!(marker["sha256"], release.artifact_sha); + assert_eq!(marker["install_attempt_id"], "ia_test_upgrade_attempt"); +} + +#[cfg(unix)] +#[test] +fn upgrade_status_reports_path_shadowing() { + let temp = tempdir(); + let release = fake_release(&temp, "9.9.9"); + let shadow_dir = temp.path().join("shadow-bin"); + fs::create_dir_all(&shadow_dir).unwrap(); + let shadow_ctx = shadow_dir.join("ctx"); + write_fake_ctx_binary(&shadow_ctx, "0.9.0"); + let managed_dir = release.target.parent().unwrap(); + let path = std::env::join_paths([shadow_dir.as_path(), managed_dir]).unwrap(); + + let mut command = ctx(&temp); + command + .args(["upgrade", "status", "--json"]) + .env("PATH", path); + let status = json_output(fake_release_env(&mut command, &release)); + + assert_eq!(status["current_version"], env!("CARGO_PKG_VERSION")); + assert_eq!( + status["path"]["entries"][0]["path"], + shadow_ctx.display().to_string() + ); + assert!(status["path"]["entries"][0]["version"].is_null()); + assert!(status["warnings"] + .as_array() + .unwrap() + .iter() + .any(|warning| { warning.as_str().unwrap().contains("PATH resolves ctx to") })); +} + +#[cfg(unix)] +#[test] +fn upgrade_commands_do_not_execute_hanging_shadow_path_ctx() { + for args in [ + ["upgrade", "status", "--json"].as_slice(), + ["upgrade", "check", "--json"].as_slice(), + ["upgrade", "--json"].as_slice(), + ] { + let temp = tempdir(); + let release = fake_release(&temp, "9.9.9"); + let shadow_dir = temp.path().join("shadow-bin"); + fs::create_dir_all(&shadow_dir).unwrap(); + let shadow_ctx = shadow_dir.join("ctx"); + write_hanging_ctx_binary(&shadow_ctx); + let marker = temp.path().join("shadow-ran"); + let managed_dir = release.target.parent().unwrap(); + let path = std::env::join_paths([shadow_dir.as_path(), managed_dir]).unwrap(); + + let started = Instant::now(); + let mut command = ctx(&temp); + command + .args(args) + .env("PATH", &path) + .env("CTX_SHADOW_MARKER", &marker); + let output = json_output(fake_release_env(&mut command, &release)); + let elapsed = started.elapsed(); + + assert!( + elapsed < Duration::from_secs(2), + "ctx {args:?} should not wait for shadow PATH binaries; elapsed {elapsed:?}" + ); + assert_eq!( + output["path"]["entries"][0]["path"], + shadow_ctx.display().to_string() + ); + assert!( + output["path"]["entries"][0]["version"].is_null(), + "shadow ctx versions should not be probed" + ); + assert!( + !marker.exists(), + "PATH shadow ctx should not have been executed" + ); + } +} + +#[cfg(unix)] +#[test] +fn upgrade_recovers_stale_lock_for_dead_pid() { + let temp = tempdir(); + let release = fake_release(&temp, "9.9.9"); + let mut child = std::process::Command::new("sh") + .arg("-c") + .arg("exit 0") + .spawn() + .unwrap(); + let stale_pid = child.id(); + child.wait().unwrap(); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + fs::write( + temp.path().join("upgrade.lock"), + format!("{stale_pid} {}\n", now.saturating_sub(60)), + ) + .unwrap(); + + let dry_run = json_output(fake_release_env( + ctx(&temp).args(["upgrade", "--dry-run", "--json"]), + &release, + )); + + assert_eq!(dry_run["status"], "dry_run"); + assert!(!temp.path().join("upgrade.lock").exists()); +} + +#[cfg(unix)] +#[test] +fn upgrade_lock_still_rejects_active_pid() { + let temp = tempdir(); + let release = fake_release(&temp, "9.9.9"); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + fs::write( + temp.path().join("upgrade.lock"), + format!("{} {now}\n", std::process::id()), + ) + .unwrap(); + + let stderr = failure_stderr(fake_release_env( + ctx(&temp).args(["upgrade", "--dry-run"]), + &release, + )); + + assert!(stderr.contains("ctx upgrade lock is held"), "{stderr}"); + assert!(temp.path().join("upgrade.lock").exists()); } #[cfg(unix)] @@ -1469,11 +2826,56 @@ fn upgrade_verifies_signed_metadata_and_fails_closed() { stderr.contains("download release metadata signature"), "{stderr}" ); + + let default_signature_path = tempdir(); + let release = fake_release(&default_signature_path, "9.9.9"); + let check = json_output( + ctx(&default_signature_path) + .args(["upgrade", "check", "--json"]) + .env("CTX_UPGRADE_TARGET", &release.target) + .env("CTX_RELEASE_METADATA_URL", file_url(&release.metadata)) + .env( + "CTX_RELEASE_METADATA_PUBLIC_KEY_PEM", + TEST_RELEASE_PUBLIC_KEY_PEM, + ), + ); + assert_eq!(check["status"], "available"); } #[cfg(unix)] #[test] fn upgrade_rejects_unsafe_metadata_and_bad_artifacts() { + let duplicate_key = tempdir(); + let release = fake_release(&duplicate_key, "9.9.9"); + rewrite_fake_release_metadata(&release, |metadata| { + format!("{metadata}CTX_RELEASE_VERSION=8.8.8\n") + }); + let stderr = failure_stderr(fake_release_env( + ctx(&duplicate_key).args(["upgrade", "check"]), + &release, + )); + assert!( + stderr.contains("metadata contains duplicate key CTX_RELEASE_VERSION"), + "{stderr}" + ); + + let malformed_bool = tempdir(); + let release = fake_release(&malformed_bool, "9.9.9"); + rewrite_fake_release_metadata(&release, |metadata| { + metadata.replace( + "CTX_RELEASE_SELF_UPGRADE_ALLOWED=true\n", + "CTX_RELEASE_SELF_UPGRADE_ALLOWED=definitely\n", + ) + }); + let stderr = failure_stderr(fake_release_env( + ctx(&malformed_bool).args(["upgrade", "check"]), + &release, + )); + assert!( + stderr.contains("metadata CTX_RELEASE_SELF_UPGRADE_ALLOWED must be a boolean"), + "{stderr}" + ); + let missing_policy = tempdir(); let release = fake_release(&missing_policy, "9.9.9"); rewrite_fake_release_metadata(&release, |metadata| { @@ -1620,20 +3022,37 @@ fn provider_session_lookup_requires_explicit_provider_flags_in_help() { fn analytics_sends_coarse_cli_metadata_when_enabled() { let temp = tempdir(); let events_path = temp.path().join("analytics.jsonl"); + let home = temp.path().join("home"); + let state = temp.path().join("state"); + let data_root = temp.path().join("data"); + fs::create_dir_all(&home).unwrap(); ctx(&temp) .arg("status") + .env("CTX_DATA_ROOT", &data_root) + .env("HOME", &home) + .env("XDG_STATE_HOME", &state) + .env("LOCALAPPDATA", &state) .env_remove("CTX_ANALYTICS_OFF") .env("CTX_ANALYTICS_ENDPOINT", file_url(&events_path)) .assert() .success(); - let body = fs::read_to_string(&events_path).unwrap(); - let event: Value = serde_json::from_str(body.lines().next().unwrap()).unwrap(); + let event = read_analytics_events(&events_path).remove(0); assert_eq!(event["broker_runtime"], "cli"); + assert!(uuid::Uuid::parse_str(event["broker_install_id"].as_str().unwrap()).is_ok()); + assert!(uuid::Uuid::parse_str(event["broker_device_id"].as_str().unwrap()).is_ok()); assert_eq!(event["events"][0]["event_name"], "cli_invocation"); assert_eq!(event["events"][0]["origin_runtime"], "cli"); assert_eq!(event["events"][0]["surface"], "cli"); + assert_eq!( + event["events"][0]["origin_install_id"], + event["broker_install_id"] + ); + assert_eq!( + event["events"][0]["origin_device_id"], + event["broker_device_id"] + ); assert_eq!(event["events"][0]["properties"]["action"], "status"); assert_eq!( event["events"][0]["properties"]["analytics_client"], @@ -1648,6 +3067,16 @@ fn analytics_sends_coarse_cli_metadata_when_enabled() { event["events"][0]["properties"]["cataloged_sessions_bucket"], "0" ); + assert_eq!( + event["events"][0]["properties"]["indexed_sessions_bucket"], + "0" + ); + assert_eq!( + event["events"][0]["properties"]["indexed_events_bucket"], + "0" + ); + assert_eq!(event["events"][0]["properties"]["db_size_bucket"], "0"); + assert_analytics_properties_are_allowlisted(analytics_event_properties(&event)); for forbidden in [ "command", "query", @@ -1669,9 +3098,395 @@ fn analytics_sends_coarse_cli_metadata_when_enabled() { } } +#[test] +fn analytics_device_id_persists_across_data_roots() { + let temp = tempdir(); + let home = temp.path().join("home"); + let state = temp.path().join("state"); + let data_root_a = temp.path().join("data-a"); + let data_root_b = temp.path().join("data-b"); + let events_path = temp.path().join("analytics.jsonl"); + fs::create_dir_all(&home).unwrap(); + + for data_root in [&data_root_a, &data_root_b] { + ctx(&temp) + .arg("status") + .env("CTX_DATA_ROOT", data_root) + .env("HOME", &home) + .env("XDG_STATE_HOME", &state) + .env("LOCALAPPDATA", &state) + .env_remove("CTX_ANALYTICS_OFF") + .env("CTX_ANALYTICS_ENDPOINT", file_url(&events_path)) + .assert() + .success(); + } + + let events = read_analytics_events(&events_path); + assert_eq!(events.len(), 2); + let install_a = events[0]["broker_install_id"].as_str().unwrap(); + let install_b = events[1]["broker_install_id"].as_str().unwrap(); + let device_a = events[0]["broker_device_id"].as_str().unwrap(); + let device_b = events[1]["broker_device_id"].as_str().unwrap(); + assert_ne!(install_a, install_b); + assert_eq!(device_a, device_b); + assert!(uuid::Uuid::parse_str(install_a).is_ok()); + assert!(uuid::Uuid::parse_str(install_b).is_ok()); + assert!(uuid::Uuid::parse_str(device_a).is_ok()); + + assert!(data_root_a.join("install.json").exists()); + assert!(data_root_b.join("install.json").exists()); + let device_path = expected_device_path(&home, &state); + assert!(device_path.exists()); + assert!(!device_path.starts_with(&data_root_a)); + assert!(!device_path.starts_with(&data_root_b)); + let device_json: Value = serde_json::from_slice(&fs::read(&device_path).unwrap()).unwrap(); + assert_eq!(device_json["schema_version"], 1); + assert_eq!(device_json["device_id"], device_a); + let device_body = serde_json::to_string(&device_json).unwrap(); + assert!(!device_body.contains(home.to_str().unwrap())); + assert!(!device_body.contains(data_root_a.to_str().unwrap())); + assert!(!device_body.contains(data_root_b.to_str().unwrap())); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + let mode = fs::metadata(device_path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + } +} + +#[test] +fn analytics_payloads_omit_sensitive_command_data() { + let temp = tempdir(); + let home = temp.path().join("alice-secret-home"); + let state = temp.path().join("state"); + let data_root = temp.path().join("ctx-data"); + let events_path = temp.path().join("analytics.jsonl"); + fs::create_dir_all(&home).unwrap(); + initialize_empty_store_with_env(&temp, &data_root, &home, &state); + let private_query = + "prompt text /home/alice/private/acme-secret repo@example.com host.internal 192.0.2.44"; + + ctx(&temp) + .args([ + "search", + private_query, + "--workspace", + "acme-secret-repo", + "--refresh", + "off", + ]) + .env("CTX_DATA_ROOT", &data_root) + .env("HOME", &home) + .env("XDG_STATE_HOME", &state) + .env("LOCALAPPDATA", &state) + .env_remove("CTX_ANALYTICS_OFF") + .env("CTX_ANALYTICS_ENDPOINT", file_url(&events_path)) + .assert() + .success(); + + ctx(&temp) + .args(["docs", "search", "private prompt text", "--limit", "1"]) + .env("CTX_DATA_ROOT", &data_root) + .env("HOME", &home) + .env("XDG_STATE_HOME", &state) + .env("LOCALAPPDATA", &state) + .env_remove("CTX_ANALYTICS_OFF") + .env("CTX_ANALYTICS_ENDPOINT", file_url(&events_path)) + .assert() + .success(); + + ctx(&temp) + .args(["upgrade", "status"]) + .env("CTX_DATA_ROOT", &data_root) + .env("HOME", &home) + .env("XDG_STATE_HOME", &state) + .env("LOCALAPPDATA", &state) + .env_remove("CTX_ANALYTICS_OFF") + .env("CTX_ANALYTICS_ENDPOINT", file_url(&events_path)) + .assert() + .success(); + + ctx(&temp) + .args(["show", "session", "not-a-uuid-secret"]) + .env("CTX_DATA_ROOT", &data_root) + .env("HOME", &home) + .env("XDG_STATE_HOME", &state) + .env("LOCALAPPDATA", &state) + .env_remove("CTX_ANALYTICS_OFF") + .env("CTX_ANALYTICS_ENDPOINT", file_url(&events_path)) + .assert() + .failure(); + + let events = read_analytics_events(&events_path); + assert_eq!(events.len(), 4); + let actions = events + .iter() + .map(|event| { + event["events"][0]["properties"]["action"] + .as_str() + .unwrap() + .to_owned() + }) + .collect::>(); + assert_eq!(actions, ["search", "docs", "upgrade", "show"]); + + let search_properties = analytics_event_properties(&events[0]); + assert_eq!(search_properties["query_length_bucket"], "21-100"); + assert_eq!(search_properties["query_term_count_bucket"], "6-20"); + assert_eq!(search_properties["search_refresh_mode"], "off"); + assert_eq!(search_properties["search_refresh_status"], "skipped"); + assert_eq!(search_properties["zero_result"], true); + assert!(search_properties.get("query_duration_bucket").is_some()); + assert!(search_properties.get("render_duration_bucket").is_some()); + assert_eq!(events[3]["events"][0]["success"], false); + assert_eq!( + events[3]["events"][0]["properties"]["failure_kind"], + "command_error" + ); + + for event in &events { + assert_analytics_properties_are_allowlisted(analytics_event_properties(event)); + assert_no_json_string_contains( + event, + &[ + private_query, + "private prompt text", + "not-a-uuid-secret", + "acme-secret-repo", + "/home/alice/private", + "repo@example.com", + "host.internal", + "192.0.2.44", + home.to_str().unwrap(), + ], + ); + let properties = analytics_event_properties(event); + for forbidden_key in [ + "install_id", + "origin_install_id", + "broker_install_id", + "device_id", + "origin_device_id", + "broker_device_id", + "hostname", + "username", + "repo_name", + "file_path", + "prompt", + "transcript", + ] { + assert!( + properties.get(forbidden_key).is_none(), + "analytics leaked forbidden property {forbidden_key}: {event:#}" + ); + } + } +} + +#[test] +fn hosted_install_marker_enriches_analytics_event_without_properties_leak() { + let temp = tempdir(); + let data_root = temp.path().join("ctx-data"); + let home = temp.path().join("home"); + let state = temp.path().join("state"); + let events_path = temp.path().join("analytics.jsonl"); + let binary = copied_ctx_binary(&temp); + let install_attempt_id = "attempt_01JZCTXHOSTED"; + let marker_secret = "marker-secret-must-not-leak"; + fs::write( + hosted_install_marker_path(&binary), + serde_json::to_vec_pretty(&json!({ + "schema_version": 1, + "install_attempt_id": install_attempt_id, + "installer_private_note": marker_secret, + })) + .unwrap(), + ) + .unwrap(); + + ctx_from_binary(&temp, &binary) + .arg("status") + .env("CTX_DATA_ROOT", &data_root) + .env("HOME", &home) + .env("XDG_STATE_HOME", &state) + .env("LOCALAPPDATA", &state) + .env_remove("CTX_ANALYTICS_OFF") + .env("CTX_ANALYTICS_ENDPOINT", file_url(&events_path)) + .env("CTX_UPGRADE_OFF", "1") + .assert() + .success(); + + let events = read_analytics_events(&events_path); + assert_eq!(events.len(), 1); + let cli_event = analytics_cli_event(&events[0]); + assert_eq!(cli_event["install_attempt_id"], install_attempt_id); + let properties = analytics_event_properties(&events[0]); + assert_eq!(properties["install_manager"], "ctx-hosted-installer"); + assert!( + properties.get("install_attempt_id").is_none(), + "raw marker id must stay out of analytics properties: {properties:#?}" + ); + assert_no_json_string_contains( + &Value::Object(properties.clone()), + &[install_attempt_id, marker_secret], + ); +} + +#[test] +fn malformed_hosted_install_marker_is_ignored() { + let temp = tempdir(); + let data_root = temp.path().join("ctx-data"); + let home = temp.path().join("home"); + let state = temp.path().join("state"); + let events_path = temp.path().join("analytics.jsonl"); + let binary = copied_ctx_binary(&temp); + fs::write( + hosted_install_marker_path(&binary), + b"{not-json marker-secret-must-not-leak", + ) + .unwrap(); + + ctx_from_binary(&temp, &binary) + .arg("status") + .env("CTX_DATA_ROOT", &data_root) + .env("HOME", &home) + .env("XDG_STATE_HOME", &state) + .env("LOCALAPPDATA", &state) + .env_remove("CTX_ANALYTICS_OFF") + .env("CTX_ANALYTICS_ENDPOINT", file_url(&events_path)) + .env("CTX_UPGRADE_OFF", "1") + .assert() + .success(); + + let events = read_analytics_events(&events_path); + assert_eq!(events.len(), 1); + let cli_event = analytics_cli_event(&events[0]); + assert!(cli_event.get("install_attempt_id").is_none()); + let properties = analytics_event_properties(&events[0]); + assert!(properties.get("install_manager").is_none()); + assert_no_json_string_contains( + &Value::Object(properties.clone()), + &["marker-secret-must-not-leak"], + ); +} + +#[test] +fn setup_analytics_emits_start_and_completion_events() { + let temp = tempdir(); + let data_root = temp.path().join("ctx-data"); + let home = temp.path().join("home"); + let state = temp.path().join("state"); + let events_path = temp.path().join("analytics.jsonl"); + fs::create_dir_all(home.join(".codex").join("sessions")).unwrap(); + + ctx(&temp) + .args(["setup", "--catalog-only", "--progress", "none"]) + .env("CTX_DATA_ROOT", &data_root) + .env("HOME", &home) + .env("XDG_STATE_HOME", &state) + .env("LOCALAPPDATA", &state) + .env_remove("CTX_ANALYTICS_OFF") + .env("CTX_ANALYTICS_ENDPOINT", file_url(&events_path)) + .env("CTX_UPGRADE_OFF", "1") + .assert() + .success(); + + let events = read_analytics_events(&events_path); + assert_eq!(events.len(), 2); + let actions = events + .iter() + .map(|event| { + analytics_event_properties(event)["action"] + .as_str() + .unwrap() + .to_owned() + }) + .collect::>(); + assert_eq!(actions, ["setup_started", "setup"]); + for event in &events { + assert_eq!(analytics_cli_event(event)["event_name"], "cli_invocation"); + assert_eq!(analytics_cli_event(event)["status"], "ok"); + assert_eq!(analytics_cli_event(event)["success"], true); + assert_analytics_properties_are_allowlisted(analytics_event_properties(event)); + } +} + +#[test] +fn setup_analytics_opt_out_suppresses_start_completion_and_identities() { + let temp = tempdir(); + let data_root = temp.path().join("ctx-data"); + let home = temp.path().join("home"); + let state = temp.path().join("state"); + let events_path = temp.path().join("analytics.jsonl"); + fs::create_dir_all(home.join(".codex").join("sessions")).unwrap(); + + ctx(&temp) + .args(["setup", "--catalog-only", "--progress", "none"]) + .env("CTX_DATA_ROOT", &data_root) + .env("HOME", &home) + .env("XDG_STATE_HOME", &state) + .env("LOCALAPPDATA", &state) + .env("CTX_ANALYTICS_ENDPOINT", file_url(&events_path)) + .env("CTX_UPGRADE_OFF", "1") + .assert() + .success(); + + assert!( + !events_path.exists(), + "setup analytics opt-out should suppress start and completion events" + ); + assert!( + !data_root.join("install.json").exists(), + "setup analytics opt-out should not create an install identity" + ); + assert!( + !expected_device_path(&home, &state).exists(), + "setup analytics opt-out should not create a device identity" + ); +} + +#[test] +fn setup_analytics_dry_run_suppresses_start_completion_and_identities() { + let temp = tempdir(); + let data_root = temp.path().join("ctx-data"); + let home = temp.path().join("home"); + let state = temp.path().join("state"); + let events_path = temp.path().join("analytics.jsonl"); + fs::create_dir_all(home.join(".codex").join("sessions")).unwrap(); + + ctx(&temp) + .args(["setup", "--catalog-only", "--progress", "none"]) + .env("CTX_DATA_ROOT", &data_root) + .env("HOME", &home) + .env("XDG_STATE_HOME", &state) + .env("LOCALAPPDATA", &state) + .env_remove("CTX_ANALYTICS_OFF") + .env("CTX_ANALYTICS_DRY_RUN", "1") + .env("CTX_ANALYTICS_ENDPOINT", file_url(&events_path)) + .env("CTX_UPGRADE_OFF", "1") + .assert() + .success(); + + assert!( + !events_path.exists(), + "setup analytics dry run should suppress start and completion events" + ); + assert!( + !data_root.join("install.json").exists(), + "setup analytics dry run should not create an install identity" + ); + assert!( + !expected_device_path(&home, &state).exists(), + "setup analytics dry run should not create a device identity" + ); +} + #[test] fn analytics_config_opt_out_suppresses_delivery() { let temp = tempdir(); + let state = temp.path().join("state"); fs::write( temp.path().join("config.toml"), "[analytics]\nenabled = false\n", @@ -1681,6 +3496,8 @@ fn analytics_config_opt_out_suppresses_delivery() { ctx(&temp) .arg("status") + .env("XDG_STATE_HOME", &state) + .env("LOCALAPPDATA", &state) .env_remove("CTX_ANALYTICS_OFF") .env("CTX_ANALYTICS_ENDPOINT", file_url(&events_path)) .assert() @@ -1690,6 +3507,185 @@ fn analytics_config_opt_out_suppresses_delivery() { !events_path.exists(), "analytics endpoint should not be touched" ); + assert!( + !temp.path().join("install.json").exists(), + "disabled analytics should not create an install identity" + ); + assert!( + !expected_device_path(temp.path(), &state).exists(), + "disabled analytics should not create a device identity" + ); +} + +#[test] +fn analytics_env_opt_out_wins_over_enable_flag() { + let temp = tempdir(); + let state = temp.path().join("state"); + let events_path = temp.path().join("analytics.jsonl"); + + ctx(&temp) + .arg("status") + .env("XDG_STATE_HOME", &state) + .env("LOCALAPPDATA", &state) + .env("CTX_ANALYTICS_OFF", "1") + .env("CTX_ANALYTICS_ENABLED", "true") + .env("CTX_ANALYTICS_ENDPOINT", file_url(&events_path)) + .assert() + .success(); + + assert!( + !events_path.exists(), + "CTX_ANALYTICS_OFF should be a hard process opt-out" + ); + assert!( + !expected_device_path(temp.path(), &state).exists(), + "hard opt-out should not create a device identity" + ); +} + +#[test] +fn analytics_refuses_device_identity_under_data_root() { + let temp = tempdir(); + let data_root = temp.path().join("ctx-data"); + let state = data_root.join("state"); + let events_path = temp.path().join("analytics.jsonl"); + + ctx(&temp) + .arg("status") + .env("CTX_DATA_ROOT", &data_root) + .env("XDG_STATE_HOME", &state) + .env("LOCALAPPDATA", &state) + .env_remove("CTX_ANALYTICS_OFF") + .env("CTX_ANALYTICS_ENDPOINT", file_url(&events_path)) + .assert() + .success(); + + assert!( + !events_path.exists(), + "device identity under data root should fail closed before delivery" + ); + assert!( + !state.join("ctx").join("device.json").exists(), + "device identity must not be created under CTX_DATA_ROOT" + ); +} + +fn expected_device_path(_home: &Path, state: &Path) -> PathBuf { + #[cfg(target_os = "windows")] + { + state.join("ctx").join("device.json") + } + #[cfg(target_os = "macos")] + { + _home + .join("Library") + .join("Application Support") + .join("ctx") + .join("device.json") + } + #[cfg(all(not(target_os = "windows"), not(target_os = "macos")))] + { + state.join("ctx").join("device.json") + } +} + +fn assert_no_json_string_contains(value: &Value, forbidden: &[&str]) { + match value { + Value::String(text) => { + for needle in forbidden { + assert!( + !text.contains(needle), + "analytics leaked forbidden string {needle:?} in {text:?}" + ); + } + } + Value::Array(values) => { + for value in values { + assert_no_json_string_contains(value, forbidden); + } + } + Value::Object(values) => { + for value in values.values() { + assert_no_json_string_contains(value, forbidden); + } + } + Value::Null | Value::Bool(_) | Value::Number(_) => {} + } +} + +fn assert_analytics_properties_are_allowlisted(properties: &serde_json::Map) { + let allowed = [ + "action", + "all_sources", + "analytics_client", + "available_sources_bucket", + "background", + "catalog_only", + "catalog_source_bytes_bucket", + "cataloged_sessions_bucket", + "citation_count_bucket", + "db_size_bucket", + "dry_run", + "edges_imported_bucket", + "event_results", + "failed_bucket", + "failed_sources_bucket", + "failure_kind", + "finding_count_bucket", + "has_event_type_filter", + "has_file_filter", + "has_provider_filter", + "has_query", + "has_session_filter", + "has_since_filter", + "has_workspace_filter", + "include_current_session", + "include_subagents", + "indexed_events_bucket", + "indexed_items_bucket", + "indexed_sessions_bucket", + "indexed_sources_bucket", + "install_manager", + "initialized", + "json_output", + "limit_bucket", + "native_sources_bucket", + "output_format", + "pending_sessions_bucket", + "primary_only", + "progress_mode", + "provider_filter", + "provider_lookup", + "providers_detected_bucket", + "query_duration_bucket", + "query_length_bucket", + "query_term_count_bucket", + "refresh_duration_bucket", + "render_duration_bucket", + "result_count_bucket", + "resume", + "search_refresh_mode", + "search_refresh_source_count_bucket", + "search_refresh_status", + "sessions_imported_bucket", + "skipped_bucket", + "source_files_bucket", + "source_mode", + "target_kind", + "transcript_mode", + "window_bucket", + "writes_out_file", + "zero_result", + ] + .into_iter() + .collect::>(); + + for key in properties.keys() { + assert!( + allowed.contains(key.as_str()), + "unexpected analytics property {key}: {properties:#?}" + ); + } } #[test] @@ -1837,6 +3833,28 @@ fn fresh_home_search_mvp_flow() { .iter() .all(|result| result["ctx_session_id"] == ctx_session_id)); + let session_prefix = &ctx_session_id[..8]; + let prefixed_session_events = json_output(ctx(&temp).args([ + "search", + "onboarding", + "--provider", + "codex", + "--session", + session_prefix, + "--json", + ])); + assert_event_search_provider_oracle( + &prefixed_session_events, + "codex", + "onboarding", + 1, + "message", + ); + assert_eq!( + prefixed_session_events["filters"]["session"], + ctx_session_id + ); + let human_search = ctx(&temp) .args(["search", "onboarding"]) .assert() @@ -1912,6 +3930,41 @@ fn fresh_home_search_mvp_flow() { && event["ctx_session_id"].is_string() && event["preview"].is_string())); + let show_event_prefix = json_output(ctx(&temp).args([ + "show", + "event", + &ctx_event_id[..8], + "--window", + "1", + "--format", + "json", + ])); + assert_eq!(show_event_prefix["event"]["ctx_event_id"], ctx_event_id); + + let oversized_after = failure_stderr(ctx(&temp).args([ + "show", + "event", + &ctx_event_id, + "--after", + "18446744073709551615", + ])); + assert!( + oversized_after.contains("event window must be between 0 and 50"), + "{oversized_after}" + ); + + let oversized_window = failure_stderr(ctx(&temp).args([ + "show", + "event", + &ctx_event_id, + "--window", + "18446744073709551615", + ])); + assert!( + oversized_window.contains("event window must be between 0 and 50"), + "{oversized_window}" + ); + let show_session = json_output(ctx(&temp).args(["show", "session", &ctx_session_id, "--format", "json"])); assert_eq!(show_session["schema_version"], 1); @@ -1920,6 +3973,10 @@ fn fresh_home_search_mvp_flow() { assert_eq!(show_session["session"]["item_id"], ctx_session_id); assert_eq!(show_session["mode"], "lite"); + let show_session_prefix = + json_output(ctx(&temp).args(["show", "session", &ctx_session_id[..8], "--format", "json"])); + assert_eq!(show_session_prefix["session"]["item_id"], ctx_session_id); + let show_session_full = json_output(ctx(&temp).args([ "show", "session", @@ -1995,6 +4052,42 @@ fn fresh_home_search_mvp_flow() { let doctor = json_output(ctx(&temp).args(["doctor", "--json"])); assert_eq!(doctor["schema_version"], 1); assert_eq!(doctor["ok"], true); + assert_eq!(doctor["progress"], "auto"); + + let doctor_progress = ctx(&temp) + .args(["doctor", "--json", "--progress", "json"]) + .assert() + .success() + .get_output() + .stderr + .clone(); + let doctor_progress = String::from_utf8(doctor_progress).unwrap(); + assert!(doctor_progress.contains(r#""operation":"doctor""#)); + assert!(doctor_progress.contains(r#""phase":"checking""#)); +} + +#[test] +fn doctor_reports_missing_store_without_creating_it() { + let temp = tempdir(); + + let doctor = json_output(ctx(&temp).args(["doctor", "--json"])); + + assert_eq!(doctor["schema_version"], 1); + assert_eq!(doctor["ok"], false); + assert!(doctor["findings"] + .as_array() + .unwrap() + .iter() + .any(|finding| { + finding + .as_str() + .unwrap() + .contains("ctx store is not initialized") + })); + assert!( + !temp.path().join("work.sqlite").exists(), + "doctor should not create the ctx store" + ); } #[test] @@ -2076,6 +4169,65 @@ fn mcp_status_and_tools_list_are_read_only_without_initialized_store() { ); } +#[test] +fn mcp_rejects_oversized_input_line_and_continues() { + let temp = tempdir(); + let initialize = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-11-25", + "capabilities": {}, + "clientInfo": { "name": "ctx-test", "version": "0" } + } + }); + let mut stdin = "x".repeat(1024 * 1024 + 1); + stdin.push('\n'); + stdin.push_str(&serde_json::to_string(&initialize).unwrap()); + stdin.push('\n'); + + let responses = mcp_raw_roundtrip(&temp, stdin); + assert_eq!(responses.len(), 2); + assert_eq!(responses[0]["error"]["code"], -32700); + assert!( + responses[0]["error"]["data"]["error"] + .as_str() + .unwrap() + .contains("exceeds max line bytes"), + "{:#}", + responses[0] + ); + assert_eq!(responses[1]["result"]["serverInfo"]["name"], "ctx"); +} + +#[test] +fn mcp_rejects_invalid_utf8_input_line_and_continues() { + let temp = tempdir(); + let initialize = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-11-25", + "capabilities": {}, + "clientInfo": { "name": "ctx-test", "version": "0" } + } + }); + let mut stdin = vec![0xff, b'\n']; + stdin.extend_from_slice(serde_json::to_string(&initialize).unwrap().as_bytes()); + stdin.push(b'\n'); + + let responses = mcp_raw_roundtrip_bytes(&temp, stdin); + assert_eq!(responses.len(), 2); + assert_eq!(responses[0]["error"]["code"], -32700); + assert_eq!( + responses[0]["error"]["data"]["error"], + "MCP message is not valid UTF-8" + ); + assert_eq!(responses[1]["result"]["serverInfo"]["name"], "ctx"); +} + #[test] fn mcp_sql_tool_returns_structured_json_and_rejects_writes() { let temp = tempdir(); @@ -2120,6 +4272,23 @@ fn mcp_sql_tool_returns_structured_json_and_rejects_writes() { } } }), + json!({ + "jsonrpc": "2.0", + "id": "budget", + "method": "tools/call", + "params": { + "name": "sql", + "arguments": { + "sql": format!( + "SELECT {}", + (0..256).map(|index| format!("1 AS c{index}")).collect::>().join(", ") + ), + "max_rows": 10000, + "max_columns": 256, + "max_value_bytes": 32 + } + } + }), ], ); @@ -2136,6 +4305,89 @@ fn mcp_sql_tool_returns_structured_json_and_rejects_writes() { .as_str() .unwrap() .contains("SQL query must be read-only")); + + let budget = &responses[3]["result"]; + assert_eq!(budget["isError"], true); + assert!(budget["structuredContent"]["error"] + .as_str() + .unwrap() + .contains("SQL result preview budget")); +} + +#[test] +fn mcp_show_session_caps_transcript_events() { + let temp = tempdir(); + ctx(&temp) + .args(["setup", "--catalog-only", "--progress", "none"]) + .assert() + .success(); + + let session_id = "018f45d0-0000-7000-8000-000000010001"; + let conn = Connection::open(temp.path().join("work.sqlite")).unwrap(); + conn.execute( + r#" + INSERT INTO sessions + ( + id, provider, external_session_id, agent_type, is_primary, status, fidelity, + started_at_ms, created_at_ms, updated_at_ms + ) + VALUES (?1, 'codex', 'mcp-large-session', 'primary', 1, 'imported', 'imported', 1, 1, 1) + "#, + [session_id], + ) + .unwrap(); + for index in 0..201 { + let event_id = format!("018f45d0-0000-7000-8000-{index:012x}"); + conn.execute( + r#" + INSERT INTO events + (id, seq, session_id, event_type, role, occurred_at_ms, payload_json) + VALUES (?1, ?2, ?3, 'message', 'assistant', ?4, ?5) + "#, + params![ + event_id, + index, + session_id, + index + 1, + format!(r#"{{"text":"mcp transcript event {index}"}}"#) + ], + ) + .unwrap(); + } + drop(conn); + + let responses = mcp_roundtrip( + &temp, + &[ + json!({ + "jsonrpc": "2.0", + "id": "init", + "method": "initialize", + "params": { + "protocolVersion": "2025-11-25", + "capabilities": {}, + "clientInfo": { "name": "ctx-test", "version": "0" } + } + }), + json!({ + "jsonrpc": "2.0", + "id": "show", + "method": "tools/call", + "params": { + "name": "show_session", + "arguments": { + "ctx_session_id": session_id, + "mode": "log" + } + } + }), + ], + ); + + let transcript = &responses[1]["result"]["structuredContent"]; + assert_eq!(transcript["truncated"]["events"], true); + assert_eq!(transcript["truncated"]["max_events"], 200); + assert_eq!(transcript["events"].as_array().unwrap().len(), 200); } #[test] @@ -2251,6 +4503,122 @@ fn mcp_search_and_show_tools_return_structured_json_without_refresh() { assert!(!event["events"].as_array().unwrap().is_empty()); } +#[test] +fn mcp_search_requires_query_term_or_file_without_opening_store() { + let temp = tempdir(); + let responses = mcp_roundtrip( + &temp, + &[ + json!({ + "jsonrpc": "2.0", + "id": "init", + "method": "initialize", + "params": { + "protocolVersion": "2025-11-25", + "capabilities": {}, + "clientInfo": { "name": "ctx-test", "version": "0" } + } + }), + json!({ + "jsonrpc": "2.0", + "id": "search", + "method": "tools/call", + "params": { + "name": "search", + "arguments": { + "provider": "codex", + "limit": 5 + } + } + }), + ], + ); + + let result = &responses[1]["result"]; + assert_eq!(result["isError"], true); + assert!(result["structuredContent"]["error"] + .as_str() + .unwrap() + .contains("search needs a query or file")); + assert!( + !temp.path().join("work.sqlite").exists(), + "invalid MCP search should fail before opening the ctx store" + ); +} + +#[test] +fn mcp_sources_and_search_support_history_source_plugins() { + let temp = tempdir(); + let plugin = write_history_source_plugin(&temp, "hermes", false, None); + json_output( + ctx(&temp) + .env("CTX_HISTORY_PLUGIN_PATH", &plugin.manifest_dir) + .args([ + "import", + "--history-source", + "hermes/default", + "--json", + "--progress", + "none", + ]), + ); + + let responses = mcp_roundtrip_with_env( + &temp, + &[ + json!({ + "jsonrpc": "2.0", + "id": "init", + "method": "initialize", + "params": { + "protocolVersion": "2025-11-25", + "capabilities": {}, + "clientInfo": { "name": "ctx-test", "version": "0" } + } + }), + json!({ + "jsonrpc": "2.0", + "id": "sources", + "method": "tools/call", + "params": { + "name": "sources", + "arguments": {} + } + }), + json!({ + "jsonrpc": "2.0", + "id": "search", + "method": "tools/call", + "params": { + "name": "search", + "arguments": { + "query": "hermes plugin initial marker", + "provider": "custom", + "history_source": "hermes/default", + "limit": 5 + } + } + }), + ], + &[( + "CTX_HISTORY_PLUGIN_PATH", + plugin.manifest_dir.to_str().unwrap(), + )], + ); + + let sources = responses[1]["result"]["structuredContent"]["sources"] + .as_array() + .unwrap(); + assert!(sources + .iter() + .any(|source| source["history_source"] == "hermes/default")); + + let search = &responses[2]["result"]["structuredContent"]; + assert_eq!(search["filters"]["provider"], "custom"); + assert_eq!(search["filters"]["history_source"], "hermes/default"); + assert_eq!(search["results"][0]["history_source"], "hermes/default"); +} + #[test] fn mcp_search_excludes_active_codex_session_by_default_when_available() { let temp = tempdir(); @@ -2295,14 +4663,52 @@ fn mcp_search_excludes_active_codex_session_by_default_when_available() { ], &[("CODEX_THREAD_ID", "codex-session-root")], ); - let excluded_search = &excluded[1]["result"]["structuredContent"]; - assert_eq!(excluded_search["results"].as_array().unwrap().len(), 0); - assert_eq!( - excluded_search["filters"]["exclude_provider_session"]["provider"], - "codex" - ); + let excluded_search = &excluded[1]["result"]["structuredContent"]; + assert_eq!(excluded_search["results"].as_array().unwrap().len(), 0); + assert_eq!( + excluded_search["filters"]["exclude_provider_session"]["provider"], + "codex" + ); + + let included = mcp_roundtrip_with_env( + &temp, + &[ + json!({ + "jsonrpc": "2.0", + "id": "init", + "method": "initialize", + "params": { + "protocolVersion": "2025-11-25", + "capabilities": {}, + "clientInfo": { "name": "ctx-test", "version": "0" } + } + }), + json!({ + "jsonrpc": "2.0", + "id": "search", + "method": "tools/call", + "params": { + "name": "search", + "arguments": { + "query": "onboarding", + "provider": "codex", + "limit": 5, + "include_current_session": true + } + } + }), + ], + &[("CODEX_THREAD_ID", "codex-session-root")], + ); + let included_search = &included[1]["result"]["structuredContent"]; + assert_eq!(included_search["results"].as_array().unwrap().len(), 1); + assert!(included_search["filters"]["exclude_provider_session"].is_null()); +} - let included = mcp_roundtrip_with_env( +#[test] +fn mcp_rejects_unknown_tool_arguments() { + let temp = tempdir(); + let responses = mcp_roundtrip( &temp, &[ json!({ @@ -2323,169 +4729,571 @@ fn mcp_search_excludes_active_codex_session_by_default_when_available() { "name": "search", "arguments": { "query": "onboarding", - "provider": "codex", - "limit": 5, - "include_current_session": true + "refresh": "strict" } } }), ], - &[("CODEX_THREAD_ID", "codex-session-root")], ); - let included_search = &included[1]["result"]["structuredContent"]; - assert_eq!(included_search["results"].as_array().unwrap().len(), 1); - assert!(included_search["filters"]["exclude_provider_session"].is_null()); + + let error = &responses[1]["error"]; + assert_eq!(error["code"], -32602); + assert!(error["data"]["error"] + .as_str() + .unwrap() + .contains("unknown argument refresh")); +} + +#[test] +fn codex_cli_resume_is_idempotent_rescan_and_filters_subagents() { + let temp = tempdir(); + let fixture = provider_history_fixture("codex-sessions"); + + let first = json_output(ctx(&temp).args([ + "import", + "--provider", + "codex", + "--path", + &fixture, + "--json", + ])); + assert_eq!(first["schema_version"], 1); + assert_eq!(first["resume"], false); + assert_eq!(first["resume_mode"], "normal_scan"); + assert_eq!(first["totals"]["imported_sessions"], 2); + assert_eq!(first["totals"]["imported_events"], 4); + assert_eq!(first["totals"]["imported_edges"], 1); + + let primary_default = json_output(ctx(&temp).args(["search", "subagent", "--json"])); + assert_eq!(primary_default["filters"]["include_subagents"], false); + let primary_default_text = serde_json::to_string(&primary_default).unwrap(); + assert!( + !primary_default_text.contains("codex-session-child"), + "{primary_default_text}" + ); + + let default_events = json_output(ctx(&temp).args(["search", "subagent", "--events", "--json"])); + assert_eq!(default_events["filters"]["include_subagents"], false); + let default_events_text = serde_json::to_string(&default_events).unwrap(); + assert!( + !default_events_text.contains("codex-session-child"), + "{default_events_text}" + ); + + let with_subagents = + json_output(ctx(&temp).args(["search", "subagent", "--include-subagents", "--json"])); + assert!(!with_subagents["results"].as_array().unwrap().is_empty()); + assert_eq!(with_subagents["filters"]["include_subagents"], true); + assert!(serde_json::to_string(&with_subagents) + .unwrap() + .contains("codex-session-child")); + + let child_session_lookup = json_output(ctx(&temp).args([ + "sql", + "SELECT ctx_session_id FROM ctx_sessions WHERE provider_session_id = 'codex-session-child'", + "--format", + "json", + ])); + let child_session_id = child_session_lookup["rows"][0][0].as_str().unwrap(); + let explicit_child_session = json_output(ctx(&temp).args([ + "search", + "subagent", + "--session", + child_session_id, + "--json", + ])); + assert_eq!( + explicit_child_session["filters"]["session"], + child_session_id + ); + assert!(serde_json::to_string(&explicit_child_session) + .unwrap() + .contains("codex-session-child")); + + let primary_only = + json_output(ctx(&temp).args(["search", "subagent", "--primary-only", "--json"])); + assert_eq!(primary_only["filters"]["include_subagents"], false); + assert!(primary_only["filters"]["primary_only"].is_null()); + assert!( + primary_only["results"].as_array().unwrap().len() + <= with_subagents["results"].as_array().unwrap().len() + ); + + let second = json_output(ctx(&temp).args([ + "import", + "--provider", + "codex", + "--path", + &fixture, + "--resume", + "--json", + ])); + assert_eq!(second["schema_version"], 1); + assert_eq!(second["resume"], true); + assert_eq!(second["resume_mode"], "idempotent_rescan"); + assert_eq!(second["totals"]["imported_sessions"], 0); + assert_eq!(second["totals"]["imported_events"], 0); + assert_eq!(second["totals"]["imported_edges"], 0); + assert!(second["totals"]["skipped"].as_u64().unwrap() > 0); + assert_eq!(second["sources"][0]["imported_sessions"], 0); + assert_eq!(second["sources"][0]["imported_events"], 0); +} + +#[test] +fn search_refreshes_discovered_codex_sessions_before_query() { + let temp = tempdir(); + let fixture = PathBuf::from(provider_history_fixture("codex-sessions")); + let discovered = temp.path().join(".codex").join("sessions"); + copy_dir_all(&fixture, &discovered); + + let search = + json_output(ctx(&temp).args(["search", "onboarding", "--provider", "codex", "--json"])); + assert_search_provider_oracle(&search, "codex", "onboarding", 1, "message"); + assert_eq!(search["freshness"]["mode"], "auto"); + assert_eq!(search["freshness"]["status"], "completed"); + assert_eq!(search["freshness"]["source_count"], 1); + assert_eq!(search["freshness"]["totals"]["imported_sessions"], 2); + + let status = json_output(ctx(&temp).args(["status", "--json"])); + assert_eq!(status["cataloged_sessions"], 2); + assert_eq!(status["indexed_catalog_sessions"], 2); + assert_eq!(status["pending_catalog_sessions"], 0); +} + +#[test] +fn search_refresh_off_serves_existing_index_without_importing() { + let temp = tempdir(); + let indexed_fixture = provider_history_fixture("codex-sessions"); + json_output(ctx(&temp).args([ + "import", + "--provider", + "codex", + "--path", + &indexed_fixture, + "--json", + ])); + let discovered_fixture = provider_history_fixture("codex-rich-sessions"); + let discovered = temp.path().join(".codex").join("sessions"); + copy_dir_all(&PathBuf::from(discovered_fixture), &discovered); + + let stale = json_output(ctx(&temp).args([ + "search", + "redacted sample app", + "--provider", + "codex", + "--refresh", + "off", + "--json", + ])); + assert_eq!(stale["freshness"]["mode"], "off"); + assert_eq!(stale["freshness"]["status"], "skipped"); + assert!(stale["results"].as_array().unwrap().is_empty()); + + let status = json_output(ctx(&temp).args(["status", "--json"])); + assert_eq!(status["cataloged_sessions"], 2); + assert_eq!(status["indexed_catalog_sessions"], 2); + + let fresh = + json_output(ctx(&temp).args(["search", "onboarding", "--provider", "codex", "--json"])); + assert_search_provider_oracle(&fresh, "codex", "onboarding", 1, "message"); +} + +#[test] +fn search_refresh_auto_runs_enabled_auto_history_source_plugins_incrementally() { + let temp = tempdir(); + let cursor_log = temp.path().join("cursor-log.txt"); + let plugin = write_history_source_plugin_with_refresh( + &temp, + "hermes", + true, + Some("auto"), + Some(&cursor_log), + ); + + let initial = json_output( + ctx(&temp) + .env("CTX_HISTORY_PLUGIN_PATH", &plugin.manifest_dir) + .args([ + "search", + "hermes plugin initial marker", + "--provider", + "custom", + "--json", + ]), + ); + assert_eq!(initial["freshness"]["mode"], "auto"); + assert_eq!(initial["freshness"]["status"], "completed"); + assert_eq!(initial["freshness"]["source_count"], 1); + assert_eq!(initial["freshness"]["totals"]["imported_sources"], 1); + assert_eq!(initial["freshness"]["totals"]["imported_sessions"], 1); + assert_eq!(initial["freshness"]["totals"]["imported_events"], 1); + assert!( + !initial["results"].as_array().unwrap().is_empty(), + "initial plugin refresh was not searchable before query: {initial:#}" + ); + assert!(plugin.run_marker.exists()); + + fs::remove_file(&plugin.run_marker).unwrap(); + let incremental = json_output( + ctx(&temp) + .env("CTX_HISTORY_PLUGIN_PATH", &plugin.manifest_dir) + .args([ + "search", + "hermes plugin incremental marker", + "--provider", + "custom", + "--json", + ]), + ); + assert_eq!(incremental["freshness"]["mode"], "auto"); + assert_eq!(incremental["freshness"]["status"], "completed"); + assert_eq!(incremental["freshness"]["source_count"], 1); + assert_eq!(incremental["freshness"]["totals"]["imported_sources"], 1); + assert_eq!(incremental["freshness"]["totals"]["imported_events"], 1); + assert!( + !incremental["results"].as_array().unwrap().is_empty(), + "incremental plugin refresh was not searchable before query: {incremental:#}" + ); + assert!(plugin.run_marker.exists()); + + let cursor_log = fs::read_to_string(cursor_log).unwrap(); + assert!(cursor_log.contains(r#""message_id":7"#), "{cursor_log}"); + assert!(cursor_log.contains("cursor_file="), "{cursor_log}"); +} + +#[test] +fn search_refresh_history_source_filter_runs_only_matching_auto_plugin() { + let temp = tempdir(); + let plugin_root = temp.path().join("history-plugins"); + let dorkos = write_history_source_plugin_at_with_refresh( + &plugin_root, + "dorkos", + true, + Some("auto"), + None, + ); + let hermes = write_history_source_plugin_at_with_refresh( + &plugin_root, + "hermes", + true, + Some("auto"), + None, + ); + + let search = json_output( + ctx(&temp) + .env("CTX_HISTORY_PLUGIN_PATH", &plugin_root) + .args([ + "search", + "dorkos plugin initial marker", + "--history-source", + "dorkos/default", + "--json", + ]), + ); + + assert_eq!(search["filters"]["provider"], "custom"); + assert_eq!(search["filters"]["history_source"], "dorkos/default"); + assert_eq!(search["freshness"]["status"], "completed"); + assert_eq!(search["freshness"]["source_count"], 1); + assert!(dorkos.run_marker.exists()); + assert!(!hermes.run_marker.exists()); + assert!( + !search["results"].as_array().unwrap().is_empty(), + "source-filtered refresh did not import matching plugin: {search:#}" + ); +} + +#[test] +fn search_refresh_auto_combines_native_sources_and_auto_history_source_plugins() { + let temp = tempdir(); + let fixture = PathBuf::from(provider_history_fixture("codex-sessions")); + copy_dir_all(&fixture, &temp.path().join(".codex").join("sessions")); + let plugin = + write_history_source_plugin_with_refresh(&temp, "hermes", true, Some("auto"), None); + + let search = json_output( + ctx(&temp) + .env("CTX_HISTORY_PLUGIN_PATH", &plugin.manifest_dir) + .args(["search", "hermes plugin initial marker", "--json"]), + ); + + assert_eq!(search["freshness"]["mode"], "auto"); + assert_eq!(search["freshness"]["status"], "completed"); + assert_eq!(search["freshness"]["source_count"], 2); + assert!( + search["freshness"]["totals"]["imported_sessions"] + .as_u64() + .unwrap() + >= 3 + ); + assert!( + !search["results"].as_array().unwrap().is_empty(), + "combined refresh did not make plugin history searchable: {search:#}" + ); + assert!(plugin.run_marker.exists()); +} + +#[test] +fn search_refresh_provider_filter_does_not_execute_history_source_plugins() { + let temp = tempdir(); + let fixture = PathBuf::from(provider_history_fixture("codex-sessions")); + copy_dir_all(&fixture, &temp.path().join(".codex").join("sessions")); + let plugin = + write_history_source_plugin_with_refresh(&temp, "hermes", true, Some("auto"), None); + + let search = json_output( + ctx(&temp) + .env("CTX_HISTORY_PLUGIN_PATH", &plugin.manifest_dir) + .args(["search", "onboarding", "--provider", "codex", "--json"]), + ); + + assert_eq!(search["freshness"]["mode"], "auto"); + assert_eq!(search["freshness"]["status"], "completed"); + assert_eq!(search["freshness"]["source_count"], 1); + assert_search_provider_oracle(&search, "codex", "onboarding", 1, "message"); + assert!(!plugin.run_marker.exists()); +} + +#[test] +fn search_refresh_off_does_not_execute_history_source_plugins() { + let temp = tempdir(); + json_output(ctx(&temp).args(["setup", "--json"])); + let plugin = + write_history_source_plugin_with_refresh(&temp, "hermes", true, Some("auto"), None); + + let search = json_output( + ctx(&temp) + .env("CTX_HISTORY_PLUGIN_PATH", &plugin.manifest_dir) + .args([ + "search", + "hermes plugin initial marker", + "--provider", + "custom", + "--refresh", + "off", + "--json", + ]), + ); + + assert_eq!(search["freshness"]["mode"], "off"); + assert_eq!(search["freshness"]["status"], "skipped"); + assert!(search["results"].as_array().unwrap().is_empty()); + assert!(!plugin.run_marker.exists()); +} + +#[test] +fn search_refresh_auto_skips_disabled_or_manual_history_source_plugins() { + let temp = tempdir(); + let plugin_root = temp.path().join("history-plugins"); + let manual = write_history_source_plugin_at_with_refresh( + &plugin_root, + "hermes", + true, + Some("manual"), + None, + ); + let disabled = write_history_source_plugin_at_with_refresh( + &plugin_root, + "dorkos", + false, + Some("auto"), + None, + ); + + let search = json_output( + ctx(&temp) + .env("CTX_HISTORY_PLUGIN_PATH", &plugin_root) + .args([ + "search", + "plugin initial marker", + "--provider", + "custom", + "--json", + ]), + ); + + assert_eq!(search["freshness"]["mode"], "auto"); + assert_eq!(search["freshness"]["status"], "no_sources"); + assert_eq!(search["freshness"]["source_count"], 0); + assert!(search["results"].as_array().unwrap().is_empty()); + assert!(!manual.run_marker.exists()); + assert!(!disabled.run_marker.exists()); } #[test] -fn mcp_rejects_unknown_tool_arguments() { +fn search_refresh_strict_fails_on_history_source_plugin_failure() { let temp = tempdir(); - let responses = mcp_roundtrip( + let script = r#"#!/usr/bin/env python3 +import sys +print("plugin exploded", file=sys.stderr) +sys.exit(23) +"#; + let plugin = write_raw_history_source_plugin_with_options( &temp, - &[ - json!({ - "jsonrpc": "2.0", - "id": "init", - "method": "initialize", - "params": { - "protocolVersion": "2025-11-25", - "capabilities": {}, - "clientInfo": { "name": "ctx-test", "version": "0" } - } - }), - json!({ - "jsonrpc": "2.0", - "id": "search", - "method": "tools/call", - "params": { - "name": "search", - "arguments": { - "query": "onboarding", - "refresh": "strict" - } - } - }), - ], + "badplugin", + script, + true, + Some("auto"), ); - let error = &responses[1]["error"]; - assert_eq!(error["code"], -32602); - assert!(error["data"]["error"] - .as_str() - .unwrap() - .contains("unknown argument refresh")); + let stderr = failure_stderr( + ctx(&temp) + .env("CTX_HISTORY_PLUGIN_PATH", &plugin.manifest_dir) + .args([ + "search", + "anything", + "--provider", + "custom", + "--refresh", + "strict", + "--json", + ]), + ); + + assert!(stderr.contains("search refresh failed"), "{stderr}"); + assert!( + stderr.contains("history source plugin badplugin/default failed"), + "{stderr}" + ); + assert!(stderr.contains("plugin exploded"), "{stderr}"); } #[test] -fn codex_cli_resume_is_idempotent_rescan_and_filters_subagents() { +fn search_refresh_auto_failure_without_prior_store_fails_instead_of_serving_empty_index() { let temp = tempdir(); - let fixture = provider_history_fixture("codex-sessions"); - - let first = json_output(ctx(&temp).args([ - "import", - "--provider", - "codex", - "--path", - &fixture, - "--json", - ])); - assert_eq!(first["schema_version"], 1); - assert_eq!(first["resume"], false); - assert_eq!(first["resume_mode"], "normal_scan"); - assert_eq!(first["totals"]["imported_sessions"], 2); - assert_eq!(first["totals"]["imported_events"], 4); - assert_eq!(first["totals"]["imported_edges"], 1); + let script = r#"#!/usr/bin/env python3 +import sys +print("plugin exploded", file=sys.stderr) +sys.exit(23) +"#; + let plugin = write_raw_history_source_plugin_with_options( + &temp, + "badplugin", + script, + true, + Some("auto"), + ); - let with_subagents = json_output(ctx(&temp).args(["search", "subagent", "--json"])); - assert!(!with_subagents["results"].as_array().unwrap().is_empty()); - assert_eq!(with_subagents["filters"]["include_subagents"], true); + let stderr = failure_stderr( + ctx(&temp) + .env("CTX_HISTORY_PLUGIN_PATH", &plugin.manifest_dir) + .args(["search", "anything", "--provider", "custom", "--json"]), + ); - let primary_only = - json_output(ctx(&temp).args(["search", "subagent", "--primary-only", "--json"])); - assert_eq!(primary_only["filters"]["include_subagents"], false); - assert_eq!(primary_only["filters"]["primary_only"], true); assert!( - primary_only["results"].as_array().unwrap().len() - <= with_subagents["results"].as_array().unwrap().len() + stderr.contains("search refresh failed and no existing ctx index is available"), + "{stderr}" + ); + assert!( + stderr.contains("history source plugin badplugin/default failed"), + "{stderr}" ); + assert!(stderr.contains("plugin exploded"), "{stderr}"); +} - let second = json_output(ctx(&temp).args([ +#[test] +fn search_refresh_auto_failure_serves_prior_index() { + let temp = tempdir(); + let fixture = provider_history_fixture("codex-sessions"); + let script = r#"#!/usr/bin/env python3 +import sys +print("plugin exploded", file=sys.stderr) +sys.exit(23) +"#; + let plugin = write_raw_history_source_plugin_with_options( + &temp, + "badplugin", + script, + true, + Some("auto"), + ); + json_output(ctx(&temp).args([ "import", "--provider", "codex", "--path", &fixture, - "--resume", "--json", ])); - assert_eq!(second["schema_version"], 1); - assert_eq!(second["resume"], true); - assert_eq!(second["resume_mode"], "idempotent_rescan"); - assert_eq!(second["totals"]["imported_sessions"], 0); - assert_eq!(second["totals"]["imported_events"], 0); - assert_eq!(second["totals"]["imported_edges"], 0); - assert!(second["totals"]["skipped"].as_u64().unwrap() > 0); - assert_eq!(second["sources"][0]["imported_sessions"], 0); - assert_eq!(second["sources"][0]["imported_events"], 0); -} - -#[test] -fn search_refreshes_discovered_codex_sessions_before_query() { - let temp = tempdir(); - let fixture = PathBuf::from(provider_history_fixture("codex-sessions")); - let discovered = temp.path().join(".codex").join("sessions"); - copy_dir_all(&fixture, &discovered); - let search = - json_output(ctx(&temp).args(["search", "onboarding", "--provider", "codex", "--json"])); - assert_search_provider_oracle(&search, "codex", "onboarding", 1, "message"); - assert_eq!(search["freshness"]["mode"], "auto"); - assert_eq!(search["freshness"]["status"], "completed"); - assert_eq!(search["freshness"]["source_count"], 1); - assert_eq!(search["freshness"]["totals"]["imported_sessions"], 2); + let search = json_output( + ctx(&temp) + .env("CTX_HISTORY_PLUGIN_PATH", &plugin.manifest_dir) + .args(["search", "onboarding", "--json"]), + ); - let status = json_output(ctx(&temp).args(["status", "--json"])); - assert_eq!(status["cataloged_sessions"], 2); - assert_eq!(status["indexed_catalog_sessions"], 2); - assert_eq!(status["pending_catalog_sessions"], 0); + assert_eq!(search["freshness"]["status"], "failed"); + assert!(search["freshness"]["error"] + .as_str() + .unwrap() + .contains("history source plugin badplugin/default failed")); + assert!(!search["results"].as_array().unwrap().is_empty()); } #[test] -fn search_refresh_off_serves_existing_index_without_importing() { +fn search_refresh_strict_times_out_when_plugin_helper_keeps_stdout_open() { let temp = tempdir(); - let fixture = PathBuf::from(provider_history_fixture("codex-sessions")); - let discovered = temp.path().join(".codex").join("sessions"); - copy_dir_all(&fixture, &discovered); - - let stale = json_output(ctx(&temp).args([ - "search", - "onboarding", - "--provider", - "codex", - "--refresh", - "off", - "--json", - ])); - assert_eq!(stale["freshness"]["mode"], "off"); - assert_eq!(stale["freshness"]["status"], "skipped"); - assert!(stale["results"].as_array().unwrap().is_empty()); - - let status = json_output(ctx(&temp).args(["status", "--json"])); - assert_eq!(status["cataloged_sessions"], 0); - assert_eq!(status["indexed_catalog_sessions"], 0); + let script = r#"#!/usr/bin/env python3 +import json +import os +import subprocess + +observed = "2026-07-01T12:00:00Z" +source_id = os.environ["CTX_HISTORY_SOURCE_ID"] +provider_key = os.environ["CTX_HISTORY_PROVIDER_KEY"] +source_format = os.environ["CTX_HISTORY_SOURCE_FORMAT"] +cursor_stream = os.environ["CTX_HISTORY_CURSOR_STREAM"] +records = [ + {"record_type": "manifest", "schema_version": "ctx-history-jsonl-v1"}, + {"record_type": "source", "source_id": source_id, "provider_key": provider_key, "source_format": source_format, "observed_at": observed, "cursor": {"after": {"stream": cursor_stream, "cursor": json.dumps({"seq": 1}), "observed_at": observed}}}, + {"record_type": "session", "source_id": source_id, "session_id": "hanging-session", "started_at": observed, "agent_type": "primary", "is_primary": True, "status": "completed"}, + {"record_type": "event", "source_id": source_id, "session_id": "hanging-session", "event_index": 0, "event_type": "message", "role": "assistant", "occurred_at": observed, "payload": {"text": "hanging plugin marker"}, "preview": "hanging plugin marker"}, +] +for record in records: + print(json.dumps(record, separators=(",", ":")), flush=True) +subprocess.Popen(["sh", "-c", "sleep 5"]) +"#; + let plugin = write_raw_history_source_plugin_with_options_and_timeout( + &temp, + "hanging", + script, + true, + Some("auto"), + 1, + ); - let fresh = - json_output(ctx(&temp).args(["search", "onboarding", "--provider", "codex", "--json"])); - assert_search_provider_oracle(&fresh, "codex", "onboarding", 1, "message"); + let started = Instant::now(); + let stderr = failure_stderr( + ctx(&temp) + .env("CTX_HISTORY_PLUGIN_PATH", &plugin.manifest_dir) + .args([ + "search", + "hanging plugin marker", + "--provider", + "custom", + "--refresh", + "strict", + "--json", + ]), + ); + assert!( + started.elapsed() < Duration::from_secs(3), + "plugin timeout did not bound pipe draining: {stderr}" + ); + assert!( + stderr.contains("history source plugin hanging/default timed out after 1s"), + "{stderr}" + ); } #[test] fn search_refresh_auto_imports_fresh_work_despite_large_existing_catalog() { let temp = tempdir(); let fixture = PathBuf::from(provider_history_fixture("codex-sessions")); + let _ = json_output(ctx(&temp).args(["setup", "--json"])); let discovered = temp.path().join(".codex").join("sessions"); copy_dir_all(&fixture, &discovered); - let _ = json_output(ctx(&temp).args(["search", "anything", "--refresh", "off", "--json"])); let mut conn = Connection::open(temp.path().join("work.sqlite")).unwrap(); let tx = conn.transaction().unwrap(); @@ -2597,6 +5405,54 @@ fn search_refresh_auto_tail_imports_appended_codex_session_event() { refreshed["freshness"]["totals"] ); assert_search_provider_oracle(&refreshed, "codex", appended_needle, 1, "message"); + + let second_append_needle = "tail-refresh-second-append-oracle"; + let mut file = fs::OpenOptions::new() + .append(true) + .open(&root_session) + .unwrap(); + writeln!( + file, + "{}", + json!({ + "timestamp": "2026-06-23T15:00:31.000Z", + "type": "response_item", + "payload": { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": second_append_needle}] + } + }) + ) + .unwrap(); + + let second_refreshed = json_output(ctx(&temp).args([ + "search", + second_append_needle, + "--provider", + "codex", + "--json", + ])); + assert_eq!(second_refreshed["freshness"]["status"], "completed"); + assert_eq!( + second_refreshed["freshness"]["totals"]["imported_events"], + 1 + ); + assert!( + second_refreshed["freshness"]["totals"]["skipped"] + .as_u64() + .unwrap() + < 20, + "second tail refresh unexpectedly reprocessed old events: {}", + second_refreshed["freshness"]["totals"] + ); + assert_search_provider_oracle( + &second_refreshed, + "codex", + second_append_needle, + 1, + "message", + ); } #[test] @@ -2609,6 +5465,9 @@ fn search_refresh_auto_imports_discovered_top_provider_sources() { ), ("pi", "pi", install_default_pi_fixture), ("cursor", "cursor", install_default_cursor_fixture), + ("openclaw", "openclaw", install_default_openclaw_fixture), + ("hermes", "hermes", install_default_hermes_fixture), + ("shelley", "shelley", install_default_shelley_fixture), ] { let temp = tempdir(); let query = format!("{stored_provider}-default-refresh-oracle"); @@ -2951,6 +5810,36 @@ fn native_provider_cli_flow_imports_new_supported_provider_paths() { "factory_ai_droid_sessions_jsonl", write_native_factory_droid_fixture, ), + ( + "openclaw", + "openclaw", + "openclaw_session_jsonl_tree", + write_native_openclaw_fixture, + ), + ( + "hermes", + "hermes", + "hermes_state_sqlite", + write_native_hermes_fixture, + ), + ( + "nanoclaw", + "nanoclaw", + "nanoclaw_project", + write_native_nanoclaw_fixture, + ), + ( + "astrbot", + "astrbot", + "astrbot_data_v4_sqlite", + write_native_astrbot_fixture, + ), + ( + "shelley", + "shelley", + "shelley_sqlite", + write_native_shelley_fixture, + ), ] { let temp = tempdir(); let query = format!("{stored_provider}-native-cli-oracle"); @@ -2974,13 +5863,126 @@ fn native_provider_cli_flow_imports_new_supported_provider_paths() { let search = json_output(ctx(&temp).args(["search", &query, "--provider", cli_provider, "--json"])); assert_search_provider_oracle(&search, stored_provider, &query, 1, "message"); + let result = &search["results"].as_array().unwrap()[0]; + let ctx_event_id = result["ctx_event_id"].as_str().unwrap(); + let ctx_session_id = result["ctx_session_id"].as_str().unwrap(); + + let show_event = + json_output(ctx(&temp).args(["show", "event", ctx_event_id, "--format", "json"])); + assert_eq!(show_event["event"]["provider"], stored_provider); + assert!(show_event["event"]["source"]["source_format"].is_string()); + assert!(show_event["event"]["source"]["path"].is_string()); + assert!(show_event["event"]["cursor"].is_string()); + + let locate_event = + json_output(ctx(&temp).args(["locate", "event", ctx_event_id, "--json"])); + assert_eq!(locate_event["provider"], stored_provider); + assert_eq!(locate_event["ctx_session_id"], ctx_session_id); + assert!(locate_event["source"]["source_format"].is_string()); + assert!(locate_event["source"]["path"].is_string()); + assert!(locate_event["cursor"].is_string()); let status = json_output(ctx(&temp).args(["status", "--json"])); assert!(status["indexed_items"].as_u64().unwrap() >= 2); assert!(status["indexed_sources"].as_u64().unwrap() >= 1); - let doctor = json_output(ctx(&temp).args(["doctor", "--json"])); - assert_eq!(doctor["ok"], true); + let doctor = json_output(ctx(&temp).args(["doctor", "--json"])); + assert_eq!(doctor["ok"], true); + + let second = json_output(ctx(&temp).args([ + "import", + "--provider", + cli_provider, + "--path", + &path, + "--json", + ])); + assert_eq!(second["totals"]["failed"], 0); + assert_eq!(second["totals"]["imported_events"], 0); + } +} + +#[test] +fn personal_agent_provider_imports_are_idempotent_and_incremental() { + for (cli_provider, stored_provider, fixture, append_event) in [ + ( + "openclaw", + "openclaw", + write_native_openclaw_fixture as fn(&TempDir, &str) -> String, + append_native_openclaw_event as fn(&str, &str), + ), + ( + "hermes", + "hermes", + write_native_hermes_fixture, + append_native_hermes_event, + ), + ( + "nanoclaw", + "nanoclaw", + write_native_nanoclaw_fixture, + append_native_nanoclaw_event, + ), + ( + "astrbot", + "astrbot", + write_native_astrbot_fixture, + append_native_astrbot_event, + ), + ( + "shelley", + "shelley", + write_native_shelley_fixture, + append_native_shelley_event, + ), + ] { + let temp = tempdir(); + let initial_query = format!("{stored_provider}-incremental-initial-oracle"); + let incremental_query = format!("{stored_provider}-incremental-next-oracle"); + let path = fixture(&temp, &initial_query); + + let first = json_output(ctx(&temp).args([ + "import", + "--provider", + cli_provider, + "--path", + &path, + "--json", + ])); + assert_eq!(first["totals"]["failed"], 0); + assert!(first["totals"]["imported_events"].as_u64().unwrap() >= 1); + + let second = json_output(ctx(&temp).args([ + "import", + "--provider", + cli_provider, + "--path", + &path, + "--json", + ])); + assert_eq!(second["totals"]["failed"], 0); + assert_eq!(second["totals"]["imported_events"], 0); + + append_event(&path, &incremental_query); + let third = json_output(ctx(&temp).args([ + "import", + "--provider", + cli_provider, + "--path", + &path, + "--json", + ])); + assert_eq!(third["totals"]["failed"], 0); + assert!(third["totals"]["imported_events"].as_u64().unwrap() >= 1); + + let search = json_output(ctx(&temp).args([ + "search", + &incremental_query, + "--provider", + cli_provider, + "--json", + ])); + assert_search_provider_oracle(&search, stored_provider, &incremental_query, 1, "message"); } } @@ -2989,36 +5991,73 @@ fn install_default_claude_fixture(temp: &TempDir, query: &str) { copy_dir_all(&source, &temp.path().join(".claude").join("projects")); } -fn install_default_pi_fixture(temp: &TempDir, query: &str) { - let root = temp.path().join(".pi"); - fs::create_dir_all(&root).unwrap(); +fn write_pi_session_jsonl(path: &Path, id: &str, query: &str) { fs::write( - root.join("sessions.jsonl"), + path, format!( "{}\n{}\n", json!({ "type": "session", "version": 3, - "id": "pi-default-refresh", + "id": id, "timestamp": "2026-06-24T12:00:00.000Z", "cwd": "/workspace" }), json!({ "type": "message", - "id": "pi-default-refresh-user", + "id": format!("{id}-user"), "timestamp": "2026-06-24T12:00:01.000Z", - "message": {"role": "user", "content": query} + "message": { + "role": "user", + "content": [{"type": "text", "text": query}] + } }) ), ) .unwrap(); } +fn install_default_pi_fixture(temp: &TempDir, query: &str) { + let root = temp.path().join(".pi/agent/sessions/--workspace--"); + fs::create_dir_all(&root).unwrap(); + write_pi_session_jsonl( + &root.join("2026-06-24T12-00-00-000Z_pi-default-refresh.jsonl"), + "pi-default-refresh", + query, + ); +} + fn install_default_cursor_fixture(temp: &TempDir, query: &str) { let source = PathBuf::from(write_native_cursor_fixture(temp, query)); copy_dir_all(&source, &temp.path().join(".cursor").join("projects")); } +fn install_default_openclaw_fixture(temp: &TempDir, query: &str) { + let source = PathBuf::from(write_native_openclaw_fixture(temp, query)); + copy_dir_all(&source, &temp.path().join(".openclaw")); +} + +fn install_default_hermes_fixture(temp: &TempDir, query: &str) { + let source = PathBuf::from(write_native_hermes_fixture(temp, query)); + let target = temp.path().join(".hermes"); + fs::create_dir_all(&target).unwrap(); + fs::copy(source, target.join("state.db")).unwrap(); +} + +fn install_default_astrbot_fixture(temp: &TempDir, query: &str) { + let source = PathBuf::from(write_native_astrbot_fixture(temp, query)); + let target = temp.path().join(".astrbot/data"); + fs::create_dir_all(&target).unwrap(); + fs::copy(source, target.join("data_v4.db")).unwrap(); +} + +fn install_default_shelley_fixture(temp: &TempDir, query: &str) { + let source = PathBuf::from(write_native_shelley_fixture(temp, query)); + let target = temp.path().join(".config/shelley"); + fs::create_dir_all(&target).unwrap(); + fs::copy(source, target.join("shelley.db")).unwrap(); +} + fn write_native_claude_fixture(temp: &TempDir, query: &str) -> String { let root = temp.path().join("native-claude/projects/-workspace"); fs::create_dir_all(&root).unwrap(); @@ -3168,100 +6207,678 @@ fn write_native_cursor_fixture(temp: &TempDir, query: &str) -> String { ), ) .unwrap(); - temp.path() - .join("native-cursor/projects") - .to_str() - .unwrap() - .to_owned() + temp.path() + .join("native-cursor/projects") + .to_str() + .unwrap() + .to_owned() +} + +fn write_native_copilot_fixture(temp: &TempDir, query: &str) -> String { + let root = temp + .path() + .join("native-copilot/session-state/copilot-cli-native"); + fs::create_dir_all(&root).unwrap(); + fs::write( + root.join("events.jsonl"), + format!( + "{}\n{}\n", + json!({ + "id": "copilot-cli-native-start", + "timestamp": "2026-06-24T12:00:00Z", + "type": "session.start", + "data": { + "sessionId": "copilot-cli-native", + "startTime": "2026-06-24T12:00:00Z", + "selectedModel": "gpt-5-mini", + "context": {"cwd": "/workspace"} + } + }), + json!({ + "id": "copilot-cli-native-user", + "timestamp": "2026-06-24T12:00:01Z", + "type": "user.message", + "data": {"content": query} + }) + ), + ) + .unwrap(); + temp.path() + .join("native-copilot/session-state") + .to_str() + .unwrap() + .to_owned() +} + +fn write_native_factory_droid_fixture(temp: &TempDir, query: &str) -> String { + let root = temp.path().join("native-droid/sessions/project"); + fs::create_dir_all(&root).unwrap(); + fs::write( + root.join("droid-cli-native.jsonl"), + format!( + "{}\n{}\n", + json!({ + "type": "session_start", + "sessionId": "droid-cli-native", + "timestamp": "2026-06-24T12:00:00Z", + "cwd": "/workspace", + "model": "factory/droid" + }), + json!({ + "type": "message", + "id": "droid-cli-native-user", + "timestamp": "2026-06-24T12:00:01Z", + "role": "user", + "content": [{"type": "text", "text": query}] + }) + ), + ) + .unwrap(); + temp.path() + .join("native-droid/sessions") + .to_str() + .unwrap() + .to_owned() +} + +fn write_native_openclaw_fixture(temp: &TempDir, query: &str) -> String { + let root = temp.path().join("native-openclaw"); + let sessions = root.join("agents/personal-agent/sessions"); + fs::create_dir_all(&sessions).unwrap(); + fs::write( + sessions.join("sessions.json"), + serde_json::to_string(&json!({ + "openclaw-cli-native": { + "sessionId": "openclaw-cli-native", + "sessionFile": sessions.join("openclaw-cli-native.jsonl"), + "sessionStartedAt": "2026-06-24T12:00:00Z", + "modelProvider": "openai", + "model": "gpt-5-mini", + "lastChannel": "telegram" + } + })) + .unwrap(), + ) + .unwrap(); + fs::write( + sessions.join("openclaw-cli-native.jsonl"), + format!( + "{}\n{}\n{}\n", + json!({ + "type": "session", + "version": 1, + "id": "openclaw-cli-native", + "timestamp": "2026-06-24T12:00:00Z", + "cwd": "/workspace" + }), + json!({ + "type": "message", + "id": "openclaw-cli-native-user", + "timestamp": "2026-06-24T12:00:01Z", + "message": {"role": "user", "content": query} + }), + json!({ + "type": "message", + "id": "openclaw-cli-native-assistant", + "parentId": "openclaw-cli-native-user", + "timestamp": "2026-06-24T12:00:02Z", + "message": {"role": "assistant", "content": "native import ok"} + }) + ), + ) + .unwrap(); + root.to_str().unwrap().to_owned() +} + +fn write_native_hermes_fixture(temp: &TempDir, query: &str) -> String { + let path = temp.path().join("native-hermes-state.db"); + let conn = Connection::open(&path).unwrap(); + conn.execute_batch( + "create table sessions ( + id text primary key, + source text not null, + model text, + model_config text, + parent_session_id text, + started_at real not null, + ended_at real, + message_count integer default 0, + tool_call_count integer default 0, + input_tokens integer default 0, + output_tokens integer default 0, + cwd text, + title text, + archived integer default 0 + ); + create table messages ( + id integer primary key autoincrement, + session_id text not null, + role text not null, + content text, + tool_calls text, + tool_call_id text, + tool_name text, + timestamp real not null, + active integer not null default 1, + compacted integer not null default 0 + );", + ) + .unwrap(); + conn.execute( + "insert into sessions ( + id, source, model, model_config, started_at, message_count, cwd, title + ) values (?1, 'acp', 'gpt-5-mini', ?2, 1782259200.0, 2, '/workspace', 'native hermes')", + [ + "hermes-cli-native", + r#"{"cwd":"/workspace","provider":"openai"}"#, + ], + ) + .unwrap(); + conn.execute( + "insert into messages (session_id, role, content, timestamp) values (?1, 'user', ?2, 1782259201.0)", + ["hermes-cli-native", query], + ) + .unwrap(); + conn.execute( + "insert into messages (session_id, role, content, timestamp) values (?1, 'assistant', 'native import ok', 1782259202.0)", + ["hermes-cli-native"], + ) + .unwrap(); + path.to_str().unwrap().to_owned() +} + +fn write_native_nanoclaw_fixture(temp: &TempDir, query: &str) -> String { + let root = temp.path().join("native-nanoclaw"); + let data = root.join("data"); + let session_dir = data.join("v2-sessions/ag-1/session-1"); + fs::create_dir_all(&session_dir).unwrap(); + let central = Connection::open(data.join("v2.db")).unwrap(); + central + .execute_batch( + "create table agent_groups ( + id text primary key, + name text, + folder text, + agent_provider text + ); + create table messaging_groups ( + id text primary key, + channel_type text, + platform_id text, + instance text, + name text + ); + create table sessions ( + id text primary key, + agent_group_id text not null, + messaging_group_id text, + thread_id text, + agent_provider text, + status text, + container_status text, + last_active integer, + created_at integer + );", + ) + .unwrap(); + central + .execute( + "insert into agent_groups values ('ag-1', 'Personal', '/workspace', 'codex')", + [], + ) + .unwrap(); + central + .execute( + "insert into messaging_groups values ('mg-1', 'telegram', 'chat-1', 'default', 'DM')", + [], + ) + .unwrap(); + central + .execute( + "insert into sessions values ( + 'session-1', 'ag-1', 'mg-1', 'thread-1', 'codex', 'active', + 'running', 1782259202000, 1782259200000 + )", + [], + ) + .unwrap(); + let inbound = Connection::open(session_dir.join("inbound.db")).unwrap(); + inbound + .execute_batch( + "create table messages_in ( + id text primary key, + seq integer, + kind text, + timestamp integer, + status text, + trigger text, + platform_id text, + channel_type text, + thread_id text, + content text, + source_session_id text, + on_wake integer + );", + ) + .unwrap(); + inbound + .execute( + "insert into messages_in values ( + 'in-1', 1, 'chat', 1782259201000, 'done', 'message', + 'chat-1', 'telegram', 'thread-1', ?1, null, 0 + )", + [json!({"text": query}).to_string()], + ) + .unwrap(); + let outbound = Connection::open(session_dir.join("outbound.db")).unwrap(); + outbound + .execute_batch( + "create table messages_out ( + id text primary key, + seq integer, + in_reply_to text, + timestamp integer, + kind text, + platform_id text, + channel_type text, + thread_id text, + content text + );", + ) + .unwrap(); + outbound + .execute( + "insert into messages_out values ( + 'out-1', 2, 'in-1', 1782259202000, 'chat', + 'chat-1', 'telegram', 'thread-1', ?1 + )", + [json!({"text": "native import ok"}).to_string()], + ) + .unwrap(); + root.to_str().unwrap().to_owned() +} + +fn write_native_astrbot_fixture(temp: &TempDir, query: &str) -> String { + let data = temp.path().join("native-astrbot/data"); + fs::create_dir_all(&data).unwrap(); + let path = data.join("data_v4.db"); + let conn = Connection::open(&path).unwrap(); + conn.execute_batch( + "create table conversations ( + id integer primary key, + inner_conversation_id text, + conversation_id text, + platform_id text, + user_id text, + content text not null, + title text, + persona_id text, + token_usage text, + created_at integer, + updated_at integer + ); + create table preferences ( + scope text, + key text, + value text + ); + create table platform_message_history ( + id integer primary key, + platform_id text, + user_id text, + sender_id text, + sender_name text, + content text, + llm_checkpoint_id text, + created_at integer + );", + ) + .unwrap(); + conn.execute( + "insert into conversations values ( + 1, 'umo-1', 'conv-1', 'webchat', 'user-1', ?1, 'native astrbot', + 'default', ?2, 1782259200000, 1782259202000 + )", + [ + json!([ + {"role": "user", "content": query}, + {"type": "_checkpoint", "id": "checkpoint-1"}, + {"role": "assistant", "content": "native import ok"} + ]) + .to_string(), + json!({"prompt": 1, "completion": 1}).to_string(), + ], + ) + .unwrap(); + conn.execute( + "insert into preferences values ('umo', 'sel_conv_id', 'conv-1')", + [], + ) + .unwrap(); + conn.execute( + "insert into platform_message_history values ( + 1, 'webchat', 'user-1', 'user-1', 'User', ?1, 'checkpoint-1', 1782259201000 + )", + [json!({"text": query}).to_string()], + ) + .unwrap(); + path.to_str().unwrap().to_owned() +} + +fn write_native_shelley_fixture(temp: &TempDir, query: &str) -> String { + let path = temp.path().join("native-shelley.db"); + let conn = Connection::open(&path).unwrap(); + conn.execute_batch( + "create table conversations ( + conversation_id text primary key, + slug text, + user_initiated boolean not null default true, + created_at datetime not null default current_timestamp, + updated_at datetime not null default current_timestamp, + cwd text, + archived boolean not null default false, + parent_conversation_id text, + model text, + conversation_options text not null default '{}', + current_generation integer not null default 1, + agent_working boolean not null default false, + tags text not null default '[]', + is_draft boolean not null default false, + draft text not null default '' + ); + create table messages ( + message_id text primary key, + conversation_id text not null, + sequence_id integer not null, + type text not null, + llm_data text, + user_data text, + usage_data text, + created_at datetime not null default current_timestamp, + display_data text, + excluded_from_context boolean not null default false, + generation integer not null default 1, + llm_api_url text, + model_name text, + forked_from_message_id text + );", + ) + .unwrap(); + conn.execute( + "insert into conversations values ( + 'shelley-cli-native', 'native shelley', 1, '2026-06-24 12:00:00', + '2026-06-24 12:00:01', '/workspace', 0, null, 'claude-opus-4-7', + '{}', 1, 0, '[]', 0, '' + )", + [], + ) + .unwrap(); + conn.execute( + "insert into messages ( + message_id, conversation_id, sequence_id, type, user_data, created_at + ) values ( + 'shelley-cli-native-user', 'shelley-cli-native', 1, 'user', ?1, + '2026-06-24 12:00:01' + )", + [json!({"Content": [{"Type": 2, "Text": query}]}).to_string()], + ) + .unwrap(); + conn.execute( + "insert into messages ( + message_id, conversation_id, sequence_id, type, llm_data, usage_data, + created_at, llm_api_url, model_name + ) values ( + 'shelley-cli-native-agent', 'shelley-cli-native', 2, 'agent', ?1, ?2, + '2026-06-24 12:00:02', 'https://api.anthropic.com/v1/messages', + 'claude-opus-4-7' + )", + [ + json!({"Content": [{"Type": 2, "Text": "native Shelley import ok"}]}).to_string(), + json!({"input_tokens": 12, "output_tokens": 8, "cost_usd": 0.001}).to_string(), + ], + ) + .unwrap(); + path.to_str().unwrap().to_owned() +} + +fn append_native_openclaw_event(path: &str, query: &str) { + let transcript = + Path::new(path).join("agents/personal-agent/sessions/openclaw-cli-native.jsonl"); + let mut file = fs::OpenOptions::new() + .append(true) + .open(transcript) + .unwrap(); + writeln!( + file, + "{}", + json!({ + "type": "message", + "id": "openclaw-cli-native-incremental", + "parentId": "openclaw-cli-native-assistant", + "timestamp": "2026-06-24T12:00:03Z", + "message": {"role": "user", "content": query} + }) + ) + .unwrap(); +} + +fn append_native_hermes_event(path: &str, query: &str) { + let conn = Connection::open(path).unwrap(); + conn.execute( + "insert into messages (session_id, role, content, timestamp) values (?1, 'user', ?2, 1782259203.0)", + ["hermes-cli-native", query], + ) + .unwrap(); +} + +fn append_native_nanoclaw_event(path: &str, query: &str) { + let conn = Connection::open( + Path::new(path) + .join("data/v2-sessions/ag-1/session-1") + .join("inbound.db"), + ) + .unwrap(); + conn.execute( + "insert into messages_in values ( + 'in-2', 1, 'chat', 1782259203000, 'done', 'message', + 'chat-1', 'telegram', 'thread-1', ?1, null, 0 + )", + [json!({"text": query}).to_string()], + ) + .unwrap(); +} + +fn append_native_astrbot_event(path: &str, query: &str) { + let conn = Connection::open(path).unwrap(); + let content: String = conn + .query_row( + "select content from conversations where id = 1", + [], + |row| row.get(0), + ) + .unwrap(); + let mut content: Value = serde_json::from_str(&content).unwrap(); + content + .as_array_mut() + .unwrap() + .push(json!({"role": "assistant", "content": query})); + conn.execute( + "update conversations set content = ?1, updated_at = 1782259203000 where id = 1", + [content.to_string()], + ) + .unwrap(); +} + +fn append_native_shelley_event(path: &str, query: &str) { + let conn = Connection::open(path).unwrap(); + conn.execute( + "insert into messages ( + message_id, conversation_id, sequence_id, type, user_data, created_at + ) values ( + 'shelley-cli-native-user-2', 'shelley-cli-native', 3, 'user', ?1, + '2026-06-24 12:00:03' + )", + [json!({"Content": [{"Type": 2, "Text": query}]}).to_string()], + ) + .unwrap(); } -fn write_native_copilot_fixture(temp: &TempDir, query: &str) -> String { - let root = temp - .path() - .join("native-copilot/session-state/copilot-cli-native"); - fs::create_dir_all(&root).unwrap(); +#[test] +fn openclaw_import_accepts_explicit_session_jsonl_file() { + let temp = tempdir(); + let query = "openclaw-explicit-file-oracle"; + let path = temp.path().join("openclaw-single-session.jsonl"); fs::write( - root.join("events.jsonl"), + &path, format!( "{}\n{}\n", json!({ - "id": "copilot-cli-native-start", - "timestamp": "2026-06-24T12:00:00Z", - "type": "session.start", - "data": { - "sessionId": "copilot-cli-native", - "startTime": "2026-06-24T12:00:00Z", - "selectedModel": "gpt-5-mini", - "context": {"cwd": "/workspace"} - } + "type": "session", + "id": "openclaw-single-session", + "timestamp": "2026-06-24T12:00:00Z" }), json!({ - "id": "copilot-cli-native-user", + "type": "message", + "id": "openclaw-single-user", "timestamp": "2026-06-24T12:00:01Z", - "type": "user.message", - "data": {"content": query} + "message": {"role": "user", "content": query} }) ), ) .unwrap(); - temp.path() - .join("native-copilot/session-state") - .to_str() - .unwrap() - .to_owned() + + let imported = json_output(ctx(&temp).args([ + "import", + "--provider", + "openclaw", + "--path", + path.to_str().unwrap(), + "--json", + ])); + assert_eq!(imported["totals"]["failed"], 0); + assert_eq!(imported["totals"]["imported_sources"], 1); + + let search = + json_output(ctx(&temp).args(["search", query, "--provider", "openclaw", "--json"])); + assert_search_provider_oracle(&search, "openclaw", query, 1, "message"); } -fn write_native_factory_droid_fixture(temp: &TempDir, query: &str) -> String { - let root = temp.path().join("native-droid/sessions/project"); - fs::create_dir_all(&root).unwrap(); - fs::write( - root.join("droid-cli-native.jsonl"), - format!( - "{}\n{}\n", - json!({ - "type": "session_start", - "sessionId": "droid-cli-native", - "timestamp": "2026-06-24T12:00:00Z", - "cwd": "/workspace", - "model": "factory/droid" - }), - json!({ - "type": "message", - "id": "droid-cli-native-user", - "timestamp": "2026-06-24T12:00:01Z", - "role": "user", - "content": [{"type": "text", "text": query}] - }) - ), +#[test] +fn nanoclaw_import_tolerates_partial_auxiliary_tables() { + let temp = tempdir(); + let query = "nanoclaw-partial-auxiliary-schema-oracle"; + let path = write_native_nanoclaw_fixture(&temp, query); + let conn = Connection::open(Path::new(&path).join("data/v2.db")).unwrap(); + conn.execute_batch( + "drop table agent_groups; + create table agent_groups (id text primary key); + insert into agent_groups values ('ag-1'); + drop table messaging_groups; + create table messaging_groups (id text primary key); + insert into messaging_groups values ('mg-1');", ) .unwrap(); - temp.path() - .join("native-droid/sessions") - .to_str() - .unwrap() - .to_owned() + + let imported = json_output(ctx(&temp).args([ + "import", + "--provider", + "nanoclaw", + "--path", + &path, + "--json", + ])); + assert_eq!(imported["totals"]["failed"], 0); + assert_eq!(imported["totals"]["imported_sources"], 1); + + let search = + json_output(ctx(&temp).args(["search", query, "--provider", "nanoclaw", "--json"])); + assert_search_provider_oracle(&search, "nanoclaw", query, 1, "message"); +} + +#[test] +fn personal_agent_sqlite_imports_report_corrupt_databases() { + for (provider, path) in [ + ("hermes", "corrupt-hermes-state.db"), + ("astrbot", "corrupt-astrbot-data_v4.db"), + ("shelley", "corrupt-shelley.db"), + ] { + let temp = tempdir(); + let db_path = temp.path().join(path); + fs::write(&db_path, b"not sqlite").unwrap(); + let output = ctx(&temp) + .args([ + "import", + "--provider", + provider, + "--path", + db_path.to_str().unwrap(), + "--json", + ]) + .assert() + .failure() + .get_output() + .stderr + .clone(); + let stderr = String::from_utf8(output).unwrap(); + assert!(stderr.contains("not a database"), "{stderr}"); + } + + let temp = tempdir(); + let root = temp.path().join("corrupt-nanoclaw"); + fs::create_dir_all(root.join("data/v2-sessions")).unwrap(); + fs::write(root.join("data/v2.db"), b"not sqlite").unwrap(); + let output = ctx(&temp) + .args([ + "import", + "--provider", + "nanoclaw", + "--path", + root.to_str().unwrap(), + "--json", + ]) + .assert() + .failure() + .get_output() + .stderr + .clone(); + let stderr = String::from_utf8(output).unwrap(); + assert!(stderr.contains("not a database"), "{stderr}"); } #[test] fn native_provider_cli_requires_existing_history_or_explicit_path() { for (cli_provider, expected_blocker) in [ - ("claude", "no native claude history found"), - ("opencode", "no native opencode history found"), - ("antigravity", "no native antigravity history found"), - ("gemini", "no native gemini history found"), - ("cursor", "no native cursor history found"), - ("copilot-cli", "no native copilot_cli history found"), + ("claude", "no importable claude history found"), + ("opencode", "no importable opencode history found"), + ("antigravity", "no importable antigravity history found"), + ("gemini", "no importable gemini history found"), + ("cursor", "no importable cursor history found"), + ("copilot-cli", "no importable copilot_cli history found"), ( "factory-ai-droid", - "no native factory_ai_droid history found", + "no importable factory_ai_droid history found", ), + ("openclaw", "no importable openclaw history found"), + ("hermes", "no importable hermes history found"), + ("nanoclaw", "no importable nanoclaw history found"), + ("astrbot", "no importable astrbot history found"), + ("shelley", "no importable shelley history found"), ] { let temp = tempdir(); - ctx(&temp) - .args(["import", "--provider", cli_provider, "--json"]) - .assert() - .failure() - .stderr(predicate::str::contains(expected_blocker)); + let stderr = + failure_stderr(ctx(&temp).args(["import", "--provider", cli_provider, "--json"])); + + assert!(stderr.contains(expected_blocker), "{stderr}"); + assert!(stderr.contains("use `ctx sources`"), "{stderr}"); + if cli_provider == "nanoclaw" { + assert!( + stderr.contains("no default paths are registered for this provider"), + "{stderr}" + ); + } else { + assert!(stderr.contains("checked paths:"), "{stderr}"); + assert!(stderr.contains(temp.path().to_str().unwrap()), "{stderr}"); + } } } @@ -3354,7 +6971,7 @@ fn human_search_reports_no_results() { .stdout .clone(); let fresh = String::from_utf8(fresh).unwrap(); - assert!(fresh.contains("no results")); + assert!(fresh.contains("no results for definitely-no-results-here")); assert!(fresh.contains("next: ctx import --all")); let fixture = provider_history_fixture("codex-sessions"); @@ -3378,15 +6995,200 @@ fn human_search_reports_no_results() { .stdout .clone(); let indexed = String::from_utf8(indexed).unwrap(); - assert!(indexed.contains("no results")); - assert!(indexed.contains("next: try broader terms with ctx search --term")); + assert!(indexed.contains("no results for definitely-no-results-here")); + assert!(indexed.contains("next: try broader terms with ctx search --term \"\"")); + + let term_only = ctx(&temp) + .args(["search", "--term", "term-only-no-results"]) + .assert() + .success() + .get_output() + .stdout + .clone(); + let term_only = String::from_utf8(term_only).unwrap(); + assert!(term_only.contains("no results for --term term-only-no-results")); +} + +#[test] +fn search_requires_query_term_or_file_before_refreshing() { + let temp = tempdir(); + let stderr = failure_stderr(ctx(&temp).args(["search", "--provider", "codex"])); + assert!( + stderr.contains("search needs a query, --term, or --file"), + "{stderr}" + ); + assert!( + stderr.contains("ctx search \"failed migration\""), + "{stderr}" + ); + assert!( + !temp.path().join("work.sqlite").exists(), + "invalid search should fail before creating the ctx store" + ); + + let punctuation = failure_stderr(ctx(&temp).args(["search", "!!!"])); + assert!( + punctuation.contains("search needs a query, --term, or --file"), + "{punctuation}" + ); + let hyphen_only = failure_stderr(ctx(&temp).args(["search", "--", "---"])); + assert!( + hyphen_only.contains("search needs a query, --term, or --file"), + "{hyphen_only}" + ); + let underscore_term = failure_stderr(ctx(&temp).args(["search", "--term", "___"])); + assert!( + underscore_term.contains("search needs a query, --term, or --file"), + "{underscore_term}" + ); +} + +#[test] +fn search_refresh_off_requires_existing_store_without_creating_one() { + let temp = tempdir(); + let stderr = failure_stderr(ctx(&temp).args(["search", "anything", "--refresh", "off"])); + + assert!(stderr.contains("ctx store is not initialized"), "{stderr}"); + assert!( + !temp.path().join("work.sqlite").exists(), + "refresh-off search should not create the ctx store" + ); +} + +#[test] +fn file_only_search_returns_touched_file_matches() { + let temp = tempdir(); + let fixture = provider_history_fixture("codex-rich-sessions"); + json_output(ctx(&temp).args([ + "import", + "--provider", + "codex", + "--path", + &fixture, + "--json", + ])); + + let search = json_output(ctx(&temp).args(["search", "--file", "src/main.rs", "--json"])); + assert_eq!(search["query"], ""); + let results = search["results"].as_array().unwrap(); + assert_eq!(results.len(), 1); + assert!(results[0]["why_matched"] + .as_array() + .unwrap() + .iter() + .any(|reason| reason == "file_touched")); + assert!(results[0]["citations"] + .as_array() + .unwrap() + .iter() + .any(|citation| citation["item_type"] == "file" && citation["label"] == "file touched")); } #[test] -fn pi_cli_rejects_directory_import_path() { +fn pi_cli_imports_directory_tree_path() { let temp = tempdir(); let path = temp.path().join("pi-sessions-dir"); - fs::create_dir_all(&path).unwrap(); + let project = path.join("--workspace--"); + fs::create_dir_all(&project).unwrap(); + write_pi_session_jsonl( + &project.join("2026-06-24T12-00-00-000Z_pi-dir-alpha.jsonl"), + "pi-dir-alpha", + "pi directory alpha oracle", + ); + write_pi_session_jsonl( + &project.join("2026-06-24T12-01-00-000Z_pi-dir-beta.jsonl"), + "pi-dir-beta", + "pi directory beta oracle", + ); + + let imported = json_output(ctx(&temp).args([ + "import", + "--provider", + "pi", + "--path", + path.to_str().unwrap(), + "--json", + ])); + assert_eq!(imported["totals"]["imported_sessions"], 2); + assert_eq!(imported["totals"]["imported_events"], 2); + + let search = json_output(ctx(&temp).args([ + "search", + "pi directory beta oracle", + "--provider", + "pi", + "--json", + ])); + assert_search_provider_oracle(&search, "pi", "pi directory beta oracle", 1, "message"); +} + +#[test] +fn pi_cli_rejects_wrong_file_import_path() { + let temp = tempdir(); + let path = temp.path().join("pi-session.txt"); + fs::write(&path, "{}\n").unwrap(); + + ctx(&temp) + .args([ + "import", + "--provider", + "pi", + "--path", + path.to_str().unwrap(), + ]) + .assert() + .failure() + .stderr( + predicate::str::contains("no importable pi history files found") + .and(predicate::str::contains(path.to_str().unwrap())), + ); +} + +#[test] +fn import_rejects_nonexistent_path() { + let temp = tempdir(); + let path = temp.path().join("missing-codex-history"); + let path = path.to_str().unwrap(); + + ctx(&temp) + .args(["import", "--provider", "codex", "--path", path]) + .assert() + .failure() + .stderr( + predicate::str::contains("import path does not exist") + .and(predicate::str::contains(path)), + ); +} + +#[test] +fn import_path_requires_provider_before_opening_store() { + let temp = tempdir(); + let path = temp.path().join("missing-codex-history"); + let path = path.to_str().unwrap(); + + ctx(&temp) + .args(["import", "--path", path]) + .assert() + .failure() + .stderr(predicate::str::contains( + "ctx import --path requires --provider", + )); + assert!( + !temp.path().join("work.sqlite").exists(), + "native path import without provider should fail before opening the store" + ); +} + +#[cfg(unix)] +#[test] +fn import_rejects_symlinked_provider_root() { + use std::os::unix::fs::symlink; + + let temp = tempdir(); + let target = temp.path().join("pi-sessions"); + fs::create_dir_all(&target).unwrap(); + let path = temp.path().join("pi-sessions-link"); + symlink(&target, &path).unwrap(); ctx(&temp) .args([ @@ -3395,11 +7197,40 @@ fn pi_cli_rejects_directory_import_path() { "pi", "--path", path.to_str().unwrap(), - "--json", ]) .assert() .failure() - .stderr(predicate::str::contains("no importable pi history files")); + .stderr( + predicate::str::contains("symlinked provider transcript roots are rejected") + .and(predicate::str::contains(path.to_str().unwrap())), + ); +} + +#[cfg(unix)] +#[test] +fn import_reports_unreadable_directory_with_path_context() { + if unsafe { libc::geteuid() } == 0 { + return; + } + + use std::os::unix::fs::PermissionsExt; + + let temp = tempdir(); + let path = temp.path().join("unreadable-pi-sessions"); + fs::create_dir_all(&path).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o000)).unwrap(); + + let stderr = failure_stderr(ctx(&temp).args([ + "import", + "--provider", + "pi", + "--path", + path.to_str().unwrap(), + ])); + fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap(); + + assert!(stderr.contains("read import source directory"), "{stderr}"); + assert!(stderr.contains(path.to_str().unwrap()), "{stderr}"); } #[test] diff --git a/crates/ctx-history-capture/Cargo.toml b/crates/ctx-history-capture/Cargo.toml index 0d9f767f5..688ea4fc3 100644 --- a/crates/ctx-history-capture/Cargo.toml +++ b/crates/ctx-history-capture/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ctx-history-capture" -version = "0.12.0" +version = "0.19.0" description = "Internal provider import adapters for ctx local agent history" edition.workspace = true license.workspace = true diff --git a/crates/ctx-history-capture/src/lib.rs b/crates/ctx-history-capture/src/lib.rs index 87def341a..ca1c0a117 100644 --- a/crates/ctx-history-capture/src/lib.rs +++ b/crates/ctx-history-capture/src/lib.rs @@ -3,26 +3,29 @@ use std::{ collections::{BTreeMap, BTreeSet}, env, fs::{self, File}, - io::{BufRead, BufReader, BufWriter, Write}, + io::{BufRead, BufReader, BufWriter, Read, Write}, path::{Path, PathBuf}, sync::Arc, thread, time::{SystemTime, UNIX_EPOCH}, }; -use chrono::{DateTime, Utc}; +use chrono::{DateTime, NaiveDateTime, Utc}; use ctx_history_core::{ - inbox_dir as core_inbox_dir, new_id, AgentType, CaptureEnvelope, CaptureProvider, - CaptureSource, CaptureSourceDescriptor, CaptureSourceKind, Confidence, EntityTimestamps, Event, - EventRole, EventType, Fidelity, FileChangeKind, FileTouched, HistoryRecord, - ProviderCaptureEnvelope, ProviderCursorCheckpoint, ProviderCursorRange, ProviderEventEnvelope, - ProviderRawRetention, ProviderRedactionBoundary, ProviderSessionEnvelope, - ProviderSourceEnvelope, ProviderSourceTrust, RedactionState, Run, RunStatus, RunType, Session, - SessionEdge, SessionEdgeType, SessionHistoryArchive, SessionStatus, SyncCursor, SyncMetadata, - SyncState, Visibility, PROVIDER_CAPTURE_ENVELOPE_SCHEMA_VERSION, + inbox_dir as core_inbox_dir, new_id, utc_now, AgentType, CaptureEnvelope, CaptureProvider, + CaptureSource, CaptureSourceDescriptor, CaptureSourceKind, Confidence, + CtxHistoryJsonlEdgeRecord, CtxHistoryJsonlEventRecord, CtxHistoryJsonlFileTouchRecord, + CtxHistoryJsonlRecord, CtxHistoryJsonlSessionRecord, CtxHistoryJsonlSourceRecord, + EntityTimestamps, Event, EventRole, EventType, Fidelity, FileChangeKind, FileTouched, + HistoryRecord, ProviderCaptureEnvelope, ProviderCursorCheckpoint, ProviderCursorRange, + ProviderEventEnvelope, ProviderRawRetention, ProviderRedactionBoundary, + ProviderSessionEnvelope, ProviderSourceEnvelope, ProviderSourceTrust, RedactionState, Run, + RunStatus, RunType, Session, SessionEdge, SessionEdgeType, SessionHistoryArchive, + SessionStatus, SyncCursor, SyncMetadata, SyncState, Visibility, + CTX_HISTORY_JSONL_V1_SCHEMA_VERSION, PROVIDER_CAPTURE_ENVELOPE_SCHEMA_VERSION, }; use ctx_history_store::{CatalogSession, Store, StoreError}; -use rusqlite::{Connection, OpenFlags}; +use rusqlite::{limits::Limit, Connection, OpenFlags, OptionalExtension}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use thiserror::Error; @@ -37,6 +40,11 @@ pub use provider_sources::{ }; pub const CAPTURE_SCHEMA_VERSION: u32 = 1; +const MAX_PROVIDER_JSONL_LINE_BYTES: usize = 16 * 1024 * 1024; +const MAX_PROVIDER_SQLITE_VALUE_BYTES: usize = MAX_PROVIDER_JSONL_LINE_BYTES; +const MAX_OPENCLAW_SESSION_INDEX_BYTES: usize = 1024 * 1024; +const MAX_OPENCLAW_SESSION_INDEX_PATHS: usize = 256; +const MAX_OPENCLAW_SESSION_INDEX_VISITED_PATHS: usize = 4096; #[derive(Debug, Error)] pub enum CaptureError { #[error("io error: {0}")] @@ -86,7 +94,7 @@ impl SpoolWriter { let machine_id = sanitize_filename_component(machine_id); let pid = std::process::id(); - let unix_ms = Utc::now().timestamp_millis(); + let unix_ms = utc_now().timestamp_millis(); let random = new_id().simple().to_string(); let name = format!("capture-{machine_id}-{pid}-{unix_ms}-{random}.jsonl"); let final_path = inbox.join(name); @@ -148,7 +156,7 @@ impl Default for FixtureOptions { dedupe_key: None, machine_id: None, cwd: None, - occurred_at: Utc::now(), + occurred_at: utc_now(), } } } @@ -194,7 +202,7 @@ impl Default for ProviderFixtureImportOptions { Self { machine_id: default_machine_id(), source_path: None, - imported_at: Utc::now(), + imported_at: utc_now(), history_record_id: None, expected_provider: None, allow_partial_failures: false, @@ -204,6 +212,27 @@ impl Default for ProviderFixtureImportOptions { } } +#[derive(Debug, Clone)] +pub struct CustomHistoryJsonlV1ImportOptions { + pub machine_id: String, + pub source_path: Option, + pub imported_at: DateTime, + pub history_record_id: Option, + pub allow_partial_failures: bool, +} + +impl Default for CustomHistoryJsonlV1ImportOptions { + fn default() -> Self { + Self { + machine_id: default_machine_id(), + source_path: None, + imported_at: utc_now(), + history_record_id: None, + allow_partial_failures: false, + } + } +} + #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct ProviderImportSummary { pub imported: usize, @@ -239,7 +268,7 @@ impl Default for CodexHistoryImportOptions { Self { machine_id: default_machine_id(), source_path: None, - imported_at: Utc::now(), + imported_at: utc_now(), history_record_id: None, allow_partial_failures: false, } @@ -273,7 +302,7 @@ impl Default for CodexSessionImportOptions { Self { machine_id: default_machine_id(), source_path: None, - imported_at: Utc::now(), + imported_at: utc_now(), history_record_id: None, allow_partial_failures: false, max_session_files: None, @@ -346,7 +375,7 @@ impl Default for CodexSessionCatalogOptions { fn default() -> Self { Self { source_root: None, - cataloged_at: Utc::now(), + cataloged_at: utc_now(), allow_partial_failures: true, max_session_files: None, max_total_bytes: None, @@ -380,7 +409,7 @@ impl Default for PiSessionImportOptions { Self { machine_id: default_machine_id(), source_path: None, - imported_at: Utc::now(), + imported_at: utc_now(), history_record_id: None, allow_partial_failures: false, } @@ -401,7 +430,7 @@ impl Default for ClaudeProjectsImportOptions { Self { machine_id: default_machine_id(), source_path: None, - imported_at: Utc::now(), + imported_at: utc_now(), history_record_id: None, allow_partial_failures: false, } @@ -422,7 +451,112 @@ impl Default for OpenCodeSqliteImportOptions { Self { machine_id: default_machine_id(), source_path: None, - imported_at: Utc::now(), + imported_at: utc_now(), + history_record_id: None, + allow_partial_failures: false, + } + } +} + +#[derive(Debug, Clone)] +pub struct OpenClawImportOptions { + pub machine_id: String, + pub source_path: Option, + pub imported_at: DateTime, + pub history_record_id: Option, + pub allow_partial_failures: bool, +} + +impl Default for OpenClawImportOptions { + fn default() -> Self { + Self { + machine_id: default_machine_id(), + source_path: None, + imported_at: utc_now(), + history_record_id: None, + allow_partial_failures: false, + } + } +} + +#[derive(Debug, Clone)] +pub struct HermesSqliteImportOptions { + pub machine_id: String, + pub source_path: Option, + pub imported_at: DateTime, + pub history_record_id: Option, + pub allow_partial_failures: bool, +} + +impl Default for HermesSqliteImportOptions { + fn default() -> Self { + Self { + machine_id: default_machine_id(), + source_path: None, + imported_at: utc_now(), + history_record_id: None, + allow_partial_failures: false, + } + } +} + +#[derive(Debug, Clone)] +pub struct NanoClawImportOptions { + pub machine_id: String, + pub source_path: Option, + pub imported_at: DateTime, + pub history_record_id: Option, + pub allow_partial_failures: bool, +} + +impl Default for NanoClawImportOptions { + fn default() -> Self { + Self { + machine_id: default_machine_id(), + source_path: None, + imported_at: utc_now(), + history_record_id: None, + allow_partial_failures: false, + } + } +} + +#[derive(Debug, Clone)] +pub struct AstrBotSqliteImportOptions { + pub machine_id: String, + pub source_path: Option, + pub imported_at: DateTime, + pub history_record_id: Option, + pub allow_partial_failures: bool, +} + +impl Default for AstrBotSqliteImportOptions { + fn default() -> Self { + Self { + machine_id: default_machine_id(), + source_path: None, + imported_at: utc_now(), + history_record_id: None, + allow_partial_failures: false, + } + } +} + +#[derive(Debug, Clone)] +pub struct ShelleySqliteImportOptions { + pub machine_id: String, + pub source_path: Option, + pub imported_at: DateTime, + pub history_record_id: Option, + pub allow_partial_failures: bool, +} + +impl Default for ShelleySqliteImportOptions { + fn default() -> Self { + Self { + machine_id: default_machine_id(), + source_path: None, + imported_at: utc_now(), history_record_id: None, allow_partial_failures: false, } @@ -443,7 +577,7 @@ impl Default for AntigravityCliImportOptions { Self { machine_id: default_machine_id(), source_path: None, - imported_at: Utc::now(), + imported_at: utc_now(), history_record_id: None, allow_partial_failures: false, } @@ -464,7 +598,7 @@ impl Default for GeminiCliImportOptions { Self { machine_id: default_machine_id(), source_path: None, - imported_at: Utc::now(), + imported_at: utc_now(), history_record_id: None, allow_partial_failures: false, } @@ -485,7 +619,7 @@ impl Default for FactoryAiDroidImportOptions { Self { machine_id: default_machine_id(), source_path: None, - imported_at: Utc::now(), + imported_at: utc_now(), history_record_id: None, allow_partial_failures: false, } @@ -506,7 +640,7 @@ impl Default for CopilotCliImportOptions { Self { machine_id: default_machine_id(), source_path: None, - imported_at: Utc::now(), + imported_at: utc_now(), history_record_id: None, allow_partial_failures: false, } @@ -527,7 +661,7 @@ impl Default for CursorNativeImportOptions { Self { machine_id: default_machine_id(), source_path: None, - imported_at: Utc::now(), + imported_at: utc_now(), history_record_id: None, allow_partial_failures: false, } @@ -646,7 +780,7 @@ impl Default for ProviderAdapterContext { Self { machine_id: default_machine_id(), source_path: None, - imported_at: Utc::now(), + imported_at: utc_now(), tool_output_mode: CodexToolOutputMode::Full, event_mode: CodexEventImportMode::Rich, include_notices: true, @@ -689,6 +823,8 @@ pub struct ProviderFileTouchedEnvelope { pub provider_touch_index: u64, #[serde(default, skip_serializing_if = "Option::is_none")] pub provider_event_index: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub raw_source_path: Option, pub path: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub change_kind: Option, @@ -736,6 +872,21 @@ pub struct ClaudeProjectsJsonlAdapter; #[derive(Debug, Clone, Copy, Default)] pub struct OpenCodeSqliteAdapter; +#[derive(Debug, Clone, Copy, Default)] +pub struct OpenClawJsonlAdapter; + +#[derive(Debug, Clone, Copy, Default)] +pub struct HermesSqliteAdapter; + +#[derive(Debug, Clone, Copy, Default)] +pub struct NanoClawProjectAdapter; + +#[derive(Debug, Clone, Copy, Default)] +pub struct AstrBotSqliteAdapter; + +#[derive(Debug, Clone, Copy, Default)] +pub struct ShelleySqliteAdapter; + #[derive(Debug, Clone, Copy, Default)] pub struct AntigravityCliJsonlAdapter; @@ -767,17 +918,18 @@ impl ProviderCaptureAdapter for ProviderFixtureJsonlAdapter { ) -> Result { ensure_regular_provider_transcript_file(path)?; let file = File::open(path)?; - let reader = BufReader::new(file); + let mut reader = BufReader::new(file); let mut result = ProviderNormalizationResult::default(); + let mut line = Vec::new(); + let mut line_number = 0usize; - for (index, line) in reader.lines().enumerate() { - let line_number = index + 1; - let line = line?; - if line.trim().is_empty() { + while read_provider_jsonl_line(&mut reader, &mut line)? { + line_number += 1; + if line.iter().all(u8::is_ascii_whitespace) { continue; } - let fixture: ProviderFixtureLine = match serde_json::from_str(&line) { + let fixture: ProviderFixtureLine = match serde_json::from_slice(&line) { Ok(fixture) => fixture, Err(err) => { result.summary.failed += 1; @@ -829,19 +981,20 @@ impl ProviderCaptureAdapter for CodexHistoryJsonlAdapter { ) -> Result { ensure_regular_provider_transcript_file(path)?; let file = File::open(path)?; - let reader = BufReader::new(file); + let mut reader = BufReader::new(file); let mut result = ProviderNormalizationResult::default(); let mut parsed = Vec::new(); let mut first_seen = BTreeMap::new(); + let mut line = Vec::new(); + let mut line_number = 0usize; - for (index, line) in reader.lines().enumerate() { - let line_number = index + 1; - let line = line?; - if line.trim().is_empty() { + while read_provider_jsonl_line(&mut reader, &mut line)? { + line_number += 1; + if line.iter().all(u8::is_ascii_whitespace) { continue; } - let history: CodexHistoryLine = match serde_json::from_str(&line) { + let history: CodexHistoryLine = match serde_json::from_slice(&line) { Ok(history) => history, Err(err) => { result.summary.failed += 1; @@ -967,7 +1120,7 @@ impl ProviderCaptureAdapter for CodexHistoryJsonlAdapter { role: Some(EventRole::User), occurred_at, fidelity: Fidelity::SummaryOnly, - redaction_state: RedactionState::SafePreview, + redaction_state: RedactionState::LocalPreview, idempotency_key: Some(format!( "provider-event:{}:{}:{}", CaptureProvider::Codex.as_str(), @@ -1014,15 +1167,14 @@ impl ProviderCaptureAdapter for CodexSessionJsonlAdapter { let mut result = ProviderNormalizationResult::default(); let mut header = None; let mut call_contexts: BTreeMap = BTreeMap::new(); + let raw_source_path = context + .source_path + .as_ref() + .map(|path| path.display().to_string()); let mut line_number = 0usize; let mut line = Vec::new(); - loop { - line.clear(); - let read = reader.read_until(b'\n', &mut line)?; - if read == 0 { - break; - } + while read_provider_jsonl_line(&mut reader, &mut line)? { line_number += 1; if line.iter().all(u8::is_ascii_whitespace) { continue; @@ -1084,19 +1236,28 @@ impl ProviderCaptureAdapter for CodexSessionJsonlAdapter { }); continue; }; - let occurred_at = value - .get("timestamp") - .and_then(Value::as_str) - .and_then(parse_rfc3339_utc) - .unwrap_or(header.timestamp); + let occurred_at = match codex_session_line_timestamp(&value, header.timestamp) { + Ok(occurred_at) => occurred_at, + Err(err) => { + result.summary.failed += 1; + result.summary.failures.push(ProviderImportFailure { + line: line_number, + error: err.to_string(), + }); + continue; + } + }; let mut line_capture = codex_session_line_capture( header, &value, - line_number, - occurred_at, &mut call_contexts, - context.tool_output_mode, - context.event_mode, + CodexSessionLineContext { + line_number, + occurred_at, + tool_output_mode: context.tool_output_mode, + event_mode: context.event_mode, + raw_source_path: raw_source_path.as_deref(), + }, ); if let Some(event) = line_capture.event.take() { if !context.include_notices && event.event_type == EventType::Notice { @@ -1246,68 +1407,112 @@ impl ProviderCaptureAdapter for PiSessionJsonlAdapter { path: &Path, context: &ProviderAdapterContext, ) -> Result { - ensure_regular_provider_transcript_file(path)?; - let file = File::open(path)?; - let reader = BufReader::new(file); - let mut result = ProviderNormalizationResult::default(); - let mut header = None; + normalize_pi_session_jsonl_path(path, context) + } +} + +fn normalize_pi_session_jsonl_path( + path: &Path, + context: &ProviderAdapterContext, +) -> Result { + if fs::symlink_metadata(path)?.file_type().is_file() { + return normalize_pi_session_jsonl_file(path, context); + } + + let mut paths = Vec::new(); + collect_jsonl_paths(path, &mut paths)?; + paths.sort(); + if paths.is_empty() { + return Err(CaptureError::InvalidProviderTranscriptPath { + path: path.to_path_buf(), + reason: native_jsonl_missing_reason(CaptureProvider::Pi), + }); + } + + let mut merged = ProviderNormalizationResult::default(); + for path in paths { + let mut file_context = context.clone(); + file_context.source_path = Some(path.clone()); + let mut result = normalize_pi_session_jsonl_file(&path, &file_context)?; + merged.summary.merge(result.summary); + merged.captures.append(&mut result.captures); + merged.files_touched.append(&mut result.files_touched); + } + Ok(merged) +} + +fn normalize_pi_session_jsonl_file( + path: &Path, + context: &ProviderAdapterContext, +) -> Result { + ensure_regular_provider_transcript_file(path)?; + let file = File::open(path)?; + let mut reader = BufReader::new(file); + let mut result = ProviderNormalizationResult::default(); + let mut header = None; + let mut line = Vec::new(); + let mut line_number = 0usize; + + while read_provider_jsonl_line(&mut reader, &mut line)? { + line_number += 1; + if line.iter().all(u8::is_ascii_whitespace) { + continue; + } - for (index, line) in reader.lines().enumerate() { - let line_number = index + 1; - let line = line?; - if line.trim().is_empty() { + let value: Value = match serde_json::from_slice(&line) { + Ok(value) => value, + Err(err) => { + result.summary.failed += 1; + result.summary.failures.push(ProviderImportFailure { + line: line_number, + error: err.to_string(), + }); continue; } - - let value: Value = match serde_json::from_str(&line) { - Ok(value) => value, + }; + let entry_type = value + .get("type") + .and_then(Value::as_str) + .unwrap_or("unknown"); + if entry_type == "session" { + match pi_session_header(value) { + Ok(parsed) => { + let capture = pi_session_capture(&parsed, None, line_number, context)?; + header = Some(parsed); + result.captures.push((line_number, capture)); + } Err(err) => { result.summary.failed += 1; result.summary.failures.push(ProviderImportFailure { line: line_number, error: err.to_string(), }); - continue; - } - }; - let entry_type = value - .get("type") - .and_then(Value::as_str) - .unwrap_or("unknown"); - if entry_type == "session" { - match pi_session_header(value) { - Ok(parsed) => { - let capture = pi_session_capture(&parsed, None, line_number, context); - header = Some(parsed); - result.captures.push((line_number, capture)); - } - Err(err) => { - result.summary.failed += 1; - result.summary.failures.push(ProviderImportFailure { - line: line_number, - error: err.to_string(), - }); - } } - continue; } + continue; + } - let Some(header) = header.as_ref() else { + let Some(header) = header.as_ref() else { + result.summary.failed += 1; + result.summary.failures.push(ProviderImportFailure { + line: line_number, + error: "pi session entry appeared before session header".to_owned(), + }); + continue; + }; + match pi_session_capture(header, Some(value), line_number, context) { + Ok(capture) => result.captures.push((line_number, capture)), + Err(err) => { result.summary.failed += 1; result.summary.failures.push(ProviderImportFailure { line: line_number, - error: "pi session entry appeared before session header".to_owned(), + error: err.to_string(), }); - continue; - }; - result.captures.push(( - line_number, - pi_session_capture(header, Some(value), line_number, context), - )); + } } - - Ok(result) } + + Ok(result) } impl ProviderCaptureAdapter for ClaudeProjectsJsonlAdapter { @@ -1364,13 +1569,13 @@ impl ProviderCaptureAdapter for OpenCodeSqliteAdapter { } } -impl ProviderCaptureAdapter for AntigravityCliJsonlAdapter { +impl ProviderCaptureAdapter for OpenClawJsonlAdapter { fn provider(&self) -> CaptureProvider { - CaptureProvider::Antigravity + CaptureProvider::OpenClaw } fn source_format(&self) -> &str { - ANTIGRAVITY_CLI_SOURCE_FORMAT + OPENCLAW_SOURCE_FORMAT } fn normalize_path( @@ -1378,8 +1583,98 @@ impl ProviderCaptureAdapter for AntigravityCliJsonlAdapter { path: &Path, context: &ProviderAdapterContext, ) -> Result { - normalize_jsonl_tree( - path, + normalize_openclaw_history(path, context) + } +} + +impl ProviderCaptureAdapter for HermesSqliteAdapter { + fn provider(&self) -> CaptureProvider { + CaptureProvider::Hermes + } + + fn source_format(&self) -> &str { + HERMES_SQLITE_SOURCE_FORMAT + } + + fn normalize_path( + &self, + path: &Path, + context: &ProviderAdapterContext, + ) -> Result { + normalize_hermes_sqlite(path, context) + } +} + +impl ProviderCaptureAdapter for NanoClawProjectAdapter { + fn provider(&self) -> CaptureProvider { + CaptureProvider::NanoClaw + } + + fn source_format(&self) -> &str { + NANOCLAW_SOURCE_FORMAT + } + + fn normalize_path( + &self, + path: &Path, + context: &ProviderAdapterContext, + ) -> Result { + normalize_nanoclaw_project(path, context) + } +} + +impl ProviderCaptureAdapter for AstrBotSqliteAdapter { + fn provider(&self) -> CaptureProvider { + CaptureProvider::AstrBot + } + + fn source_format(&self) -> &str { + ASTRBOT_SQLITE_SOURCE_FORMAT + } + + fn normalize_path( + &self, + path: &Path, + context: &ProviderAdapterContext, + ) -> Result { + normalize_astrbot_sqlite(path, context) + } +} + +impl ProviderCaptureAdapter for ShelleySqliteAdapter { + fn provider(&self) -> CaptureProvider { + CaptureProvider::Shelley + } + + fn source_format(&self) -> &str { + SHELLEY_SQLITE_SOURCE_FORMAT + } + + fn normalize_path( + &self, + path: &Path, + context: &ProviderAdapterContext, + ) -> Result { + normalize_shelley_sqlite(path, context) + } +} + +impl ProviderCaptureAdapter for AntigravityCliJsonlAdapter { + fn provider(&self) -> CaptureProvider { + CaptureProvider::Antigravity + } + + fn source_format(&self) -> &str { + ANTIGRAVITY_CLI_SOURCE_FORMAT + } + + fn normalize_path( + &self, + path: &Path, + context: &ProviderAdapterContext, + ) -> Result { + normalize_jsonl_tree( + path, context, CaptureProvider::Antigravity, ANTIGRAVITY_CLI_SOURCE_FORMAT, @@ -1573,18 +1868,20 @@ pub fn read_jsonl(path: impl AsRef) -> Result> { let path = path.as_ref(); ensure_regular_spool_file(path)?; let file = File::open(path)?; - let reader = BufReader::new(file); + let mut reader = BufReader::new(file); let mut envelopes = Vec::new(); + let mut line = Vec::new(); + let mut line_number = 0usize; - for (index, line) in reader.lines().enumerate() { - let line = line?; - if line.trim().is_empty() { + while read_provider_jsonl_line(&mut reader, &mut line)? { + line_number += 1; + if line.iter().all(u8::is_ascii_whitespace) { continue; } let envelope: CaptureEnvelope = - serde_json::from_str(&line).map_err(|source| CaptureError::InvalidJsonLine { + serde_json::from_slice(&line).map_err(|source| CaptureError::InvalidJsonLine { path: path.to_path_buf(), - line: index + 1, + line: line_number, source, })?; validate_envelope(&envelope)?; @@ -1674,6 +1971,115 @@ pub fn import_provider_fixture_jsonl( ) } +pub fn import_custom_history_jsonl_v1( + path: impl AsRef, + store: &mut Store, + options: CustomHistoryJsonlV1ImportOptions, +) -> Result { + let path = path.as_ref(); + let source_path = options + .source_path + .clone() + .unwrap_or_else(|| path.to_path_buf()); + let normalization = normalize_custom_history_jsonl_v1( + path, + &ProviderAdapterContext { + machine_id: options.machine_id, + source_path: Some(source_path), + imported_at: options.imported_at, + tool_output_mode: CodexToolOutputMode::Full, + event_mode: CodexEventImportMode::Rich, + include_notices: true, + }, + )?; + if normalization.provider.summary.failed > 0 && !options.allow_partial_failures { + return Ok(normalization.provider.summary); + } + + let mut summary = import_normalized_provider_captures( + store, + normalization.provider, + NormalizedProviderImportOptions { + history_record_id: options.history_record_id, + allow_partial_failures: options.allow_partial_failures, + persist_cursors: true, + wrap_transaction: true, + fast_event_inserts: true, + }, + )?; + import_custom_history_edges( + store, + &normalization.edges, + options.history_record_id, + options.allow_partial_failures, + &mut summary, + )?; + import_custom_history_source_cursors(store, &normalization.source_cursors)?; + Ok(summary) +} + +pub fn import_custom_history_jsonl_v1_reader( + reader: impl BufRead, + store: &mut Store, + options: CustomHistoryJsonlV1ImportOptions, +) -> Result { + let normalization = normalize_custom_history_jsonl_v1_reader( + reader, + &ProviderAdapterContext { + machine_id: options.machine_id, + source_path: options.source_path, + imported_at: options.imported_at, + tool_output_mode: CodexToolOutputMode::Full, + event_mode: CodexEventImportMode::Rich, + include_notices: true, + }, + )?; + if normalization.provider.summary.failed > 0 && !options.allow_partial_failures { + return Ok(normalization.provider.summary); + } + + let mut summary = import_normalized_provider_captures( + store, + normalization.provider, + NormalizedProviderImportOptions { + history_record_id: options.history_record_id, + allow_partial_failures: options.allow_partial_failures, + persist_cursors: true, + wrap_transaction: true, + fast_event_inserts: true, + }, + )?; + import_custom_history_edges( + store, + &normalization.edges, + options.history_record_id, + options.allow_partial_failures, + &mut summary, + )?; + import_custom_history_source_cursors(store, &normalization.source_cursors)?; + Ok(summary) +} + +pub fn validate_custom_history_jsonl_v1(path: impl AsRef) -> Result { + let path = path.as_ref(); + let normalization = normalize_custom_history_jsonl_v1( + path, + &ProviderAdapterContext { + source_path: Some(path.to_path_buf()), + ..ProviderAdapterContext::default() + }, + )?; + Ok(normalization.provider.summary) +} + +pub fn validate_custom_history_jsonl_v1_reader( + reader: impl BufRead, +) -> Result { + let normalization = + normalize_custom_history_jsonl_v1_reader(reader, &ProviderAdapterContext::default())?; + Ok(normalization.provider.summary) +} + pub fn import_codex_history_jsonl( path: impl AsRef, store: &mut Store, @@ -1780,6 +2186,10 @@ pub fn import_codex_session_jsonl_tail( wrap_transaction: false, fast_event_inserts: true, }; + let raw_source_path = context + .source_path + .as_ref() + .map(|path| path.display().to_string()); report_codex_import_progress( &options, @@ -1799,22 +2209,21 @@ pub fn import_codex_session_jsonl_tail( let mut line_number = 0usize; let mut position = 0u64; - let read = reader.read_until(b'\n', &mut line)?; - if read == 0 { + if !read_provider_jsonl_line(&mut reader, &mut line)? { return Ok(summary); } line_number += 1; + let read = line.len(); position = position.saturating_add(read as u64); let header_value: Value = serde_json::from_slice(&line)?; let header = codex_session_header(header_value)?; while position < start_offset { - line.clear(); - let read = reader.read_until(b'\n', &mut line)?; - if read == 0 { + if !read_provider_jsonl_line(&mut reader, &mut line)? { return Ok(summary); } line_number += 1; + let read = line.len(); position = position.saturating_add(read as u64); } @@ -1832,13 +2241,9 @@ pub fn import_codex_session_jsonl_tail( let mut call_contexts: BTreeMap = BTreeMap::new(); let mut completed_bytes = 0u64; - loop { - line.clear(); - let read = reader.read_until(b'\n', &mut line)?; - if read == 0 { - break; - } + while read_provider_jsonl_line(&mut reader, &mut line)? { line_number += 1; + let read = line.len(); completed_bytes = completed_bytes.saturating_add(read as u64); if line.iter().all(u8::is_ascii_whitespace) { continue; @@ -1873,19 +2278,31 @@ pub fn import_codex_session_jsonl_tail( { continue; } - let occurred_at = value - .get("timestamp") - .and_then(Value::as_str) - .and_then(parse_rfc3339_utc) - .unwrap_or(header.timestamp); + let occurred_at = match codex_session_line_timestamp(&value, header.timestamp) { + Ok(occurred_at) => occurred_at, + Err(err) => { + summary.failed += 1; + summary.failures.push(ProviderImportFailure { + line: line_number, + error: err.to_string(), + }); + if !options.allow_partial_failures { + return Ok(summary); + } + continue; + } + }; let mut line_capture = codex_session_line_capture( &header, &value, - line_number, - occurred_at, &mut call_contexts, - options.tool_output_mode, - options.event_mode, + CodexSessionLineContext { + line_number, + occurred_at, + tool_output_mode: options.tool_output_mode, + event_mode: options.event_mode, + raw_source_path: raw_source_path.as_deref(), + }, ); if let Some(event) = line_capture.event.take() { if !options.include_notices && event.event_type == EventType::Notice { @@ -1899,6 +2316,7 @@ pub fn import_codex_session_jsonl_tail( options.history_record_id, line_number, context.imported_at, + raw_source_path.as_deref(), )?); } } @@ -2323,17 +2741,16 @@ fn import_codex_session_path_fast( wrap_transaction: false, fast_event_inserts: true, }; + let raw_source_path = context + .source_path + .as_ref() + .map(|path| path.display().to_string()); let mut header = None; let mut call_contexts: BTreeMap = BTreeMap::new(); let mut line_number = 0usize; let mut line = Vec::new(); - loop { - line.clear(); - let read = reader.read_until(b'\n', &mut line)?; - if read == 0 { - break; - } + while read_provider_jsonl_line(&mut reader, &mut line)? { line_number += 1; if line.iter().all(u8::is_ascii_whitespace) { continue; @@ -2411,19 +2828,31 @@ fn import_codex_session_path_fast( } continue; }; - let occurred_at = value - .get("timestamp") - .and_then(Value::as_str) - .and_then(parse_rfc3339_utc) - .unwrap_or(header.timestamp); + let occurred_at = match codex_session_line_timestamp(&value, header.timestamp) { + Ok(occurred_at) => occurred_at, + Err(err) => { + summary.failed += 1; + summary.failures.push(ProviderImportFailure { + line: line_number, + error: err.to_string(), + }); + if !options.allow_partial_failures { + return Ok(()); + } + continue; + } + }; let mut line_capture = codex_session_line_capture( header, &value, - line_number, - occurred_at, &mut call_contexts, - options.tool_output_mode, - options.event_mode, + CodexSessionLineContext { + line_number, + occurred_at, + tool_output_mode: options.tool_output_mode, + event_mode: options.event_mode, + raw_source_path: raw_source_path.as_deref(), + }, ); if let Some(event) = line_capture.event.take() { if !options.include_notices && event.event_type == EventType::Notice { @@ -2437,6 +2866,7 @@ fn import_codex_session_path_fast( options.history_record_id, line_number, context.imported_at, + raw_source_path.as_deref(), )?; summary.merge(line_summary); } @@ -2455,36 +2885,45 @@ fn import_codex_provider_event_fast( history_record_id: Option, line_number: usize, imported_at: DateTime, + raw_source_path: Option<&str>, ) -> Result { let mut summary = ProviderImportSummary::default(); let provider = CaptureProvider::Codex; let session_id = provider_session_uuid(provider, &header.id); - let source_id = provider_source_uuid(provider, &header.id); + let source_id = provider_scoped_source_uuid( + provider, + &header.id, + CODEX_SESSION_SOURCE_FORMAT, + raw_source_path, + ); let (payload, redacted_payload) = sanitize_value(event.payload.clone()); let (event_metadata, redacted_metadata) = sanitize_value(event.metadata.clone()); let event_hash = event .provider_event_hash .clone() .unwrap_or(compute_payload_hash(&payload)?); - let dedupe_key = Store::provider_event_dedupe_key( + let event_identity = provider_event_import_identity( + store, provider, &header.id, + source_id, event.provider_event_index, &event_hash, - ); + )?; let command_run = provider_command_run_from_event(ProviderCommandRunInput { provider, provider_session_id: &header.id, session_id, source_id, + run_source_id: event_identity.run_source_id, history_record_id, event, payload: &payload, event_hash: &event_hash, - }); + })?; let normalized_event = Event { - id: provider_event_uuid(provider, &header.id, event.provider_event_index), - seq: provider_event_seq(provider, &header.id, event.provider_event_index), + id: event_identity.id, + seq: event_identity.seq, history_record_id, session_id: Some(session_id), run_id: command_run.as_ref().map(|run| run.id), @@ -2502,7 +2941,7 @@ fn import_codex_provider_event_fast( "body": payload, }), payload_blob_id: None, - dedupe_key: Some(dedupe_key), + dedupe_key: Some(event_identity.dedupe_key), redaction_state: effective_event_redaction_state( event.redaction_state, redacted_payload || redacted_metadata, @@ -2876,17 +3315,18 @@ fn catalog_codex_session_file( }) } -fn read_codex_session_meta(path: &Path) -> std::io::Result> { +fn read_codex_session_meta(path: &Path) -> Result> { let file = File::open(path)?; - let reader = BufReader::new(file); - for line in reader.lines().take(32) { - let line = line?; - if !line.as_bytes().contains(&b'{') - || !contains_bytes(line.as_bytes(), br#""session_meta""#) - { + let mut reader = BufReader::new(file); + let mut line = Vec::new(); + for _ in 0..32 { + if !read_provider_jsonl_line(&mut reader, &mut line)? { + break; + } + if !line.contains(&b'{') || !contains_bytes(&line, br#""session_meta""#) { continue; } - let Ok(value) = serde_json::from_str::(&line) else { + let Ok(value) = serde_json::from_slice::(&line) else { continue; }; if value.get("type").and_then(Value::as_str) == Some("session_meta") { @@ -3076,10 +3516,10 @@ pub fn import_opencode_sqlite( ) } -pub fn import_antigravity_cli_history( +pub fn import_openclaw_history( path: impl AsRef, store: &mut Store, - options: AntigravityCliImportOptions, + options: OpenClawImportOptions, ) -> Result { import_native_jsonl_tree( store, @@ -3091,39 +3531,194 @@ pub fn import_antigravity_cli_history( history_record_id: options.history_record_id, allow_partial_failures: options.allow_partial_failures, }, - AntigravityCliJsonlAdapter, + OpenClawJsonlAdapter, ) } -pub fn import_gemini_cli_history( +pub fn import_hermes_sqlite( path: impl AsRef, store: &mut Store, - options: GeminiCliImportOptions, + options: HermesSqliteImportOptions, ) -> Result { - import_native_jsonl_tree( - store, - NativeJsonlTreeImport { - path: path.as_ref(), + let path = path.as_ref(); + let source_path = options + .source_path + .clone() + .unwrap_or_else(|| path.to_path_buf()); + let normalization = HermesSqliteAdapter.normalize_path( + path, + &ProviderAdapterContext { machine_id: options.machine_id, - source_path: options.source_path, + source_path: Some(source_path), imported_at: options.imported_at, + tool_output_mode: CodexToolOutputMode::Full, + event_mode: CodexEventImportMode::Rich, + include_notices: true, + }, + )?; + import_normalized_provider_captures( + store, + normalization, + NormalizedProviderImportOptions { history_record_id: options.history_record_id, allow_partial_failures: options.allow_partial_failures, + persist_cursors: true, + wrap_transaction: true, + fast_event_inserts: true, }, - GeminiCliJsonlAdapter, ) } -pub fn import_cursor_native_history( +pub fn import_nanoclaw_project( path: impl AsRef, store: &mut Store, - options: CursorNativeImportOptions, + options: NanoClawImportOptions, ) -> Result { - import_native_jsonl_tree( - store, - NativeJsonlTreeImport { - path: path.as_ref(), - machine_id: options.machine_id, + let path = path.as_ref(); + let source_path = options + .source_path + .clone() + .unwrap_or_else(|| path.to_path_buf()); + let normalization = NanoClawProjectAdapter.normalize_path( + path, + &ProviderAdapterContext { + machine_id: options.machine_id, + source_path: Some(source_path), + imported_at: options.imported_at, + tool_output_mode: CodexToolOutputMode::Full, + event_mode: CodexEventImportMode::Rich, + include_notices: true, + }, + )?; + import_normalized_provider_captures( + store, + normalization, + NormalizedProviderImportOptions { + history_record_id: options.history_record_id, + allow_partial_failures: options.allow_partial_failures, + persist_cursors: true, + wrap_transaction: true, + fast_event_inserts: true, + }, + ) +} + +pub fn import_astrbot_sqlite( + path: impl AsRef, + store: &mut Store, + options: AstrBotSqliteImportOptions, +) -> Result { + let path = path.as_ref(); + let source_path = options + .source_path + .clone() + .unwrap_or_else(|| path.to_path_buf()); + let normalization = AstrBotSqliteAdapter.normalize_path( + path, + &ProviderAdapterContext { + machine_id: options.machine_id, + source_path: Some(source_path), + imported_at: options.imported_at, + tool_output_mode: CodexToolOutputMode::Full, + event_mode: CodexEventImportMode::Rich, + include_notices: true, + }, + )?; + import_normalized_provider_captures( + store, + normalization, + NormalizedProviderImportOptions { + history_record_id: options.history_record_id, + allow_partial_failures: options.allow_partial_failures, + persist_cursors: true, + wrap_transaction: true, + fast_event_inserts: true, + }, + ) +} + +pub fn import_shelley_sqlite( + path: impl AsRef, + store: &mut Store, + options: ShelleySqliteImportOptions, +) -> Result { + let path = path.as_ref(); + let source_path = options + .source_path + .clone() + .unwrap_or_else(|| path.to_path_buf()); + let normalization = ShelleySqliteAdapter.normalize_path( + path, + &ProviderAdapterContext { + machine_id: options.machine_id, + source_path: Some(source_path), + imported_at: options.imported_at, + tool_output_mode: CodexToolOutputMode::Full, + event_mode: CodexEventImportMode::Rich, + include_notices: true, + }, + )?; + import_normalized_provider_captures( + store, + normalization, + NormalizedProviderImportOptions { + history_record_id: options.history_record_id, + allow_partial_failures: options.allow_partial_failures, + persist_cursors: true, + wrap_transaction: true, + fast_event_inserts: true, + }, + ) +} + +pub fn import_antigravity_cli_history( + path: impl AsRef, + store: &mut Store, + options: AntigravityCliImportOptions, +) -> Result { + import_native_jsonl_tree( + store, + NativeJsonlTreeImport { + path: path.as_ref(), + machine_id: options.machine_id, + source_path: options.source_path, + imported_at: options.imported_at, + history_record_id: options.history_record_id, + allow_partial_failures: options.allow_partial_failures, + }, + AntigravityCliJsonlAdapter, + ) +} + +pub fn import_gemini_cli_history( + path: impl AsRef, + store: &mut Store, + options: GeminiCliImportOptions, +) -> Result { + import_native_jsonl_tree( + store, + NativeJsonlTreeImport { + path: path.as_ref(), + machine_id: options.machine_id, + source_path: options.source_path, + imported_at: options.imported_at, + history_record_id: options.history_record_id, + allow_partial_failures: options.allow_partial_failures, + }, + GeminiCliJsonlAdapter, + ) +} + +pub fn import_cursor_native_history( + path: impl AsRef, + store: &mut Store, + options: CursorNativeImportOptions, +) -> Result { + import_native_jsonl_tree( + store, + NativeJsonlTreeImport { + path: path.as_ref(), + machine_id: options.machine_id, source_path: options.source_path, imported_at: options.imported_at, history_record_id: options.history_record_id, @@ -3228,6 +3823,11 @@ pub fn import_normalized_provider_captures( const CODEX_SESSION_SOURCE_FORMAT: &str = "codex_session_jsonl"; const CLAUDE_PROJECTS_SOURCE_FORMAT: &str = "claude_projects_jsonl_tree"; const OPENCODE_SQLITE_SOURCE_FORMAT: &str = "opencode_sqlite"; +const OPENCLAW_SOURCE_FORMAT: &str = "openclaw_session_jsonl_tree"; +const HERMES_SQLITE_SOURCE_FORMAT: &str = "hermes_state_sqlite"; +const NANOCLAW_SOURCE_FORMAT: &str = "nanoclaw_project"; +const ASTRBOT_SQLITE_SOURCE_FORMAT: &str = "astrbot_data_v4_sqlite"; +const SHELLEY_SQLITE_SOURCE_FORMAT: &str = "shelley_sqlite"; const ANTIGRAVITY_CLI_SOURCE_FORMAT: &str = "antigravity_cli_transcript_jsonl_tree"; const GEMINI_CLI_SOURCE_FORMAT: &str = "gemini_cli_chat_recording_jsonl"; const CURSOR_AGENT_TRANSCRIPT_SOURCE_FORMAT: &str = "cursor_agent_transcript_jsonl"; @@ -3241,1103 +3841,2214 @@ const PROVIDER_MAX_PREVIEW_CHARS: usize = 4_000; const CODEX_FAST_IMPORT_TRANSACTION_FILES: usize = 512; const CODEX_FAST_IMPORT_PASSIVE_CHECKPOINT_MIN_BYTES: u64 = 2 * 1024 * 1024 * 1024; -fn collect_jsonl_paths(root: &Path, paths: &mut Vec) -> Result<()> { - let metadata = fs::symlink_metadata(root)?; - let file_type = metadata.file_type(); - if file_type.is_symlink() { - return Err(CaptureError::InvalidProviderTranscriptPath { - path: root.to_path_buf(), - reason: "symlinked provider transcript roots are rejected", - }); - } - if file_type.is_file() { - if root.extension().and_then(|ext| ext.to_str()) == Some("jsonl") { - ensure_regular_provider_transcript_file(root)?; - paths.push(root.to_path_buf()); - } - return Ok(()); - } - if !file_type.is_dir() { - return Ok(()); - } - for entry in fs::read_dir(root)? { - let entry = entry?; - let path = entry.path(); - let file_type = entry.file_type()?; - if file_type.is_dir() { - collect_jsonl_paths(&path, paths)?; - } else if path.extension().and_then(|ext| ext.to_str()) == Some("jsonl") { - ensure_regular_provider_transcript_file(&path)?; - paths.push(path); - } - } - Ok(()) +#[derive(Debug, Clone, Default)] +struct CustomHistoryJsonlV1NormalizationResult { + provider: ProviderNormalizationResult, + edges: Vec<(usize, CustomHistoryJsonlV1EdgeImport)>, + source_cursors: Vec, } -fn ensure_regular_provider_transcript_file(path: &Path) -> Result<()> { - let metadata = fs::symlink_metadata(path)?; - let file_type = metadata.file_type(); - if file_type.is_symlink() { - return Err(CaptureError::InvalidProviderTranscriptPath { - path: path.to_path_buf(), - reason: "symlinked provider transcript files are rejected", - }); - } - if !file_type.is_file() { - return Err(CaptureError::InvalidProviderTranscriptPath { - path: path.to_path_buf(), - reason: "provider transcript paths must be regular files", - }); - } - Ok(()) +#[derive(Debug, Clone)] +struct CustomHistoryJsonlV1SourceCursorImport { + machine_id: String, + checkpoint: ProviderCursorCheckpoint, } -fn parse_rfc3339_utc(value: &str) -> Option> { - DateTime::parse_from_rfc3339(value) - .ok() - .map(|time| time.with_timezone(&Utc)) +#[derive(Debug, Clone)] +struct CustomHistoryJsonlV1EdgeImport { + provider_key: String, + source_id: String, + source_format: String, + raw_source_path: Option, + from_provider_session_id: String, + to_provider_session_id: String, + edge_id: Option, + edge_type: SessionEdgeType, + confidence: Confidence, + occurred_at: DateTime, + fidelity: Fidelity, + metadata: Value, } -fn codex_session_header(value: Value) -> Result { - let payload = value - .get("payload") - .ok_or_else(|| CaptureError::InvalidPayload("codex session_meta missing payload".into()))?; - let id = payload - .get("id") - .and_then(Value::as_str) - .filter(|id| !id.trim().is_empty()) - .ok_or_else(|| CaptureError::InvalidPayload("codex session_meta missing id".into()))? - .to_owned(); - let timestamp = payload - .get("timestamp") - .and_then(Value::as_str) - .or_else(|| value.get("timestamp").and_then(Value::as_str)) - .and_then(parse_rfc3339_utc) - .ok_or_else(|| { - CaptureError::InvalidPayload("codex session_meta missing timestamp".into()) - })?; - let source = payload.get("source").cloned().unwrap_or(Value::Null); - let parent_session = source - .pointer("/subagent/thread_spawn/parent_thread_id") - .or_else(|| source.pointer("/thread_spawn/parent_thread_id")) - .or_else(|| source.get("parent_thread_id")) - .and_then(Value::as_str) - .filter(|id| !id.trim().is_empty()) - .map(str::to_owned); +fn normalize_custom_history_jsonl_v1( + path: &Path, + context: &ProviderAdapterContext, +) -> Result { + ensure_regular_provider_transcript_file(path)?; + let file = File::open(path)?; + let reader = BufReader::new(file); + normalize_custom_history_jsonl_v1_reader(reader, context) +} - Ok(CodexSessionHeader { - id, - timestamp, - cwd: payload - .get("cwd") - .and_then(Value::as_str) - .map(str::to_owned), - originator: payload - .get("originator") - .and_then(Value::as_str) - .map(str::to_owned), - cli_version: payload - .get("cli_version") - .and_then(Value::as_str) - .map(str::to_owned), - source, - parent_session, - agent_nickname: payload - .get("agent_nickname") - .and_then(Value::as_str) - .map(str::to_owned), - agent_role: payload - .get("agent_role") - .and_then(Value::as_str) - .map(str::to_owned), - model_provider: payload - .get("model_provider") - .and_then(Value::as_str) - .map(str::to_owned), - raw: value, +fn normalize_custom_history_jsonl_v1_reader( + reader: impl BufRead, + context: &ProviderAdapterContext, +) -> Result { + let mut reader = reader; + let mut summary = ProviderImportSummary::default(); + let mut records = Vec::new(); + let mut line = Vec::new(); + let mut line_number = 0usize; + + while read_provider_jsonl_line(&mut reader, &mut line)? { + line_number += 1; + if line.iter().all(u8::is_ascii_whitespace) { + continue; + } + match serde_json::from_slice::(&line) { + Ok(record) => records.push((line_number, record)), + Err(err) => push_provider_import_failure(&mut summary, line_number, err.to_string()), + } + } + + if summary.failed > 0 { + return Ok(custom_history_failed_normalization(summary)); + } + + let mut manifest_line = None; + let mut sources = BTreeMap::::new(); + let mut sessions = BTreeMap::<(String, String), (usize, CtxHistoryJsonlSessionRecord)>::new(); + let mut events = Vec::<(usize, CtxHistoryJsonlEventRecord)>::new(); + let mut event_keys = BTreeSet::<(String, String, u64)>::new(); + let mut file_touches = Vec::<(usize, CtxHistoryJsonlFileTouchRecord)>::new(); + let mut touch_keys = BTreeSet::<(String, String, u64)>::new(); + let mut edges = Vec::<(usize, CtxHistoryJsonlEdgeRecord)>::new(); + let mut edge_keys = BTreeSet::<(String, String, String, String)>::new(); + + for (line_number, record) in records { + match record { + CtxHistoryJsonlRecord::Manifest(manifest) => { + if manifest.schema_version != CTX_HISTORY_JSONL_V1_SCHEMA_VERSION { + push_provider_import_failure( + &mut summary, + line_number, + format!( + "unsupported custom history schema version `{}`", + manifest.schema_version + ), + ); + } + if manifest_line.replace(line_number).is_some() { + push_provider_import_failure( + &mut summary, + line_number, + "duplicate manifest record".to_owned(), + ); + } + } + CtxHistoryJsonlRecord::Source(source) => { + validate_custom_source_record(&mut summary, line_number, &source); + if sources + .insert(source.source_id.clone(), (line_number, source)) + .is_some() + { + push_provider_import_failure( + &mut summary, + line_number, + "duplicate source_id".to_owned(), + ); + } + } + CtxHistoryJsonlRecord::Session(session) => { + validate_custom_history_identifier( + &mut summary, + line_number, + "source_id", + &session.source_id, + ); + validate_custom_history_identifier( + &mut summary, + line_number, + "session_id", + &session.session_id, + ); + let key = (session.source_id.clone(), session.session_id.clone()); + if sessions.insert(key, (line_number, session)).is_some() { + push_provider_import_failure( + &mut summary, + line_number, + "duplicate session record".to_owned(), + ); + } + } + CtxHistoryJsonlRecord::Event(event) => { + validate_custom_history_identifier( + &mut summary, + line_number, + "source_id", + &event.source_id, + ); + validate_custom_history_identifier( + &mut summary, + line_number, + "session_id", + &event.session_id, + ); + let key = ( + event.source_id.clone(), + event.session_id.clone(), + event.event_index, + ); + if !event_keys.insert(key) { + push_provider_import_failure( + &mut summary, + line_number, + "duplicate event_index for session".to_owned(), + ); + } + events.push((line_number, event)); + } + CtxHistoryJsonlRecord::FileTouch(file_touch) => { + validate_custom_history_identifier( + &mut summary, + line_number, + "source_id", + &file_touch.source_id, + ); + validate_custom_history_identifier( + &mut summary, + line_number, + "session_id", + &file_touch.session_id, + ); + if file_touch.path.trim().is_empty() { + push_provider_import_failure( + &mut summary, + line_number, + "file_touch path must not be empty".to_owned(), + ); + } + let key = ( + file_touch.source_id.clone(), + file_touch.session_id.clone(), + file_touch.touch_index, + ); + if !touch_keys.insert(key) { + push_provider_import_failure( + &mut summary, + line_number, + "duplicate touch_index for session".to_owned(), + ); + } + file_touches.push((line_number, file_touch)); + } + CtxHistoryJsonlRecord::Edge(edge) => { + validate_custom_history_identifier( + &mut summary, + line_number, + "source_id", + &edge.source_id, + ); + validate_custom_history_identifier( + &mut summary, + line_number, + "from_session_id", + &edge.from_session_id, + ); + validate_custom_history_identifier( + &mut summary, + line_number, + "to_session_id", + &edge.to_session_id, + ); + let edge_key = edge.edge_id.clone().unwrap_or_else(|| { + format!( + "{}:{}:{}", + edge.from_session_id, + edge.to_session_id, + edge.edge_type.as_str() + ) + }); + let key = ( + edge.source_id.clone(), + edge.from_session_id.clone(), + edge.to_session_id.clone(), + edge_key, + ); + if !edge_keys.insert(key) { + push_provider_import_failure( + &mut summary, + line_number, + "duplicate edge record".to_owned(), + ); + } + edges.push((line_number, edge)); + } + } + } + + let reference_index = CustomHistoryReferenceIndex { + manifest_line, + sources: &sources, + sessions: &sessions, + events: &events, + event_keys: &event_keys, + file_touches: &file_touches, + edges: &edges, + }; + validate_custom_history_references(&mut summary, reference_index); + if summary.failed > 0 { + return Ok(custom_history_failed_normalization(summary)); + } + + let mut result = ProviderNormalizationResult { + summary, + ..ProviderNormalizationResult::default() + }; + let mut source_cursors = Vec::new(); + for (_, source) in sources.values() { + let machine_id = source + .machine_id + .clone() + .unwrap_or_else(|| context.machine_id.clone()); + if let Some(after) = source + .cursor + .as_ref() + .and_then(|cursor| custom_history_normalized_cursor_range(source, cursor).after) + { + source_cursors.push(CustomHistoryJsonlV1SourceCursorImport { + machine_id, + checkpoint: after, + }); + } + } + for (line_number, session) in sessions.values() { + let source = &sources + .get(&session.source_id) + .expect("session source already validated") + .1; + result.captures.push(( + *line_number, + custom_history_session_capture(source, session, None, context), + )); + } + for (line_number, event) in events { + let (_, session) = sessions + .get(&(event.source_id.clone(), event.session_id.clone())) + .expect("event session already validated"); + let source = &sources + .get(&event.source_id) + .expect("event source already validated") + .1; + let envelope = custom_history_event_envelope(source, &event); + result.captures.push(( + line_number, + custom_history_session_capture(source, session, Some(envelope), context), + )); + } + for (line_number, file_touch) in file_touches { + let source = &sources + .get(&file_touch.source_id) + .expect("file_touch source already validated") + .1; + result.files_touched.push(( + line_number, + custom_history_file_touch_envelope(source, &file_touch, context), + )); + } + + let mut custom_edges = Vec::new(); + for (line_number, edge) in edges { + let source = &sources + .get(&edge.source_id) + .expect("edge source already validated") + .1; + custom_edges.push(( + line_number, + custom_history_edge_import(source, &edge, context), + )); + } + + Ok(CustomHistoryJsonlV1NormalizationResult { + provider: result, + edges: custom_edges, + source_cursors, }) } -fn codex_session_capture( - header: &CodexSessionHeader, - event: Option, - line_number: usize, - occurred_at: DateTime, - context: &ProviderAdapterContext, -) -> ProviderCaptureEnvelope { - let cursor = Some(ProviderCursorRange { - before: None, - after: Some(ProviderCursorCheckpoint { - stream: provider_cursor_stream(CaptureProvider::Codex, CODEX_SESSION_SOURCE_FORMAT), - cursor: format!("line:{line_number}"), - observed_at: occurred_at, - }), - }); - let is_subagent = header.parent_session.is_some(); - let role_hint = header - .agent_role - .clone() - .or_else(|| is_subagent.then(|| "subagent".to_owned())) - .or_else(|| Some("primary".to_owned())); - - ProviderCaptureEnvelope { - schema_version: PROVIDER_CAPTURE_ENVELOPE_SCHEMA_VERSION, - provider: CaptureProvider::Codex, - source: ProviderSourceEnvelope { - source_format: CODEX_SESSION_SOURCE_FORMAT.to_owned(), - machine_id: context.machine_id.clone(), - observed_at: context.imported_at, - raw_source_path: context - .source_path - .as_ref() - .map(|path| path.display().to_string()), - raw_retention: ProviderRawRetention::PathReference, - redaction_boundary: ProviderRedactionBoundary::BeforeExport, - trust: ProviderSourceTrust::ProviderExport, - fidelity: Fidelity::Imported, - cursor, - idempotency_key: Some(format!( - "provider-source:codex:{CODEX_SESSION_SOURCE_FORMAT}:{}", - header.id - )), - metadata: json!({ - "adapter": CODEX_SESSION_SOURCE_FORMAT, - "source_fidelity": "codex_rollout_jsonl", - }), - }, - session: ProviderSessionEnvelope { - provider_session_id: header.id.clone(), - parent_provider_session_id: header.parent_session.clone(), - root_provider_session_id: header.parent_session.clone(), - external_agent_id: header.agent_nickname.clone(), - agent_type: if is_subagent { - AgentType::Subagent - } else { - AgentType::Primary - }, - role_hint, - is_primary: !is_subagent, - status: SessionStatus::Imported, - started_at: header.timestamp, - ended_at: None, - cwd: header.cwd.clone(), - fidelity: Fidelity::Imported, - idempotency_key: Some(format!("provider-session:codex:{}", header.id)), - artifacts: Vec::new(), - metadata: json!({ - "source_format": CODEX_SESSION_SOURCE_FORMAT, - "source_fidelity": "codex_rollout_jsonl", - "originator": header.originator, - "cli_version": header.cli_version, - "source": header.source, - "agent_nickname": header.agent_nickname, - "agent_role": header.agent_role, - "model_provider": header.model_provider, - "parent_session": header.parent_session, - "raw_session_meta_keys": header.raw.as_object().map(|object| object.keys().cloned().collect::>()), - "import_profile": match context.event_mode { - CodexEventImportMode::Search => "search", - CodexEventImportMode::Rich => "rich", - }, - "limitations": [ - "search profile indexes session metadata, user and assistant messages, compacted context summaries, and parent-child session edges where present", - "rich profile can additionally index tool call previews, command output previews, reasoning summaries, and lifecycle notices", - "full raw tool arguments, complete command output, encrypted reasoning content, bootstrap context, and binary artifacts remain in the raw transcript referenced by raw_source_path", - "previews are capped before local indexing/export" - ], - }), +fn custom_history_failed_normalization( + summary: ProviderImportSummary, +) -> CustomHistoryJsonlV1NormalizationResult { + CustomHistoryJsonlV1NormalizationResult { + provider: ProviderNormalizationResult { + summary, + ..ProviderNormalizationResult::default() }, - event, + edges: Vec::new(), + source_cursors: Vec::new(), } } -fn codex_session_line_capture( - header: &CodexSessionHeader, - value: &Value, +fn push_provider_import_failure(summary: &mut ProviderImportSummary, line: usize, error: String) { + summary.failed += 1; + summary.failures.push(ProviderImportFailure { line, error }); +} + +fn validate_custom_source_record( + summary: &mut ProviderImportSummary, line_number: usize, - occurred_at: DateTime, - call_contexts: &mut BTreeMap, - tool_output_mode: CodexToolOutputMode, - event_mode: CodexEventImportMode, -) -> CodexSessionLineCapture { - let event = codex_session_event( - value, + source: &CtxHistoryJsonlSourceRecord, +) { + validate_custom_history_identifier(summary, line_number, "source_id", &source.source_id); + validate_custom_history_identifier( + summary, line_number, - occurred_at, - call_contexts, - tool_output_mode, - event_mode, + "source_format", + &source.source_format, ); - let mut drafts = Vec::new(); - collect_patch_file_touches(value, &mut drafts); - if drafts.is_empty() - && (event - .as_ref() - .is_some_and(|event| event_type_supports_structured_file_touches(event.event_type)) - || codex_value_is_tool_call(value)) - { - collect_structured_file_touches(value, &mut drafts); - } - let files_touched = provider_file_touch_envelopes( - ProviderFileTouchEnvelopeContext { - provider: CaptureProvider::Codex, - provider_session_id: &header.id, - source_format: CODEX_SESSION_SOURCE_FORMAT, - occurred_at, - provider_event_index: event.as_ref().map(|event| event.provider_event_index), - provider_touch_base_index: (line_number as u64) << 16, + let valid = !source.provider_key.is_empty() + && source.provider_key.len() <= 128 + && source.provider_key.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-') + }) + && source + .provider_key + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()); + if !valid { + push_provider_import_failure( + summary, line_number, - }, - drafts, - ); - CodexSessionLineCapture { - event, - files_touched, + "provider_key must be 1 to 128 bytes, start with a lowercase ASCII letter or digit, and use only lowercase ASCII letters, digits, '.', '_', or '-'".to_owned(), + ); } } -fn codex_value_is_tool_call(value: &Value) -> bool { - value.get("type").and_then(Value::as_str) == Some("response_item") - && matches!( - value - .get("payload") - .and_then(|payload| payload.get("type")) - .and_then(Value::as_str), - Some("function_call" | "custom_tool_call") - ) +fn validate_custom_history_identifier( + summary: &mut ProviderImportSummary, + line_number: usize, + field: &str, + value: &str, +) { + let error = if value.trim().is_empty() { + Some(format!("{field} must not be empty")) + } else if value.len() > 512 { + Some(format!("{field} must be at most 512 bytes")) + } else if value.chars().any(char::is_control) { + Some(format!("{field} must not contain control characters")) + } else { + None + }; + if let Some(error) = error { + push_provider_import_failure(summary, line_number, error); + } } -fn codex_session_event( - value: &Value, - line_number: usize, - occurred_at: DateTime, - call_contexts: &mut BTreeMap, - tool_output_mode: CodexToolOutputMode, - event_mode: CodexEventImportMode, -) -> Option { - let entry_type = value - .get("type") - .and_then(Value::as_str) - .unwrap_or("unknown"); - match entry_type { - "response_item" => { - let payload = value.get("payload")?; - codex_response_item_event( - payload, - line_number, - occurred_at, - call_contexts, - tool_output_mode, - event_mode, - ) +struct CustomHistoryReferenceIndex<'a> { + manifest_line: Option, + sources: &'a BTreeMap, + sessions: &'a BTreeMap<(String, String), (usize, CtxHistoryJsonlSessionRecord)>, + events: &'a [(usize, CtxHistoryJsonlEventRecord)], + event_keys: &'a BTreeSet<(String, String, u64)>, + file_touches: &'a [(usize, CtxHistoryJsonlFileTouchRecord)], + edges: &'a [(usize, CtxHistoryJsonlEdgeRecord)], +} + +fn validate_custom_history_references( + summary: &mut ProviderImportSummary, + references: CustomHistoryReferenceIndex<'_>, +) { + if references.manifest_line.is_none() { + push_provider_import_failure( + summary, + 0, + "missing manifest record for ctx-history-jsonl-v1".to_owned(), + ); + } + + for (line_number, session) in references.sessions.values() { + if !references.sources.contains_key(&session.source_id) { + push_provider_import_failure( + summary, + *line_number, + format!( + "session references unknown source_id `{}`", + session.source_id + ), + ); } - "compacted" => { - let text = value - .get("payload") - .and_then(codex_json_text) - .unwrap_or_else(|| "context compacted".to_owned()); - let (text, truncated) = codex_safe_preview(&text, CODEX_MAX_TEXT_CHARS); - Some(codex_provider_event( - line_number, - occurred_at, - EventType::Summary, - Some(EventRole::System), - json!({ - "entry_type": entry_type, - "text": text, - "truncated": truncated, - }), - json!({ - "source": "codex_session", - "source_format": CODEX_SESSION_SOURCE_FORMAT, - "line": line_number, - "entry_type": entry_type, - }), - )) + if let Some(parent) = &session.parent_session_id { + let key = (session.source_id.clone(), parent.clone()); + if !references.sessions.contains_key(&key) { + push_provider_import_failure( + summary, + *line_number, + format!("session references unknown parent_session_id `{parent}`"), + ); + } } - "event_msg" => { - if event_mode == CodexEventImportMode::Search { - return None; + if let Some(root) = &session.root_session_id { + let key = (session.source_id.clone(), root.clone()); + if root != &session.session_id && !references.sessions.contains_key(&key) { + push_provider_import_failure( + summary, + *line_number, + format!("session references unknown root_session_id `{root}`"), + ); } - let payload = value.get("payload")?; - let msg_type = payload - .get("type") - .and_then(Value::as_str) - .unwrap_or("unknown"); - if matches!( - msg_type, - "task_started" - | "task_complete" - | "turn_aborted" - | "context_compacted" - | "token_count" - | "patch_apply_end" - | "web_search_end" - ) { - let body = codex_lifecycle_body(payload, msg_type); - Some(codex_provider_event( - line_number, - occurred_at, - EventType::Notice, - Some(EventRole::System), - json!({ - "entry_type": entry_type, - "event_msg_type": msg_type, - "body": body, - }), - json!({ - "source": "codex_session", - "source_format": CODEX_SESSION_SOURCE_FORMAT, - "line": line_number, - "entry_type": entry_type, - "event_msg_type": msg_type, - }), - )) - } else { - None + } + } + + for (line_number, event) in references.events { + if !references + .sessions + .contains_key(&(event.source_id.clone(), event.session_id.clone())) + { + push_provider_import_failure( + summary, + *line_number, + format!( + "event references unknown session `{}` in source `{}`", + event.session_id, event.source_id + ), + ); + } + } + + for (line_number, file_touch) in references.file_touches { + if !references + .sessions + .contains_key(&(file_touch.source_id.clone(), file_touch.session_id.clone())) + { + push_provider_import_failure( + summary, + *line_number, + format!( + "file_touch references unknown session `{}` in source `{}`", + file_touch.session_id, file_touch.source_id + ), + ); + } + if let Some(event_index) = file_touch.event_index { + let key = ( + file_touch.source_id.clone(), + file_touch.session_id.clone(), + event_index, + ); + if !references.event_keys.contains(&key) { + push_provider_import_failure( + summary, + *line_number, + format!("file_touch references unknown event_index `{event_index}`"), + ); } } - _ => None, } -} -fn codex_response_item_event( - payload: &Value, - line_number: usize, - occurred_at: DateTime, - call_contexts: &mut BTreeMap, - tool_output_mode: CodexToolOutputMode, - event_mode: CodexEventImportMode, -) -> Option { - let item_type = payload - .get("type") - .and_then(Value::as_str) - .unwrap_or("unknown"); - match item_type { - "message" => codex_message_event(payload, line_number, occurred_at), - _ if event_mode == CodexEventImportMode::Search => None, - "function_call" | "custom_tool_call" | "web_search_call" | "tool_search_call" => { - codex_tool_call_event(payload, line_number, occurred_at, call_contexts) + for (line_number, edge) in references.edges { + let from_key = (edge.source_id.clone(), edge.from_session_id.clone()); + let to_key = (edge.source_id.clone(), edge.to_session_id.clone()); + if !references.sessions.contains_key(&from_key) { + push_provider_import_failure( + summary, + *line_number, + format!( + "edge references unknown from_session_id `{}`", + edge.from_session_id + ), + ); } - "function_call_output" | "custom_tool_call_output" | "tool_search_output" => { - codex_tool_output_event( - payload, - line_number, - occurred_at, - call_contexts, - tool_output_mode, - ) + if !references.sessions.contains_key(&to_key) { + push_provider_import_failure( + summary, + *line_number, + format!( + "edge references unknown to_session_id `{}`", + edge.to_session_id + ), + ); + } + if edge.edge_type == SessionEdgeType::ParentChild { + let Some((_, child)) = references.sessions.get(&to_key) else { + continue; + }; + if let Some(parent) = &child.parent_session_id { + if parent != &edge.from_session_id { + push_provider_import_failure( + summary, + *line_number, + format!( + "parent_child edge from_session_id `{}` conflicts with session parent_session_id `{parent}`", + edge.from_session_id + ), + ); + } + } } - "reasoning" => codex_reasoning_event(payload, line_number, occurred_at), - _ => Some(codex_provider_event( - line_number, - occurred_at, - EventType::Notice, - None, - json!({ - "item_type": item_type, - "body": codex_capped_json(payload, CODEX_MAX_METADATA_TEXT_CHARS), - }), - json!({ - "source": "codex_session", - "source_format": CODEX_SESSION_SOURCE_FORMAT, - "line": line_number, - "item_type": item_type, - }), - )), } } -fn codex_tool_call_event( - payload: &Value, - line_number: usize, - occurred_at: DateTime, - call_contexts: &mut BTreeMap, -) -> Option { - let item_type = payload - .get("type") - .and_then(Value::as_str) - .unwrap_or("tool_call"); - let tool_name = codex_tool_name(payload, item_type); - let call_id = payload.get("call_id").and_then(Value::as_str); - let argument_value = payload - .get("arguments") - .or_else(|| payload.get("input")) - .or_else(|| payload.get("action")) - .or_else(|| payload.get("execution")); - let command_preview = codex_command_preview(&tool_name, argument_value); - let (arguments_preview, arguments_truncated) = argument_value - .map(|value| codex_value_preview(value, CODEX_MAX_METADATA_TEXT_CHARS)) - .unwrap_or_else(|| (String::new(), false)); - let text = command_preview +fn custom_history_session_capture( + source: &CtxHistoryJsonlSourceRecord, + session: &CtxHistoryJsonlSessionRecord, + event: Option, + context: &ProviderAdapterContext, +) -> ProviderCaptureEnvelope { + let provider_session_id = custom_history_internal_session_id( + &source.provider_key, + &source.source_id, + &session.session_id, + ); + let event_cursor = event.as_ref().and_then(|event| { + event.cursor.as_ref().map(|cursor| ProviderCursorRange { + before: None, + after: Some(ProviderCursorCheckpoint { + stream: custom_history_cursor_stream(source), + cursor: cursor.clone(), + observed_at: event.occurred_at, + }), + }) + }); + let source_cursor = source + .cursor .as_ref() - .map(|command| format!("{tool_name}: {command}")) - .unwrap_or_else(|| { - if arguments_preview.is_empty() { - format!("{tool_name} tool call") - } else { - format!("{tool_name}: {arguments_preview}") - } - }); - let (text, text_truncated) = codex_safe_preview(&text, CODEX_MAX_METADATA_TEXT_CHARS); - - if let Some(call_id) = call_id { - call_contexts.insert( - call_id.to_owned(), - CodexToolCallContext { - tool_name: tool_name.clone(), - command_preview: command_preview.clone(), - arguments_preview: (!arguments_preview.is_empty()) - .then_some(arguments_preview.clone()), + .map(|cursor| custom_history_normalized_cursor_range(source, cursor)) + .or(event_cursor); + ProviderCaptureEnvelope { + schema_version: PROVIDER_CAPTURE_ENVELOPE_SCHEMA_VERSION, + provider: CaptureProvider::Custom, + source: ProviderSourceEnvelope { + source_format: source.source_format.clone(), + machine_id: source + .machine_id + .clone() + .unwrap_or_else(|| context.machine_id.clone()), + observed_at: source.observed_at.unwrap_or(context.imported_at), + raw_source_path: custom_history_effective_raw_source_path(source, context), + raw_retention: source.raw_retention, + redaction_boundary: source.redaction_boundary, + trust: match source.trust { + ProviderSourceTrust::Unknown => ProviderSourceTrust::ProviderExport, + other => other, }, - ); + fidelity: source.fidelity, + cursor: source_cursor, + idempotency_key: Some(format!( + "ctx-history-jsonl-v1:{}:{}", + source.provider_key, source.source_id + )), + metadata: custom_history_metadata( + source.metadata.clone(), + json!({ + "provider_key": source.provider_key, + "source_id": source.source_id, + "source_format": source.source_format, + "raw_uri": source.raw_uri, + "raw_source_path": source.raw_source_path, + "fingerprint": source.fingerprint, + "importer_version": source.importer_version, + "cursor": source.cursor, + }), + ), + }, + session: ProviderSessionEnvelope { + provider_session_id, + parent_provider_session_id: session.parent_session_id.as_ref().map(|parent| { + custom_history_internal_session_id(&source.provider_key, &source.source_id, parent) + }), + root_provider_session_id: session.root_session_id.as_ref().map(|root| { + custom_history_internal_session_id(&source.provider_key, &source.source_id, root) + }), + external_agent_id: session.external_agent_id.clone(), + agent_type: session.agent_type, + role_hint: session.role_hint.clone(), + is_primary: session.is_primary, + status: session.status, + started_at: session.started_at, + ended_at: session.ended_at, + cwd: session.cwd.clone(), + fidelity: session.fidelity, + idempotency_key: session.idempotency_key.clone().or_else(|| { + Some(format!( + "ctx-history-jsonl-v1:{}:{}:{}", + source.provider_key, source.source_id, session.session_id + )) + }), + artifacts: session.artifacts.clone(), + metadata: custom_history_metadata( + session.metadata.clone(), + json!({ + "provider_key": source.provider_key, + "source_id": source.source_id, + "session_id": session.session_id, + "native_session_id": session.native_session_id, + "parent_session_id": session.parent_session_id, + "root_session_id": session.root_session_id, + }), + ), + }, + event, } +} - Some(codex_provider_event( - line_number, - occurred_at, - EventType::ToolCall, - Some(EventRole::Assistant), - json!({ - "item_type": item_type, - "tool": tool_name, - "name": tool_name, - "call_id": call_id, - "command": command_preview, - "arguments_preview": arguments_preview, - "arguments_truncated": arguments_truncated, - "text": text, - "truncated": text_truncated || arguments_truncated, - }), - json!({ - "source": "codex_session", - "source_format": CODEX_SESSION_SOURCE_FORMAT, - "line": line_number, - "item_type": item_type, - "tool": tool_name, - }), - )) -} - -fn codex_tool_output_event( - payload: &Value, - line_number: usize, - occurred_at: DateTime, - call_contexts: &BTreeMap, - tool_output_mode: CodexToolOutputMode, -) -> Option { - if tool_output_mode == CodexToolOutputMode::Skip { - return None; - } - let item_type = payload - .get("type") - .and_then(Value::as_str) - .unwrap_or("tool_output"); - let call_id = payload.get("call_id").and_then(Value::as_str); - let context = call_id.and_then(|call_id| call_contexts.get(call_id)); - let tool_name = context - .map(|context| context.tool_name.clone()) - .unwrap_or_else(|| codex_tool_name(payload, item_type)); - let output_value = payload - .get("output") - .or_else(|| payload.get("tools")) - .or_else(|| payload.get("result")); - let output_text = output_value.map(codex_output_text); - let command_preview = context.and_then(|context| context.command_preview.clone()); - let output_text_ref = output_text.as_deref(); - let exit_code = output_text_ref.and_then(codex_exit_code); - let duration_ms = output_text_ref.and_then(codex_wall_time_ms); - let output_bytes = output_text_ref.map(str::len).unwrap_or(0); - let timed_out = codex_timed_out(payload).unwrap_or(false); - if tool_output_mode == CodexToolOutputMode::Failures - && !timed_out - && !exit_code.is_some_and(|code| code != 0) - { - return None; - } - let event_type = if codex_is_command_tool(&tool_name) { - EventType::CommandOutput - } else { - EventType::ToolOutput - }; - let keep_preview = tool_output_mode == CodexToolOutputMode::Full - || timed_out - || exit_code.is_some_and(|code| code != 0); - let preview_limit = if tool_output_mode == CodexToolOutputMode::Full { - CODEX_MAX_OUTPUT_PREVIEW_CHARS - } else { - 512 - }; - let (output_preview, output_truncated) = if keep_preview { - output_text_ref - .map(|text| codex_safe_preview(text, preview_limit)) - .unwrap_or_else(|| (String::new(), false)) +fn custom_history_event_envelope( + source: &CtxHistoryJsonlSourceRecord, + event: &CtxHistoryJsonlEventRecord, +) -> ProviderEventEnvelope { + let payload = if let Some(preview) = &event.preview { + json!({ "text": preview }) } else { - (String::new(), output_bytes > 0) - }; - let text = match tool_output_mode { - CodexToolOutputMode::Full => { - if let Some(command) = command_preview.as_deref() { - format!("{tool_name} output for `{command}`: {output_preview}") - } else { - format!("{tool_name} output: {output_preview}") - } - } - CodexToolOutputMode::Metadata - | CodexToolOutputMode::Failures - | CodexToolOutputMode::Skip => { - let command = command_preview - .as_deref() - .map(|command| format!(" for `{command}`")) - .unwrap_or_default(); - let status = exit_code - .map(|code| format!("exit_code={code}")) - .unwrap_or_else(|| "exit_code=unknown".to_owned()); - let duration = duration_ms - .map(|ms| format!(", duration_ms={ms}")) - .unwrap_or_default(); - let timeout = if timed_out { ", timed_out=true" } else { "" }; - let preview = if output_preview.is_empty() { - String::new() - } else { - format!(": {output_preview}") - }; - format!("{tool_name} output{command}: {status}{duration}, output_bytes={output_bytes}{timeout}{preview}") - } + event.payload.clone() }; - let (text, text_truncated) = codex_safe_preview(&text, CODEX_MAX_OUTPUT_PREVIEW_CHARS); + let raw_payload = event + .preview + .as_ref() + .map(|_| event.payload.clone()) + .filter(|payload| payload != &json!({})); + ProviderEventEnvelope { + provider_event_index: event.event_index, + provider_event_hash: event.event_hash.clone(), + cursor: event.native_cursor.clone(), + event_type: event.event_type, + role: event.role, + occurred_at: event.occurred_at, + fidelity: event.fidelity, + redaction_state: event.redaction_state, + idempotency_key: event.idempotency_key.clone(), + artifacts: event.artifacts.clone(), + payload, + metadata: custom_history_metadata( + event.metadata.clone(), + json!({ + "provider_key": source.provider_key, + "source_id": event.source_id, + "session_id": event.session_id, + "event_id": event.event_id, + "native_cursor": event.native_cursor, + "preview": event.preview, + "raw_payload": raw_payload, + }), + ), + } +} - Some(codex_provider_event( - line_number, - occurred_at, - event_type, - Some(EventRole::Tool), - json!({ - "item_type": item_type, - "tool": tool_name, - "name": tool_name, - "call_id": call_id, - "command": command_preview, - "arguments_preview": context.and_then(|context| context.arguments_preview.clone()), - "output": if tool_output_mode == CodexToolOutputMode::Full { Some(output_preview.clone()) } else { None }, - "output_preview": output_preview, - "output_retention": if tool_output_mode == CodexToolOutputMode::Full { "preview" } else { "raw_transcript" }, - "output_bytes": output_bytes, - "output_truncated": output_truncated, - "exit_code": exit_code, - "duration_ms": duration_ms, - "timed_out": timed_out, - "text": text, - "truncated": text_truncated || output_truncated, - }), - json!({ - "source": "codex_session", - "source_format": CODEX_SESSION_SOURCE_FORMAT, - "line": line_number, - "item_type": item_type, - "tool": tool_name, - }), - )) +fn custom_history_file_touch_envelope( + source: &CtxHistoryJsonlSourceRecord, + file_touch: &CtxHistoryJsonlFileTouchRecord, + context: &ProviderAdapterContext, +) -> ProviderFileTouchedEnvelope { + ProviderFileTouchedEnvelope { + provider: CaptureProvider::Custom, + provider_session_id: custom_history_internal_session_id( + &source.provider_key, + &source.source_id, + &file_touch.session_id, + ), + provider_touch_index: file_touch.touch_index, + provider_event_index: file_touch.event_index, + raw_source_path: custom_history_effective_raw_source_path(source, context), + path: file_touch.path.clone(), + change_kind: file_touch.change_kind, + old_path: file_touch.old_path.clone(), + line_count_delta: file_touch.line_count_delta, + confidence: file_touch.confidence, + occurred_at: file_touch.occurred_at, + source_format: source.source_format.clone(), + metadata: custom_history_metadata( + file_touch.metadata.clone(), + json!({ + "provider_key": source.provider_key, + "source_id": file_touch.source_id, + "session_id": file_touch.session_id, + }), + ), + } } -fn codex_output_text(value: &Value) -> Cow<'_, str> { - match value { - Value::String(text) => Cow::Borrowed(text), - Value::Null => Cow::Borrowed(""), - other => Cow::Owned(serde_json::to_string(other).unwrap_or_else(|_| other.to_string())), +fn custom_history_edge_import( + source: &CtxHistoryJsonlSourceRecord, + edge: &CtxHistoryJsonlEdgeRecord, + context: &ProviderAdapterContext, +) -> CustomHistoryJsonlV1EdgeImport { + CustomHistoryJsonlV1EdgeImport { + provider_key: source.provider_key.clone(), + source_id: source.source_id.clone(), + source_format: source.source_format.clone(), + raw_source_path: custom_history_effective_raw_source_path(source, context), + from_provider_session_id: custom_history_internal_session_id( + &source.provider_key, + &source.source_id, + &edge.from_session_id, + ), + to_provider_session_id: custom_history_internal_session_id( + &source.provider_key, + &source.source_id, + &edge.to_session_id, + ), + edge_id: edge.edge_id.clone(), + edge_type: edge.edge_type, + confidence: edge.confidence, + occurred_at: edge.occurred_at.unwrap_or(context.imported_at), + fidelity: edge.fidelity, + metadata: custom_history_metadata( + edge.metadata.clone(), + json!({ + "provider_key": source.provider_key, + "source_id": edge.source_id, + "from_session_id": edge.from_session_id, + "to_session_id": edge.to_session_id, + "edge_id": edge.edge_id, + }), + ), } } -fn codex_reasoning_event( - payload: &Value, - line_number: usize, - occurred_at: DateTime, -) -> Option { - let summary = payload - .get("summary") - .and_then(codex_content_text) - .or_else(|| { - payload - .get("summary_text") - .and_then(Value::as_str) - .map(str::to_owned) - })?; - let (summary, truncated) = codex_safe_preview(&summary, CODEX_MAX_TEXT_CHARS); - Some(codex_provider_event( - line_number, - occurred_at, - EventType::Summary, - Some(EventRole::Assistant), - json!({ - "item_type": "reasoning", - "summary": summary, - "text": summary, - "truncated": truncated, - "encrypted_content_withheld": payload.get("encrypted_content").is_some(), - }), - json!({ - "source": "codex_session", - "source_format": CODEX_SESSION_SOURCE_FORMAT, - "line": line_number, - "item_type": "reasoning", - }), - )) +fn custom_history_effective_raw_source_path( + source: &CtxHistoryJsonlSourceRecord, + context: &ProviderAdapterContext, +) -> Option { + source.raw_source_path.clone().or_else(|| { + context + .source_path + .as_ref() + .map(|path| path.display().to_string()) + }) } -fn codex_message_event( - payload: &Value, - line_number: usize, - occurred_at: DateTime, -) -> Option { - let role_text = payload - .get("role") - .and_then(Value::as_str) - .unwrap_or("unknown"); - if matches!(role_text, "developer" | "system") { - return None; +fn custom_history_internal_session_id( + provider_key: &str, + source_id: &str, + session_id: &str, +) -> String { + let key = custom_history_key(json!({ + "schema": CTX_HISTORY_JSONL_V1_SCHEMA_VERSION, + "kind": "session", + "provider_key": provider_key, + "source_id": source_id, + "session_id": session_id, + })); + let id = stable_capture_uuid(&key, "custom-provider-session-id"); + format!("ctx-history-jsonl-v1-{id}") +} + +fn custom_history_cursor_stream(source: &CtxHistoryJsonlSourceRecord) -> String { + custom_history_jsonl_v1_cursor_stream( + &source.provider_key, + &source.source_id, + &source.source_format, + ) +} + +pub fn custom_history_jsonl_v1_cursor_stream( + provider_key: &str, + source_id: &str, + source_format: &str, +) -> String { + let key = custom_history_key(json!({ + "schema": CTX_HISTORY_JSONL_V1_SCHEMA_VERSION, + "kind": "cursor_stream", + "provider_key": provider_key, + "source_id": source_id, + "source_format": source_format, + })); + let stream_id = stable_capture_uuid(&key, "custom-cursor-stream"); + format!("provider:custom:{provider_key}:{stream_id}") +} + +fn custom_history_normalized_cursor_range( + source: &CtxHistoryJsonlSourceRecord, + cursor: &ProviderCursorRange, +) -> ProviderCursorRange { + ProviderCursorRange { + before: cursor + .before + .as_ref() + .map(|checkpoint| custom_history_normalized_cursor_checkpoint(source, checkpoint)), + after: cursor + .after + .as_ref() + .map(|checkpoint| custom_history_normalized_cursor_checkpoint(source, checkpoint)), } - let text = payload.get("content").and_then(codex_content_text)?; - let (text, truncated) = capped_text(&text, CODEX_MAX_TEXT_CHARS); - Some(codex_provider_event( - line_number, - occurred_at, - EventType::Message, - Some(codex_event_role(role_text)), - json!({ - "item_type": "message", - "message_role": role_text, - "phase": payload.get("phase").and_then(Value::as_str), - "text": text, - "truncated": truncated, - }), - json!({ - "source": "codex_session", - "source_format": CODEX_SESSION_SOURCE_FORMAT, - "import_scope": "fast_transcript_index", - "line": line_number, - "item_type": "message", - "message_role": role_text, - }), - )) } -fn codex_provider_event( - line_number: usize, - occurred_at: DateTime, - event_type: EventType, - role: Option, - payload: Value, - metadata: Value, -) -> ProviderEventEnvelope { - ProviderEventEnvelope { - provider_event_index: (line_number - 1) as u64, - provider_event_hash: None, - cursor: Some(format!("line:{line_number}")), - event_type, - role, - occurred_at, - fidelity: Fidelity::Imported, - redaction_state: RedactionState::SafePreview, - idempotency_key: Some(format!("provider-event:codex-session:{line_number}")), - artifacts: Vec::new(), - payload, - metadata, +fn custom_history_normalized_cursor_checkpoint( + source: &CtxHistoryJsonlSourceRecord, + checkpoint: &ProviderCursorCheckpoint, +) -> ProviderCursorCheckpoint { + ProviderCursorCheckpoint { + stream: custom_history_cursor_stream(source), + cursor: checkpoint.cursor.clone(), + observed_at: checkpoint.observed_at, } } -fn codex_lifecycle_body(payload: &Value, msg_type: &str) -> Value { - let preview = payload - .get("last_agent_message") - .or_else(|| payload.get("message")) - .or_else(|| payload.get("stdout")) - .or_else(|| payload.get("stderr")) - .and_then(codex_json_text) - .unwrap_or_else(|| format!("Codex lifecycle: {msg_type}")); - let (text, truncated) = codex_safe_preview(&preview, CODEX_MAX_METADATA_TEXT_CHARS); - json!({ - "text": text, - "event_msg_type": msg_type, - "status": payload.get("status").and_then(Value::as_str), - "success": payload.get("success").and_then(Value::as_bool), - "duration_ms": payload.get("duration_ms").and_then(Value::as_i64), - "time_to_first_token_ms": payload.get("time_to_first_token_ms").and_then(Value::as_i64), - "truncated": truncated, - }) -} - -fn codex_tool_name(payload: &Value, item_type: &str) -> String { - payload - .get("name") - .or_else(|| payload.get("tool")) - .and_then(Value::as_str) - .filter(|name| !name.trim().is_empty()) - .unwrap_or(item_type) - .to_owned() +fn custom_history_key(value: Value) -> String { + serde_json::to_string(&value).expect("custom history identity key is serializable") } -fn codex_is_command_tool(tool_name: &str) -> bool { - matches!(tool_name, "exec_command" | "shell" | "bash" | "command") +fn custom_history_metadata(base: Value, custom: Value) -> Value { + let mut map = match base { + Value::Object(map) => map, + Value::Null => serde_json::Map::new(), + other => { + let mut map = serde_json::Map::new(); + map.insert("metadata".to_owned(), other); + map + } + }; + map.insert("ctx_history_jsonl_v1".to_owned(), custom); + Value::Object(map) } -fn codex_command_preview(tool_name: &str, argument_value: Option<&Value>) -> Option { - if !codex_is_command_tool(tool_name) { - return None; +fn import_custom_history_edges( + store: &mut Store, + edges: &[(usize, CustomHistoryJsonlV1EdgeImport)], + history_record_id: Option, + allow_partial_failures: bool, + summary: &mut ProviderImportSummary, +) -> Result<()> { + if edges.is_empty() { + return Ok(()); } - let value = argument_value?; - let parsed = codex_parse_embedded_json(value).unwrap_or_else(|| value.clone()); - let command = parsed - .get("cmd") - .or_else(|| parsed.get("command")) - .or_else(|| parsed.get("shell_command")) - .and_then(Value::as_str) - .or_else(|| value.as_str())?; - Some(codex_safe_preview(command, CODEX_MAX_METADATA_TEXT_CHARS).0) -} - -fn codex_value_preview(value: &Value, max_chars: usize) -> (String, bool) { - let rendered = match value { - Value::String(text) => text.clone(), - Value::Null => String::new(), - _ => serde_json::to_string(value).unwrap_or_else(|_| value.to_string()), - }; - codex_safe_preview(&rendered, max_chars) -} -fn codex_safe_preview(value: &str, max_chars: usize) -> (String, bool) { - capped_text(value, max_chars) + store.begin_immediate_batch()?; + for (line_number, edge) in edges { + let edge_id = if edge.edge_type == SessionEdgeType::ParentChild { + provider_edge_uuid( + CaptureProvider::Custom, + &edge.to_provider_session_id, + "parent_child", + ) + } else { + let key = custom_history_key(json!({ + "schema": CTX_HISTORY_JSONL_V1_SCHEMA_VERSION, + "kind": "session_edge", + "provider_key": edge.provider_key, + "source_id": edge.source_id, + "from_provider_session_id": edge.from_provider_session_id, + "to_provider_session_id": edge.to_provider_session_id, + "edge_type": edge.edge_type.as_str(), + "edge_id": edge.edge_id, + })); + stable_capture_uuid(&key, "session-edge") + }; + let from_session_id = + provider_session_uuid(CaptureProvider::Custom, &edge.from_provider_session_id); + let to_session_id = + provider_session_uuid(CaptureProvider::Custom, &edge.to_provider_session_id); + let source_id = provider_scoped_source_uuid( + CaptureProvider::Custom, + &edge.to_provider_session_id, + &edge.source_format, + edge.raw_source_path.as_deref(), + ); + let mut exists_cache = BTreeMap::::new(); + if !provider_session_exists_cached(store, from_session_id, &mut exists_cache)? + || !provider_session_exists_cached(store, to_session_id, &mut exists_cache)? + { + push_provider_import_failure( + summary, + *line_number, + "edge endpoint session was not imported".to_owned(), + ); + if !allow_partial_failures { + let _ = store.rollback_batch(); + return Ok(()); + } + continue; + } + let was_present = store.session_edge_exists(edge_id)?; + let session_edge = SessionEdge { + id: edge_id, + from_session_id, + to_session_id, + edge_type: edge.edge_type, + confidence: edge.confidence, + source_id: Some(source_id), + timestamps: timestamps(edge.occurred_at), + sync: provider_sync_metadata( + edge.fidelity, + json!({ + "provider_key": edge.provider_key, + "source_id": edge.source_id, + "history_record_id": history_record_id, + "metadata": edge.metadata, + }), + ), + }; + store.upsert_session_edge(&session_edge)?; + if edge.edge_type == SessionEdgeType::ParentChild { + let mut child = store.get_session(to_session_id)?; + child.parent_session_id = Some(from_session_id); + if child.root_session_id.is_none() { + child.root_session_id = Some(from_session_id); + } + store.upsert_session(&child)?; + } + if was_present { + summary.skipped_edges += 1; + summary.skipped += 1; + } else { + summary.imported_edges += 1; + summary.imported += 1; + } + } + if let Err(err) = store.commit_batch() { + let _ = store.rollback_batch(); + return Err(err.into()); + } + Ok(()) } -fn codex_parse_embedded_json(value: &Value) -> Option { - match value { - Value::String(text) => serde_json::from_str::(text).ok(), - Value::Object(_) | Value::Array(_) => Some(value.clone()), - _ => None, +fn import_custom_history_source_cursors( + store: &mut Store, + cursors: &[CustomHistoryJsonlV1SourceCursorImport], +) -> Result<()> { + for cursor in cursors { + store.upsert_sync_cursor(&SyncCursor { + id: stable_capture_uuid( + &format!( + "provider-cursor:{}:{}:{}", + CaptureProvider::Custom.as_str(), + cursor.machine_id, + cursor.checkpoint.stream + ), + "provider-sync-cursor", + ), + team_id: None, + device_id: cursor.machine_id.clone(), + stream: cursor.checkpoint.stream.clone(), + cursor: cursor.checkpoint.cursor.clone(), + last_synced_at: Some(cursor.checkpoint.observed_at), + timestamps: timestamps(cursor.checkpoint.observed_at), + })?; } + Ok(()) } -fn codex_timed_out(payload: &Value) -> Option { - payload - .get("timed_out") - .and_then(Value::as_bool) - .or_else(|| { - payload - .get("output") - .and_then(codex_parse_embedded_json) - .and_then(|value| { - value - .get("timed_out") - .and_then(Value::as_bool) - .or_else(|| value.pointer("/status/timed_out").and_then(Value::as_bool)) - }) - }) +fn collect_jsonl_paths(root: &Path, paths: &mut Vec) -> Result<()> { + let metadata = fs::symlink_metadata(root)?; + let file_type = metadata.file_type(); + if file_type.is_symlink() { + return Err(CaptureError::InvalidProviderTranscriptPath { + path: root.to_path_buf(), + reason: "symlinked provider transcript roots are rejected", + }); + } + ensure_provider_path_parents_are_not_symlinks(root)?; + if file_type.is_file() { + if root.extension().and_then(|ext| ext.to_str()) == Some("jsonl") { + ensure_regular_provider_transcript_file(root)?; + paths.push(root.to_path_buf()); + } + return Ok(()); + } + if !file_type.is_dir() { + return Ok(()); + } + for entry in fs::read_dir(root)? { + let entry = entry?; + let path = entry.path(); + let file_type = entry.file_type()?; + if file_type.is_dir() { + collect_jsonl_paths(&path, paths)?; + } else if path.extension().and_then(|ext| ext.to_str()) == Some("jsonl") { + ensure_regular_provider_transcript_file(&path)?; + paths.push(path); + } + } + Ok(()) } -fn codex_exit_code(text: &str) -> Option { - let marker = "Process exited with code "; - let index = text.find(marker)? + marker.len(); - let tail = &text[index..]; - let digits = tail - .chars() - .take_while(|ch| ch.is_ascii_digit() || *ch == '-') - .collect::(); - digits.parse().ok() +fn ensure_regular_provider_transcript_file(path: &Path) -> Result<()> { + let metadata = fs::symlink_metadata(path)?; + let file_type = metadata.file_type(); + if file_type.is_symlink() { + return Err(CaptureError::InvalidProviderTranscriptPath { + path: path.to_path_buf(), + reason: "symlinked provider transcript files are rejected", + }); + } + if !file_type.is_file() { + return Err(CaptureError::InvalidProviderTranscriptPath { + path: path.to_path_buf(), + reason: "provider transcript paths must be regular files", + }); + } + ensure_provider_path_parents_are_not_symlinks(path)?; + Ok(()) } -fn codex_wall_time_ms(text: &str) -> Option { - let marker = "Wall time: "; - let index = text.find(marker)? + marker.len(); - let tail = &text[index..]; - let seconds_text = tail - .chars() - .take_while(|ch| ch.is_ascii_digit() || *ch == '.') - .collect::(); - let seconds = seconds_text.parse::().ok()?; - Some((seconds * 1000.0).round() as i64) +fn ensure_provider_path_parents_are_not_symlinks(path: &Path) -> Result<()> { + let parent_count = path.components().count().saturating_sub(1); + let mut current = PathBuf::new(); + for component in path.components().take(parent_count) { + current.push(component.as_os_str()); + if current.as_os_str().is_empty() { + continue; + } + let Ok(metadata) = fs::symlink_metadata(¤t) else { + continue; + }; + if metadata.file_type().is_symlink() { + return Err(CaptureError::InvalidProviderTranscriptPath { + path: path.to_path_buf(), + reason: "symlinked provider transcript path components are rejected", + }); + } + } + Ok(()) } -fn codex_event_role(role: &str) -> EventRole { - match role { - "user" => EventRole::User, - "assistant" => EventRole::Assistant, - "tool" => EventRole::Tool, - "system" | "developer" => EventRole::System, - _ => EventRole::Unknown, +fn read_text_file_limited(path: &Path, max_bytes: usize, label: &str) -> Result { + let file = File::open(path)?; + let mut reader = file.take((max_bytes as u64).saturating_add(1)); + let mut bytes = Vec::new(); + reader.read_to_end(&mut bytes)?; + if bytes.len() > max_bytes { + return Err(CaptureError::InvalidPayload(format!( + "{label} exceeds max bytes ({max_bytes})" + ))); } + String::from_utf8(bytes) + .map_err(|err| CaptureError::InvalidPayload(format!("{label} is not valid UTF-8: {err}"))) } -fn codex_content_text(value: &Value) -> Option { - match value { - Value::String(text) => Some(text.clone()), - Value::Array(blocks) => { - let mut parts = Vec::new(); - for block in blocks { - if let Some(text) = block - .get("text") - .or_else(|| block.get("input_text")) - .or_else(|| block.get("output_text")) - .or_else(|| block.get("summary_text")) - .and_then(Value::as_str) - { - parts.push(text.to_owned()); - continue; - } - if let Some(text) = block.get("content").and_then(Value::as_str) { - parts.push(text.to_owned()); - continue; - } - if let Some(kind) = block.get("type").and_then(Value::as_str) { - if matches!(kind, "tool_call" | "function_call" | "custom_tool_call") { - let name = block.get("name").and_then(Value::as_str).unwrap_or("tool"); - parts.push(format!("tool call: {name}")); - } - } - } - if parts.is_empty() { - None - } else { - Some(parts.join("\n")) +fn read_provider_jsonl_line(reader: &mut impl BufRead, buffer: &mut Vec) -> Result { + buffer.clear(); + let mut total = 0usize; + loop { + let available = reader.fill_buf()?; + if available.is_empty() { + return Ok(total > 0); + } + if let Some(newline_index) = available.iter().position(|byte| *byte == b'\n') { + let bytes_to_consume = newline_index + 1; + if total.saturating_add(bytes_to_consume) > MAX_PROVIDER_JSONL_LINE_BYTES { + reader.consume(bytes_to_consume); + return Err(provider_jsonl_line_too_large()); } + buffer.extend_from_slice(&available[..bytes_to_consume]); + reader.consume(bytes_to_consume); + return Ok(true); } - Value::Object(_) => codex_json_text(value), - _ => None, + + let bytes_to_consume = available.len(); + if total.saturating_add(bytes_to_consume) > MAX_PROVIDER_JSONL_LINE_BYTES { + reader.consume(bytes_to_consume); + discard_provider_jsonl_line(reader)?; + return Err(provider_jsonl_line_too_large()); + } + buffer.extend_from_slice(available); + reader.consume(bytes_to_consume); + total = total.saturating_add(bytes_to_consume); } } -fn codex_json_text(value: &Value) -> Option { - match value { - Value::Null => None, - Value::String(text) => Some(text.clone()), - Value::Array(_) | Value::Object(_) => serde_json::to_string(value).ok(), - _ => Some(value.to_string()), +fn discard_provider_jsonl_line(reader: &mut impl BufRead) -> Result<()> { + loop { + let available = reader.fill_buf()?; + if available.is_empty() { + return Ok(()); + } + let bytes_to_consume = available + .iter() + .position(|byte| *byte == b'\n') + .map(|index| index + 1) + .unwrap_or(available.len()); + let found_newline = available + .get(bytes_to_consume.saturating_sub(1)) + .is_some_and(|byte| *byte == b'\n'); + reader.consume(bytes_to_consume); + if found_newline { + return Ok(()); + } } } -fn codex_capped_json(value: &Value, max_chars: usize) -> Value { - match value { - Value::String(text) => { - let (text, truncated) = capped_text(text, max_chars); - json!({ "text": text, "truncated": truncated }) - } - _ => { - let rendered = serde_json::to_string(value).unwrap_or_else(|_| "null".to_owned()); - let (text, truncated) = capped_text(&rendered, max_chars); - json!({ "json": text, "truncated": truncated }) - } - } +fn provider_jsonl_line_too_large() -> CaptureError { + CaptureError::InvalidPayload(format!( + "provider JSONL line exceeds max bytes ({MAX_PROVIDER_JSONL_LINE_BYTES})" + )) } -fn capped_text(value: &str, max_chars: usize) -> (String, bool) { - let mut out = String::new(); - let mut truncated = false; - for (index, ch) in value.chars().enumerate() { - if index >= max_chars { - truncated = true; - break; - } - out.push(ch); - } - (out, truncated) +fn parse_rfc3339_utc(value: &str) -> Option> { + DateTime::parse_from_rfc3339(value) + .ok() + .map(|time| time.with_timezone(&Utc)) } -fn provider_safe_preview(value: &str, max_chars: usize) -> (String, bool) { - capped_text(value, max_chars) +fn parse_optional_rfc3339_field( + value: &Value, + field: &'static str, +) -> Result>> { + let Some(raw_value) = value.get(field) else { + return Ok(None); + }; + let raw = raw_value.as_str().ok_or_else(|| { + CaptureError::InvalidPayload(format!("{field} must be an RFC3339 string")) + })?; + parse_rfc3339_utc(raw) + .ok_or_else(|| { + CaptureError::InvalidPayload(format!("{field} is not a valid RFC3339 timestamp")) + }) + .map(Some) } -#[derive(Debug, Clone, PartialEq)] -struct FileTouchDraft { - path: String, - old_path: Option, - change_kind: Option, - confidence: Confidence, - metadata: Value, +fn codex_session_line_timestamp(value: &Value, fallback: DateTime) -> Result> { + Ok(parse_optional_rfc3339_field(value, "timestamp")?.unwrap_or(fallback)) } -fn provider_file_touches_from_event( - provider: CaptureProvider, - provider_session_id: &str, - source_format: &str, - event: &ProviderEventEnvelope, +fn codex_session_header(value: Value) -> Result { + let payload = value + .get("payload") + .ok_or_else(|| CaptureError::InvalidPayload("codex session_meta missing payload".into()))?; + let id = payload + .get("id") + .and_then(Value::as_str) + .filter(|id| !id.trim().is_empty()) + .ok_or_else(|| CaptureError::InvalidPayload("codex session_meta missing id".into()))? + .to_owned(); + let timestamp = payload + .get("timestamp") + .and_then(Value::as_str) + .or_else(|| value.get("timestamp").and_then(Value::as_str)) + .and_then(parse_rfc3339_utc) + .ok_or_else(|| { + CaptureError::InvalidPayload("codex session_meta missing timestamp".into()) + })?; + let source = payload.get("source").cloned().unwrap_or(Value::Null); + let parent_session = source + .pointer("/subagent/thread_spawn/parent_thread_id") + .or_else(|| source.pointer("/thread_spawn/parent_thread_id")) + .or_else(|| source.get("parent_thread_id")) + .and_then(Value::as_str) + .filter(|id| !id.trim().is_empty()) + .map(str::to_owned); + + Ok(CodexSessionHeader { + id, + timestamp, + cwd: payload + .get("cwd") + .and_then(Value::as_str) + .map(str::to_owned), + originator: payload + .get("originator") + .and_then(Value::as_str) + .map(str::to_owned), + cli_version: payload + .get("cli_version") + .and_then(Value::as_str) + .map(str::to_owned), + source, + parent_session, + agent_nickname: payload + .get("agent_nickname") + .and_then(Value::as_str) + .map(str::to_owned), + agent_role: payload + .get("agent_role") + .and_then(Value::as_str) + .map(str::to_owned), + model_provider: payload + .get("model_provider") + .and_then(Value::as_str) + .map(str::to_owned), + raw: value, + }) +} + +fn codex_session_capture( + header: &CodexSessionHeader, + event: Option, line_number: usize, -) -> Vec<(usize, ProviderFileTouchedEnvelope)> { - if !matches!( - event.event_type, - EventType::ToolCall - | EventType::ToolOutput - | EventType::CommandOutput - | EventType::FileTouched - ) { - return Vec::new(); + occurred_at: DateTime, + context: &ProviderAdapterContext, +) -> ProviderCaptureEnvelope { + let cursor = Some(ProviderCursorRange { + before: None, + after: Some(ProviderCursorCheckpoint { + stream: provider_cursor_stream(CaptureProvider::Codex, CODEX_SESSION_SOURCE_FORMAT), + cursor: format!("line:{line_number}"), + observed_at: occurred_at, + }), + }); + let is_subagent = header.parent_session.is_some(); + let role_hint = header + .agent_role + .clone() + .or_else(|| is_subagent.then(|| "subagent".to_owned())) + .or_else(|| Some("primary".to_owned())); + + ProviderCaptureEnvelope { + schema_version: PROVIDER_CAPTURE_ENVELOPE_SCHEMA_VERSION, + provider: CaptureProvider::Codex, + source: ProviderSourceEnvelope { + source_format: CODEX_SESSION_SOURCE_FORMAT.to_owned(), + machine_id: context.machine_id.clone(), + observed_at: context.imported_at, + raw_source_path: context + .source_path + .as_ref() + .map(|path| path.display().to_string()), + raw_retention: ProviderRawRetention::PathReference, + redaction_boundary: ProviderRedactionBoundary::BeforeExport, + trust: ProviderSourceTrust::ProviderExport, + fidelity: Fidelity::Imported, + cursor, + idempotency_key: Some(format!( + "provider-source:codex:{CODEX_SESSION_SOURCE_FORMAT}:{}", + header.id + )), + metadata: json!({ + "adapter": CODEX_SESSION_SOURCE_FORMAT, + "source_fidelity": "codex_rollout_jsonl", + }), + }, + session: ProviderSessionEnvelope { + provider_session_id: header.id.clone(), + parent_provider_session_id: header.parent_session.clone(), + root_provider_session_id: header.parent_session.clone(), + external_agent_id: header.agent_nickname.clone(), + agent_type: if is_subagent { + AgentType::Subagent + } else { + AgentType::Primary + }, + role_hint, + is_primary: !is_subagent, + status: SessionStatus::Imported, + started_at: header.timestamp, + ended_at: None, + cwd: header.cwd.clone(), + fidelity: Fidelity::Imported, + idempotency_key: Some(format!("provider-session:codex:{}", header.id)), + artifacts: Vec::new(), + metadata: json!({ + "source_format": CODEX_SESSION_SOURCE_FORMAT, + "source_fidelity": "codex_rollout_jsonl", + "originator": header.originator, + "cli_version": header.cli_version, + "source": header.source, + "agent_nickname": header.agent_nickname, + "agent_role": header.agent_role, + "model_provider": header.model_provider, + "parent_session": header.parent_session, + "raw_session_meta_keys": header.raw.as_object().map(|object| object.keys().cloned().collect::>()), + "import_profile": match context.event_mode { + CodexEventImportMode::Search => "search", + CodexEventImportMode::Rich => "rich", + }, + "limitations": [ + "search profile indexes session metadata, user and assistant messages, compacted context summaries, and parent-child session edges where present", + "rich profile can additionally index tool call previews, command output previews, reasoning summaries, and lifecycle notices", + "full raw tool arguments, complete command output, encrypted reasoning content, bootstrap context, and binary artifacts remain in the raw transcript referenced by raw_source_path", + "previews are capped before local indexing/export" + ], + }), + }, + event, } +} + +struct CodexSessionLineContext<'a> { + line_number: usize, + occurred_at: DateTime, + tool_output_mode: CodexToolOutputMode, + event_mode: CodexEventImportMode, + raw_source_path: Option<&'a str>, +} +fn codex_session_line_capture( + header: &CodexSessionHeader, + value: &Value, + call_contexts: &mut BTreeMap, + context: CodexSessionLineContext<'_>, +) -> CodexSessionLineCapture { + let CodexSessionLineContext { + line_number, + occurred_at, + tool_output_mode, + event_mode, + raw_source_path, + } = context; + let event = codex_session_event( + value, + line_number, + occurred_at, + call_contexts, + tool_output_mode, + event_mode, + ); let mut drafts = Vec::new(); - collect_patch_file_touches(&event.payload, &mut drafts); - if drafts.is_empty() && event_type_supports_structured_file_touches(event.event_type) { - collect_structured_file_touches(&event.payload, &mut drafts); + collect_patch_file_touches(value, &mut drafts); + if drafts.is_empty() + && (event + .as_ref() + .is_some_and(|event| event_type_supports_structured_file_touches(event.event_type)) + || codex_value_is_tool_call(value)) + { + collect_structured_file_touches(value, &mut drafts); } - - provider_file_touch_envelopes( + let files_touched = provider_file_touch_envelopes( ProviderFileTouchEnvelopeContext { - provider, - provider_session_id, - source_format, - occurred_at: event.occurred_at, - provider_event_index: Some(event.provider_event_index), - provider_touch_base_index: event.provider_event_index << 16, + provider: CaptureProvider::Codex, + provider_session_id: &header.id, + source_format: CODEX_SESSION_SOURCE_FORMAT, + raw_source_path, + occurred_at, + provider_event_index: event.as_ref().map(|event| event.provider_event_index), + provider_touch_base_index: (line_number as u64) << 16, line_number, }, drafts, - ) -} - -fn provider_file_touches_from_raw_value( - provider: CaptureProvider, - provider_session_id: &str, - source_format: &str, - raw_value: &Value, - event: &ProviderEventEnvelope, - line_number: usize, -) -> Vec<(usize, ProviderFileTouchedEnvelope)> { - if !matches!( - event.event_type, - EventType::ToolCall - | EventType::ToolOutput - | EventType::CommandOutput - | EventType::FileTouched - ) { - return Vec::new(); - } - - let mut drafts = Vec::new(); - collect_patch_file_touches(raw_value, &mut drafts); - if drafts.is_empty() && event_type_supports_structured_file_touches(event.event_type) { - collect_structured_file_touches(raw_value, &mut drafts); + ); + CodexSessionLineCapture { + event, + files_touched, } - - provider_file_touch_envelopes( - ProviderFileTouchEnvelopeContext { - provider, - provider_session_id, - source_format, - occurred_at: event.occurred_at, - provider_event_index: Some(event.provider_event_index), - provider_touch_base_index: event.provider_event_index << 16, - line_number, - }, - drafts, - ) } -fn event_type_supports_structured_file_touches(event_type: EventType) -> bool { - matches!(event_type, EventType::ToolCall | EventType::FileTouched) +fn codex_value_is_tool_call(value: &Value) -> bool { + value.get("type").and_then(Value::as_str) == Some("response_item") + && matches!( + value + .get("payload") + .and_then(|payload| payload.get("type")) + .and_then(Value::as_str), + Some("function_call" | "custom_tool_call") + ) } -struct ProviderFileTouchEnvelopeContext<'a> { - provider: CaptureProvider, - provider_session_id: &'a str, - source_format: &'a str, - occurred_at: DateTime, - provider_event_index: Option, - provider_touch_base_index: u64, +fn codex_session_event( + value: &Value, line_number: usize, -} - -fn provider_file_touch_envelopes( - context: ProviderFileTouchEnvelopeContext<'_>, - drafts: Vec, -) -> Vec<(usize, ProviderFileTouchedEnvelope)> { - let mut seen = BTreeSet::new(); - let mut out = Vec::new(); - for draft in drafts { - let key = ( - draft.path.clone(), - draft.old_path.clone(), - draft.change_kind.map(|kind| kind.as_str().to_owned()), - ); - if !seen.insert(key) { - continue; + occurred_at: DateTime, + call_contexts: &mut BTreeMap, + tool_output_mode: CodexToolOutputMode, + event_mode: CodexEventImportMode, +) -> Option { + let entry_type = value + .get("type") + .and_then(Value::as_str) + .unwrap_or("unknown"); + match entry_type { + "response_item" => { + let payload = value.get("payload")?; + codex_response_item_event( + payload, + line_number, + occurred_at, + call_contexts, + tool_output_mode, + event_mode, + ) } - let provider_touch_index = context.provider_touch_base_index | (out.len() as u64); - out.push(( - context.line_number, - ProviderFileTouchedEnvelope { - provider: context.provider, - provider_session_id: context.provider_session_id.to_owned(), - provider_touch_index, - provider_event_index: context.provider_event_index, - path: draft.path, - change_kind: draft.change_kind, - old_path: draft.old_path, - line_count_delta: None, - confidence: draft.confidence, - occurred_at: context.occurred_at, - source_format: context.source_format.to_owned(), - metadata: draft.metadata, - }, - )); - } - out -} - -fn collect_patch_file_touches(value: &Value, out: &mut Vec) { - match value { - Value::String(text) => { - if text.contains("*** Begin Patch") { - out.extend(parse_apply_patch_file_touches(text)); - } + "compacted" => { + let text = value + .get("payload") + .and_then(codex_json_text) + .unwrap_or_else(|| "context compacted".to_owned()); + let (text, truncated) = codex_local_preview(&text, CODEX_MAX_TEXT_CHARS); + Some(codex_provider_event( + line_number, + occurred_at, + EventType::Summary, + Some(EventRole::System), + json!({ + "entry_type": entry_type, + "text": text, + "truncated": truncated, + }), + json!({ + "source": "codex_session", + "source_format": CODEX_SESSION_SOURCE_FORMAT, + "line": line_number, + "entry_type": entry_type, + }), + )) } - Value::Array(items) => { - for item in items { - collect_patch_file_touches(item, out); + "event_msg" => { + if event_mode == CodexEventImportMode::Search { + return None; } - } - Value::Object(object) => { - for value in object.values() { - collect_patch_file_touches(value, out); + let payload = value.get("payload")?; + let msg_type = payload + .get("type") + .and_then(Value::as_str) + .unwrap_or("unknown"); + if matches!( + msg_type, + "task_started" + | "task_complete" + | "turn_aborted" + | "context_compacted" + | "token_count" + | "patch_apply_end" + | "web_search_end" + ) { + let body = codex_lifecycle_body(payload, msg_type); + Some(codex_provider_event( + line_number, + occurred_at, + EventType::Notice, + Some(EventRole::System), + json!({ + "entry_type": entry_type, + "event_msg_type": msg_type, + "body": body, + }), + json!({ + "source": "codex_session", + "source_format": CODEX_SESSION_SOURCE_FORMAT, + "line": line_number, + "entry_type": entry_type, + "event_msg_type": msg_type, + }), + )) + } else { + None } } - _ => {} + _ => None, } } -fn parse_apply_patch_file_touches(patch: &str) -> Vec { - let mut out = Vec::new(); - let mut pending_update: Option = None; - for line in patch.lines() { - if let Some(path) = line.strip_prefix("*** Add File: ") { - flush_pending_patch_update(&mut out, &mut pending_update); - if let Some(path) = normalize_file_path(path) { - out.push(file_touch_draft( - path, - None, - FileChangeKind::Created, - Confidence::Explicit, - "apply_patch_add", - )); - } - continue; - } - if let Some(path) = line.strip_prefix("*** Update File: ") { - flush_pending_patch_update(&mut out, &mut pending_update); - pending_update = normalize_file_path(path); - continue; +fn codex_response_item_event( + payload: &Value, + line_number: usize, + occurred_at: DateTime, + call_contexts: &mut BTreeMap, + tool_output_mode: CodexToolOutputMode, + event_mode: CodexEventImportMode, +) -> Option { + let item_type = payload + .get("type") + .and_then(Value::as_str) + .unwrap_or("unknown"); + match item_type { + "message" => codex_message_event(payload, line_number, occurred_at), + _ if event_mode == CodexEventImportMode::Search => None, + "function_call" | "custom_tool_call" | "web_search_call" | "tool_search_call" => { + codex_tool_call_event(payload, line_number, occurred_at, call_contexts) } - if let Some(path) = line.strip_prefix("*** Delete File: ") { - flush_pending_patch_update(&mut out, &mut pending_update); - if let Some(path) = normalize_file_path(path) { - out.push(file_touch_draft( - path, - None, - FileChangeKind::Deleted, - Confidence::Explicit, - "apply_patch_delete", - )); - } - continue; + "function_call_output" | "custom_tool_call_output" | "tool_search_output" => { + codex_tool_output_event( + payload, + line_number, + occurred_at, + call_contexts, + tool_output_mode, + ) } - if let Some(path) = line.strip_prefix("*** Move to: ") { - let old_path = pending_update.take(); - if let Some(path) = normalize_file_path(path) { - out.push(file_touch_draft( + "reasoning" => codex_reasoning_event(payload, line_number, occurred_at), + _ => Some(codex_provider_event( + line_number, + occurred_at, + EventType::Notice, + None, + json!({ + "item_type": item_type, + "body": codex_capped_json(payload, CODEX_MAX_METADATA_TEXT_CHARS), + }), + json!({ + "source": "codex_session", + "source_format": CODEX_SESSION_SOURCE_FORMAT, + "line": line_number, + "item_type": item_type, + }), + )), + } +} + +fn codex_tool_call_event( + payload: &Value, + line_number: usize, + occurred_at: DateTime, + call_contexts: &mut BTreeMap, +) -> Option { + let item_type = payload + .get("type") + .and_then(Value::as_str) + .unwrap_or("tool_call"); + let tool_name = codex_tool_name(payload, item_type); + let call_id = payload.get("call_id").and_then(Value::as_str); + let argument_value = payload + .get("arguments") + .or_else(|| payload.get("input")) + .or_else(|| payload.get("action")) + .or_else(|| payload.get("execution")); + let command_preview = codex_command_preview(&tool_name, argument_value); + let (arguments_preview, arguments_truncated) = argument_value + .map(|value| codex_value_preview(value, CODEX_MAX_METADATA_TEXT_CHARS)) + .unwrap_or_else(|| (String::new(), false)); + let text = command_preview + .as_ref() + .map(|command| format!("{tool_name}: {command}")) + .unwrap_or_else(|| { + if arguments_preview.is_empty() { + format!("{tool_name} tool call") + } else { + format!("{tool_name}: {arguments_preview}") + } + }); + let (text, text_truncated) = codex_local_preview(&text, CODEX_MAX_METADATA_TEXT_CHARS); + + if let Some(call_id) = call_id { + call_contexts.insert( + call_id.to_owned(), + CodexToolCallContext { + tool_name: tool_name.clone(), + command_preview: command_preview.clone(), + arguments_preview: (!arguments_preview.is_empty()) + .then_some(arguments_preview.clone()), + }, + ); + } + + Some(codex_provider_event( + line_number, + occurred_at, + EventType::ToolCall, + Some(EventRole::Assistant), + json!({ + "item_type": item_type, + "tool": tool_name, + "name": tool_name, + "call_id": call_id, + "command": command_preview, + "arguments_preview": arguments_preview, + "arguments_truncated": arguments_truncated, + "text": text, + "truncated": text_truncated || arguments_truncated, + }), + json!({ + "source": "codex_session", + "source_format": CODEX_SESSION_SOURCE_FORMAT, + "line": line_number, + "item_type": item_type, + "tool": tool_name, + }), + )) +} + +fn codex_tool_output_event( + payload: &Value, + line_number: usize, + occurred_at: DateTime, + call_contexts: &BTreeMap, + tool_output_mode: CodexToolOutputMode, +) -> Option { + if tool_output_mode == CodexToolOutputMode::Skip { + return None; + } + let item_type = payload + .get("type") + .and_then(Value::as_str) + .unwrap_or("tool_output"); + let call_id = payload.get("call_id").and_then(Value::as_str); + let context = call_id.and_then(|call_id| call_contexts.get(call_id)); + let tool_name = context + .map(|context| context.tool_name.clone()) + .unwrap_or_else(|| codex_tool_name(payload, item_type)); + let output_value = payload + .get("output") + .or_else(|| payload.get("tools")) + .or_else(|| payload.get("result")); + let output_text = output_value.map(codex_output_text); + let command_preview = context.and_then(|context| context.command_preview.clone()); + let output_text_ref = output_text.as_deref(); + let exit_code = output_text_ref.and_then(codex_exit_code); + let duration_ms = output_text_ref.and_then(codex_wall_time_ms); + let output_bytes = output_text_ref.map(str::len).unwrap_or(0); + let timed_out = codex_timed_out(payload).unwrap_or(false); + if tool_output_mode == CodexToolOutputMode::Failures + && !timed_out + && !exit_code.is_some_and(|code| code != 0) + { + return None; + } + let event_type = if codex_is_command_tool(&tool_name) { + EventType::CommandOutput + } else { + EventType::ToolOutput + }; + let keep_preview = tool_output_mode == CodexToolOutputMode::Full + || timed_out + || exit_code.is_some_and(|code| code != 0); + let preview_limit = if tool_output_mode == CodexToolOutputMode::Full { + CODEX_MAX_OUTPUT_PREVIEW_CHARS + } else { + 512 + }; + let (output_preview, output_truncated) = if keep_preview { + output_text_ref + .map(|text| codex_local_preview(text, preview_limit)) + .unwrap_or_else(|| (String::new(), false)) + } else { + (String::new(), output_bytes > 0) + }; + let text = match tool_output_mode { + CodexToolOutputMode::Full => { + if let Some(command) = command_preview.as_deref() { + format!("{tool_name} output for `{command}`: {output_preview}") + } else { + format!("{tool_name} output: {output_preview}") + } + } + CodexToolOutputMode::Metadata + | CodexToolOutputMode::Failures + | CodexToolOutputMode::Skip => { + let command = command_preview + .as_deref() + .map(|command| format!(" for `{command}`")) + .unwrap_or_default(); + let status = exit_code + .map(|code| format!("exit_code={code}")) + .unwrap_or_else(|| "exit_code=unknown".to_owned()); + let duration = duration_ms + .map(|ms| format!(", duration_ms={ms}")) + .unwrap_or_default(); + let timeout = if timed_out { ", timed_out=true" } else { "" }; + let preview = if output_preview.is_empty() { + String::new() + } else { + format!(": {output_preview}") + }; + format!("{tool_name} output{command}: {status}{duration}, output_bytes={output_bytes}{timeout}{preview}") + } + }; + let (text, text_truncated) = codex_local_preview(&text, CODEX_MAX_OUTPUT_PREVIEW_CHARS); + + Some(codex_provider_event( + line_number, + occurred_at, + event_type, + Some(EventRole::Tool), + json!({ + "item_type": item_type, + "tool": tool_name, + "name": tool_name, + "call_id": call_id, + "command": command_preview, + "arguments_preview": context.and_then(|context| context.arguments_preview.clone()), + "output": if tool_output_mode == CodexToolOutputMode::Full { Some(output_preview.clone()) } else { None }, + "output_preview": output_preview, + "output_retention": if tool_output_mode == CodexToolOutputMode::Full { "preview" } else { "raw_transcript" }, + "output_bytes": output_bytes, + "output_truncated": output_truncated, + "exit_code": exit_code, + "duration_ms": duration_ms, + "timed_out": timed_out, + "text": text, + "truncated": text_truncated || output_truncated, + }), + json!({ + "source": "codex_session", + "source_format": CODEX_SESSION_SOURCE_FORMAT, + "line": line_number, + "item_type": item_type, + "tool": tool_name, + }), + )) +} + +fn codex_output_text(value: &Value) -> Cow<'_, str> { + match value { + Value::String(text) => Cow::Borrowed(text), + Value::Null => Cow::Borrowed(""), + other => Cow::Owned(serde_json::to_string(other).unwrap_or_else(|_| other.to_string())), + } +} + +fn codex_reasoning_event( + payload: &Value, + line_number: usize, + occurred_at: DateTime, +) -> Option { + let summary = payload + .get("summary") + .and_then(codex_content_text) + .or_else(|| { + payload + .get("summary_text") + .and_then(Value::as_str) + .map(str::to_owned) + })?; + let (summary, truncated) = codex_local_preview(&summary, CODEX_MAX_TEXT_CHARS); + Some(codex_provider_event( + line_number, + occurred_at, + EventType::Summary, + Some(EventRole::Assistant), + json!({ + "item_type": "reasoning", + "summary": summary, + "text": summary, + "truncated": truncated, + "encrypted_content_withheld": payload.get("encrypted_content").is_some(), + }), + json!({ + "source": "codex_session", + "source_format": CODEX_SESSION_SOURCE_FORMAT, + "line": line_number, + "item_type": "reasoning", + }), + )) +} + +fn codex_message_event( + payload: &Value, + line_number: usize, + occurred_at: DateTime, +) -> Option { + let role_text = payload + .get("role") + .and_then(Value::as_str) + .unwrap_or("unknown"); + if matches!(role_text, "developer" | "system") { + return None; + } + let text = payload.get("content").and_then(codex_content_text)?; + let (text, truncated) = capped_text(&text, CODEX_MAX_TEXT_CHARS); + Some(codex_provider_event( + line_number, + occurred_at, + EventType::Message, + Some(codex_event_role(role_text)), + json!({ + "item_type": "message", + "message_role": role_text, + "phase": payload.get("phase").and_then(Value::as_str), + "text": text, + "truncated": truncated, + }), + json!({ + "source": "codex_session", + "source_format": CODEX_SESSION_SOURCE_FORMAT, + "import_scope": "fast_transcript_index", + "line": line_number, + "item_type": "message", + "message_role": role_text, + }), + )) +} + +fn codex_provider_event( + line_number: usize, + occurred_at: DateTime, + event_type: EventType, + role: Option, + payload: Value, + metadata: Value, +) -> ProviderEventEnvelope { + ProviderEventEnvelope { + provider_event_index: (line_number - 1) as u64, + provider_event_hash: None, + cursor: Some(format!("line:{line_number}")), + event_type, + role, + occurred_at, + fidelity: Fidelity::Imported, + redaction_state: RedactionState::LocalPreview, + idempotency_key: Some(format!("provider-event:codex-session:{line_number}")), + artifacts: Vec::new(), + payload, + metadata, + } +} + +fn codex_lifecycle_body(payload: &Value, msg_type: &str) -> Value { + let preview = payload + .get("last_agent_message") + .or_else(|| payload.get("message")) + .or_else(|| payload.get("stdout")) + .or_else(|| payload.get("stderr")) + .and_then(codex_json_text) + .unwrap_or_else(|| format!("Codex lifecycle: {msg_type}")); + let (text, truncated) = codex_local_preview(&preview, CODEX_MAX_METADATA_TEXT_CHARS); + json!({ + "text": text, + "event_msg_type": msg_type, + "status": payload.get("status").and_then(Value::as_str), + "success": payload.get("success").and_then(Value::as_bool), + "duration_ms": payload.get("duration_ms").and_then(Value::as_i64), + "time_to_first_token_ms": payload.get("time_to_first_token_ms").and_then(Value::as_i64), + "truncated": truncated, + }) +} + +fn codex_tool_name(payload: &Value, item_type: &str) -> String { + payload + .get("name") + .or_else(|| payload.get("tool")) + .and_then(Value::as_str) + .filter(|name| !name.trim().is_empty()) + .unwrap_or(item_type) + .to_owned() +} + +fn codex_is_command_tool(tool_name: &str) -> bool { + matches!(tool_name, "exec_command" | "shell" | "bash" | "command") +} + +fn codex_command_preview(tool_name: &str, argument_value: Option<&Value>) -> Option { + if !codex_is_command_tool(tool_name) { + return None; + } + let value = argument_value?; + let parsed = codex_parse_embedded_json(value).unwrap_or_else(|| value.clone()); + let command = parsed + .get("cmd") + .or_else(|| parsed.get("command")) + .or_else(|| parsed.get("shell_command")) + .and_then(Value::as_str) + .or_else(|| value.as_str())?; + Some(codex_local_preview(command, CODEX_MAX_METADATA_TEXT_CHARS).0) +} + +fn codex_value_preview(value: &Value, max_chars: usize) -> (String, bool) { + let rendered = match value { + Value::String(text) => text.clone(), + Value::Null => String::new(), + _ => serde_json::to_string(value).unwrap_or_else(|_| value.to_string()), + }; + codex_local_preview(&rendered, max_chars) +} + +fn codex_local_preview(value: &str, max_chars: usize) -> (String, bool) { + capped_text(value, max_chars) +} + +fn codex_parse_embedded_json(value: &Value) -> Option { + match value { + Value::String(text) => serde_json::from_str::(text).ok(), + Value::Object(_) | Value::Array(_) => Some(value.clone()), + _ => None, + } +} + +fn codex_timed_out(payload: &Value) -> Option { + payload + .get("timed_out") + .and_then(Value::as_bool) + .or_else(|| { + payload + .get("output") + .and_then(codex_parse_embedded_json) + .and_then(|value| { + value + .get("timed_out") + .and_then(Value::as_bool) + .or_else(|| value.pointer("/status/timed_out").and_then(Value::as_bool)) + }) + }) +} + +fn codex_exit_code(text: &str) -> Option { + let marker = "Process exited with code "; + let index = text.find(marker)? + marker.len(); + let tail = &text[index..]; + let digits = tail + .chars() + .take_while(|ch| ch.is_ascii_digit() || *ch == '-') + .collect::(); + digits.parse().ok() +} + +fn codex_wall_time_ms(text: &str) -> Option { + let marker = "Wall time: "; + let index = text.find(marker)? + marker.len(); + let tail = &text[index..]; + let seconds_text = tail + .chars() + .take_while(|ch| ch.is_ascii_digit() || *ch == '.') + .collect::(); + let seconds = seconds_text.parse::().ok()?; + Some((seconds * 1000.0).round() as i64) +} + +fn codex_event_role(role: &str) -> EventRole { + match role { + "user" => EventRole::User, + "assistant" => EventRole::Assistant, + "tool" => EventRole::Tool, + "system" | "developer" => EventRole::System, + _ => EventRole::Unknown, + } +} + +fn codex_content_text(value: &Value) -> Option { + match value { + Value::String(text) => Some(text.clone()), + Value::Array(blocks) => { + let mut parts = Vec::new(); + for block in blocks { + if let Some(text) = block + .get("text") + .or_else(|| block.get("input_text")) + .or_else(|| block.get("output_text")) + .or_else(|| block.get("summary_text")) + .and_then(Value::as_str) + { + parts.push(text.to_owned()); + continue; + } + if let Some(text) = block.get("content").and_then(Value::as_str) { + parts.push(text.to_owned()); + continue; + } + if let Some(kind) = block.get("type").and_then(Value::as_str) { + if matches!(kind, "tool_call" | "function_call" | "custom_tool_call") { + let name = block.get("name").and_then(Value::as_str).unwrap_or("tool"); + parts.push(format!("tool call: {name}")); + } + } + } + if parts.is_empty() { + None + } else { + Some(parts.join("\n")) + } + } + Value::Object(_) => codex_json_text(value), + _ => None, + } +} + +fn codex_json_text(value: &Value) -> Option { + match value { + Value::Null => None, + Value::String(text) => Some(text.clone()), + Value::Array(_) | Value::Object(_) => serde_json::to_string(value).ok(), + _ => Some(value.to_string()), + } +} + +fn codex_capped_json(value: &Value, max_chars: usize) -> Value { + match value { + Value::String(text) => { + let (text, truncated) = capped_text(text, max_chars); + json!({ "text": text, "truncated": truncated }) + } + _ => { + let rendered = serde_json::to_string(value).unwrap_or_else(|_| "null".to_owned()); + let (text, truncated) = capped_text(&rendered, max_chars); + json!({ "json": text, "truncated": truncated }) + } + } +} + +fn capped_text(value: &str, max_chars: usize) -> (String, bool) { + let mut out = String::new(); + let mut truncated = false; + for (index, ch) in value.chars().enumerate() { + if index >= max_chars { + truncated = true; + break; + } + out.push(ch); + } + (out, truncated) +} + +fn provider_local_preview(value: &str, max_chars: usize) -> (String, bool) { + capped_text(value, max_chars) +} + +#[derive(Debug, Clone, PartialEq)] +struct FileTouchDraft { + path: String, + old_path: Option, + change_kind: Option, + confidence: Confidence, + metadata: Value, +} + +fn provider_file_touches_from_event( + provider: CaptureProvider, + provider_session_id: &str, + source_format: &str, + raw_source_path: Option<&str>, + event: &ProviderEventEnvelope, + line_number: usize, +) -> Vec<(usize, ProviderFileTouchedEnvelope)> { + if !matches!( + event.event_type, + EventType::ToolCall + | EventType::ToolOutput + | EventType::CommandOutput + | EventType::FileTouched + ) { + return Vec::new(); + } + + let mut drafts = Vec::new(); + collect_patch_file_touches(&event.payload, &mut drafts); + if drafts.is_empty() && event_type_supports_structured_file_touches(event.event_type) { + collect_structured_file_touches(&event.payload, &mut drafts); + } + + provider_file_touch_envelopes( + ProviderFileTouchEnvelopeContext { + provider, + provider_session_id, + source_format, + raw_source_path, + occurred_at: event.occurred_at, + provider_event_index: Some(event.provider_event_index), + provider_touch_base_index: event.provider_event_index << 16, + line_number, + }, + drafts, + ) +} + +fn provider_file_touches_from_raw_value( + provider: CaptureProvider, + provider_session_id: &str, + source_format: &str, + raw_source_path: Option<&str>, + raw_value: &Value, + event: &ProviderEventEnvelope, + line_number: usize, +) -> Vec<(usize, ProviderFileTouchedEnvelope)> { + if !matches!( + event.event_type, + EventType::ToolCall + | EventType::ToolOutput + | EventType::CommandOutput + | EventType::FileTouched + ) { + return Vec::new(); + } + + let mut drafts = Vec::new(); + collect_patch_file_touches(raw_value, &mut drafts); + if drafts.is_empty() && event_type_supports_structured_file_touches(event.event_type) { + collect_structured_file_touches(raw_value, &mut drafts); + } + + provider_file_touch_envelopes( + ProviderFileTouchEnvelopeContext { + provider, + provider_session_id, + source_format, + raw_source_path, + occurred_at: event.occurred_at, + provider_event_index: Some(event.provider_event_index), + provider_touch_base_index: event.provider_event_index << 16, + line_number, + }, + drafts, + ) +} + +fn event_type_supports_structured_file_touches(event_type: EventType) -> bool { + matches!(event_type, EventType::ToolCall | EventType::FileTouched) +} + +struct ProviderFileTouchEnvelopeContext<'a> { + provider: CaptureProvider, + provider_session_id: &'a str, + source_format: &'a str, + raw_source_path: Option<&'a str>, + occurred_at: DateTime, + provider_event_index: Option, + provider_touch_base_index: u64, + line_number: usize, +} + +fn provider_file_touch_envelopes( + context: ProviderFileTouchEnvelopeContext<'_>, + drafts: Vec, +) -> Vec<(usize, ProviderFileTouchedEnvelope)> { + let mut seen = BTreeSet::new(); + let mut out = Vec::new(); + for draft in drafts { + let key = ( + draft.path.clone(), + draft.old_path.clone(), + draft.change_kind.map(|kind| kind.as_str().to_owned()), + ); + if !seen.insert(key) { + continue; + } + let provider_touch_index = context.provider_touch_base_index | (out.len() as u64); + out.push(( + context.line_number, + ProviderFileTouchedEnvelope { + provider: context.provider, + provider_session_id: context.provider_session_id.to_owned(), + provider_touch_index, + provider_event_index: context.provider_event_index, + raw_source_path: context.raw_source_path.map(str::to_owned), + path: draft.path, + change_kind: draft.change_kind, + old_path: draft.old_path, + line_count_delta: None, + confidence: draft.confidence, + occurred_at: context.occurred_at, + source_format: context.source_format.to_owned(), + metadata: draft.metadata, + }, + )); + } + out +} + +fn collect_patch_file_touches(value: &Value, out: &mut Vec) { + match value { + Value::String(text) => { + if text.contains("*** Begin Patch") { + out.extend(parse_apply_patch_file_touches(text)); + } + } + Value::Array(items) => { + for item in items { + collect_patch_file_touches(item, out); + } + } + Value::Object(object) => { + for value in object.values() { + collect_patch_file_touches(value, out); + } + } + _ => {} + } +} + +fn parse_apply_patch_file_touches(patch: &str) -> Vec { + let mut out = Vec::new(); + let mut pending_update: Option = None; + for line in patch.lines() { + if let Some(path) = line.strip_prefix("*** Add File: ") { + flush_pending_patch_update(&mut out, &mut pending_update); + if let Some(path) = normalize_file_path(path) { + out.push(file_touch_draft( + path, + None, + FileChangeKind::Created, + Confidence::Explicit, + "apply_patch_add", + )); + } + continue; + } + if let Some(path) = line.strip_prefix("*** Update File: ") { + flush_pending_patch_update(&mut out, &mut pending_update); + pending_update = normalize_file_path(path); + continue; + } + if let Some(path) = line.strip_prefix("*** Delete File: ") { + flush_pending_patch_update(&mut out, &mut pending_update); + if let Some(path) = normalize_file_path(path) { + out.push(file_touch_draft( + path, + None, + FileChangeKind::Deleted, + Confidence::Explicit, + "apply_patch_delete", + )); + } + continue; + } + if let Some(path) = line.strip_prefix("*** Move to: ") { + let old_path = pending_update.take(); + if let Some(path) = normalize_file_path(path) { + out.push(file_touch_draft( path, old_path, FileChangeKind::Renamed, @@ -4347,663 +6058,3221 @@ fn parse_apply_patch_file_touches(patch: &str) -> Vec { } } } - flush_pending_patch_update(&mut out, &mut pending_update); - out + flush_pending_patch_update(&mut out, &mut pending_update); + out +} + +fn flush_pending_patch_update(out: &mut Vec, pending_update: &mut Option) { + if let Some(path) = pending_update.take() { + out.push(file_touch_draft( + path, + None, + FileChangeKind::Modified, + Confidence::Explicit, + "apply_patch_update", + )); + } +} + +fn collect_structured_file_touches(value: &Value, out: &mut Vec) { + collect_structured_file_touches_with_context(value, out, None); +} + +fn collect_structured_file_touches_with_context( + value: &Value, + out: &mut Vec, + inherited_kind: Option, +) { + match value { + Value::Array(items) => { + for item in items { + collect_structured_file_touches_with_context(item, out, inherited_kind); + } + } + Value::Object(object) => { + let operation_kind = object_operation_hint_kind(object); + let object_kind = operation_kind.or(inherited_kind); + collect_structured_file_touch_object(object, out, object_kind); + for value in object.values() { + collect_structured_file_touches_with_context(value, out, object_kind); + } + } + _ => {} + } +} + +fn collect_structured_file_touch_object( + object: &serde_json::Map, + out: &mut Vec, + inherited_kind: Option, +) { + let inferred_kind = inferred_file_change_kind(object); + let change_kind = inherited_kind.unwrap_or(inferred_kind); + let old_path = object.iter().find_map(|(key, value)| { + is_old_file_path_key(key) + .then(|| value.as_str()) + .flatten() + .and_then(normalize_file_path) + }); + for (key, value) in object { + if !is_file_path_key(key) { + continue; + } + let Some(raw_path) = value.as_str() else { + continue; + }; + if normalized_key(key) == "uri" && !raw_path.trim().starts_with("file://") { + continue; + } + let Some(path) = normalize_file_path(raw_path) else { + continue; + }; + out.push(FileTouchDraft { + path, + old_path: old_path.clone(), + change_kind: Some(change_kind), + confidence: Confidence::High, + metadata: json!({ + "source": "structured_provider_payload", + "path_key": key, + }), + }); + } +} + +fn object_operation_hint_kind(object: &serde_json::Map) -> Option { + object + .iter() + .any(|(key, value)| { + matches!( + normalized_key(key).as_str(), + "tool" | "name" | "action" | "command" | "operation" | "type" + ) && value.as_str().is_some_and(|text| !text.trim().is_empty()) + }) + .then(|| inferred_file_change_kind(object)) + .filter(|kind| *kind != FileChangeKind::Unknown) +} + +fn inferred_file_change_kind(object: &serde_json::Map) -> FileChangeKind { + let mut haystack = String::new(); + for (key, value) in object { + haystack.push_str(&key.to_ascii_lowercase()); + haystack.push(' '); + if matches!( + key.to_ascii_lowercase().as_str(), + "tool" | "name" | "action" | "command" | "operation" | "type" + ) { + if let Some(text) = value.as_str() { + haystack.push_str(&text.to_ascii_lowercase()); + haystack.push(' '); + } + } + } + if haystack.contains("rename") || haystack.contains("move") { + FileChangeKind::Renamed + } else if haystack.contains("delete") || haystack.contains("remove") { + FileChangeKind::Deleted + } else if haystack.contains("create") || haystack.contains("write") || haystack.contains("add") + { + FileChangeKind::Created + } else if haystack.contains("read") || haystack.contains("view") || haystack.contains("open") { + FileChangeKind::Read + } else if object.values().any(value_looks_like_file_content) + || haystack.contains("edit") + || haystack.contains("patch") + || haystack.contains("replace") + || haystack.contains("update") + { + FileChangeKind::Modified + } else { + FileChangeKind::Unknown + } +} + +fn value_looks_like_file_content(value: &Value) -> bool { + value.as_str().is_some_and(|text| { + text.contains('\n') + || text.len() > 120 + || text.contains("*** Begin Patch") + || text.contains("@@") + }) +} + +fn is_file_path_key(key: &str) -> bool { + matches!( + normalized_key(key).as_str(), + "path" + | "file" + | "filepath" + | "filename" + | "targetfile" + | "targetpath" + | "relativepath" + | "absolutepath" + | "uri" + | "destinationfile" + | "destinationpath" + ) +} + +fn is_old_file_path_key(key: &str) -> bool { + matches!( + normalized_key(key).as_str(), + "oldpath" | "frompath" | "sourcepath" | "originalpath" | "previouspath" + ) +} + +fn normalized_key(key: &str) -> String { + key.chars() + .filter(|ch| ch.is_ascii_alphanumeric()) + .flat_map(|ch| ch.to_lowercase()) + .collect() +} + +fn normalize_file_path(value: &str) -> Option { + let trimmed = value.trim().trim_matches('"').trim_matches('\''); + let trimmed = trimmed.strip_prefix("file://").unwrap_or(trimmed); + if !looks_like_file_path(trimmed) { + return None; + } + Some(trimmed.to_owned()) +} + +fn looks_like_file_path(value: &str) -> bool { + if value.is_empty() + || value.len() > 512 + || value.contains('\n') + || value.contains('\r') + || value.contains("://") + || value.contains("[REDACTED") + || value.starts_with('{') + || value.starts_with('[') + { + return false; + } + value.contains('/') + || value.contains('\\') + || value.starts_with('.') + || value.rsplit(['/', '\\']).next().is_some_and(|name| { + name.rsplit_once('.').is_some_and(|(stem, ext)| { + !stem.is_empty() + && !ext.is_empty() + && ext.len() <= 12 + && ext.chars().all(|ch| ch.is_ascii_alphanumeric()) + }) + }) +} + +fn file_touch_draft( + path: String, + old_path: Option, + change_kind: FileChangeKind, + confidence: Confidence, + source: &'static str, +) -> FileTouchDraft { + FileTouchDraft { + path, + old_path, + change_kind: Some(change_kind), + confidence, + metadata: json!({ "source": source }), + } +} + +fn provider_value_text(value: &Value) -> Option { + match value { + Value::String(text) => Some(text.clone()), + Value::Array(blocks) => { + let mut parts = Vec::new(); + for block in blocks { + if let Some(text) = block + .get("text") + .or_else(|| block.get("content")) + .or_else(|| block.get("output")) + .or_else(|| block.get("summary")) + .and_then(Value::as_str) + { + parts.push(text.to_owned()); + continue; + } + if let Some(kind) = block.get("type").and_then(Value::as_str) { + if matches!( + kind, + "tool_use" | "tool" | "toolCall" | "function_call" | "agent" + ) { + let name = block + .get("name") + .or_else(|| block.get("tool")) + .and_then(Value::as_str) + .unwrap_or("tool"); + parts.push(format!("tool call: {name}")); + } else if kind == "tool_result" { + parts.push("tool result".to_owned()); + } + } + } + (!parts.is_empty()).then(|| parts.join("\n")) + } + Value::Object(_) => serde_json::to_string(value).ok(), + Value::Number(_) | Value::Bool(_) => Some(value.to_string()), + Value::Null => None, + } +} + +fn provider_role(value: Option<&str>) -> EventRole { + match value { + Some("user") => EventRole::User, + Some("assistant") => EventRole::Assistant, + Some("system" | "developer") => EventRole::System, + Some("tool" | "toolResult" | "bashExecution") => EventRole::Tool, + _ => EventRole::Unknown, + } +} + +fn normalize_claude_projects_jsonl_file( + path: &Path, + context: &ProviderAdapterContext, +) -> Result { + ensure_regular_provider_transcript_file(path)?; + let file = File::open(path)?; + let mut reader = BufReader::new(file); + let mut result = ProviderNormalizationResult::default(); + let mut rows = Vec::new(); + let mut line = Vec::new(); + let mut line_number = 0usize; + + while read_provider_jsonl_line(&mut reader, &mut line)? { + line_number += 1; + if line.iter().all(u8::is_ascii_whitespace) { + continue; + } + let value: Value = match serde_json::from_slice(&line) { + Ok(value) => value, + Err(err) => { + result.summary.failed += 1; + result.summary.failures.push(ProviderImportFailure { + line: line_number, + error: format!("malformed JSONL: {err}"), + }); + continue; + } + }; + let timestamp = value + .get("timestamp") + .and_then(Value::as_str) + .and_then(parse_rfc3339_utc) + .unwrap_or(context.imported_at); + rows.push((line_number, value, timestamp)); + } + if rows.is_empty() { + return Ok(result); + } + + let first = &rows[0].1; + let file_stem = path + .file_stem() + .and_then(|name| name.to_str()) + .unwrap_or("unknown-session"); + let native_session_id = first + .get("sessionId") + .and_then(Value::as_str) + .filter(|id| !id.trim().is_empty()) + .unwrap_or(file_stem) + .to_owned(); + let (provider_session_id, parent_provider_session_id, external_agent_id, is_subagent) = + claude_path_session_ids(path, &native_session_id); + let started_at = rows + .iter() + .map(|(_, _, timestamp)| *timestamp) + .min() + .unwrap_or(context.imported_at); + let cwd = first + .get("cwd") + .and_then(Value::as_str) + .filter(|cwd| !cwd.trim().is_empty()) + .map(str::to_owned); + let version = first + .get("version") + .and_then(Value::as_str) + .map(str::to_owned); + let git_branch = first + .get("gitBranch") + .and_then(Value::as_str) + .map(str::to_owned); + let raw_source_path = path.display().to_string(); + + for (line_number, value, occurred_at) in rows { + let event = claude_event(&value, line_number, occurred_at); + if let Some(event) = &event { + result + .files_touched + .extend(provider_file_touches_from_raw_value( + CaptureProvider::Claude, + &provider_session_id, + CLAUDE_PROJECTS_SOURCE_FORMAT, + Some(raw_source_path.as_str()), + &value, + event, + line_number, + )); + } + result.captures.push(( + line_number, + ProviderCaptureEnvelope { + schema_version: PROVIDER_CAPTURE_ENVELOPE_SCHEMA_VERSION, + provider: CaptureProvider::Claude, + source: ProviderSourceEnvelope { + source_format: CLAUDE_PROJECTS_SOURCE_FORMAT.to_owned(), + machine_id: context.machine_id.clone(), + observed_at: context.imported_at, + raw_source_path: Some(raw_source_path.clone()), + raw_retention: ProviderRawRetention::PathReference, + redaction_boundary: ProviderRedactionBoundary::BeforeExport, + trust: ProviderSourceTrust::ProviderNative, + fidelity: Fidelity::Imported, + cursor: Some(ProviderCursorRange { + before: None, + after: Some(ProviderCursorCheckpoint { + stream: provider_cursor_stream( + CaptureProvider::Claude, + CLAUDE_PROJECTS_SOURCE_FORMAT, + ), + cursor: format!("{}:line:{line_number}", path.display()), + observed_at: occurred_at, + }), + }), + idempotency_key: Some(format!( + "provider-source:claude:{CLAUDE_PROJECTS_SOURCE_FORMAT}:{provider_session_id}" + )), + metadata: json!({ + "adapter": CLAUDE_PROJECTS_SOURCE_FORMAT, + "native_session_id": native_session_id, + "source_path": raw_source_path.clone(), + }), + }, + session: ProviderSessionEnvelope { + provider_session_id: provider_session_id.clone(), + parent_provider_session_id: parent_provider_session_id.clone(), + root_provider_session_id: parent_provider_session_id.clone(), + external_agent_id: external_agent_id.clone(), + agent_type: if is_subagent { + AgentType::Subagent + } else { + AgentType::Primary + }, + role_hint: Some(if is_subagent { "subagent" } else { "primary" }.to_owned()), + is_primary: !is_subagent, + status: SessionStatus::Imported, + started_at, + ended_at: None, + cwd: cwd.clone(), + fidelity: Fidelity::Imported, + idempotency_key: Some(format!("provider-session:claude:{provider_session_id}")), + artifacts: Vec::new(), + metadata: json!({ + "source_format": CLAUDE_PROJECTS_SOURCE_FORMAT, + "native_session_id": native_session_id, + "version": version, + "git_branch": git_branch, + "source_path": path.display().to_string(), + "limitations": [ + "binary attachments are referenced by native payload metadata but not expanded", + "previews are capped before local indexing/export" + ], + }), + }, + event, + }, + )); + } + + Ok(result) +} + +fn claude_path_session_ids( + path: &Path, + native_session_id: &str, +) -> (String, Option, Option, bool) { + let Some(parent) = path.parent() else { + return (native_session_id.to_owned(), None, None, false); + }; + if parent.file_name().and_then(|name| name.to_str()) == Some("subagents") { + let parent_session_id = parent + .parent() + .and_then(Path::file_name) + .and_then(|name| name.to_str()) + .filter(|name| !name.trim().is_empty()) + .unwrap_or(native_session_id) + .to_owned(); + let agent_id = path + .file_stem() + .and_then(|name| name.to_str()) + .filter(|name| !name.trim().is_empty()) + .unwrap_or("subagent") + .to_owned(); + return ( + format!("{parent_session_id}/subagents/{agent_id}"), + Some(parent_session_id), + Some(agent_id), + true, + ); + } + (native_session_id.to_owned(), None, None, false) +} + +fn claude_event( + value: &Value, + line_number: usize, + occurred_at: DateTime, +) -> Option { + let entry_type = value + .get("type") + .and_then(Value::as_str) + .unwrap_or("unknown"); + let message = value.get("message").unwrap_or(value); + let message_role = message + .get("role") + .and_then(Value::as_str) + .or_else(|| value.get("role").and_then(Value::as_str)); + let null = Value::Null; + let content = message.get("content").unwrap_or(&null); + let event_type = claude_event_type(entry_type, message); + let role = Some(provider_role(message_role)); + let text = provider_value_text(content).unwrap_or_else(|| { + if event_type == EventType::Notice { + format!("Claude event: {entry_type}") + } else { + String::new() + } + }); + let (text, truncated) = provider_local_preview(&text, PROVIDER_MAX_TEXT_CHARS); + + Some(ProviderEventEnvelope { + provider_event_index: (line_number - 1) as u64, + provider_event_hash: value.get("uuid").and_then(Value::as_str).map(str::to_owned), + cursor: value.get("uuid").and_then(Value::as_str).map(str::to_owned), + event_type, + role, + occurred_at, + fidelity: Fidelity::Imported, + redaction_state: RedactionState::LocalPreview, + idempotency_key: value + .get("uuid") + .and_then(Value::as_str) + .map(|uuid| format!("provider-event:claude:{uuid}")), + artifacts: Vec::new(), + payload: json!({ + "entry_type": entry_type, + "uuid": value.get("uuid").and_then(Value::as_str), + "parent_uuid": value.get("parentUuid").and_then(Value::as_str), + "message_id": message.get("id").and_then(Value::as_str), + "request_id": value.get("requestId").and_then(Value::as_str), + "role": message_role, + "text": text, + "truncated": truncated, + "content_preview": provider_capped_json(content, PROVIDER_MAX_PREVIEW_CHARS), + }), + metadata: json!({ + "source": "claude_projects_jsonl", + "source_format": CLAUDE_PROJECTS_SOURCE_FORMAT, + "line": line_number, + "entry_type": entry_type, + "model": message.get("model").and_then(Value::as_str), + "usage": message.get("usage").cloned(), + "stop_reason": message.get("stop_reason").and_then(Value::as_str), + "is_sidechain": value.get("isSidechain").and_then(Value::as_bool), + "tool_use_result": value.get("toolUseResult").cloned(), + }), + }) +} + +fn claude_event_type(entry_type: &str, message: &Value) -> EventType { + if claude_content_has_type(message.get("content"), "tool_result") + || message.get("toolUseResult").is_some() + { + return EventType::ToolOutput; + } + if claude_content_has_type(message.get("content"), "tool_use") { + return EventType::ToolCall; + } + match entry_type { + "user" | "assistant" => EventType::Message, + "system" + | "progress" + | "permission-mode" + | "last-prompt" + | "queue-operation" + | "attachment" + | "file-history-snapshot" + | "ai-title" => EventType::Notice, + _ => EventType::Notice, + } +} + +fn claude_content_has_type(content: Option<&Value>, expected: &str) -> bool { + content + .and_then(Value::as_array) + .map(|blocks| { + blocks + .iter() + .any(|block| block.get("type").and_then(Value::as_str) == Some(expected)) + }) + .unwrap_or(false) +} + +fn provider_capped_json(value: &Value, max_chars: usize) -> Value { + match value { + Value::Null => Value::Null, + Value::String(text) => { + let (text, truncated) = provider_local_preview(text, max_chars); + json!({ "text": text, "truncated": truncated }) + } + _ => { + let rendered = serde_json::to_string(value).unwrap_or_else(|_| value.to_string()); + let (json_text, truncated) = provider_local_preview(&rendered, max_chars); + json!({ "json": json_text, "truncated": truncated }) + } + } +} + +fn provider_capped_json_value(value: &Value, max_string_chars: usize) -> Value { + match value { + Value::String(text) => { + let (text, truncated) = provider_local_preview(text, max_string_chars); + if truncated { + json!({ "text": text, "truncated": true }) + } else { + Value::String(text) + } + } + Value::Array(items) => Value::Array( + items + .iter() + .map(|item| provider_capped_json_value(item, max_string_chars)) + .collect(), + ), + Value::Object(object) => Value::Object( + object + .iter() + .map(|(key, value)| { + ( + key.clone(), + provider_capped_json_value(value, max_string_chars), + ) + }) + .collect(), + ), + _ => value.clone(), + } +} + +fn antigravity_tool_call_text(value: &Value) -> Option { + value.as_array().and_then(|calls| { + let names: Vec<&str> = calls + .iter() + .filter_map(|call| call.get("name").and_then(Value::as_str)) + .collect(); + if names.is_empty() { + None + } else { + Some(format!("tool calls: {}", names.join(", "))) + } + }) +} + +#[derive(Debug, Clone)] +struct OpenCodeSessionRow { + id: String, + parent_id: Option, + title: String, + directory: String, + model: Option, + agent: Option, + time_created: i64, + time_updated: i64, + tokens_input: i64, + tokens_output: i64, + tokens_reasoning: i64, + tokens_cache_read: i64, + tokens_cache_write: i64, +} + +#[derive(Debug, Clone)] +struct OpenCodeMessageRow { + id: String, + session_id: String, + entry_type: String, + seq: i64, + time_created: i64, + time_updated: i64, + data: String, +} + +#[derive(Debug, Clone)] +struct ShelleyConversationRow { + conversation_id: String, + slug: Option, + user_initiated: bool, + created_at: Option, + updated_at: Option, + cwd: Option, + archived: bool, + parent_conversation_id: Option, + model: Option, + conversation_options: Option, + current_generation: Option, + agent_working: bool, + tags: Option, + is_draft: bool, + draft: Option, + queued_messages: Option, +} + +#[derive(Debug, Clone)] +struct ShelleyMessageRow { + rowid: i64, + message_id: String, + conversation_id: String, + sequence_id: i64, + entry_type: String, + llm_data: Option, + user_data: Option, + usage_data: Option, + created_at: Option, + display_data: Option, + excluded_from_context: bool, + generation: Option, + llm_api_url: Option, + model_name: Option, + forked_from_message_id: Option, +} + +struct NativeSessionDraft { + provider: CaptureProvider, + source_format: &'static str, + provider_session_id: String, + parent_provider_session_id: Option, + root_provider_session_id: Option, + external_agent_id: Option, + agent_type: AgentType, + role_hint: Option, + is_primary: bool, + started_at: DateTime, + ended_at: Option>, + cwd: Option, + fidelity: Fidelity, + raw_source_path: String, + trust: ProviderSourceTrust, + source_metadata: Value, + session_metadata: Value, +} + +fn native_provider_capture( + draft: NativeSessionDraft, + context: &ProviderAdapterContext, + event: Option, +) -> ProviderCaptureEnvelope { + ProviderCaptureEnvelope { + schema_version: PROVIDER_CAPTURE_ENVELOPE_SCHEMA_VERSION, + provider: draft.provider, + source: ProviderSourceEnvelope { + source_format: draft.source_format.to_owned(), + machine_id: context.machine_id.clone(), + observed_at: context.imported_at, + raw_source_path: Some(draft.raw_source_path), + raw_retention: ProviderRawRetention::PathReference, + redaction_boundary: ProviderRedactionBoundary::BeforeExport, + trust: draft.trust, + fidelity: draft.fidelity, + cursor: event.as_ref().and_then(|event| { + event.cursor.as_ref().map(|cursor| ProviderCursorRange { + before: None, + after: Some(ProviderCursorCheckpoint { + stream: provider_cursor_stream(draft.provider, draft.source_format), + cursor: cursor.clone(), + observed_at: event.occurred_at, + }), + }) + }), + idempotency_key: Some(format!( + "provider-source:{}:{}:{}", + draft.provider.as_str(), + draft.source_format, + draft.provider_session_id + )), + metadata: draft.source_metadata, + }, + session: ProviderSessionEnvelope { + provider_session_id: draft.provider_session_id.clone(), + parent_provider_session_id: draft.parent_provider_session_id, + root_provider_session_id: draft.root_provider_session_id, + external_agent_id: draft.external_agent_id, + agent_type: draft.agent_type, + role_hint: draft.role_hint, + is_primary: draft.is_primary, + status: SessionStatus::Imported, + started_at: draft.started_at, + ended_at: draft.ended_at, + cwd: draft.cwd, + fidelity: draft.fidelity, + idempotency_key: Some(format!( + "provider-session:{}:{}", + draft.provider.as_str(), + draft.provider_session_id + )), + artifacts: Vec::new(), + metadata: draft.session_metadata, + }, + event, + } +} + +fn open_provider_sqlite_readonly(path: &Path) -> Result { + ensure_regular_provider_transcript_file(path)?; + let conn = Connection::open_with_flags( + path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + )?; + let value_limit = i32::try_from(MAX_PROVIDER_SQLITE_VALUE_BYTES).map_err(|_| { + CaptureError::InvalidPayload(format!( + "provider SQLite value byte limit is unrepresentable: {MAX_PROVIDER_SQLITE_VALUE_BYTES}" + )) + })?; + conn.set_limit(Limit::SQLITE_LIMIT_LENGTH, value_limit); + conn.busy_timeout(std::time::Duration::from_secs(5))?; + conn.pragma_update(None, "query_only", true)?; + Ok(conn) +} + +fn provider_nonnegative_i64_to_u64(value: i64, field: &'static str) -> Result { + u64::try_from(value).map_err(|_| { + CaptureError::InvalidPayload(format!("{field} must be nonnegative, got {value}")) + }) +} + +fn provider_line_from_index(index: u64) -> usize { + index.min(usize::MAX as u64) as usize +} + +fn provider_timestamp_seconds_to_datetime(value: f64) -> Option> { + if !value.is_finite() { + return None; + } + let millis = if value.abs() > 1_000_000_000_000.0 { + value.round() + } else { + (value * 1000.0).round() + }; + if millis < i64::MIN as f64 || millis > i64::MAX as f64 { + return None; + } + DateTime::::from_timestamp_millis(millis as i64) +} + +fn provider_timestamp_seconds(value: Option, fallback: DateTime) -> DateTime { + value + .and_then(provider_timestamp_seconds_to_datetime) + .unwrap_or(fallback) +} + +fn provider_required_timestamp_seconds(value: f64, field: &'static str) -> Result> { + provider_timestamp_seconds_to_datetime(value).ok_or_else(|| { + CaptureError::InvalidPayload(format!( + "{field} is outside representable timestamp range: {value}" + )) + }) +} + +fn provider_timestamp_millis(value: Option, fallback: DateTime) -> DateTime { + value + .and_then(DateTime::::from_timestamp_millis) + .unwrap_or(fallback) +} + +fn provider_required_timestamp_millis(value: i64, field: &'static str) -> Result> { + DateTime::::from_timestamp_millis(value).ok_or_else(|| { + CaptureError::InvalidPayload(format!( + "{field} is outside representable timestamp range: {value}" + )) + }) +} + +fn provider_timestamp_value(value: Option<&Value>, fallback: DateTime) -> DateTime { + match value { + Some(Value::String(raw)) => parse_rfc3339_utc(raw) + .or_else(|| { + raw.parse::() + .ok() + .map(|ts| provider_timestamp_seconds(Some(ts), fallback)) + }) + .unwrap_or(fallback), + Some(Value::Number(number)) => number + .as_f64() + .map(|ts| provider_timestamp_seconds(Some(ts), fallback)) + .unwrap_or(fallback), + _ => fallback, + } +} + +fn text_id_index(seed: &str, offset: u64) -> u64 { + offset.saturating_add(fnv1a64(seed.as_bytes()) & 0x0fff_ffff) +} + +fn provider_json_text(raw: &str) -> Value { + serde_json::from_str::(raw).unwrap_or_else(|_| Value::String(raw.to_owned())) +} + +fn hermes_decode_content(raw: Option<&str>) -> Value { + let Some(raw) = raw else { + return Value::Null; + }; + if let Some(json) = raw.strip_prefix("\0json:") { + return provider_json_text(json); + } + Value::String(raw.to_owned()) +} + +struct NativeEventDraft { + provider: CaptureProvider, + source_format: &'static str, + provider_session_id: String, + provider_event_index: u64, + provider_event_hash: Option, + cursor: String, + event_type: EventType, + role: Option, + occurred_at: DateTime, + text: String, + body: Value, + metadata: Value, +} + +fn native_event(draft: NativeEventDraft) -> ProviderEventEnvelope { + let (text, truncated) = provider_local_preview(&draft.text, PROVIDER_MAX_TEXT_CHARS); + ProviderEventEnvelope { + provider_event_index: draft.provider_event_index, + provider_event_hash: draft.provider_event_hash, + cursor: Some(draft.cursor), + event_type: draft.event_type, + role: draft.role, + occurred_at: draft.occurred_at, + fidelity: Fidelity::Imported, + redaction_state: RedactionState::LocalPreview, + idempotency_key: Some(format!( + "provider-event:{}:{}:{}", + draft.provider.as_str(), + draft.provider_session_id, + draft.provider_event_index + )), + artifacts: Vec::new(), + payload: json!({ + "text": text, + "truncated": truncated, + "source_format": draft.source_format, + "body": provider_capped_json(&draft.body, PROVIDER_MAX_PREVIEW_CHARS), + }), + metadata: draft.metadata, + } +} + +fn openclaw_agent_id(path: &Path) -> Option { + let components = path + .components() + .map(|component| component.as_os_str().to_string_lossy().to_string()) + .collect::>(); + components.windows(2).find_map(|window| { + (window[0] == "agents" && !window[1].is_empty()).then(|| window[1].clone()) + }) +} + +fn provider_path_has_component(path: &Path, expected: &str) -> bool { + path.components() + .any(|component| component.as_os_str() == expected) +} + +fn openclaw_session_indexes(root: &Path) -> BTreeMap { + let mut indexes = BTreeMap::new(); + let mut paths = Vec::new(); + let mut visited = 0usize; + collect_named_paths( + root, + "sessions.json", + &mut paths, + &mut visited, + MAX_OPENCLAW_SESSION_INDEX_PATHS, + MAX_OPENCLAW_SESSION_INDEX_VISITED_PATHS, + ); + for path in paths { + let Ok(text) = read_text_file_limited( + &path, + MAX_OPENCLAW_SESSION_INDEX_BYTES, + "OpenClaw sessions.json", + ) else { + continue; + }; + let Ok(value) = serde_json::from_str::(&text) else { + continue; + }; + let agent_id = openclaw_agent_id(&path); + for (key, value) in openclaw_session_index_entries(value) { + if let Some(session_id) = value + .get("sessionId") + .or_else(|| value.get("id")) + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + { + if let Some(agent_id) = &agent_id { + indexes + .entry(format!("{agent_id}/{session_id}")) + .or_insert(value.clone()); + } + indexes + .entry(session_id.to_owned()) + .or_insert(value.clone()); + } + if let Some(agent_id) = &agent_id { + indexes + .entry(format!("{agent_id}/{key}")) + .or_insert(value.clone()); + } + indexes.entry(key).or_insert(value); + } + } + indexes +} + +fn openclaw_session_index_entries(value: Value) -> Vec<(String, Value)> { + match value { + Value::Array(items) => items + .into_iter() + .enumerate() + .map(|(index, value)| { + let key = value + .get("sessionId") + .or_else(|| value.get("id")) + .and_then(Value::as_str) + .map(str::to_owned) + .unwrap_or_else(|| index.to_string()); + (key, value) + }) + .collect(), + Value::Object(mut map) => { + if let Some(Value::Array(items)) = map.remove("sessions") { + return openclaw_session_index_entries(Value::Array(items)); + } + map.into_iter().collect() + } + _ => Vec::new(), + } +} + +fn collect_named_paths( + root: &Path, + name: &str, + paths: &mut Vec, + visited: &mut usize, + max_paths: usize, + max_visited: usize, +) { + if paths.len() >= max_paths || *visited >= max_visited { + return; + } + *visited += 1; + let Ok(metadata) = fs::symlink_metadata(root) else { + return; + }; + if metadata.file_type().is_symlink() { + return; + } + if metadata.file_type().is_file() { + if root.file_name().and_then(|file_name| file_name.to_str()) == Some(name) { + paths.push(root.to_path_buf()); + } + return; + } + if !metadata.file_type().is_dir() { + return; + } + let Ok(entries) = fs::read_dir(root) else { + return; + }; + for entry in entries.flatten() { + if paths.len() >= max_paths || *visited >= max_visited { + break; + } + collect_named_paths(&entry.path(), name, paths, visited, max_paths, max_visited); + } +} + +fn normalize_openclaw_history( + path: &Path, + context: &ProviderAdapterContext, +) -> Result { + let mut paths = Vec::new(); + collect_jsonl_paths(path, &mut paths)?; + if !path.is_file() { + paths.retain(|candidate| provider_path_has_component(candidate, "sessions")); + } + paths.sort(); + if paths.is_empty() { + return Err(CaptureError::InvalidProviderTranscriptPath { + path: path.to_path_buf(), + reason: "no OpenClaw session JSONL transcripts found", + }); + } + let indexes = openclaw_session_indexes(path); + let mut merged = ProviderNormalizationResult::default(); + for transcript_path in paths { + let mut result = normalize_openclaw_jsonl_file(&transcript_path, context, &indexes)?; + merged.summary.merge(result.summary); + merged.captures.append(&mut result.captures); + merged.files_touched.append(&mut result.files_touched); + } + Ok(merged) +} + +fn normalize_openclaw_jsonl_file( + path: &Path, + context: &ProviderAdapterContext, + indexes: &BTreeMap, +) -> Result { + ensure_regular_provider_transcript_file(path)?; + let file = File::open(path)?; + let mut reader = BufReader::new(file); + let mut result = ProviderNormalizationResult::default(); + let fallback_id = path + .file_stem() + .and_then(|name| name.to_str()) + .unwrap_or("openclaw-session") + .to_owned(); + let agent_id = openclaw_agent_id(path); + let mut provider_session_id = agent_id + .as_ref() + .map(|agent| format!("{agent}/{fallback_id}")) + .unwrap_or_else(|| fallback_id.clone()); + let mut started_at = context.imported_at; + let mut cwd = None; + let mut header_raw = Value::Null; + let mut header_seen = false; + let mut line_number = 0usize; + let mut line = Vec::new(); + while read_provider_jsonl_line(&mut reader, &mut line)? { + line_number += 1; + if line.iter().all(u8::is_ascii_whitespace) { + continue; + } + let value: Value = match serde_json::from_slice(&line) { + Ok(value) => value, + Err(err) => { + result.summary.failed += 1; + result.summary.failures.push(ProviderImportFailure { + line: line_number, + error: err.to_string(), + }); + continue; + } + }; + let row_type = value + .get("type") + .and_then(Value::as_str) + .unwrap_or("message"); + if row_type == "session" { + if let Some(id) = value.get("id").and_then(Value::as_str) { + provider_session_id = agent_id + .as_ref() + .map(|agent| format!("{agent}/{id}")) + .unwrap_or_else(|| id.to_owned()); + } + started_at = provider_timestamp_value(value.get("timestamp"), context.imported_at); + cwd = value.get("cwd").and_then(Value::as_str).map(str::to_owned); + header_raw = value.clone(); + header_seen = true; + result.captures.push(( + line_number, + openclaw_capture( + OpenClawCaptureDraft { + provider_session_id: &provider_session_id, + agent_id: agent_id.as_deref(), + started_at, + ended_at: None, + cwd: cwd.clone(), + path, + indexes, + header_raw: header_raw.clone(), + event: None, + }, + context, + ), + )); + continue; + } + + let occurred_at = provider_timestamp_value(value.get("timestamp"), started_at); + let event_index = (line_number - 1) as u64; + let event = openclaw_event( + &provider_session_id, + event_index, + line_number, + &value, + occurred_at, + ); + if !header_seen { + header_seen = true; + result.captures.push(( + line_number, + openclaw_capture( + OpenClawCaptureDraft { + provider_session_id: &provider_session_id, + agent_id: agent_id.as_deref(), + started_at, + ended_at: None, + cwd: cwd.clone(), + path, + indexes, + header_raw: header_raw.clone(), + event: None, + }, + context, + ), + )); + } + result.captures.push(( + line_number, + openclaw_capture( + OpenClawCaptureDraft { + provider_session_id: &provider_session_id, + agent_id: agent_id.as_deref(), + started_at, + ended_at: None, + cwd: cwd.clone(), + path, + indexes, + header_raw: header_raw.clone(), + event: Some(event), + }, + context, + ), + )); + } + Ok(result) +} + +struct OpenClawCaptureDraft<'a> { + provider_session_id: &'a str, + agent_id: Option<&'a str>, + started_at: DateTime, + ended_at: Option>, + cwd: Option, + path: &'a Path, + indexes: &'a BTreeMap, + header_raw: Value, + event: Option, +} + +fn openclaw_capture( + draft: OpenClawCaptureDraft<'_>, + context: &ProviderAdapterContext, +) -> ProviderCaptureEnvelope { + let OpenClawCaptureDraft { + provider_session_id, + agent_id, + started_at, + ended_at, + cwd, + path, + indexes, + header_raw, + event, + } = draft; + let local_id = provider_session_id + .rsplit_once('/') + .map(|(_, id)| id) + .unwrap_or(provider_session_id); + let index = indexes + .get(provider_session_id) + .or_else(|| indexes.get(local_id)) + .cloned() + .unwrap_or(Value::Null); + native_provider_capture( + NativeSessionDraft { + provider: CaptureProvider::OpenClaw, + source_format: OPENCLAW_SOURCE_FORMAT, + provider_session_id: provider_session_id.to_owned(), + parent_provider_session_id: index + .get("parentSessionId") + .or_else(|| index.get("parent_session_id")) + .and_then(Value::as_str) + .map(str::to_owned), + root_provider_session_id: None, + external_agent_id: agent_id.map(str::to_owned), + agent_type: AgentType::Primary, + role_hint: Some("personal-agent".to_owned()), + is_primary: true, + started_at, + ended_at, + cwd, + fidelity: Fidelity::Partial, + raw_source_path: path.display().to_string(), + trust: ProviderSourceTrust::ProviderNative, + source_metadata: json!({ + "adapter": OPENCLAW_SOURCE_FORMAT, + "index": provider_capped_json(&index, PROVIDER_MAX_PREVIEW_CHARS), + "header": provider_capped_json(&header_raw, PROVIDER_MAX_PREVIEW_CHARS), + "support_level": "beta", + }), + session_metadata: json!({ + "source_format": OPENCLAW_SOURCE_FORMAT, + "agent_id": agent_id, + "session_index": provider_capped_json(&index, PROVIDER_MAX_PREVIEW_CHARS), + "fidelity_gap": "OpenClaw session JSONL is current native storage, but upstream keeps a storage-neutral accessor for future schema changes", + }), + }, + context, + event, + ) } -fn flush_pending_patch_update(out: &mut Vec, pending_update: &mut Option) { - if let Some(path) = pending_update.take() { - out.push(file_touch_draft( - path, - None, - FileChangeKind::Modified, - Confidence::Explicit, - "apply_patch_update", - )); - } +fn openclaw_event( + provider_session_id: &str, + event_index: u64, + line_number: usize, + row: &Value, + occurred_at: DateTime, +) -> ProviderEventEnvelope { + let row_type = row.get("type").and_then(Value::as_str).unwrap_or("message"); + let message = row.get("message").unwrap_or(row); + let role = message + .get("role") + .or_else(|| row.get("role")) + .and_then(Value::as_str) + .map(|role| provider_role(Some(role))); + let event_type = match row_type { + "message" => match role { + Some(EventRole::Tool) => EventType::ToolOutput, + _ => EventType::Message, + }, + "leaf" | "compaction" | "custom" => EventType::Notice, + _ => EventType::Notice, + }; + let text = message + .get("content") + .or_else(|| message.get("text")) + .or_else(|| message.get("output")) + .and_then(provider_value_text) + .unwrap_or_else(|| format!("OpenClaw {row_type}")); + native_event(NativeEventDraft { + provider: CaptureProvider::OpenClaw, + source_format: OPENCLAW_SOURCE_FORMAT, + provider_session_id: provider_session_id.to_owned(), + provider_event_index: event_index, + provider_event_hash: row.get("id").and_then(Value::as_str).map(str::to_owned), + cursor: format!("line:{line_number}"), + event_type, + role, + occurred_at, + text, + body: row.clone(), + metadata: json!({ + "source": "openclaw_jsonl", + "source_format": OPENCLAW_SOURCE_FORMAT, + "row_type": row_type, + "message_id": row.get("id").and_then(Value::as_str), + "parent_id": row.get("parentId").or_else(|| row.get("parent_id")).cloned(), + }), + }) } -fn collect_structured_file_touches(value: &Value, out: &mut Vec) { - collect_structured_file_touches_with_context(value, out, None); +#[derive(Debug, Clone)] +struct HermesSessionRow { + id: String, + source: String, + parent_session_id: Option, + model: Option, + model_config: Option, + started_at: f64, + ended_at: Option, + end_reason: Option, + message_count: i64, + tool_call_count: i64, + input_tokens: i64, + output_tokens: i64, + cache_read_tokens: i64, + cache_write_tokens: i64, + reasoning_tokens: i64, + cwd: Option, + git_branch: Option, + git_repo_root: Option, + billing_provider: Option, + billing_base_url: Option, + billing_mode: Option, + estimated_cost_usd: Option, + actual_cost_usd: Option, + title: Option, + archived: i64, } -fn collect_structured_file_touches_with_context( - value: &Value, - out: &mut Vec, - inherited_kind: Option, -) { - match value { - Value::Array(items) => { - for item in items { - collect_structured_file_touches_with_context(item, out, inherited_kind); - } - } - Value::Object(object) => { - let operation_kind = object_operation_hint_kind(object); - let object_kind = operation_kind.or(inherited_kind); - collect_structured_file_touch_object(object, out, object_kind); - for value in object.values() { - collect_structured_file_touches_with_context(value, out, object_kind); - } - } - _ => {} - } -} +#[derive(Debug, Clone)] +struct HermesMessageRow { + id: i64, + session_id: String, + role: String, + content: Option, + tool_call_id: Option, + tool_calls: Option, + tool_name: Option, + timestamp: f64, + token_count: Option, + finish_reason: Option, + reasoning: Option, + reasoning_content: Option, + reasoning_details: Option, + codex_reasoning_items: Option, + codex_message_items: Option, + platform_message_id: Option, + observed: i64, + active: i64, + compacted: i64, +} + +fn normalize_hermes_sqlite( + path: &Path, + context: &ProviderAdapterContext, +) -> Result { + let conn = open_provider_sqlite_readonly(path)?; + let user_version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; + let schema_fingerprint = opencode_schema_fingerprint(&conn)?; + let sessions = hermes_sessions(&conn)?; + let messages = hermes_messages(&conn)?; + let sessions_by_id = sessions + .into_iter() + .map(|session| (session.id.clone(), session)) + .collect::>(); + let mut result = ProviderNormalizationResult::default(); -fn collect_structured_file_touch_object( - object: &serde_json::Map, - out: &mut Vec, - inherited_kind: Option, -) { - let inferred_kind = inferred_file_change_kind(object); - let change_kind = inherited_kind.unwrap_or(inferred_kind); - let old_path = object.iter().find_map(|(key, value)| { - is_old_file_path_key(key) - .then(|| value.as_str()) - .flatten() - .and_then(normalize_file_path) - }); - for (key, value) in object { - if !is_file_path_key(key) { - continue; - } - let Some(raw_path) = value.as_str() else { + for row in messages { + let provider_event_index = + match provider_nonnegative_i64_to_u64(row.id, "Hermes message id") { + Ok(value) => value, + Err(err) => { + push_provider_import_failure(&mut result.summary, 0, err.to_string()); + continue; + } + }; + let line = provider_line_from_index(provider_event_index); + let Some(session) = sessions_by_id.get(&row.session_id) else { + push_provider_import_failure( + &mut result.summary, + line, + format!( + "Hermes message {} references missing session {}", + row.id, row.session_id + ), + ); continue; }; - if normalized_key(key) == "uri" && !raw_path.trim().starts_with("file://") { - continue; - } - let Some(path) = normalize_file_path(raw_path) else { - continue; + let provider_session_id = session.id.clone(); + let occurred_at = + match provider_required_timestamp_seconds(row.timestamp, "Hermes message timestamp") { + Ok(timestamp) => timestamp, + Err(err) => { + push_provider_import_failure(&mut result.summary, line, err.to_string()); + continue; + } + }; + let started_at = match provider_required_timestamp_seconds( + session.started_at, + "Hermes session started_at", + ) { + Ok(timestamp) => timestamp, + Err(err) => { + push_provider_import_failure(&mut result.summary, line, err.to_string()); + continue; + } }; - out.push(FileTouchDraft { - path, - old_path: old_path.clone(), - change_kind: Some(change_kind), - confidence: Confidence::High, + let ended_at = match session + .ended_at + .map(|timestamp| { + provider_required_timestamp_seconds(timestamp, "Hermes session ended_at") + }) + .transpose() + { + Ok(timestamp) => timestamp, + Err(err) => { + push_provider_import_failure(&mut result.summary, line, err.to_string()); + continue; + } + }; + let content = hermes_decode_content(row.content.as_deref()); + let text = provider_value_text(&content).unwrap_or_else(|| { + row.tool_name + .as_ref() + .map(|name| format!("tool: {name}")) + .unwrap_or_else(|| format!("Hermes {}", row.role)) + }); + let event_type = hermes_event_type(&row); + let role = Some(provider_role(Some(&row.role))); + let event = native_event(NativeEventDraft { + provider: CaptureProvider::Hermes, + source_format: HERMES_SQLITE_SOURCE_FORMAT, + provider_session_id: provider_session_id.clone(), + provider_event_index, + provider_event_hash: Some(format!("message:{}", row.id)), + cursor: format!("messages:id:{}", row.id), + event_type, + role, + occurred_at, + text, + body: json!({ + "message_id": row.id, + "role": row.role, + "content": content, + "tool_call_id": row.tool_call_id, + "tool_calls": row.tool_calls.as_deref().map(provider_json_text), + "tool_name": row.tool_name, + "reasoning": row.reasoning, + "reasoning_content": row.reasoning_content, + "reasoning_details": row.reasoning_details.as_deref().map(provider_json_text), + "codex_reasoning_items": row.codex_reasoning_items.as_deref().map(provider_json_text), + "codex_message_items": row.codex_message_items.as_deref().map(provider_json_text), + }), metadata: json!({ - "source": "structured_provider_payload", - "path_key": key, + "source": "hermes_state_db", + "source_format": HERMES_SQLITE_SOURCE_FORMAT, + "message_id": row.id, + "platform_message_id": row.platform_message_id, + "token_count": row.token_count, + "finish_reason": row.finish_reason, + "observed": row.observed != 0, + "active": row.active != 0, + "compacted": row.compacted != 0, }), }); + result.captures.push(( + line, + native_provider_capture( + NativeSessionDraft { + provider: CaptureProvider::Hermes, + source_format: HERMES_SQLITE_SOURCE_FORMAT, + provider_session_id: provider_session_id.clone(), + parent_provider_session_id: session.parent_session_id.clone(), + root_provider_session_id: None, + external_agent_id: Some(session.source.clone()), + agent_type: if session.parent_session_id.is_some() { + AgentType::Subagent + } else { + AgentType::Primary + }, + role_hint: Some(session.source.clone()), + is_primary: session.parent_session_id.is_none(), + started_at, + ended_at, + cwd: session.cwd.clone(), + fidelity: Fidelity::Imported, + raw_source_path: path.display().to_string(), + trust: ProviderSourceTrust::ProviderNative, + source_metadata: json!({ + "adapter": HERMES_SQLITE_SOURCE_FORMAT, + "sqlite_user_version": user_version, + "schema_fingerprint": schema_fingerprint, + "upstream_schema_version_at_research": 17, + }), + session_metadata: json!({ + "source_format": HERMES_SQLITE_SOURCE_FORMAT, + "source": session.source, + "title": session.title, + "model": session.model, + "model_config": session.model_config.as_deref().map(provider_json_text), + "end_reason": session.end_reason, + "message_count": session.message_count, + "tool_call_count": session.tool_call_count, + "tokens": { + "input": session.input_tokens, + "output": session.output_tokens, + "cache_read": session.cache_read_tokens, + "cache_write": session.cache_write_tokens, + "reasoning": session.reasoning_tokens, + }, + "git": { + "branch": session.git_branch, + "repo_root": session.git_repo_root, + }, + "billing": { + "provider": session.billing_provider, + "base_url": session.billing_base_url, + "mode": session.billing_mode, + "estimated_cost_usd": session.estimated_cost_usd, + "actual_cost_usd": session.actual_cost_usd, + }, + "archived": session.archived != 0, + }), + }, + context, + Some(event), + ), + )); } -} -fn object_operation_hint_kind(object: &serde_json::Map) -> Option { - object - .iter() - .any(|(key, value)| { - matches!( - normalized_key(key).as_str(), - "tool" | "name" | "action" | "command" | "operation" | "type" - ) && value.as_str().is_some_and(|text| !text.trim().is_empty()) - }) - .then(|| inferred_file_change_kind(object)) - .filter(|kind| *kind != FileChangeKind::Unknown) + Ok(result) } -fn inferred_file_change_kind(object: &serde_json::Map) -> FileChangeKind { - let mut haystack = String::new(); - for (key, value) in object { - haystack.push_str(&key.to_ascii_lowercase()); - haystack.push(' '); - if matches!( - key.to_ascii_lowercase().as_str(), - "tool" | "name" | "action" | "command" | "operation" | "type" - ) { - if let Some(text) = value.as_str() { - haystack.push_str(&text.to_ascii_lowercase()); - haystack.push(' '); - } - } - } - if haystack.contains("rename") || haystack.contains("move") { - FileChangeKind::Renamed - } else if haystack.contains("delete") || haystack.contains("remove") { - FileChangeKind::Deleted - } else if haystack.contains("create") || haystack.contains("write") || haystack.contains("add") - { - FileChangeKind::Created - } else if haystack.contains("read") || haystack.contains("view") || haystack.contains("open") { - FileChangeKind::Read - } else if object.values().any(value_looks_like_file_content) - || haystack.contains("edit") - || haystack.contains("patch") - || haystack.contains("replace") - || haystack.contains("update") +fn hermes_event_type(row: &HermesMessageRow) -> EventType { + if row.role == "tool" { + EventType::ToolOutput + } else if row + .tool_calls + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + || row + .tool_name + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) { - FileChangeKind::Modified + EventType::ToolCall } else { - FileChangeKind::Unknown + EventType::Message } } -fn value_looks_like_file_content(value: &Value) -> bool { - value.as_str().is_some_and(|text| { - text.contains('\n') - || text.len() > 120 - || text.contains("*** Begin Patch") - || text.contains("@@") - }) +fn hermes_sessions(conn: &Connection) -> Result> { + if !sqlite_table_exists(conn, "sessions")? { + return Err(CaptureError::InvalidPayload( + "Hermes state.db is missing required sessions table".into(), + )); + } + let columns = sqlite_table_columns(conn, "sessions")?; + ensure_sqlite_table_columns( + &columns, + "Hermes sessions table", + &["id", "source", "started_at"], + )?; + let parent_session_id = optional_column_expr(&columns, "parent_session_id", "NULL"); + let model = optional_column_expr(&columns, "model", "NULL"); + let model_config = optional_column_expr(&columns, "model_config", "NULL"); + let ended_at = optional_column_expr(&columns, "ended_at", "NULL"); + let end_reason = optional_column_expr(&columns, "end_reason", "NULL"); + let message_count = optional_column_expr(&columns, "message_count", "0"); + let tool_call_count = optional_column_expr(&columns, "tool_call_count", "0"); + let input_tokens = optional_column_expr(&columns, "input_tokens", "0"); + let output_tokens = optional_column_expr(&columns, "output_tokens", "0"); + let cache_read_tokens = optional_column_expr(&columns, "cache_read_tokens", "0"); + let cache_write_tokens = optional_column_expr(&columns, "cache_write_tokens", "0"); + let reasoning_tokens = optional_column_expr(&columns, "reasoning_tokens", "0"); + let cwd = optional_column_expr(&columns, "cwd", "NULL"); + let git_branch = optional_column_expr(&columns, "git_branch", "NULL"); + let git_repo_root = optional_column_expr(&columns, "git_repo_root", "NULL"); + let billing_provider = optional_column_expr(&columns, "billing_provider", "NULL"); + let billing_base_url = optional_column_expr(&columns, "billing_base_url", "NULL"); + let billing_mode = optional_column_expr(&columns, "billing_mode", "NULL"); + let estimated_cost_usd = optional_column_expr(&columns, "estimated_cost_usd", "NULL"); + let actual_cost_usd = optional_column_expr(&columns, "actual_cost_usd", "NULL"); + let title = optional_column_expr(&columns, "title", "NULL"); + let archived = optional_column_expr(&columns, "archived", "0"); + let sql = format!( + "select id, source, {parent_session_id}, {model}, {model_config}, started_at, \ + {ended_at}, {end_reason}, {message_count}, {tool_call_count}, {input_tokens}, \ + {output_tokens}, {cache_read_tokens}, {cache_write_tokens}, {reasoning_tokens}, \ + {cwd}, {git_branch}, {git_repo_root}, {billing_provider}, {billing_base_url}, \ + {billing_mode}, {estimated_cost_usd}, {actual_cost_usd}, {title}, {archived} \ + from sessions order by started_at, id" + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map([], |row| { + Ok(HermesSessionRow { + id: row.get(0)?, + source: row.get(1)?, + parent_session_id: row.get(2)?, + model: row.get(3)?, + model_config: row.get(4)?, + started_at: row.get(5)?, + ended_at: row.get(6)?, + end_reason: row.get(7)?, + message_count: row.get(8)?, + tool_call_count: row.get(9)?, + input_tokens: row.get(10)?, + output_tokens: row.get(11)?, + cache_read_tokens: row.get(12)?, + cache_write_tokens: row.get(13)?, + reasoning_tokens: row.get(14)?, + cwd: row.get(15)?, + git_branch: row.get(16)?, + git_repo_root: row.get(17)?, + billing_provider: row.get(18)?, + billing_base_url: row.get(19)?, + billing_mode: row.get(20)?, + estimated_cost_usd: row.get(21)?, + actual_cost_usd: row.get(22)?, + title: row.get(23)?, + archived: row.get(24)?, + }) + })?; + rows.collect::, _>>() + .map_err(CaptureError::from) } -fn is_file_path_key(key: &str) -> bool { - matches!( - normalized_key(key).as_str(), - "path" - | "file" - | "filepath" - | "filename" - | "targetfile" - | "targetpath" - | "relativepath" - | "absolutepath" - | "uri" - | "destinationfile" - | "destinationpath" - ) +fn hermes_messages(conn: &Connection) -> Result> { + if !sqlite_table_exists(conn, "messages")? { + return Err(CaptureError::InvalidPayload( + "Hermes state.db is missing required messages table".into(), + )); + } + let columns = sqlite_table_columns(conn, "messages")?; + ensure_sqlite_table_columns( + &columns, + "Hermes messages table", + &["id", "session_id", "role", "timestamp"], + )?; + let content = optional_column_expr(&columns, "content", "NULL"); + let tool_call_id = optional_column_expr(&columns, "tool_call_id", "NULL"); + let tool_calls = optional_column_expr(&columns, "tool_calls", "NULL"); + let tool_name = optional_column_expr(&columns, "tool_name", "NULL"); + let token_count = optional_column_expr(&columns, "token_count", "NULL"); + let finish_reason = optional_column_expr(&columns, "finish_reason", "NULL"); + let reasoning = optional_column_expr(&columns, "reasoning", "NULL"); + let reasoning_content = optional_column_expr(&columns, "reasoning_content", "NULL"); + let reasoning_details = optional_column_expr(&columns, "reasoning_details", "NULL"); + let codex_reasoning_items = optional_column_expr(&columns, "codex_reasoning_items", "NULL"); + let codex_message_items = optional_column_expr(&columns, "codex_message_items", "NULL"); + let platform_message_id = optional_column_expr(&columns, "platform_message_id", "NULL"); + let observed = optional_column_expr(&columns, "observed", "0"); + let active = optional_column_expr(&columns, "active", "1"); + let compacted = optional_column_expr(&columns, "compacted", "0"); + let visibility = if columns.contains("active") || columns.contains("compacted") { + format!("where ({active} = 1 or {compacted} = 1)") + } else { + String::new() + }; + let sql = format!( + "select id, session_id, role, {content}, {tool_call_id}, {tool_calls}, {tool_name}, \ + timestamp, {token_count}, {finish_reason}, {reasoning}, {reasoning_content}, \ + {reasoning_details}, {codex_reasoning_items}, {codex_message_items}, \ + {platform_message_id}, {observed}, {active}, {compacted} \ + from messages {visibility} order by session_id, id" + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map([], |row| { + Ok(HermesMessageRow { + id: row.get(0)?, + session_id: row.get(1)?, + role: row.get(2)?, + content: row.get(3)?, + tool_call_id: row.get(4)?, + tool_calls: row.get(5)?, + tool_name: row.get(6)?, + timestamp: row.get(7)?, + token_count: row.get(8)?, + finish_reason: row.get(9)?, + reasoning: row.get(10)?, + reasoning_content: row.get(11)?, + reasoning_details: row.get(12)?, + codex_reasoning_items: row.get(13)?, + codex_message_items: row.get(14)?, + platform_message_id: row.get(15)?, + observed: row.get(16)?, + active: row.get(17)?, + compacted: row.get(18)?, + }) + })?; + rows.collect::, _>>() + .map_err(CaptureError::from) } - -fn is_old_file_path_key(key: &str) -> bool { - matches!( - normalized_key(key).as_str(), - "oldpath" | "frompath" | "sourcepath" | "originalpath" | "previouspath" - ) + +#[derive(Debug, Clone)] +struct NanoClawSessionRow { + id: String, + agent_group_id: String, + messaging_group_id: Option, + thread_id: Option, + agent_provider: Option, + status: Option, + container_status: Option, + last_active: Option, + created_at: Option, + agent_group_name: Option, + agent_group_folder: Option, + messaging_channel_type: Option, + messaging_platform_id: Option, + messaging_instance: Option, + messaging_name: Option, } -fn normalized_key(key: &str) -> String { - key.chars() - .filter(|ch| ch.is_ascii_alphanumeric()) - .flat_map(|ch| ch.to_lowercase()) - .collect() +#[derive(Debug, Clone)] +struct NanoClawMessageRow { + source: &'static str, + id: String, + seq: Option, + kind: Option, + timestamp: Option, + status: Option, + in_reply_to: Option, + platform_id: Option, + channel_type: Option, + thread_id: Option, + content: Option, + trigger: Option, + source_session_id: Option, + on_wake: Option, +} + +fn normalize_nanoclaw_project( + path: &Path, + context: &ProviderAdapterContext, +) -> Result { + let project_root = nanoclaw_project_root(path)?; + let central_path = project_root.join("data").join("v2.db"); + let conn = open_provider_sqlite_readonly(¢ral_path)?; + let user_version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; + let schema_fingerprint = opencode_schema_fingerprint(&conn)?; + let sessions = nanoclaw_sessions(&conn)?; + let mut result = ProviderNormalizationResult::default(); + for session in sessions { + let session_dir = project_root + .join("data") + .join("v2-sessions") + .join(&session.agent_group_id) + .join(&session.id); + let mut messages = Vec::new(); + let inbound_path = session_dir.join("inbound.db"); + if inbound_path.is_file() { + messages.extend(nanoclaw_inbound_messages(&inbound_path)?); + } + let outbound_path = session_dir.join("outbound.db"); + if outbound_path.is_file() { + messages.extend(nanoclaw_outbound_messages(&outbound_path)?); + } + messages.sort_by_key(|message| { + ( + message.timestamp.unwrap_or_default(), + message.seq.unwrap_or_default(), + message.source, + message.id.clone(), + ) + }); + for message in messages { + let seq = match message + .seq + .map(|seq| provider_nonnegative_i64_to_u64(seq, "NanoClaw message seq")) + .transpose() + { + Ok(seq) => seq, + Err(err) => { + push_provider_import_failure(&mut result.summary, 0, err.to_string()); + continue; + } + }; + let provider_session_id = format!("{}/{}", session.agent_group_id, session.id); + let occurred_at = provider_timestamp_millis(message.timestamp, context.imported_at); + let started_at = provider_timestamp_millis(session.created_at, occurred_at); + let content = message + .content + .as_deref() + .map(provider_json_text) + .unwrap_or(Value::Null); + let text = provider_value_text(&content).unwrap_or_else(|| { + format!( + "NanoClaw {}", + message.kind.as_deref().unwrap_or(message.source) + ) + }); + let event_index = nanoclaw_event_index(&message, seq); + let role = if message.source == "inbound" { + Some(EventRole::User) + } else { + Some(EventRole::Assistant) + }; + let event = native_event(NativeEventDraft { + provider: CaptureProvider::NanoClaw, + source_format: NANOCLAW_SOURCE_FORMAT, + provider_session_id: provider_session_id.clone(), + provider_event_index: event_index, + provider_event_hash: Some(format!("{}:{}", message.source, message.id)), + cursor: format!( + "{}:{}:{}", + message.source, + session.id, + message.seq.unwrap_or_default() + ), + event_type: EventType::Message, + role, + occurred_at, + text, + body: json!({ + "message_id": message.id, + "seq": message.seq, + "kind": message.kind, + "content": content, + "status": message.status, + "in_reply_to": message.in_reply_to, + "platform_id": message.platform_id, + "channel_type": message.channel_type, + "thread_id": message.thread_id, + "trigger": message.trigger, + "source_session_id": message.source_session_id, + "on_wake": message.on_wake, + }), + metadata: json!({ + "source": format!("nanoclaw_{}", message.source), + "source_format": NANOCLAW_SOURCE_FORMAT, + "message_id": message.id, + "seq": message.seq, + }), + }); + result.captures.push(( + event_index.min(usize::MAX as u64) as usize, + native_provider_capture( + NativeSessionDraft { + provider: CaptureProvider::NanoClaw, + source_format: NANOCLAW_SOURCE_FORMAT, + provider_session_id: provider_session_id.clone(), + parent_provider_session_id: None, + root_provider_session_id: None, + external_agent_id: session.agent_provider.clone(), + agent_type: AgentType::Primary, + role_hint: Some("container-session".to_owned()), + is_primary: true, + started_at, + ended_at: session.last_active.map(|timestamp| { + provider_timestamp_millis(Some(timestamp), context.imported_at) + }), + cwd: session.agent_group_folder.clone(), + fidelity: Fidelity::Partial, + raw_source_path: project_root.display().to_string(), + trust: ProviderSourceTrust::ProviderNative, + source_metadata: json!({ + "adapter": NANOCLAW_SOURCE_FORMAT, + "central_db": central_path.display().to_string(), + "sqlite_user_version": user_version, + "schema_fingerprint": schema_fingerprint, + "support_level": "preview", + }), + session_metadata: json!({ + "source_format": NANOCLAW_SOURCE_FORMAT, + "session_id": session.id, + "agent_group_id": session.agent_group_id, + "agent_group_name": session.agent_group_name, + "agent_provider": session.agent_provider, + "status": session.status, + "container_status": session.container_status, + "messaging_group_id": session.messaging_group_id, + "messaging": { + "channel_type": session.messaging_channel_type, + "platform_id": session.messaging_platform_id, + "instance": session.messaging_instance, + "name": session.messaging_name, + "thread_id": session.thread_id, + }, + }), + }, + context, + Some(event), + ), + )); + } + } + Ok(result) } -fn normalize_file_path(value: &str) -> Option { - let trimmed = value.trim().trim_matches('"').trim_matches('\''); - let trimmed = trimmed.strip_prefix("file://").unwrap_or(trimmed); - if !looks_like_file_path(trimmed) { - return None; +fn nanoclaw_project_root(path: &Path) -> Result { + if path.is_dir() && path.join("data").join("v2.db").is_file() { + return Ok(path.to_path_buf()); } - Some(trimmed.to_owned()) + if path.file_name().and_then(|name| name.to_str()) == Some("v2.db") { + if let Some(data_dir) = path.parent() { + if let Some(root) = data_dir.parent() { + return Ok(root.to_path_buf()); + } + } + } + Err(CaptureError::InvalidProviderTranscriptPath { + path: path.to_path_buf(), + reason: "NanoClaw import path must be a project root or data/v2.db", + }) } -fn looks_like_file_path(value: &str) -> bool { - if value.is_empty() - || value.len() > 512 - || value.contains('\n') - || value.contains('\r') - || value.contains("://") - || value.contains("[REDACTED") - || value.starts_with('{') - || value.starts_with('[') - { - return false; +fn nanoclaw_event_index(message: &NanoClawMessageRow, seq: Option) -> u64 { + if let Some(seq) = seq { + let source_bucket = if message.source == "outbound" { + 500_000 + } else { + 0 + }; + let row_bucket = fnv1a64(format!("{}:{}", message.source, message.id).as_bytes()) % 500_000; + return seq + .saturating_mul(1_000_000) + .saturating_add(source_bucket) + .saturating_add(row_bucket); } - value.contains('/') - || value.contains('\\') - || value.starts_with('.') - || value.rsplit(['/', '\\']).next().is_some_and(|name| { - name.rsplit_once('.').is_some_and(|(stem, ext)| { - !stem.is_empty() - && !ext.is_empty() - && ext.len() <= 12 - && ext.chars().all(|ch| ch.is_ascii_alphanumeric()) - }) - }) + text_id_index(&format!("{}:{}", message.source, message.id), 2_000_000_000) } -fn file_touch_draft( - path: String, - old_path: Option, - change_kind: FileChangeKind, - confidence: Confidence, - source: &'static str, -) -> FileTouchDraft { - FileTouchDraft { - path, - old_path, - change_kind: Some(change_kind), - confidence, - metadata: json!({ "source": source }), +fn nanoclaw_sessions(conn: &Connection) -> Result> { + if !sqlite_table_exists(conn, "sessions")? { + return Err(CaptureError::InvalidPayload( + "NanoClaw data/v2.db is missing required sessions table".into(), + )); } + let columns = sqlite_table_columns(conn, "sessions")?; + ensure_sqlite_table_columns( + &columns, + "NanoClaw sessions table", + &["id", "agent_group_id"], + )?; + let messaging_group_id = optional_column_expr(&columns, "messaging_group_id", "NULL"); + let thread_id = optional_column_expr(&columns, "thread_id", "NULL"); + let agent_provider = optional_column_expr(&columns, "agent_provider", "NULL"); + let status = optional_column_expr(&columns, "status", "NULL"); + let container_status = optional_column_expr(&columns, "container_status", "NULL"); + let last_active = optional_column_expr(&columns, "last_active", "NULL"); + let created_at = optional_column_expr(&columns, "created_at", "NULL"); + let agent_group_columns = if sqlite_table_exists(conn, "agent_groups")? { + sqlite_table_columns(conn, "agent_groups")? + } else { + BTreeSet::new() + }; + let agent_group_name = + if agent_group_columns.contains("id") && agent_group_columns.contains("name") { + "(select name from agent_groups where agent_groups.id = sessions.agent_group_id)" + } else { + "NULL" + }; + let agent_group_folder = + if agent_group_columns.contains("id") && agent_group_columns.contains("folder") { + "(select folder from agent_groups where agent_groups.id = sessions.agent_group_id)" + } else { + "NULL" + }; + let (messaging_channel_type, messaging_platform_id, messaging_instance, messaging_name) = + if columns.contains("messaging_group_id") && sqlite_table_exists(conn, "messaging_groups")? + { + let messaging_columns = sqlite_table_columns(conn, "messaging_groups")?; + ( + if messaging_columns.contains("id") && messaging_columns.contains("channel_type") { + "(select channel_type from messaging_groups where messaging_groups.id = sessions.messaging_group_id)" + } else { + "NULL" + }, + if messaging_columns.contains("id") && messaging_columns.contains("platform_id") { + "(select platform_id from messaging_groups where messaging_groups.id = sessions.messaging_group_id)" + } else { + "NULL" + }, + if messaging_columns.contains("id") && messaging_columns.contains("instance") { + "(select instance from messaging_groups where messaging_groups.id = sessions.messaging_group_id)" + } else { + "NULL" + }, + if messaging_columns.contains("id") && messaging_columns.contains("name") { + "(select name from messaging_groups where messaging_groups.id = sessions.messaging_group_id)" + } else { + "NULL" + }, + ) + } else { + ("NULL", "NULL", "NULL", "NULL") + }; + let sql = format!( + "select id, agent_group_id, {messaging_group_id}, {thread_id}, {agent_provider}, \ + {status}, {container_status}, {last_active}, {created_at}, {agent_group_name}, \ + {agent_group_folder}, {messaging_channel_type}, {messaging_platform_id}, \ + {messaging_instance}, {messaging_name} from sessions order by created_at, id" + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map([], |row| { + Ok(NanoClawSessionRow { + id: row.get(0)?, + agent_group_id: row.get(1)?, + messaging_group_id: row.get(2)?, + thread_id: row.get(3)?, + agent_provider: row.get(4)?, + status: row.get(5)?, + container_status: row.get(6)?, + last_active: row.get(7)?, + created_at: row.get(8)?, + agent_group_name: row.get(9)?, + agent_group_folder: row.get(10)?, + messaging_channel_type: row.get(11)?, + messaging_platform_id: row.get(12)?, + messaging_instance: row.get(13)?, + messaging_name: row.get(14)?, + }) + })?; + rows.collect::, _>>() + .map_err(CaptureError::from) } -fn provider_value_text(value: &Value) -> Option { - match value { - Value::String(text) => Some(text.clone()), - Value::Array(blocks) => { - let mut parts = Vec::new(); - for block in blocks { - if let Some(text) = block - .get("text") - .or_else(|| block.get("content")) - .or_else(|| block.get("output")) - .or_else(|| block.get("summary")) - .and_then(Value::as_str) - { - parts.push(text.to_owned()); - continue; - } - if let Some(kind) = block.get("type").and_then(Value::as_str) { - if matches!( - kind, - "tool_use" | "tool" | "toolCall" | "function_call" | "agent" - ) { - let name = block - .get("name") - .or_else(|| block.get("tool")) - .and_then(Value::as_str) - .unwrap_or("tool"); - parts.push(format!("tool call: {name}")); - } else if kind == "tool_result" { - parts.push("tool result".to_owned()); - } - } - } - (!parts.is_empty()).then(|| parts.join("\n")) - } - Value::Object(_) => serde_json::to_string(value).ok(), - Value::Number(_) | Value::Bool(_) => Some(value.to_string()), - Value::Null => None, +fn nanoclaw_inbound_messages(path: &Path) -> Result> { + let conn = open_provider_sqlite_readonly(path)?; + if !sqlite_table_exists(&conn, "messages_in")? { + return Ok(Vec::new()); } + let columns = sqlite_table_columns(&conn, "messages_in")?; + ensure_sqlite_table_columns(&columns, "NanoClaw inbound messages table", &["id"])?; + let seq = optional_column_expr(&columns, "seq", "NULL"); + let kind = optional_column_expr(&columns, "kind", "NULL"); + let timestamp = optional_column_expr(&columns, "timestamp", "NULL"); + let status = optional_column_expr(&columns, "status", "NULL"); + let trigger = optional_column_expr(&columns, "trigger", "NULL"); + let platform_id = optional_column_expr(&columns, "platform_id", "NULL"); + let channel_type = optional_column_expr(&columns, "channel_type", "NULL"); + let thread_id = optional_column_expr(&columns, "thread_id", "NULL"); + let content = optional_column_expr(&columns, "content", "NULL"); + let source_session_id = optional_column_expr(&columns, "source_session_id", "NULL"); + let on_wake = optional_column_expr(&columns, "on_wake", "NULL"); + let sql = format!( + "select id, {seq}, {kind}, {timestamp}, {status}, {trigger}, {platform_id}, \ + {channel_type}, {thread_id}, {content}, {source_session_id}, {on_wake} \ + from messages_in order by {seq}, id" + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map([], |row| { + Ok(NanoClawMessageRow { + source: "inbound", + id: row.get(0)?, + seq: row.get(1)?, + kind: row.get(2)?, + timestamp: row.get(3)?, + status: row.get(4)?, + trigger: row.get(5)?, + platform_id: row.get(6)?, + channel_type: row.get(7)?, + thread_id: row.get(8)?, + content: row.get(9)?, + source_session_id: row.get(10)?, + on_wake: row.get(11)?, + in_reply_to: None, + }) + })?; + rows.collect::, _>>() + .map_err(CaptureError::from) } -fn provider_role(value: Option<&str>) -> EventRole { - match value { - Some("user") => EventRole::User, - Some("assistant") => EventRole::Assistant, - Some("system" | "developer") => EventRole::System, - Some("tool" | "toolResult" | "bashExecution") => EventRole::Tool, - _ => EventRole::Unknown, +fn nanoclaw_outbound_messages(path: &Path) -> Result> { + let conn = open_provider_sqlite_readonly(path)?; + if !sqlite_table_exists(&conn, "messages_out")? { + return Ok(Vec::new()); } + let columns = sqlite_table_columns(&conn, "messages_out")?; + ensure_sqlite_table_columns(&columns, "NanoClaw outbound messages table", &["id"])?; + let seq = optional_column_expr(&columns, "seq", "NULL"); + let kind = optional_column_expr(&columns, "kind", "NULL"); + let timestamp = optional_column_expr(&columns, "timestamp", "NULL"); + let in_reply_to = optional_column_expr(&columns, "in_reply_to", "NULL"); + let platform_id = optional_column_expr(&columns, "platform_id", "NULL"); + let channel_type = optional_column_expr(&columns, "channel_type", "NULL"); + let thread_id = optional_column_expr(&columns, "thread_id", "NULL"); + let content = optional_column_expr(&columns, "content", "NULL"); + let sql = format!( + "select id, {seq}, {kind}, {timestamp}, {in_reply_to}, {platform_id}, \ + {channel_type}, {thread_id}, {content} from messages_out order by {seq}, id" + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map([], |row| { + Ok(NanoClawMessageRow { + source: "outbound", + id: row.get(0)?, + seq: row.get(1)?, + kind: row.get(2)?, + timestamp: row.get(3)?, + in_reply_to: row.get(4)?, + platform_id: row.get(5)?, + channel_type: row.get(6)?, + thread_id: row.get(7)?, + content: row.get(8)?, + status: None, + trigger: None, + source_session_id: None, + on_wake: None, + }) + })?; + rows.collect::, _>>() + .map_err(CaptureError::from) } -fn normalize_claude_projects_jsonl_file( +fn normalize_shelley_sqlite( path: &Path, context: &ProviderAdapterContext, ) -> Result { - ensure_regular_provider_transcript_file(path)?; - let file = File::open(path)?; - let reader = BufReader::new(file); + let conn = open_provider_sqlite_readonly(path)?; + let user_version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; + let schema_fingerprint = opencode_schema_fingerprint(&conn)?; + let conversations = shelley_conversations(&conn)?; + let messages = shelley_messages(&conn)?; + let conversations_by_id = conversations + .iter() + .map(|conversation| (conversation.conversation_id.clone(), conversation)) + .collect::>(); + let mut seen_message_conversations = BTreeSet::new(); + let raw_source_path = path.display().to_string(); let mut result = ProviderNormalizationResult::default(); - let mut rows = Vec::new(); - - for (index, line) in reader.lines().enumerate() { - let line_number = index + 1; - let line = line?; - if line.trim().is_empty() { - continue; - } - let value: Value = match serde_json::from_str(&line) { - Ok(value) => value, - Err(err) => { - result.summary.failed += 1; - result.summary.failures.push(ProviderImportFailure { - line: line_number, - error: format!("malformed JSONL: {err}"), - }); - continue; - } - }; - let timestamp = value - .get("timestamp") - .and_then(Value::as_str) - .and_then(parse_rfc3339_utc) - .unwrap_or(context.imported_at); - rows.push((line_number, value, timestamp)); - } - if rows.is_empty() { - return Ok(result); - } - let first = &rows[0].1; - let file_stem = path - .file_stem() - .and_then(|name| name.to_str()) - .unwrap_or("unknown-session"); - let native_session_id = first - .get("sessionId") - .and_then(Value::as_str) - .filter(|id| !id.trim().is_empty()) - .unwrap_or(file_stem) - .to_owned(); - let (provider_session_id, parent_provider_session_id, external_agent_id, is_subagent) = - claude_path_session_ids(path, &native_session_id); - let started_at = rows - .iter() - .map(|(_, _, timestamp)| *timestamp) - .min() - .unwrap_or(context.imported_at); - let cwd = first - .get("cwd") - .and_then(Value::as_str) - .filter(|cwd| !cwd.trim().is_empty()) - .map(str::to_owned); - let version = first - .get("version") - .and_then(Value::as_str) - .map(str::to_owned); - let git_branch = first - .get("gitBranch") - .and_then(Value::as_str) - .map(str::to_owned); + for message in messages { + let Some(conversation) = conversations_by_id.get(&message.conversation_id) else { + result.summary.failed += 1; + result.summary.failures.push(ProviderImportFailure { + line: message.sequence_id.max(0) as usize, + error: format!( + "Shelley message {} references missing conversation {}", + message.message_id, message.conversation_id + ), + }); + continue; + }; + seen_message_conversations.insert(message.conversation_id.clone()); + let started_at = shelley_timestamp(conversation.created_at.as_deref(), context.imported_at); + let ended_at = conversation + .updated_at + .as_deref() + .map(|timestamp| shelley_timestamp(Some(timestamp), context.imported_at)); + let occurred_at = shelley_timestamp(message.created_at.as_deref(), started_at); + let body = shelley_message_body(&message); + let text = shelley_message_text(&message, &body) + .unwrap_or_else(|| format!("Shelley {} message", message.entry_type)); + let event_type = shelley_event_type(&message, &body); + let role = shelley_event_role(&message.entry_type); + let event = native_event(NativeEventDraft { + provider: CaptureProvider::Shelley, + source_format: SHELLEY_SQLITE_SOURCE_FORMAT, + provider_session_id: conversation.conversation_id.clone(), + provider_event_index: shelley_event_index(&message), + provider_event_hash: Some(message.message_id.clone()), + cursor: format!( + "conversation:{}:sequence:{}:message:{}", + message.conversation_id, message.sequence_id, message.message_id + ), + event_type, + role, + occurred_at, + text, + body, + metadata: json!({ + "source": "shelley_messages", + "source_format": SHELLEY_SQLITE_SOURCE_FORMAT, + "message_id": message.message_id, + "conversation_id": message.conversation_id, + "sequence_id": message.sequence_id, + "rowid": message.rowid, + "message_type": message.entry_type, + "generation": message.generation, + "excluded_from_context": message.excluded_from_context, + "usage": message.usage_data.as_deref().map(provider_json_text), + "llm_api_url": message.llm_api_url, + "model_name": message.model_name, + "forked_from_message_id": message.forked_from_message_id, + }), + }); + result.captures.push(( + message.rowid.max(0) as usize, + shelley_capture( + ShelleyCaptureDraft { + conversation, + started_at, + ended_at, + raw_source_path: &raw_source_path, + user_version, + schema_fingerprint: &schema_fingerprint, + event: Some(event), + }, + context, + ), + )); + } - for (line_number, value, occurred_at) in rows { - let event = claude_event(&value, line_number, occurred_at); - if let Some(event) = &event { - result - .files_touched - .extend(provider_file_touches_from_raw_value( - CaptureProvider::Claude, - &provider_session_id, - CLAUDE_PROJECTS_SOURCE_FORMAT, - &value, - event, - line_number, - )); + for conversation in conversations { + if seen_message_conversations.contains(&conversation.conversation_id) { + continue; } + let started_at = shelley_timestamp(conversation.created_at.as_deref(), context.imported_at); + let ended_at = conversation + .updated_at + .as_deref() + .map(|timestamp| shelley_timestamp(Some(timestamp), context.imported_at)); result.captures.push(( - line_number, - ProviderCaptureEnvelope { - schema_version: PROVIDER_CAPTURE_ENVELOPE_SCHEMA_VERSION, - provider: CaptureProvider::Claude, - source: ProviderSourceEnvelope { - source_format: CLAUDE_PROJECTS_SOURCE_FORMAT.to_owned(), - machine_id: context.machine_id.clone(), - observed_at: context.imported_at, - raw_source_path: Some(path.display().to_string()), - raw_retention: ProviderRawRetention::PathReference, - redaction_boundary: ProviderRedactionBoundary::BeforeExport, - trust: ProviderSourceTrust::ProviderNative, - fidelity: Fidelity::Imported, - cursor: Some(ProviderCursorRange { - before: None, - after: Some(ProviderCursorCheckpoint { - stream: provider_cursor_stream( - CaptureProvider::Claude, - CLAUDE_PROJECTS_SOURCE_FORMAT, - ), - cursor: format!("{}:line:{line_number}", path.display()), - observed_at: occurred_at, - }), - }), - idempotency_key: Some(format!( - "provider-source:claude:{CLAUDE_PROJECTS_SOURCE_FORMAT}:{provider_session_id}" - )), - metadata: json!({ - "adapter": CLAUDE_PROJECTS_SOURCE_FORMAT, - "native_session_id": native_session_id, - "source_path": path.display().to_string(), - }), - }, - session: ProviderSessionEnvelope { - provider_session_id: provider_session_id.clone(), - parent_provider_session_id: parent_provider_session_id.clone(), - root_provider_session_id: parent_provider_session_id.clone(), - external_agent_id: external_agent_id.clone(), - agent_type: if is_subagent { - AgentType::Subagent - } else { - AgentType::Primary - }, - role_hint: Some(if is_subagent { "subagent" } else { "primary" }.to_owned()), - is_primary: !is_subagent, - status: SessionStatus::Imported, + 0, + shelley_capture( + ShelleyCaptureDraft { + conversation: &conversation, started_at, - ended_at: None, - cwd: cwd.clone(), - fidelity: Fidelity::Imported, - idempotency_key: Some(format!("provider-session:claude:{provider_session_id}")), - artifacts: Vec::new(), - metadata: json!({ - "source_format": CLAUDE_PROJECTS_SOURCE_FORMAT, - "native_session_id": native_session_id, - "version": version, - "git_branch": git_branch, - "source_path": path.display().to_string(), - "limitations": [ - "binary attachments are referenced by native payload metadata but not expanded", - "previews are capped before local indexing/export" - ], - }), + ended_at, + raw_source_path: &raw_source_path, + user_version, + schema_fingerprint: &schema_fingerprint, + event: None, }, - event, - }, + context, + ), )); } Ok(result) } -fn claude_path_session_ids( - path: &Path, - native_session_id: &str, -) -> (String, Option, Option, bool) { - let Some(parent) = path.parent() else { - return (native_session_id.to_owned(), None, None, false); - }; - if parent.file_name().and_then(|name| name.to_str()) == Some("subagents") { - let parent_session_id = parent - .parent() - .and_then(Path::file_name) - .and_then(|name| name.to_str()) - .filter(|name| !name.trim().is_empty()) - .unwrap_or(native_session_id) - .to_owned(); - let agent_id = path - .file_stem() - .and_then(|name| name.to_str()) - .filter(|name| !name.trim().is_empty()) - .unwrap_or("subagent") - .to_owned(); - return ( - format!("{parent_session_id}/subagents/{agent_id}"), - Some(parent_session_id), - Some(agent_id), - true, - ); +struct ShelleyCaptureDraft<'a> { + conversation: &'a ShelleyConversationRow, + started_at: DateTime, + ended_at: Option>, + raw_source_path: &'a str, + user_version: i64, + schema_fingerprint: &'a str, + event: Option, +} + +fn shelley_capture( + draft: ShelleyCaptureDraft<'_>, + context: &ProviderAdapterContext, +) -> ProviderCaptureEnvelope { + let ShelleyCaptureDraft { + conversation, + started_at, + ended_at, + raw_source_path, + user_version, + schema_fingerprint, + event, + } = draft; + let is_subagent = conversation.parent_conversation_id.is_some() || !conversation.user_initiated; + let conversation_options = conversation + .conversation_options + .as_deref() + .map(provider_json_text) + .unwrap_or(Value::Null); + let tags = conversation + .tags + .as_deref() + .map(provider_json_text) + .unwrap_or(Value::Null); + let queued_messages = conversation + .queued_messages + .as_deref() + .map(provider_json_text) + .unwrap_or(Value::Null); + native_provider_capture( + NativeSessionDraft { + provider: CaptureProvider::Shelley, + source_format: SHELLEY_SQLITE_SOURCE_FORMAT, + provider_session_id: conversation.conversation_id.clone(), + parent_provider_session_id: conversation.parent_conversation_id.clone(), + root_provider_session_id: conversation.parent_conversation_id.clone(), + external_agent_id: None, + agent_type: if is_subagent { + AgentType::Subagent + } else { + AgentType::Primary + }, + role_hint: Some(if is_subagent { "subagent" } else { "primary" }.to_owned()), + is_primary: !is_subagent, + started_at, + ended_at, + cwd: conversation.cwd.clone(), + fidelity: Fidelity::Imported, + raw_source_path: raw_source_path.to_owned(), + trust: ProviderSourceTrust::ProviderNative, + source_metadata: json!({ + "adapter": SHELLEY_SQLITE_SOURCE_FORMAT, + "sqlite_user_version": user_version, + "schema_fingerprint": schema_fingerprint, + "source_path": raw_source_path, + }), + session_metadata: json!({ + "source_format": SHELLEY_SQLITE_SOURCE_FORMAT, + "conversation_id": conversation.conversation_id, + "slug": conversation.slug, + "title": conversation.slug, + "user_initiated": conversation.user_initiated, + "archived": conversation.archived, + "parent_conversation_id": conversation.parent_conversation_id, + "model": conversation.model, + "conversation_options": conversation_options, + "current_generation": conversation.current_generation, + "agent_working": conversation.agent_working, + "tags": tags, + "is_draft": conversation.is_draft, + "draft": conversation.draft, + "queued_messages": queued_messages, + }), + }, + context, + event, + ) +} + +fn shelley_conversations(conn: &Connection) -> Result> { + if !sqlite_table_exists(conn, "conversations")? { + return Err(CaptureError::InvalidPayload( + "Shelley shelley.db is missing required conversations table".into(), + )); } - (native_session_id.to_owned(), None, None, false) + let columns = sqlite_table_columns(conn, "conversations")?; + ensure_sqlite_table_columns( + &columns, + "Shelley conversations table", + &["conversation_id"], + )?; + let slug = optional_column_expr(&columns, "slug", "NULL"); + let user_initiated = optional_column_expr(&columns, "user_initiated", "1"); + let created_at = optional_column_expr(&columns, "created_at", "NULL"); + let updated_at = optional_column_expr(&columns, "updated_at", "NULL"); + let cwd = optional_column_expr(&columns, "cwd", "NULL"); + let archived = optional_column_expr(&columns, "archived", "0"); + let parent_conversation_id = optional_column_expr(&columns, "parent_conversation_id", "NULL"); + let model = optional_column_expr(&columns, "model", "NULL"); + let conversation_options = optional_column_expr(&columns, "conversation_options", "NULL"); + let current_generation = optional_column_expr(&columns, "current_generation", "NULL"); + let agent_working = optional_column_expr(&columns, "agent_working", "0"); + let tags = optional_column_expr(&columns, "tags", "NULL"); + let is_draft = optional_column_expr(&columns, "is_draft", "0"); + let draft = optional_column_expr(&columns, "draft", "NULL"); + let queued_messages = optional_column_expr(&columns, "queued_messages", "NULL"); + let sql = format!( + "select conversation_id, {slug}, {user_initiated}, {created_at}, {updated_at}, \ + {cwd}, {archived}, {parent_conversation_id}, {model}, {conversation_options}, \ + {current_generation}, {agent_working}, {tags}, {is_draft}, {draft}, \ + {queued_messages} \ + from conversations order by {created_at}, conversation_id" + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map([], |row| { + Ok(ShelleyConversationRow { + conversation_id: row.get(0)?, + slug: row.get(1)?, + user_initiated: sqlite_bool(row.get::<_, Option>(2)?), + created_at: row.get(3)?, + updated_at: row.get(4)?, + cwd: row.get(5)?, + archived: sqlite_bool(row.get::<_, Option>(6)?), + parent_conversation_id: row.get(7)?, + model: row.get(8)?, + conversation_options: row.get(9)?, + current_generation: row.get(10)?, + agent_working: sqlite_bool(row.get::<_, Option>(11)?), + tags: row.get(12)?, + is_draft: sqlite_bool(row.get::<_, Option>(13)?), + draft: row.get(14)?, + queued_messages: row.get(15)?, + }) + })?; + rows.collect::, _>>() + .map_err(CaptureError::from) } -fn claude_event( - value: &Value, - line_number: usize, - occurred_at: DateTime, -) -> Option { - let entry_type = value - .get("type") - .and_then(Value::as_str) - .unwrap_or("unknown"); - let message = value.get("message").unwrap_or(value); - let message_role = message - .get("role") - .and_then(Value::as_str) - .or_else(|| value.get("role").and_then(Value::as_str)); - let null = Value::Null; - let content = message.get("content").unwrap_or(&null); - let event_type = claude_event_type(entry_type, message); - let role = Some(provider_role(message_role)); - let text = provider_value_text(content).unwrap_or_else(|| { - if event_type == EventType::Notice { - format!("Claude event: {entry_type}") - } else { - String::new() - } - }); - let (text, truncated) = provider_safe_preview(&text, PROVIDER_MAX_TEXT_CHARS); +fn shelley_messages(conn: &Connection) -> Result> { + if !sqlite_table_exists(conn, "messages")? { + return Err(CaptureError::InvalidPayload( + "Shelley shelley.db is missing required messages table".into(), + )); + } + let columns = sqlite_table_columns(conn, "messages")?; + ensure_sqlite_table_columns( + &columns, + "Shelley messages table", + &["message_id", "conversation_id", "type"], + )?; + let sequence_id = optional_column_expr(&columns, "sequence_id", "rowid"); + let llm_data = optional_column_expr(&columns, "llm_data", "NULL"); + let user_data = optional_column_expr(&columns, "user_data", "NULL"); + let usage_data = optional_column_expr(&columns, "usage_data", "NULL"); + let created_at = optional_column_expr(&columns, "created_at", "NULL"); + let display_data = optional_column_expr(&columns, "display_data", "NULL"); + let excluded_from_context = optional_column_expr(&columns, "excluded_from_context", "0"); + let generation = optional_column_expr(&columns, "generation", "NULL"); + let llm_api_url = optional_column_expr(&columns, "llm_api_url", "NULL"); + let model_name = optional_column_expr(&columns, "model_name", "NULL"); + let forked_from_message_id = optional_column_expr(&columns, "forked_from_message_id", "NULL"); + let sql = format!( + "select rowid, message_id, conversation_id, {sequence_id}, type, {llm_data}, \ + {user_data}, {usage_data}, {created_at}, {display_data}, \ + {excluded_from_context}, {generation}, {llm_api_url}, {model_name}, \ + {forked_from_message_id} from messages order by conversation_id, {sequence_id}, rowid" + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map([], |row| { + Ok(ShelleyMessageRow { + rowid: row.get(0)?, + message_id: row.get(1)?, + conversation_id: row.get(2)?, + sequence_id: row.get(3)?, + entry_type: row.get(4)?, + llm_data: row.get(5)?, + user_data: row.get(6)?, + usage_data: row.get(7)?, + created_at: row.get(8)?, + display_data: row.get(9)?, + excluded_from_context: sqlite_bool(row.get::<_, Option>(10)?), + generation: row.get(11)?, + llm_api_url: row.get(12)?, + model_name: row.get(13)?, + forked_from_message_id: row.get(14)?, + }) + })?; + rows.collect::, _>>() + .map_err(CaptureError::from) +} - Some(ProviderEventEnvelope { - provider_event_index: (line_number - 1) as u64, - provider_event_hash: value.get("uuid").and_then(Value::as_str).map(str::to_owned), - cursor: value.get("uuid").and_then(Value::as_str).map(str::to_owned), - event_type, - role, - occurred_at, - fidelity: Fidelity::Imported, - redaction_state: RedactionState::SafePreview, - idempotency_key: value - .get("uuid") - .and_then(Value::as_str) - .map(|uuid| format!("provider-event:claude:{uuid}")), - artifacts: Vec::new(), - payload: json!({ - "entry_type": entry_type, - "uuid": value.get("uuid").and_then(Value::as_str), - "parent_uuid": value.get("parentUuid").and_then(Value::as_str), - "message_id": message.get("id").and_then(Value::as_str), - "request_id": value.get("requestId").and_then(Value::as_str), - "role": message_role, - "text": text, - "truncated": truncated, - "content_preview": provider_capped_json(content, PROVIDER_MAX_PREVIEW_CHARS), - }), - metadata: json!({ - "source": "claude_projects_jsonl", - "source_format": CLAUDE_PROJECTS_SOURCE_FORMAT, - "line": line_number, - "entry_type": entry_type, - "model": message.get("model").and_then(Value::as_str), - "usage": message.get("usage").cloned(), - "stop_reason": message.get("stop_reason").and_then(Value::as_str), - "is_sidechain": value.get("isSidechain").and_then(Value::as_bool), - "tool_use_result": value.get("toolUseResult").cloned(), - }), +fn sqlite_bool(value: Option) -> bool { + value.unwrap_or(0) != 0 +} + +fn shelley_timestamp(raw: Option<&str>, fallback: DateTime) -> DateTime { + let Some(raw) = raw.map(str::trim).filter(|raw| !raw.is_empty()) else { + return fallback; + }; + parse_rfc3339_utc(raw) + .or_else(|| { + NaiveDateTime::parse_from_str(raw, "%Y-%m-%d %H:%M:%S%.f") + .ok() + .map(|naive| DateTime::::from_naive_utc_and_offset(naive, Utc)) + }) + .unwrap_or(fallback) +} + +fn shelley_message_body(message: &ShelleyMessageRow) -> Value { + json!({ + "message_id": message.message_id, + "conversation_id": message.conversation_id, + "sequence_id": message.sequence_id, + "type": message.entry_type, + "llm_data": message.llm_data.as_deref().map(provider_json_text), + "user_data": message.user_data.as_deref().map(provider_json_text), + "display_data": message.display_data.as_deref().map(provider_json_text), + "usage_data": message.usage_data.as_deref().map(provider_json_text), + }) +} + +fn shelley_message_text(message: &ShelleyMessageRow, body: &Value) -> Option { + let mut parts = Vec::new(); + for pointer in ["/user_data", "/llm_data", "/display_data"] { + if let Some(text) = body.pointer(pointer).and_then(shelley_value_text) { + parts.push(text); + } + } + if parts.is_empty() && message.entry_type == "system" { + Some("Shelley system message".to_owned()) + } else if parts.is_empty() { + None + } else { + Some(parts.join("\n")) + } +} + +fn shelley_event_role(entry_type: &str) -> Option { + Some(match entry_type { + "user" => EventRole::User, + "agent" | "assistant" => EventRole::Assistant, + "tool" => EventRole::Tool, + "system" | "error" | "gitinfo" | "warning" | "modelchange" => EventRole::System, + _ => EventRole::Unknown, }) } -fn claude_event_type(entry_type: &str, message: &Value) -> EventType { - if claude_content_has_type(message.get("content"), "tool_result") - || message.get("toolUseResult").is_some() - { - return EventType::ToolOutput; - } - if claude_content_has_type(message.get("content"), "tool_use") { - return EventType::ToolCall; - } - match entry_type { - "user" | "assistant" => EventType::Message, - "system" - | "progress" - | "permission-mode" - | "last-prompt" - | "queue-operation" - | "attachment" - | "file-history-snapshot" - | "ai-title" => EventType::Notice, +fn shelley_event_type(message: &ShelleyMessageRow, body: &Value) -> EventType { + match message.entry_type.as_str() { + "tool" => EventType::ToolOutput, + "gitinfo" => EventType::VcsChange, + "system" | "error" | "warning" | "modelchange" => EventType::Notice, + "agent" | "assistant" if shelley_value_has_tool_use(body) => EventType::ToolCall, + "user" | "agent" | "assistant" if shelley_value_has_tool_result(body) => { + EventType::ToolOutput + } + "user" | "agent" | "assistant" => EventType::Message, _ => EventType::Notice, } } -fn claude_content_has_type(content: Option<&Value>, expected: &str) -> bool { - content - .and_then(Value::as_array) - .map(|blocks| { - blocks - .iter() - .any(|block| block.get("type").and_then(Value::as_str) == Some(expected)) - }) - .unwrap_or(false) +fn shelley_event_index(message: &ShelleyMessageRow) -> u64 { + let sequence = message.sequence_id.max(0) as u64; + let bucket = text_id_index( + &format!("{}:{}", message.conversation_id, message.message_id), + 4_096, + ); + sequence.saturating_mul(4_096).saturating_add(bucket) } -fn provider_capped_json(value: &Value, max_chars: usize) -> Value { +fn shelley_value_has_tool_use(value: &Value) -> bool { match value { - Value::Null => Value::Null, - Value::String(text) => { - let (text, truncated) = provider_safe_preview(text, max_chars); - json!({ "text": text, "truncated": truncated }) + Value::Array(items) => items.iter().any(shelley_value_has_tool_use), + Value::Object(object) => { + let content_type = shelley_content_type(value); + matches!( + content_type.as_deref(), + Some("tool_use" | "server_tool_use") + ) || object.values().any(shelley_value_has_tool_use) } - _ => { - let rendered = serde_json::to_string(value).unwrap_or_else(|_| value.to_string()); - let (json_text, truncated) = provider_safe_preview(&rendered, max_chars); - json!({ "json": json_text, "truncated": truncated }) + _ => false, + } +} + +fn shelley_value_has_tool_result(value: &Value) -> bool { + match value { + Value::Array(items) => items.iter().any(shelley_value_has_tool_result), + Value::Object(object) => { + let content_type = shelley_content_type(value); + matches!( + content_type.as_deref(), + Some("tool_result" | "web_search_tool_result" | "web_search_result") + ) || object.values().any(shelley_value_has_tool_result) } + _ => false, } } -fn provider_capped_json_value(value: &Value, max_string_chars: usize) -> Value { +fn shelley_value_text(value: &Value) -> Option { + let mut parts = Vec::new(); + shelley_collect_text(value, &mut parts); + (!parts.is_empty()).then(|| parts.join("\n")) +} + +fn shelley_collect_text(value: &Value, parts: &mut Vec) { match value { - Value::String(text) => { - let (text, truncated) = provider_safe_preview(text, max_string_chars); - if truncated { - json!({ "text": text, "truncated": true }) - } else { - Value::String(text) + Value::String(text) => shelley_push_text(parts, text), + Value::Array(items) => { + for item in items { + if shelley_text_budget_remaining(parts) == 0 { + break; + } + shelley_collect_text(item, parts); } } - Value::Array(items) => Value::Array( - items - .iter() - .map(|item| provider_capped_json_value(item, max_string_chars)) - .collect(), - ), - Value::Object(object) => Value::Object( - object - .iter() - .map(|(key, value)| { - ( - key.clone(), - provider_capped_json_value(value, max_string_chars), - ) - }) - .collect(), - ), - _ => value.clone(), + Value::Object(object) => { + if let Some(kind) = shelley_content_type(value) { + let handled = match kind.as_str() { + "text" => { + if let Some(text) = object.get("Text").and_then(Value::as_str) { + shelley_push_text(parts, text); + } + true + } + "thinking" | "redacted_thinking" => { + if let Some(text) = object.get("Thinking").and_then(Value::as_str) { + shelley_push_text(parts, text); + } + true + } + "tool_use" | "server_tool_use" => { + let name = object + .get("ToolName") + .and_then(Value::as_str) + .unwrap_or("tool"); + shelley_push_text(parts, &format!("tool call: {name}")); + if let Some(input) = object.get("ToolInput") { + if !input.is_null() { + let input = provider_capped_json(input, PROVIDER_MAX_PREVIEW_CHARS); + shelley_push_text(parts, &format!("tool input: {input}")); + } + } + true + } + "tool_result" | "web_search_tool_result" => { + shelley_push_text(parts, "tool result"); + if let Some(results) = object.get("ToolResult") { + shelley_collect_text(results, parts); + } + if let Some(display) = object.get("Display") { + shelley_collect_text(display, parts); + } + true + } + "web_search_result" => { + for key in ["Title", "URL", "PageAge"] { + if let Some(text) = object.get(key).and_then(Value::as_str) { + shelley_push_text(parts, text); + } + } + true + } + _ => false, + }; + if handled { + return; + } + } + + for key in [ + "Text", + "text", + "Thinking", + "thinking", + "content", + "Content", + "output", + "Output", + "summary", + "Summary", + "message", + "Message", + "error", + "Error", + "LLMContent", + "ToolResult", + "Display", + ] { + if shelley_text_budget_remaining(parts) == 0 { + break; + } + if let Some(child) = object.get(key) { + shelley_collect_text(child, parts); + } + } + } + Value::Number(_) | Value::Bool(_) | Value::Null => {} } } -fn antigravity_tool_call_text(value: &Value) -> Option { - value.as_array().and_then(|calls| { - let names: Vec<&str> = calls - .iter() - .filter_map(|call| call.get("name").and_then(Value::as_str)) - .collect(); - if names.is_empty() { - None - } else { - Some(format!("tool calls: {}", names.join(", "))) +fn shelley_push_text(parts: &mut Vec, text: &str) { + let text = text.trim(); + if !text.is_empty() { + let remaining = shelley_text_budget_remaining(parts); + if remaining == 0 { + return; + } + let separator_budget = usize::from(!parts.is_empty()); + if remaining <= separator_budget { + return; } + let (text, _) = capped_text(text, remaining - separator_budget); + parts.push(text); + } +} + +fn shelley_text_budget_remaining(parts: &[String]) -> usize { + let used = parts.iter().map(|part| part.chars().count()).sum::() + + parts.len().saturating_sub(1); + (PROVIDER_MAX_TEXT_CHARS + 1).saturating_sub(used) +} + +fn shelley_content_type(value: &Value) -> Option { + let raw = value.get("Type")?; + if let Some(text) = raw.as_str() { + let normalized = text.trim().to_ascii_lowercase(); + return match normalized.as_str() { + "contenttypetext" => Some("text".to_owned()), + "contenttypethinking" => Some("thinking".to_owned()), + "contenttyperedactedthinking" => Some("redacted_thinking".to_owned()), + "contenttypetooluse" => Some("tool_use".to_owned()), + "contenttypetoolresult" => Some("tool_result".to_owned()), + "contenttypeservertooluse" => Some("server_tool_use".to_owned()), + "contenttypewebsearchtoolresult" => Some("web_search_tool_result".to_owned()), + "contenttypewebsearchresult" => Some("web_search_result".to_owned()), + _ => Some(normalized), + }; + } + raw.as_i64().and_then(|kind| { + match kind { + 2 => Some("text"), + 3 => Some("thinking"), + 4 => Some("redacted_thinking"), + 5 => Some("tool_use"), + 6 => Some("tool_result"), + 7 => Some("server_tool_use"), + 8 => Some("web_search_tool_result"), + 9 => Some("web_search_result"), + _ => None, + } + .map(str::to_owned) }) } #[derive(Debug, Clone)] -struct OpenCodeSessionRow { - id: String, - parent_id: Option, - title: String, - directory: String, - model: Option, - agent: Option, - time_created: i64, - time_updated: i64, - tokens_input: i64, - tokens_output: i64, - tokens_reasoning: i64, - tokens_cache_read: i64, - tokens_cache_write: i64, +struct AstrBotConversationRow { + row_id: i64, + inner_conversation_id: Option, + conversation_id: String, + platform_id: Option, + user_id: Option, + content: String, + title: Option, + persona_id: Option, + token_usage: Option, + created_at: Option, + updated_at: Option, +} + +#[derive(Debug, Clone)] +struct AstrBotPlatformMessageRow { + id: i64, + platform_id: Option, + user_id: Option, + sender_id: Option, + sender_name: Option, + content: Option, + llm_checkpoint_id: Option, + created_at: Option, +} + +fn normalize_astrbot_sqlite( + path: &Path, + context: &ProviderAdapterContext, +) -> Result { + let conn = open_provider_sqlite_readonly(path)?; + let user_version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; + let schema_fingerprint = opencode_schema_fingerprint(&conn)?; + let conversations = astrbot_conversations(&conn)?; + let platform_messages = astrbot_platform_messages(&conn)?; + let selected_conversation = astrbot_selected_conversation(&conn).ok().flatten(); + let mut result = ProviderNormalizationResult::default(); + let mut checkpoint_sessions = BTreeMap::::new(); + + for conversation in &conversations { + let conversation_line = match provider_nonnegative_i64_to_u64( + conversation.row_id, + "AstrBot conversation row id", + ) { + Ok(value) => provider_line_from_index(value), + Err(err) => { + push_provider_import_failure(&mut result.summary, 0, err.to_string()); + continue; + } + }; + let provider_session_id = astrbot_provider_session_id(conversation); + let started_at = provider_timestamp_millis(conversation.created_at, context.imported_at); + let ended_at = conversation + .updated_at + .map(|timestamp| provider_timestamp_millis(Some(timestamp), context.imported_at)); + let content = provider_json_text(&conversation.content); + if let Value::Array(items) = &content { + for (index, item) in items.iter().enumerate() { + if let Some(checkpoint) = astrbot_checkpoint_id(item) { + checkpoint_sessions.insert(checkpoint, provider_session_id.clone()); + continue; + } + let role = astrbot_role(item); + let text = astrbot_item_text(item) + .unwrap_or_else(|| "AstrBot conversation item".to_owned()); + let event = native_event(NativeEventDraft { + provider: CaptureProvider::AstrBot, + source_format: ASTRBOT_SQLITE_SOURCE_FORMAT, + provider_session_id: provider_session_id.clone(), + provider_event_index: index as u64, + provider_event_hash: astrbot_item_id(item) + .map(|id| format!("conversation:{id}")), + cursor: format!("conversation:{}:item:{index}", conversation.conversation_id), + event_type: EventType::Message, + role, + occurred_at: started_at, + text, + body: item.clone(), + metadata: json!({ + "source": "astrbot_conversations", + "source_format": ASTRBOT_SQLITE_SOURCE_FORMAT, + "conversation_id": conversation.conversation_id, + "inner_conversation_id": conversation.inner_conversation_id, + "item_index": index, + }), + }); + result.captures.push(( + index + 1, + astrbot_capture( + AstrBotCaptureDraft { + conversation, + provider_session_id: &provider_session_id, + started_at, + ended_at, + path, + user_version, + schema_fingerprint: &schema_fingerprint, + selected_conversation: selected_conversation.as_deref(), + event: Some(event), + }, + context, + ), + )); + } + } else { + let text = + provider_value_text(&content).unwrap_or_else(|| "AstrBot conversation".to_owned()); + let event = native_event(NativeEventDraft { + provider: CaptureProvider::AstrBot, + source_format: ASTRBOT_SQLITE_SOURCE_FORMAT, + provider_session_id: provider_session_id.clone(), + provider_event_index: 0, + provider_event_hash: Some(format!("conversation-row:{}", conversation.row_id)), + cursor: format!("conversation:{}:content", conversation.conversation_id), + event_type: EventType::Message, + role: None, + occurred_at: started_at, + text, + body: content.clone(), + metadata: json!({ + "source": "astrbot_conversations", + "source_format": ASTRBOT_SQLITE_SOURCE_FORMAT, + "conversation_id": conversation.conversation_id, + }), + }); + result.captures.push(( + conversation_line, + astrbot_capture( + AstrBotCaptureDraft { + conversation, + provider_session_id: &provider_session_id, + started_at, + ended_at, + path, + user_version, + schema_fingerprint: &schema_fingerprint, + selected_conversation: selected_conversation.as_deref(), + event: Some(event), + }, + context, + ), + )); + } + } + + let conversations_by_id = conversations + .iter() + .map(|conversation| (astrbot_provider_session_id(conversation), conversation)) + .collect::>(); + for message in platform_messages { + let message_id = + match provider_nonnegative_i64_to_u64(message.id, "AstrBot platform message id") { + Ok(value) => value, + Err(err) => { + push_provider_import_failure(&mut result.summary, 0, err.to_string()); + continue; + } + }; + let provider_session_id = message + .llm_checkpoint_id + .as_ref() + .and_then(|checkpoint| checkpoint_sessions.get(checkpoint)) + .cloned() + .unwrap_or_else(|| { + format!( + "platform/{}/{}", + message.platform_id.as_deref().unwrap_or("unknown"), + message.user_id.as_deref().unwrap_or("unknown") + ) + }); + let conversation = conversations_by_id.get(&provider_session_id).copied(); + let started_at = conversation + .and_then(|conversation| conversation.created_at) + .map(|timestamp| provider_timestamp_millis(Some(timestamp), context.imported_at)) + .unwrap_or_else(|| provider_timestamp_millis(message.created_at, context.imported_at)); + let content = message + .content + .as_deref() + .map(provider_json_text) + .unwrap_or(Value::Null); + let text = + provider_value_text(&content).unwrap_or_else(|| "AstrBot platform message".to_owned()); + let role = if message.sender_id.as_deref() == message.user_id.as_deref() { + Some(EventRole::User) + } else { + Some(EventRole::Assistant) + }; + let event_index = 1_000_000u64.saturating_add(message_id); + let event = native_event(NativeEventDraft { + provider: CaptureProvider::AstrBot, + source_format: ASTRBOT_SQLITE_SOURCE_FORMAT, + provider_session_id: provider_session_id.clone(), + provider_event_index: event_index, + provider_event_hash: Some(format!("platform-message:{}", message.id)), + cursor: format!("platform_message_history:id:{}", message.id), + event_type: EventType::Message, + role, + occurred_at: provider_timestamp_millis(message.created_at, started_at), + text, + body: json!({ + "message_id": message.id, + "platform_id": message.platform_id, + "user_id": message.user_id, + "sender_id": message.sender_id, + "sender_name": message.sender_name, + "content": content, + "llm_checkpoint_id": message.llm_checkpoint_id, + }), + metadata: json!({ + "source": "astrbot_platform_message_history", + "source_format": ASTRBOT_SQLITE_SOURCE_FORMAT, + "message_id": message.id, + }), + }); + if let Some(conversation) = conversation { + result.captures.push(( + event_index.min(usize::MAX as u64) as usize, + astrbot_capture( + AstrBotCaptureDraft { + conversation, + provider_session_id: &provider_session_id, + started_at, + ended_at: conversation.updated_at.map(|timestamp| { + provider_timestamp_millis(Some(timestamp), context.imported_at) + }), + path, + user_version, + schema_fingerprint: &schema_fingerprint, + selected_conversation: selected_conversation.as_deref(), + event: Some(event), + }, + context, + ), + )); + } else { + result.captures.push(( + event_index.min(usize::MAX as u64) as usize, + native_provider_capture( + NativeSessionDraft { + provider: CaptureProvider::AstrBot, + source_format: ASTRBOT_SQLITE_SOURCE_FORMAT, + provider_session_id: provider_session_id.clone(), + parent_provider_session_id: None, + root_provider_session_id: None, + external_agent_id: message.platform_id.clone(), + agent_type: AgentType::Primary, + role_hint: Some("platform-history".to_owned()), + is_primary: true, + started_at, + ended_at: None, + cwd: None, + fidelity: Fidelity::Partial, + raw_source_path: path.display().to_string(), + trust: ProviderSourceTrust::ProviderNative, + source_metadata: json!({ + "adapter": ASTRBOT_SQLITE_SOURCE_FORMAT, + "sqlite_user_version": user_version, + "schema_fingerprint": schema_fingerprint, + "support_level": "preview", + }), + session_metadata: json!({ + "source_format": ASTRBOT_SQLITE_SOURCE_FORMAT, + "platform_id": message.platform_id, + "user_id": message.user_id, + "fidelity_gap": "platform history row was not linked to a conversations checkpoint", + }), + }, + context, + Some(event), + ), + )); + } + } + + Ok(result) +} + +fn astrbot_provider_session_id(conversation: &AstrBotConversationRow) -> String { + conversation + .inner_conversation_id + .as_ref() + .or(Some(&conversation.conversation_id)) + .cloned() + .unwrap_or_else(|| format!("conversation-row-{}", conversation.row_id)) +} + +struct AstrBotCaptureDraft<'a> { + conversation: &'a AstrBotConversationRow, + provider_session_id: &'a str, + started_at: DateTime, + ended_at: Option>, + path: &'a Path, + user_version: i64, + schema_fingerprint: &'a str, + selected_conversation: Option<&'a str>, + event: Option, +} + +fn astrbot_capture( + draft: AstrBotCaptureDraft<'_>, + context: &ProviderAdapterContext, +) -> ProviderCaptureEnvelope { + let AstrBotCaptureDraft { + conversation, + provider_session_id, + started_at, + ended_at, + path, + user_version, + schema_fingerprint, + selected_conversation, + event, + } = draft; + native_provider_capture( + NativeSessionDraft { + provider: CaptureProvider::AstrBot, + source_format: ASTRBOT_SQLITE_SOURCE_FORMAT, + provider_session_id: provider_session_id.to_owned(), + parent_provider_session_id: None, + root_provider_session_id: None, + external_agent_id: conversation.platform_id.clone(), + agent_type: AgentType::Primary, + role_hint: Some("llm-context".to_owned()), + is_primary: true, + started_at, + ended_at, + cwd: None, + fidelity: Fidelity::Partial, + raw_source_path: path.display().to_string(), + trust: ProviderSourceTrust::ProviderNative, + source_metadata: json!({ + "adapter": ASTRBOT_SQLITE_SOURCE_FORMAT, + "sqlite_user_version": user_version, + "schema_fingerprint": schema_fingerprint, + "support_level": "preview", + }), + session_metadata: json!({ + "source_format": ASTRBOT_SQLITE_SOURCE_FORMAT, + "conversation_id": conversation.conversation_id, + "inner_conversation_id": conversation.inner_conversation_id, + "platform_id": conversation.platform_id, + "user_id": conversation.user_id, + "title": conversation.title, + "persona_id": conversation.persona_id, + "token_usage": conversation.token_usage.as_deref().map(provider_json_text), + "selected_conversation": selected_conversation, + "fidelity_gap": "AstrBot preview imports local LLM context plus available platform history; it may not be a complete raw IM transcript", + }), + }, + context, + event, + ) +} + +fn astrbot_item_id(item: &Value) -> Option<&str> { + item.get("id") + .or_else(|| item.get("message_id")) + .or_else(|| item.get("checkpoint_id")) + .and_then(Value::as_str) +} + +fn astrbot_checkpoint_id(item: &Value) -> Option { + let item_type = item + .get("type") + .or_else(|| item.get("role")) + .and_then(Value::as_str)?; + if item_type != "_checkpoint" && item_type != "checkpoint" { + return None; + } + astrbot_item_id(item).map(str::to_owned) +} + +fn astrbot_role(item: &Value) -> Option { + item.get("role") + .or_else(|| item.get("type")) + .and_then(Value::as_str) + .map(|role| provider_role(Some(role))) +} + +fn astrbot_item_text(item: &Value) -> Option { + item.get("content") + .or_else(|| item.get("text")) + .or_else(|| item.get("message")) + .and_then(provider_value_text) +} + +fn astrbot_conversations(conn: &Connection) -> Result> { + if !sqlite_table_exists(conn, "conversations")? { + return Err(CaptureError::InvalidPayload( + "AstrBot data_v4.db is missing required conversations table".into(), + )); + } + let columns = sqlite_table_columns(conn, "conversations")?; + ensure_sqlite_table_columns(&columns, "AstrBot conversations table", &["content"])?; + let row_id = if columns.contains("id") { + "id" + } else { + "rowid" + }; + let inner_conversation_id = optional_column_expr(&columns, "inner_conversation_id", "NULL"); + let conversation_id = optional_column_expr( + &columns, + "conversation_id", + optional_column_expr(&columns, "inner_conversation_id", "CAST(rowid AS TEXT)"), + ); + let platform_id = optional_column_expr(&columns, "platform_id", "NULL"); + let user_id = optional_column_expr(&columns, "user_id", "NULL"); + let title = optional_column_expr(&columns, "title", "NULL"); + let persona_id = optional_column_expr(&columns, "persona_id", "NULL"); + let token_usage = optional_column_expr(&columns, "token_usage", "NULL"); + let created_at = optional_column_expr(&columns, "created_at", "NULL"); + let updated_at = optional_column_expr(&columns, "updated_at", "NULL"); + let sql = format!( + "select {row_id}, {inner_conversation_id}, {conversation_id}, {platform_id}, \ + {user_id}, content, {title}, {persona_id}, {token_usage}, {created_at}, \ + {updated_at} from conversations order by {created_at}, {row_id}" + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map([], |row| { + Ok(AstrBotConversationRow { + row_id: row.get(0)?, + inner_conversation_id: row.get(1)?, + conversation_id: row.get::<_, String>(2)?, + platform_id: row.get(3)?, + user_id: row.get(4)?, + content: row.get(5)?, + title: row.get(6)?, + persona_id: row.get(7)?, + token_usage: row.get(8)?, + created_at: row.get(9)?, + updated_at: row.get(10)?, + }) + })?; + rows.collect::, _>>() + .map_err(CaptureError::from) +} + +fn astrbot_platform_messages(conn: &Connection) -> Result> { + if !sqlite_table_exists(conn, "platform_message_history")? { + return Ok(Vec::new()); + } + let columns = sqlite_table_columns(conn, "platform_message_history")?; + let id = if columns.contains("id") { + "id" + } else { + "rowid" + }; + let platform_id = optional_column_expr(&columns, "platform_id", "NULL"); + let user_id = optional_column_expr(&columns, "user_id", "NULL"); + let sender_id = optional_column_expr(&columns, "sender_id", "NULL"); + let sender_name = optional_column_expr(&columns, "sender_name", "NULL"); + let content = optional_column_expr(&columns, "content", "NULL"); + let llm_checkpoint_id = optional_column_expr(&columns, "llm_checkpoint_id", "NULL"); + let created_at = optional_column_expr(&columns, "created_at", "NULL"); + let sql = format!( + "select {id}, {platform_id}, {user_id}, {sender_id}, {sender_name}, \ + {content}, {llm_checkpoint_id}, {created_at} from platform_message_history \ + order by {created_at}, {id}" + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map([], |row| { + Ok(AstrBotPlatformMessageRow { + id: row.get(0)?, + platform_id: row.get(1)?, + user_id: row.get(2)?, + sender_id: row.get(3)?, + sender_name: row.get(4)?, + content: row.get(5)?, + llm_checkpoint_id: row.get(6)?, + created_at: row.get(7)?, + }) + })?; + rows.collect::, _>>() + .map_err(CaptureError::from) } -#[derive(Debug, Clone)] -struct OpenCodeMessageRow { - id: String, - session_id: String, - entry_type: String, - seq: i64, - time_created: i64, - time_updated: i64, - data: String, +fn astrbot_selected_conversation(conn: &Connection) -> Result> { + if !sqlite_table_exists(conn, "preferences")? { + return Ok(None); + } + let columns = sqlite_table_columns(conn, "preferences")?; + if !columns.contains("key") || !columns.contains("value") { + return Ok(None); + } + let scope_filter = if columns.contains("scope") { + "AND scope = 'umo'" + } else { + "" + }; + let sql = + format!("select value from preferences where key = 'sel_conv_id' {scope_filter} limit 1"); + let value = conn + .query_row(&sql, [], |row| row.get::<_, Option>(0)) + .optional()? + .flatten(); + Ok(value) } fn normalize_opencode_sqlite( path: &Path, context: &ProviderAdapterContext, ) -> Result { - let conn = Connection::open_with_flags( - path, - OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, - )?; - conn.busy_timeout(std::time::Duration::from_secs(5))?; - conn.pragma_update(None, "query_only", true)?; + let conn = open_provider_sqlite_readonly(path)?; let user_version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; let schema_fingerprint = opencode_schema_fingerprint(&conn)?; let legacy_message_rows = opencode_count(&conn, "message").unwrap_or(0); @@ -5011,64 +9280,90 @@ fn normalize_opencode_sqlite( let sessions = opencode_sessions(&conn)?; let messages = opencode_session_messages(&conn)?; let mut result = ProviderNormalizationResult::default(); - let session_started = sessions - .iter() - .map(|session| { - ( - session.id.clone(), - timestamp_millis_utc(session.time_created, context.imported_at), - ) - }) - .collect::>(); + let mut session_started = BTreeMap::new(); + for session in &sessions { + session_started.insert( + session.id.clone(), + provider_required_timestamp_millis( + session.time_created, + "OpenCode session time_created", + )?, + ); + } let sessions_by_id = sessions .into_iter() .map(|session| (session.id.clone(), session)) .collect::>(); + let raw_source_path = path.display().to_string(); for row in messages { + let provider_event_index = + match provider_nonnegative_i64_to_u64(row.seq, "OpenCode session_message seq") { + Ok(value) => value, + Err(err) => { + push_provider_import_failure(&mut result.summary, 0, err.to_string()); + continue; + } + }; + let line = provider_line_from_index(provider_event_index); let Some(session) = sessions_by_id.get(&row.session_id) else { - result.summary.failed += 1; - result.summary.failures.push(ProviderImportFailure { - line: row.seq.max(0) as usize, - error: format!( + push_provider_import_failure( + &mut result.summary, + line, + format!( "OpenCode session_message {} references missing session {}", row.id, row.session_id ), - }); + ); continue; }; let data: Value = match serde_json::from_str(&row.data) { Ok(data) => data, Err(err) => { - result.summary.failed += 1; - result.summary.failures.push(ProviderImportFailure { - line: row.seq.max(0) as usize, - error: format!("invalid JSON in session_message {}: {err}", row.id), - }); + push_provider_import_failure( + &mut result.summary, + line, + format!("invalid JSON in session_message {}: {err}", row.id), + ); + continue; + } + }; + let occurred_at = match opencode_event_time(&data) { + Ok(Some(time)) => time, + Ok(None) => match provider_required_timestamp_millis( + row.time_created, + "OpenCode session_message time_created", + ) { + Ok(time) => time, + Err(err) => { + push_provider_import_failure(&mut result.summary, line, err.to_string()); + continue; + } + }, + Err(err) => { + push_provider_import_failure(&mut result.summary, line, err.to_string()); continue; } }; - let occurred_at = opencode_event_time(&data) - .or_else(|| Some(timestamp_millis_utc(row.time_created, context.imported_at))) - .unwrap_or(context.imported_at); let started_at = session_started .get(&session.id) .copied() .unwrap_or(occurred_at); - let event = opencode_event(&row, &data, occurred_at); + let event = opencode_event(&row, &data, occurred_at, provider_event_index); result .files_touched .extend(provider_file_touches_from_raw_value( CaptureProvider::OpenCode, &session.id, OPENCODE_SQLITE_SOURCE_FORMAT, + Some(raw_source_path.as_str()), &data, &event, - row.seq.max(0) as usize, + line, )); let is_subagent = session.parent_id.is_some(); result.captures.push(( - row.seq.max(0) as usize, + line, ProviderCaptureEnvelope { schema_version: PROVIDER_CAPTURE_ENVELOPE_SCHEMA_VERSION, provider: CaptureProvider::OpenCode, @@ -5076,7 +9371,7 @@ fn normalize_opencode_sqlite( source_format: OPENCODE_SQLITE_SOURCE_FORMAT.to_owned(), machine_id: context.machine_id.clone(), observed_at: context.imported_at, - raw_source_path: Some(path.display().to_string()), + raw_source_path: Some(raw_source_path.clone()), raw_retention: ProviderRawRetention::PathReference, redaction_boundary: ProviderRedactionBoundary::BeforeExport, trust: ProviderSourceTrust::ProviderNative, @@ -5462,13 +9757,14 @@ fn opencode_event( row: &OpenCodeMessageRow, data: &Value, occurred_at: DateTime, + provider_event_index: u64, ) -> ProviderEventEnvelope { let event_type = opencode_event_type(&row.entry_type, data); let role = Some(provider_role(Some(&row.entry_type))); let text = opencode_event_text(&row.entry_type, data, event_type); - let (text, truncated) = provider_safe_preview(&text, PROVIDER_MAX_TEXT_CHARS); + let (text, truncated) = provider_local_preview(&text, PROVIDER_MAX_TEXT_CHARS); ProviderEventEnvelope { - provider_event_index: row.seq.max(0) as u64, + provider_event_index, provider_event_hash: Some(row.id.clone()), cursor: Some(format!( "session_message:{}:seq:{}", @@ -5478,7 +9774,7 @@ fn opencode_event( role, occurred_at, fidelity: Fidelity::Imported, - redaction_state: RedactionState::SafePreview, + redaction_state: RedactionState::LocalPreview, idempotency_key: Some(format!( "provider-event:opencode:{}:{}", row.session_id, row.id @@ -5555,14 +9851,16 @@ fn opencode_content_has_tool(data: &Value) -> bool { .unwrap_or(false) } -fn opencode_event_time(data: &Value) -> Option> { - data.pointer("/time/created") - .and_then(Value::as_i64) - .and_then(DateTime::::from_timestamp_millis) -} - -fn timestamp_millis_utc(millis: i64, fallback: DateTime) -> DateTime { - DateTime::::from_timestamp_millis(millis).unwrap_or(fallback) +fn opencode_event_time(data: &Value) -> Result>> { + let Some(value) = data.pointer("/time/created") else { + return Ok(None); + }; + let millis = value.as_i64().ok_or_else(|| { + CaptureError::InvalidPayload( + "OpenCode event time.created must be integer millis".to_owned(), + ) + })?; + provider_required_timestamp_millis(millis, "OpenCode event time.created").map(Some) } fn parse_json_object_string(value: Option<&str>) -> Value { @@ -5604,6 +9902,7 @@ fn normalize_jsonl_tree( fn native_jsonl_missing_reason(provider: CaptureProvider) -> &'static str { match provider { + CaptureProvider::Pi => "no Pi session JSONL files found", CaptureProvider::Antigravity => { "no Antigravity transcript JSONL files found under brain/*/.system_generated/logs" } @@ -5668,17 +9967,18 @@ fn normalize_native_jsonl_session_file( ) -> Result { ensure_regular_provider_transcript_file(path)?; let file = File::open(path)?; - let reader = BufReader::new(file); + let mut reader = BufReader::new(file); let mut result = ProviderNormalizationResult::default(); let mut rows = Vec::new(); + let mut line = Vec::new(); + let mut line_number = 0usize; - for (index, line) in reader.lines().enumerate() { - let line_number = index + 1; - let line = line?; - if line.trim().is_empty() { + while read_provider_jsonl_line(&mut reader, &mut line)? { + line_number += 1; + if line.iter().all(u8::is_ascii_whitespace) { continue; } - let value: Value = match serde_json::from_str(&line) { + let value: Value = match serde_json::from_slice(&line) { Ok(value) => value, Err(err) => { result.summary.failed += 1; @@ -5727,6 +10027,7 @@ fn normalize_native_jsonl_session_file( .unwrap_or(context.imported_at); let cwd = native_jsonl_header_cwd(provider, &header); let is_subagent = parent_provider_session_id.is_some() || agent_type == AgentType::Subagent; + let raw_source_path = path.display().to_string(); for (line_number, value) in rows { let occurred_at = native_jsonl_timestamp(&value).unwrap_or(started_at); @@ -5738,6 +10039,7 @@ fn normalize_native_jsonl_session_file( provider, &provider_session_id, source_format, + Some(raw_source_path.as_str()), &value, event, line_number, @@ -5752,7 +10054,7 @@ fn normalize_native_jsonl_session_file( source_format: source_format.to_owned(), machine_id: context.machine_id.clone(), observed_at: context.imported_at, - raw_source_path: Some(path.display().to_string()), + raw_source_path: Some(raw_source_path.clone()), raw_retention: ProviderRawRetention::PathReference, redaction_boundary: ProviderRedactionBoundary::BeforeExport, trust: ProviderSourceTrust::ProviderNative, @@ -5772,7 +10074,7 @@ fn normalize_native_jsonl_session_file( metadata: json!({ "adapter": source_format, "native_session_id": native_session_id, - "source_path": path.display().to_string(), + "source_path": raw_source_path.clone(), }), }, session: ProviderSessionEnvelope { @@ -5989,7 +10291,7 @@ fn native_jsonl_event( let entry_type = native_jsonl_entry_type(provider, value); let role = native_jsonl_role(provider, value); let text = native_jsonl_event_text(provider, value, event_type, &entry_type); - let (text, truncated) = provider_safe_preview(&text, PROVIDER_MAX_TEXT_CHARS); + let (text, truncated) = provider_local_preview(&text, PROVIDER_MAX_TEXT_CHARS); let event_id = native_jsonl_event_id(provider, value, line_number); let tool_calls = if provider == CaptureProvider::Antigravity { value @@ -6007,7 +10309,7 @@ fn native_jsonl_event( role: Some(role), occurred_at, fidelity: Fidelity::Imported, - redaction_state: RedactionState::SafePreview, + redaction_state: RedactionState::LocalPreview, idempotency_key: Some(format!( "provider-event:{}:{source_format}:{event_id}", provider.as_str() @@ -6338,8 +10640,10 @@ fn pi_session_capture( entry: Option, line_number: usize, context: &ProviderAdapterContext, -) -> ProviderCaptureEnvelope { - let event = entry.map(|entry| pi_session_event(&entry, line_number)); +) -> Result { + let event = entry + .map(|entry| pi_session_event(header, &entry, line_number)) + .transpose()?; let cursor = event.as_ref().and_then(|event| { event.cursor.as_ref().map(|cursor| ProviderCursorRange { before: None, @@ -6351,7 +10655,7 @@ fn pi_session_capture( }) }); - ProviderCaptureEnvelope { + Ok(ProviderCaptureEnvelope { schema_version: PROVIDER_CAPTURE_ENVELOPE_SCHEMA_VERSION, provider: CaptureProvider::Pi, source: ProviderSourceEnvelope { @@ -6402,10 +10706,14 @@ fn pi_session_capture( }), }, event, - } + }) } -fn pi_session_event(entry: &Value, line_number: usize) -> ProviderEventEnvelope { +fn pi_session_event( + header: &PiSessionHeader, + entry: &Value, + line_number: usize, +) -> Result { let entry_type = entry .get("type") .and_then(Value::as_str) @@ -6414,17 +10722,14 @@ fn pi_session_event(entry: &Value, line_number: usize) -> ProviderEventEnvelope let message_role = message .and_then(|message| message.get("role")) .and_then(Value::as_str); - let occurred_at = entry - .get("timestamp") - .and_then(Value::as_str) - .and_then(|timestamp| DateTime::parse_from_rfc3339(timestamp).ok()) - .map(|time| time.with_timezone(&Utc)) - .unwrap_or_else(Utc::now); + let occurred_at = parse_optional_rfc3339_field(entry, "timestamp")?.ok_or_else(|| { + CaptureError::InvalidPayload("pi session event missing timestamp".to_owned()) + })?; let event_type = pi_event_type(entry_type, message); let role = message_role.map(pi_event_role); let text = message.and_then(pi_message_text); - ProviderEventEnvelope { + Ok(ProviderEventEnvelope { provider_event_index: (line_number - 1) as u64, provider_event_hash: None, cursor: entry.get("id").and_then(Value::as_str).map(str::to_owned), @@ -6432,8 +10737,8 @@ fn pi_session_event(entry: &Value, line_number: usize) -> ProviderEventEnvelope role, occurred_at, fidelity: Fidelity::Imported, - redaction_state: RedactionState::SafePreview, - idempotency_key: Some(format!("provider-event:pi:{line_number}")), + redaction_state: RedactionState::LocalPreview, + idempotency_key: Some(format!("provider-event:pi:{}:{line_number}", header.id)), artifacts: Vec::new(), payload: json!({ "entry_type": entry_type, @@ -6459,7 +10764,7 @@ fn pi_session_event(entry: &Value, line_number: usize) -> ProviderEventEnvelope .and_then(Value::as_str), "usage": message.and_then(|message| message.get("usage")).cloned(), }), - } + }) } fn pi_event_type(entry_type: &str, message: Option<&Value>) -> EventType { @@ -6579,6 +10884,7 @@ fn import_provider_capture_lines( capture.provider, &capture.session.provider_session_id, &capture.source.source_format, + capture.source.raw_source_path.as_deref(), event, *line_number, )); @@ -6605,7 +10911,12 @@ fn import_provider_capture_lines( } } } - resolve_pending_provider_edges(store, &mut summary, &mut caches)?; + if let Err(err) = resolve_pending_provider_edges(store, &mut summary, &mut caches) { + if has_captures && options.wrap_transaction { + let _ = store.rollback_batch(); + } + return Err(err); + } for (line_number, file) in files_touched { if let Err(err) = import_provider_file_touched_line(store, &file, &options) { summary.failed += 1; @@ -6615,6 +10926,12 @@ fn import_provider_capture_lines( }); } } + if summary.failed > 0 && !options.allow_partial_failures { + if has_captures && options.wrap_transaction { + let _ = store.rollback_batch(); + } + return Ok(summary); + } if has_captures && options.wrap_transaction { if let Err(err) = store.commit_batch() { let _ = store.rollback_batch(); @@ -6631,16 +10948,31 @@ fn import_provider_file_touched_line( options: &NormalizedProviderImportOptions, ) -> Result<()> { let session_id = provider_session_uuid(file.provider, &file.provider_session_id); - let source_id = provider_source_uuid(file.provider, &file.provider_session_id); - let event_id = file - .provider_event_index - .map(|index| provider_event_uuid(file.provider, &file.provider_session_id, index)); - let touched = FileTouched { - id: provider_file_touch_uuid( + let source_id = provider_scoped_source_uuid( + file.provider, + &file.provider_session_id, + &file.source_format, + file.raw_source_path.as_deref(), + ); + let event_id = match file.provider_event_index { + Some(index) => provider_file_touch_event_id( + store, file.provider, &file.provider_session_id, - file.provider_touch_index, - ), + source_id, + index, + )?, + None => None, + }; + let touch_id = provider_file_touch_import_id( + store, + file.provider, + &file.provider_session_id, + source_id, + file.provider_touch_index, + )?; + let touched = FileTouched { + id: touch_id, history_record_id: options.history_record_id, run_id: None, event_id, @@ -6659,6 +10991,8 @@ fn import_provider_file_touched_line( "provider_session_id": file.provider_session_id, "provider_touch_index": file.provider_touch_index, "provider_event_index": file.provider_event_index, + "raw_source_path": file.raw_source_path, + "source_id": source_id, "source_format": file.source_format, "metadata": file.metadata, "session_id": session_id, @@ -6714,7 +11048,13 @@ fn import_provider_capture_line( let source = &capture.source; let imported_at = source.observed_at; let session_id = provider_session_uuid(provider, &session.provider_session_id); - let source_id = provider_source_uuid(provider, &session.provider_session_id); + let source_identity_key = provider_scoped_source_identity_key( + provider, + &session.provider_session_id, + &source.source_format, + source.raw_source_path.as_deref(), + ); + let source_id = stable_capture_uuid(&source_identity_key, "source"); let requested_parent_session_id = session .parent_provider_session_id .as_ref() @@ -6769,6 +11109,7 @@ fn import_provider_capture_line( "fixture_line": line_number, "imported_at": imported_at, "source_idempotency_key": source.idempotency_key, + "source_identity_key": source_identity_key, "source_metadata": source_metadata, "session_metadata": session_metadata, }), @@ -6895,33 +11236,28 @@ fn import_provider_capture_line( .provider_event_hash .clone() .unwrap_or(compute_payload_hash(&payload)?); - let dedupe_key = Store::provider_event_dedupe_key( + let event_identity = provider_event_import_identity( + store, provider, &session.provider_session_id, + source_id, event.provider_event_index, &event_hash, - ); + )?; let command_run = provider_command_run_from_event(ProviderCommandRunInput { provider, provider_session_id: &session.provider_session_id, session_id, source_id, + run_source_id: event_identity.run_source_id, history_record_id: options.history_record_id, event, payload: &payload, event_hash: &event_hash, - }); + })?; let normalized_event = Event { - id: provider_event_uuid( - provider, - &session.provider_session_id, - event.provider_event_index, - ), - seq: provider_event_seq( - provider, - &session.provider_session_id, - event.provider_event_index, - ), + id: event_identity.id, + seq: event_identity.seq, history_record_id: options.history_record_id, session_id: Some(session_id), run_id: command_run.as_ref().map(|run| run.id), @@ -6939,7 +11275,7 @@ fn import_provider_capture_line( "body": payload, }), payload_blob_id: None, - dedupe_key: Some(dedupe_key.clone()), + dedupe_key: Some(event_identity.dedupe_key.clone()), redaction_state: effective_event_redaction_state( event.redaction_state, redacted_payload || redacted_metadata, @@ -6966,7 +11302,7 @@ fn import_provider_capture_line( } !store.insert_event_if_absent(&normalized_event)? } else { - let was_present = provider_event_exists(store, &dedupe_key)?; + let was_present = provider_event_exists(store, &event_identity.dedupe_key)?; if let Some(run) = &command_run { store.upsert_run(run)?; } @@ -7186,7 +11522,7 @@ fn fixture_line_to_capture( role: event.role, occurred_at: event.occurred_at, fidelity, - redaction_state: RedactionState::SafePreview, + redaction_state: RedactionState::LocalPreview, idempotency_key: Some(format!( "provider-event:{}:{}:{}", fixture.provider.as_str(), @@ -7213,7 +11549,7 @@ fn effective_event_redaction_state( RedactionState::Redacted => RedactionState::Redacted, RedactionState::Raw if !sanitizer_redacted => RedactionState::Raw, _ if sanitizer_redacted => RedactionState::Redacted, - _ => RedactionState::SafePreview, + _ => RedactionState::LocalPreview, } } @@ -7454,7 +11790,7 @@ fn ensure_regular_spool_file(path: &Path) -> Result<()> { fn write_failure_metadata(failed_path: &Path, err: &CaptureError) -> Result<()> { let sidecar = append_suffix(failed_path, ".error.json")?; let metadata = json!({ - "failed_at": Utc::now(), + "failed_at": utc_now(), "spool_file": failed_path, "error": err.to_string(), }); @@ -7572,6 +11908,130 @@ fn provider_event_exists(store: &Store, dedupe_key: &str) -> Result { } } +#[derive(Clone)] +struct ProviderEventImportIdentity { + id: Uuid, + seq: u64, + dedupe_key: String, + run_source_id: Option, +} + +fn provider_event_import_identity( + store: &Store, + provider: CaptureProvider, + provider_session_id: &str, + source_id: Uuid, + provider_event_index: u64, + event_hash: &str, +) -> Result { + let source_identity = + provider_source_event_import_identity(source_id, provider_event_index, event_hash); + if provider_event_exists(store, &source_identity.dedupe_key)? + || provider_event_id_exists(store, source_identity.id)? + { + return Ok(source_identity); + } + + let legacy_identity = provider_legacy_event_import_identity( + provider, + provider_session_id, + provider_event_index, + event_hash, + ); + if provider_event_exists(store, &legacy_identity.dedupe_key)? + || provider_event_id_exists(store, legacy_identity.id)? + { + Ok(legacy_identity) + } else { + Ok(source_identity) + } +} + +fn provider_source_event_import_identity( + source_id: Uuid, + provider_event_index: u64, + event_hash: &str, +) -> ProviderEventImportIdentity { + ProviderEventImportIdentity { + id: provider_source_event_uuid(source_id, provider_event_index), + seq: provider_source_event_seq(source_id, provider_event_index), + dedupe_key: Store::provider_source_event_dedupe_key( + source_id, + provider_event_index, + event_hash, + ), + run_source_id: Some(source_id), + } +} + +fn provider_legacy_event_import_identity( + provider: CaptureProvider, + provider_session_id: &str, + provider_event_index: u64, + event_hash: &str, +) -> ProviderEventImportIdentity { + ProviderEventImportIdentity { + id: provider_event_uuid(provider, provider_session_id, provider_event_index), + seq: provider_event_seq(provider, provider_session_id, provider_event_index), + dedupe_key: Store::provider_event_dedupe_key( + provider, + provider_session_id, + provider_event_index, + event_hash, + ), + run_source_id: None, + } +} + +fn provider_file_touch_event_id( + store: &Store, + provider: CaptureProvider, + provider_session_id: &str, + source_id: Uuid, + provider_event_index: u64, +) -> Result> { + let source_event_id = provider_source_event_uuid(source_id, provider_event_index); + if provider_event_id_exists(store, source_event_id)? { + return Ok(Some(source_event_id)); + } + + let legacy_event_id = provider_event_uuid(provider, provider_session_id, provider_event_index); + if provider_event_id_exists(store, legacy_event_id)? { + Ok(Some(legacy_event_id)) + } else { + Ok(None) + } +} + +fn provider_file_touch_import_id( + store: &Store, + provider: CaptureProvider, + provider_session_id: &str, + source_id: Uuid, + provider_touch_index: u64, +) -> Result { + let source_touch_id = provider_source_file_touch_uuid(source_id, provider_touch_index); + if store.file_touched_exists(source_touch_id)? { + return Ok(source_touch_id); + } + + let legacy_touch_id = + provider_file_touch_uuid(provider, provider_session_id, provider_touch_index); + if store.file_touched_exists(legacy_touch_id)? { + Ok(legacy_touch_id) + } else { + Ok(source_touch_id) + } +} + +fn provider_event_id_exists(store: &Store, id: Uuid) -> Result { + match store.get_event(id) { + Ok(_) => Ok(true), + Err(StoreError::NotFound(_)) => Ok(false), + Err(err) => Err(CaptureError::Store(err)), + } +} + fn provider_session_exists(store: &Store, session_id: Uuid) -> Result { match store.get_session(session_id) { Ok(_) => Ok(true), @@ -7598,25 +12058,27 @@ struct ProviderCommandRunInput<'a> { provider_session_id: &'a str, session_id: Uuid, source_id: Uuid, + run_source_id: Option, history_record_id: Option, event: &'a ProviderEventEnvelope, payload: &'a Value, event_hash: &'a str, } -fn provider_command_run_from_event(input: ProviderCommandRunInput<'_>) -> Option { +fn provider_command_run_from_event(input: ProviderCommandRunInput<'_>) -> Result> { let ProviderCommandRunInput { provider, provider_session_id, session_id, source_id, + run_source_id, history_record_id, event, payload, event_hash, } = input; if event.event_type != EventType::CommandOutput { - return None; + return Ok(None); } let command_preview = payload .get("command") @@ -7625,17 +12087,32 @@ fn provider_command_run_from_event(input: ProviderCommandRunInput<'_>) -> Option .map(str::to_owned); let call_id = payload.get("call_id").and_then(Value::as_str); let key = call_id.unwrap_or(event_hash); - let duration_ms = payload.get("duration_ms").and_then(Value::as_i64); + let duration_ms = provider_command_duration_ms(payload)?; let ended_at = Some(event.occurred_at); - let started_at = duration_ms - .and_then(|duration| { + let started_at = match duration_ms { + Some(duration) => { + let duration_value = duration; + let duration = chrono::Duration::try_milliseconds(duration_value).ok_or_else(|| { + CaptureError::InvalidPayload(format!( + "duration_ms is not representable as milliseconds: {duration_value}" + )) + })?; event .occurred_at - .checked_sub_signed(chrono::Duration::milliseconds(duration.max(0))) - }) - .unwrap_or(event.occurred_at); - Some(Run { - id: provider_run_uuid(provider, provider_session_id, key), + .checked_sub_signed(duration) + .ok_or_else(|| { + CaptureError::InvalidPayload(format!( + "duration_ms moves command start before representable time: {}", + duration_value + )) + })? + } + None => event.occurred_at, + }; + Ok(Some(Run { + id: run_source_id + .map(|source_id| provider_source_run_uuid(source_id, key)) + .unwrap_or_else(|| provider_run_uuid(provider, provider_session_id, key)), history_record_id, session_id: Some(session_id), run_type: RunType::Command, @@ -7662,7 +12139,25 @@ fn provider_command_run_from_event(input: ProviderCommandRunInput<'_>) -> Option "source": "provider_command_output", }), ), - }) + })) +} + +fn provider_command_duration_ms(payload: &Value) -> Result> { + let Some(value) = payload.get("duration_ms") else { + return Ok(None); + }; + if value.is_null() { + return Ok(None); + } + let duration = value + .as_i64() + .ok_or_else(|| CaptureError::InvalidPayload("duration_ms must be an integer".to_owned()))?; + if duration < 0 { + return Err(CaptureError::InvalidPayload(format!( + "duration_ms must be nonnegative, got {duration}" + ))); + } + Ok(Some(duration)) } fn provider_command_run_status(payload: &Value) -> RunStatus { @@ -7680,6 +12175,7 @@ fn provider_command_run_status(payload: &Value) -> RunStatus { } } +#[cfg(test)] fn provider_source_uuid(provider: CaptureProvider, provider_session_id: &str) -> Uuid { stable_capture_uuid( &format!("provider:{}:{provider_session_id}", provider.as_str()), @@ -7687,6 +12183,39 @@ fn provider_source_uuid(provider: CaptureProvider, provider_session_id: &str) -> ) } +fn provider_scoped_source_uuid( + provider: CaptureProvider, + provider_session_id: &str, + source_format: &str, + raw_source_path: Option<&str>, +) -> Uuid { + stable_capture_uuid( + &provider_scoped_source_identity_key( + provider, + provider_session_id, + source_format, + raw_source_path, + ), + "source", + ) +} + +fn provider_scoped_source_identity_key( + provider: CaptureProvider, + provider_session_id: &str, + source_format: &str, + raw_source_path: Option<&str>, +) -> String { + serde_json::to_string(&( + "provider-source-v2", + provider.as_str(), + provider_session_id, + source_format, + raw_source_path, + )) + .expect("provider source identity key should serialize") +} + fn provider_session_uuid(provider: CaptureProvider, provider_session_id: &str) -> Uuid { stable_capture_uuid( &format!("provider:{}:{provider_session_id}", provider.as_str()), @@ -7704,6 +12233,10 @@ fn provider_run_uuid(provider: CaptureProvider, provider_session_id: &str, run_k ) } +fn provider_source_run_uuid(source_id: Uuid, run_key: &str) -> Uuid { + stable_capture_uuid(&format!("provider-source:{source_id}:run:{run_key}"), "run") +} + fn provider_event_uuid( provider: CaptureProvider, provider_session_id: &str, @@ -7718,6 +12251,23 @@ fn provider_event_uuid( ) } +fn provider_event_seq( + provider: CaptureProvider, + provider_session_id: &str, + provider_event_index: u64, +) -> u64 { + let session_key = format!("provider:{}:{provider_session_id}", provider.as_str()); + ((fnv1a64(session_key.as_bytes()) & 0x0000_07ff_ffff_ffff) << 20) + | (provider_event_index & 0x000f_ffff) +} + +fn provider_source_event_uuid(source_id: Uuid, provider_event_index: u64) -> Uuid { + stable_capture_uuid( + &format!("provider-source:{source_id}:event:{provider_event_index}"), + "event", + ) +} + fn provider_file_touch_uuid( provider: CaptureProvider, provider_session_id: &str, @@ -7732,14 +12282,17 @@ fn provider_file_touch_uuid( ) } -fn provider_event_seq( - provider: CaptureProvider, - provider_session_id: &str, - provider_event_index: u64, -) -> u64 { - let session_key = format!("provider:{}:{provider_session_id}", provider.as_str()); - ((fnv1a64(session_key.as_bytes()) & 0x0000_07ff_ffff_ffff) << 20) - | (provider_event_index & 0x000f_ffff) +fn provider_source_file_touch_uuid(source_id: Uuid, provider_touch_index: u64) -> Uuid { + stable_capture_uuid( + &format!("provider-source:{source_id}:file-touch:{provider_touch_index}"), + "file-touch", + ) +} + +fn provider_source_event_seq(source_id: Uuid, provider_event_index: u64) -> u64 { + let source_key = source_id.to_string(); + ((fnv1a64(source_key.as_bytes()) & 0x0000_0000_7fff_ffff) << 32) + | (provider_event_index & 0xffff_ffff) } fn provider_edge_uuid( @@ -7865,6 +12418,35 @@ mod tests { materialized_fixture("provider-history", name) } + fn custom_history_fixture(name: &str) -> PathBuf { + materialized_fixture("custom-history-jsonl", name) + } + + fn write_oversized_jsonl_line(path: &Path) { + fs::write(path, vec![b'x'; MAX_PROVIDER_JSONL_LINE_BYTES + 1]).unwrap(); + } + + fn jsonl_line(value: Value) -> String { + serde_json::to_string(&value).unwrap() + "\n" + } + + fn test_provider_event(event_type: EventType) -> ProviderEventEnvelope { + ProviderEventEnvelope { + provider_event_index: 0, + provider_event_hash: Some("event-hash".to_owned()), + cursor: None, + event_type, + role: Some(EventRole::Tool), + occurred_at: "2026-07-03T12:00:00Z".parse().unwrap(), + fidelity: Fidelity::Imported, + redaction_state: RedactionState::LocalPreview, + idempotency_key: None, + artifacts: Vec::new(), + payload: json!({}), + metadata: json!({}), + } + } + fn materialized_fixture(category: &str, name: &str) -> PathBuf { let source = match category { "provider" => PathBuf::from(env!("CARGO_MANIFEST_DIR")) @@ -7873,6 +12455,9 @@ mod tests { "provider-history" => PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../../tests/fixtures/provider-history") .join(name), + "custom-history-jsonl" => PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../tests/fixtures/custom-history-jsonl") + .join(name), _ => panic!("unknown fixture category {category}"), }; let root = std::env::current_dir() @@ -8022,7 +12607,8 @@ mod tests { source_path: &session.source_path, file_size_bytes: session.file_size_bytes, file_modified_at_ms: session.file_modified_at_ms, - event_count: 1, + file_sha256: None, + event_count: Some(1), indexed_at_ms, }, ) @@ -8392,7 +12978,7 @@ mod tests { let session_id = provider_session_uuid(CaptureProvider::Pi, "pi-session-1"); let events = store.events_for_session(session_id).unwrap(); assert_eq!(events.len(), 2); - assert_eq!(events[1].redaction_state, RedactionState::SafePreview); + assert_eq!(events[1].redaction_state, RedactionState::LocalPreview); assert!(events[1] .sync .metadata @@ -8457,6 +13043,109 @@ mod tests { assert!(!events[3].payload.to_string().contains("[REDACTED]")); } + #[test] + fn pi_session_import_rejects_malformed_event_timestamp() { + let temp = tempdir(); + let path = temp.path().join("bad-timestamp-pi.jsonl"); + fs::write( + &path, + [ + jsonl_line(json!({ + "type": "session", + "id": "pi-bad-timestamp", + "timestamp": "2026-07-03T12:00:00Z", + "version": 1 + })), + jsonl_line(json!({ + "type": "message", + "id": "pi-bad-event", + "timestamp": "not-rfc3339", + "message": { + "role": "user", + "content": "bad timestamp should not import" + } + })), + ] + .concat(), + ) + .unwrap(); + + let mut store = Store::open(temp.path().join("work.sqlite")).unwrap(); + let summary = import_pi_session_jsonl( + &path, + &mut store, + PiSessionImportOptions { + imported_at: "2026-07-03T12:30:00Z".parse().unwrap(), + ..PiSessionImportOptions::default() + }, + ) + .unwrap(); + + assert_eq!(summary.failed, 1, "{:?}", summary.failures); + assert!(summary.failures[0] + .error + .contains("timestamp is not a valid RFC3339 timestamp")); + assert!(store.list_sessions().unwrap().is_empty()); + } + + #[test] + fn pi_session_import_replays_default_session_directory_tree() { + let temp = tempdir(); + let root = temp.path().join(".pi/agent/sessions/--workspace--"); + fs::create_dir_all(&root).unwrap(); + fs::write( + root.join("2026-06-24T12-00-00-000Z_pi-dir-alpha.jsonl"), + concat!( + "{\"type\":\"session\",\"version\":3,\"id\":\"pi-dir-alpha\",\"timestamp\":\"2026-06-24T12:00:00Z\",\"cwd\":\"/workspace\"}\n", + "{\"type\":\"message\",\"id\":\"pi-dir-alpha-user\",\"timestamp\":\"2026-06-24T12:00:01Z\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"alpha directory import\"}]}}\n", + ), + ) + .unwrap(); + fs::write( + root.join("2026-06-24T12-01-00-000Z_pi-dir-beta.jsonl"), + concat!( + "{\"type\":\"session\",\"version\":3,\"id\":\"pi-dir-beta\",\"timestamp\":\"2026-06-24T12:01:00Z\",\"cwd\":\"/workspace\"}\n", + "{\"type\":\"message\",\"id\":\"pi-dir-beta-user\",\"timestamp\":\"2026-06-24T12:01:01Z\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"beta directory import\"}]}}\n", + ), + ) + .unwrap(); + let sessions_root = temp.path().join(".pi/agent/sessions"); + let mut store = Store::open(temp.path().join("work.sqlite")).unwrap(); + + let first = import_pi_session_jsonl( + &sessions_root, + &mut store, + PiSessionImportOptions { + source_path: Some(sessions_root.clone()), + imported_at: "2026-06-24T16:00:00Z".parse().unwrap(), + ..PiSessionImportOptions::default() + }, + ) + .unwrap(); + assert_eq!(first.failed, 0, "{:?}", first.failures); + assert_eq!(first.imported_sessions, 2); + assert_eq!(first.imported_events, 2); + + let second = import_pi_session_jsonl( + &sessions_root, + &mut store, + PiSessionImportOptions { + source_path: Some(sessions_root.clone()), + imported_at: "2026-06-24T16:00:00Z".parse().unwrap(), + ..PiSessionImportOptions::default() + }, + ) + .unwrap(); + assert_eq!(second.failed, 0, "{:?}", second.failures); + assert_eq!(second.imported_events, 0); + assert_eq!(second.skipped_events, 2); + + let alpha = provider_session_uuid(CaptureProvider::Pi, "pi-dir-alpha"); + let beta = provider_session_uuid(CaptureProvider::Pi, "pi-dir-beta"); + assert_eq!(store.events_for_session(alpha).unwrap().len(), 1); + assert_eq!(store.events_for_session(beta).unwrap().len(), 1); + } + #[test] fn codex_session_tree_imports_messages_and_subagent_edges() { let temp = tempdir(); @@ -8611,6 +13300,32 @@ mod tests { assert_eq!(third.failed_sessions, 0); } + #[test] + fn codex_session_catalog_rejects_oversized_metadata_line() { + let temp = tempdir(); + let root = temp.path().join("sessions/2026/07/03"); + fs::create_dir_all(&root).unwrap(); + write_oversized_jsonl_line(&root.join("oversized.jsonl")); + let store = Store::open(temp.path().join("work.sqlite")).unwrap(); + + let err = catalog_codex_session_tree( + temp.path().join("sessions"), + &store, + CodexSessionCatalogOptions { + source_root: Some(temp.path().join("sessions")), + cataloged_at: "2026-07-03T12:00:00Z".parse().unwrap(), + allow_partial_failures: false, + ..CodexSessionCatalogOptions::default() + }, + ) + .unwrap_err(); + + assert!( + err.to_string().contains("provider JSONL line exceeds"), + "{err}" + ); + } + #[test] fn codex_session_catalog_marks_deleted_paths_stale_when_additions_outnumber_deletions() { let temp = tempdir(); @@ -8990,6 +13705,40 @@ mod tests { assert!(store.list_sessions().unwrap().is_empty()); } + #[cfg(unix)] + #[test] + fn codex_session_file_rejects_symlinked_parent_components() { + use std::os::unix::fs::symlink; + + let temp = tempdir(); + let real_dir = temp.path().join("real-parent"); + fs::create_dir_all(&real_dir).unwrap(); + let fixture = provider_history_fixture("codex-sessions").join("2026/06/23/root.jsonl"); + fs::copy(&fixture, real_dir.join("root.jsonl")).unwrap(); + let link_dir = temp.path().join("linked-parent"); + symlink(&real_dir, &link_dir).unwrap(); + let linked_file = link_dir.join("root.jsonl"); + + let mut store = Store::open(temp.path().join("work.sqlite")).unwrap(); + let err = import_codex_session_jsonl( + &linked_file, + &mut store, + CodexSessionImportOptions { + imported_at: "2026-06-23T16:30:00Z".parse().unwrap(), + ..CodexSessionImportOptions::default() + }, + ) + .unwrap_err(); + + assert!(matches!( + err, + CaptureError::InvalidProviderTranscriptPath { path, reason } + if path.ends_with("linked-parent/root.jsonl") + && reason == "symlinked provider transcript path components are rejected" + )); + assert!(store.list_sessions().unwrap().is_empty()); + } + #[cfg(unix)] #[test] fn codex_session_tree_rejects_symlinked_jsonl_files() { @@ -9021,6 +13770,92 @@ mod tests { assert!(store.list_sessions().unwrap().is_empty()); } + #[test] + fn codex_session_jsonl_rejects_oversized_line() { + let temp = tempdir(); + let path = temp.path().join("oversized-codex.jsonl"); + write_oversized_jsonl_line(&path); + + let err = CodexSessionJsonlAdapter + .normalize_path(&path, &ProviderAdapterContext::default()) + .unwrap_err(); + assert!(err.to_string().contains("provider JSONL line exceeds")); + } + + #[test] + fn codex_session_jsonl_rejects_malformed_event_timestamp() { + let temp = tempdir(); + let path = temp.path().join("bad-timestamp-codex.jsonl"); + fs::write( + &path, + [ + jsonl_line(json!({ + "timestamp": "2026-07-03T12:00:00Z", + "type": "session_meta", + "payload": { + "id": "codex-bad-timestamp", + "timestamp": "2026-07-03T12:00:00Z", + "cwd": "/workspace", + "originator": "codex-cli" + } + })), + jsonl_line(json!({ + "timestamp": "not-rfc3339", + "type": "response_item", + "payload": { + "type": "message", + "role": "user", + "content": [ + {"type": "input_text", "text": "bad timestamp should not import"} + ] + } + })), + ] + .concat(), + ) + .unwrap(); + + let mut store = Store::open(temp.path().join("work.sqlite")).unwrap(); + let summary = import_codex_session_jsonl( + &path, + &mut store, + CodexSessionImportOptions { + imported_at: "2026-07-03T12:30:00Z".parse().unwrap(), + fast_event_inserts: false, + ..CodexSessionImportOptions::default() + }, + ) + .unwrap(); + + assert_eq!(summary.failed, 1, "{:?}", summary.failures); + assert!(summary.failures[0] + .error + .contains("timestamp is not a valid RFC3339 timestamp")); + assert!(store.list_sessions().unwrap().is_empty()); + } + + #[test] + fn provider_command_run_rejects_negative_duration() { + let event = test_provider_event(EventType::CommandOutput); + let err = provider_command_run_from_event(ProviderCommandRunInput { + provider: CaptureProvider::Codex, + provider_session_id: "duration-session", + session_id: new_id(), + source_id: new_id(), + run_source_id: None, + history_record_id: None, + event: &event, + payload: &json!({ + "command": "cargo test", + "duration_ms": -1 + }), + event_hash: "event-hash", + }) + .unwrap_err(); + + assert!(err.to_string().contains("duration_ms must be nonnegative")); + } + #[test] fn codex_session_tree_imports_rich_tool_outputs_and_preserves_previews() { let temp = tempdir(); @@ -9196,7 +14031,7 @@ mod tests { role: Some(EventRole::Assistant), occurred_at: "2026-06-24T01:00:00Z".parse().unwrap(), fidelity: Fidelity::Imported, - redaction_state: RedactionState::SafePreview, + redaction_state: RedactionState::LocalPreview, idempotency_key: None, artifacts: Vec::new(), payload: serde_json::json!({}), @@ -9230,6 +14065,7 @@ mod tests { CaptureProvider::Antigravity, "agy-session", ANTIGRAVITY_CLI_SOURCE_FORMAT, + None, &antigravity, &event, 1, @@ -9238,6 +14074,7 @@ mod tests { CaptureProvider::Cursor, "cursor-session", CURSOR_AGENT_TRANSCRIPT_SOURCE_FORMAT, + None, &cursor, &event, 1, @@ -9265,7 +14102,7 @@ mod tests { role: Some(EventRole::Assistant), occurred_at: "2026-06-24T01:00:00Z".parse().unwrap(), fidelity: Fidelity::Imported, - redaction_state: RedactionState::SafePreview, + redaction_state: RedactionState::LocalPreview, idempotency_key: None, artifacts: Vec::new(), payload: serde_json::json!({}), @@ -9342,6 +14179,7 @@ mod tests { provider, "provider-session", source_format, + None, &raw, &event, 1, @@ -9544,55 +14382,183 @@ mod tests { assert_eq!(summary.failed, 1); assert_eq!(summary.imported_sessions, 1); - assert_eq!(summary.imported_events, 1); - assert!(summary.failures[0].error.contains("malformed JSONL")); + assert_eq!(summary.imported_events, 1); + assert!(summary.failures[0].error.contains("malformed JSONL")); + } + + #[test] + fn native_opencode_imports_read_only_sqlite() { + let temp = tempdir(); + let fixture = write_opencode_smoke_db(&temp, false); + let mut store = Store::open(temp.path().join("work.sqlite")).unwrap(); + + let summary = import_opencode_sqlite( + &fixture, + &mut store, + OpenCodeSqliteImportOptions { + machine_id: "test-machine".into(), + source_path: Some(fixture.clone()), + imported_at: DateTime::parse_from_rfc3339("2026-06-24T12:00:00Z") + .unwrap() + .with_timezone(&Utc), + allow_partial_failures: true, + ..OpenCodeSqliteImportOptions::default() + }, + ) + .unwrap(); + + assert_eq!(summary.failed, 0); + assert_eq!(summary.imported_sessions, 2); + assert_eq!(summary.imported_events, 3); + assert_eq!(summary.imported_edges, 1); + let parent_id = provider_session_uuid(CaptureProvider::OpenCode, "opencode-root"); + let child_id = provider_session_uuid(CaptureProvider::OpenCode, "opencode-child"); + assert_eq!( + store.get_session(child_id).unwrap().parent_session_id, + Some(parent_id) + ); + let events = store.events_for_session(parent_id).unwrap(); + assert!(events + .iter() + .any(|event| event.event_type == EventType::ToolCall)); + assert_eq!( + events[0].sync.metadata["source_format"].as_str(), + Some(OPENCODE_SQLITE_SOURCE_FORMAT) + ); + } + + #[test] + fn native_hermes_rejects_out_of_range_message_timestamp() { + let temp = tempdir(); + let fixture = write_hermes_smoke_db(&temp); + let conn = Connection::open(&fixture).unwrap(); + conn.execute( + "update messages set timestamp = ?1 where content = 'bad timestamp'", + [1.0e300_f64], + ) + .unwrap(); + drop(conn); + let mut store = Store::open(temp.path().join("work.sqlite")).unwrap(); + + let summary = import_hermes_sqlite( + &fixture, + &mut store, + HermesSqliteImportOptions { + allow_partial_failures: true, + ..HermesSqliteImportOptions::default() + }, + ) + .unwrap(); + + assert_eq!(summary.failed, 1); + assert!(summary.failures[0] + .error + .contains("Hermes message timestamp")); + assert_eq!(summary.imported_events, 1); + } + + #[cfg(unix)] + #[test] + fn native_opencode_normalizer_rejects_symlinked_sqlite() { + use std::os::unix::fs::symlink; + + let temp = tempdir(); + let fixture = write_opencode_smoke_db(&temp, false); + let link = temp.path().join("linked-opencode.db"); + symlink(&fixture, &link).unwrap(); + + let err = normalize_opencode_sqlite(&link, &ProviderAdapterContext::default()).unwrap_err(); + assert!(matches!( + err, + CaptureError::InvalidProviderTranscriptPath { path, reason } + if path.ends_with("linked-opencode.db") + && reason == "symlinked provider transcript files are rejected" + )); + } + + #[test] + fn native_opencode_synthesizes_session_message_seq_when_missing() { + let temp = tempdir(); + let fixture = write_opencode_session_message_without_seq_db(&temp); + let mut store = Store::open(temp.path().join("work.sqlite")).unwrap(); + + let summary = import_opencode_sqlite( + &fixture, + &mut store, + OpenCodeSqliteImportOptions { + allow_partial_failures: true, + ..OpenCodeSqliteImportOptions::default() + }, + ) + .unwrap(); + + assert_eq!(summary.failed, 0); + assert_eq!(summary.imported_sessions, 1); + assert_eq!(summary.imported_events, 2); + + let session_id = provider_session_uuid(CaptureProvider::OpenCode, "opencode-no-seq"); + let events = store.events_for_session(session_id).unwrap(); + assert_eq!(events.len(), 2); + assert_eq!( + events[0].payload["body"]["session_message_seq"].as_i64(), + Some(1) + ); + assert_eq!( + events[1].payload["body"]["session_message_seq"].as_i64(), + Some(2) + ); + assert_ne!(events[0].id, events[1].id); } #[test] - fn native_opencode_imports_read_only_sqlite() { + fn native_opencode_rejects_negative_session_message_seq() { let temp = tempdir(); let fixture = write_opencode_smoke_db(&temp, false); + let conn = Connection::open(&fixture).unwrap(); + conn.execute( + "update session_message set seq = -1 where id = 'msg-user'", + [], + ) + .unwrap(); + drop(conn); let mut store = Store::open(temp.path().join("work.sqlite")).unwrap(); let summary = import_opencode_sqlite( &fixture, &mut store, OpenCodeSqliteImportOptions { - machine_id: "test-machine".into(), - source_path: Some(fixture.clone()), - imported_at: DateTime::parse_from_rfc3339("2026-06-24T12:00:00Z") - .unwrap() - .with_timezone(&Utc), allow_partial_failures: true, ..OpenCodeSqliteImportOptions::default() }, ) .unwrap(); - assert_eq!(summary.failed, 0); - assert_eq!(summary.imported_sessions, 2); - assert_eq!(summary.imported_events, 3); - assert_eq!(summary.imported_edges, 1); - let parent_id = provider_session_uuid(CaptureProvider::OpenCode, "opencode-root"); - let child_id = provider_session_uuid(CaptureProvider::OpenCode, "opencode-child"); - assert_eq!( - store.get_session(child_id).unwrap().parent_session_id, - Some(parent_id) - ); - let events = store.events_for_session(parent_id).unwrap(); - assert!(events - .iter() - .any(|event| event.event_type == EventType::ToolCall)); - assert_eq!( - events[0].sync.metadata["source_format"].as_str(), - Some(OPENCODE_SQLITE_SOURCE_FORMAT) - ); + assert_eq!(summary.failed, 1); + assert!(summary.failures[0] + .error + .contains("OpenCode session_message seq must be nonnegative")); + assert_eq!(summary.imported_events, 2); + let session_id = provider_session_uuid(CaptureProvider::OpenCode, "opencode-root"); + let events = store.events_for_session(session_id).unwrap(); + assert!(events.iter().all(|event| { + event.payload["body"]["session_message_seq"] + .as_i64() + .is_some_and(|seq| seq >= 0) + })); } #[test] - fn native_opencode_synthesizes_session_message_seq_when_missing() { + fn native_opencode_rejects_out_of_range_message_timestamp() { let temp = tempdir(); - let fixture = write_opencode_session_message_without_seq_db(&temp); + let fixture = write_opencode_smoke_db(&temp, false); + let conn = Connection::open(&fixture).unwrap(); + let data_without_payload_time = json!({"text": "bad timestamp fallback"}).to_string(); + conn.execute( + "update session_message set time_created = ?1, data = ?2 where id = 'msg-user'", + rusqlite::params![i64::MAX, data_without_payload_time], + ) + .unwrap(); + drop(conn); let mut store = Store::open(temp.path().join("work.sqlite")).unwrap(); let summary = import_opencode_sqlite( @@ -9605,22 +14571,40 @@ mod tests { ) .unwrap(); - assert_eq!(summary.failed, 0); - assert_eq!(summary.imported_sessions, 1); + assert_eq!(summary.failed, 1); + assert!(summary.failures[0] + .error + .contains("OpenCode session_message time_created")); assert_eq!(summary.imported_events, 2); + } - let session_id = provider_session_uuid(CaptureProvider::OpenCode, "opencode-no-seq"); - let events = store.events_for_session(session_id).unwrap(); - assert_eq!(events.len(), 2); - assert_eq!( - events[0].payload["body"]["session_message_seq"].as_i64(), - Some(1) + #[test] + fn native_opencode_rejects_oversized_sqlite_text_value() { + let temp = tempdir(); + let fixture = write_opencode_smoke_db(&temp, false); + let conn = Connection::open(&fixture).unwrap(); + let oversized_data = format!( + "{{\"time\":{{\"created\":1782259200000}},\"text\":\"{}\"}}", + "x".repeat(MAX_PROVIDER_SQLITE_VALUE_BYTES + 1) ); - assert_eq!( - events[1].payload["body"]["session_message_seq"].as_i64(), - Some(2) + conn.execute( + "update session_message set data = ?1 where id = 'msg-user'", + [&oversized_data], + ) + .unwrap(); + drop(conn); + + let err = import_opencode_sqlite( + &fixture, + &mut Store::open(temp.path().join("work.sqlite")).unwrap(), + OpenCodeSqliteImportOptions::default(), + ) + .unwrap_err(); + + assert!( + err.to_string().contains("too big"), + "unexpected error: {err}" ); - assert_ne!(events[0].id, events[1].id); } #[test] @@ -9686,32 +14670,379 @@ mod tests { ) .unwrap(); - assert_eq!(summary.failed, 0); - assert_eq!(summary.imported_sessions, 1); - assert_eq!(summary.imported_events, 1); + assert_eq!(summary.failed, 0); + assert_eq!(summary.imported_sessions, 1); + assert_eq!(summary.imported_events, 1); + + let session_id = provider_session_uuid(CaptureProvider::OpenCode, "current-root"); + let events = store.events_for_session(session_id).unwrap(); + assert_eq!(events.len(), 1); + assert_eq!( + events[0].sync.metadata["source_format"].as_str(), + Some(OPENCODE_SQLITE_SOURCE_FORMAT) + ); + } + + #[test] + fn native_opencode_rejects_changed_message_schema_before_querying() { + let temp = tempdir(); + let fixture = write_opencode_future_incomplete_schema_db(&temp); + let mut store = Store::open(temp.path().join("work.sqlite")).unwrap(); + + let err = + import_opencode_sqlite(&fixture, &mut store, OpenCodeSqliteImportOptions::default()) + .unwrap_err(); + + assert!(err + .to_string() + .contains("OpenCode SQLite message table missing required column(s): data")); + } + + #[test] + fn openclaw_import_ignores_oversized_session_index_sidecar() { + let temp = tempdir(); + let root = temp.path().join("openclaw"); + let sessions = root.join("agents/personal-agent/sessions"); + fs::create_dir_all(&sessions).unwrap(); + fs::write( + sessions.join("sessions.json"), + vec![b'x'; MAX_OPENCLAW_SESSION_INDEX_BYTES + 1], + ) + .unwrap(); + fs::write( + sessions.join("openclaw-oversized-index.jsonl"), + format!( + "{}\n{}\n", + json!({ + "type": "session", + "id": "openclaw-oversized-index", + "timestamp": "2026-06-24T12:00:00Z", + "cwd": "/workspace" + }), + json!({ + "type": "message", + "id": "openclaw-oversized-index-user", + "timestamp": "2026-06-24T12:00:01Z", + "message": {"role": "user", "content": "oversized sidecar should not block import"} + }) + ), + ) + .unwrap(); + let mut store = Store::open(temp.path().join("work.sqlite")).unwrap(); + + let summary = import_openclaw_history( + &root, + &mut store, + OpenClawImportOptions { + allow_partial_failures: true, + ..OpenClawImportOptions::default() + }, + ) + .unwrap(); + + assert_eq!(summary.failed, 0); + assert_eq!(summary.imported_sessions, 1); + assert_eq!(summary.imported_events, 1); + let session_id = provider_session_uuid( + CaptureProvider::OpenClaw, + "personal-agent/openclaw-oversized-index", + ); + let session = store.get_session(session_id).unwrap(); + assert_eq!( + session.external_session_id.as_deref(), + Some("personal-agent/openclaw-oversized-index") + ); + } + + #[test] + fn native_shelley_imports_sessions_messages_metadata_and_citations() { + let temp = tempdir(); + let fixture = write_shelley_smoke_db(&temp); + let mut store = Store::open(temp.path().join("work.sqlite")).unwrap(); + + let summary = import_shelley_sqlite( + &fixture, + &mut store, + ShelleySqliteImportOptions { + machine_id: "test-machine".into(), + source_path: Some(fixture.clone()), + imported_at: DateTime::parse_from_rfc3339("2026-06-24T12:00:00Z") + .unwrap() + .with_timezone(&Utc), + allow_partial_failures: true, + ..ShelleySqliteImportOptions::default() + }, + ) + .unwrap(); + + assert_eq!(summary.failed, 0, "{:?}", summary.failures); + assert_eq!(summary.imported_sessions, 3); + assert_eq!(summary.imported_events, 4); + assert_eq!(summary.imported_edges, 1); + + let parent_id = provider_session_uuid(CaptureProvider::Shelley, "shelley-root"); + let child_id = provider_session_uuid(CaptureProvider::Shelley, "shelley-child"); + assert_eq!( + store.get_session(child_id).unwrap().parent_session_id, + Some(parent_id) + ); + assert!(store + .get_session(parent_id) + .unwrap() + .sync + .metadata + .to_string() + .contains("queued oracle")); + + let source = store + .capture_source_by_external_session(CaptureProvider::Shelley, "shelley-root") + .unwrap() + .unwrap(); + assert_eq!( + source.descriptor.raw_source_path.as_deref(), + fixture.to_str() + ); + assert_eq!(source.descriptor.provider, CaptureProvider::Shelley); + + let events = store.events_for_session(parent_id).unwrap(); + assert_eq!(events.len(), 3); + let agent_event = events + .iter() + .find(|event| { + event.sync.metadata["metadata"]["message_id"].as_str() == Some("msg-agent") + }) + .expect("Shelley agent event imported"); + let tool_result_event = events + .iter() + .find(|event| { + event.sync.metadata["metadata"]["message_id"].as_str() == Some("msg-tool-result") + }) + .expect("Shelley tool-result event imported"); + assert_eq!(agent_event.event_type, EventType::ToolCall); + assert_eq!(tool_result_event.event_type, EventType::ToolOutput); + let rendered = serde_json::to_string(&events).unwrap(); + assert!(rendered.contains("shelley search oracle")); + assert!(rendered.contains("thinking through the search")); + assert!(rendered.contains("tool call: bash")); + assert!(rendered.contains("tool output oracle")); + assert!(rendered.contains("claude-opus-4-7")); + assert!(rendered.contains("https://api.anthropic.com/v1/messages")); + let user_event = events + .iter() + .find(|event| { + event.sync.metadata["metadata"]["message_id"].as_str() == Some("msg-user") + }) + .expect("Shelley user event imported"); + assert!(user_event + .sync + .metadata + .to_string() + .contains("conversation:shelley-root:sequence:1:message:msg-user")); + + let cursor = store + .get_sync_cursor( + None, + "test-machine", + &provider_cursor_stream(CaptureProvider::Shelley, SHELLEY_SQLITE_SOURCE_FORMAT), + ) + .unwrap() + .unwrap(); + assert!(cursor + .cursor + .contains("conversation:shelley-root:sequence:3:message:msg-tool-result")); + } + + #[test] + fn native_shelley_reimport_is_idempotent() { + let temp = tempdir(); + let fixture = write_shelley_smoke_db(&temp); + let mut store = Store::open(temp.path().join("work.sqlite")).unwrap(); + + let first = import_shelley_sqlite( + &fixture, + &mut store, + ShelleySqliteImportOptions { + allow_partial_failures: true, + ..ShelleySqliteImportOptions::default() + }, + ) + .unwrap(); + assert_eq!(first.imported_events, 4); + + let second = import_shelley_sqlite( + &fixture, + &mut store, + ShelleySqliteImportOptions { + allow_partial_failures: true, + ..ShelleySqliteImportOptions::default() + }, + ) + .unwrap(); + assert_eq!(second.failed, 0, "{:?}", second.failures); + assert_eq!(second.imported_sessions, 0); + assert_eq!(second.imported_events, 0); + assert_eq!(second.imported_edges, 0); + assert_eq!(second.skipped_sessions, 3); + assert_eq!(second.skipped_events, 4); + assert_eq!(second.skipped_edges, 1); + } + + #[test] + fn native_shelley_handles_duplicate_sequences_and_nonchat_rows() { + let temp = tempdir(); + let fixture = write_shelley_adversarial_db(&temp); + let mut store = Store::open(temp.path().join("work.sqlite")).unwrap(); + + let summary = import_shelley_sqlite( + &fixture, + &mut store, + ShelleySqliteImportOptions { + allow_partial_failures: true, + ..ShelleySqliteImportOptions::default() + }, + ) + .unwrap(); + + assert_eq!(summary.failed, 0, "{:?}", summary.failures); + assert_eq!(summary.imported_sessions, 1); + assert_eq!(summary.imported_events, 5); + + let session_id = provider_session_uuid(CaptureProvider::Shelley, "shelley-adversarial"); + let events = store.events_for_session(session_id).unwrap(); + assert_eq!(events.len(), 5); + assert_eq!( + events + .iter() + .map(|event| event.id) + .collect::>() + .len(), + 5 + ); + let rendered = serde_json::to_string(&events).unwrap(); + assert!(rendered.contains("duplicate sequence first")); + assert!(rendered.contains("duplicate sequence second")); + assert!(events + .iter() + .any(|event| event.event_type == EventType::VcsChange)); + assert!(events + .iter() + .any( + |event| event.sync.metadata["metadata"]["message_type"].as_str() == Some("warning") + )); + + let large = events + .iter() + .find(|event| { + event.sync.metadata["metadata"]["message_id"].as_str() == Some("msg-large") + }) + .expect("large Shelley event imported"); + assert_eq!(large.payload["body"]["truncated"].as_bool(), Some(true)); + assert!( + large.payload["body"]["text"] + .as_str() + .unwrap() + .chars() + .count() + <= PROVIDER_MAX_TEXT_CHARS + ); + } + + #[test] + fn native_shelley_text_extraction_is_not_duplicate_or_unbounded() { + let text = shelley_value_text(&json!({ + "Content": [ + {"Type": 2, "Text": "once"} + ] + })) + .unwrap(); + assert_eq!(text, "once"); + + let huge = "x".repeat(PROVIDER_MAX_TEXT_CHARS + 200); + let text = shelley_value_text(&json!({ + "Content": [ + {"Type": 2, "Text": huge}, + {"Type": 2, "Text": "after cap"} + ] + })) + .unwrap(); + assert_eq!(text.chars().count(), PROVIDER_MAX_TEXT_CHARS + 1); + assert!(!text.contains("after cap")); + } + + #[test] + fn native_shelley_event_index_uses_stable_message_identity() { + let message = ShelleyMessageRow { + rowid: 1, + message_id: "msg-stable".to_owned(), + conversation_id: "conv-stable".to_owned(), + sequence_id: 42, + entry_type: "user".to_owned(), + llm_data: None, + user_data: None, + usage_data: None, + created_at: None, + display_data: None, + excluded_from_context: false, + generation: None, + llm_api_url: None, + model_name: None, + forked_from_message_id: None, + }; + let mut moved_row = message.clone(); + moved_row.rowid = 999; + let mut duplicate_sequence = message.clone(); + duplicate_sequence.message_id = "msg-stable-other".to_owned(); - let session_id = provider_session_uuid(CaptureProvider::OpenCode, "current-root"); - let events = store.events_for_session(session_id).unwrap(); - assert_eq!(events.len(), 1); assert_eq!( - events[0].sync.metadata["source_format"].as_str(), - Some(OPENCODE_SQLITE_SOURCE_FORMAT) + shelley_event_index(&message), + shelley_event_index(&moved_row) + ); + assert_ne!( + shelley_event_index(&message), + shelley_event_index(&duplicate_sequence) ); } #[test] - fn native_opencode_rejects_changed_message_schema_before_querying() { + fn native_shelley_reports_malformed_and_corrupt_db() { let temp = tempdir(); - let fixture = write_opencode_future_incomplete_schema_db(&temp); + let malformed = write_shelley_malformed_db(&temp); + let corrupt = temp.path().join("corrupt-shelley.db"); + fs::write(&corrupt, b"not sqlite").unwrap(); let mut store = Store::open(temp.path().join("work.sqlite")).unwrap(); + let err = import_shelley_sqlite( + &malformed, + &mut store, + ShelleySqliteImportOptions::default(), + ) + .unwrap_err(); + assert!(err + .to_string() + .contains("Shelley messages table missing required column(s): type")); + let err = - import_opencode_sqlite(&fixture, &mut store, OpenCodeSqliteImportOptions::default()) + import_shelley_sqlite(&corrupt, &mut store, ShelleySqliteImportOptions::default()) .unwrap_err(); + assert!(err.to_string().contains("not a database")); + } - assert!(err - .to_string() - .contains("OpenCode SQLite message table missing required column(s): data")); + #[test] + fn provider_sources_discovers_shelley_default_db() { + let temp = tempdir(); + let db = temp.path().join(".config/shelley/shelley.db"); + fs::create_dir_all(db.parent().unwrap()).unwrap(); + fs::write(&db, b"not inspected by source probe").unwrap(); + + let sources = discover_provider_sources_for_provider(temp.path(), CaptureProvider::Shelley); + let source = sources + .iter() + .find(|source| source.source_format == SHELLEY_SQLITE_SOURCE_FORMAT) + .unwrap_or_else(|| panic!("missing Shelley source in {sources:#?}")); + assert_eq!(source.provider, CaptureProvider::Shelley); + assert_eq!(source.status, ProviderSourceStatus::Available); + assert_eq!(source.import_support, ProviderImportSupport::Native); + assert_eq!(source.path, db); } #[test] @@ -9867,6 +15198,44 @@ mod tests { path } + fn write_hermes_smoke_db(temp: &TempDir) -> PathBuf { + let path = temp.path().join("hermes-state.db"); + let conn = Connection::open(&path).unwrap(); + conn.execute_batch( + "create table sessions ( + id text primary key, + source text not null, + started_at real not null + ); + create table messages ( + id integer primary key autoincrement, + session_id text not null, + role text not null, + content text, + timestamp real not null, + active integer not null default 1, + compacted integer not null default 0 + );", + ) + .unwrap(); + conn.execute( + "insert into sessions values (?1, 'acp', 1782259200.0)", + ["hermes-root"], + ) + .unwrap(); + conn.execute( + "insert into messages (session_id, role, content, timestamp) values (?1, 'user', 'bad timestamp', 1782259201.0)", + ["hermes-root"], + ) + .unwrap(); + conn.execute( + "insert into messages (session_id, role, content, timestamp) values (?1, 'assistant', 'good timestamp', 1782259202.0)", + ["hermes-root"], + ) + .unwrap(); + path + } + fn write_opencode_session_message_without_seq_db(temp: &TempDir) -> PathBuf { let path = temp.path().join("opencode-no-seq.db"); let conn = Connection::open(&path).unwrap(); @@ -10026,6 +15395,253 @@ mod tests { path } + fn write_shelley_smoke_db(temp: &TempDir) -> PathBuf { + let path = temp.path().join("shelley.db"); + let conn = Connection::open(&path).unwrap(); + conn.execute_batch( + "create table conversations ( + conversation_id text primary key, + slug text, + user_initiated boolean not null default true, + created_at datetime not null default current_timestamp, + updated_at datetime not null default current_timestamp, + cwd text, + archived boolean not null default false, + parent_conversation_id text, + model text, + conversation_options text not null default '{}', + current_generation integer not null default 1, + agent_working boolean not null default false, + tags text not null default '[]', + is_draft boolean not null default false, + draft text not null default '', + queued_messages text not null default '[]' + ); + create table messages ( + message_id text primary key, + conversation_id text not null, + sequence_id integer not null, + type text not null, + llm_data text, + user_data text, + usage_data text, + created_at datetime not null default current_timestamp, + display_data text, + excluded_from_context boolean not null default false, + generation integer not null default 1, + llm_api_url text, + model_name text, + forked_from_message_id text + );", + ) + .unwrap(); + conn.execute( + "insert into conversations values ( + 'shelley-root', 'root-slug', 1, '2026-06-24 12:00:00', + '2026-06-24 12:05:00', '/workspace/shelley', 0, null, + 'claude-opus-4-7', ?1, 2, 0, ?2, 0, '', ?3 + )", + [ + r#"{"thinking_level":"high","subagent_backend":"shelley"}"#, + r#"["native","ctx"]"#, + r#"[{"id":"queued-1","llm":{"Content":[{"Type":2,"Text":"queued oracle"}]},"created_at":"2026-06-24T12:00:04Z","model":"claude-opus-4-7"}]"#, + ], + ) + .unwrap(); + conn.execute( + "insert into conversations values ( + 'shelley-child', 'child-slug', 0, '2026-06-24 12:01:00', + '2026-06-24 12:02:00', '/workspace/shelley', 0, 'shelley-root', + 'claude-sonnet-4-5', '{}', 1, 0, '[]', 0, '', '[]' + )", + [], + ) + .unwrap(); + conn.execute( + "insert into conversations values ( + 'shelley-draft', 'old-draft', 1, '2026-06-24 11:00:00', + '2026-06-24 11:01:00', '/workspace/archive', 1, null, + null, '{}', 1, 0, '[]', 1, 'draft body', '[]' + )", + [], + ) + .unwrap(); + conn.execute( + "insert into messages ( + message_id, conversation_id, sequence_id, type, user_data, created_at + ) values ('msg-user', 'shelley-root', 1, 'user', ?1, '2026-06-24 12:00:01')", + [json!({ + "Content": [ + {"Type": 2, "Text": "please run shelley search oracle"} + ] + }) + .to_string()], + ) + .unwrap(); + conn.execute( + "insert into messages ( + message_id, conversation_id, sequence_id, type, llm_data, usage_data, + created_at, generation, llm_api_url, model_name + ) values ( + 'msg-agent', 'shelley-root', 2, 'agent', ?1, ?2, + '2026-06-24 12:00:02', 2, 'https://api.anthropic.com/v1/messages', + 'claude-opus-4-7' + )", + [ + json!({ + "Role": 1, + "Content": [ + {"Type": 3, "Thinking": "thinking through the search"}, + {"Type": 2, "Text": "I will inspect the source."}, + {"Type": 5, "ID": "toolu_1", "ToolName": "bash", "ToolInput": {"command": "rg shelley"}} + ], + "EndOfTurn": false + }) + .to_string(), + json!({ + "input_tokens": 100, + "cache_read_input_tokens": 25, + "output_tokens": 40, + "cost_usd": 0.0123, + "model": "claude-opus-4-7", + "url": "https://api.anthropic.com/v1/messages" + }) + .to_string(), + ], + ) + .unwrap(); + conn.execute( + "insert into messages ( + message_id, conversation_id, sequence_id, type, user_data, display_data, + created_at, forked_from_message_id + ) values ( + 'msg-tool-result', 'shelley-root', 3, 'user', ?1, ?2, + '2026-06-24 12:00:03', 'source-msg-tool-result' + )", + [ + json!({ + "Role": 0, + "Content": [ + {"Type": 6, "ToolUseID": "toolu_1", "ToolResult": [{"Type": 2, "Text": "tool output oracle"}]} + ] + }) + .to_string(), + json!({"stdout": "tool output oracle", "exit_code": 0}).to_string(), + ], + ) + .unwrap(); + conn.execute( + "insert into messages ( + message_id, conversation_id, sequence_id, type, llm_data, created_at + ) values ('msg-child', 'shelley-child', 1, 'agent', ?1, '2026-06-24 12:01:01')", + [json!({ + "Content": [ + {"Type": 2, "Text": "subagent result from Shelley"} + ] + }) + .to_string()], + ) + .unwrap(); + path + } + + fn write_shelley_adversarial_db(temp: &TempDir) -> PathBuf { + let path = temp.path().join("shelley-adversarial.db"); + let conn = Connection::open(&path).unwrap(); + conn.execute_batch( + "create table conversations ( + conversation_id text primary key, + slug text, + user_initiated boolean not null default true, + created_at datetime not null default current_timestamp, + updated_at datetime not null default current_timestamp, + cwd text, + archived boolean not null default false, + parent_conversation_id text, + model text, + conversation_options text not null default '{}', + current_generation integer not null default 1, + agent_working boolean not null default false, + tags text not null default '[]', + is_draft boolean not null default false, + draft text not null default '', + queued_messages text not null default '[]' + ); + create table messages ( + message_id text primary key, + conversation_id text not null, + sequence_id integer not null, + type text not null, + llm_data text, + user_data text, + usage_data text, + created_at datetime not null default current_timestamp, + display_data text, + excluded_from_context boolean not null default false, + generation integer not null default 1, + llm_api_url text, + model_name text, + forked_from_message_id text + );", + ) + .unwrap(); + conn.execute( + "insert into conversations values ( + 'shelley-adversarial', 'adversarial', 1, '2026-06-24 12:00:00', + '2026-06-24 12:05:00', '/workspace/shelley', 0, null, + 'claude-opus-4-7', '{}', 1, 0, '[]', 0, '', '[]' + )", + [], + ) + .unwrap(); + for (message_id, sequence_id, message_type, text) in [ + ("msg-dup-a", 1, "user", "duplicate sequence first"), + ("msg-dup-b", 1, "user", "duplicate sequence second"), + ("msg-git", 2, "gitinfo", "commit abc touched shelley.rs"), + ("msg-warning", 3, "warning", "warning message for Shelley"), + ] { + conn.execute( + "insert into messages ( + message_id, conversation_id, sequence_id, type, user_data, created_at + ) values (?1, 'shelley-adversarial', ?2, ?3, ?4, '2026-06-24 12:00:01')", + rusqlite::params![ + message_id, + sequence_id, + message_type, + json!({"Content": [{"Type": 2, "Text": text}]}).to_string(), + ], + ) + .unwrap(); + } + conn.execute( + "insert into messages ( + message_id, conversation_id, sequence_id, type, llm_data, created_at + ) values ('msg-large', 'shelley-adversarial', 4, 'agent', ?1, '2026-06-24 12:00:04')", + [json!({ + "Content": [ + {"Type": 2, "Text": "x".repeat(PROVIDER_MAX_TEXT_CHARS + 200)} + ] + }) + .to_string()], + ) + .unwrap(); + path + } + + fn write_shelley_malformed_db(temp: &TempDir) -> PathBuf { + let path = temp.path().join("shelley-malformed.db"); + let conn = Connection::open(&path).unwrap(); + conn.execute_batch( + "create table conversations (conversation_id text primary key); + create table messages ( + message_id text primary key, + conversation_id text not null + );", + ) + .unwrap(); + path + } + fn write_gemini_smoke_fixture(temp: &TempDir) -> PathBuf { let chats = temp.path().join("gemini/.gemini/tmp/project/chats"); let child_dir = chats.join("gemini-root"); @@ -10344,12 +15960,438 @@ mod tests { Some("path_reference") ); assert_eq!( - source.sync.metadata["redaction_boundary"].as_str(), - Some("before_export") + source.sync.metadata["redaction_boundary"].as_str(), + Some("before_export") + ); + assert!(source.sync.metadata["source_idempotency_key"] + .as_str() + .is_some()); + } + + #[test] + fn provider_import_scopes_provenance_by_source_format_and_path() { + let temp = tempdir(); + let shared_path = temp + .path() + .join("shared-source.jsonl") + .display() + .to_string(); + assert_provider_source_collision_is_distinct( + "provider_format_a", + &shared_path, + "provider_format_b", + &shared_path, + ); + + let first_path = temp.path().join("first-source.jsonl").display().to_string(); + let second_path = temp + .path() + .join("second-source.jsonl") + .display() + .to_string(); + assert_provider_source_collision_is_distinct( + "provider_format", + &first_path, + "provider_format", + &second_path, + ); + } + + #[test] + fn provider_import_reuses_existing_legacy_provider_event_identity() { + let temp = tempdir(); + let mut store = Store::open(temp.path().join("work.sqlite")).unwrap(); + let provider = CaptureProvider::Claude; + let provider_session_id = "legacy-provider-session"; + let source_format = "provider_format"; + let raw_source_path = temp + .path() + .join("legacy-source.jsonl") + .display() + .to_string(); + let occurred_at = DateTime::parse_from_rfc3339("2026-06-23T17:00:01Z") + .unwrap() + .with_timezone(&Utc); + let legacy_source_id = provider_source_uuid(provider, provider_session_id); + let new_source_id = provider_scoped_source_uuid( + provider, + provider_session_id, + source_format, + Some(&raw_source_path), + ); + let session_id = provider_session_uuid(provider, provider_session_id); + let legacy_event_id = provider_event_uuid(provider, provider_session_id, 0); + let legacy_touch_id = provider_file_touch_uuid(provider, provider_session_id, 0); + let event_hash = + compute_payload_hash(&json!({"text": "same provider event payload"})).unwrap(); + assert_ne!(legacy_source_id, new_source_id); + + store + .upsert_capture_source(&CaptureSource { + id: legacy_source_id, + descriptor: CaptureSourceDescriptor { + kind: CaptureSourceKind::ProviderImport, + provider, + machine_id: "test-machine".to_owned(), + process_id: None, + cwd: Some("/workspace/example".to_owned()), + raw_source_path: None, + external_session_id: Some(provider_session_id.to_owned()), + }, + started_at: occurred_at, + ended_at: None, + sync: provider_sync_metadata(Fidelity::Imported, json!({"legacy": true})), + }) + .unwrap(); + store + .upsert_session(&Session { + id: session_id, + history_record_id: None, + parent_session_id: None, + root_session_id: None, + capture_source_id: Some(legacy_source_id), + provider, + external_session_id: Some(provider_session_id.to_owned()), + external_agent_id: None, + agent_type: AgentType::Primary, + role_hint: Some("primary".to_owned()), + is_primary: true, + status: SessionStatus::Imported, + transcript_blob_id: None, + started_at: occurred_at, + ended_at: None, + timestamps: timestamps(occurred_at), + sync: provider_sync_metadata(Fidelity::Imported, json!({"legacy": true})), + }) + .unwrap(); + store + .upsert_event(&Event { + id: legacy_event_id, + seq: provider_event_seq(provider, provider_session_id, 0), + history_record_id: None, + session_id: Some(session_id), + run_id: None, + event_type: EventType::Message, + role: Some(EventRole::User), + occurred_at, + capture_source_id: Some(legacy_source_id), + payload: json!({"body": {"text": "same provider event payload"}}), + payload_blob_id: None, + dedupe_key: Some(Store::provider_event_dedupe_key( + provider, + provider_session_id, + 0, + &event_hash, + )), + redaction_state: RedactionState::LocalPreview, + sync: provider_sync_metadata(Fidelity::Imported, json!({"legacy": true})), + }) + .unwrap(); + store + .upsert_file_touched(&FileTouched { + id: legacy_touch_id, + history_record_id: None, + run_id: None, + event_id: Some(legacy_event_id), + vcs_workspace_id: None, + path: "src/lib.rs".to_owned(), + change_kind: Some(FileChangeKind::Modified), + old_path: None, + line_count_delta: Some(1), + confidence: Confidence::Explicit, + timestamps: timestamps(occurred_at), + source_id: Some(legacy_source_id), + sync: provider_sync_metadata(Fidelity::Imported, json!({"legacy": true})), + }) + .unwrap(); + + let normalization = ProviderNormalizationResult { + summary: ProviderImportSummary::default(), + captures: vec![( + 1, + provider_collision_capture( + provider, + provider_session_id, + source_format, + &raw_source_path, + occurred_at, + ), + )], + files_touched: vec![( + 1, + provider_collision_file_touch( + provider, + provider_session_id, + source_format, + &raw_source_path, + occurred_at, + ), + )], + }; + + let summary = import_normalized_provider_captures( + &mut store, + normalization, + NormalizedProviderImportOptions::default(), + ) + .unwrap(); + + assert_eq!(summary.failed, 0, "{:?}", summary.failures); + assert_eq!(summary.skipped_events, 1); + let events = store.events_for_session(session_id).unwrap(); + assert_eq!(events.len(), 1); + assert_eq!(events[0].id, legacy_event_id); + assert_eq!(events[0].capture_source_id, Some(legacy_source_id)); + + let archive = store.export_archive().unwrap(); + assert_eq!(archive.files_touched.len(), 1); + assert_eq!(archive.files_touched[0].id, legacy_touch_id); + assert_eq!(archive.files_touched[0].event_id, Some(legacy_event_id)); + assert_eq!(archive.files_touched[0].source_id, Some(new_source_id)); + } + + #[test] + fn provider_source_event_seq_keeps_large_provider_indices_distinct() { + let source_id = Uuid::parse_str("018fe2e4-2266-7000-8000-000000000001").unwrap(); + + assert_ne!( + provider_source_event_seq(source_id, 0), + provider_source_event_seq(source_id, 1_048_576) + ); + assert_eq!( + provider_source_event_seq(source_id, 1_048_576) & 0xffff_ffff, + 1_048_576 + ); + } + + fn assert_provider_source_collision_is_distinct( + first_source_format: &str, + first_source_path: &str, + second_source_format: &str, + second_source_path: &str, + ) { + let temp = tempdir(); + let mut store = Store::open(temp.path().join("work.sqlite")).unwrap(); + let provider = CaptureProvider::Claude; + let provider_session_id = "shared-provider-session"; + let occurred_at = DateTime::parse_from_rfc3339("2026-06-23T17:00:01Z") + .unwrap() + .with_timezone(&Utc); + let first_source_id = provider_scoped_source_uuid( + provider, + provider_session_id, + first_source_format, + Some(first_source_path), + ); + let second_source_id = provider_scoped_source_uuid( + provider, + provider_session_id, + second_source_format, + Some(second_source_path), + ); + assert_ne!(first_source_id, second_source_id); + + let normalization = ProviderNormalizationResult { + summary: ProviderImportSummary::default(), + captures: vec![ + ( + 1, + provider_collision_capture( + provider, + provider_session_id, + first_source_format, + first_source_path, + occurred_at, + ), + ), + ( + 2, + provider_collision_capture( + provider, + provider_session_id, + second_source_format, + second_source_path, + occurred_at, + ), + ), + ], + files_touched: vec![ + ( + 1, + provider_collision_file_touch( + provider, + provider_session_id, + first_source_format, + first_source_path, + occurred_at, + ), + ), + ( + 2, + provider_collision_file_touch( + provider, + provider_session_id, + second_source_format, + second_source_path, + occurred_at, + ), + ), + ], + }; + + let summary = import_normalized_provider_captures( + &mut store, + normalization, + NormalizedProviderImportOptions::default(), + ) + .unwrap(); + assert_eq!(summary.failed, 0, "{:?}", summary.failures); + assert_eq!(summary.imported_events, 2); + assert_eq!(store.capture_source_count().unwrap(), 2); + + let first_source = store.get_capture_source(first_source_id).unwrap(); + let second_source = store.get_capture_source(second_source_id).unwrap(); + assert_eq!( + first_source.descriptor.raw_source_path.as_deref(), + Some(first_source_path) + ); + assert_eq!( + first_source.sync.metadata["source_format"].as_str(), + Some(first_source_format) + ); + assert_eq!( + second_source.descriptor.raw_source_path.as_deref(), + Some(second_source_path) + ); + assert_eq!( + second_source.sync.metadata["source_format"].as_str(), + Some(second_source_format) + ); + + let session_id = provider_session_uuid(provider, provider_session_id); + let event_source_ids = store + .events_for_session(session_id) + .unwrap() + .into_iter() + .map(|event| event.capture_source_id.unwrap()) + .collect::>(); + assert_eq!( + event_source_ids, + BTreeSet::from([first_source_id, second_source_id]) + ); + + let archive = store.export_archive().unwrap(); + assert_eq!(archive.files_touched.len(), 2); + let touched_source_ids = archive + .files_touched + .iter() + .map(|file| file.source_id.unwrap()) + .collect::>(); + assert_eq!( + touched_source_ids, + BTreeSet::from([first_source_id, second_source_id]) ); - assert!(source.sync.metadata["source_idempotency_key"] - .as_str() - .is_some()); + for file in archive.files_touched { + let source_id = file.source_id.unwrap(); + assert_eq!( + file.event_id, + Some(provider_source_event_uuid(source_id, 0)) + ); + } + } + + fn provider_collision_capture( + provider: CaptureProvider, + provider_session_id: &str, + source_format: &str, + raw_source_path: &str, + occurred_at: DateTime, + ) -> ProviderCaptureEnvelope { + ProviderCaptureEnvelope { + schema_version: PROVIDER_CAPTURE_ENVELOPE_SCHEMA_VERSION, + provider, + source: ProviderSourceEnvelope { + source_format: source_format.to_owned(), + machine_id: "test-machine".to_owned(), + observed_at: occurred_at, + raw_source_path: Some(raw_source_path.to_owned()), + raw_retention: ProviderRawRetention::PathReference, + redaction_boundary: ProviderRedactionBoundary::BeforeExport, + trust: ProviderSourceTrust::ProviderExport, + fidelity: Fidelity::Imported, + cursor: None, + idempotency_key: Some(format!( + "provider-source:{}:{}:{}", + provider.as_str(), + source_format, + provider_session_id + )), + metadata: json!({}), + }, + session: ProviderSessionEnvelope { + provider_session_id: provider_session_id.to_owned(), + parent_provider_session_id: None, + root_provider_session_id: None, + external_agent_id: None, + agent_type: AgentType::Primary, + role_hint: Some("primary".to_owned()), + is_primary: true, + status: SessionStatus::Imported, + started_at: occurred_at, + ended_at: None, + cwd: Some("/workspace/example".to_owned()), + fidelity: Fidelity::Imported, + idempotency_key: Some(format!( + "provider-session:{}:{}", + provider.as_str(), + provider_session_id + )), + artifacts: Vec::new(), + metadata: json!({}), + }, + event: Some(ProviderEventEnvelope { + provider_event_index: 0, + provider_event_hash: None, + cursor: None, + event_type: EventType::Message, + role: Some(EventRole::User), + occurred_at, + fidelity: Fidelity::Imported, + redaction_state: RedactionState::LocalPreview, + idempotency_key: Some(format!( + "provider-event:{}:{}:0", + provider.as_str(), + provider_session_id + )), + artifacts: Vec::new(), + payload: json!({"text": "same provider event payload"}), + metadata: json!({}), + }), + } + } + + fn provider_collision_file_touch( + provider: CaptureProvider, + provider_session_id: &str, + source_format: &str, + raw_source_path: &str, + occurred_at: DateTime, + ) -> ProviderFileTouchedEnvelope { + ProviderFileTouchedEnvelope { + provider, + provider_session_id: provider_session_id.to_owned(), + provider_touch_index: 0, + provider_event_index: Some(0), + raw_source_path: Some(raw_source_path.to_owned()), + path: "src/lib.rs".to_owned(), + change_kind: Some(FileChangeKind::Modified), + old_path: None, + line_count_delta: Some(1), + confidence: Confidence::Explicit, + occurred_at, + source_format: source_format.to_owned(), + metadata: json!({}), + } } #[test] @@ -10420,6 +16462,427 @@ mod tests { assert_eq!(cursor.cursor, "line:3"); } + #[test] + fn custom_history_jsonl_imports_full_shape_and_is_idempotent() { + let temp = tempdir(); + let fixture = custom_history_fixture("basic.jsonl"); + let mut store = Store::open(temp.path().join("work.sqlite")).unwrap(); + + let first = import_custom_history_jsonl_v1( + &fixture, + &mut store, + CustomHistoryJsonlV1ImportOptions { + source_path: Some(fixture.clone()), + imported_at: "2026-06-23T12:10:00Z".parse().unwrap(), + ..CustomHistoryJsonlV1ImportOptions::default() + }, + ) + .unwrap(); + assert_eq!(first.failed, 0, "{:?}", first.failures); + assert_eq!(first.imported_sessions, 2); + assert_eq!(first.imported_events, 2); + assert_eq!(first.imported_edges, 2); + + let root_provider_session_id = + custom_history_internal_session_id("demo-agent", "demo-source", "demo-session"); + let child_provider_session_id = + custom_history_internal_session_id("demo-agent", "demo-source", "demo-session-worker"); + let root_id = provider_session_uuid(CaptureProvider::Custom, &root_provider_session_id); + let child_id = provider_session_uuid(CaptureProvider::Custom, &child_provider_session_id); + let root = store.get_session(root_id).unwrap(); + let child = store.get_session(child_id).unwrap(); + assert_eq!(root.provider, CaptureProvider::Custom); + assert_eq!(child.parent_session_id, Some(root_id)); + assert!(root + .sync + .metadata + .to_string() + .contains("\"provider_key\":\"demo-agent\"")); + let events = store.events_for_session(root_id).unwrap(); + assert_eq!(events.len(), 2); + assert!(events[0].payload.to_string().contains("Add a parser test.")); + + let conn = rusqlite::Connection::open(temp.path().join("work.sqlite")).unwrap(); + let touched: i64 = conn + .query_row("SELECT COUNT(*) FROM files_touched", [], |row| row.get(0)) + .unwrap(); + assert_eq!(touched, 1); + let spawned_edges: i64 = conn + .query_row( + "SELECT COUNT(*) FROM session_edges WHERE edge_type = 'spawned'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(spawned_edges, 1); + let cursor_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM sync_cursors WHERE stream LIKE 'provider:custom:demo-agent:%'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(cursor_count, 1); + let cursor: String = conn + .query_row( + "SELECT cursor FROM sync_cursors WHERE stream LIKE 'provider:custom:demo-agent:%'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(cursor, "5"); + let raw_cursor_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM sync_cursors WHERE stream = 'demo-agent:demo-source'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(raw_cursor_count, 0); + drop(conn); + + let second = import_custom_history_jsonl_v1( + &fixture, + &mut store, + CustomHistoryJsonlV1ImportOptions { + source_path: Some(fixture.clone()), + imported_at: "2026-06-23T12:10:00Z".parse().unwrap(), + ..CustomHistoryJsonlV1ImportOptions::default() + }, + ) + .unwrap(); + assert_eq!(second.failed, 0); + assert_eq!(second.imported_sessions, 0); + assert_eq!(second.imported_events, 0); + assert_eq!(second.imported_edges, 0); + assert_eq!(second.skipped_events, 2); + assert_eq!(second.skipped_edges, 2); + } + + #[test] + fn custom_history_jsonl_reader_import_persists_normalized_cursor() { + let temp = tempdir(); + let mut store = Store::open(temp.path().join("work.sqlite")).unwrap(); + let input = [ + r#"{"record_type":"manifest","schema_version":"ctx-history-jsonl-v1"}"#, + r#"{"record_type":"source","source_id":"src","provider_key":"stream-agent","source_format":"stream-v1","cursor":{"after":{"stream":"native-stream","cursor":"{\"message_id\":7}","observed_at":"2026-07-01T12:00:00Z"}}}"#, + r#"{"record_type":"session","source_id":"src","session_id":"run","started_at":"2026-07-01T11:59:00Z"}"#, + r#"{"record_type":"event","source_id":"src","session_id":"run","event_index":0,"event_type":"message","role":"assistant","occurred_at":"2026-07-01T12:00:00Z","preview":"stream import marker"}"#, + ] + .join("\n"); + + let summary = import_custom_history_jsonl_v1_reader( + std::io::Cursor::new(input.into_bytes()), + &mut store, + CustomHistoryJsonlV1ImportOptions { + source_path: Some(PathBuf::from("plugin://stream-agent/default")), + imported_at: "2026-07-01T12:01:00Z".parse().unwrap(), + ..CustomHistoryJsonlV1ImportOptions::default() + }, + ) + .unwrap(); + + assert_eq!(summary.failed, 0, "{:?}", summary.failures); + assert_eq!(summary.imported_sessions, 1); + assert_eq!(summary.imported_events, 1); + let cursor = store + .get_sync_cursor( + None, + &CustomHistoryJsonlV1ImportOptions::default().machine_id, + &custom_history_jsonl_v1_cursor_stream("stream-agent", "src", "stream-v1"), + ) + .unwrap() + .unwrap(); + assert_eq!(cursor.cursor, r#"{"message_id":7}"#); + } + + #[test] + fn custom_history_jsonl_reader_persists_source_only_cursor() { + let temp = tempdir(); + let mut store = Store::open(temp.path().join("work.sqlite")).unwrap(); + let input = [ + r#"{"record_type":"manifest","schema_version":"ctx-history-jsonl-v1"}"#, + r#"{"record_type":"source","source_id":"src","provider_key":"stream-agent","source_format":"stream-v1","cursor":{"after":{"stream":"native-stream","cursor":"{\"message_id\":9}","observed_at":"2026-07-01T12:02:00Z"}}}"#, + ] + .join("\n"); + + let summary = import_custom_history_jsonl_v1_reader( + std::io::Cursor::new(input.into_bytes()), + &mut store, + CustomHistoryJsonlV1ImportOptions { + imported_at: "2026-07-01T12:03:00Z".parse().unwrap(), + ..CustomHistoryJsonlV1ImportOptions::default() + }, + ) + .unwrap(); + + assert_eq!(summary.failed, 0, "{:?}", summary.failures); + assert_eq!(summary.imported_sessions, 0); + assert_eq!(summary.imported_events, 0); + let cursor = store + .get_sync_cursor( + None, + &CustomHistoryJsonlV1ImportOptions::default().machine_id, + &custom_history_jsonl_v1_cursor_stream("stream-agent", "src", "stream-v1"), + ) + .unwrap() + .unwrap(); + assert_eq!(cursor.cursor, r#"{"message_id":9}"#); + } + + #[test] + fn custom_history_jsonl_malformed_import_is_atomic_by_default() { + let temp = tempdir(); + let fixture = custom_history_fixture("malformed-partial.jsonl"); + let mut store = Store::open(temp.path().join("work.sqlite")).unwrap(); + + let summary = import_custom_history_jsonl_v1( + &fixture, + &mut store, + CustomHistoryJsonlV1ImportOptions { + source_path: Some(fixture.clone()), + imported_at: "2026-06-23T13:10:00Z".parse().unwrap(), + ..CustomHistoryJsonlV1ImportOptions::default() + }, + ) + .unwrap(); + + assert_eq!(summary.imported_sessions, 0); + assert_eq!(summary.imported_events, 0); + assert_eq!(summary.failed, 1); + assert_eq!(store.capture_source_count().unwrap(), 0); + let conn = rusqlite::Connection::open(temp.path().join("work.sqlite")).unwrap(); + let sessions: i64 = conn + .query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get(0)) + .unwrap(); + let events: i64 = conn + .query_row("SELECT COUNT(*) FROM events", [], |row| row.get(0)) + .unwrap(); + assert_eq!(sessions, 0); + assert_eq!(events, 0); + } + + #[test] + fn custom_history_jsonl_rejects_oversized_line() { + let temp = tempdir(); + let path = temp.path().join("oversized-custom.jsonl"); + write_oversized_jsonl_line(&path); + let mut store = Store::open(temp.path().join("work.sqlite")).unwrap(); + + let err = import_custom_history_jsonl_v1( + &path, + &mut store, + CustomHistoryJsonlV1ImportOptions::default(), + ) + .unwrap_err(); + + assert!(err.to_string().contains("provider JSONL line exceeds")); + assert_eq!(store.capture_source_count().unwrap(), 0); + } + + #[test] + fn custom_history_jsonl_preview_overrides_raw_payload_for_searchable_event_payload() { + let temp = tempdir(); + let fixture = temp.path().join("preview-overrides-payload.jsonl"); + fs::write( + &fixture, + [ + r#"{"record_type":"manifest","schema_version":"ctx-history-jsonl-v1"}"#, + r#"{"record_type":"source","source_id":"src","provider_key":"preview-agent","source_format":"demo"}"#, + r#"{"record_type":"session","source_id":"src","session_id":"run","started_at":"2026-06-23T14:00:00Z"}"#, + r#"{"record_type":"event","source_id":"src","session_id":"run","event_index":0,"event_type":"message","role":"assistant","occurred_at":"2026-06-23T14:00:01Z","payload":{"raw":"unindexed-raw-payload-token"},"preview":"bounded searchable preview text"}"#, + ] + .join("\n"), + ) + .unwrap(); + let mut store = Store::open(temp.path().join("work.sqlite")).unwrap(); + + let summary = import_custom_history_jsonl_v1( + &fixture, + &mut store, + CustomHistoryJsonlV1ImportOptions { + source_path: Some(fixture.clone()), + imported_at: "2026-06-23T14:10:00Z".parse().unwrap(), + ..CustomHistoryJsonlV1ImportOptions::default() + }, + ) + .unwrap(); + + assert_eq!(summary.failed, 0, "{:?}", summary.failures); + let session_id = provider_session_uuid( + CaptureProvider::Custom, + &custom_history_internal_session_id("preview-agent", "src", "run"), + ); + let events = store.events_for_session(session_id).unwrap(); + assert_eq!(events.len(), 1); + assert_eq!( + events[0].payload["body"], + json!({ "text": "bounded searchable preview text" }) + ); + assert!(!events[0] + .payload + .to_string() + .contains("unindexed-raw-payload-token")); + assert_eq!( + events[0].sync.metadata["metadata"]["ctx_history_jsonl_v1"]["raw_payload"]["raw"] + .as_str(), + Some("unindexed-raw-payload-token") + ); + } + + #[test] + fn custom_history_jsonl_namespaces_provider_keys_to_avoid_collisions() { + let temp = tempdir(); + let fixture = temp.path().join("same-native-ids.jsonl"); + fs::write( + &fixture, + [ + r#"{"record_type":"manifest","schema_version":"ctx-history-jsonl-v1"}"#, + r#"{"record_type":"source","source_id":"src","provider_key":"alpha","source_format":"demo"}"#, + r#"{"record_type":"session","source_id":"src","session_id":"same","started_at":"2026-06-23T14:00:00Z"}"#, + r#"{"record_type":"event","source_id":"src","session_id":"same","event_index":0,"event_type":"message","role":"user","occurred_at":"2026-06-23T14:00:01Z","payload":{"text":"alpha text"}}"#, + r#"{"record_type":"source","source_id":"src-2","provider_key":"beta","source_format":"demo"}"#, + r#"{"record_type":"session","source_id":"src-2","session_id":"same","started_at":"2026-06-23T14:01:00Z"}"#, + r#"{"record_type":"event","source_id":"src-2","session_id":"same","event_index":0,"event_type":"message","role":"user","occurred_at":"2026-06-23T14:01:01Z","payload":{"text":"beta text"}}"#, + ] + .join("\n"), + ) + .unwrap(); + let mut store = Store::open(temp.path().join("work.sqlite")).unwrap(); + + let summary = import_custom_history_jsonl_v1( + &fixture, + &mut store, + CustomHistoryJsonlV1ImportOptions { + source_path: Some(fixture.clone()), + imported_at: "2026-06-23T14:10:00Z".parse().unwrap(), + ..CustomHistoryJsonlV1ImportOptions::default() + }, + ) + .unwrap(); + + assert_eq!(summary.failed, 0, "{:?}", summary.failures); + assert_eq!(summary.imported_sessions, 2); + assert_eq!(summary.imported_events, 2); + let alpha_session = provider_session_uuid( + CaptureProvider::Custom, + &custom_history_internal_session_id("alpha", "src", "same"), + ); + let beta_session = provider_session_uuid( + CaptureProvider::Custom, + &custom_history_internal_session_id("beta", "src-2", "same"), + ); + assert_ne!(alpha_session, beta_session); + assert!(store + .events_for_session(alpha_session) + .unwrap() + .iter() + .any(|event| event.payload.to_string().contains("alpha text"))); + assert!(store + .events_for_session(beta_session) + .unwrap() + .iter() + .any(|event| event.payload.to_string().contains("beta text"))); + } + + #[test] + fn custom_history_jsonl_hashes_delimited_identifiers_without_collisions() { + let temp = tempdir(); + let fixture = temp.path().join("delimited-identifiers.jsonl"); + fs::write( + &fixture, + [ + r#"{"record_type":"manifest","schema_version":"ctx-history-jsonl-v1"}"#, + r#"{"record_type":"source","source_id":"a:b","provider_key":"delim-agent","source_format":"demo"}"#, + r#"{"record_type":"session","source_id":"a:b","session_id":"c","started_at":"2026-06-23T14:00:00Z"}"#, + r#"{"record_type":"event","source_id":"a:b","session_id":"c","event_index":0,"event_type":"message","role":"user","occurred_at":"2026-06-23T14:00:01Z","payload":{"text":"left text"}}"#, + r#"{"record_type":"source","source_id":"a","provider_key":"delim-agent","source_format":"demo"}"#, + r#"{"record_type":"session","source_id":"a","session_id":"b:c","started_at":"2026-06-23T14:01:00Z"}"#, + r#"{"record_type":"event","source_id":"a","session_id":"b:c","event_index":0,"event_type":"message","role":"user","occurred_at":"2026-06-23T14:01:01Z","payload":{"text":"right text"}}"#, + ] + .join("\n"), + ) + .unwrap(); + let mut store = Store::open(temp.path().join("work.sqlite")).unwrap(); + + let summary = import_custom_history_jsonl_v1( + &fixture, + &mut store, + CustomHistoryJsonlV1ImportOptions { + source_path: Some(fixture.clone()), + imported_at: "2026-06-23T14:10:00Z".parse().unwrap(), + ..CustomHistoryJsonlV1ImportOptions::default() + }, + ) + .unwrap(); + + assert_eq!(summary.failed, 0, "{:?}", summary.failures); + assert_eq!(summary.imported_sessions, 2); + assert_eq!(summary.imported_events, 2); + let left_session = provider_session_uuid( + CaptureProvider::Custom, + &custom_history_internal_session_id("delim-agent", "a:b", "c"), + ); + let right_session = provider_session_uuid( + CaptureProvider::Custom, + &custom_history_internal_session_id("delim-agent", "a", "b:c"), + ); + assert_ne!(left_session, right_session); + assert!(store + .events_for_session(left_session) + .unwrap() + .iter() + .any(|event| event.payload.to_string().contains("left text"))); + assert!(store + .events_for_session(right_session) + .unwrap() + .iter() + .any(|event| event.payload.to_string().contains("right text"))); + } + + #[test] + fn custom_history_jsonl_dedupes_explicit_parent_child_edge_from_session_parent() { + let temp = tempdir(); + let fixture = temp.path().join("duplicate-parent-child.jsonl"); + fs::write( + &fixture, + [ + r#"{"record_type":"manifest","schema_version":"ctx-history-jsonl-v1"}"#, + r#"{"record_type":"source","source_id":"src","provider_key":"edge-agent","source_format":"demo"}"#, + r#"{"record_type":"session","source_id":"src","session_id":"root","started_at":"2026-06-23T15:00:00Z"}"#, + r#"{"record_type":"session","source_id":"src","session_id":"child","parent_session_id":"root","started_at":"2026-06-23T15:00:01Z"}"#, + r#"{"record_type":"edge","source_id":"src","from_session_id":"root","to_session_id":"child","edge_type":"parent_child","edge_id":"explicit-parent","occurred_at":"2026-06-23T15:00:02Z"}"#, + ] + .join("\n"), + ) + .unwrap(); + let mut store = Store::open(temp.path().join("work.sqlite")).unwrap(); + + let summary = import_custom_history_jsonl_v1( + &fixture, + &mut store, + CustomHistoryJsonlV1ImportOptions { + source_path: Some(fixture.clone()), + imported_at: "2026-06-23T15:10:00Z".parse().unwrap(), + ..CustomHistoryJsonlV1ImportOptions::default() + }, + ) + .unwrap(); + + assert_eq!(summary.failed, 0, "{:?}", summary.failures); + assert_eq!(summary.imported_edges, 1); + assert_eq!(summary.skipped_edges, 1); + let conn = rusqlite::Connection::open(temp.path().join("work.sqlite")).unwrap(); + let parent_child_edges: i64 = conn + .query_row( + "SELECT COUNT(*) FROM session_edges WHERE edge_type = 'parent_child'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(parent_child_edges, 1); + } + #[test] fn provider_fixture_replay_rejects_malformed_lines_without_partial_import_by_default() { let temp = tempdir(); diff --git a/crates/ctx-history-capture/src/provider_sources.rs b/crates/ctx-history-capture/src/provider_sources.rs index de5e75df9..27da1b859 100644 --- a/crates/ctx-history-capture/src/provider_sources.rs +++ b/crates/ctx-history-capture/src/provider_sources.rs @@ -1,4 +1,9 @@ -use std::path::{Path, PathBuf}; +use std::{ + collections::HashSet, + env, + io::ErrorKind, + path::{Path, PathBuf}, +}; use ctx_history_core::{CaptureProvider, ProviderRawRetention, ProviderRedactionBoundary}; @@ -11,9 +16,20 @@ pub enum ProviderSourceKind { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ProviderImportSupport { Native, + Preview, Unsupported, } +impl ProviderImportSupport { + pub fn is_importable(self) -> bool { + matches!(self, Self::Native | Self::Preview) + } + + pub fn is_auto_importable(self) -> bool { + matches!(self, Self::Native) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ProviderCatalogSupport { Native, @@ -89,7 +105,7 @@ const CODEX_DEFAULTS: &[ProviderDefaultLocation] = &[ ]; const PI_DEFAULTS: &[ProviderDefaultLocation] = &[ProviderDefaultLocation { - path_components: &[".pi", "sessions.jsonl"], + path_components: &[".pi", "agent", "sessions"], source_format: "pi_session_jsonl", source_kind: ProviderSourceKind::NativeHistory, }]; @@ -136,6 +152,44 @@ const FACTORY_DROID_DEFAULTS: &[ProviderDefaultLocation] = &[ProviderDefaultLoca source_kind: ProviderSourceKind::NativeHistory, }]; +const OPENCLAW_DEFAULTS: &[ProviderDefaultLocation] = &[ + ProviderDefaultLocation { + path_components: &[".openclaw"], + source_format: "openclaw_session_jsonl_tree", + source_kind: ProviderSourceKind::NativeHistory, + }, + ProviderDefaultLocation { + path_components: &[".clawdbot"], + source_format: "openclaw_session_jsonl_tree", + source_kind: ProviderSourceKind::NativeHistory, + }, + ProviderDefaultLocation { + path_components: &[".moltbot"], + source_format: "openclaw_session_jsonl_tree", + source_kind: ProviderSourceKind::NativeHistory, + }, +]; + +const HERMES_DEFAULTS: &[ProviderDefaultLocation] = &[ProviderDefaultLocation { + path_components: &[".hermes", "state.db"], + source_format: "hermes_state_sqlite", + source_kind: ProviderSourceKind::NativeHistory, +}]; + +const NANOCLAW_DEFAULTS: &[ProviderDefaultLocation] = &[]; + +const ASTRBOT_DEFAULTS: &[ProviderDefaultLocation] = &[ProviderDefaultLocation { + path_components: &[".astrbot", "data", "data_v4.db"], + source_format: "astrbot_data_v4_sqlite", + source_kind: ProviderSourceKind::NativeHistory, +}]; + +const SHELLEY_DEFAULTS: &[ProviderDefaultLocation] = &[ProviderDefaultLocation { + path_components: &[".config", "shelley", "shelley.db"], + source_format: "shelley_sqlite", + source_kind: ProviderSourceKind::NativeHistory, +}]; + const PROVIDER_SPECS: &[ProviderSourceSpec] = &[ ProviderSourceSpec { provider: CaptureProvider::Codex, @@ -227,6 +281,56 @@ const PROVIDER_SPECS: &[ProviderSourceSpec] = &[ redaction_boundary: ProviderRedactionBoundary::BeforeExport, unsupported_reason: None, }, + ProviderSourceSpec { + provider: CaptureProvider::OpenClaw, + display_name: "OpenClaw", + default_locations: OPENCLAW_DEFAULTS, + import_support: ProviderImportSupport::Native, + catalog_support: ProviderCatalogSupport::None, + raw_retention: ProviderRawRetention::PathReference, + redaction_boundary: ProviderRedactionBoundary::BeforeExport, + unsupported_reason: None, + }, + ProviderSourceSpec { + provider: CaptureProvider::Hermes, + display_name: "Hermes Agent", + default_locations: HERMES_DEFAULTS, + import_support: ProviderImportSupport::Native, + catalog_support: ProviderCatalogSupport::None, + raw_retention: ProviderRawRetention::PathReference, + redaction_boundary: ProviderRedactionBoundary::BeforeExport, + unsupported_reason: None, + }, + ProviderSourceSpec { + provider: CaptureProvider::NanoClaw, + display_name: "NanoClaw", + default_locations: NANOCLAW_DEFAULTS, + import_support: ProviderImportSupport::Preview, + catalog_support: ProviderCatalogSupport::None, + raw_retention: ProviderRawRetention::PathReference, + redaction_boundary: ProviderRedactionBoundary::BeforeExport, + unsupported_reason: None, + }, + ProviderSourceSpec { + provider: CaptureProvider::AstrBot, + display_name: "AstrBot", + default_locations: ASTRBOT_DEFAULTS, + import_support: ProviderImportSupport::Preview, + catalog_support: ProviderCatalogSupport::None, + raw_retention: ProviderRawRetention::PathReference, + redaction_boundary: ProviderRedactionBoundary::BeforeExport, + unsupported_reason: None, + }, + ProviderSourceSpec { + provider: CaptureProvider::Shelley, + display_name: "Shelley", + default_locations: SHELLEY_DEFAULTS, + import_support: ProviderImportSupport::Native, + catalog_support: ProviderCatalogSupport::None, + raw_retention: ProviderRawRetention::PathReference, + redaction_boundary: ProviderRedactionBoundary::BeforeExport, + unsupported_reason: None, + }, ]; pub fn provider_source_specs() -> &'static [ProviderSourceSpec] { @@ -238,39 +342,152 @@ pub fn provider_source_spec(provider: CaptureProvider) -> Option<&'static Provid } pub fn discover_provider_sources(home: &Path) -> Vec { - PROVIDER_SPECS - .iter() - .flat_map(|spec| { - spec.default_locations.iter().map(|location| { - let path = location - .path_components - .iter() - .fold(home.to_path_buf(), |path, component| path.join(component)); - provider_source_from_location(spec, location, path) - }) - }) - .collect() + dedupe_sources( + PROVIDER_SPECS + .iter() + .flat_map(|spec| discover_provider_sources_for_spec(home, spec)) + .collect(), + ) } pub fn discover_provider_sources_for_provider( home: &Path, provider: CaptureProvider, ) -> Vec { - PROVIDER_SPECS + dedupe_sources( + PROVIDER_SPECS + .iter() + .filter(|spec| spec.provider == provider) + .flat_map(|spec| discover_provider_sources_for_spec(home, spec)) + .collect(), + ) +} + +fn discover_provider_sources_for_spec( + home: &Path, + spec: &ProviderSourceSpec, +) -> Vec { + let mut sources = spec + .default_locations .iter() - .filter(|spec| spec.provider == provider) - .flat_map(|spec| { - spec.default_locations.iter().map(|location| { - let path = location - .path_components - .iter() - .fold(home.to_path_buf(), |path, component| path.join(component)); - provider_source_from_location(spec, location, path) - }) + .map(|location| { + let path = location + .path_components + .iter() + .fold(home.to_path_buf(), |path, component| path.join(component)); + provider_source_from_location(spec, location, path) }) + .collect::>(); + + match spec.provider { + CaptureProvider::OpenClaw => { + if let Some(path) = env_path("OPENCLAW_STATE_DIR") { + sources.push(provider_source_from_parts( + spec, + path, + "openclaw_session_jsonl_tree", + ProviderSourceKind::NativeHistory, + )); + } + } + CaptureProvider::Hermes => { + if let Some(path) = env_path("HERMES_HOME") { + sources.push(provider_source_from_parts( + spec, + path.join("state.db"), + "hermes_state_sqlite", + ProviderSourceKind::NativeHistory, + )); + } + } + CaptureProvider::NanoClaw => { + for root in current_dir_ancestors_with(|candidate| { + candidate.join("data").join("v2.db").is_file() + && candidate.join("data").join("v2-sessions").is_dir() + }) { + sources.push(provider_source_from_parts( + spec, + root, + "nanoclaw_project", + ProviderSourceKind::NativeHistory, + )); + } + } + CaptureProvider::AstrBot => { + if let Some(path) = env_path("ASTRBOT_ROOT") { + sources.push(provider_source_from_parts( + spec, + path.join("data").join("data_v4.db"), + "astrbot_data_v4_sqlite", + ProviderSourceKind::NativeHistory, + )); + } + for root in current_dir_ancestors_with(|candidate| { + candidate.join("data").join("data_v4.db").is_file() + }) { + sources.push(provider_source_from_parts( + spec, + root.join("data").join("data_v4.db"), + "astrbot_data_v4_sqlite", + ProviderSourceKind::NativeHistory, + )); + } + } + CaptureProvider::Shelley => { + if let Some(path) = env_path("SHELLEY_DB") { + sources.push(provider_source_from_parts( + spec, + path, + "shelley_sqlite", + ProviderSourceKind::NativeHistory, + )); + } + } + _ => {} + } + + sources +} + +fn env_path(name: &str) -> Option { + env::var_os(name) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) +} + +fn current_dir_ancestors_with(matches: impl Fn(&Path) -> bool) -> Vec { + let Ok(current_dir) = env::current_dir() else { + return Vec::new(); + }; + current_dir + .ancestors() + .filter(|candidate| matches(candidate)) + .map(Path::to_path_buf) + .collect() +} + +fn dedupe_sources(sources: Vec) -> Vec { + let mut seen = HashSet::new(); + sources + .into_iter() + .filter(|source| seen.insert((source.provider, source.path.clone(), source.source_format))) .collect() } +fn provider_source_from_parts( + spec: &ProviderSourceSpec, + path: PathBuf, + source_format: &'static str, + source_kind: ProviderSourceKind, +) -> ProviderSource { + let location = ProviderDefaultLocation { + path_components: &[], + source_format, + source_kind, + }; + provider_source_from_location(spec, &location, path) +} + pub fn provider_source_for_path(provider: CaptureProvider, path: PathBuf) -> ProviderSource { let unknown_spec = ProviderSourceSpec { provider, @@ -307,10 +524,15 @@ pub fn provider_source_for_path(provider: CaptureProvider, path: PathBuf) -> Pro CaptureProvider::Cursor => "cursor_agent_transcript_jsonl_tree", CaptureProvider::CopilotCli => "copilot_cli_session_events_jsonl", CaptureProvider::FactoryAiDroid => "factory_ai_droid_sessions_jsonl", + CaptureProvider::OpenClaw => "openclaw_session_jsonl_tree", + CaptureProvider::Hermes => "hermes_state_sqlite", + CaptureProvider::NanoClaw => "nanoclaw_project", + CaptureProvider::AstrBot => "astrbot_data_v4_sqlite", + CaptureProvider::Shelley => "shelley_sqlite", _ => "unsupported", }; let explicit_import_support = spec.import_support; - let source_kind = if matches!(explicit_import_support, ProviderImportSupport::Native) { + let source_kind = if explicit_import_support.is_importable() { ProviderSourceKind::NativeHistory } else { ProviderSourceKind::DetectionOnly @@ -342,23 +564,37 @@ fn provider_source_from_location( location: &ProviderDefaultLocation, path: PathBuf, ) -> ProviderSource { - let exists = path.exists(); - let status = if matches!(spec.import_support, ProviderImportSupport::Unsupported) { - ProviderSourceStatus::Unsupported - } else if !exists { - ProviderSourceStatus::Missing - } else { - match default_location_import_probe(spec.provider, location, &path) { - BoundedProbe::Found => ProviderSourceStatus::Available, - BoundedProbe::NotFound => ProviderSourceStatus::Empty, - BoundedProbe::BudgetExhausted => ProviderSourceStatus::Unknown, - } - }; - let unsupported_reason = match status { - ProviderSourceStatus::Empty => empty_source_reason(spec.provider), - ProviderSourceStatus::Unknown => unknown_source_reason(spec.provider), - _ => spec.unsupported_reason, - }; + let path_exists = path.try_exists(); + let exists = path_exists.as_ref().copied().unwrap_or(true); + let (status, unsupported_reason) = + if matches!(spec.import_support, ProviderImportSupport::Unsupported) { + (ProviderSourceStatus::Unsupported, spec.unsupported_reason) + } else { + match path_exists { + Ok(false) => (ProviderSourceStatus::Missing, spec.unsupported_reason), + Err(_) => ( + ProviderSourceStatus::Unknown, + probe_io_error_reason(spec.provider), + ), + Ok(true) => match default_location_import_probe(spec.provider, location, &path) { + BoundedProbe::Found => { + (ProviderSourceStatus::Available, spec.unsupported_reason) + } + BoundedProbe::NotFound => ( + ProviderSourceStatus::Empty, + empty_source_reason(spec.provider), + ), + BoundedProbe::BudgetExhausted => ( + ProviderSourceStatus::Unknown, + unknown_source_reason(spec.provider), + ), + BoundedProbe::IoError => ( + ProviderSourceStatus::Unknown, + probe_io_error_reason(spec.provider), + ), + }, + } + }; ProviderSource { provider: spec.provider, path, @@ -377,7 +613,7 @@ fn provider_source_from_location( fn empty_source_reason(provider: CaptureProvider) -> Option<&'static str> { match provider { CaptureProvider::Codex => Some("path exists but no Codex JSONL sessions were found"), - CaptureProvider::Pi => Some("path exists but no Pi session JSONL file was found"), + CaptureProvider::Pi => Some("path exists but no Pi session JSONL files were found"), CaptureProvider::Claude => { Some("path exists but no Claude project JSONL transcripts were found") } @@ -397,6 +633,15 @@ fn empty_source_reason(provider: CaptureProvider) -> Option<&'static str> { CaptureProvider::FactoryAiDroid => { Some("path exists but no Factory AI Droid session JSONL files were found") } + CaptureProvider::OpenClaw => { + Some("path exists but no OpenClaw agent session JSONL files were found") + } + CaptureProvider::Hermes => Some("path exists but no Hermes state.db file was found"), + CaptureProvider::NanoClaw => { + Some("path exists but no NanoClaw data/v2.db and data/v2-sessions store was found") + } + CaptureProvider::AstrBot => Some("path exists but no AstrBot data/data_v4.db was found"), + CaptureProvider::Shelley => Some("path exists but no Shelley SQLite database was found"), _ => None, } } @@ -406,6 +651,9 @@ fn unknown_source_reason(provider: CaptureProvider) -> Option<&'static str> { CaptureProvider::Codex => { Some("path exists but the Codex session transcript probe hit its scan budget") } + CaptureProvider::Pi => { + Some("path exists but the Pi session transcript probe hit its scan budget") + } CaptureProvider::Claude => { Some("path exists but the Claude transcript probe hit its scan budget") } @@ -424,6 +672,57 @@ fn unknown_source_reason(provider: CaptureProvider) -> Option<&'static str> { CaptureProvider::FactoryAiDroid => { Some("path exists but the Factory AI Droid transcript probe hit its scan budget") } + CaptureProvider::OpenClaw => { + Some("path exists but the OpenClaw transcript probe hit its scan budget") + } + _ => None, + } +} + +fn probe_io_error_reason(provider: CaptureProvider) -> Option<&'static str> { + match provider { + CaptureProvider::Codex => { + Some("path exists but Codex session transcripts could not be read; check permissions") + } + CaptureProvider::Pi => { + Some("path exists but Pi session transcripts could not be read; check permissions") + } + CaptureProvider::Claude => { + Some("path exists but Claude project transcripts could not be read; check permissions") + } + CaptureProvider::OpenCode => { + Some("path exists but the OpenCode database could not be read; check permissions") + } + CaptureProvider::Antigravity => { + Some("path exists but Antigravity transcripts could not be read; check permissions") + } + CaptureProvider::Gemini => { + Some("path exists but Gemini CLI chat transcripts could not be read; check permissions") + } + CaptureProvider::Cursor => { + Some("path exists but Cursor agent transcripts could not be read; check permissions") + } + CaptureProvider::CopilotCli => { + Some("path exists but Copilot CLI session events could not be read; check permissions") + } + CaptureProvider::FactoryAiDroid => { + Some("path exists but Factory AI Droid sessions could not be read; check permissions") + } + CaptureProvider::OpenClaw => Some( + "path exists but OpenClaw session transcripts could not be read; check permissions", + ), + CaptureProvider::Hermes => { + Some("path exists but the Hermes state database could not be read; check permissions") + } + CaptureProvider::NanoClaw => { + Some("path exists but the NanoClaw project store could not be read; check permissions") + } + CaptureProvider::AstrBot => { + Some("path exists but the AstrBot data database could not be read; check permissions") + } + CaptureProvider::Shelley => { + Some("path exists but the Shelley database could not be read; check permissions") + } _ => None, } } @@ -435,12 +734,17 @@ fn default_location_import_probe( ) -> BoundedProbe { match provider { CaptureProvider::Codex if location.source_format == "codex_history_jsonl" => { - BoundedProbe::from_bool(path.is_file()) + path_is_file_probe(path) } CaptureProvider::Codex => has_jsonl_file_under_matching(path, 10_000, |_| true), - CaptureProvider::Pi => BoundedProbe::from_bool(path.is_file()), - CaptureProvider::OpenCode => BoundedProbe::from_bool(path.is_file()), + CaptureProvider::Pi => has_jsonl_file_under_matching(path, 10_000, |_| true), + CaptureProvider::OpenCode => path_is_file_probe(path), CaptureProvider::Claude => has_jsonl_file_under_matching(path, 10_000, |_| true), + CaptureProvider::OpenClaw => has_openclaw_session_jsonl(path, 10_000), + CaptureProvider::Hermes => path_is_file_probe(path), + CaptureProvider::NanoClaw => has_nanoclaw_project(path), + CaptureProvider::AstrBot => path_is_file_probe(path), + CaptureProvider::Shelley => path_is_file_probe(path), CaptureProvider::Antigravity => has_jsonl_file_under_matching(path, 10_000, |candidate| { matches!( candidate.file_name().and_then(|name| name.to_str()), @@ -455,23 +759,68 @@ fn default_location_import_probe( candidate.file_name().and_then(|name| name.to_str()) == Some("events.jsonl") }), CaptureProvider::FactoryAiDroid => has_jsonl_file_under_matching(path, 10_000, |_| true), - _ => BoundedProbe::from_bool(path.exists()), + CaptureProvider::Shell + | CaptureProvider::Git + | CaptureProvider::Jj + | CaptureProvider::Gh + | CaptureProvider::Custom + | CaptureProvider::Unknown => BoundedProbe::NotFound, } } fn has_gemini_chat_jsonl(root: &Path, max_entries: usize) -> BoundedProbe { let tmp = root.join("tmp"); - if !tmp.is_dir() { - return BoundedProbe::NotFound; + match path_is_dir_probe(&tmp) { + BoundedProbe::Found => {} + BoundedProbe::IoError => return BoundedProbe::IoError, + _ => return BoundedProbe::NotFound, } has_jsonl_file_under_matching(&tmp, max_entries, |path| path_has_component(path, "chats")) } +fn has_openclaw_session_jsonl(root: &Path, max_entries: usize) -> BoundedProbe { + match path_metadata_probe(root) { + PathProbe::File => { + return BoundedProbe::from_bool( + root.extension().and_then(|ext| ext.to_str()) == Some("jsonl"), + ); + } + PathProbe::Dir => {} + PathProbe::Missing | PathProbe::Other => return BoundedProbe::NotFound, + PathProbe::IoError => return BoundedProbe::IoError, + } + let agents = root.join("agents"); + match path_is_dir_probe(&agents) { + BoundedProbe::Found => { + return has_jsonl_file_under_matching(&agents, max_entries, |path| { + path_has_component(path, "sessions") + }); + } + BoundedProbe::IoError => return BoundedProbe::IoError, + _ => {} + } + has_jsonl_file_under_matching(root, max_entries, |path| { + path_has_component(path, "sessions") + }) +} + +fn has_nanoclaw_project(root: &Path) -> BoundedProbe { + match ( + path_is_file_probe(&root.join("data").join("v2.db")), + path_is_dir_probe(&root.join("data").join("v2-sessions")), + ) { + (BoundedProbe::Found, BoundedProbe::Found) => BoundedProbe::Found, + (BoundedProbe::IoError, _) | (_, BoundedProbe::IoError) => BoundedProbe::IoError, + _ => BoundedProbe::NotFound, + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum BoundedProbe { Found, NotFound, BudgetExhausted, + IoError, } impl BoundedProbe { @@ -484,41 +833,85 @@ impl BoundedProbe { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PathProbe { + File, + Dir, + Other, + Missing, + IoError, +} + +fn path_metadata_probe(path: &Path) -> PathProbe { + match path.metadata() { + Ok(metadata) if metadata.is_file() => PathProbe::File, + Ok(metadata) if metadata.is_dir() => PathProbe::Dir, + Ok(_) => PathProbe::Other, + Err(err) if err.kind() == ErrorKind::NotFound => PathProbe::Missing, + Err(_) => PathProbe::IoError, + } +} + +fn path_is_file_probe(path: &Path) -> BoundedProbe { + match path_metadata_probe(path) { + PathProbe::File => BoundedProbe::Found, + PathProbe::IoError => BoundedProbe::IoError, + _ => BoundedProbe::NotFound, + } +} + +fn path_is_dir_probe(path: &Path) -> BoundedProbe { + match path_metadata_probe(path) { + PathProbe::Dir => BoundedProbe::Found, + PathProbe::IoError => BoundedProbe::IoError, + _ => BoundedProbe::NotFound, + } +} + fn has_jsonl_file_under_matching( root: &Path, max_entries: usize, matches_path: impl Fn(&Path) -> bool, ) -> BoundedProbe { - if root.is_file() { - return if root.extension().and_then(|ext| ext.to_str()) == Some("jsonl") - && matches_path(root) - { - BoundedProbe::Found - } else { - BoundedProbe::NotFound - }; - } - if !root.is_dir() { - return BoundedProbe::NotFound; + match path_metadata_probe(root) { + PathProbe::File => { + return if root.extension().and_then(|ext| ext.to_str()) == Some("jsonl") + && matches_path(root) + { + BoundedProbe::Found + } else { + BoundedProbe::NotFound + }; + } + PathProbe::Dir => {} + PathProbe::Missing | PathProbe::Other => return BoundedProbe::NotFound, + PathProbe::IoError => return BoundedProbe::IoError, } let mut visited = 0usize; - let mut stack = vec![root.to_path_buf()]; - while let Some(dir) = stack.pop() { - let Ok(entries) = std::fs::read_dir(&dir) else { - continue; + let mut stack = vec![(root.to_path_buf(), true)]; + while let Some((dir, is_root)) = stack.pop() { + let entries = match std::fs::read_dir(&dir) { + Ok(entries) => entries, + Err(_) if is_root => return BoundedProbe::IoError, + Err(_) => continue, }; - for entry in entries.flatten() { + for entry in entries { + let entry = match entry { + Ok(entry) => entry, + Err(_) => continue, + }; visited = visited.saturating_add(1); if visited > max_entries { return BoundedProbe::BudgetExhausted; } let path = entry.path(); - let Ok(file_type) = entry.file_type() else { - continue; + let file_type = match entry.file_type() { + Ok(file_type) => file_type, + Err(_) => continue, }; if file_type.is_dir() { - stack.push(path); + stack.push((path, false)); } else if file_type.is_file() && path.extension().and_then(|ext| ext.to_str()) == Some("jsonl") && matches_path(&path) @@ -599,6 +992,21 @@ mod tests { fn native_provider_default_discovery_uses_importer_specific_file_predicates() { let temp = tempfile::tempdir().unwrap(); + let pi = temp.path().join(".pi/agent/sessions"); + std::fs::create_dir_all(pi.join("--workspace--")).unwrap(); + assert_source_status( + temp.path(), + CaptureProvider::Pi, + ProviderSourceStatus::Empty, + ); + std::fs::write(pi.join("--workspace--/session.jsonl"), "{}\n").unwrap(); + let pi_source = discover_provider_sources(temp.path()) + .into_iter() + .find(|source| source.provider == CaptureProvider::Pi) + .unwrap(); + assert_eq!(pi_source.status, ProviderSourceStatus::Available); + assert_eq!(pi_source.path, temp.path().join(".pi/agent/sessions")); + let antigravity = temp.path().join(".gemini/antigravity-cli/brain"); std::fs::create_dir_all(antigravity.join("session/.system_generated/logs")).unwrap(); std::fs::write( @@ -656,6 +1064,56 @@ mod tests { CaptureProvider::CopilotCli, ProviderSourceStatus::Available, ); + + let openclaw = temp.path().join(".openclaw/agents/personal/sessions"); + std::fs::create_dir_all(&openclaw).unwrap(); + assert_source_status( + temp.path(), + CaptureProvider::OpenClaw, + ProviderSourceStatus::Empty, + ); + std::fs::write(openclaw.join("session.jsonl"), "{}\n").unwrap(); + assert_source_status( + temp.path(), + CaptureProvider::OpenClaw, + ProviderSourceStatus::Available, + ); + + let hermes = temp.path().join(".hermes"); + std::fs::create_dir_all(&hermes).unwrap(); + std::fs::write(hermes.join("state.db"), b"sqlite fixture marker").unwrap(); + let hermes_source = discover_provider_sources(temp.path()) + .into_iter() + .find(|source| source.provider == CaptureProvider::Hermes) + .unwrap(); + assert_eq!(hermes_source.status, ProviderSourceStatus::Available); + assert_eq!(hermes_source.import_support, ProviderImportSupport::Native); + + let astrbot = temp.path().join(".astrbot/data"); + std::fs::create_dir_all(&astrbot).unwrap(); + std::fs::write(astrbot.join("data_v4.db"), b"sqlite fixture marker").unwrap(); + let astrbot_source = discover_provider_sources(temp.path()) + .into_iter() + .find(|source| source.provider == CaptureProvider::AstrBot) + .unwrap(); + assert_eq!(astrbot_source.status, ProviderSourceStatus::Available); + assert_eq!( + astrbot_source.import_support, + ProviderImportSupport::Preview + ); + assert!(astrbot_source.import_support.is_importable()); + assert!(!astrbot_source.import_support.is_auto_importable()); + + let shelley = temp.path().join(".config/shelley"); + std::fs::create_dir_all(&shelley).unwrap(); + std::fs::write(shelley.join("shelley.db"), b"sqlite fixture marker").unwrap(); + let shelley_source = discover_provider_sources(temp.path()) + .into_iter() + .find(|source| source.provider == CaptureProvider::Shelley) + .unwrap(); + assert_eq!(shelley_source.status, ProviderSourceStatus::Available); + assert_eq!(shelley_source.import_support, ProviderImportSupport::Native); + assert!(shelley_source.import_support.is_auto_importable()); } #[test] @@ -674,6 +1132,89 @@ mod tests { ); } + #[test] + fn default_location_probe_does_not_fallback_to_path_existence_for_unhandled_providers() { + let temp = tempfile::tempdir().unwrap(); + let existing = temp.path().join("shell-history"); + std::fs::write(&existing, "{}\n").unwrap(); + let location = ProviderDefaultLocation { + path_components: &["shell-history"], + source_format: "shell_history", + source_kind: ProviderSourceKind::NativeHistory, + }; + + assert_eq!( + default_location_import_probe(CaptureProvider::Shell, &location, &existing), + BoundedProbe::NotFound + ); + } + + #[cfg(unix)] + #[test] + fn default_source_probe_reports_unreadable_directory_as_unknown() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let sessions = temp.path().join(".codex/sessions"); + std::fs::create_dir_all(&sessions).unwrap(); + let original_permissions = std::fs::metadata(&sessions).unwrap().permissions(); + std::fs::set_permissions(&sessions, std::fs::Permissions::from_mode(0o000)).unwrap(); + + if std::fs::read_dir(&sessions).is_ok() { + std::fs::set_permissions(&sessions, original_permissions).unwrap(); + return; + } + + let source = discover_provider_sources(temp.path()) + .into_iter() + .find(|source| { + source.provider == CaptureProvider::Codex + && source.source_format == "codex_session_jsonl_tree" + }) + .unwrap(); + std::fs::set_permissions(&sessions, original_permissions).unwrap(); + + assert_eq!(source.status, ProviderSourceStatus::Unknown); + assert!(source + .unsupported_reason + .unwrap() + .contains("could not be read")); + } + + #[cfg(unix)] + #[test] + fn default_source_probe_skips_unreadable_child_directory() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let sessions = temp.path().join(".codex/sessions"); + let readable = sessions.join("readable"); + let unreadable = sessions.join("unreadable"); + std::fs::create_dir_all(&readable).unwrap(); + std::fs::create_dir_all(&unreadable).unwrap(); + std::fs::write(readable.join("session.jsonl"), "{}\n").unwrap(); + + let original_permissions = std::fs::metadata(&unreadable).unwrap().permissions(); + std::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).unwrap(); + + if std::fs::read_dir(&unreadable).is_ok() { + std::fs::set_permissions(&unreadable, original_permissions).unwrap(); + return; + } + + let source = discover_provider_sources(temp.path()) + .into_iter() + .find(|source| { + source.provider == CaptureProvider::Codex + && source.source_format == "codex_session_jsonl_tree" + }); + std::fs::set_permissions(&unreadable, original_permissions).unwrap(); + + let source = source.unwrap(); + assert_eq!(source.status, ProviderSourceStatus::Available); + assert_eq!(source.unsupported_reason, None); + } + fn assert_source_status( home: &Path, provider: CaptureProvider, diff --git a/crates/ctx-history-core/Cargo.toml b/crates/ctx-history-core/Cargo.toml index 5b396fc30..4ae07ac59 100644 --- a/crates/ctx-history-core/Cargo.toml +++ b/crates/ctx-history-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ctx-history-core" -version = "0.12.0" +version = "0.19.0" description = "Internal core types for ctx local agent history indexing" edition.workspace = true license.workspace = true diff --git a/crates/ctx-history-core/src/history_jsonl.rs b/crates/ctx-history-core/src/history_jsonl.rs new file mode 100644 index 000000000..7ecdaa6b6 --- /dev/null +++ b/crates/ctx-history-core/src/history_jsonl.rs @@ -0,0 +1,213 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::{ + AgentType, Confidence, EventRole, EventType, Fidelity, FileChangeKind, + ProviderArtifactDescriptor, ProviderCursorRange, ProviderRawRetention, + ProviderRedactionBoundary, ProviderSourceTrust, RedactionState, SessionEdgeType, SessionStatus, +}; + +pub const CTX_HISTORY_JSONL_V1_SCHEMA_VERSION: &str = "ctx-history-jsonl-v1"; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "record_type", rename_all = "snake_case")] +pub enum CtxHistoryJsonlRecord { + Manifest(CtxHistoryJsonlManifestRecord), + Source(CtxHistoryJsonlSourceRecord), + Session(CtxHistoryJsonlSessionRecord), + Event(CtxHistoryJsonlEventRecord), + FileTouch(CtxHistoryJsonlFileTouchRecord), + Edge(CtxHistoryJsonlEdgeRecord), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CtxHistoryJsonlManifestRecord { + pub schema_version: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub producer: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub exported_at: Option>, + #[serde(default = "super::default_metadata")] + pub metadata: Value, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CtxHistoryJsonlSourceRecord { + pub source_id: String, + pub provider_key: String, + pub source_format: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub raw_uri: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub raw_source_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fingerprint: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub importer_version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub observed_at: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub machine_id: Option, + #[serde(default)] + pub raw_retention: ProviderRawRetention, + #[serde(default)] + pub redaction_boundary: ProviderRedactionBoundary, + #[serde(default)] + pub trust: ProviderSourceTrust, + #[serde(default = "default_imported_fidelity")] + pub fidelity: Fidelity, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cursor: Option, + #[serde(default = "super::default_metadata")] + pub metadata: Value, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CtxHistoryJsonlSessionRecord { + pub source_id: String, + pub session_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub native_session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub root_session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub external_agent_id: Option, + #[serde(default)] + pub agent_type: AgentType, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub role_hint: Option, + #[serde(default)] + pub is_primary: bool, + #[serde(default = "default_imported_session_status")] + pub status: SessionStatus, + pub started_at: DateTime, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ended_at: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option, + #[serde(default = "default_imported_fidelity")] + pub fidelity: Fidelity, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub idempotency_key: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub artifacts: Vec, + #[serde(default = "super::default_metadata")] + pub metadata: Value, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CtxHistoryJsonlEventRecord { + pub source_id: String, + pub session_id: String, + pub event_index: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub event_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub native_cursor: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub event_hash: Option, + #[serde(default)] + pub event_type: EventType, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub role: Option, + pub occurred_at: DateTime, + #[serde(default = "default_imported_fidelity")] + pub fidelity: Fidelity, + #[serde(default)] + pub redaction_state: RedactionState, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub idempotency_key: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub artifacts: Vec, + #[serde(default = "super::default_metadata")] + pub payload: Value, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preview: Option, + #[serde(default = "super::default_metadata")] + pub metadata: Value, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CtxHistoryJsonlFileTouchRecord { + pub source_id: String, + pub session_id: String, + pub touch_index: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub event_index: Option, + pub path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub change_kind: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub old_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub line_count_delta: Option, + #[serde(default)] + pub confidence: Confidence, + pub occurred_at: DateTime, + #[serde(default = "super::default_metadata")] + pub metadata: Value, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CtxHistoryJsonlEdgeRecord { + pub source_id: String, + pub from_session_id: String, + pub to_session_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub edge_id: Option, + pub edge_type: SessionEdgeType, + #[serde(default)] + pub confidence: Confidence, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub occurred_at: Option>, + #[serde(default = "default_imported_fidelity")] + pub fidelity: Fidelity, + #[serde(default = "super::default_metadata")] + pub metadata: Value, +} + +const fn default_imported_session_status() -> SessionStatus { + SessionStatus::Imported +} + +const fn default_imported_fidelity() -> Fidelity { + Fidelity::Imported +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ctx_history_jsonl_records_round_trip() { + let raw = r#"{"record_type":"event","source_id":"src-1","session_id":"sess-1","event_index":2,"event_id":"evt-2","native_cursor":"line:3","event_type":"message","role":"assistant","occurred_at":"2026-07-01T12:00:02Z","payload":{"text":"hello"},"preview":"hello"}"#; + let parsed: CtxHistoryJsonlRecord = serde_json::from_str(raw).unwrap(); + let CtxHistoryJsonlRecord::Event(event) = parsed else { + panic!("expected event record"); + }; + assert_eq!(event.source_id, "src-1"); + assert_eq!(event.session_id, "sess-1"); + assert_eq!(event.event_index, 2); + assert_eq!(event.role, Some(EventRole::Assistant)); + assert_eq!( + serde_json::to_value(CtxHistoryJsonlRecord::Event(event)) + .unwrap() + .get("record_type") + .and_then(Value::as_str), + Some("event") + ); + } + + #[test] + fn ctx_history_jsonl_edge_type_is_required() { + let raw = r#"{"record_type":"edge","source_id":"src-1","from_session_id":"root","to_session_id":"child"}"#; + let err = serde_json::from_str::(raw).unwrap_err(); + assert!( + err.to_string().contains("missing field `edge_type`"), + "{err}" + ); + } +} diff --git a/crates/ctx-history-core/src/lib.rs b/crates/ctx-history-core/src/lib.rs index 4e1898427..3ed7e2bef 100644 --- a/crates/ctx-history-core/src/lib.rs +++ b/crates/ctx-history-core/src/lib.rs @@ -1,4 +1,4 @@ -use std::{env, fmt, path::PathBuf, str::FromStr, sync::OnceLock}; +use std::{env, fmt, path::PathBuf, str::FromStr, sync::OnceLock, time::SystemTime}; use chrono::{DateTime, Utc}; use directories::BaseDirs; @@ -20,6 +20,10 @@ pub enum CoreError { pub type Result = std::result::Result; +pub fn utc_now() -> DateTime { + DateTime::::from(SystemTime::now()) +} + macro_rules! text_enum { ( $(#[$meta:meta])* @@ -93,8 +97,10 @@ macro_rules! text_enum { }; } +mod history_jsonl; mod provider; +pub use history_jsonl::*; pub use provider::*; text_enum! { @@ -142,13 +148,26 @@ text_enum! { } text_enum! { + /// Payload handling state. + /// + /// The serialized value `safe_preview` is legacy contract spelling for a + /// local searchable preview. It is not a promise that output is share-safe. pub enum RedactionState { Raw => "raw", Redacted => "redacted", - SafePreview => "safe_preview", + LocalPreview => "safe_preview", Withheld => "withheld", } - default SafePreview + default LocalPreview +} + +impl RedactionState { + /// Compatibility alias for the legacy Rust API name. + /// + /// New code should prefer `LocalPreview`, which better matches the local + /// search contract while preserving the serialized `safe_preview` value. + #[allow(non_upper_case_globals)] + pub const SafePreview: Self = Self::LocalPreview; } text_enum! { @@ -172,10 +191,16 @@ text_enum! { Cursor => "cursor", CopilotCli => "copilot_cli", FactoryAiDroid => "factory_ai_droid", + OpenClaw => "openclaw", + Hermes => "hermes", + NanoClaw => "nanoclaw", + AstrBot => "astrbot", + Shelley => "shelley", Shell => "shell", Git => "git", Jj => "jj", Gh => "gh", + Custom => "custom", Unknown => "unknown", } default Unknown @@ -588,7 +613,7 @@ impl HistoryRecord { kind: impl Into, workspace: Option, ) -> Self { - let now = Utc::now(); + let now = utc_now(); Self { id: new_id(), title: title.into(), @@ -1470,7 +1495,7 @@ mod tests { assert_eq!(Fidelity::default(), Fidelity::Partial); assert_eq!(SyncState::default(), SyncState::LocalOnly); assert_eq!(Confidence::default(), Confidence::Unknown); - assert_eq!(RedactionState::default(), RedactionState::SafePreview); + assert_eq!(RedactionState::default(), RedactionState::LocalPreview); assert_eq!( serde_json::from_str::("\"copilot_cli\"").unwrap(), CaptureProvider::CopilotCli @@ -1500,6 +1525,20 @@ mod tests { assert_eq!(outbox.sync_state, SyncState::Pending); } + #[test] + fn safe_preview_is_legacy_local_preview_spelling() { + assert_eq!(RedactionState::LocalPreview.as_str(), "safe_preview"); + assert_eq!( + "safe_preview".parse::().unwrap(), + RedactionState::LocalPreview + ); + assert_eq!( + serde_json::to_string(&RedactionState::LocalPreview).unwrap(), + "\"safe_preview\"" + ); + assert_eq!(RedactionState::SafePreview, RedactionState::LocalPreview); + } + #[test] fn history_record_json_names_are_public_names() { let record_id = Uuid::parse_str("018f45d0-0000-7000-8000-000000000001").unwrap(); diff --git a/crates/ctx-history-core/src/provider.rs b/crates/ctx-history-core/src/provider.rs index c849292ed..699b2bfc1 100644 --- a/crates/ctx-history-core/src/provider.rs +++ b/crates/ctx-history-core/src/provider.rs @@ -52,6 +52,14 @@ pub enum ProviderId { FactoryAiDroid, FactoryDroid, DroidFactoryAi, + #[serde(rename = "openclaw", alias = "open_claw")] + OpenClaw, + Hermes, + #[serde(rename = "nanoclaw", alias = "nano_claw")] + NanoClaw, + #[serde(rename = "astrbot", alias = "astr_bot")] + AstrBot, + Shelley, Goose, #[serde(rename = "openhands")] OpenHands, @@ -69,7 +77,7 @@ pub enum ProviderId { } impl ProviderId { - pub const ALL: [Self; 27] = [ + pub const ALL: [Self; 32] = [ Self::Codex, Self::ClaudeCode, Self::ClaudeCliCrp, @@ -84,6 +92,11 @@ impl ProviderId { Self::FactoryAiDroid, Self::FactoryDroid, Self::DroidFactoryAi, + Self::OpenClaw, + Self::Hermes, + Self::NanoClaw, + Self::AstrBot, + Self::Shelley, Self::Goose, Self::OpenHands, Self::Cagent, @@ -393,14 +406,19 @@ mod tests { .collect::>(); let expected = [ ProviderId::AntigravityCli, + ProviderId::AstrBot, ProviderId::ClaudeCode, ProviderId::Codex, ProviderId::Cursor, ProviderId::CopilotCli, ProviderId::FactoryAiDroid, ProviderId::GeminiCli, + ProviderId::Hermes, + ProviderId::NanoClaw, ProviderId::OpenCode, + ProviderId::OpenClaw, ProviderId::Pi, + ProviderId::Shelley, ] .into_iter() .collect::>(); diff --git a/crates/ctx-history-search/Cargo.toml b/crates/ctx-history-search/Cargo.toml index 59ffdf3ea..12dd4b78d 100644 --- a/crates/ctx-history-search/Cargo.toml +++ b/crates/ctx-history-search/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ctx-history-search" -version = "0.12.0" +version = "0.19.0" description = "Internal search projection and ranking helpers for ctx" edition.workspace = true license.workspace = true diff --git a/crates/ctx-history-search/src/lib.rs b/crates/ctx-history-search/src/lib.rs index e027c22f4..fd217f92e 100644 --- a/crates/ctx-history-search/src/lib.rs +++ b/crates/ctx-history-search/src/lib.rs @@ -6,7 +6,7 @@ use std::{ use chrono::Utc; use ctx_history_core::{ - Artifact, ContextCitation, ContextCitationType, ContextLinks, ContextPagination, + utc_now, Artifact, ContextCitation, ContextCitationType, ContextLinks, ContextPagination, ContextTruncation, Event, EventType, FileTouched, HistoryRecord, RedactionState, Run, Session, Summary, VcsChange, Visibility, }; @@ -56,17 +56,25 @@ pub enum SearchResultMode { Events, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] pub struct SearchFilters { #[serde(default, skip_serializing_if = "Option::is_none")] pub session: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub history_source: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_format: Option, #[serde(default, rename = "workspace", skip_serializing_if = "Option::is_none")] pub repo: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub since: Option>, - #[serde(default)] + #[serde(skip_serializing)] pub primary_only: bool, #[serde(default)] pub include_subagents: bool, @@ -86,22 +94,6 @@ pub struct ProviderSessionFilter { pub session_id: Option, } -impl Default for SearchFilters { - fn default() -> Self { - Self { - session: None, - provider: None, - repo: None, - since: None, - primary_only: false, - include_subagents: true, - event_type: None, - file: None, - exclude_provider_session: None, - } - } -} - #[derive(Debug, Clone, PartialEq, Serialize)] pub struct SearchPacket { pub schema_version: u32, @@ -136,6 +128,16 @@ pub struct SearchPacketResult { #[serde(default, skip_serializing_if = "Option::is_none")] pub provider_session_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub history_source: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub history_source_plugin: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_format: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub timestamp: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub cwd: Option, @@ -211,6 +213,11 @@ struct HitMetadata { time: chrono::DateTime, provider: Option, provider_session_id: Option, + history_source: Option, + history_source_plugin: Option, + provider_key: Option, + source_id: Option, + source_format: Option, session_id: Option, parent_session_id: Option, root_session_id: Option, @@ -266,7 +273,7 @@ pub fn search_packet(store: &Store, query: &str, options: &PacketOptions) -> Res schema_version: SEARCH_PACKET_SCHEMA_VERSION, query: query.to_owned(), filters: options.filters, - generated_at: Utc::now(), + generated_at: utc_now(), results, pagination: pagination(Some(cursor_offset), has_more), truncation, @@ -340,7 +347,7 @@ pub fn search_packet_terms( schema_version: SEARCH_PACKET_SCHEMA_VERSION, query: search_terms.join(" OR "), filters: options.filters, - generated_at: Utc::now(), + generated_at: utc_now(), results: merged_results, pagination: pagination(Some(cursor_offset), has_more), truncation, @@ -381,9 +388,34 @@ fn merge_search_result(existing: &mut SearchPacketResult, incoming: SearchPacket existing.event_seq = incoming.event_seq; existing.timestamp = incoming.timestamp; existing.cwd = incoming.cwd.clone(); + existing.provider = incoming.provider; + existing.provider_session_id = incoming.provider_session_id.clone(); + existing.history_source = incoming.history_source.clone(); + existing.history_source_plugin = incoming.history_source_plugin.clone(); + existing.provider_key = incoming.provider_key.clone(); + existing.source_id = incoming.source_id.clone(); + existing.source_format = incoming.source_format.clone(); existing.raw_source_path = incoming.raw_source_path.clone(); existing.raw_source_exists = incoming.raw_source_exists; existing.cursor = incoming.cursor.clone(); + } else { + existing.history_source = existing + .history_source + .clone() + .or(incoming.history_source.clone()); + existing.history_source_plugin = existing + .history_source_plugin + .clone() + .or(incoming.history_source_plugin.clone()); + existing.provider_key = existing + .provider_key + .clone() + .or(incoming.provider_key.clone()); + existing.source_id = existing.source_id.clone().or(incoming.source_id.clone()); + existing.source_format = existing + .source_format + .clone() + .or(incoming.source_format.clone()); } existing.rank = existing_rank.max(incoming_rank) + 0.08; existing.more_matches_in_session = existing @@ -487,6 +519,19 @@ fn candidate_search_result( provider_session_id: display_hit .as_ref() .and_then(|hit| hit.provider_session_id.clone()), + history_source: display_hit + .as_ref() + .and_then(|hit| hit.history_source.clone()), + history_source_plugin: display_hit + .as_ref() + .and_then(|hit| hit.history_source_plugin.clone()), + provider_key: display_hit + .as_ref() + .and_then(|hit| hit.provider_key.clone()), + source_id: display_hit.as_ref().and_then(|hit| hit.source_id.clone()), + source_format: display_hit + .as_ref() + .and_then(|hit| hit.source_format.clone()), timestamp: display_hit.as_ref().map(|hit| hit.time), cwd: display_hit.as_ref().and_then(|hit| hit.cwd.clone()), raw_source_path: display_hit @@ -515,6 +560,7 @@ fn candidate_display_hit(candidate: &Candidate, filters: &SearchFilters) -> Opti && filters .session .map_or(true, |id| hit.session_id == Some(id)) + && hit_matches_history_source_filter(&hit, filters) }) { return Some(event_hit(event, &candidate.context)); } @@ -532,6 +578,10 @@ fn candidate_display_hit(candidate: &Candidate, filters: &SearchFilters) -> Opti .provider .map_or(true, |provider| session.provider == provider) && filters.session.map_or(true, |id| session.id == id) + && hit_matches_history_source_filter( + &session_hit(session, &candidate.context), + filters, + ) }) .or_else(|| candidate.context.sessions.first()) .map(|session| session_hit(session, &candidate.context)) @@ -546,6 +596,9 @@ fn fast_event_search_packet( if query.trim().is_empty() { return Ok(None); } + if has_history_source_filter(&options.filters) { + return Ok(None); + } if !store.has_at_least_events(LARGE_EVENT_CORPUS_THRESHOLD)? { return Ok(None); } @@ -656,7 +709,7 @@ fn fast_event_search_packet( schema_version: SEARCH_PACKET_SCHEMA_VERSION, query: query.to_owned(), filters: options.filters.clone(), - generated_at: Utc::now(), + generated_at: utc_now(), results, pagination: pagination(Some(cursor_offset), has_more), truncation, @@ -668,7 +721,7 @@ fn empty_search_packet(query: &str, options: &PacketOptions) -> SearchPacket { schema_version: SEARCH_PACKET_SCHEMA_VERSION, query: query.to_owned(), filters: options.filters.clone(), - generated_at: Utc::now(), + generated_at: utc_now(), results: Vec::new(), pagination: pagination(Some(0), false), truncation: ContextTruncation::default(), @@ -698,15 +751,7 @@ fn event_hit_matches_filters( return false; } } - if filters.primary_only { - let is_primary = hit.session_is_primary.unwrap_or(false) - || hit.agent_type == Some(ctx_history_core::AgentType::Primary); - if !is_primary { - return false; - } - } else if !filters.include_subagents - && hit.agent_type == Some(ctx_history_core::AgentType::Subagent) - { + if !event_hit_matches_agent_scope(hit, filters) { return false; } if let Some(event_type) = filters.event_type { @@ -885,6 +930,11 @@ fn event_search_result( session_importance: 0.0, provider: hit.provider, provider_session_id: hit.session_external_session_id.clone(), + history_source: hit.history_source.clone(), + history_source_plugin: hit.history_source_plugin.clone(), + provider_key: hit.provider_key.clone(), + source_id: hit.source_id.clone(), + source_format: hit.source_format.clone(), timestamp: Some(hit.occurred_at), cwd: hit.cwd.clone(), raw_source_path: hit.raw_source_path.clone(), @@ -1000,12 +1050,48 @@ fn ranked_candidates( file_scope: Option<&FileTouchScope>, ) -> Result { let target_candidates = options.limit.saturating_add(1); - let filtered = has_filters(&options.filters); let terms = query_terms(query.unwrap_or_default()); let mut candidates = Vec::new(); let mut seen = BTreeSet::::new(); let mut scan_budget_exhausted = false; + let file_only = terms.is_empty() && file_scope.is_some(); + if terms.is_empty() && !file_only { + return Ok(CandidateSearch { + candidates, + scan_budget_exhausted, + }); + } + + if file_only { + let Some(scope) = file_scope else { + return Ok(CandidateSearch { + candidates, + scan_budget_exhausted, + }); + }; + for record_id in &scope.history_record_ids { + if !seen.insert(*record_id) { + continue; + } + let record = store.get_record(*record_id)?; + if let Some(candidate) = + candidate_for_record(store, record, &terms, &options.filters, file_scope)? + { + candidates.push(candidate); + } + } + normalize_scores(&mut candidates); + candidates.sort_by(compare_candidates); + if candidates.len() > target_candidates { + candidates.truncate(target_candidates); + } + return Ok(CandidateSearch { + candidates, + scan_budget_exhausted, + }); + } + let filtered = has_filters(&options.filters); if filtered { let page_size = FILTERED_SEARCH_PAGE_SIZE.max(target_candidates); let mut offset = 0_usize; @@ -1016,7 +1102,7 @@ fn ranked_candidates( Some(query) if !query.trim().is_empty() => { store.search_records_page(query, page_size, offset)? } - _ => store.list_records_page(page_size, offset)?, + _ => Vec::new(), }; let page_len = records.len(); @@ -1055,7 +1141,7 @@ fn ranked_candidates( let fetch_limit = target_candidates; let records = match query { Some(query) if !query.trim().is_empty() => store.search_records(query, fetch_limit)?, - _ => store.list_records(fetch_limit)?, + _ => Vec::new(), }; for record in records { if !seen.insert(record.id) { @@ -1073,14 +1159,7 @@ fn ranked_candidates( } normalize_scores(&mut candidates); - candidates.sort_by(|left, right| { - right - .score - .total_cmp(&left.score) - .then_with(|| right.record.updated_at.cmp(&left.record.updated_at)) - .then_with(|| left.record.title.cmp(&right.record.title)) - .then_with(|| left.record.id.cmp(&right.record.id)) - }); + candidates.sort_by(compare_candidates); if candidates.len() > target_candidates { candidates.truncate(target_candidates); } @@ -1090,6 +1169,15 @@ fn ranked_candidates( }) } +fn compare_candidates(left: &Candidate, right: &Candidate) -> Ordering { + right + .score + .total_cmp(&left.score) + .then_with(|| right.record.updated_at.cmp(&left.record.updated_at)) + .then_with(|| left.record.title.cmp(&right.record.title)) + .then_with(|| left.record.id.cmp(&right.record.id)) +} + fn candidate_for_record( store: &Store, record: HistoryRecord, @@ -1206,6 +1294,37 @@ fn analyze_record( let mut citations = Vec::new(); if terms.is_empty() { + if filters + .file + .as_ref() + .is_some_and(|file| !file.trim().is_empty()) + { + let mut primary_hit = None; + for section in search_sections(record, context, filters) + .into_iter() + .filter(|section| section.reason == "file_touched") + { + if primary_hit.is_none() { + primary_hit = Some(section.hit.clone()); + } + score += section.weight; + add_match( + &mut why, + &mut citations, + section.reason, + section.citation, + §ion.hit, + ); + } + if !why.is_empty() { + return MatchAnalysis { + score, + why_matched: why, + citations, + primary_hit, + }; + } + } add_match( &mut why, &mut citations, @@ -1296,47 +1415,60 @@ fn search_sections( ) -> Vec { let mut sections = Vec::new(); let record_hit = record_context_display_hit(context, filters, record.updated_at); - sections.push(SearchSection { - reason: "title", - weight: 8.0, - text: record.title.clone(), - citation: citation( - ContextCitationType::HistoryRecord, - record.id, - "session title", - record.updated_at, - ), - hit: record_hit.clone(), - }); - if !context_has_excluded_provider_session(context, filters) { + let include_record_bookkeeping_text = !is_agent_history_bookkeeping_record(record); + if include_record_bookkeeping_text { sections.push(SearchSection { - reason: "primary_user_message", - weight: 5.0, - text: record.body.clone(), + reason: "title", + weight: 8.0, + text: record.title.clone(), citation: citation( ContextCitationType::HistoryRecord, record.id, - "session text", + "session title", record.updated_at, ), hit: record_hit.clone(), }); } - for tag in &record.tags { + let include_record_text = include_record_bookkeeping_text + && record_text_matches_agent_scope(context, filters) + && !context_has_excluded_provider_session(context, filters); + if include_record_text { sections.push(SearchSection { - reason: "tag", - weight: 3.0, - text: tag.clone(), + reason: "primary_user_message", + weight: 5.0, + text: record.body.clone(), citation: citation( ContextCitationType::HistoryRecord, record.id, - "session tag", + "session text", record.updated_at, ), hit: record_hit.clone(), }); } + if include_record_text { + for tag in &record.tags { + sections.push(SearchSection { + reason: "tag", + weight: 3.0, + text: tag.clone(), + citation: citation( + ContextCitationType::HistoryRecord, + record.id, + "session tag", + record.updated_at, + ), + hit: record_hit.clone(), + }); + } + } for session in &context.sessions { + if !session_matches_agent_scope(session, filters) + || !source_id_matches_history_source_filter(session.capture_source_id, context, filters) + { + continue; + } let hit = session_hit(session, context); sections.push(SearchSection { reason: "session_metadata", @@ -1360,6 +1492,9 @@ fn search_sections( } for run in &context.runs { + if !item_matches_agent_scope(run.session_id, run.source_id, context, filters) { + continue; + } let hit = run_hit(run, context); sections.push(SearchSection { reason: "run_command", @@ -1385,6 +1520,9 @@ fn search_sections( } for event in &context.events { + if !item_matches_agent_scope(event.session_id, event.capture_source_id, context, filters) { + continue; + } let event_text = event_text(event); let hit = event_hit(event, context); sections.push(SearchSection { @@ -1410,6 +1548,9 @@ fn search_sections( } for artifact in &context.artifacts { + if !item_matches_agent_scope(None, artifact.source_id, context, filters) { + continue; + } let hit = artifact_hit(artifact, context); sections.push(SearchSection { reason: "artifact", @@ -1431,6 +1572,16 @@ fn search_sections( } for file in &context.files_touched { + let session_id = file.event_id.and_then(|id| { + context + .events + .iter() + .find(|event| event.id == id) + .and_then(|event| event.session_id) + }); + if !item_matches_agent_scope(session_id, file.source_id, context, filters) { + continue; + } let hit = file_hit(file, context); sections.push(SearchSection { reason: "file_touched", @@ -1447,6 +1598,9 @@ fn search_sections( } for change in &context.vcs_changes { + if !item_matches_agent_scope(None, change.source_id, context, filters) { + continue; + } let parent_change_ids = change.parent_change_ids.join(" "); let hit = source_hit( change.source_id, @@ -1474,6 +1628,9 @@ fn search_sections( } for summary in &context.summaries { + if !item_matches_agent_scope(None, summary.source_id, context, filters) { + continue; + } let hit = source_hit(summary.source_id, summary.timestamps.updated_at, context); sections.push(SearchSection { reason: "summary", @@ -1492,6 +1649,120 @@ fn search_sections( sections } +fn is_agent_history_bookkeeping_record(record: &HistoryRecord) -> bool { + record.kind == "agent_history" + || record.tags.iter().any(|tag| tag == "agent-history") + || record + .body + .trim_start() + .starts_with("Indexed local agent history from ") + || record + .body + .trim_start() + .starts_with("Indexed custom agent history from ") +} + +fn session_matches_agent_scope(session: &Session, filters: &SearchFilters) -> bool { + if filters.session == Some(session.id) { + return true; + } + if filters.include_subagents && !filters.primary_only { + return true; + } + session_is_primary(session) + || (!filters.primary_only + && session.agent_type == ctx_history_core::AgentType::Unknown + && session.parent_session_id.is_none()) +} + +fn session_is_primary(session: &Session) -> bool { + session.is_primary || session.agent_type == ctx_history_core::AgentType::Primary +} + +fn event_hit_matches_agent_scope(hit: &EventSearchHit, filters: &SearchFilters) -> bool { + if filters.session.is_some() && filters.session == hit.session_id { + return true; + } + if filters.include_subagents && !filters.primary_only { + return true; + } + if hit.session_is_primary == Some(true) + || hit.agent_type == Some(ctx_history_core::AgentType::Primary) + { + return true; + } + if filters.primary_only { + return false; + } + hit.session_is_primary.is_none() && hit.agent_type.is_none() +} + +fn record_text_matches_agent_scope(context: &RecordContext, filters: &SearchFilters) -> bool { + if has_history_source_filter(filters) { + return false; + } + context + .sessions + .iter() + .all(|session| session_matches_agent_scope(session, filters)) +} + +fn item_matches_agent_scope( + session_id: Option, + source_id: Option, + context: &RecordContext, + filters: &SearchFilters, +) -> bool { + let item_source_id = source_id.or_else(|| { + session_id + .and_then(|id| context.sessions.iter().find(|session| session.id == id)) + .and_then(|session| session.capture_source_id) + }); + if !source_id_matches_history_source_filter(item_source_id, context, filters) { + return false; + } + associated_session(session_id, source_id, context) + .map(|session| session_matches_agent_scope(session, filters)) + .unwrap_or(true) +} + +fn source_id_matches_history_source_filter( + source_id: Option, + context: &RecordContext, + filters: &SearchFilters, +) -> bool { + if !has_history_source_filter(filters) { + return true; + } + source_id + .and_then(|id| context.sources.get(&id)) + .is_some_and(|source| source_matches_history_source_filter(source, filters)) +} + +fn associated_session( + session_id: Option, + source_id: Option, + context: &RecordContext, +) -> Option<&Session> { + session_id + .and_then(|id| context.sessions.iter().find(|session| session.id == id)) + .or_else(|| source_id.and_then(|id| associated_session_for_source(id, context))) +} + +fn associated_session_for_source(source_id: Uuid, context: &RecordContext) -> Option<&Session> { + context + .sessions + .iter() + .find(|session| session.capture_source_id == Some(source_id)) + .or_else(|| { + let source = context.sources.get(&source_id)?; + context.sessions.iter().find(|session| { + session.provider == source.descriptor.provider + && session.external_session_id == source.descriptor.external_session_id + }) + }) +} + fn record_context_display_hit( context: &RecordContext, filters: &SearchFilters, @@ -1501,12 +1772,19 @@ fn record_context_display_hit( .sessions .iter() .find(|session| { - filters - .provider - .map_or(true, |provider| session.provider == provider) + session_matches_agent_scope(session, filters) + && filters + .provider + .map_or(true, |provider| session.provider == provider) && filters.session.map_or(true, |id| session.id == id) + && hit_matches_history_source_filter(&session_hit(session, context), filters) + }) + .or_else(|| { + context + .sessions + .iter() + .find(|session| session_matches_agent_scope(session, filters)) }) - .or_else(|| context.sessions.first()) .map(|session| session_hit(session, context)) .unwrap_or_else(|| empty_hit(time)) } @@ -1548,6 +1826,11 @@ fn empty_hit(time: chrono::DateTime) -> HitMetadata { time, provider: None, provider_session_id: None, + history_source: None, + history_source_plugin: None, + provider_key: None, + source_id: None, + source_format: None, session_id: None, parent_session_id: None, root_session_id: None, @@ -1653,10 +1936,16 @@ fn source_hit( return empty_hit(time); }; let raw_source_path = source.descriptor.raw_source_path.clone(); - HitMetadata { + let identity = source_history_identity(source); + let mut hit = HitMetadata { time, provider: Some(source.descriptor.provider), provider_session_id: source.descriptor.external_session_id.clone(), + history_source: identity.history_source, + history_source_plugin: identity.history_source_plugin, + provider_key: identity.provider_key, + source_id: identity.source_id, + source_format: identity.source_format, session_id: None, parent_session_id: None, root_session_id: None, @@ -1668,7 +1957,15 @@ fn source_hit( .map(|path| Path::new(path).exists()), raw_source_path, cursor: source_cursor(source), + }; + if let Some(session) = associated_session_for_source(source.id, context) { + hit.provider = Some(session.provider); + hit.provider_session_id = session.external_session_id.clone(); + hit.session_id = Some(session.id); + hit.parent_session_id = session.parent_session_id; + hit.root_session_id = session.root_session_id; } + hit } fn source_for_id( @@ -1689,6 +1986,175 @@ fn source_cursor(source: &ctx_history_core::CaptureSource) -> Option { .map(str::to_owned) } +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct SourceHistoryIdentity { + history_source: Option, + history_source_plugin: Option, + provider_key: Option, + source_id: Option, + source_format: Option, +} + +fn source_history_identity(source: &ctx_history_core::CaptureSource) -> SourceHistoryIdentity { + let metadata = &source.sync.metadata; + let source_metadata = metadata + .get("source_metadata") + .and_then(serde_json::Value::as_object); + let plugin = source_metadata + .and_then(|metadata| metadata.get("ctx_history_plugin")) + .or_else(|| metadata.get("ctx_history_plugin")) + .and_then(serde_json::Value::as_object); + let custom = source_metadata + .and_then(|metadata| metadata.get("ctx_history_jsonl_v1")) + .or_else(|| metadata.get("ctx_history_jsonl_v1")) + .and_then(serde_json::Value::as_object); + let plugin_name = plugin + .and_then(|plugin| plugin.get("plugin_name")) + .and_then(serde_json::Value::as_str) + .map(str::to_owned); + let plugin_source_id = plugin + .and_then(|plugin| plugin.get("plugin_source_id")) + .and_then(serde_json::Value::as_str) + .map(str::to_owned); + let history_source = plugin + .and_then(|plugin| plugin.get("history_source")) + .and_then(serde_json::Value::as_str) + .map(str::to_owned) + .or_else(|| { + plugin_name + .as_deref() + .zip(plugin_source_id.as_deref()) + .map(|(plugin_name, source_id)| format!("{plugin_name}/{source_id}")) + }); + let provider_key = custom + .and_then(|custom| custom.get("provider_key")) + .and_then(serde_json::Value::as_str) + .map(str::to_owned); + let source_id = custom + .and_then(|custom| custom.get("source_id")) + .and_then(serde_json::Value::as_str) + .map(str::to_owned); + let source_format = custom + .and_then(|custom| custom.get("source_format")) + .and_then(serde_json::Value::as_str) + .or_else(|| { + source_metadata + .and_then(|metadata| metadata.get("source_format")) + .and_then(serde_json::Value::as_str) + }) + .or_else(|| { + metadata + .get("source_format") + .and_then(serde_json::Value::as_str) + }) + .map(str::to_owned); + SourceHistoryIdentity { + history_source, + history_source_plugin: plugin_name, + provider_key, + source_id, + source_format, + } +} + +fn has_history_source_filter(filters: &SearchFilters) -> bool { + filters + .history_source + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + || filters + .provider_key + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + || filters + .source_id + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + || filters + .source_format + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) +} + +fn source_matches_history_source_filter( + source: &ctx_history_core::CaptureSource, + filters: &SearchFilters, +) -> bool { + let identity = source_history_identity(source); + source_identity_matches_history_source_filter(&identity, filters) +} + +fn hit_matches_history_source_filter(hit: &HitMetadata, filters: &SearchFilters) -> bool { + if !has_history_source_filter(filters) { + return true; + } + source_identity_matches_history_source_filter( + &SourceHistoryIdentity { + history_source: hit.history_source.clone(), + history_source_plugin: hit.history_source_plugin.clone(), + provider_key: hit.provider_key.clone(), + source_id: hit.source_id.clone(), + source_format: hit.source_format.clone(), + }, + filters, + ) +} + +fn source_identity_matches_history_source_filter( + identity: &SourceHistoryIdentity, + filters: &SearchFilters, +) -> bool { + if let Some(selector) = filters + .history_source + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + let plugin_match = identity.history_source.as_deref() == Some(selector); + let provider_source_match = identity + .provider_key + .as_deref() + .zip(identity.source_id.as_deref()) + .is_some_and(|(provider_key, source_id)| { + selector == format!("{provider_key}/{source_id}") + }); + if !plugin_match && !provider_source_match { + return false; + } + } + if let Some(provider_key) = filters + .provider_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + if identity.provider_key.as_deref() != Some(provider_key) { + return false; + } + } + if let Some(source_id) = filters + .source_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + if identity.source_id.as_deref() != Some(source_id) { + return false; + } + } + if let Some(source_format) = filters + .source_format + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + if identity.source_format.as_deref() != Some(source_format) { + return false; + } + } + true +} + fn event_cursor(event: &Event) -> Option { event .payload @@ -1829,7 +2295,7 @@ fn query_terms(query: &str) -> Vec { .split(|ch: char| !ch.is_alphanumeric() && ch != '_' && ch != '-') .filter_map(|term| { let term = term.trim().to_lowercase(); - if term.is_empty() { + if term.is_empty() || !term.chars().any(char::is_alphanumeric) { None } else { Some(term) @@ -1854,6 +2320,7 @@ fn has_filters(filters: &SearchFilters) -> bool { .as_ref() .is_some_and(|value| !value.trim().is_empty()) || filters.exclude_provider_session.is_some() + || has_history_source_filter(filters) } fn record_matches_filters( @@ -1915,6 +2382,15 @@ fn record_matches_filters( } } + if has_history_source_filter(filters) + && !context + .sources + .values() + .any(|source| source_matches_history_source_filter(source, filters)) + { + return false; + } + if let Some(since) = filters.since { let has_recent_event = context .events @@ -1928,20 +2404,12 @@ fn record_matches_filters( } } - if filters.primary_only { - if !context.sessions.iter().any(|session| { - session.is_primary || session.agent_type == ctx_history_core::AgentType::Primary - }) { - return false; - } - } else if !filters.include_subagents - && context + if (filters.primary_only || !filters.include_subagents) + && !context.sessions.is_empty() + && !context .sessions .iter() - .any(|session| session.agent_type == ctx_history_core::AgentType::Subagent) - && !context.sessions.iter().any(|session| { - session.is_primary || session.agent_type == ctx_history_core::AgentType::Primary - }) + .any(|session| session_matches_agent_scope(session, filters)) { return false; } @@ -2076,7 +2544,11 @@ fn search_snippet( return matched_snippet(§ion.text, &terms, max_chars); } } - if !record.body.trim().is_empty() && !context_has_excluded_provider_session(context, filters) { + if !record.body.trim().is_empty() + && !is_agent_history_bookkeeping_record(record) + && record_text_matches_agent_scope(context, filters) + && !context_has_excluded_provider_session(context, filters) + { return local_snippet(&record.body, max_chars); } String::new() @@ -2181,6 +2653,17 @@ mod tests { } } + fn excluded_filter(session_id: Option) -> SearchFilters { + SearchFilters { + exclude_provider_session: Some(ProviderSessionFilter { + provider: CaptureProvider::Codex, + provider_session_id: "provider-session-1".into(), + session_id, + }), + ..SearchFilters::default() + } + } + fn test_store() -> (tempfile::TempDir, ctx_history_store::Store) { let temp = tempdir(); let path = temp.path().join("work.sqlite"); @@ -2225,12 +2708,137 @@ mod tests { } #[test] - fn rich_search_matches_typed_metadata_with_citations_and_redaction() { - let (_temp, store) = test_store(); - let record = HistoryRecord::new( - "Plain work", - "ordinary body without the query", - vec!["needle-tag".into()], + fn excluded_provider_session_matches_provider_external_id_for_hits() { + let filters = excluded_filter(None); + let hit = HitMetadata { + provider: Some(CaptureProvider::Codex), + provider_session_id: Some("provider-session-1".into()), + ..empty_hit(fixed_time()) + }; + assert!(hit_matches_excluded_provider_session(&hit, &filters)); + + let event_hit = EventSearchHit { + event_id: Uuid::parse_str("018f45d0-0000-7000-8000-000000001001").unwrap(), + history_record_id: None, + session_id: None, + session_parent_session_id: None, + session_root_session_id: None, + run_id: None, + seq: 1, + event_type: EventType::Message, + role: Some(EventRole::User), + occurred_at: fixed_time(), + preview: "synthetic preview".into(), + score: 1.0, + provider: Some(CaptureProvider::Codex), + session_external_session_id: Some("provider-session-1".into()), + history_source: None, + history_source_plugin: None, + provider_key: None, + source_id: None, + source_format: None, + agent_type: Some(AgentType::Primary), + session_is_primary: Some(true), + cwd: None, + raw_source_path: None, + cursor: None, + record_title: None, + record_kind: None, + record_workspace: None, + }; + assert!(event_hit_matches_excluded_provider_session( + &event_hit, &filters + )); + + let mut different_provider = event_hit; + different_provider.provider = Some(CaptureProvider::Claude); + assert!(!event_hit_matches_excluded_provider_session( + &different_provider, + &filters + )); + } + + #[test] + fn excluded_provider_session_matches_parent_and_root_session_tree() { + let excluded_session_id = Uuid::parse_str("018f45d0-0000-7000-8000-000000001100").unwrap(); + let child_session_id = Uuid::parse_str("018f45d0-0000-7000-8000-000000001101").unwrap(); + let grandchild_session_id = + Uuid::parse_str("018f45d0-0000-7000-8000-000000001102").unwrap(); + let filters = excluded_filter(Some(excluded_session_id)); + + let parent_hit = HitMetadata { + session_id: Some(child_session_id), + parent_session_id: Some(excluded_session_id), + ..empty_hit(fixed_time()) + }; + assert!(hit_matches_excluded_provider_session(&parent_hit, &filters)); + + let root_event_hit = EventSearchHit { + event_id: Uuid::parse_str("018f45d0-0000-7000-8000-000000001103").unwrap(), + history_record_id: None, + session_id: Some(grandchild_session_id), + session_parent_session_id: Some(child_session_id), + session_root_session_id: Some(excluded_session_id), + run_id: None, + seq: 1, + event_type: EventType::Message, + role: Some(EventRole::Assistant), + occurred_at: fixed_time(), + preview: "synthetic preview".into(), + score: 1.0, + provider: None, + session_external_session_id: None, + history_source: None, + history_source_plugin: None, + provider_key: None, + source_id: None, + source_format: None, + agent_type: Some(AgentType::Subagent), + session_is_primary: Some(false), + cwd: None, + raw_source_path: None, + cursor: None, + record_title: None, + record_kind: None, + record_workspace: None, + }; + assert!(event_hit_matches_excluded_provider_session( + &root_event_hit, + &filters + )); + + let context = RecordContext { + sessions: vec![Session { + id: grandchild_session_id, + history_record_id: None, + parent_session_id: Some(child_session_id), + root_session_id: Some(excluded_session_id), + capture_source_id: None, + provider: CaptureProvider::Claude, + external_session_id: Some("different-provider-session".into()), + external_agent_id: None, + agent_type: AgentType::Subagent, + role_hint: None, + is_primary: false, + status: SessionStatus::Imported, + transcript_blob_id: None, + started_at: fixed_time(), + ended_at: None, + timestamps: timestamps(), + sync: sync_metadata(), + }], + ..RecordContext::default() + }; + assert!(context_has_excluded_provider_session(&context, &filters)); + } + + #[test] + fn rich_search_matches_typed_metadata_with_citations_and_redaction() { + let (_temp, store) = test_store(); + let record = HistoryRecord::new( + "Plain work", + "ordinary body without the query", + vec!["needle-tag".into()], "task", None, ); @@ -3000,6 +3608,33 @@ mod tests { && citation.cursor.as_deref() == Some("line:8") })); + let file_only = search_packet( + &store, + "", + &PacketOptions { + limit: 10, + filters: SearchFilters { + provider: Some(CaptureProvider::Codex), + file: Some("source_filter.rs".into()), + ..SearchFilters::default() + }, + ..PacketOptions::default() + }, + ) + .unwrap(); + assert_eq!(file_only.results.len(), 1); + assert!(file_only.results[0] + .why_matched + .iter() + .any(|reason| reason == "file_touched")); + assert!(!file_only.results[0] + .why_matched + .iter() + .any(|reason| reason == "recent_activity")); + assert!(file_only.results[0].citations.iter().any(|citation| { + citation.citation_type == ContextCitationType::File && citation.id == file.id + })); + let wrong_provider = search_packet( &store, "source-filter-needle", @@ -3016,6 +3651,280 @@ mod tests { assert!(wrong_provider.results.is_empty()); } + #[test] + fn search_filters_custom_history_source_identity() { + let (_temp, store) = test_store(); + let record = HistoryRecord::new( + "Custom plugin import", + "ordinary body", + Vec::new(), + "session", + Some("/workspace/custom".into()), + ); + store.insert_record(&record).unwrap(); + + let source_id = Uuid::parse_str("018f45d0-0000-7000-8000-000000000451").unwrap(); + let source = CaptureSource { + id: source_id, + descriptor: CaptureSourceDescriptor { + kind: CaptureSourceKind::ProviderImport, + provider: CaptureProvider::Custom, + machine_id: "machine-1".into(), + process_id: None, + cwd: Some("/workspace/custom".into()), + raw_source_path: Some("/tmp/dorkos-plugin/ctx-history-plugin.json".into()), + external_session_id: Some("ctx-history-jsonl-v1-session".into()), + }, + started_at: fixed_time(), + ended_at: None, + sync: SyncMetadata { + metadata: serde_json::json!({ + "ctx_history_plugin": { + "plugin_name": "dorkos", + "plugin_source_id": "default", + "history_source": "dorkos/default" + }, + "ctx_history_jsonl_v1": { + "provider_key": "dorkos", + "source_id": "default", + "source_format": "dorkos-history-v1" + } + }), + ..sync_metadata() + }, + }; + store.upsert_capture_source(&source).unwrap(); + + let session = Session { + id: Uuid::parse_str("018f45d0-0000-7000-8000-000000000452").unwrap(), + history_record_id: Some(record.id), + parent_session_id: None, + root_session_id: None, + capture_source_id: Some(source_id), + provider: CaptureProvider::Custom, + external_session_id: Some("ctx-history-jsonl-v1-session".into()), + external_agent_id: None, + agent_type: AgentType::Primary, + role_hint: Some("primary".into()), + is_primary: true, + status: SessionStatus::Imported, + transcript_blob_id: None, + started_at: fixed_time(), + ended_at: None, + timestamps: timestamps(), + sync: sync_metadata(), + }; + store.upsert_session(&session).unwrap(); + + let event = Event { + id: Uuid::parse_str("018f45d0-0000-7000-8000-000000000453").unwrap(), + seq: 451, + history_record_id: Some(record.id), + session_id: Some(session.id), + run_id: None, + event_type: EventType::Message, + role: Some(EventRole::Assistant), + occurred_at: fixed_time(), + capture_source_id: Some(source_id), + payload: serde_json::json!({ + "body": { + "text": "dorkos-source-filter-needle" + } + }), + payload_blob_id: None, + dedupe_key: Some("custom-history-source-filter-event".into()), + redaction_state: RedactionState::SafePreview, + sync: sync_metadata(), + }; + store.upsert_event(&event).unwrap(); + store.upsert_record(&record).unwrap(); + + let packet = search_packet( + &store, + "dorkos-source-filter-needle", + &PacketOptions { + limit: 10, + filters: SearchFilters { + provider: Some(CaptureProvider::Custom), + history_source: Some("dorkos/default".into()), + ..SearchFilters::default() + }, + ..PacketOptions::default() + }, + ) + .unwrap(); + + assert_eq!(packet.results.len(), 1); + let result = &packet.results[0]; + assert_eq!(result.provider, Some(CaptureProvider::Custom)); + assert_eq!(result.history_source.as_deref(), Some("dorkos/default")); + assert_eq!(result.history_source_plugin.as_deref(), Some("dorkos")); + assert_eq!(result.provider_key.as_deref(), Some("dorkos")); + assert_eq!(result.source_id.as_deref(), Some("default")); + assert_eq!(result.source_format.as_deref(), Some("dorkos-history-v1")); + + let provider_source_packet = search_packet( + &store, + "dorkos-source-filter-needle", + &PacketOptions { + limit: 10, + filters: SearchFilters { + provider: Some(CaptureProvider::Custom), + provider_key: Some("dorkos".into()), + source_id: Some("default".into()), + source_format: Some("dorkos-history-v1".into()), + ..SearchFilters::default() + }, + ..PacketOptions::default() + }, + ) + .unwrap(); + assert_eq!(provider_source_packet.results.len(), 1); + + let wrong_source = search_packet( + &store, + "dorkos-source-filter-needle", + &PacketOptions { + limit: 10, + filters: SearchFilters { + provider: Some(CaptureProvider::Custom), + history_source: Some("openclaw/default".into()), + ..SearchFilters::default() + }, + ..PacketOptions::default() + }, + ) + .unwrap(); + assert!(wrong_source.results.is_empty()); + } + + #[test] + fn fast_event_search_exposes_custom_history_source_identity() { + let (_temp, store) = test_store(); + let record = HistoryRecord::new( + "Large custom plugin import", + "ordinary body", + Vec::new(), + "agent_history", + Some("/workspace/custom".into()), + ); + store.insert_record(&record).unwrap(); + + let source_id = Uuid::parse_str("018f45d0-0000-7000-8000-000000000481").unwrap(); + store + .upsert_capture_source(&CaptureSource { + id: source_id, + descriptor: CaptureSourceDescriptor { + kind: CaptureSourceKind::ProviderImport, + provider: CaptureProvider::Custom, + machine_id: "machine-1".into(), + process_id: None, + cwd: Some("/workspace/custom".into()), + raw_source_path: Some("/tmp/large-dorkos/ctx-history-plugin.json".into()), + external_session_id: Some("ctx-history-jsonl-v1-large".into()), + }, + started_at: fixed_time(), + ended_at: None, + sync: SyncMetadata { + metadata: serde_json::json!({ + "source_metadata": { + "ctx_history_plugin": { + "plugin_name": "dorkos", + "plugin_source_id": "default", + "history_source": "dorkos/default" + }, + "ctx_history_jsonl_v1": { + "provider_key": "dorkos", + "source_id": "default", + "source_format": "dorkos-history-v1" + } + } + }), + ..sync_metadata() + }, + }) + .unwrap(); + + let session = Session { + id: Uuid::parse_str("018f45d0-0000-7000-8000-000000000482").unwrap(), + history_record_id: Some(record.id), + parent_session_id: None, + root_session_id: None, + capture_source_id: Some(source_id), + provider: CaptureProvider::Custom, + external_session_id: Some("ctx-history-jsonl-v1-large".into()), + external_agent_id: None, + agent_type: AgentType::Primary, + role_hint: Some("primary".into()), + is_primary: true, + status: SessionStatus::Imported, + transcript_blob_id: None, + started_at: fixed_time(), + ended_at: None, + timestamps: timestamps(), + sync: sync_metadata(), + }; + store.upsert_session(&session).unwrap(); + + let target_event_id = Uuid::parse_str("018f45d0-0000-7000-8000-000000000483").unwrap(); + for index in 0..=(LARGE_EVENT_CORPUS_THRESHOLD as u64) { + let event_id = if index == LARGE_EVENT_CORPUS_THRESHOLD as u64 { + target_event_id + } else { + Uuid::parse_str(&format!("018f45d0-0000-7000-8000-0000002{index:05x}")).unwrap() + }; + let text = if event_id == target_event_id { + "large-custom-source-identity-needle" + } else { + "ordinary large custom event" + }; + store + .upsert_event(&Event { + id: event_id, + seq: 40_000 + index, + history_record_id: Some(record.id), + session_id: Some(session.id), + run_id: None, + event_type: EventType::Message, + role: Some(EventRole::Assistant), + occurred_at: fixed_time() + chrono::Duration::milliseconds(index as i64), + capture_source_id: Some(source_id), + payload: serde_json::json!({ + "body": { "text": text } + }), + payload_blob_id: None, + dedupe_key: Some(format!("large-custom-source-identity-{index}")), + redaction_state: RedactionState::SafePreview, + sync: sync_metadata(), + }) + .unwrap(); + } + store.refresh_search_index().unwrap(); + + let packet = search_packet( + &store, + "large-custom-source-identity-needle", + &PacketOptions { + limit: 5, + filters: SearchFilters { + provider: Some(CaptureProvider::Custom), + ..SearchFilters::default() + }, + ..PacketOptions::default() + }, + ) + .unwrap(); + + assert_eq!(packet.results.len(), 1); + let result = &packet.results[0]; + assert_eq!(result.event_id, Some(target_event_id)); + assert_eq!(result.history_source.as_deref(), Some("dorkos/default")); + assert_eq!(result.history_source_plugin.as_deref(), Some("dorkos")); + assert_eq!(result.provider_key.as_deref(), Some("dorkos")); + assert_eq!(result.source_id.as_deref(), Some("default")); + assert_eq!(result.source_format.as_deref(), Some("dorkos-history-v1")); + } + #[test] fn filtered_search_pages_past_fts_decoys() { let (_temp, store) = test_store(); @@ -3329,6 +4238,178 @@ mod tests { assert!(packet.results.is_empty()); } + #[test] + fn file_only_search_finds_old_sparse_file_touch_beyond_recent_scan_budget() { + let (_temp, store) = test_store(); + let old_time = fixed_time() - chrono::Duration::days(30); + let target_id = Uuid::parse_str("018f45d0-0000-7000-8003-ffffffffffff").unwrap(); + let mut target = HistoryRecord::new( + "Old sparse file touch", + "older session that only relates through file touch scope", + Vec::new(), + "task", + Some("/workspace/ctx".into()), + ); + target.id = target_id; + target.created_at = old_time; + target.updated_at = old_time; + store.upsert_record(&target).unwrap(); + store + .upsert_file_touched(&FileTouched { + id: Uuid::parse_str("018f45d0-0000-7000-8003-fffffffffffe").unwrap(), + history_record_id: Some(target_id), + run_id: None, + event_id: None, + vcs_workspace_id: None, + path: "crates/ctx-history-search/src/sparse_history.rs".into(), + change_kind: Some(FileChangeKind::Modified), + old_path: None, + line_count_delta: Some(1), + confidence: Confidence::Explicit, + timestamps: EntityTimestamps { + created_at: old_time, + updated_at: old_time, + }, + source_id: None, + sync: sync_metadata(), + }) + .unwrap(); + + let mut decoys = Vec::new(); + for index in 0..=(FILTERED_SEARCH_PAGE_SIZE * FILTERED_SEARCH_MAX_PAGES) { + let decoy_time = fixed_time() + chrono::Duration::seconds(index as i64); + let mut decoy = HistoryRecord::new( + "Recent unrelated session", + format!("recent non-file decoy {index:05}"), + Vec::new(), + "task", + Some("/workspace/other".into()), + ); + decoy.id = Uuid::parse_str(&format!("018f45d0-0000-7000-8004-{index:012x}")).unwrap(); + decoy.created_at = decoy_time; + decoy.updated_at = decoy_time; + decoys.push(decoy); + } + store.upsert_records(&decoys).unwrap(); + + let old_scan_window = store + .list_records_page(FILTERED_SEARCH_PAGE_SIZE * FILTERED_SEARCH_MAX_PAGES, 0) + .unwrap(); + assert!( + !old_scan_window.iter().any(|record| record.id == target_id), + "regression setup must place the file match beyond the old recent-record scan window" + ); + + let packet = search_packet( + &store, + "", + &PacketOptions { + limit: 5, + filters: SearchFilters { + file: Some("sparse_history.rs".into()), + ..SearchFilters::default() + }, + ..PacketOptions::default() + }, + ) + .unwrap(); + + assert_eq!( + packet + .results + .iter() + .map(|result| result.record_id) + .collect::>(), + vec![target_id] + ); + assert!(!packet.truncation.truncated); + assert!(packet.results[0] + .why_matched + .iter() + .any(|reason| reason == "file_touched")); + } + + #[test] + fn search_ignores_agent_history_bookkeeping_terms_without_content_evidence() { + let (_temp, store) = test_store(); + let mut record = HistoryRecord::new( + "codex agent history", + "Indexed local agent history from /tmp/codex/sessions.jsonl (codex_session_jsonl)", + vec!["agent-history".into(), "codex".into()], + "agent_history", + Some("/tmp/codex".into()), + ); + record.id = Uuid::parse_str("018f45d0-0000-7000-8005-000000000001").unwrap(); + record.created_at = fixed_time(); + record.updated_at = fixed_time(); + store.upsert_record(&record).unwrap(); + + for query in [ + "Indexed local agent history", + "agent-history", + "codex_session_jsonl", + ] { + let packet = search_packet(&store, query, &PacketOptions::default()).unwrap(); + assert!( + packet.results.is_empty(), + "bookkeeping-only query {query:?} returned {:?}", + packet.results + ); + } + + let session = Session { + id: Uuid::parse_str("018f45d0-0000-7000-8005-000000000002").unwrap(), + history_record_id: Some(record.id), + parent_session_id: None, + root_session_id: None, + capture_source_id: None, + provider: CaptureProvider::Codex, + external_session_id: Some("bookkeeping-content-session".into()), + external_agent_id: None, + agent_type: AgentType::Primary, + role_hint: Some("primary".into()), + is_primary: true, + status: SessionStatus::Imported, + transcript_blob_id: None, + started_at: fixed_time(), + ended_at: None, + timestamps: timestamps(), + sync: sync_metadata(), + }; + store.upsert_session(&session).unwrap(); + let event = Event { + id: Uuid::parse_str("018f45d0-0000-7000-8005-000000000003").unwrap(), + seq: 1, + history_record_id: Some(record.id), + session_id: Some(session.id), + run_id: None, + event_type: EventType::Message, + role: Some(EventRole::Assistant), + occurred_at: fixed_time(), + capture_source_id: None, + payload: serde_json::json!({ + "text": "actual agent-history session evidence" + }), + payload_blob_id: None, + dedupe_key: None, + redaction_state: RedactionState::SafePreview, + sync: sync_metadata(), + }; + store.upsert_event(&event).unwrap(); + + let packet = search_packet(&store, "agent-history", &PacketOptions::default()).unwrap(); + assert_eq!(packet.results.len(), 1); + assert_eq!(packet.results[0].event_id, Some(event.id)); + assert!(packet.results[0] + .why_matched + .iter() + .any(|reason| reason == "message")); + assert!(!packet.results[0] + .why_matched + .iter() + .any(|reason| reason == "title" || reason == "tag")); + } + #[test] fn filtered_search_stops_at_scan_budget_when_no_candidates_match() { let (_temp, store) = test_store(); @@ -3369,7 +4450,7 @@ mod tests { } #[test] - fn empty_query_filtered_search_stops_at_scan_budget() { + fn empty_query_filtered_search_returns_empty_without_scanning() { let (_temp, store) = test_store(); let mut records = Vec::new(); for index in 0..=(FILTERED_SEARCH_PAGE_SIZE * FILTERED_SEARCH_MAX_PAGES) { @@ -3402,8 +4483,29 @@ mod tests { .unwrap(); assert!(packet.results.is_empty()); - assert!(packet.truncation.truncated); - assert_eq!(packet.truncation.reason.as_deref(), Some("scan_budget")); + assert!(!packet.truncation.truncated); + assert_eq!(packet.truncation.reason.as_deref(), None); + } + + #[test] + fn no_token_query_returns_empty_without_recent_activity() { + let (_temp, store) = test_store(); + let record = HistoryRecord::new( + "No-token query decoy", + "This record should not be returned for punctuation-only search.", + Vec::new(), + "task", + Some("/workspace/punctuation".into()), + ); + store.upsert_record(&record).unwrap(); + + for query in ["!!!", "---", "___"] { + let packet = + search_packet(&store, query, &PacketOptions::default()).expect("search packet"); + + assert!(packet.results.is_empty(), "{query}"); + assert!(!packet.truncation.truncated, "{query}"); + } } #[test] diff --git a/crates/ctx-history-store/Cargo.toml b/crates/ctx-history-store/Cargo.toml index 1b884d52e..3557c20a1 100644 --- a/crates/ctx-history-store/Cargo.toml +++ b/crates/ctx-history-store/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ctx-history-store" -version = "0.12.0" +version = "0.19.0" description = "Internal SQLite storage layer for ctx local agent history" edition.workspace = true license.workspace = true diff --git a/crates/ctx-history-store/src/lib.rs b/crates/ctx-history-store/src/lib.rs index 3f10be35b..f31ebb283 100644 --- a/crates/ctx-history-store/src/lib.rs +++ b/crates/ctx-history-store/src/lib.rs @@ -14,7 +14,7 @@ use std::os::unix::fs::PermissionsExt; use chrono::{DateTime, Utc}; use ctx_history_core::{ - new_id, AgentType, Artifact, ArtifactKind, CaptureProvider, CaptureSource, + new_id, utc_now, AgentType, Artifact, ArtifactKind, CaptureProvider, CaptureSource, CaptureSourceDescriptor, EntityTimestamps, Event, EventRole, EventType, Fidelity, FileTouched, HistoryRecord, HistoryRecordLink, RedactionState, Run, RunStatus, RunType, Session, SessionEdge, SessionHistoryArchive, SessionStatus, Summary, SyncCursor, SyncMetadata, @@ -87,13 +87,18 @@ pub enum StoreError { min: usize, max: usize, }, + #[error("SQL result preview budget {estimated_bytes} bytes exceeds maximum {max_result_bytes}; lower max_rows, max_columns, or max_value_bytes")] + RawSqlResultBudgetTooLarge { + estimated_bytes: usize, + max_result_bytes: usize, + }, #[error("SQL query timed out after {timeout_ms}ms")] RawSqlTimedOut { timeout_ms: u64 }, } pub type Result = std::result::Result; -const SCHEMA_VERSION: i64 = 13; +const SCHEMA_VERSION: i64 = 15; const BUSY_TIMEOUT: Duration = Duration::from_millis(30_000); const OBJECTS_DIR: &str = "objects"; const SPOOL_DIR: &str = "spool"; @@ -106,6 +111,8 @@ pub const RAW_SQL_DEFAULT_MAX_COLUMNS: usize = 64; pub const RAW_SQL_MAX_COLUMNS_CAP: usize = 256; pub const RAW_SQL_DEFAULT_MAX_VALUE_BYTES: usize = 512; pub const RAW_SQL_MAX_VALUE_BYTES_CAP: usize = 1_048_576; +pub const RAW_SQL_MAX_RESULT_PREVIEW_BYTES: usize = 64 * 1024 * 1024; +pub const RAW_SQL_MAX_RESULT_CELLS: usize = 262_144; const RAW_SQL_MIN_SQLITE_LENGTH_LIMIT_BYTES: usize = 64 * 1024; const RAW_SQL_VALUE_LENGTH_MARGIN_BYTES: usize = 1024; pub const RAW_SQL_DEFAULT_MAX_SQL_BYTES: usize = 64 * 1024; @@ -235,14 +242,18 @@ pub struct CatalogSourceIndexUpdate<'a> { pub source_path: &'a str, pub file_size_bytes: u64, pub file_modified_at_ms: i64, - pub event_count: u64, + pub file_sha256: Option<&'a str>, + pub event_count: Option, pub indexed_at_ms: i64, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct CatalogSourceIndexState { - pub indexed_file_size_bytes: Option, - pub indexed_file_modified_at_ms: Option, + pub last_imported_file_size_bytes: Option, + pub last_imported_file_modified_at_ms: Option, + pub last_imported_event_count: Option, + pub last_imported_at_ms: Option, + pub last_imported_file_sha256: Option, } #[derive(Debug, Clone, PartialEq)] @@ -275,6 +286,18 @@ pub struct CatalogCounts { pub failed: usize, } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct IndexedHistoryCounts { + pub sessions: usize, + pub events: usize, +} + +impl IndexedHistoryCounts { + pub fn items(self) -> usize { + self.sessions.saturating_add(self.events) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CatalogIndexedStatus { Pending, @@ -308,6 +331,11 @@ pub struct EventSearchHit { pub score: f64, pub provider: Option, pub session_external_session_id: Option, + pub history_source: Option, + pub history_source_plugin: Option, + pub provider_key: Option, + pub source_id: Option, + pub source_format: Option, pub agent_type: Option, pub session_is_primary: Option, pub cwd: Option, @@ -429,13 +457,33 @@ const CATALOG_SESSION_IMPORT_STATE_COLUMNS: &[ColumnSpec] = &[ name: "indexed_event_count", definition: "indexed_event_count INTEGER", }, + ColumnSpec { + name: "last_imported_at_ms", + definition: "last_imported_at_ms INTEGER", + }, + ColumnSpec { + name: "last_imported_file_size_bytes", + definition: "last_imported_file_size_bytes INTEGER", + }, + ColumnSpec { + name: "last_imported_file_modified_at_ms", + definition: "last_imported_file_modified_at_ms INTEGER", + }, + ColumnSpec { + name: "last_imported_file_sha256", + definition: "last_imported_file_sha256 TEXT", + }, + ColumnSpec { + name: "last_imported_event_count", + definition: "last_imported_event_count INTEGER", + }, ]; const CREATE_TABLES_SQL: &str = r#" CREATE TABLE IF NOT EXISTS capture_sources ( id TEXT PRIMARY KEY NOT NULL, kind TEXT NOT NULL CHECK (kind IN ('provider_import', 'provider_hook', 'direct_cli', 'manual')), - provider TEXT NOT NULL CHECK (provider IN ('codex', 'claude', 'pi', 'opencode', 'antigravity', 'gemini', 'cursor', 'copilot_cli', 'factory_ai_droid', 'shell', 'git', 'jj', 'gh', 'unknown')), + provider TEXT NOT NULL CHECK (provider IN ('codex', 'claude', 'pi', 'opencode', 'antigravity', 'gemini', 'cursor', 'copilot_cli', 'factory_ai_droid', 'openclaw', 'hermes', 'nanoclaw', 'astrbot', 'shelley', 'shell', 'git', 'jj', 'gh', 'custom', 'unknown')), machine_id TEXT NOT NULL, process_id INTEGER, cwd TEXT, @@ -452,7 +500,7 @@ CREATE TABLE IF NOT EXISTS capture_sources ( CREATE TABLE IF NOT EXISTS catalog_sessions ( source_path TEXT PRIMARY KEY NOT NULL, - provider TEXT NOT NULL CHECK (provider IN ('codex', 'claude', 'pi', 'opencode', 'antigravity', 'gemini', 'cursor', 'copilot_cli', 'factory_ai_droid', 'shell', 'git', 'jj', 'gh', 'unknown')), + provider TEXT NOT NULL CHECK (provider IN ('codex', 'claude', 'pi', 'opencode', 'antigravity', 'gemini', 'cursor', 'copilot_cli', 'factory_ai_droid', 'openclaw', 'hermes', 'nanoclaw', 'astrbot', 'shelley', 'shell', 'git', 'jj', 'gh', 'custom', 'unknown')), source_format TEXT NOT NULL, source_root TEXT NOT NULL, external_session_id TEXT, @@ -472,11 +520,16 @@ CREATE TABLE IF NOT EXISTS catalog_sessions ( indexed_status TEXT NOT NULL DEFAULT 'pending' CHECK (indexed_status IN ('pending', 'indexed', 'failed')), indexed_error TEXT, indexed_event_count INTEGER, + last_imported_at_ms INTEGER, + last_imported_file_size_bytes INTEGER, + last_imported_file_modified_at_ms INTEGER, + last_imported_file_sha256 TEXT, + last_imported_event_count INTEGER, metadata_json TEXT NOT NULL DEFAULT '{}' ); CREATE TABLE IF NOT EXISTS source_import_files ( - provider TEXT NOT NULL CHECK (provider IN ('codex', 'claude', 'pi', 'opencode', 'antigravity', 'gemini', 'cursor', 'copilot_cli', 'factory_ai_droid', 'shell', 'git', 'jj', 'gh', 'unknown')), + provider TEXT NOT NULL CHECK (provider IN ('codex', 'claude', 'pi', 'opencode', 'antigravity', 'gemini', 'cursor', 'copilot_cli', 'factory_ai_droid', 'openclaw', 'hermes', 'nanoclaw', 'astrbot', 'shelley', 'shell', 'git', 'jj', 'gh', 'custom', 'unknown')), source_format TEXT NOT NULL, source_root TEXT NOT NULL, source_path TEXT NOT NULL, @@ -927,6 +980,8 @@ CREATE INDEX IF NOT EXISTS idx_local_workspaces_vcs_workspace_id ON local_worksp CREATE INDEX IF NOT EXISTS idx_audit_log_source_id ON audit_log(source_id); "#; +// `safe_preview_text` is legacy schema naming. It stores local searchable +// preview text and must not be interpreted as share-safe redaction. const FTS_TABLES_SQL: &str = r#" CREATE VIRTUAL TABLE IF NOT EXISTS ctx_history_search USING fts5( record_id UNINDEXED, @@ -1010,16 +1065,27 @@ SELECT ft.line_count_delta AS line_count_delta, ft.confidence AS confidence, ft.event_id AS ctx_event_id, - COALESCE(e.session_id, r.session_id) AS ctx_session_id, - COALESCE(e.history_record_id, r.history_record_id, ft.history_record_id) AS history_record_id, - s.provider AS provider, - s.external_session_id AS provider_session_id, + COALESCE(e.session_id, r.session_id, source_session.id) AS ctx_session_id, + COALESCE( + e.history_record_id, + r.history_record_id, + ft.history_record_id, + event_session.history_record_id, + run_session.history_record_id, + source_session.history_record_id + ) AS history_record_id, + COALESCE(s.provider, cs.provider) AS provider, + COALESCE(s.external_session_id, cs.external_session_id) AS provider_session_id, ft.created_at_ms AS created_at_ms, ft.updated_at_ms AS updated_at_ms FROM files_touched ft LEFT JOIN events e ON e.id = ft.event_id LEFT JOIN runs r ON r.id = ft.run_id -LEFT JOIN sessions s ON s.id = COALESCE(e.session_id, r.session_id) +LEFT JOIN capture_sources cs ON cs.id = ft.source_id +LEFT JOIN sessions event_session ON event_session.id = e.session_id +LEFT JOIN sessions run_session ON run_session.id = r.session_id +LEFT JOIN sessions source_session ON source_session.capture_source_id = ft.source_id +LEFT JOIN sessions s ON s.id = COALESCE(e.session_id, r.session_id, source_session.id) WHERE ft.deleted_at_ms IS NULL; DROP VIEW IF EXISTS ctx_sources; @@ -1043,6 +1109,11 @@ SELECT indexed_status AS indexed_status, indexed_error AS indexed_error, indexed_event_count AS indexed_event_count, + last_imported_at_ms AS last_imported_at_ms, + last_imported_file_size_bytes AS last_imported_file_size_bytes, + last_imported_file_modified_at_ms AS last_imported_file_modified_at_ms, + last_imported_file_sha256 AS last_imported_file_sha256, + last_imported_event_count AS last_imported_event_count, is_stale AS is_stale FROM catalog_sessions; "#; @@ -1145,6 +1216,7 @@ impl Store { max_columns: options.max_columns, }); } + validate_raw_sql_result_preview_budget(&options, column_count)?; let columns = stmt .column_names() @@ -1325,6 +1397,12 @@ impl Store { if user_version < 13 { migrate_to_v13(&self.conn)?; } + if user_version < 14 { + migrate_to_v14(&self.conn)?; + } + if user_version < 15 { + migrate_to_v15(&self.conn)?; + } create_fts_tables_if_supported(&self.conn)?; Ok(()) } @@ -1502,19 +1580,19 @@ impl Store { WHEN catalog_sessions.file_size_bytes = excluded.file_size_bytes AND catalog_sessions.file_modified_at_ms = excluded.file_modified_at_ms THEN catalog_sessions.indexed_at_ms - ELSE catalog_sessions.indexed_at_ms + ELSE NULL END, indexed_file_size_bytes = CASE WHEN catalog_sessions.file_size_bytes = excluded.file_size_bytes AND catalog_sessions.file_modified_at_ms = excluded.file_modified_at_ms THEN catalog_sessions.indexed_file_size_bytes - ELSE catalog_sessions.indexed_file_size_bytes + ELSE NULL END, indexed_file_modified_at_ms = CASE WHEN catalog_sessions.file_size_bytes = excluded.file_size_bytes AND catalog_sessions.file_modified_at_ms = excluded.file_modified_at_ms THEN catalog_sessions.indexed_file_modified_at_ms - ELSE catalog_sessions.indexed_file_modified_at_ms + ELSE NULL END, indexed_status = CASE WHEN catalog_sessions.file_size_bytes = excluded.file_size_bytes @@ -1532,7 +1610,67 @@ impl Store { WHEN catalog_sessions.file_size_bytes = excluded.file_size_bytes AND catalog_sessions.file_modified_at_ms = excluded.file_modified_at_ms THEN catalog_sessions.indexed_event_count - ELSE catalog_sessions.indexed_event_count + ELSE NULL + END, + last_imported_at_ms = CASE + WHEN catalog_sessions.file_size_bytes = excluded.file_size_bytes + AND catalog_sessions.file_modified_at_ms = excluded.file_modified_at_ms + THEN catalog_sessions.last_imported_at_ms + WHEN excluded.file_size_bytes > catalog_sessions.file_size_bytes + AND catalog_sessions.indexed_status = 'indexed' + AND catalog_sessions.indexed_file_size_bytes = catalog_sessions.file_size_bytes + AND catalog_sessions.indexed_file_modified_at_ms = catalog_sessions.file_modified_at_ms + AND catalog_sessions.last_imported_file_size_bytes = catalog_sessions.file_size_bytes + THEN catalog_sessions.last_imported_at_ms + ELSE NULL + END, + last_imported_file_size_bytes = CASE + WHEN catalog_sessions.file_size_bytes = excluded.file_size_bytes + AND catalog_sessions.file_modified_at_ms = excluded.file_modified_at_ms + THEN catalog_sessions.last_imported_file_size_bytes + WHEN excluded.file_size_bytes > catalog_sessions.file_size_bytes + AND catalog_sessions.indexed_status = 'indexed' + AND catalog_sessions.indexed_file_size_bytes = catalog_sessions.file_size_bytes + AND catalog_sessions.indexed_file_modified_at_ms = catalog_sessions.file_modified_at_ms + AND catalog_sessions.last_imported_file_size_bytes = catalog_sessions.file_size_bytes + THEN catalog_sessions.last_imported_file_size_bytes + ELSE NULL + END, + last_imported_file_modified_at_ms = CASE + WHEN catalog_sessions.file_size_bytes = excluded.file_size_bytes + AND catalog_sessions.file_modified_at_ms = excluded.file_modified_at_ms + THEN catalog_sessions.last_imported_file_modified_at_ms + WHEN excluded.file_size_bytes > catalog_sessions.file_size_bytes + AND catalog_sessions.indexed_status = 'indexed' + AND catalog_sessions.indexed_file_size_bytes = catalog_sessions.file_size_bytes + AND catalog_sessions.indexed_file_modified_at_ms = catalog_sessions.file_modified_at_ms + AND catalog_sessions.last_imported_file_size_bytes = catalog_sessions.file_size_bytes + THEN catalog_sessions.last_imported_file_modified_at_ms + ELSE NULL + END, + last_imported_file_sha256 = CASE + WHEN catalog_sessions.file_size_bytes = excluded.file_size_bytes + AND catalog_sessions.file_modified_at_ms = excluded.file_modified_at_ms + THEN catalog_sessions.last_imported_file_sha256 + WHEN excluded.file_size_bytes > catalog_sessions.file_size_bytes + AND catalog_sessions.indexed_status = 'indexed' + AND catalog_sessions.indexed_file_size_bytes = catalog_sessions.file_size_bytes + AND catalog_sessions.indexed_file_modified_at_ms = catalog_sessions.file_modified_at_ms + AND catalog_sessions.last_imported_file_size_bytes = catalog_sessions.file_size_bytes + THEN catalog_sessions.last_imported_file_sha256 + ELSE NULL + END, + last_imported_event_count = CASE + WHEN catalog_sessions.file_size_bytes = excluded.file_size_bytes + AND catalog_sessions.file_modified_at_ms = excluded.file_modified_at_ms + THEN catalog_sessions.last_imported_event_count + WHEN excluded.file_size_bytes > catalog_sessions.file_size_bytes + AND catalog_sessions.indexed_status = 'indexed' + AND catalog_sessions.indexed_file_size_bytes = catalog_sessions.file_size_bytes + AND catalog_sessions.indexed_file_modified_at_ms = catalog_sessions.file_modified_at_ms + AND catalog_sessions.last_imported_file_size_bytes = catalog_sessions.file_size_bytes + THEN catalog_sessions.last_imported_event_count + ELSE NULL END, metadata_json = excluded.metadata_json WHERE catalog_sessions.provider IS NOT excluded.provider @@ -1689,7 +1827,12 @@ impl Store { indexed_file_modified_at_ms = ?6, indexed_status = ?8, indexed_error = NULL, - indexed_event_count = ?7 + indexed_event_count = ?7, + last_imported_at_ms = ?4, + last_imported_file_size_bytes = ?5, + last_imported_file_modified_at_ms = ?6, + last_imported_file_sha256 = ?9, + last_imported_event_count = ?7 WHERE provider = ?1 AND source_root = ?2 AND source_path = ?3 @@ -1702,8 +1845,9 @@ impl Store { update.indexed_at_ms, capped_i64(update.file_size_bytes), update.file_modified_at_ms, - capped_i64(update.event_count), + update.event_count.map(capped_i64), CatalogIndexedStatus::Indexed.as_str(), + update.file_sha256, ], )?; Ok(changed) @@ -1752,7 +1896,11 @@ impl Store { self.conn .query_row( r#" - SELECT indexed_file_size_bytes, indexed_file_modified_at_ms + SELECT last_imported_file_size_bytes, + last_imported_file_modified_at_ms, + last_imported_event_count, + last_imported_at_ms, + last_imported_file_sha256 FROM catalog_sessions WHERE provider = ?1 AND source_root = ?2 @@ -1761,13 +1909,20 @@ impl Store { "#, params![provider.as_str(), source_root, source_path], |row| { - let indexed_file_size_bytes = row + let last_imported_file_size_bytes = row .get::<_, Option>(0)? .map(nonnegative_i64_to_u64) .transpose()?; + let last_imported_event_count = row + .get::<_, Option>(2)? + .map(nonnegative_i64_to_u64) + .transpose()?; Ok(CatalogSourceIndexState { - indexed_file_size_bytes, - indexed_file_modified_at_ms: row.get(1)?, + last_imported_file_size_bytes, + last_imported_file_modified_at_ms: row.get(1)?, + last_imported_event_count, + last_imported_at_ms: row.get(3)?, + last_imported_file_sha256: row.get(4)?, }) }, ) @@ -2103,6 +2258,14 @@ impl Store { .ok_or(StoreError::NotFound(id)) } + pub fn sessions_by_id_prefix(&self, prefix: &str) -> Result> { + let mut stmt = self + .conn + .prepare(session_select_sql("WHERE id LIKE ?1 ORDER BY id LIMIT 2").as_str())?; + let rows = stmt.query_map(params![format!("{prefix}%")], session_from_row)?; + collect_rows(rows) + } + pub fn session_by_external_session( &self, provider: CaptureProvider, @@ -2121,6 +2284,29 @@ impl Store { .map_err(StoreError::from) } + pub fn sessions_by_external_session_limited( + &self, + provider: CaptureProvider, + external_session_id: &str, + limit: usize, + ) -> Result> { + let mut stmt = self.conn.prepare( + session_select_sql( + "WHERE provider = ?1 AND external_session_id = ?2 ORDER BY started_at_ms DESC LIMIT ?3", + ) + .as_str(), + )?; + let rows = stmt.query_map( + params![ + provider.as_str(), + external_session_id, + i64::try_from(limit).unwrap_or(i64::MAX) + ], + session_from_row, + )?; + collect_rows(rows) + } + pub fn sessions_for_record(&self, record_id: Uuid) -> Result> { let mut stmt = self.conn.prepare( session_select_sql("WHERE history_record_id = ?1 ORDER BY started_at_ms, id").as_str(), @@ -2154,13 +2340,20 @@ impl Store { } pub fn indexed_history_item_count(&self) -> Result { + Ok(self.indexed_history_counts()?.items()) + } + + pub fn indexed_history_counts(&self) -> Result { let sessions: i64 = self .conn .query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get(0))?; let events: i64 = self .conn .query_row("SELECT COUNT(*) FROM events", [], |row| row.get(0))?; - Ok((sessions as usize).saturating_add(events as usize)) + Ok(IndexedHistoryCounts { + sessions: sessions as usize, + events: events as usize, + }) } pub fn upsert_session_edge(&self, edge: &SessionEdge) -> Result<()> { @@ -2362,6 +2555,14 @@ impl Store { ) } + pub fn provider_source_event_dedupe_key( + source_id: Uuid, + provider_index: u64, + payload_hash: &str, + ) -> String { + format!("provider-source:{source_id}:{provider_index}:{payload_hash}") + } + pub fn upsert_event(&self, event: &Event) -> Result { let event_id = if let Some(dedupe_key) = &event.dedupe_key { reject_provider_event_hash_conflict(&self.conn, dedupe_key)?; @@ -2498,6 +2699,14 @@ impl Store { .ok_or(StoreError::NotFound(id)) } + pub fn events_by_id_prefix(&self, prefix: &str) -> Result> { + let mut stmt = self + .conn + .prepare(event_select_sql("WHERE id LIKE ?1 ORDER BY id LIMIT 2").as_str())?; + let rows = stmt.query_map(params![format!("{prefix}%")], event_from_row)?; + collect_rows(rows) + } + pub fn events_for_session(&self, session_id: Uuid) -> Result> { let mut stmt = self.conn.prepare( event_select_sql("WHERE session_id = ?1 ORDER BY seq, occurred_at_ms").as_str(), @@ -2506,6 +2715,73 @@ impl Store { collect_rows(rows) } + pub fn events_for_session_limited(&self, session_id: Uuid, limit: usize) -> Result> { + let mut stmt = self.conn.prepare( + event_select_sql("WHERE session_id = ?1 ORDER BY seq, occurred_at_ms LIMIT ?2") + .as_str(), + )?; + let rows = stmt.query_map( + params![ + session_id.to_string(), + i64::try_from(limit).unwrap_or(i64::MAX) + ], + event_from_row, + )?; + collect_rows(rows) + } + + pub fn events_for_session_window( + &self, + event: &Event, + before: usize, + after: usize, + ) -> Result> { + let Some(session_id) = event.session_id else { + return Ok(vec![event.clone()]); + }; + let event_seq = i64::try_from(event.seq).unwrap_or(i64::MAX); + let mut events = if before == 0 { + Vec::new() + } else { + let mut stmt = self.conn.prepare( + event_select_sql( + "WHERE session_id = ?1 AND seq < ?2 ORDER BY seq DESC, occurred_at_ms DESC LIMIT ?3", + ) + .as_str(), + )?; + let rows = stmt.query_map( + params![ + session_id.to_string(), + event_seq, + i64::try_from(before).unwrap_or(i64::MAX) + ], + event_from_row, + )?; + let mut rows = collect_rows(rows)?; + rows.reverse(); + rows + }; + events.push(event.clone()); + if after > 0 { + let mut stmt = self.conn.prepare( + event_select_sql( + "WHERE session_id = ?1 AND seq > ?2 ORDER BY seq, occurred_at_ms LIMIT ?3", + ) + .as_str(), + )?; + let rows = stmt.query_map( + params![ + session_id.to_string(), + event_seq, + i64::try_from(after).unwrap_or(i64::MAX) + ], + event_from_row, + )?; + events.extend(collect_rows(rows)?); + } + Ok(events) + } + pub fn events_for_record(&self, record_id: Uuid) -> Result> { let mut stmt = self.conn.prepare( event_select_sql( @@ -2648,7 +2924,7 @@ impl Store { if let Some(device) = self.local_device()? { return Ok(device); } - let now = Utc::now(); + let now = utc_now(); let device = LocalDeviceIdentity { id: new_id(), stable_device_id: format!("ctx-device-{}", new_id().simple()), @@ -2680,7 +2956,7 @@ impl Store { let root = root_path.as_ref(); let root_path_hash = sha256_hex(root.display().to_string().as_bytes()); let display_root = root.display().to_string(); - let now = Utc::now(); + let now = utc_now(); let id = new_id(); self.conn.execute( r#" @@ -2899,6 +3175,18 @@ impl Store { Ok(()) } + pub fn file_touched_exists(&self, id: Uuid) -> Result { + Ok(self + .conn + .query_row( + "SELECT 1 FROM files_touched WHERE id = ?1", + params![id.to_string()], + |_| Ok(()), + ) + .optional()? + .is_some()) + } + fn list_files_touched(&self) -> Result> { let mut stmt = self .conn @@ -3366,6 +3654,9 @@ impl Store { limit: usize, offset: usize, ) -> Result> { + if fts_match_query(query).is_none() { + return Ok(Vec::new()); + } if let Some(records) = self.search_records_fts(query, limit, offset)? { return Ok(records); } @@ -3390,7 +3681,7 @@ impl Store { return Ok(None); } let Some(match_query) = fts_match_query(query) else { - return Ok(Some(self.list_records_page(limit, offset)?)); + return Ok(Some(Vec::new())); }; let has_event_search = table_exists(&self.conn, "event_search")?; let has_artifact_search = table_exists(&self.conn, "artifact_search")?; @@ -3553,12 +3844,14 @@ impl Store { |row| { let payload_json = row.get::<_, String>(18)?; let source_metadata_json = row.get::<_, Option>(19)?; + let source_identity = + event_search_source_identity(source_metadata_json.as_deref())?; Ok(EventSearchHit { event_id: parse_uuid(row.get::<_, String>(0)?)?, history_record_id: parse_optional_uuid(row.get(1)?)?, session_id: parse_optional_uuid(row.get(2)?)?, run_id: parse_optional_uuid(row.get(3)?)?, - seq: row.get::<_, i64>(4)? as u64, + seq: nonnegative_i64_to_u64(row.get(4)?)?, event_type: parse_text_enum::(row.get::<_, String>(5)?)?, role: parse_optional_text_enum::(row.get(6)?)?, occurred_at: ms_to_time(row.get(7)?)?, @@ -3566,6 +3859,11 @@ impl Store { score: row.get(9)?, provider: parse_optional_text_enum::(row.get(10)?)?, session_external_session_id: row.get(11)?, + history_source: source_identity.history_source, + history_source_plugin: source_identity.history_source_plugin, + provider_key: source_identity.provider_key, + source_id: source_identity.source_id, + source_format: source_identity.source_format, session_parent_session_id: parse_optional_uuid(row.get(12)?)?, session_root_session_id: parse_optional_uuid(row.get(13)?)?, agent_type: parse_optional_text_enum::(row.get(14)?)?, @@ -3753,6 +4051,31 @@ fn validate_raw_sql_statement_bytes(sql: &str, options: &RawSqlOptions) -> Resul validate_raw_sql_usize("sql_bytes", sql.len(), 1, options.max_sql_bytes) } +fn validate_raw_sql_result_preview_budget( + options: &RawSqlOptions, + column_count: usize, +) -> Result<()> { + let estimated_cells = options.max_rows.saturating_mul(column_count); + let per_cell_bytes = options + .max_value_bytes + .saturating_mul(4) + .saturating_add(64) + .max(128); + let estimated_bytes = options + .max_rows + .saturating_mul(column_count) + .saturating_mul(per_cell_bytes); + if estimated_cells > RAW_SQL_MAX_RESULT_CELLS + || estimated_bytes > RAW_SQL_MAX_RESULT_PREVIEW_BYTES + { + return Err(StoreError::RawSqlResultBudgetTooLarge { + estimated_bytes, + max_result_bytes: RAW_SQL_MAX_RESULT_PREVIEW_BYTES, + }); + } + Ok(()) +} + struct RawSqlLimitGuard<'a> { conn: &'a Connection, length: i32, @@ -4686,6 +5009,83 @@ fn migrate_to_v13(conn: &Connection) -> Result<()> { } } +fn migrate_to_v14(conn: &Connection) -> Result<()> { + let foreign_keys_enabled: i64 = conn.query_row("PRAGMA foreign_keys", [], |row| row.get(0))?; + conn.execute_batch("PRAGMA foreign_keys = OFF; BEGIN IMMEDIATE;")?; + let migration = (|| -> Result<()> { + conn.execute_batch(CREATE_TABLES_SQL)?; + ensure_columns( + conn, + "catalog_sessions", + CATALOG_SESSION_IMPORT_STATE_COLUMNS, + )?; + rebuild_capture_sources_provider_check(conn)?; + rebuild_catalog_sessions_provider_check(conn)?; + rebuild_source_import_files_provider_check(conn)?; + backfill_catalog_session_import_checkpoints(conn)?; + create_stable_sql_views(conn)?; + conn.execute_batch(INDEXES_SQL)?; + conn.execute_batch("PRAGMA user_version = 14;")?; + Ok(()) + })(); + + match migration { + Ok(()) => { + conn.execute_batch("COMMIT;")?; + if foreign_keys_enabled != 0 { + conn.execute_batch("PRAGMA foreign_keys = ON;")?; + } + Ok(()) + } + Err(err) => { + if let Err(rollback_err) = conn.execute_batch("ROLLBACK;") { + return Err(StoreError::Sql(rollback_err)); + } + if foreign_keys_enabled != 0 { + conn.execute_batch("PRAGMA foreign_keys = ON;")?; + } + Err(err) + } + } +} + +fn migrate_to_v15(conn: &Connection) -> Result<()> { + let foreign_keys_enabled: i64 = conn.query_row("PRAGMA foreign_keys", [], |row| row.get(0))?; + conn.execute_batch("PRAGMA foreign_keys = OFF; BEGIN IMMEDIATE;")?; + let migration = (|| -> Result<()> { + conn.execute_batch(CREATE_TABLES_SQL)?; + if stable_sql_views_exist(conn)? { + drop_stable_sql_views(conn)?; + } + rebuild_capture_sources_provider_check(conn)?; + rebuild_catalog_sessions_provider_check(conn)?; + rebuild_source_import_files_provider_check(conn)?; + conn.execute_batch(INDEXES_SQL)?; + create_stable_sql_views(conn)?; + conn.execute_batch("PRAGMA user_version = 15;")?; + Ok(()) + })(); + + match migration { + Ok(()) => { + conn.execute_batch("COMMIT;")?; + if foreign_keys_enabled != 0 { + conn.execute_batch("PRAGMA foreign_keys = ON;")?; + } + Ok(()) + } + Err(err) => { + if let Err(rollback_err) = conn.execute_batch("ROLLBACK;") { + return Err(StoreError::Sql(rollback_err)); + } + if foreign_keys_enabled != 0 { + conn.execute_batch("PRAGMA foreign_keys = ON;")?; + } + Err(err) + } + } +} + fn create_stable_sql_views(conn: &Connection) -> Result<()> { conn.execute_batch(STABLE_SQL_VIEWS_SQL)?; Ok(()) @@ -4747,6 +5147,25 @@ fn invalidate_provider_import_indexes(conn: &Connection) -> Result<()> { Ok(()) } +fn backfill_catalog_session_import_checkpoints(conn: &Connection) -> Result<()> { + if !table_exists(conn, "catalog_sessions")? { + return Ok(()); + } + conn.execute( + r#" + UPDATE catalog_sessions + SET last_imported_at_ms = indexed_at_ms, + last_imported_file_size_bytes = indexed_file_size_bytes, + last_imported_file_modified_at_ms = indexed_file_modified_at_ms, + last_imported_event_count = indexed_event_count + WHERE last_imported_file_size_bytes IS NULL + AND indexed_file_size_bytes IS NOT NULL + "#, + [], + )?; + Ok(()) +} + fn drop_legacy_history_record_indexes(conn: &Connection) -> Result<()> { conn.execute_batch( r#" @@ -4833,7 +5252,7 @@ fn rebuild_capture_sources_provider_check(conn: &Connection) -> Result<()> { CREATE TABLE capture_sources_new ( id TEXT PRIMARY KEY NOT NULL, kind TEXT NOT NULL CHECK (kind IN ('provider_import', 'provider_hook', 'direct_cli', 'manual')), - provider TEXT NOT NULL CHECK (provider IN ('codex', 'claude', 'pi', 'opencode', 'antigravity', 'gemini', 'cursor', 'copilot_cli', 'factory_ai_droid', 'shell', 'git', 'jj', 'gh', 'unknown')), + provider TEXT NOT NULL CHECK (provider IN ('codex', 'claude', 'pi', 'opencode', 'antigravity', 'gemini', 'cursor', 'copilot_cli', 'factory_ai_droid', 'openclaw', 'hermes', 'nanoclaw', 'astrbot', 'shelley', 'shell', 'git', 'jj', 'gh', 'custom', 'unknown')), machine_id TEXT NOT NULL, process_id INTEGER, cwd TEXT, @@ -4871,12 +5290,17 @@ fn rebuild_catalog_sessions_provider_check(conn: &Connection) -> Result<()> { if recreate_views { drop_stable_sql_views(conn)?; } + ensure_columns( + conn, + "catalog_sessions", + CATALOG_SESSION_IMPORT_STATE_COLUMNS, + )?; conn.execute_batch( r#" DROP TABLE IF EXISTS catalog_sessions_new; CREATE TABLE catalog_sessions_new ( source_path TEXT PRIMARY KEY NOT NULL, - provider TEXT NOT NULL CHECK (provider IN ('codex', 'claude', 'pi', 'opencode', 'antigravity', 'gemini', 'cursor', 'copilot_cli', 'factory_ai_droid', 'shell', 'git', 'jj', 'gh', 'unknown')), + provider TEXT NOT NULL CHECK (provider IN ('codex', 'claude', 'pi', 'opencode', 'antigravity', 'gemini', 'cursor', 'copilot_cli', 'factory_ai_droid', 'openclaw', 'hermes', 'nanoclaw', 'astrbot', 'shelley', 'shell', 'git', 'jj', 'gh', 'custom', 'unknown')), source_format TEXT NOT NULL, source_root TEXT NOT NULL, external_session_id TEXT, @@ -4896,11 +5320,16 @@ fn rebuild_catalog_sessions_provider_check(conn: &Connection) -> Result<()> { indexed_status TEXT NOT NULL DEFAULT 'pending' CHECK (indexed_status IN ('pending', 'indexed', 'failed')), indexed_error TEXT, indexed_event_count INTEGER, + last_imported_at_ms INTEGER, + last_imported_file_size_bytes INTEGER, + last_imported_file_modified_at_ms INTEGER, + last_imported_file_sha256 TEXT, + last_imported_event_count INTEGER, metadata_json TEXT NOT NULL DEFAULT '{}' ); INSERT INTO catalog_sessions_new - (source_path, provider, source_format, source_root, external_session_id, parent_external_session_id, agent_type, role_hint, external_agent_id, cwd, session_started_at_ms, file_size_bytes, file_modified_at_ms, cataloged_at_ms, is_stale, indexed_at_ms, indexed_file_size_bytes, indexed_file_modified_at_ms, indexed_status, indexed_error, indexed_event_count, metadata_json) - SELECT source_path, provider, source_format, source_root, external_session_id, parent_external_session_id, agent_type, role_hint, external_agent_id, cwd, session_started_at_ms, file_size_bytes, file_modified_at_ms, cataloged_at_ms, is_stale, indexed_at_ms, indexed_file_size_bytes, indexed_file_modified_at_ms, indexed_status, indexed_error, indexed_event_count, metadata_json + (source_path, provider, source_format, source_root, external_session_id, parent_external_session_id, agent_type, role_hint, external_agent_id, cwd, session_started_at_ms, file_size_bytes, file_modified_at_ms, cataloged_at_ms, is_stale, indexed_at_ms, indexed_file_size_bytes, indexed_file_modified_at_ms, indexed_status, indexed_error, indexed_event_count, last_imported_at_ms, last_imported_file_size_bytes, last_imported_file_modified_at_ms, last_imported_file_sha256, last_imported_event_count, metadata_json) + SELECT source_path, provider, source_format, source_root, external_session_id, parent_external_session_id, agent_type, role_hint, external_agent_id, cwd, session_started_at_ms, file_size_bytes, file_modified_at_ms, cataloged_at_ms, is_stale, indexed_at_ms, indexed_file_size_bytes, indexed_file_modified_at_ms, indexed_status, indexed_error, indexed_event_count, last_imported_at_ms, last_imported_file_size_bytes, last_imported_file_modified_at_ms, last_imported_file_sha256, last_imported_event_count, metadata_json FROM catalog_sessions; DROP TABLE catalog_sessions; ALTER TABLE catalog_sessions_new RENAME TO catalog_sessions; @@ -4912,6 +5341,50 @@ fn rebuild_catalog_sessions_provider_check(conn: &Connection) -> Result<()> { Ok(()) } +fn rebuild_source_import_files_provider_check(conn: &Connection) -> Result<()> { + if !table_exists(conn, "source_import_files")? { + conn.execute_batch(CREATE_TABLES_SQL)?; + return Ok(()); + } + + let recreate_views = stable_sql_views_exist(conn)?; + if recreate_views { + drop_stable_sql_views(conn)?; + } + conn.execute_batch( + r#" + DROP TABLE IF EXISTS source_import_files_new; + CREATE TABLE source_import_files_new ( + provider TEXT NOT NULL CHECK (provider IN ('codex', 'claude', 'pi', 'opencode', 'antigravity', 'gemini', 'cursor', 'copilot_cli', 'factory_ai_droid', 'openclaw', 'hermes', 'nanoclaw', 'astrbot', 'shelley', 'shell', 'git', 'jj', 'gh', 'custom', 'unknown')), + source_format TEXT NOT NULL, + source_root TEXT NOT NULL, + source_path TEXT NOT NULL, + file_size_bytes INTEGER NOT NULL, + file_modified_at_ms INTEGER NOT NULL, + observed_at_ms INTEGER NOT NULL, + is_stale INTEGER NOT NULL DEFAULT 0, + indexed_at_ms INTEGER, + indexed_file_size_bytes INTEGER, + indexed_file_modified_at_ms INTEGER, + indexed_status TEXT NOT NULL DEFAULT 'pending' CHECK (indexed_status IN ('pending', 'indexed', 'failed')), + indexed_error TEXT, + metadata_json TEXT NOT NULL DEFAULT '{}', + PRIMARY KEY (provider, source_root, source_path) + ); + INSERT INTO source_import_files_new + (provider, source_format, source_root, source_path, file_size_bytes, file_modified_at_ms, observed_at_ms, is_stale, indexed_at_ms, indexed_file_size_bytes, indexed_file_modified_at_ms, indexed_status, indexed_error, metadata_json) + SELECT provider, source_format, source_root, source_path, file_size_bytes, file_modified_at_ms, observed_at_ms, is_stale, indexed_at_ms, indexed_file_size_bytes, indexed_file_modified_at_ms, indexed_status, indexed_error, metadata_json + FROM source_import_files; + DROP TABLE source_import_files; + ALTER TABLE source_import_files_new RENAME TO source_import_files; + "#, + )?; + if recreate_views { + create_stable_sql_views(conn)?; + } + Ok(()) +} + fn create_fts_tables_if_supported(conn: &Connection) -> Result<()> { match conn.execute_batch(FTS_TABLES_SQL) { Ok(()) => Ok(()), @@ -4970,12 +5443,10 @@ fn table_exists(conn: &Connection, table: &str) -> Result { } fn reject_provider_event_hash_conflict(conn: &Connection, dedupe_key: &str) -> Result<()> { - let Some((provider, external_session_id, provider_index, _new_hash)) = - parse_provider_event_dedupe_key(dedupe_key) - else { + let Some(parsed) = parse_provider_event_dedupe_key(dedupe_key) else { return Ok(()); }; - let prefix = provider_event_dedupe_key_prefix(&provider, &external_session_id, provider_index); + let prefix = provider_event_dedupe_key_prefix(&parsed); let upper_bound = provider_event_dedupe_key_upper_bound(&prefix); let mut stmt = conn.prepare( "SELECT dedupe_key FROM events @@ -4987,12 +5458,10 @@ fn reject_provider_event_hash_conflict(conn: &Connection, dedupe_key: &str) -> R } fn reject_provider_event_hash_conflict_tx(tx: &Transaction<'_>, dedupe_key: &str) -> Result<()> { - let Some((provider, external_session_id, provider_index, _new_hash)) = - parse_provider_event_dedupe_key(dedupe_key) - else { + let Some(parsed) = parse_provider_event_dedupe_key(dedupe_key) else { return Ok(()); }; - let prefix = provider_event_dedupe_key_prefix(&provider, &external_session_id, provider_index); + let prefix = provider_event_dedupe_key_prefix(&parsed); let upper_bound = provider_event_dedupe_key_upper_bound(&prefix); let mut stmt = tx.prepare( "SELECT dedupe_key FROM events @@ -5007,51 +5476,83 @@ fn reject_provider_event_hash_conflict_from_rows( dedupe_key: &str, rows: rusqlite::MappedRows<'_, impl FnMut(&rusqlite::Row<'_>) -> rusqlite::Result>, ) -> Result<()> { - let Some((provider, external_session_id, provider_index, new_hash)) = - parse_provider_event_dedupe_key(dedupe_key) - else { + let Some(incoming) = parse_provider_event_dedupe_key(dedupe_key) else { return Ok(()); }; for row in rows { let existing_key = row?; - let Some((existing_provider, existing_session_id, existing_index, existing_hash)) = - parse_provider_event_dedupe_key(&existing_key) - else { + let Some(existing) = parse_provider_event_dedupe_key(&existing_key) else { continue; }; - if existing_provider == provider - && existing_session_id == external_session_id - && existing_index == provider_index - && existing_hash != new_hash + if existing.has_same_event_identity(&incoming) + && existing.payload_hash != incoming.payload_hash { return Err(StoreError::ProviderEventConflict { - provider, - external_session_id, - provider_index, - existing_hash, - new_hash, + provider: incoming.provider, + external_session_id: incoming.external_session_id, + provider_index: incoming.provider_index, + existing_hash: existing.payload_hash, + new_hash: incoming.payload_hash, }); } } Ok(()) } -fn provider_event_dedupe_key_prefix( - provider: &str, - external_session_id: &str, +#[derive(Debug, Clone)] +struct ParsedProviderEventDedupeKey { + provider: String, + external_session_id: String, + source_id: Option, provider_index: u64, -) -> String { - format!("provider:{provider}:{external_session_id}:{provider_index}:") + payload_hash: String, } -fn provider_event_dedupe_key_upper_bound(prefix: &str) -> String { - let mut upper_bound = prefix.to_owned(); - upper_bound.push(char::MAX); - upper_bound +impl ParsedProviderEventDedupeKey { + fn has_same_event_identity(&self, other: &Self) -> bool { + self.provider == other.provider + && self.external_session_id == other.external_session_id + && self.source_id == other.source_id + && self.provider_index == other.provider_index + } } -fn parse_provider_event_dedupe_key(dedupe_key: &str) -> Option<(String, String, u64, String)> { - let mut parts = dedupe_key.splitn(5, ':'); +fn provider_event_dedupe_key_prefix(parsed: &ParsedProviderEventDedupeKey) -> String { + if let Some(source_id) = &parsed.source_id { + format!("provider-source:{source_id}:{}:", parsed.provider_index) + } else { + format!( + "provider:{}:{}:{}:", + parsed.provider, parsed.external_session_id, parsed.provider_index + ) + } +} + +fn provider_event_dedupe_key_upper_bound(prefix: &str) -> String { + let mut upper_bound = prefix.to_owned(); + upper_bound.push(char::MAX); + upper_bound +} + +fn parse_provider_event_dedupe_key(dedupe_key: &str) -> Option { + if let Some(rest) = dedupe_key.strip_prefix("provider-source:") { + let mut parts = rest.splitn(3, ':'); + let source_id = parts.next()?.to_owned(); + let provider_index = parts.next()?.parse().ok()?; + let payload_hash = parts.next()?.to_owned(); + if source_id.is_empty() || payload_hash.is_empty() { + return None; + } + return Some(ParsedProviderEventDedupeKey { + provider: "provider-source".to_owned(), + external_session_id: source_id.clone(), + source_id: Some(source_id), + provider_index, + payload_hash, + }); + } + + let mut parts = dedupe_key.splitn(5, ':'); let prefix = parts.next()?; if prefix != "provider" { return None; @@ -5063,7 +5564,13 @@ fn parse_provider_event_dedupe_key(dedupe_key: &str) -> Option<(String, String, if provider.is_empty() || external_session_id.is_empty() || payload_hash.is_empty() { None } else { - Some((provider, external_session_id, provider_index, payload_hash)) + Some(ParsedProviderEventDedupeKey { + provider, + external_session_id, + source_id: None, + provider_index, + payload_hash, + }) } } @@ -5071,7 +5578,7 @@ fn fts_match_query(query: &str) -> Option { let terms = query .split_whitespace() .map(|term| term.trim_matches(|ch: char| !ch.is_alphanumeric() && ch != '_' && ch != '-')) - .filter(|term| !term.is_empty()) + .filter(|term| term.chars().any(char::is_alphanumeric)) .map(|term| format!("\"{}\"", term.replace('"', "\"\""))) .collect::>(); if terms.is_empty() { @@ -5134,6 +5641,10 @@ fn nonnegative_i64_to_u64(value: i64) -> rusqlite::Result { u64::try_from(value).map_err(|err| rusqlite::Error::ToSqlConversionFailure(Box::new(err))) } +fn nonnegative_i64_to_u32(value: i64) -> rusqlite::Result { + u32::try_from(value).map_err(|err| rusqlite::Error::ToSqlConversionFailure(Box::new(err))) +} + fn time_ms(value: i64) -> DateTime { DateTime::::from_timestamp_millis(value).unwrap_or(DateTime::::UNIX_EPOCH) } @@ -5278,18 +5789,6 @@ fn reject_rich_import_conflicts( "capture_source", source.id, )?; - if let Some(external_session_id) = &source.descriptor.external_session_id { - reject_entity_conflict( - existing_capture_source_by_external_session( - tx, - source.descriptor.provider, - external_session_id, - )?, - source, - "capture_source", - source.id, - )?; - } } for workspace in &archive.vcs_workspaces { reject_entity_conflict( @@ -5410,7 +5909,8 @@ fn reject_rich_import_conflicts( fn reject_archive_event_internal_conflicts(archive: &SessionHistoryArchive) -> Result<()> { let mut seen_seq: HashMap = HashMap::new(); - let mut seen_provider_events: HashMap<(String, String, u64), String> = HashMap::new(); + let mut seen_provider_events: HashMap<(String, String, Option, u64), String> = + HashMap::new(); for event in &archive.events { if let Some(existing) = seen_seq.insert(event.seq, event) { @@ -5425,24 +5925,27 @@ fn reject_archive_event_internal_conflicts(archive: &SessionHistoryArchive) -> R let Some(dedupe_key) = &event.dedupe_key else { continue; }; - let Some((provider, external_session_id, provider_index, payload_hash)) = - parse_provider_event_dedupe_key(dedupe_key) - else { + let Some(parsed) = parse_provider_event_dedupe_key(dedupe_key) else { continue; }; - let key = (provider, external_session_id, provider_index); + let key = ( + parsed.provider, + parsed.external_session_id, + parsed.source_id, + parsed.provider_index, + ); if let Some(existing_hash) = seen_provider_events.get(&key) { - if existing_hash != &payload_hash { + if existing_hash != &parsed.payload_hash { return Err(StoreError::ProviderEventConflict { provider: key.0, external_session_id: key.1, - provider_index: key.2, + provider_index: key.3, existing_hash: existing_hash.clone(), - new_hash: payload_hash, + new_hash: parsed.payload_hash, }); } } else { - seen_provider_events.insert(key, payload_hash); + seen_provider_events.insert(key, parsed.payload_hash); } } @@ -5473,20 +5976,6 @@ fn existing_capture_source_by_id(tx: &Transaction<'_>, id: Uuid) -> Result, - provider: CaptureProvider, - external_session_id: &str, -) -> Result> { - tx.query_row( - "SELECT id, kind, provider, machine_id, process_id, cwd, raw_source_path, external_session_id, started_at_ms, ended_at_ms, fidelity, visibility, sync_state, sync_version, metadata_json FROM capture_sources WHERE provider = ?1 AND external_session_id = ?2 ORDER BY started_at_ms DESC LIMIT 1", - params![provider.as_str(), external_session_id], - capture_source_from_row, - ) - .optional() - .map_err(StoreError::from) -} - fn existing_session_by_id(tx: &Transaction<'_>, id: Uuid) -> Result> { tx.query_row( session_select_sql("WHERE id = ?1").as_str(), @@ -5690,6 +6179,162 @@ fn existing_history_record_link_by_identity( .map_err(StoreError::from) } +#[cfg(test)] +mod archive_validation_tests { + use super::*; + + fn tempdir() -> tempfile::TempDir { + let root = std::env::current_dir().unwrap().join("target/test-data"); + fs::create_dir_all(&root).unwrap(); + tempfile::Builder::new() + .prefix("ctx-history-store-archive-validation-") + .tempdir_in(root) + .unwrap() + } + + fn fixed_time() -> DateTime { + DateTime::parse_from_rfc3339("2026-06-23T12:00:00Z") + .unwrap() + .with_timezone(&Utc) + } + + fn artifact(id: Uuid, blob_hash: String, byte_size: u64) -> Artifact { + Artifact { + id, + kind: ArtifactKind::Markdown, + blob_path: object_relative_path(&blob_hash), + blob_hash, + byte_size, + media_type: Some("text/markdown".into()), + preview_text: Some("synthetic local preview blob".into()), + redaction_state: RedactionState::LocalPreview, + timestamps: EntityTimestamps { + created_at: fixed_time(), + updated_at: fixed_time(), + }, + source_id: None, + sync: SyncMetadata { + visibility: Visibility::LocalOnly, + fidelity: Fidelity::Imported, + sync_state: SyncState::LocalOnly, + sync_version: 0, + deleted_at: None, + metadata: serde_json::json!({}), + }, + } + } + + fn write_blob(blob_dir: &Path, blob_hash: &str, content: &[u8]) { + let path = blob_dir.join(&blob_hash[..2]).join(blob_hash); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, content).unwrap(); + } + + fn assert_artifact_error( + error: StoreError, + matches_expected: impl FnOnce(&StoreError) -> bool, + ) { + assert!( + matches_expected(&error), + "unexpected archive artifact validation error: {error:?}" + ); + } + + #[test] + fn archive_blob_validation_fails_closed_when_blob_is_missing() { + let temp = tempdir(); + let content = b"missing synthetic blob"; + let artifact = artifact(new_id(), sha256_hex(content), content.len() as u64); + + let error = validate_archive_artifact_record_blob(temp.path(), &artifact).unwrap_err(); + assert_artifact_error( + error, + |error| matches!(error, StoreError::ArchiveArtifactMissingContent { id } if *id == artifact.id), + ); + } + + #[test] + fn archive_blob_validation_fails_closed_when_hash_differs() { + let temp = tempdir(); + let stored_content = b"stored bytes"; + let expected_content = b"expected bytes"; + let artifact = artifact( + new_id(), + sha256_hex(expected_content), + stored_content.len() as u64, + ); + write_blob(temp.path(), &artifact.blob_hash, stored_content); + + let error = validate_archive_artifact_record_blob(temp.path(), &artifact).unwrap_err(); + assert_artifact_error( + error, + |error| matches!(error, StoreError::ArchiveArtifactHashMismatch { id } if *id == artifact.id), + ); + } + + #[test] + fn archive_blob_validation_fails_closed_when_byte_size_differs() { + let temp = tempdir(); + let content = b"size checked bytes"; + let artifact = artifact(new_id(), sha256_hex(content), content.len() as u64 + 1); + write_blob(temp.path(), &artifact.blob_hash, content); + + let error = validate_archive_artifact_record_blob(temp.path(), &artifact).unwrap_err(); + assert_artifact_error( + error, + |error| matches!(error, StoreError::ArchiveArtifactSizeMismatch { id } if *id == artifact.id), + ); + } + + #[test] + fn archive_blob_validation_fails_closed_when_blob_path_mismatches_hash() { + let temp = tempdir(); + let content = b"path checked bytes"; + let mut artifact = artifact(new_id(), sha256_hex(content), content.len() as u64); + artifact.blob_path = "objects/ff/not-the-recorded-hash".into(); + write_blob(temp.path(), &artifact.blob_hash, content); + + let error = validate_archive_artifact_record_blob(temp.path(), &artifact).unwrap_err(); + assert_artifact_error( + error, + |error| matches!(error, StoreError::ArchiveArtifactPathMismatch { id } if *id == artifact.id), + ); + } + + #[test] + fn archive_blob_validation_fails_closed_when_blob_is_not_regular_file() { + let temp = tempdir(); + let content = b"directory at blob path"; + let artifact = artifact(new_id(), sha256_hex(content), content.len() as u64); + let path = temp + .path() + .join(&artifact.blob_hash[..2]) + .join(&artifact.blob_hash); + fs::create_dir_all(&path).unwrap(); + + let error = validate_archive_artifact_record_blob(temp.path(), &artifact).unwrap_err(); + assert_artifact_error( + error, + |error| matches!(error, StoreError::ArchiveArtifactNonRegularFile { id, .. } if *id == artifact.id), + ); + } + + #[test] + fn archive_version_validation_rejects_future_version() { + let archive = SessionHistoryArchive { + schema_version: 3, + version: 3, + ..SessionHistoryArchive::default() + }; + + let error = validate_archive_version(&archive).unwrap_err(); + assert!(matches!( + error, + StoreError::UnsupportedArchiveVersion(version) if version == 3 + )); + } +} + fn expected_archive_blob_path(id: Uuid, blob_hash: &str) -> Result { if blob_hash.get(..2).is_none() { return Err(StoreError::ArchiveArtifactPathMismatch { id }); @@ -6400,7 +7045,10 @@ fn capture_source_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result(row.get::<_, String>(1)?)?, provider: parse_text_enum::(row.get::<_, String>(2)?)?, machine_id: row.get(3)?, - process_id: row.get::<_, Option>(4)?.map(|value| value as u32), + process_id: row + .get::<_, Option>(4)? + .map(nonnegative_i64_to_u32) + .transpose()?, cwd: row.get(5)?, raw_source_path: row.get(6)?, external_session_id: row.get(7)?, @@ -6411,7 +7059,7 @@ fn capture_source_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result(row.get::<_, String>(10)?)?, visibility: parse_text_enum::(row.get::<_, String>(11)?)?, sync_state: parse_text_enum::(row.get::<_, String>(12)?)?, - sync_version: row.get::<_, i64>(13)? as u64, + sync_version: nonnegative_i64_to_u64(row.get(13)?)?, deleted_at: None, metadata: parse_json(row.get::<_, String>(14)?)?, }, @@ -6574,7 +7222,7 @@ fn event_select_sql(tail: &str) -> String { fn event_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { Ok(Event { id: parse_uuid(row.get::<_, String>(0)?)?, - seq: row.get::<_, i64>(1)? as u64, + seq: nonnegative_i64_to_u64(row.get(1)?)?, history_record_id: parse_optional_uuid(row.get(2)?)?, session_id: parse_optional_uuid(row.get(3)?)?, run_id: parse_optional_uuid(row.get(4)?)?, @@ -6605,7 +7253,7 @@ fn artifact_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { kind: parse_text_enum::(row.get::<_, String>(1)?)?, blob_hash: row.get(2)?, blob_path: row.get(3)?, - byte_size: row.get::<_, i64>(4)? as u64, + byte_size: nonnegative_i64_to_u64(row.get(4)?)?, media_type: row.get(5)?, preview_text: row.get(6)?, redaction_state: parse_text_enum::(row.get::<_, String>(7)?)?, @@ -6781,7 +7429,7 @@ fn sync_metadata_from_row( visibility: parse_text_enum::(row.get::<_, String>(visibility_index)?)?, fidelity: parse_text_enum::(row.get::<_, String>(fidelity_index)?)?, sync_state: parse_text_enum::(row.get::<_, String>(sync_state_index)?)?, - sync_version: row.get::<_, i64>(sync_version_index)? as u64, + sync_version: nonnegative_i64_to_u64(row.get(sync_version_index)?)?, deleted_at: optional_ms_to_time(row.get(deleted_at_index)?)?, metadata: parse_json(row.get::<_, String>(metadata_index)?)?, }) @@ -6919,6 +7567,83 @@ fn event_search_cursor( .map(str::to_owned)) } +#[derive(Default)] +struct EventSearchSourceIdentity { + history_source: Option, + history_source_plugin: Option, + provider_key: Option, + source_id: Option, + source_format: Option, +} + +fn event_search_source_identity( + source_metadata_json: Option<&str>, +) -> rusqlite::Result { + let Some(source_metadata_json) = source_metadata_json else { + return Ok(EventSearchSourceIdentity::default()); + }; + let metadata: serde_json::Value = serde_json::from_str(source_metadata_json) + .map_err(|err| rusqlite::Error::ToSqlConversionFailure(Box::new(err)))?; + let source_metadata = metadata + .get("source_metadata") + .and_then(serde_json::Value::as_object); + let plugin = source_metadata + .and_then(|metadata| metadata.get("ctx_history_plugin")) + .or_else(|| metadata.get("ctx_history_plugin")) + .and_then(serde_json::Value::as_object); + let custom = source_metadata + .and_then(|metadata| metadata.get("ctx_history_jsonl_v1")) + .or_else(|| metadata.get("ctx_history_jsonl_v1")) + .and_then(serde_json::Value::as_object); + let plugin_name = plugin + .and_then(|plugin| plugin.get("plugin_name")) + .and_then(serde_json::Value::as_str) + .map(str::to_owned); + let plugin_source_id = plugin + .and_then(|plugin| plugin.get("plugin_source_id")) + .and_then(serde_json::Value::as_str) + .map(str::to_owned); + let history_source = plugin + .and_then(|plugin| plugin.get("history_source")) + .and_then(serde_json::Value::as_str) + .map(str::to_owned) + .or_else(|| { + plugin_name + .as_deref() + .zip(plugin_source_id.as_deref()) + .map(|(plugin_name, source_id)| format!("{plugin_name}/{source_id}")) + }); + let provider_key = custom + .and_then(|custom| custom.get("provider_key")) + .and_then(serde_json::Value::as_str) + .map(str::to_owned); + let source_id = custom + .and_then(|custom| custom.get("source_id")) + .and_then(serde_json::Value::as_str) + .map(str::to_owned); + let source_format = custom + .and_then(|custom| custom.get("source_format")) + .and_then(serde_json::Value::as_str) + .or_else(|| { + source_metadata + .and_then(|metadata| metadata.get("source_format")) + .and_then(serde_json::Value::as_str) + }) + .or_else(|| { + metadata + .get("source_format") + .and_then(serde_json::Value::as_str) + }) + .map(str::to_owned); + Ok(EventSearchSourceIdentity { + history_source, + history_source_plugin: plugin_name, + provider_key, + source_id, + source_format, + }) +} + fn collect_rows( rows: rusqlite::MappedRows<'_, impl FnMut(&rusqlite::Row<'_>) -> rusqlite::Result>, ) -> Result> { @@ -6948,6 +7673,36 @@ mod search_order_tests { .with_timezone(&Utc) } + fn sync_metadata() -> SyncMetadata { + SyncMetadata { + visibility: Visibility::LocalOnly, + fidelity: Fidelity::Imported, + sync_state: SyncState::LocalOnly, + sync_version: 0, + deleted_at: None, + metadata: serde_json::json!({}), + } + } + + fn local_preview_event(seq: u64, text: &str, redaction_state: RedactionState) -> Event { + Event { + id: new_id(), + seq, + history_record_id: None, + session_id: None, + run_id: None, + event_type: EventType::Message, + role: Some(EventRole::User), + occurred_at: fixed_time(), + capture_source_id: None, + payload: serde_json::json!({ "text": text }), + payload_blob_id: None, + dedupe_key: None, + redaction_state, + sync: sync_metadata(), + } + } + #[test] fn indexed_history_item_count_uses_sessions_and_events() { let temp = tempdir(); @@ -7082,6 +7837,60 @@ mod search_order_tests { assert_search_order(&reopened, &expected); } + #[test] + fn search_records_empty_or_no_token_query_returns_empty() { + let temp = tempdir(); + let store = Store::open(temp.path().join("work.sqlite")).unwrap(); + let record = stable_tie_record(1); + store.insert_record(&record).unwrap(); + + assert!(store.search_records("", 10).unwrap().is_empty()); + assert!(store.search_records("!!!", 10).unwrap().is_empty()); + assert!(store.search_records("---", 10).unwrap().is_empty()); + assert!(store.search_records("___", 10).unwrap().is_empty()); + assert!(store.search_records_page("", 10, 0).unwrap().is_empty()); + } + + #[test] + fn event_search_local_preview_preserves_private_text_but_raw_is_withheld() { + let temp = tempdir(); + let store = Store::open(temp.path().join("work.sqlite")).unwrap(); + let local_event = local_preview_event( + 1, + "cwd=/home/example/private token=ghp_1234567890abcdef", + RedactionState::LocalPreview, + ); + let raw_event = local_preview_event( + 2, + "raw cwd=/home/example/private token=ghp_1234567890abcdef", + RedactionState::Raw, + ); + + store.upsert_event(&local_event).unwrap(); + store.upsert_event(&raw_event).unwrap(); + + let local_preview: String = store + .conn + .query_row( + "SELECT safe_preview_text FROM event_search WHERE event_id = ?1", + [local_event.id.to_string()], + |row| row.get(0), + ) + .unwrap(); + assert!(local_preview.contains("/home/example/private")); + assert!(local_preview.contains("ghp_1234567890abcdef")); + + let raw_preview: String = store + .conn + .query_row( + "SELECT safe_preview_text FROM event_search WHERE event_id = ?1", + [raw_event.id.to_string()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(raw_preview, "raw event payload withheld"); + } + #[test] fn upsert_record_updates_record_search_without_rebuilding_event_search() { let temp = tempdir(); @@ -7118,6 +7927,18 @@ mod search_order_tests { mod catalog_tests { use super::*; + type CatalogSessionCheckpointRow = ( + String, + Option, + Option, + Option, + Option, + Option, + Option, + Option, + Option, + ); + fn tempdir() -> tempfile::TempDir { let root = std::env::current_dir().unwrap().join("target/test-data"); fs::create_dir_all(&root).unwrap(); @@ -7197,6 +8018,48 @@ mod catalog_tests { } } + fn session_event(session_id: Uuid, index: u64) -> Event { + Event { + id: new_id(), + seq: index, + history_record_id: None, + session_id: Some(session_id), + run_id: None, + event_type: EventType::Message, + role: Some(EventRole::Assistant), + occurred_at: fixed_time() + chrono::Duration::seconds(index as i64), + capture_source_id: None, + payload: serde_json::json!({"index": index}), + payload_blob_id: None, + dedupe_key: None, + redaction_state: RedactionState::LocalPreview, + sync: sync_metadata(), + } + } + + fn artifact_record(id: Uuid, byte_size: u64) -> Artifact { + Artifact { + id, + kind: ArtifactKind::Markdown, + blob_hash: format!("{:064x}", 1), + blob_path: format!("{OBJECTS_DIR}/00/test-artifact"), + byte_size, + media_type: Some("text/markdown".to_owned()), + preview_text: Some("artifact preview".to_owned()), + redaction_state: RedactionState::LocalPreview, + timestamps: timestamps(), + source_id: None, + sync: sync_metadata(), + } + } + + fn assert_sql_conversion_error(result: Result) { + assert!( + matches!(result, Err(StoreError::Sql(_))), + "expected sqlite conversion error, got {result:?}" + ); + } + #[test] fn catalog_session_upsert_skips_unchanged_rows() { let temp = tempdir(); @@ -7239,6 +8102,70 @@ mod catalog_tests { assert!(after_changed > after_noop); } + #[test] + fn events_for_session_window_returns_bounded_neighbors() { + let temp = tempdir(); + let store = Store::open(temp.path().join("work.sqlite")).unwrap(); + let session = imported_session("window-session"); + store.upsert_session(&session).unwrap(); + let events = (0..10) + .map(|index| { + let event = session_event(session.id, index); + store.upsert_event(&event).unwrap(); + event + }) + .collect::>(); + + let middle = store + .events_for_session_window(&events[5], 2, 3) + .unwrap() + .into_iter() + .map(|event| event.seq) + .collect::>(); + assert_eq!(middle, vec![3, 4, 5, 6, 7, 8]); + + let first = store + .events_for_session_window(&events[0], 50, 1) + .unwrap() + .into_iter() + .map(|event| event.seq) + .collect::>(); + assert_eq!(first, vec![0, 1]); + + let last = store + .events_for_session_window(&events[9], 1, 50) + .unwrap() + .into_iter() + .map(|event| event.seq) + .collect::>(); + assert_eq!(last, vec![8, 9]); + } + + #[test] + fn sessions_by_external_session_limited_caps_ambiguity_scan() { + let temp = tempdir(); + let store = Store::open(temp.path().join("work.sqlite")).unwrap(); + for index in 0..5 { + let mut session = imported_session("shared-provider-session"); + session.started_at = fixed_time() + chrono::Duration::seconds(index); + store.upsert_session(&session).unwrap(); + } + + let matches = store + .sessions_by_external_session_limited( + CaptureProvider::Codex, + "shared-provider-session", + 2, + ) + .unwrap(); + + assert_eq!(matches.len(), 2); + assert_eq!( + matches[0].external_session_id.as_deref(), + Some("shared-provider-session") + ); + } + #[test] fn search_index_optimize_is_safe_on_initialized_store() { let temp = tempdir(); @@ -7293,7 +8220,8 @@ mod catalog_tests { source_path: "/home/user/.codex/sessions/2026/06/24/rollout.jsonl", file_size_bytes: 42, file_modified_at_ms: cataloged_at_ms, - event_count: 3, + file_sha256: None, + event_count: Some(3), indexed_at_ms: cataloged_at_ms + 10, }, ) @@ -7345,7 +8273,8 @@ mod catalog_tests { source_path: "/home/user/.codex/sessions/2026/06/24/rollout.jsonl", file_size_bytes: 42, file_modified_at_ms: cataloged_at_ms, - event_count: 3, + file_sha256: None, + event_count: Some(3), indexed_at_ms: cataloged_at_ms + 10, }, ) @@ -7410,7 +8339,7 @@ mod catalog_tests { } #[test] - fn catalog_upsert_preserves_index_state_until_file_changes() { + fn catalog_upsert_clears_completion_metadata_but_preserves_append_checkpoint() { let temp = tempdir(); let store = Store::open(temp.path().join("work.sqlite")).unwrap(); let cataloged_at_ms = timestamp_ms(fixed_time()); @@ -7433,7 +8362,8 @@ mod catalog_tests { source_path, file_size_bytes: 42, file_modified_at_ms: cataloged_at_ms, - event_count: 3, + file_sha256: None, + event_count: Some(3), indexed_at_ms: cataloged_at_ms + 10, }, ) @@ -7455,59 +8385,197 @@ mod catalog_tests { let counts = store.catalog_session_counts().unwrap(); assert_eq!(counts.indexed, 0); assert_eq!(counts.pending, 1); - let (status, indexed_size, indexed_mtime, indexed_event_count): ( - String, - Option, - Option, - Option, - ) = store + let ( + status, + indexed_at_ms, + indexed_size, + indexed_mtime, + indexed_event_count, + checkpoint_at_ms, + checkpoint_size, + checkpoint_mtime, + checkpoint_event_count, + ): CatalogSessionCheckpointRow = store .conn .query_row( - "SELECT indexed_status, indexed_file_size_bytes, indexed_file_modified_at_ms, indexed_event_count FROM catalog_sessions WHERE source_path = ?1", + "SELECT indexed_status, indexed_at_ms, indexed_file_size_bytes, indexed_file_modified_at_ms, indexed_event_count, last_imported_at_ms, last_imported_file_size_bytes, last_imported_file_modified_at_ms, last_imported_event_count FROM catalog_sessions WHERE source_path = ?1", [source_path], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + row.get(6)?, + row.get(7)?, + row.get(8)?, + )) + }, ) .unwrap(); assert_eq!(status, CatalogIndexedStatus::Pending.as_str()); - assert_eq!(indexed_size, Some(42)); - assert_eq!(indexed_mtime, Some(cataloged_at_ms)); - assert_eq!(indexed_event_count, Some(3)); + assert_eq!(indexed_at_ms, None); + assert_eq!(indexed_size, None); + assert_eq!(indexed_mtime, None); + assert_eq!(indexed_event_count, None); + assert_eq!(checkpoint_at_ms, Some(cataloged_at_ms + 10)); + assert_eq!(checkpoint_size, Some(42)); + assert_eq!(checkpoint_mtime, Some(cataloged_at_ms)); + assert_eq!(checkpoint_event_count, Some(3)); + + let checkpoint = store + .catalog_source_index_state( + CaptureProvider::Codex, + "/home/user/.codex/sessions", + source_path, + ) + .unwrap() + .unwrap(); + assert_eq!(checkpoint.last_imported_file_size_bytes, Some(42)); + assert_eq!( + checkpoint.last_imported_file_modified_at_ms, + Some(cataloged_at_ms) + ); + assert_eq!(checkpoint.last_imported_file_sha256, None); + assert_eq!(checkpoint.last_imported_event_count, Some(3)); + assert_eq!(checkpoint.last_imported_at_ms, Some(cataloged_at_ms + 10)); } #[test] - fn source_import_manifest_upsert_ignores_observed_at_for_unchanged_files() { + fn catalog_upsert_invalidates_checkpoint_for_shrink_and_same_size_change() { let temp = tempdir(); let store = Store::open(temp.path().join("work.sqlite")).unwrap(); - let observed_at_ms = timestamp_ms(fixed_time()); - let mut file = SourceImportFile { - provider: CaptureProvider::Claude, - source_format: "claude_projects_jsonl_tree".into(), - source_root: "/home/user/.claude/projects".into(), - source_path: "/home/user/.claude/projects/session.jsonl".into(), - file_size_bytes: 42, - file_modified_at_ms: observed_at_ms, - observed_at_ms, - metadata: serde_json::json!({}), - }; - store - .upsert_source_import_files(std::slice::from_ref(&file)) - .unwrap(); - store - .mark_source_import_file_indexed( - CaptureProvider::Claude, - SourceImportFileIndexUpdate { - source_root: "/home/user/.claude/projects", - source_path: "/home/user/.claude/projects/session.jsonl", - file_size_bytes: 42, - file_modified_at_ms: observed_at_ms, - indexed_at_ms: observed_at_ms + 10, - }, - ) - .unwrap(); - let after_indexed: i64 = store - .conn - .query_row("SELECT total_changes()", [], |row| row.get(0)) - .unwrap(); + let cataloged_at_ms = timestamp_ms(fixed_time()); + for (source_path, file_size_bytes) in [ + ("/home/user/.codex/sessions/2026/06/24/shrink.jsonl", 41_u64), + ( + "/home/user/.codex/sessions/2026/06/24/same-size.jsonl", + 42_u64, + ), + ] { + store + .upsert_catalog_sessions(&[catalog_session( + source_path, + source_path, + cataloged_at_ms, + )]) + .unwrap(); + store + .upsert_session(&imported_session(source_path)) + .unwrap(); + store + .mark_catalog_source_indexed( + CaptureProvider::Codex, + CatalogSourceIndexUpdate { + source_root: "/home/user/.codex/sessions", + source_path, + file_size_bytes: 42, + file_modified_at_ms: cataloged_at_ms, + file_sha256: None, + event_count: Some(3), + indexed_at_ms: cataloged_at_ms + 10, + }, + ) + .unwrap(); + + let mut changed = catalog_session(source_path, source_path, cataloged_at_ms + 1); + changed.file_size_bytes = file_size_bytes; + store.upsert_catalog_sessions(&[changed]).unwrap(); + + let (status, indexed_size, checkpoint_size): (String, Option, Option) = + store + .conn + .query_row( + "SELECT indexed_status, indexed_file_size_bytes, last_imported_file_size_bytes FROM catalog_sessions WHERE source_path = ?1", + [source_path], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .unwrap(); + assert_eq!(status, CatalogIndexedStatus::Pending.as_str()); + assert_eq!(indexed_size, None); + assert_eq!(checkpoint_size, None); + } + } + + #[test] + fn catalog_index_checkpoint_event_count_can_be_unknown() { + let temp = tempdir(); + let store = Store::open(temp.path().join("work.sqlite")).unwrap(); + let cataloged_at_ms = timestamp_ms(fixed_time()); + let source_path = "/home/user/.codex/sessions/2026/06/24/unknown-count.jsonl"; + store + .upsert_catalog_sessions(&[catalog_session( + source_path, + "codex-session-unknown-count", + cataloged_at_ms, + )]) + .unwrap(); + store + .mark_catalog_source_indexed( + CaptureProvider::Codex, + CatalogSourceIndexUpdate { + source_root: "/home/user/.codex/sessions", + source_path, + file_size_bytes: 42, + file_modified_at_ms: cataloged_at_ms, + file_sha256: Some("abc123"), + event_count: None, + indexed_at_ms: cataloged_at_ms + 10, + }, + ) + .unwrap(); + + let checkpoint = store + .catalog_source_index_state( + CaptureProvider::Codex, + "/home/user/.codex/sessions", + source_path, + ) + .unwrap() + .unwrap(); + assert_eq!(checkpoint.last_imported_event_count, None); + assert_eq!( + checkpoint.last_imported_file_sha256.as_deref(), + Some("abc123") + ); + } + + #[test] + fn source_import_manifest_upsert_ignores_observed_at_for_unchanged_files() { + let temp = tempdir(); + let store = Store::open(temp.path().join("work.sqlite")).unwrap(); + let observed_at_ms = timestamp_ms(fixed_time()); + let mut file = SourceImportFile { + provider: CaptureProvider::Claude, + source_format: "claude_projects_jsonl_tree".into(), + source_root: "/home/user/.claude/projects".into(), + source_path: "/home/user/.claude/projects/session.jsonl".into(), + file_size_bytes: 42, + file_modified_at_ms: observed_at_ms, + observed_at_ms, + metadata: serde_json::json!({}), + }; + store + .upsert_source_import_files(std::slice::from_ref(&file)) + .unwrap(); + store + .mark_source_import_file_indexed( + CaptureProvider::Claude, + SourceImportFileIndexUpdate { + source_root: "/home/user/.claude/projects", + source_path: "/home/user/.claude/projects/session.jsonl", + file_size_bytes: 42, + file_modified_at_ms: observed_at_ms, + indexed_at_ms: observed_at_ms + 10, + }, + ) + .unwrap(); + let after_indexed: i64 = store + .conn + .query_row("SELECT total_changes()", [], |row| row.get(0)) + .unwrap(); file.observed_at_ms += 1_000; store @@ -7538,6 +8606,11 @@ mod catalog_tests { assert!(schema.contains("indexed_status TEXT NOT NULL DEFAULT 'pending'")); assert!(schema.contains("indexed_error TEXT")); assert!(schema.contains("indexed_event_count INTEGER")); + assert!(schema.contains("last_imported_at_ms INTEGER")); + assert!(schema.contains("last_imported_file_size_bytes INTEGER")); + assert!(schema.contains("last_imported_file_modified_at_ms INTEGER")); + assert!(schema.contains("last_imported_file_sha256 TEXT")); + assert!(schema.contains("last_imported_event_count INTEGER")); } #[test] @@ -7565,6 +8638,154 @@ mod catalog_tests { assert_eq!(result.rows[0][0], RawSqlValue::Integer(0)); } + #[test] + fn ctx_files_touched_resolves_session_from_source_id() { + let temp = tempdir(); + let store = Store::open(temp.path().join("work.sqlite")).unwrap(); + let record_id = "018f45d0-0000-7000-8000-000000080001"; + let source_id = "018f45d0-0000-7000-8000-000000080002"; + let session_id = "018f45d0-0000-7000-8000-000000080003"; + let touch_id = "018f45d0-0000-7000-8000-000000080004"; + let detached_source_id = "018f45d0-0000-7000-8000-000000080005"; + let detached_touch_id = "018f45d0-0000-7000-8000-000000080006"; + + store + .conn + .execute( + r#" + INSERT INTO history_records + (id, title, last_activity_at_ms, created_at_ms, updated_at_ms, body, created_at, updated_at) + VALUES (?1, 'Touched file view record', 1, 1, 1, '', '', '') + "#, + [record_id], + ) + .unwrap(); + store + .conn + .execute( + r#" + INSERT INTO capture_sources + (id, kind, provider, machine_id, raw_source_path, external_session_id, started_at_ms, fidelity) + VALUES (?1, 'provider_import', 'codex', 'test-machine', '/tmp/session.jsonl', 'codex-session-1', 1, 'imported') + "#, + [source_id], + ) + .unwrap(); + store + .conn + .execute( + r#" + INSERT INTO capture_sources + (id, kind, provider, machine_id, raw_source_path, external_session_id, started_at_ms, fidelity) + VALUES (?1, 'provider_import', 'opencode', 'test-machine', '/tmp/opencode.db', 'opencode-session-1', 1, 'imported') + "#, + [detached_source_id], + ) + .unwrap(); + store + .conn + .execute( + r#" + INSERT INTO sessions + ( + id, history_record_id, capture_source_id, provider, external_session_id, + agent_type, is_primary, status, fidelity, started_at_ms, created_at_ms, updated_at_ms + ) + VALUES (?1, ?2, ?3, 'codex', 'codex-session-1', 'primary', 1, 'imported', 'imported', 1, 1, 1) + "#, + params![session_id, record_id, source_id], + ) + .unwrap(); + store + .conn + .execute( + r#" + INSERT INTO files_touched + (id, source_id, path, change_kind, confidence, created_at_ms, updated_at_ms, fidelity) + VALUES (?1, ?2, 'src/main.rs', 'modified', 'explicit', 1, 1, 'imported') + "#, + params![touch_id, source_id], + ) + .unwrap(); + store + .conn + .execute( + r#" + INSERT INTO files_touched + (id, source_id, path, change_kind, confidence, created_at_ms, updated_at_ms, fidelity) + VALUES (?1, ?2, 'detached.rs', 'modified', 'explicit', 1, 1, 'imported') + "#, + params![detached_touch_id, detached_source_id], + ) + .unwrap(); + + let result = store + .raw_sql_query( + "SELECT provider, provider_session_id, ctx_session_id, history_record_id FROM ctx_files_touched WHERE path = 'src/main.rs'", + RawSqlOptions::default(), + ) + .unwrap(); + assert_eq!(result.returned_rows, 1); + assert_eq!( + result.rows[0][0], + RawSqlValue::Text { + value: "codex".to_owned(), + bytes: 5, + truncated: false, + } + ); + assert_eq!( + result.rows[0][1], + RawSqlValue::Text { + value: "codex-session-1".to_owned(), + bytes: 15, + truncated: false, + } + ); + assert_eq!( + result.rows[0][2], + RawSqlValue::Text { + value: session_id.to_owned(), + bytes: session_id.len(), + truncated: false, + } + ); + assert_eq!( + result.rows[0][3], + RawSqlValue::Text { + value: record_id.to_owned(), + bytes: record_id.len(), + truncated: false, + } + ); + + let detached = store + .raw_sql_query( + "SELECT provider, provider_session_id, ctx_session_id, history_record_id FROM ctx_files_touched WHERE path = 'detached.rs'", + RawSqlOptions::default(), + ) + .unwrap(); + assert_eq!(detached.returned_rows, 1); + assert_eq!( + detached.rows[0][0], + RawSqlValue::Text { + value: "opencode".to_owned(), + bytes: 8, + truncated: false, + } + ); + assert_eq!( + detached.rows[0][1], + RawSqlValue::Text { + value: "opencode-session-1".to_owned(), + bytes: 18, + truncated: false, + } + ); + assert_eq!(detached.rows[0][2], RawSqlValue::Null); + assert_eq!(detached.rows[0][3], RawSqlValue::Null); + } + #[test] fn raw_sql_query_rejects_writes_parameters_and_multiple_statements() { let temp = tempdir(); @@ -7633,6 +8854,131 @@ mod catalog_tests { assert!(result.truncated.values); } + #[test] + fn row_readers_reject_negative_unsigned_columns() { + let temp = tempdir(); + let store = Store::open(temp.path().join("work.sqlite")).unwrap(); + + let bad_process_id = new_id(); + store + .conn + .execute( + r#" + INSERT INTO capture_sources + ( + id, kind, provider, machine_id, process_id, cwd, raw_source_path, + external_session_id, started_at_ms, fidelity, sync_version + ) + VALUES (?1, 'provider_import', 'codex', 'test-machine', -1, '/repo', '/tmp/session.jsonl', 'session', 1, 'imported', 0) + "#, + params![bad_process_id.to_string()], + ) + .unwrap(); + assert_sql_conversion_error(store.get_capture_source(bad_process_id)); + + let bad_sync_version = new_id(); + store + .conn + .execute( + r#" + INSERT INTO capture_sources + ( + id, kind, provider, machine_id, cwd, raw_source_path, + external_session_id, started_at_ms, fidelity, sync_version + ) + VALUES (?1, 'provider_import', 'codex', 'test-machine', '/repo', '/tmp/session.jsonl', 'session', 1, 'imported', -1) + "#, + params![bad_sync_version.to_string()], + ) + .unwrap(); + assert_sql_conversion_error(store.get_capture_source(bad_sync_version)); + + let event = Event { + id: new_id(), + seq: 1, + history_record_id: None, + session_id: None, + run_id: None, + event_type: EventType::Message, + role: Some(EventRole::Assistant), + occurred_at: fixed_time(), + capture_source_id: None, + payload: serde_json::json!({"text": "negative seq marker"}), + payload_blob_id: None, + dedupe_key: None, + redaction_state: RedactionState::LocalPreview, + sync: sync_metadata(), + }; + store.upsert_event(&event).unwrap(); + store + .conn + .execute( + "UPDATE events SET seq = -1 WHERE id = ?1", + params![event.id.to_string()], + ) + .unwrap(); + assert_sql_conversion_error(store.get_event(event.id)); + assert_sql_conversion_error(store.search_event_hits("negative seq marker", 1)); + + let artifact = artifact_record(new_id(), 1); + store.upsert_artifact(&artifact).unwrap(); + store + .conn + .execute( + "UPDATE artifacts SET byte_size = -1 WHERE id = ?1", + params![artifact.id.to_string()], + ) + .unwrap(); + assert_sql_conversion_error(store.list_artifacts()); + } + + #[test] + fn raw_sql_query_rejects_excessive_result_preview_budget() { + let temp = tempdir(); + let store = Store::open(temp.path().join("work.sqlite")).unwrap(); + let many_columns = (0..RAW_SQL_MAX_COLUMNS_CAP) + .map(|index| format!("1 AS c{index}")) + .collect::>() + .join(", "); + let err = store + .raw_sql_query( + &format!("SELECT {many_columns}"), + RawSqlOptions { + max_rows: RAW_SQL_MAX_ROWS_CAP, + max_columns: RAW_SQL_MAX_COLUMNS_CAP, + max_value_bytes: 32, + ..RawSqlOptions::default() + }, + ) + .unwrap_err(); + assert!(matches!( + err, + StoreError::RawSqlResultBudgetTooLarge { + max_result_bytes: RAW_SQL_MAX_RESULT_PREVIEW_BYTES, + .. + } + )); + } + + #[test] + fn raw_sql_query_budgets_against_actual_column_count() { + let temp = tempdir(); + let store = Store::open(temp.path().join("work.sqlite")).unwrap(); + let result = store + .raw_sql_query( + "SELECT 1", + RawSqlOptions { + max_rows: RAW_SQL_MAX_ROWS_CAP, + max_columns: RAW_SQL_MAX_COLUMNS_CAP, + max_value_bytes: 32, + ..RawSqlOptions::default() + }, + ) + .unwrap(); + assert_eq!(result.returned_rows, 1); + assert_eq!(result.rows[0][0], RawSqlValue::Integer(1)); + } + #[test] fn raw_sql_query_times_out_long_running_queries() { let temp = tempdir(); @@ -7793,6 +9139,58 @@ mod catalog_tests { assert_eq!(file_status, ("pending".to_owned(), None, None, None)); } + #[test] + fn schema_v14_backfills_catalog_import_checkpoints() { + let temp = tempdir(); + let path = temp.path().join("work.sqlite"); + { + let conn = Connection::open(&path).unwrap(); + let legacy_sql = CREATE_TABLES_SQL + .replace(" last_imported_at_ms INTEGER,\n", "") + .replace(" last_imported_file_size_bytes INTEGER,\n", "") + .replace(" last_imported_file_modified_at_ms INTEGER,\n", "") + .replace(" last_imported_file_sha256 TEXT,\n", "") + .replace(" last_imported_event_count INTEGER,\n", ""); + conn.execute_batch(&legacy_sql).unwrap(); + conn.execute( + r#" + INSERT INTO catalog_sessions + ( + source_path, provider, source_format, source_root, external_session_id, + agent_type, file_size_bytes, file_modified_at_ms, cataloged_at_ms, + indexed_at_ms, indexed_file_size_bytes, indexed_file_modified_at_ms, + indexed_status, indexed_event_count + ) + VALUES + ( + '/tmp/codex/session.jsonl', 'codex', 'codex_rollout_jsonl', '/tmp/codex', + 'session-1', 'primary', 20, 30, 40, 50, 10, 15, 'pending', 7 + ) + "#, + [], + ) + .unwrap(); + conn.execute_batch("PRAGMA user_version = 13;").unwrap(); + } + + let store = Store::open(&path).unwrap(); + let version: i64 = store + .conn + .query_row("PRAGMA user_version", [], |row| row.get(0)) + .unwrap(); + assert_eq!(version, SCHEMA_VERSION); + + let checkpoint: (Option, Option, Option, Option) = store + .conn + .query_row( + "SELECT last_imported_at_ms, last_imported_file_size_bytes, last_imported_file_modified_at_ms, last_imported_event_count FROM catalog_sessions", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ) + .unwrap(); + assert_eq!(checkpoint, (Some(50), Some(10), Some(15), Some(7))); + } + fn legacy_history_record_sql(sql: &str) -> String { sql.replace("history_record_links", "work_record_links") .replace("history_record_tags", "work_record_tags") @@ -7811,6 +9209,7 @@ mod catalog_tests { for (provider, source_format) in [ ("copilot_cli", "copilot_cli_session_events_jsonl"), ("factory_ai_droid", "factory_ai_droid_sessions_jsonl"), + ("custom", "ctx_history_jsonl_v1"), ] { assert!( schema.contains(provider), @@ -7843,7 +9242,7 @@ mod catalog_tests { let source_count: i64 = store .conn .query_row( - "SELECT COUNT(*) FROM capture_sources WHERE provider IN ('copilot_cli', 'factory_ai_droid')", + "SELECT COUNT(*) FROM capture_sources WHERE provider IN ('copilot_cli', 'factory_ai_droid', 'custom')", [], |row| row.get(0), ) @@ -7851,12 +9250,167 @@ mod catalog_tests { let catalog_count: i64 = store .conn .query_row( - "SELECT COUNT(*) FROM catalog_sessions WHERE provider IN ('copilot_cli', 'factory_ai_droid')", + "SELECT COUNT(*) FROM catalog_sessions WHERE provider IN ('copilot_cli', 'factory_ai_droid', 'custom')", [], |row| row.get(0), ) .unwrap(); - assert_eq!(source_count, 2); - assert_eq!(catalog_count, 2); + assert_eq!(source_count, 3); + assert_eq!(catalog_count, 3); + } + + #[test] + fn archive_import_allows_multiple_capture_sources_for_same_provider_session() { + let temp = tempdir(); + let mut store = Store::open(temp.path().join("work.sqlite")).unwrap(); + let external_session_id = "provider-session-1"; + let first_source = provider_archive_source( + "018f45d0-0000-7000-8000-000000080001", + external_session_id, + "/tmp/provider/first.jsonl", + ); + let second_source = provider_archive_source( + "018f45d0-0000-7000-8000-000000080002", + external_session_id, + "/tmp/provider/second.jsonl", + ); + + store + .import_archive(&archive_with_source(first_source.clone()), false) + .unwrap(); + store + .import_archive(&archive_with_source(second_source.clone()), false) + .unwrap(); + + let sources = store.list_capture_sources().unwrap(); + assert_eq!(sources.len(), 2); + assert_eq!( + sources + .iter() + .map(|source| source.id) + .collect::>(), + BTreeSet::from([first_source.id, second_source.id]) + ); + assert!(sources + .iter() + .all(|source| source.descriptor.external_session_id.as_deref() + == Some(external_session_id))); + } + + fn archive_with_source(source: CaptureSource) -> SessionHistoryArchive { + SessionHistoryArchive { + capture_sources: vec![source], + ..SessionHistoryArchive::default() + } + } + + fn provider_archive_source( + id: &str, + external_session_id: &str, + raw_source_path: &str, + ) -> CaptureSource { + CaptureSource { + id: Uuid::parse_str(id).unwrap(), + descriptor: CaptureSourceDescriptor { + kind: ctx_history_core::CaptureSourceKind::ProviderImport, + provider: CaptureProvider::Claude, + machine_id: "test-machine".to_owned(), + process_id: None, + cwd: Some("/repo".to_owned()), + raw_source_path: Some(raw_source_path.to_owned()), + external_session_id: Some(external_session_id.to_owned()), + }, + started_at: fixed_time(), + ended_at: None, + sync: sync_metadata(), + } + } + + #[test] + fn schema_v15_rebuilds_provider_checks_with_referenced_sources_and_indexes() { + let temp = tempdir(); + let path = temp.path().join("work.sqlite"); + let source_id = new_id(); + let session_id; + let event_id; + { + let store = Store::open(&path).unwrap(); + let source = CaptureSource { + id: source_id, + descriptor: CaptureSourceDescriptor { + kind: ctx_history_core::CaptureSourceKind::ProviderImport, + provider: CaptureProvider::Codex, + machine_id: "test-machine".to_owned(), + process_id: None, + cwd: Some("/repo".to_owned()), + raw_source_path: Some("/home/user/.codex/sessions/session.jsonl".to_owned()), + external_session_id: Some("codex-session-1".to_owned()), + }, + started_at: fixed_time(), + ended_at: None, + sync: sync_metadata(), + }; + store.upsert_capture_source(&source).unwrap(); + + let mut session = imported_session("codex-session-1"); + session.capture_source_id = Some(source_id); + session_id = session.id; + store.upsert_session(&session).unwrap(); + + let event = Event { + id: new_id(), + seq: 0, + history_record_id: None, + session_id: Some(session_id), + run_id: None, + event_type: EventType::Message, + role: Some(EventRole::User), + occurred_at: fixed_time(), + capture_source_id: Some(source_id), + payload: serde_json::json!({"text": "migration source reference"}), + payload_blob_id: None, + dedupe_key: None, + redaction_state: RedactionState::LocalPreview, + sync: sync_metadata(), + }; + event_id = event.id; + store.upsert_event(&event).unwrap(); + store + .conn + .execute_batch("PRAGMA user_version = 14;") + .unwrap(); + } + + let store = Store::open(&path).unwrap(); + let version: i64 = store + .conn + .query_row("PRAGMA user_version", [], |row| row.get(0)) + .unwrap(); + assert_eq!(version, SCHEMA_VERSION); + let source_refs: i64 = store + .conn + .query_row( + "SELECT COUNT(*) FROM sessions s JOIN events e ON e.session_id = s.id \ + WHERE s.id = ?1 AND e.id = ?2 AND s.capture_source_id = ?3 AND e.capture_source_id = ?3", + params![session_id.to_string(), event_id.to_string(), source_id.to_string()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(source_refs, 1); + for index in [ + "idx_capture_sources_external_session_id", + "idx_catalog_sessions_provider_source_root_import", + "idx_source_import_files_provider_source_root_import", + ] { + let exists: i64 = store + .conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = ?1", + [index], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(exists, 1, "missing rebuilt index {index}"); + } } } diff --git a/crates/ctx-sdk/src/lib.rs b/crates/ctx-sdk/src/lib.rs index 46434df24..9f696e73a 100644 --- a/crates/ctx-sdk/src/lib.rs +++ b/crates/ctx-sdk/src/lib.rs @@ -115,6 +115,21 @@ impl Default for SearchOptions { } } +impl SearchOptions { + fn has_intent(&self) -> bool { + self.query + .as_deref() + .map(str::trim) + .is_some_and(|query| !query.is_empty()) + || self.terms.iter().any(|term| !term.trim().is_empty()) + || self + .file + .as_ref() + .map(|path| !path.to_string_lossy().trim().is_empty()) + .unwrap_or(false) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SearchRefresh { Auto, @@ -215,6 +230,13 @@ impl AgentHistoryClient { &self, options: SearchOptions, ) -> Result { + if !options.has_intent() { + return Err(AgentHistoryError::new( + AgentHistoryErrorCode::InvalidRequest, + "search requires a query, term, or file option", + false, + )); + } let mut owned = Vec::::new(); owned.push("search".to_owned()); if let Some(query) = options.query { @@ -675,6 +697,31 @@ mod tests { assert_eq!(options.terms, vec!["ctx"]); } + #[test] + fn search_requires_query_term_or_file_before_cli() { + let client = AgentHistoryClient::local(LocalBackendConfig { + ctx_binary: PathBuf::from("/definitely/missing/ctx"), + data_root: None, + timeout: Duration::from_secs(1), + }); + + for options in [ + SearchOptions::default(), + SearchOptions { + refresh: SearchRefresh::Off, + ..SearchOptions::default() + }, + SearchOptions { + query: Some(" ".to_owned()), + terms: vec!["".to_owned(), " ".to_owned()], + ..SearchOptions::default() + }, + ] { + let err = client.search(options).unwrap_err(); + assert_eq!(err.body.code, AgentHistoryErrorCode::InvalidRequest); + } + } + #[test] fn local_client_can_dogfood_fake_ctx_without_private_history() { let temp = tempfile::tempdir().unwrap(); diff --git a/docs/agent-usage.md b/docs/agent-usage.md index 459c3d70d..13a6e4433 100644 --- a/docs/agent-usage.md +++ b/docs/agent-usage.md @@ -18,13 +18,14 @@ ctx show event --window 5 ``` Normal `ctx search` uses `--refresh auto`, which can import newly discovered -provider history into the local ctx index before querying. Use -`ctx search ... --refresh off` when the task requires a strictly read-only -query over the existing index. +provider history into the local ctx index before querying. Rerun the same +search with `--refresh off` when the task requires a strictly read-only query +over the existing index. Use `ctx sql` only when normal search does not express the question, such as exact counts, joins, audits, or scripting over stable `ctx_*` views. It is -read-only and does not refresh or import provider history. +read-only and does not refresh or import provider history. See +`ctx docs show sql` for stable view schemas and examples. When ctx runs inside Codex and `CODEX_THREAD_ID` is available, search excludes the active Codex session tree by default to avoid returning the current prompt diff --git a/docs/assets/ctx-readme-banner.png b/docs/assets/ctx-readme-banner.png index bc4d4c346..6ebac3e10 100644 Binary files a/docs/assets/ctx-readme-banner.png and b/docs/assets/ctx-readme-banner.png differ diff --git a/docs/cli-reference.md b/docs/cli-reference.md index a9f656be8..5eed80ad6 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -28,8 +28,9 @@ ctx doctor --json - `setup` creates the data root, opens or creates `work.sqlite`, writes `config.toml` when needed, discovers known provider history locations, - catalogs Codex sessions, imports all discovered importable sources, optimizes - the local search index, and prints next steps. + catalogs Codex sessions, imports discovered native provider sources, optimizes + the local search index, and prints next steps. It does not execute + history-source plugin commands. - `setup --catalog-only` stops after discovery/cataloging. It is useful for fast inventory or troubleshooting, but it does not make history searchable. - `status` reports the ctx root, database path, config path, indexed item @@ -56,14 +57,23 @@ machine. Current rows include: - Codex session trees at `~/.codex/sessions`; - Codex prompt history at `~/.codex/history.jsonl`; -- Pi session JSONL at `~/.pi/sessions.jsonl`; -- native rows for supported Antigravity, Claude, OpenCode, Gemini, Cursor, - Copilot CLI, and Factory AI Droid local history locations. - -Each JSON row includes `provider`, `path`, `exists`, `source_format`, `status`, -`import_support`, `native_import`, `importable`, `raw_retention`, and any -`unsupported_reason`. `sources` reads home-directory path metadata and writes -nothing to provider files or source repositories. +- Pi session JSONL files under `~/.pi/agent/sessions`; +- native rows for supported Antigravity, Claude, OpenCode, OpenClaw, Hermes, + Gemini, Cursor, Copilot CLI, and Factory AI Droid local history locations; +- preview rows for NanoClaw project roots and AstrBot SQLite history when those + paths are discoverable; +- local history-source plugin manifests under `$CTX_DATA_ROOT/plugins` or + `CTX_HISTORY_PLUGIN_PATH`. + +Native JSON rows include `provider`, `path`, `exists`, `source_format`, +`status`, `import_support`, `native_import`, `importable`, `raw_retention`, and +any `unsupported_reason`. Plugin JSON rows use +`kind: "history_source_plugin"` and include `plugin`, `history_source`, +`provider_key`, `source_id`, `manifest_path`, and `enabled`. Invalid installed +plugin manifests appear as non-importable plugin rows with `status: "invalid"` +and an `error`. `sources` reads path metadata and plugin manifests, writes +nothing to provider files or source repositories, and does not execute plugin +commands. ## Import @@ -75,30 +85,64 @@ ctx import --provider pi ctx import --provider antigravity ctx import --provider claude ctx import --provider opencode +ctx import --provider openclaw +ctx import --provider hermes +ctx import --provider nanoclaw --path /path/to/nanoclaw-project +ctx import --provider astrbot --path /path/to/data/data_v4.db +ctx import --provider shelley --path ~/.config/shelley/shelley.db ctx import --provider gemini ctx import --provider cursor ctx import --provider copilot-cli ctx import --provider factory-ai-droid -ctx import --path ~/.codex/sessions -ctx import --provider pi --path ~/.pi/sessions.jsonl +ctx import --provider codex --path ~/.codex/sessions +ctx import --provider pi --path ~/.pi/agent/sessions +ctx import --format ctx-history-jsonl-v1 --path ./history.jsonl +ctx import --history-source example-agent/default +ctx import --history-source-manifest ./ctx-history-plugin.json +ctx import --history-source example-agent/default --reset-cursor ctx import --resume ctx import --json ctx import --progress json --json ``` `import` explicitly indexes provider history into the local SQLite store. The -normal first-run path is `ctx setup`, which already imports discovered sources. +normal first-run path is `ctx setup`, which already imports discovered native +provider sources. Use `import` to repair, re-run, resume, or target a specific provider/path. It creates the data root and default config if needed, reads provider transcript files, and writes indexed source metadata, sessions, events, searchable text, citations, and import totals to SQLite. +Custom history can be imported from an explicit JSONL file with +`--format ctx-history-jsonl-v1 --path `. This path is not discovered or +remembered as a provider home; see `docs/custom-history-import-format.md` for +the schema and incremental semantics. + +History-source plugins are local commands that stream `ctx-history-jsonl-v1` to +stdout. Use `--history-source ` for an explicit plugin import, or +`--history-source-manifest ` to test a manifest without installing it. +Selectors are exact `plugin/source` or +`provider_key/source_id` values. `--reset-cursor` withholds the previous plugin +cursor for that run and asks the plugin to perform a full rescan. See +`docs/history-source-plugins.md`. + Import selection rules: -- with no arguments or with `--all`, import all discovered sources that exist; +- with no arguments, import discovered native sources that exist; +- with `--all`, import discovered native sources that exist and enabled + history-source plugin sources; - with `--provider`, import discovered sources for that provider; -- with `--path`, import exactly that path; -- with `--path` and no provider, parse the path as Codex format. +- with `--format ctx-history-jsonl-v1 --path `, import that custom + history JSONL file; +- with `--history-source`, import matching local plugin sources; +- with `--history-source-manifest`, import sources from that manifest path; +- with `--provider --path `, import exactly that native + provider path. + +Preview providers such as NanoClaw and AstrBot are not included in `--all` or +pre-search refresh. Import them explicitly with `--provider` when discovery +finds the desired source, or add `--path` to target a specific source, then +search the existing index. The current `--resume` flag is an idempotent-rescan mode marker. JSON reports `resume: true` and `resume_mode: "idempotent_rescan"`, but provider-native @@ -154,28 +198,43 @@ ctx search "token budget" --refresh off ctx search "signed metadata" --term checksum --term release ctx search "token budget" --limit 5 ctx search "token budget" --session +ctx search "review findings" --include-subagents ctx search "this current task" --include-current-session +ctx search "release notes" --history-source example-agent/default +ctx search "release notes" --provider-key example-agent --source-id default ``` `search` defaults to `--refresh auto`, which quietly refreshes discovered native -provider sources before querying indexed sessions and events. The refresh is -best-effort and keeps JSON stdout reserved for the search result object. On -large discovered sources or already-cataloged indexes, `auto` serves current -results without a foreground catch-up scan; use `--refresh strict` or -`ctx import --all` when you need a full catch-up before querying. Use -`--refresh off` to search the existing index without refreshing, or +provider sources and enabled auto history-source plugins before querying indexed +sessions and events. The refresh is best-effort and keeps JSON stdout reserved +for the search result object. On large discovered sources or already-cataloged +indexes, `auto` serves current results without a foreground catch-up scan; use +`--refresh strict` or `ctx import --all` when you need a full catch-up before +querying. Use `--refresh off` to search the existing index without refreshing, or `--refresh strict` to fail when the pre-search refresh cannot run or import -successfully. Search-only sources without native import support are searched +successfully. Preview native sources such as NanoClaw and AstrBot are searched from the existing index until they are explicitly imported through a supported -path. The query argument is optional so file or metadata filters can drive a -search. Default results are session-diverse: ctx +path. Search requires a non-empty query, at least one non-empty `--term`, or +`--file `; provider, workspace, time, session, event, source, and result +flags only narrow an actual search. Default results are session-diverse: ctx returns the strongest matching span from each session, plus `more_matches_in_session` and `session_importance` when more indexed events from that session also matched. Use `--session ` after a default search has identified a session to inspect; scoped session search returns dense -event hits. Use `--events` without `--session` for dense event-level results -across sessions. Repeat `--term ` when you want to broaden a -search across several related words or phrases and merge the ranked results. +event hits. Session/event commands accept full ctx IDs or unambiguous ctx ID +prefixes of at least eight hex characters. Use `--events` without `--session` +for dense event-level results across sessions. Repeat +`--term ` when you want to broaden a search across several +related words or phrases and merge the ranked results; `--term` is OR-style +broadening, not a must-include filter. +Custom history imports can be filtered by `--history-source` using +`plugin/source` or `provider_key/source_id`, or by exact `--provider-key`, +`--source-id`, and `--source-format` values. These filters imply +`--provider custom` and cannot be combined with another provider. +Default search excludes subagent sessions so primary human-agent intent and +decisions stay prominent. Use `--include-subagents` when implementation details, +code review notes, test output, or failure analysis from subagent sessions +should be searched too. When ctx is run from Codex and `CODEX_THREAD_ID` is available, search excludes the active Codex session tree by default so the current task and its subagents @@ -193,23 +252,32 @@ optimized for agent reading; use `--verbose` for expanded text diagnostics. Filters: -- `--provider codex|pi|claude|opencode|antigravity|gemini|cursor|copilot-cli|factory-ai-droid`; -- `--workspace `; +- `--provider codex|pi|claude|opencode|openclaw|hermes|nanoclaw|astrbot|shelley|antigravity|gemini|cursor|copilot-cli|factory-ai-droid|custom`; +- `--workspace `, substring match over stored workspace, cwd, + source path, or repository-name text; - `--since d`, for example `2026-06-01T00:00:00Z` or `30d`; -- `--event-type `; -- `--file `; -- `--session `, for dense event results within one session; -- `--term `, repeatable broadening terms merged with the main query; +- `--event-type `, one of `message`, `tool_call`, `tool_output`, + `command_started`, `command_output`, `command_finished`, `file_touched`, + `vcs_change`, `artifact`, `summary`, or `notice`; +- `--file `, indexed touched-file path metadata, not the current + filesystem; +- `--session `, for dense event results within one session; +- `--term `, repeatable broadening terms merged with OR-style semantics; - `--events`, for dense event-level results instead of the default session-diverse results; -- `--primary-only`; - `--include-subagents`; - `--limit `, capped at `200`; - `--refresh auto|off|strict`; - `--include-current-session`. -`search` reads discovered native provider files for pre-search refresh plus -SQLite, and may write newly discovered native provider history into the local -index before querying. +CLI provider filters use kebab-case names. JSON output and stable SQL views use +provider IDs in ctx output; multiword IDs may be snake_case, such as +`copilot_cli` or `factory_ai_droid`, while compact IDs such as `openclaw`, +`nanoclaw`, `astrbot`, and `shelley` stay compact. + +`search` reads discovered native provider files and runs enabled auto +history-source plugin commands for pre-search refresh, then queries SQLite. It +may write newly discovered provider or plugin history into the local index before +querying. ## SQL @@ -322,6 +390,9 @@ binary, such as `~/.local/bin/ctx.install.json`, recording the managed install path, platform, version, channel, binary SHA-256, metadata URL, and artifact URL. Source builds, `cargo install`, package-manager installs, copied binaries, and mismatched sidecars are treated as unmanaged and will not self-upgrade. +`ctx upgrade status --json` also reports the current executable and every `ctx` +binary found on `PATH`, with warnings when an older binary shadows the managed +install or multiple `ctx` binaries are present. Official installer-managed installs default to background auto-upgrade after successful normal commands when signed release metadata explicitly allows @@ -367,7 +438,7 @@ ctx show session --format json ctx show event --format json ctx locate session --format json ctx locate event --format json -ctx search [query] --json +ctx search |--term |--file --json ctx sql "SELECT COUNT(*) FROM ctx_sessions" --json ctx docs list --json ctx docs search --json diff --git a/docs/contracts/json.md b/docs/contracts/json.md index 8f5d929a0..06ad01378 100644 --- a/docs/contracts/json.md +++ b/docs/contracts/json.md @@ -163,6 +163,13 @@ Writes nothing and returns: `occurred_at`, `source`, `cursor`, `text` or `preview`, and `redaction_state`. +`redaction_state` values describe local payload handling, not whether a row is +safe to publish. In particular, `safe_preview` is legacy contract spelling for a +local searchable preview: the text may be truncated or projected from provider +payloads, but it can still include absolute paths, token-shaped strings, command +output, and other private transcript content. Treat `safe_preview` output as +private unless a user separately reviews and redacts it. + ## Locate ```bash @@ -199,7 +206,7 @@ artifact. JSON and JSONL artifact rows use the same ctx-owned ID fields as ## Search ```bash -ctx search [query] --json +ctx search |--term |--file --json ``` Returns: @@ -256,7 +263,7 @@ external publication. - `error`, present when refresh failed but results were still served. `suggested_next_commands` can include `ctx show event`, `ctx show session`, -`ctx search ... --session `, `ctx locate event`, and +`ctx search "" --session `, `ctx locate event`, and `ctx locate session` command strings when the required ctx IDs are known. When ctx can identify the active Codex provider session through diff --git a/docs/custom-history-import-format.md b/docs/custom-history-import-format.md new file mode 100644 index 000000000..29047965a --- /dev/null +++ b/docs/custom-history-import-format.md @@ -0,0 +1,241 @@ +# Custom History Import Format + +`ctx-history-jsonl-v1` is the public JSONL format for importing session history +from tools without a built-in local-history adapter. + +## Transports + +The same JSONL schema can be imported from an explicit local file path: + +```bash +ctx import --format ctx-history-jsonl-v1 --path ./history.jsonl +``` + +or from a local history-source plugin command: + +```bash +ctx import --history-source my-agent/default +``` + +ctx does not discover a fixed storage location for this format. File imports +are explicit paths. Plugin imports run local commands declared by a local +manifest; see `docs/history-source-plugins.md`. + +Each line is one JSON object. Every object has a `record_type` field with one +of: + +- `manifest` +- `source` +- `session` +- `event` +- `file_touch` +- `edge` + +Record order is flexible, but exporters should write a manifest first, then +source and session records before their dependent events, file touches, and +edges. Unknown fields are ignored unless they are inside `metadata`, `payload`, +or another explicitly documented open object. + +## Manifest + +Exactly one manifest record should appear near the top of the file. + +Required fields: + +- `schema_version`: must be `"ctx-history-jsonl-v1"`. + +Example: + +```json +{"record_type":"manifest","schema_version":"ctx-history-jsonl-v1","metadata":{"exporter":"example"}} +``` + +## Source + +A source describes the exporting system, input corpus, or incremental cursor. + +Required fields: + +- `source_id` +- `provider_key` +- `source_format` + +Optional fields: + +- `raw_uri` +- `raw_source_path` +- `fingerprint` +- `importer_version` +- `observed_at` +- `machine_id` +- `cursor` +- `metadata` + +`provider_key` is the exporter-owned namespace, such as `my-agent` or +`internal-build-bot`. Internally ctx stores these rows under the bounded +provider value `custom`, then derives internal session IDs from the structured +`provider_key`, `source_id`, and `session_id` tuple. Public provider, source, +and session IDs are preserved in metadata for display and lookup. + +`provider_key` must be 1 to 128 bytes, start with a lowercase ASCII letter or +digit, and contain only lowercase ASCII letters, digits, `.`, `_`, or `-`. + +Example: + +```json +{"record_type":"source","source_id":"laptop-main","provider_key":"my-agent","source_format":"my-agent-export-v3","raw_source_path":"/home/me/.my-agent/history.jsonl","cursor":{"after":{"stream":"my-agent:laptop-main","cursor":"171","observed_at":"2026-06-23T12:00:00Z"}},"metadata":{"team":"tools"}} +``` + +## Session + +A session describes one conversation, task, or agent run. + +Required fields: + +- `source_id` +- `session_id` +- `started_at` + +Optional fields: + +- `parent_session_id` +- `root_session_id` +- `native_session_id` +- `cwd` +- `ended_at` +- `agent_type` +- `role_hint` +- `is_primary` +- `status` +- `metadata` + +Use `parent_session_id` and `root_session_id` to model subagents, forks, +handoffs, or resumed tasks when the exporter knows those relationships. + +Example: + +```json +{"record_type":"session","source_id":"laptop-main","session_id":"run-1","native_session_id":"abc123","cwd":"/workspace/app","started_at":"2026-06-23T12:00:00Z","agent_type":"primary","role_hint":"developer","is_primary":true,"status":"completed"} +``` + +## Event + +An event is a time-ordered item inside a session. + +Required fields: + +- `source_id` +- `session_id` +- `event_index`: unsigned 64-bit integer. +- `occurred_at` + +Optional fields: + +- `event_id` +- `native_cursor` +- `event_type` +- `role` +- `payload` +- `preview` +- `metadata` + +`event_index` is the stable exporter order within the session. Use +`native_cursor` for provider cursor tokens or byte offsets that should survive +re-imports. `payload` is open JSON; `preview` should be a bounded searchable +summary when payloads are large or sensitive. When `preview` is present, ctx +uses it as the event's searchable payload and preserves any non-empty `payload` +under import metadata. + +Example: + +```json +{"record_type":"event","source_id":"laptop-main","session_id":"run-1","event_index":0,"event_type":"message","role":"user","occurred_at":"2026-06-23T12:00:01Z","payload":{"text":"Find the failing test."},"preview":"Find the failing test.","native_cursor":"line:42"} +``` + +## File Touch + +A file touch records a path that the session read, wrote, created, deleted, or +renamed. + +Required fields: + +- `source_id` +- `session_id` +- `touch_index`: unsigned 64-bit integer. +- `path` +- `occurred_at` + +Optional fields: + +- `event_index` +- `change_kind` +- `old_path` +- `line_count_delta` +- `confidence` +- `metadata` + +`event_index` links the touch to an event when known. Use `old_path` for +renames, `line_count_delta` for approximate net line changes, and `confidence` +when a touch is inferred from text rather than structured tool output. + +Example: + +```json +{"record_type":"file_touch","source_id":"laptop-main","session_id":"run-1","touch_index":0,"event_index":1,"path":"crates/app/src/lib.rs","change_kind":"modified","line_count_delta":12,"confidence":"high","occurred_at":"2026-06-23T12:00:03Z"} +``` + +## Edge + +An edge records a relationship between two sessions from the same source. + +Required fields: + +- `source_id` +- `from_session_id` +- `to_session_id` +- `edge_type` + +Optional fields: + +- `edge_id` +- `confidence` +- `occurred_at` +- `metadata` + +Example: + +```json +{"record_type":"edge","source_id":"laptop-main","from_session_id":"run-1","to_session_id":"run-1-worker","edge_type":"spawned","confidence":"explicit","occurred_at":"2026-06-23T12:00:05Z"} +``` + +## Incremental Semantics + +v1 imports are explicit, local, and idempotent. On each file import, ctx rescans +the file and upserts equivalent records instead of appending duplicates. On each +plugin import, ctx invokes the plugin, validates stdout atomically, and upserts +the emitted records. + +When a source record supplies `cursor`, ctx rewrites its storage stream under a +`provider:custom::` namespace and also preserves the +exporter-supplied cursor object in source metadata. Event `native_cursor` values +are also preserved. + +For plugin imports, ctx passes the previously stored source cursor to the next +command through `CTX_HISTORY_CURSOR` for small cursors and always through +`CTX_HISTORY_CURSOR_FILE` when a previous cursor exists. The cursor string +remains exporter-owned, so it can encode byte offsets, SQLite row ids, session +sequence maps, or another native high-water mark. + +If an import is interrupted, run the same command again. File imports perform +another idempotent rescan. Plugin imports receive the last successfully stored +cursor; failed plugin runs do not advance it. + +## Compact Example + +```jsonl +{"record_type":"manifest","schema_version":"ctx-history-jsonl-v1"} +{"record_type":"source","source_id":"demo-source","provider_key":"demo-agent","source_format":"demo-jsonl","raw_source_path":"/tmp/demo-history.jsonl","cursor":{"after":{"stream":"demo-agent:demo-source","cursor":"3","observed_at":"2026-06-23T12:00:00Z"}}} +{"record_type":"session","source_id":"demo-source","session_id":"demo-session","cwd":"/workspace/demo","started_at":"2026-06-23T12:00:00Z","agent_type":"primary","role_hint":"developer","is_primary":true,"status":"completed"} +{"record_type":"event","source_id":"demo-source","session_id":"demo-session","event_index":0,"event_type":"message","role":"user","occurred_at":"2026-06-23T12:00:01Z","payload":{"text":"Add a parser test."},"preview":"Add a parser test.","native_cursor":"line:1"} +{"record_type":"file_touch","source_id":"demo-source","session_id":"demo-session","touch_index":0,"event_index":0,"path":"tests/parser.rs","change_kind":"modified","confidence":"high","occurred_at":"2026-06-23T12:00:02Z"} +``` diff --git a/docs/docs.md b/docs/docs.md new file mode 100644 index 000000000..c77d33bbf --- /dev/null +++ b/docs/docs.md @@ -0,0 +1,38 @@ +# Docs + +`ctx docs` exposes curated public ctx documentation embedded in the installed +binary. It is for humans and agents that need local command help without +opening a website or reading repository files. + +```bash +ctx docs +ctx docs list +ctx docs list --json +ctx docs search "file path" +ctx docs search "upgrade" --limit 5 --json +ctx docs show cli-reference +ctx docs show search --format text +ctx docs show json-contracts --format json +ctx docs man --print ctx +ctx docs man --out ~/.local/share/man/man1 +``` + +`ctx docs list`, `ctx docs search`, and `ctx docs show` read embedded text and +do not touch provider history or the local SQLite index. `ctx docs show --out +PATH` writes one embedded topic to that explicit path. + +`ctx docs man --print PAGE` prints one generated man page to stdout. `ctx docs +man --out DIR` writes generated section-1 man pages for `ctx` and its public +subcommands. + +Agents should usually use `ctx docs search` or `ctx docs show` rather than +shelling through `man`, because the docs commands return concise markdown, +text, or JSON that is easier for agents to inspect and cite. + +Useful starting points: + +- `ctx docs show search` for search filters and output behavior; +- `ctx docs show sql` for stable read-only SQL views; +- `ctx docs show mcp` for read-only MCP tools; +- `ctx docs show upgrade` for managed upgrade and auto-upgrade behavior; +- `ctx docs show json-contracts` for structured output contracts. diff --git a/docs/first-10-minutes.md b/docs/first-10-minutes.md index 0eddca2da..a9af2223e 100644 --- a/docs/first-10-minutes.md +++ b/docs/first-10-minutes.md @@ -26,8 +26,9 @@ ctx status --json ``` `ctx setup` creates local storage, discovers supported provider history, -catalogs Codex sessions, imports discovered sources, and optimizes the local -search index. The default root is `~/.ctx`. Use a temporary root for trials: +catalogs Codex sessions, imports discovered native provider sources, and +optimizes the local search index. It does not execute history-source plugin +commands. The default root is `~/.ctx`. Use a temporary root for trials: ```bash ctx --data-root /tmp/ctx-first-10 setup @@ -41,11 +42,13 @@ ctx sources --json ``` Expect rows for supported local import providers such as Codex, Pi, -Antigravity, Claude, OpenCode, Gemini, Cursor, Copilot CLI, and Factory AI -Droid. A row with `exists: false` means ctx knows the default path but did not -find local history there. A JSON row with `status: "empty"` means the path -exists but no provider-specific transcript files were found. A row with -`status: "unknown"` means the bounded transcript probe hit its scan budget. +Antigravity, Claude, OpenCode, OpenClaw, Hermes, Gemini, Cursor, Copilot CLI, +and Factory AI Droid. NanoClaw and AstrBot can appear as preview rows when ctx +can discover their local project or SQLite paths. A row with `exists: false` +means ctx knows the default path but did not find local history there. A JSON +row with `status: "empty"` means the path exists but no provider-specific +transcript files were found. A row with `status: "unknown"` means the bounded +transcript probe hit its scan budget. ## 4. Re-Run Or Target Imports @@ -53,15 +56,25 @@ exists but no provider-specific transcript files were found. A row with ctx import --all ``` -Setup already imports discovered sources. Use `ctx import` when you want to -repair, re-run, resume, or pass an explicit path: +Setup already imports discovered auto-importable sources. Use `ctx import` when +you want to repair, re-run, resume, or pass an explicit path: ```bash ctx import --provider codex --path ~/.codex/sessions -ctx import --provider pi --path ~/.pi/sessions.jsonl +ctx import --provider pi --path ~/.pi/agent/sessions ctx import --provider cursor --path ~/.cursor/projects +ctx import --provider hermes --path ~/.hermes/state.db +ctx import --provider nanoclaw --path /path/to/nanoclaw-project +ctx import --provider astrbot --path /path/to/data/data_v4.db +ctx import --provider shelley --path ~/.config/shelley/shelley.db ``` +Preview providers such as NanoClaw and AstrBot are explicit-import only. Use +`ctx import --provider nanoclaw` or `ctx import --provider astrbot` when +discovery finds the desired source, or add `--path` to target a specific source. +They are not included in `ctx import --all` or the default pre-search refresh +until their storage contracts are promoted. + After upgrading from an older ctx version, the first refresh or import can re-read previously indexed provider transcripts once so the local index includes current touched-file metadata and unredacted local transcript text. @@ -74,8 +87,9 @@ ctx search "build failure" --term checksum --term release --limit 5 ``` `--limit` is capped at `200`. Search defaults to `--refresh auto`, which -best-effort refreshes discovered native provider sources before querying; use -`--refresh off` to search only the existing index. +best-effort refreshes discovered native provider sources and enabled auto +history-source plugins before querying; use `--refresh off` to search only the +existing index. Inside Codex, ctx excludes the active session tree by default when it can identify it, so your current prompt and subagents do not dominate results. Add @@ -102,13 +116,14 @@ ctx upgrade status ``` `ctx docs` is embedded in the binary for humans and agents. `ctx upgrade status` -shows whether the current binary is managed by the official installer and -eligible for signed self-upgrades. +shows whether the current binary is managed by the official installer, eligible +for signed self-upgrades, and shadowed by another `ctx` binary on `PATH`. ## Failure Paths - No sources listed: this machine may not have supported local provider - history. Use `ctx import --path` only for a known supported format. + history. Use `ctx import --provider --path ` only for a + known supported native provider format. - Import fails on a file: rerun with `--json` and inspect the per-source `failed` count. - Search returns no results: confirm `ctx status` shows indexed items, then diff --git a/docs/getting-started.md b/docs/getting-started.md index 10b227c94..3ac5b604d 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -28,8 +28,9 @@ ctx status Setup creates the configured ctx data root, initializes SQLite, writes `config.toml` when missing, discovers known provider history paths, catalogs -Codex sessions, imports discovered sources, optimizes the local search index, -and prints next steps. The default data root is `~/.ctx`. +Codex sessions, imports discovered native provider sources, optimizes the local +search index, and prints next steps. It does not execute history-source plugin +commands. The default data root is `~/.ctx`. Use a different root when testing: @@ -64,7 +65,7 @@ ctx import --all ctx import --provider codex ctx import --provider pi ctx import --provider cursor -ctx import --path ~/.codex/sessions +ctx import --provider codex --path ~/.codex/sessions ctx import --resume --json ``` @@ -78,7 +79,8 @@ After upgrading an older data root to `0.10.x` or newer, the first refresh or im re-read previously indexed provider transcripts once. That rebuilds search content with touched-file metadata and local/private transcript text. -When `--path` is used without `--provider`, ctx treats the path as Codex format. +Native provider `--path` imports require `--provider`. Custom JSONL imports use +`--format ctx-history-jsonl-v1 --path ` instead. ## 5. Search @@ -91,8 +93,9 @@ ctx show session Use `ctx_event_id` with `ctx show event` when you need a hit plus surrounding events. Use `ctx_session_id` with `ctx show session` when you need the -transcript. Search also accepts filters such as `--provider`, `--workspace`, -`--since`, `--event-type`, `--file`, `--primary-only`, `--include-subagents`, +transcript. Commands accept full ctx IDs or unambiguous ID prefixes of at least +eight hex characters. Search also accepts filters such as `--provider`, +`--workspace`, `--since`, `--event-type`, `--file`, `--include-subagents`, `--include-current-session`, `--term`, `--limit`, and `--refresh auto|off|strict`. `--limit` is capped at `200`. diff --git a/docs/history-source-plugin-design.md b/docs/history-source-plugin-design.md new file mode 100644 index 000000000..7f2d9ce60 --- /dev/null +++ b/docs/history-source-plugin-design.md @@ -0,0 +1,349 @@ +# History Source Plugin Design + +This document describes history source plugins as implemented on +`codex/history-source-plugins`. + +## Problem + +ctx has first-party local history adapters for common agent tools, but the agent +ecosystem changes quickly and many tools use custom local storage. Maintaining a +native adapter for every tool would couple ctx to unstable schemas owned by other +projects. + +Plugins let tools that ctx does not support natively make their history +searchable without ctx learning their storage shape. + +The integration must: + +- work fully locally; +- support incremental refresh before `ctx search`; +- avoid a hosted plugin store or in-process extension ABI; +- keep adapter ownership with the third-party tool or user; +- reuse the existing ctx capture, store, and search pipeline; +- provide a batch escape hatch for tools that only write files. + +## Non-Goals + +This design does not add: + +- an in-process plugin ABI; +- a remote marketplace; +- background daemon scheduling; +- plugin installation management; +- native adapters for every third-party agent; +- a guarantee that plugin commands are sandboxed from the local user account. + +Plugins are local commands. A user or local tool that installs a plugin is +choosing to run that command with the user's normal local permissions. + +## User Model + +There are two supported paths. + +The preferred path for ongoing integrations is a history source plugin: + +1. A manifest declares one or more local history sources. +2. ctx discovers the manifest. +3. ctx runs the source command during explicit import or search refresh. +4. The command writes `ctx-history-jsonl-v1` records to stdout. +5. ctx imports the stream and stores the latest cursor. +6. On the next run, ctx passes that cursor back to the command. + +The optional batch path is a file import: + +1. A tool writes `ctx-history-jsonl-v1` records to a file. +2. The user or tool runs `ctx import --format ctx-history-jsonl-v1 --path ...`. +3. ctx imports the file idempotently. + +The file path is useful for simple exporters, debugging, and one-time imports. +It is not the best path for day-to-day refresh because ctx cannot discover, +invoke, or cursor an arbitrary file writer by itself. + +## Public Contracts + +The feature has two public contracts. + +### Manifest Contract + +A plugin manifest is JSON at `ctx-history-plugin.json`. + +Manifests can be discovered from: + +- `$CTX_DATA_ROOT/plugins//ctx-history-plugin.json`; +- entries in `CTX_HISTORY_PLUGIN_PATH`. + +The implemented schema is: + +```json +{ + "schema_version": 1, + "name": "example-agent", + "display_name": "Example Agent", + "version": "0.1.0", + "history_sources": [ + { + "id": "default", + "display_name": "Example local history", + "provider_key": "example-agent", + "source_id": "default", + "source_format": "example-agent-sqlite-v1", + "enabled": true, + "refresh": "auto", + "command": ["example-agent-to-ctx", "export"], + "working_dir": ".", + "env": { + "EXAMPLE_AGENT_PROFILE": "default" + }, + "timeout_seconds": 300 + } + ] +} +``` + +`schema_version`, `name`, `history_sources[].id`, `source_format`, and +`command` are required. + +`provider_key` defaults to the manifest `name`. `source_id` defaults to the +source `id`. `enabled` defaults to `false`. `refresh` defaults to `manual`. +`timeout_seconds` defaults to 300 seconds and is clamped to at least 1 second. + +Identifiers must be stable lowercase ASCII values with digits, `.`, `_`, or +`-`. They must start with a lowercase ASCII letter or digit and be no more than +128 bytes. + +`command` is an argv array. ctx does not execute it through a shell. + +### Stream Contract + +Plugin commands and batch files emit `ctx-history-jsonl-v1`. + +Each line is a JSON object with one of these `record_type` values: + +- `manifest`; +- `source`; +- `session`; +- `event`; +- `file_touch`; +- `edge`. + +The stream contract intentionally mirrors the normalized shape ctx already +stores: + +- source metadata identifies the exporter, native format, cursor, machine, and + raw input; +- sessions represent conversations, tasks, runs, branches, or subagents; +- events represent ordered messages and tool events; +- file touches connect history to code search and audit workflows; +- edges preserve parent-child, spawned, forked, resumed, or related sessions. + +ctx stores these imports under the bounded internal provider `custom`, while +preserving exporter-owned `provider_key`, `source_id`, `source_format`, +`session_id`, and native metadata. + +## Runtime Contract + +Before running a plugin command, ctx sets: + +- `CTX_DATA_ROOT`; +- `CTX_HISTORY_PLUGIN=1`; +- `CTX_HISTORY_PLUGIN_NAME`; +- `CTX_HISTORY_PLUGIN_MANIFEST`; +- `CTX_HISTORY_SOURCE`, such as `example-agent/default`; +- `CTX_HISTORY_SOURCE_ID`; +- `CTX_HISTORY_PROVIDER_KEY`; +- `CTX_HISTORY_SOURCE_FORMAT`; +- `CTX_HISTORY_CURSOR_STREAM`; +- `CTX_HISTORY_MACHINE_ID`; +- `CTX_HISTORY_FULL_RESCAN`, `1` or `0`; +- `CTX_HISTORY_CURSOR`, when a previous cursor exists and is small enough; +- `CTX_HISTORY_CURSOR_FILE`, a temporary file containing the previous cursor. + +Plugins should read `CTX_HISTORY_CURSOR_FILE` first. Inline cursor environment +variables are only a convenience for small cursors. + +Plugins must write only `ctx-history-jsonl-v1` to stdout. Progress and warnings +belong on stderr. + +ctx clears the inherited environment and re-adds a small allowlist: + +- `PATH`; +- `HOME`; +- user and locale variables; +- temporary-directory variables; +- XDG data, config, cache, and state roots. + +Manifest `env` entries are then added. This avoids accidental dependence on the +parent shell while still allowing plugin authors to pass explicit configuration. + +## Incremental Semantics + +The plugin owns cursor meaning. ctx treats the cursor as an opaque string. + +Examples: + +- append-only files can use byte offsets; +- SQLite stores can use row ids; +- split stores can use JSON maps keyed by session id or file path; +- API-backed local tools can use an opaque sync token. + +On a successful import, ctx stores the cursor emitted by the plugin's `source` +record. Failed runs do not advance the cursor. + +`ctx import --history-source ... --reset-cursor` withholds the previous cursor +and sets `CTX_HISTORY_FULL_RESCAN=1`. A reset plugin run must emit a fresh +`source.cursor.after` checkpoint; otherwise ctx rejects the run so an old stored +cursor cannot be reused accidentally. + +`ctx search` uses the same pre-search refresh model as native provider sources: + +- `--refresh auto` best-effort refreshes enabled auto plugins and then searches + the current index; +- `--refresh strict` fails if refresh cannot complete; +- `--refresh off` never executes plugin commands. + +Provider-filtered search only runs plugin refresh when the provider filter is +`custom` or absent. + +## Import And Discovery Behavior + +`ctx sources` lists plugin sources without executing plugin commands. + +`ctx import --history-source ` runs exactly one matching source. +Selectors can match: + +- `plugin/source`; +- `provider_key/source_id`. + +The selector must resolve to one source before ctx runs anything. + +`ctx import --history-source-manifest ` adds a manifest for the current +command without installing it. + +`ctx import --all` includes enabled plugin sources, plus discovered native +provider sources. + +`ctx setup` does not execute plugin commands. + +## Failure Model + +Plugin runs fail closed for that run: + +- nonzero exit status fails the run; +- invalid stdout fails the run; +- stdout over 64 MiB fails the run; +- stderr over 256 KiB fails the run; +- timeout fails the run; +- source identity mismatches fail before records are imported. + +For explicit single-source imports, failures are returned to the user. For +`ctx import --all`, plugin failures can be reported as source failures without +discarding successful imports from other sources. For `ctx search --refresh +auto`, failures are recorded as refresh failures and search continues against +the existing index. + +The cursor only advances after a successful source import, so the usual recovery +path is to fix the plugin and run the same command again. + +## Security And Trust + +This reduces ctx's native schema maintenance burden. It does not make +third-party code harmless. A plugin command is local code. It can read whatever +the current user can read unless the operating system or user wraps it in +additional isolation. + +The implemented mitigations are practical guardrails: + +- commands are argv arrays, not shell strings; +- ctx clears the environment and re-adds only a small allowlist; +- stdin is closed; +- stdout, stderr, and runtime are bounded; +- cursor files are private temporary files on Unix; +- plugin discovery never executes commands; +- invalid installed manifests are reported by `ctx sources` as non-importable + rows; +- selectors fail before execution unless they identify exactly one source. + +The product should describe plugins as local adapters, not as trusted apps from +ctx. + +## Why This Is Smaller Than Native Adapter Expansion + +Adding a native adapter requires ctx to own: + +- discovery paths; +- native schema parsing; +- incremental logic; +- storage migrations or upstream compatibility breaks; +- tests and fixtures for that provider forever. + +The plugin model keeps ctx's owned surface to: + +- one manifest schema; +- one stream schema; +- one command runtime; +- one cursor handoff; +- common validation and import behavior. + +That is still a public API commitment, but it is a narrower and more durable +commitment than chasing every custom agent database. + +## Why Keep Batch File Import + +The batch importer uses the same stream parser as plugins. Keeping it provides a +low-friction path for: + +- one-off migration; +- local debugging; +- agents that can write a file but cannot easily be invoked by ctx; +- support reproduction cases; +- tests for the stream contract independent of process execution. + +The UX distinction should stay clear: + +- use a plugin for ongoing search-time refresh; +- use a file for explicit batch import. + +## Current Implementation + +The branch adds: + +- `crates/ctx-history-core/src/history_jsonl.rs` for typed + `ctx-history-jsonl-v1` records; +- custom-history normalization and import in `ctx-history-capture`; +- `crates/ctx-cli/src/history_source_plugins.rs` for manifest discovery, + command execution, cursor environment, timeout, and output limits; +- CLI support for `--history-source` and `--history-source-manifest`; +- search refresh support for enabled auto plugin sources; +- source listing for valid and invalid installed plugin manifests; +- source-aware search filters for `--history-source`, `--provider-key`, + `--source-id`, and `--source-format`; +- plugin identity metadata on imported custom sources; +- docs for the stream format and plugin manifest; +- tests for schema round trips, malformed streams, idempotency, cursors, + discovery, explicit imports, `import --all`, search refresh, failures, and + timeouts. + +## Open Questions Before Shipping + +The implementation is mergeable, but these are the product/API questions worth +settling before a stable release: + +- Should `ctx-history-jsonl-v1` be documented as stable immediately, or marked + preview while plugin feedback is collected? +- Should direct file import stay in public CLI help, or be documented as a + batch/debug path behind the plugin story? +- Should the manifest support a semver range for stream schema versions before + v2 exists, or is `schema_version: 1` enough for now? +- Should plugin commands receive `CTX_HISTORY_CURSOR_FILE` even when no cursor + exists, containing a well-known empty value, or is absence simpler? +- Should auto-refresh plugin failures appear more visibly in normal human + `ctx search` output, or is current best-effort behavior enough? + +## Recommendation + +Ship this after final API wording review. It solves a real integration problem +with a small local contract, keeps ctx out of third-party storage schemas, and +preserves the native-provider experience of incremental refresh before search. + +Keep the batch file importer, but position it as an optional explicit path. The +preferred ongoing integration should remain manifest plus command stdout. diff --git a/docs/history-source-plugins.md b/docs/history-source-plugins.md new file mode 100644 index 000000000..1af347a03 --- /dev/null +++ b/docs/history-source-plugins.md @@ -0,0 +1,243 @@ +# History Source Plugins + +History source plugins let local tools make their histories searchable in ctx +without ctx owning their storage schemas. + +A plugin integration works like this: + +1. A local manifest declares one or more history sources. +2. ctx invokes enabled auto-refresh commands during search refresh, or any + selected source during explicit import. +3. The command writes `ctx-history-jsonl-v1` records to stdout. +4. The stream is checked and imported as one batch. +5. ctx passes the previous source cursor back on the next run. + +Plugins run as local commands. ctx does not load plugin code in-process or +operate a plugin store. Plugin authors own their native JSONL, SQLite, or API +reads. ctx owns the manifest, cursor handoff, validation, import, and search +index. + +## Install And Discover + +Put a manifest at one of: + +- `$CTX_DATA_ROOT/plugins//ctx-history-plugin.json`; +- any directory or manifest file listed in `CTX_HISTORY_PLUGIN_PATH`. + +`ctx sources` and `ctx sources --json` list plugin sources without executing +their commands. Invalid installed manifests are listed as non-importable +`history_source_plugin` rows so authors can diagnose broken local config. + +Manifest example: + +```json +{ + "schema_version": 1, + "name": "example-agent", + "display_name": "Example Agent history", + "version": "0.1.0", + "history_sources": [ + { + "id": "default", + "provider_key": "example-agent", + "source_id": "default", + "source_format": "example-agent-sqlite-v1", + "enabled": true, + "refresh": "auto", + "command": ["example-agent-to-ctx", "export"], + "timeout_seconds": 300 + } + ] +} +``` + +`name`, `id`, `provider_key`, and `source_id` must be stable lowercase ASCII +identifiers. `command` is an argv array; ctx does not run it through a shell. + +`enabled: true` means `ctx import --all` may run that source. `refresh: auto` +means `ctx search` may run it during the normal pre-search refresh. Explicit +imports can run a discovered source even when it is not enabled or is marked +`refresh: manual`. + +## Import + +```bash +ctx import --history-source example-agent/default +ctx import --history-source-manifest ./ctx-history-plugin.json +ctx import --all +ctx import --history-source example-agent/default --reset-cursor +``` + +Selectors match `plugin/source` or `provider_key/source_id`, and must resolve +to exactly one source before ctx executes a command. ctx does not accept bare +plugin names, bare source ids, or bare provider keys because many integrations +use ids like `default`. + +`--history-source-manifest` is a development path: it adds that manifest for the +current command without installing it. With no selector, ctx imports sources +from the supplied manifest path. + +`--reset-cursor` withholds the previous cursor and sets +`CTX_HISTORY_FULL_RESCAN=1`. The plugin should emit a fresh `source.cursor.after` +checkpoint if the rescan succeeds; ctx rejects reset runs that do not emit a +new after checkpoint so an old cursor cannot be reused accidentally. + +`ctx setup` does not execute plugins. `ctx search` defaults to `--refresh auto` +and runs discovered plugin sources only when they are both `enabled: true` and +`refresh: auto`; `--refresh off` never runs plugins, and `--refresh strict` +fails if an auto plugin refresh fails. Plugin refresh is incremental because ctx +passes the previously stored source cursor before invoking the command. + +Search can be limited to a custom history source after import: + +```bash +ctx search "release notes" --history-source example-agent/default +ctx search "release notes" --provider-key example-agent --source-id default +ctx search "release notes" --source-format example-agent-sqlite-v1 +``` + +These filters imply `--provider custom`; combining them with another provider is +an error. + +## Runtime Environment + +ctx sets these variables before invoking a plugin command: + +- `CTX_DATA_ROOT` +- `CTX_HISTORY_PLUGIN=1` +- `CTX_HISTORY_PLUGIN_NAME` +- `CTX_HISTORY_PLUGIN_MANIFEST` +- `CTX_HISTORY_SOURCE`, such as `example-agent/default` +- `CTX_HISTORY_SOURCE_ID` +- `CTX_HISTORY_PROVIDER_KEY` +- `CTX_HISTORY_SOURCE_FORMAT` +- `CTX_HISTORY_CURSOR_STREAM` +- `CTX_HISTORY_MACHINE_ID` +- `CTX_HISTORY_FULL_RESCAN`, `1` or `0` +- `CTX_HISTORY_CURSOR`, when a previous cursor exists and is small enough for + inline environment handoff +- `CTX_HISTORY_CURSOR_FILE`, a temporary file containing the cursor + +Use `CTX_HISTORY_CURSOR_FILE` for large native cursor maps. The file exists only +while the plugin process runs and is the reliable cursor handoff path. + +The plugin must write only `ctx-history-jsonl-v1` JSONL to stdout. Progress and +diagnostics belong on stderr. If the command exits nonzero or stdout is invalid, +ctx imports nothing from that run and does not advance the cursor. +stdout is capped at 64 MiB per run and stderr at 256 KiB, so plugins should emit +incremental batches from the supplied cursor instead of full historical dumps +during normal refresh. + +Plugin commands receive a limited inherited environment by default: `PATH`, +`HOME`, basic locale variables, temporary-directory variables, and XDG data or +config homes. Put provider-specific environment values in the manifest `env` +object instead of relying on the parent shell. + +## Cursor Contract + +The plugin controls the cursor string. It may be a number, an opaque token, or a +JSON string. ctx stores it under a stable custom stream derived from: + +- `provider_key` +- `source_id` +- `source_format` + +The local machine id is stored separately with the cursor so multiple machines +can import the same custom source without overwriting each other's progress. + +On the next import, ctx passes the stored `cursor.after.cursor` value back in +the runtime environment. This keeps native cursor design inside the provider +adapter: + +- file appenders can use byte offsets; +- SQLite stores can use row ids; +- split stores can use JSON maps keyed by session id, file path, or direction. + +Every plugin run should emit a `source` record matching the manifest +`provider_key`, `source_id`, and `source_format`. ctx rejects mismatches before +writing imported rows. + +## Common Storage Shapes + +Use the cursor format that matches your native storage. ctx treats it as an +opaque string and passes it back on the next run. + +### Append-Only Files + +For one JSONL transcript per session, read each file from the last imported byte +offset and store a cursor keyed by path: + +```json +{"files":{"/home/me/.example-agent/sessions/a.jsonl":{"offset":12345,"size":13000,"mtimeMs":1780000000000}}} +``` + +If a file shrinks or its fingerprint changes, rescan that file from the +beginning and emit the same stable session and event IDs. + +### SQLite + +For a local database with monotonic message IDs, read rows above the previous +high-water mark and advance the cursor to the largest imported ID: + +```json +{"message_id":1234} +``` + +Use a second field if session metadata has its own reliable update marker: + +```json +{"message_id":1234,"session_updated_at":"2026-07-01T12:00:00Z"} +``` + +### Split Stores + +Some tools keep session metadata in one place and transcripts somewhere else. +Use a cursor map for each moving part: + +```json +{"sessions_version":17,"transcripts":{"/home/me/.example-agent/transcripts/a.jsonl":{"offset":456,"size":900}}} +``` + +The plugin can change how it reads native storage later without changing the ctx +manifest or stdout contract. + +### Local APIs Or Commands + +If the tool already has an export command or local API, call that API and store +its sync token: + +```json +{"sync_token":"opaque-provider-token"} +``` + +## Minimal Plugin Pseudocode + +```python +import json, os, pathlib, sqlite3, sys + +cursor_text = os.environ.get("CTX_HISTORY_CURSOR") +if not cursor_text and os.environ.get("CTX_HISTORY_CURSOR_FILE"): + cursor_text = pathlib.Path(os.environ["CTX_HISTORY_CURSOR_FILE"]).read_text() +cursor = json.loads(cursor_text or "{}") +after_message_id = cursor.get("message_id", 0) +db = sqlite3.connect(os.path.expanduser("~/.example-agent/state.db")) + +print(json.dumps({"record_type": "manifest", "schema_version": "ctx-history-jsonl-v1"})) +print(json.dumps({ + "record_type": "source", + "source_id": os.environ["CTX_HISTORY_SOURCE_ID"], + "provider_key": os.environ["CTX_HISTORY_PROVIDER_KEY"], + "source_format": os.environ["CTX_HISTORY_SOURCE_FORMAT"], + "cursor": { + "after": { + "stream": os.environ["CTX_HISTORY_CURSOR_STREAM"], + "cursor": json.dumps({"message_id": after_message_id}), + "observed_at": "2026-07-01T12:00:00Z" + } + } +})) + +for row in db.execute("SELECT id, session_id, role, content, timestamp FROM messages WHERE id > ? ORDER BY id", (after_message_id,)): + # Emit session records as needed, then event records with stable event_index. + pass +``` diff --git a/docs/limitations.md b/docs/limitations.md index 7a637dcbe..f74ffde75 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -7,11 +7,17 @@ shipped. ## Provider Coverage - Codex local import is supported for documented local JSONL sources. -- Pi local import is supported only when a matching local `sessions.jsonl` file - exists. -- Antigravity, Claude, OpenCode, Gemini, Cursor, Copilot CLI, and Factory AI - Droid local import is supported only when their documented local history - paths exist and match the supported native formats in the provider matrix. +- Pi local import is supported when matching local session JSONL files exist + under `~/.pi/agent/sessions`, or when an explicit Pi session JSONL file is + passed with `--path`. +- Antigravity, Claude, OpenCode, OpenClaw, Hermes, Gemini, Cursor, Copilot CLI, + and Factory AI Droid local import is supported only when their documented + local history paths exist and match the supported native formats in the + provider matrix. +- NanoClaw and AstrBot local import are preview/manual-path support. They are + not included in `ctx import --all` or pre-search refresh, and AstrBot imports + local LLM context plus available platform history rather than guaranteeing a + complete raw IM transcript. - Unknown provider formats should not be parsed optimistically. ## Import Semantics @@ -28,7 +34,8 @@ shipped. - Large outputs may be represented as bounded previews. - Ranking is deterministic for the same local database and options, but it is not a claim of semantic understanding. -- Empty or very broad queries can return metadata-driven matches. +- Empty or punctuation-only search is invalid. Broad valid queries can still + return metadata-driven matches. ## Retrieval Semantics diff --git a/docs/mcp.md b/docs/mcp.md new file mode 100644 index 000000000..fb37399cb --- /dev/null +++ b/docs/mcp.md @@ -0,0 +1,38 @@ +# MCP + +`ctx mcp serve` starts a read-only MCP server over newline-delimited stdio +JSON-RPC. It is for agents or MCP hosts that prefer tool discovery over shell +commands. The CLI remains the primary interface. + +```bash +ctx mcp serve +``` + +The server exposes these tools: + +- `status`, local ctx index status; +- `sources`, discovered local agent history sources; +- `search`, search the existing index; +- `sql`, run one read-only SQL statement against the existing index; +- `show_session`, return an indexed session transcript by ctx session ID; +- `show_event`, return an indexed event and optional surrounding window by ctx + event ID. + +MCP search and SQL query the existing index only. They do not refresh provider +history, import files, initialize storage, or write provider data. + +MCP search defaults to primary-agent sessions only, matching `ctx search`. +Pass `include_subagents: true` when implementation details, code review notes, +test output, or failure traces from subagent sessions are relevant. When +`CODEX_THREAD_ID` is set, MCP search also excludes the active Codex session tree +by default; pass `include_current_session: true` when the active session tree is +the target. + +The MCP `sql` tool uses the same read-only stable views and result limits as +`ctx sql --json`. Prefer stable `ctx_*` views for scripts and agent workflows. +Run `ctx docs show sql` for the view schemas and examples. + +Tool results include MCP text content plus `structuredContent` JSON. Treat all +MCP output as private local history: it may include absolute paths, source +metadata, snippets, transcript text, and raw SQL result fields, and the MCP host +may log or forward tool output. diff --git a/docs/product-contract.md b/docs/product-contract.md index 86d05b78e..421b1f4d3 100644 --- a/docs/product-contract.md +++ b/docs/product-contract.md @@ -14,10 +14,11 @@ product boundary is retrieval, not interpretation. transcript formats. - `ctx sources` reports known local provider history paths, including whether a native source is currently importable. -- `ctx import` indexes supported local transcript formats. -- `ctx search` can refresh discovered native provider sources before returning - ranked local hits from the local index, with event IDs when a hit maps to an - indexed event. +- `ctx import` indexes supported local transcript formats and selected local + history-source plugins. +- `ctx search` can refresh discovered native provider sources and enabled auto + history-source plugins before returning ranked local hits from the local + index, with event IDs when a hit maps to an indexed event. - `ctx show session` and `ctx show event` render transcripts, hits, and context windows using ctx-owned IDs, and `ctx show session --out` writes transcript artifacts. diff --git a/docs/provider-support-matrix.json b/docs/provider-support-matrix.json index 1e2c928ca..b1324271a 100644 --- a/docs/provider-support-matrix.json +++ b/docs/provider-support-matrix.json @@ -87,12 +87,13 @@ "ctx import --provider pi" ], "notes": [ - "Reads ~/.pi/sessions.jsonl when that file exists and matches the supported JSONL format." + "Reads Pi session JSONL files under ~/.pi/agent/sessions, including per-cwd session directories produced by current Pi releases.", + "An explicit Pi session JSONL file path remains supported with ctx import --provider pi --path." ] } ], "history_locations": [ - "~/.pi/sessions.jsonl" + "~/.pi/agent/sessions" ], "imports_existing_history": true, "captures_new_runs_passively": false, @@ -215,6 +216,280 @@ "crates/ctx-history-capture/src/lib.rs" ] }, + { + "id": "openclaw", + "display_name": "OpenClaw", + "priority": "p1", + "status": "local_import_when_supported", + "capture_provider": "openclaw", + "implemented_paths": [ + { + "kind": "native_import", + "source_format": "openclaw_session_jsonl_tree", + "fidelity": "partial", + "proof": [ + "ctx sources", + "ctx import --provider openclaw" + ], + "notes": [ + "Reads OpenClaw session JSONL transcripts under OPENCLAW_STATE_DIR, ~/.openclaw, and verified legacy ~/.clawdbot or ~/.moltbot homes.", + "This is beta because upstream exposes newer session helpers and warns plugins not to depend on legacy sessions.json shape." + ] + } + ], + "history_locations": [ + "OPENCLAW_STATE_DIR/agents/*/sessions/*.jsonl", + "~/.openclaw/agents/*/sessions/*.jsonl", + "~/.clawdbot/agents/*/sessions/*.jsonl", + "~/.moltbot/agents/*/sessions/*.jsonl" + ], + "imports_existing_history": true, + "captures_new_runs_passively": false, + "child_sessions_supported": false, + "fidelity": { + "user_prompts": true, + "assistant_messages": true, + "tool_calls": false, + "tool_output": true, + "command_output": false, + "files_touched": false, + "artifacts": false, + "model_identity": true, + "costs": false, + "token_usage": false, + "parent_child_session_edges": false + }, + "redaction_notes": [ + "Imports are local/private and preserve source paths for citations." + ], + "blockers": [ + "Full GA needs confirmation that the local transcript contract remains stable across newer OpenClaw session APIs." + ], + "public_docs": "docs/providers.md", + "fixture_paths": [], + "tests": [ + "crates/ctx-cli/tests/cli.rs", + "crates/ctx-history-capture/src/lib.rs" + ] + }, + { + "id": "hermes", + "display_name": "Hermes Agent", + "priority": "p1", + "status": "local_import_when_supported", + "capture_provider": "hermes", + "implemented_paths": [ + { + "kind": "native_import", + "source_format": "hermes_state_sqlite", + "fidelity": "imported", + "proof": [ + "ctx sources", + "ctx import --provider hermes" + ], + "notes": [ + "Reads Hermes Agent SQLite history from HERMES_HOME/state.db or ~/.hermes/state.db using a read-only SQLite connection.", + "Preserves sessions/messages rows, parent session IDs, model/config metadata, tool call metadata, token fields, and billing fields when present." + ] + } + ], + "history_locations": [ + "HERMES_HOME/state.db", + "~/.hermes/state.db" + ], + "imports_existing_history": true, + "captures_new_runs_passively": false, + "child_sessions_supported": true, + "fidelity": { + "user_prompts": true, + "assistant_messages": true, + "tool_calls": true, + "tool_output": true, + "command_output": false, + "files_touched": false, + "artifacts": false, + "model_identity": true, + "costs": true, + "token_usage": true, + "parent_child_session_edges": true + }, + "redaction_notes": [ + "Reads the provider SQLite database read-only; imported text remains in the local ctx index." + ], + "blockers": [], + "public_docs": "docs/providers.md", + "fixture_paths": [], + "tests": [ + "crates/ctx-cli/tests/cli.rs", + "crates/ctx-history-capture/src/lib.rs" + ] + }, + { + "id": "nanoclaw", + "display_name": "NanoClaw", + "priority": "p1", + "status": "local_import_when_supported", + "capture_provider": "nanoclaw", + "implemented_paths": [ + { + "kind": "native_import", + "source_format": "nanoclaw_project", + "fidelity": "partial", + "proof": [ + "ctx sources", + "ctx import --provider nanoclaw --path " + ], + "notes": [ + "Preview importer for a NanoClaw project root containing data/v2.db and data/v2-sessions/*/*/{inbound.db,outbound.db}.", + "Project roots are discovered from the current working directory and ancestors, but preview sources are not imported by ctx import --all or pre-search refresh." + ] + } + ], + "history_locations": [ + "/data/v2.db", + "/data/v2-sessions/*/*/inbound.db", + "/data/v2-sessions/*/*/outbound.db" + ], + "imports_existing_history": true, + "captures_new_runs_passively": false, + "child_sessions_supported": false, + "fidelity": { + "user_prompts": true, + "assistant_messages": true, + "tool_calls": false, + "tool_output": false, + "command_output": false, + "files_touched": false, + "artifacts": false, + "model_identity": false, + "costs": false, + "token_usage": false, + "parent_child_session_edges": false + }, + "redaction_notes": [ + "Manual preview imports may include IM/chat channel identifiers stored in NanoClaw project databases." + ], + "blockers": [ + "Automatic refresh is intentionally disabled until more real-world NanoClaw project layout drift is validated." + ], + "public_docs": "docs/providers.md", + "fixture_paths": [], + "tests": [ + "crates/ctx-cli/tests/cli.rs", + "crates/ctx-history-capture/src/lib.rs" + ] + }, + { + "id": "astrbot", + "display_name": "AstrBot", + "priority": "p1", + "status": "local_import_when_supported", + "capture_provider": "astrbot", + "implemented_paths": [ + { + "kind": "native_import", + "source_format": "astrbot_data_v4_sqlite", + "fidelity": "partial", + "proof": [ + "ctx sources", + "ctx import --provider astrbot --path " + ], + "notes": [ + "Preview importer for AstrBot data/data_v4.db conversation context plus available platform_message_history rows.", + "This is not a full all-channel IM transcript guarantee; connector/plugin histories may live outside the supported tables." + ] + } + ], + "history_locations": [ + "ASTRBOT_ROOT/data/data_v4.db", + "~/.astrbot/data/data_v4.db", + "/data/data_v4.db" + ], + "imports_existing_history": true, + "captures_new_runs_passively": false, + "child_sessions_supported": false, + "fidelity": { + "user_prompts": true, + "assistant_messages": true, + "tool_calls": false, + "tool_output": false, + "command_output": false, + "files_touched": false, + "artifacts": false, + "model_identity": false, + "costs": false, + "token_usage": true, + "parent_child_session_edges": false + }, + "redaction_notes": [ + "Manual preview imports may include IM platform IDs, user IDs, sender names, and local chat text." + ], + "blockers": [ + "Full GA needs stronger upstream guarantees for complete per-platform raw message retention." + ], + "public_docs": "docs/providers.md", + "fixture_paths": [], + "tests": [ + "crates/ctx-cli/tests/cli.rs", + "crates/ctx-history-capture/src/lib.rs" + ] + }, + { + "id": "shelley", + "display_name": "Shelley", + "priority": "p1", + "status": "local_import_when_supported", + "capture_provider": "shelley", + "implemented_paths": [ + { + "kind": "native_import", + "source_format": "shelley_sqlite", + "fidelity": "imported", + "proof": [ + "ctx sources", + "ctx import --provider shelley", + "ctx import --provider shelley --path " + ], + "notes": [ + "Reads Shelley SQLite history from SHELLEY_DB or ~/.config/shelley/shelley.db using a read-only SQLite connection.", + "Normalizes conversations as sessions and messages as events with stable conversation/sequence/message cursors.", + "Indexes text from Shelley user/agent/tool JSON, including thinking, tool calls, and tool results when present." + ] + } + ], + "history_locations": [ + "SHELLEY_DB", + "~/.config/shelley/shelley.db" + ], + "imports_existing_history": true, + "captures_new_runs_passively": false, + "child_sessions_supported": true, + "fidelity": { + "user_prompts": true, + "assistant_messages": true, + "tool_calls": true, + "tool_output": true, + "command_output": false, + "files_touched": false, + "artifacts": false, + "model_identity": true, + "costs": true, + "token_usage": true, + "parent_child_session_edges": true + }, + "redaction_notes": [ + "Reads the provider SQLite database read-only; imported conversation text, tool output, cwd, model, usage, and local source paths remain in the local ctx index." + ], + "blockers": [ + "Full GA needs ongoing validation against upstream Shelley schema drift." + ], + "public_docs": "docs/providers.md", + "fixture_paths": [], + "tests": [ + "crates/ctx-cli/tests/cli.rs", + "crates/ctx-history-capture/src/lib.rs" + ] + }, { "id": "antigravity_cli", "display_name": "Antigravity", diff --git a/docs/provider-support.md b/docs/provider-support.md index a7bc050d0..b9e96df90 100644 --- a/docs/provider-support.md +++ b/docs/provider-support.md @@ -23,15 +23,26 @@ is: | Provider | Status | Public import path | Public smoke | | --- | --- | --- | --- | | Codex | `local_import` | `~/.codex/sessions`, `~/.codex/history.jsonl`, or an explicit Codex path. | Static local-history fixture smoke. | -| Pi | `local_import_when_supported` | `~/.pi/sessions.jsonl` or an explicit Pi JSONL path. | Static local-history fixture smoke. | +| Pi | `local_import_when_supported` | `~/.pi/agent/sessions` or an explicit Pi session JSONL path. | Static local-history fixture smoke. | | Claude | `local_import_when_supported` | `~/.claude/projects` or an explicit Claude projects JSONL tree. | Static local-history fixture smoke. | | OpenCode | `local_import_when_supported` | `~/.local/share/opencode/opencode.db` or an explicit OpenCode SQLite DB. | Static local-history fixture smoke. | +| OpenClaw | `local_import_when_supported` | `OPENCLAW_STATE_DIR`, `~/.openclaw`, legacy `~/.clawdbot`/`~/.moltbot`, or an explicit OpenClaw state tree. | Static local-history fixture smoke; beta storage-contract notes in the matrix. | +| Hermes Agent | `local_import_when_supported` | `HERMES_HOME/state.db`, `~/.hermes/state.db`, or an explicit Hermes SQLite DB. | Static local-history fixture smoke. | +| NanoClaw | `local_import_when_supported` | Preview/manual import from a NanoClaw project root or `data/v2.db`; cwd/ancestor discovery only. | Static local-history fixture smoke; excluded from `ctx import --all` and pre-search refresh until promoted. | +| AstrBot | `local_import_when_supported` | Preview/manual import from `ASTRBOT_ROOT/data/data_v4.db`, `~/.astrbot/data/data_v4.db`, cwd/ancestor project DBs, or an explicit DB path. | Static local-history fixture smoke; imports LLM context plus available platform history, not guaranteed complete IM transcripts. | +| Shelley | `local_import_when_supported` | `SHELLEY_DB`, `~/.config/shelley/shelley.db`, or an explicit Shelley SQLite DB. | Static local-history fixture smoke; imports conversations/messages read-only with tool text, usage/model metadata, and parent conversation links. | | Antigravity | `local_import_when_supported` | Antigravity `transcript_full.jsonl` or `transcript.jsonl` files under `~/.gemini/antigravity-cli/brain`, or an explicit Antigravity transcript JSONL tree. | Static local-history fixture smoke. | | Gemini | `local_import_when_supported` | Gemini chat JSONL files under `~/.gemini/tmp/**/chats`, or an explicit Gemini CLI history tree. | Static local-history fixture smoke. | | Cursor | `local_import_when_supported` | Cursor agent transcript JSONL files under `~/.cursor/projects/**/agent-transcripts`, or an explicit Cursor agent transcript path. | Static local-history fixture smoke. | | Copilot CLI | `local_import_when_supported` | Copilot CLI `events.jsonl` files under `~/.copilot/session-state`, or an explicit Copilot CLI session-state tree. | Static local-history fixture smoke. | | Factory AI Droid | `local_import_when_supported` | `~/.factory/sessions` or an explicit Factory AI Droid sessions tree. | Static local-history fixture smoke. | +`ctx sources --json` uses `import_support: "preview"` and `native_import: +false` for preview sources such as NanoClaw and AstrBot. Those paths can be +imported explicitly with `ctx import --provider ...` when discovery finds them, +or with `ctx import --provider ... --path ...` for a specific path. They are not +swept up by `ctx import --all` or the default pre-search refresh. + Fidelity fields in the machine-readable matrix describe the default public CLI import behavior and normalized ctx storage fields. Supported adapters record normalized `files_touched` metadata when provider transcripts expose file paths @@ -41,11 +52,11 @@ modes import them explicitly. ## Provider Smoke -Provider smoke coverage uses static local-history fixtures checked into the -repository. The public smoke target exercises supported imports, blocked -unsupported providers, provider filtering, citations, and deterministic search -without executing provider CLIs, reading real user history, requiring API keys, -or making network calls: +Provider smoke coverage uses public fixture data and generated local-history +trees. The public smoke target exercises supported imports, blocked unsupported +providers, provider filtering, citations, and deterministic search without +executing provider CLIs, reading real user history, requiring API keys, or +making network calls: ```bash bazel test //:provider_fixture_e2e --config=ci diff --git a/docs/providers.md b/docs/providers.md index 52b347b45..1852e9792 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -10,10 +10,20 @@ The current CLI imports local history for: - Codex session JSONL trees under `~/.codex/sessions`; - Codex `~/.codex/history.jsonl`; -- Pi `~/.pi/sessions.jsonl` when that local file exists and matches the - supported JSONL format; +- Pi session JSONL files under `~/.pi/agent/sessions`; - Claude Code project JSONL transcripts under `~/.claude/projects`; - OpenCode SQLite history under `~/.local/share/opencode/opencode.db`; +- OpenClaw session JSONL trees under `OPENCLAW_STATE_DIR`, `~/.openclaw`, + legacy `~/.clawdbot`, or legacy `~/.moltbot`; +- Hermes Agent SQLite history under `HERMES_HOME/state.db` or + `~/.hermes/state.db`; +- NanoClaw project history from a project root with `data/v2.db` and + `data/v2-sessions` when imported explicitly; +- AstrBot local SQLite history from `ASTRBOT_ROOT/data/data_v4.db`, + `~/.astrbot/data/data_v4.db`, or a project `data/data_v4.db` when imported + explicitly; +- Shelley SQLite history from `SHELLEY_DB`, `~/.config/shelley/shelley.db`, or + an explicit Shelley DB path; - Antigravity transcript JSONL mirrors under `~/.gemini/antigravity-cli/brain/*/.system_generated/logs/transcript_full.jsonl` or `transcript.jsonl`; @@ -24,6 +34,15 @@ The current CLI imports local history for: `~/.copilot/session-state`; - Factory AI Droid session JSONL files under `~/.factory/sessions`. +These are built-in provider adapters for native local history. The custom +history format is separate: `ctx import --format ctx-history-jsonl-v1 --path +` reads an explicit JSONL interchange file from any exporter, and +history-source plugins can stream the same format from local adapter commands. +Custom history is stored internally under the bounded provider `custom` while +preserving the exporter's `provider_key`, `source_id`, and `session_id` as +metadata and ID namespace components. File imports are not auto-discovered; +local plugin manifests are listed by `ctx sources`. + Use `ctx sources` for the truth on the current machine: ```bash @@ -31,9 +50,20 @@ ctx sources ctx sources --json ``` +CLI provider flags use names such as `openclaw`, `hermes`, `nanoclaw`, +`astrbot`, `shelley`, `copilot-cli`, and `factory-ai-droid`. +Structured JSON and stable SQL views use provider IDs in ctx output; multiword IDs may be +snake_case, such as `copilot_cli` or `factory_ai_droid`, while compact native +IDs such as `openclaw`, `nanoclaw`, `astrbot`, and `shelley` stay compact. + `ctx sources --json` reports each known provider source with `import_support` and `importable` fields. A native source is marked available/importable only -when provider-specific transcript files exist. Sources with +when provider-specific transcript files exist. Sources with `import_support: +"preview"` are explicit-import preview paths: use `ctx import --provider +nanoclaw` or `ctx import --provider astrbot` when discovery finds the desired +source, or add `--path` to target a specific source before searching it. They +are intentionally excluded from `ctx import --all` and pre-search refresh until +promoted. Sources with `status: "unknown"` hit the bounded transcript probe budget before proving history exists, and sources with `import_support: "unsupported"` are detections or blockers, not importable native history. @@ -67,6 +97,10 @@ Provider imports should be: - clear about which fields were indexed and which were left raw-only; - conservative when a transcript schema is unknown or malformed. +Custom history imports follow the same read-only and idempotent principles, but +their compatibility contract is the `ctx-history-jsonl-v1` schema rather than a +provider-owned native transcript format. + ## Fidelity An imported session may include messages, tool calls, command events, output diff --git a/docs/redaction-corpus.md b/docs/redaction-corpus.md index e7bca1b1f..9697953da 100644 --- a/docs/redaction-corpus.md +++ b/docs/redaction-corpus.md @@ -15,4 +15,6 @@ credentials before indexing or display. Corpus tests should cover at least: Passing the corpus does not make output safe to share. It proves local search/show/SQLite projections preserve representative transcript text so users and agents can find exact local history. Share-safe or shared-service redaction -is outside the current local CLI contract. +is outside the current local CLI contract. Rows marked +`redaction_state: "safe_preview"` use that legacy spelling for a local searchable +preview and must still be treated as private local history. diff --git a/docs/search.md b/docs/search.md index ed165b002..6ca4a94f3 100644 --- a/docs/search.md +++ b/docs/search.md @@ -3,8 +3,8 @@ `ctx search` finds matching indexed history. Default results are session-diverse: ctx shows the strongest matching span from each session, then lets you drill into dense event-level results when needed. By default it first performs a quiet -best-effort refresh of discovered native provider sources, then queries the -local SQLite store. +best-effort refresh of discovered native provider sources and enabled auto +history-source plugins, then queries the local SQLite store. ## Search @@ -20,6 +20,7 @@ ctx search "token budget" --refresh off ctx search "signed metadata" --term checksum --term release ctx search "token budget" --limit 5 ctx search "token budget" --session +ctx search "review findings" --include-subagents ctx search "this current task" --include-current-session ``` @@ -42,30 +43,44 @@ A result can include: - `suggested_next_commands`, copyable commands for `ctx show`, `ctx locate`, and scoped follow-up searches. -Search result IDs are ctx-owned. Provider-owned IDs are exposed as metadata so -humans can recognize the original provider session, but they are not positional -lookup IDs. Provider-owned lookup must be explicit, for example -`--provider codex --provider-session ` on commands that -support it. +Search result IDs are ctx-owned. Commands accept full ctx IDs or unambiguous +ctx ID prefixes of at least eight hex characters. Provider-owned IDs are +exposed as metadata so humans can recognize the original provider session, but +they are not positional lookup IDs. Provider-owned lookup must be explicit, for +example `--provider codex --provider-session ` on commands +that support it. ## Filters Search filters narrow both human output and JSON: -- `--provider codex|pi|claude|opencode|antigravity|gemini|cursor|copilot-cli|factory-ai-droid`; -- `--workspace `; +- `--provider codex|pi|claude|opencode|openclaw|hermes|nanoclaw|astrbot|shelley|antigravity|gemini|cursor|copilot-cli|factory-ai-droid`; +- `--history-source `, for custom + history imports; +- `--provider-key `, `--source-id `, and + `--source-format `, for exact custom history source filters; +- `--workspace `, substring match over stored workspace, cwd, + source path, or repository-name text; - `--since d`; -- `--event-type `; -- `--file `; -- `--session `; -- `--term `, repeatable broadening terms merged with the main query; +- `--event-type `, one of `message`, `tool_call`, `tool_output`, + `command_started`, `command_output`, `command_finished`, `file_touched`, + `vcs_change`, `artifact`, `summary`, or `notice`; +- `--file `, indexed touched-file path metadata, not the current + filesystem; +- `--session `; +- `--term `, repeatable broadening terms merged with OR-style + semantics, not required terms; - `--events`; -- `--primary-only`; - `--include-subagents`; - `--limit `; - `--refresh auto|off|strict`; - `--include-current-session`. +CLI provider filters use the kebab-case names above. JSON output and stable SQL +views use provider IDs in ctx output; multiword provider IDs may be snake_case, +such as `copilot_cli` or `factory_ai_droid`, while compact IDs such as +`openclaw`, `nanoclaw`, `astrbot`, and `shelley` stay compact. + `--since` accepts RFC 3339 timestamps such as `2026-06-01T00:00:00Z` or a day window such as `30d`. @@ -75,9 +90,14 @@ for a file, or combine it with query terms to find sessions that both mention a topic and touched that path. It searches paths recorded during import; it does not inspect the current filesystem. -The default includes subagent material. `--primary-only` restricts results to -primary sessions and excludes subagent material. `--include-subagents` keeps the -default explicit; it does not override `--primary-only`. +Search requires a non-empty query, at least one non-empty `--term`, or +`--file `. Provider, workspace, time, session, event, source, and result +flags only narrow an actual search; by themselves they do not browse recent +history. + +The default searches primary-agent sessions so human intent and decisions stay +prominent. Use `--include-subagents` when you want implementation details, code +review notes, test output, or failure analysis from subagent sessions too. `--limit` defaults to `20` and is capped at `200`. @@ -92,17 +112,19 @@ work do not dominate history research. Use `--include-current-session` when you are intentionally looking for material from the active session tree. `--refresh` defaults to `auto`. `auto` attempts a best-effort pre-search import -of discovered native provider sources and serves the existing index if that -refresh fails. On large discovered sources or already-cataloged indexes, `auto` -serves current results without a foreground catch-up scan; use -`--refresh strict` or `ctx import --all` when you need a full catch-up before -querying. `off` skips the pre-search refresh. `strict` fails the search if the -refresh cannot run or import successfully. Search-only sources without native -import support are searched from the existing index until they are explicitly -imported through a supported path. +of discovered native provider sources and enabled auto history-source plugins, +then serves the existing index if that refresh fails. On large discovered +sources or already-cataloged indexes, `auto` serves current results without a +foreground catch-up scan; use `--refresh strict` or `ctx import --all` when you +need a full catch-up before querying. `off` skips the pre-search refresh and +never runs plugin commands. `strict` fails the search if the refresh cannot run +or import successfully. Preview native sources such as NanoClaw and AstrBot, +plus search-only sources without native import support, are searched from the +existing index until they are explicitly imported through a supported path. Use `--refresh off` for a strictly read-only search over the existing ctx index. -This avoids provider imports and avoids updating the ctx SQLite store. +This avoids provider imports, plugin execution, and updates to the ctx SQLite +store. ## History Reports @@ -113,9 +135,10 @@ only retrieves indexed local evidence; it does not synthesize conclusions. ## Machine Output -Use default text output for agent reading. Use `ctx search --json` for scripts, -`jq`, or exact field extraction. JSON results include the same result metadata -and citations as the human output, plus a top-level `freshness` object +Use default text output for agent reading. Use `ctx search --json` or a +term/file search with `--json` for scripts, `jq`, or exact field extraction. +JSON results include the same result metadata and citations as the human output, +plus a top-level `freshness` object describing the pre-search refresh mode and outcome. A citation with `source_exists: false` means ctx can return indexed text, but the raw provider file was not available at the stored path when the result was built. diff --git a/docs/security-checks.md b/docs/security-checks.md index db3071b8c..9ce02f463 100644 --- a/docs/security-checks.md +++ b/docs/security-checks.md @@ -32,6 +32,8 @@ the local retrieval product. - Search/show/locate JSON and SQLite search projections preserve local transcript text by default, including absolute paths and secret-shaped strings. They must be treated as private local data. +- The legacy `safe_preview` state and `safe_preview_text` columns mean local + searchable preview text, not share-safe redaction. - Unsupported providers remain explicit in the provider support matrix. ## Static Docs Checks diff --git a/docs/sql.md b/docs/sql.md new file mode 100644 index 000000000..bd6b9e4bc --- /dev/null +++ b/docs/sql.md @@ -0,0 +1,155 @@ +# SQL + +`ctx sql` runs read-only SQL against the existing local ctx SQLite index. Use it +when normal `ctx search` does not express the question: counts, audits, joins, +file/session metadata lookups, or scripts that need structured output. + +`ctx sql` does not refresh provider history, import files, initialize storage, or +migrate schemas. Run `ctx status`, `ctx setup`, or `ctx import` first if the +local store needs to be created or migrated. + +## Examples + +```bash +ctx sql "SELECT provider, COUNT(*) AS sessions FROM ctx_sessions GROUP BY provider" +ctx sql "SELECT event_type, COUNT(*) AS events FROM ctx_events GROUP BY event_type ORDER BY events DESC" +ctx sql "SELECT path, provider, provider_session_id FROM ctx_files_touched WHERE path LIKE '%AGENTS.md%' LIMIT 20" +ctx sql --format json "SELECT ctx_session_id, cwd FROM ctx_sessions ORDER BY started_at_ms DESC LIMIT 5" +ctx sql --format csv --file query.sql +ctx sql - --format raw < query.sql +``` + +Use normal `ctx search` for transcript text search. Avoid broad scans over +`payload_json`; event payloads can be large and normal search is optimized for +finding text. + +## Stable Views + +Prefer stable `ctx_*` views. Internal tables remain queryable locally, but they +are implementation details and can change between versions. + +`ctx_sessions`: + +| Column | Meaning | +| --- | --- | +| `ctx_session_id` | ctx-owned session ID for `ctx show session`. | +| `history_record_id` | ctx history record backing the session, when known. | +| `parent_ctx_session_id` | Parent ctx session ID for subagent/session trees. | +| `root_ctx_session_id` | Root ctx session ID for session trees. | +| `provider` | Provider name such as `codex`, `claude`, or `opencode`. | +| `provider_session_id` | Provider-owned session ID. | +| `external_agent_id` | Provider-owned agent identifier, when present. | +| `agent_type` | `primary`, `subagent`, `reviewer`, `implementer`, or related type. | +| `role_hint` | Provider/importer role hint. | +| `is_primary` | `1` for primary-agent sessions, `0` otherwise. | +| `status` | Imported/session status. | +| `fidelity` | Import fidelity. | +| `started_at_ms`, `ended_at_ms` | Unix epoch milliseconds. | +| `cwd` | Captured working directory, when known. | +| `source_path` | Raw provider source path, when known. | + +`ctx_events`: + +| Column | Meaning | +| --- | --- | +| `ctx_event_id` | ctx-owned event ID for `ctx show event`. | +| `ctx_session_id` | ctx session ID, when known. | +| `history_record_id` | ctx history record backing the event, when known. | +| `provider`, `provider_session_id` | Provider context from the session. | +| `event_seq` | Provider/session event sequence. | +| `event_type` | `message`, `tool_call`, `tool_output`, `command_started`, `command_output`, `command_finished`, `file_touched`, `vcs_change`, `artifact`, `summary`, or `notice`. | +| `role` | Event role such as `user`, `assistant`, or `tool`, when known. | +| `occurred_at_ms` | Unix epoch milliseconds. | +| `payload_json` | Local private event payload. | +| `redaction_state` | Local payload handling state. `safe_preview` is legacy spelling for a local searchable preview, not share-safe redaction. | +| `fidelity` | Import fidelity. | +| `cwd`, `source_path` | Captured source context, when known. | + +`ctx_files_touched`: + +| Column | Meaning | +| --- | --- | +| `ctx_file_touch_id` | ctx-owned touched-file row ID. | +| `path`, `old_path` | Touched path and prior path for renames. | +| `change_kind` | `read`, `created`, `modified`, `deleted`, `renamed`, or `unknown`. | +| `line_count_delta` | Imported line delta, when known. | +| `confidence` | `explicit`, `high`, `medium`, `low`, or `unknown`. | +| `ctx_event_id` | Associated event ID, when importer knows it. | +| `ctx_session_id` | Associated session ID, resolved from event, run, or capture source. | +| `history_record_id` | Associated history record, resolved from event, run, row, or capture source. | +| `provider`, `provider_session_id` | Provider context, when resolvable. | +| `created_at_ms`, `updated_at_ms` | Unix epoch milliseconds. | + +`ctx_sources`: + +| Column | Meaning | +| --- | --- | +| `provider`, `source_format` | Provider and importer/source format. | +| `source_root`, `source_path` | Discovered provider source location. | +| `provider_session_id`, `parent_provider_session_id` | Provider session identifiers. | +| `agent_type`, `role_hint` | Imported session role metadata. | +| `cwd` | Captured working directory, when known. | +| `session_started_at_ms` | Provider session start time in Unix epoch milliseconds. | +| `file_size_bytes`, `file_modified_at_ms`, `cataloged_at_ms` | Catalog metadata. | +| `indexed_status`, `indexed_at_ms`, `indexed_error`, `indexed_event_count` | Import/index status. | + +## File Path Queries + +Touched-file rows are metadata about files mentioned by imported provider +events. They are not a live filesystem index. A row may be associated directly +with an event, with a command/run, with a history record, or only with a capture +source. The stable view resolves provider and session context when possible. + +```sql +SELECT path, provider, provider_session_id, ctx_session_id +FROM ctx_files_touched +WHERE path = 'crates/ctx-cli/src/main.rs' +ORDER BY updated_at_ms DESC +LIMIT 20; +``` + +Combine file metadata with normal search when you need transcript relevance: + +```bash +ctx search "release blocker" --file crates/ctx-cli/src/main.rs +``` + +## Input And Output + +Pass SQL as an argument, from stdin with `-`, or with `--file`: + +```bash +ctx sql "SELECT COUNT(*) FROM ctx_events" +ctx sql - < query.sql +ctx sql --file query.sql +``` + +Formats: + +- `--format table`, the default human-readable table; +- `--format json`, structured output with columns, rows, limits, timing, and truncation; +- `--json`, alias for `--format json`; +- `--format csv`, script-friendly CSV; +- `--format raw`, one-column raw lines for piping. + +`--format raw` requires exactly one selected column. + +## Limits + +`ctx sql` is intentionally bounded: + +- read-only statements only; +- one statement per invocation; +- no query parameters; +- default row, column, SQL byte, and value byte caps; +- timeout for long-running queries; +- JSON output marked `share_safe: false`. + +Increase limits only when scripting needs them: + +```bash +ctx sql "SELECT * FROM ctx_events LIMIT 500" --max-rows 500 --timeout 30s +``` + +Keep SQL output local unless you have reviewed it. Payloads, paths, prompts, +tool output, and repository names can contain private data. diff --git a/docs/storage.md b/docs/storage.md index cd8272dc6..266f6271f 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -76,11 +76,11 @@ analytics marker described under network behavior. | --- | --- | --- | | `ctx setup` | provider transcript files and home path metadata for source discovery | data root, `work.sqlite`, `config.toml`, and SQLite index | | `ctx status` | data root metadata and existing SQLite store | none | -| `ctx sources` | known provider paths under the user's home | none | -| `ctx import` | provider transcript files and path metadata | data root, `config.toml` if missing, and SQLite index | +| `ctx sources` | known provider paths under the user's home and local history-source plugin manifests | none | +| `ctx import` | provider transcript files and path metadata, the explicit custom history JSONL file passed with `--format ctx-history-jsonl-v1 --path`, or stdout from an explicit history-source plugin command | data root, `config.toml` if missing, and SQLite index | | `ctx show` | SQLite index | selected `--out` path for `show session` when provided | | `ctx locate` | SQLite index and raw source path metadata | none | -| `ctx search` | native provider transcript files, path metadata, and SQLite index | SQLite index for newly discovered native provider history | +| `ctx search` | native provider transcript files, path metadata, enabled auto history-source plugin stdout, and SQLite index | SQLite index for newly discovered native provider or plugin history | | `ctx sql` | existing SQLite index only | none | | `ctx docs` | embedded documentation in the binary | selected topic `--out` path for `ctx docs show --out` or selected `--out` directory for `ctx docs man --out` | | `ctx upgrade` | signed release metadata and installed binary/sidecar metadata | installed binary for manual upgrade, install sidecar, `upgrade-state.json`, `upgrade.lock`, and `logs/upgrade.log` | @@ -125,11 +125,20 @@ Re-import or update the index: ```bash ctx import --all ctx import --resume -ctx import --path ~/.codex/sessions +ctx import --provider codex --path ~/.codex/sessions +ctx import --format ctx-history-jsonl-v1 --path ./history.jsonl +ctx import --history-source example-agent/default ``` Current adapters are safe to re-run. They rescan sources idempotently and keep source paths or cursors when available. +Custom history JSONL imports follow the same v1 lifecycle: ctx rescans the +explicit file, upserts already-imported records, stores supplied source cursor +metadata under ctx-owned custom cursor streams, and preserves event native +cursors. History-source plugins receive the previous stored cursor on each +explicit import and stream the same JSONL format to stdout. Failed plugin runs +do not advance cursors. Explicit file paths and plugin manifests are not added +to `config.toml` or treated as fixed provider homes. ## Upgrade Reindexing @@ -153,8 +162,9 @@ $EDITOR ~/.ctx/config.toml The current CLI does not add provider source entries to `config.toml`; default provider locations are discovered each time and explicit `--path` imports are -not remembered as future defaults. To remove already indexed data, rebuild the -index and import only the sources you still want. +not remembered as future defaults. Custom history JSONL paths are also +one-shot explicit imports. To remove already indexed data, rebuild the index and +import only the sources you still want. ## SQL Inspection @@ -173,6 +183,7 @@ Stable read-only views are the preferred compatibility surface: - `ctx_files_touched`; - `ctx_sources`. +Run `ctx docs show sql` for view schemas, examples, limits, and output formats. Internal tables remain local and queryable, but they are implementation details and can change across versions. SQL output is private local history by default. @@ -208,6 +219,9 @@ not remove provider-owned history such as `~/.codex/sessions`. No local search index can be considered share-safe by default. Indexed prompts, code, commands, file paths, and output previews may contain credentials, customer data, private repository names, or proprietary design notes. +The persisted `safe_preview` redaction state and `safe_preview_text` search +columns are legacy local-index names for searchable previews; they do not mean +the stored text has been redacted for sharing. Recommended handling: @@ -233,23 +247,32 @@ opt-outs such as `CTX_UPGRADE_OFF=1` or `CTX_DISABLE_AUTO_UPGRADE=1`. Upgrade metadata checks do not send provider transcript text, search queries, result snippets, source paths, repository names, or command output. -First-party analytics are default-on and may create `install.json` and send -coarse product metadata. They do not send session text, prompts, transcripts, -search queries, result snippets, source paths, repository or branch names, -native session IDs, command text, command output, or raw IP addresses. +First-party analytics are default-on and may create `install.json` plus a +separate device identity file in OS user state, then send coarse product +metadata. They do not send session text, prompts, transcripts, search queries, +result snippets, source paths, repository or branch names, native session IDs, +command text, command output, usernames, hostnames, raw IP addresses, or +hardware-derived machine fingerprints. Analytics may include: -- a generated install identifier that is hashed server-side; +- generated random install and device identifiers that are hashed server-side; - ctx version, OS, architecture, command name, success state, and duration bucket; - JSON-output and option booleans such as whether a search used filters; - bucketed counts such as indexed sessions, import totals, result counts, and validation finding counts; +- bucketed search query length and term count, but not query content; - provider identifiers such as `codex` or `claude` when selected as filters; - coarse Cloudflare-derived geography such as country, region, colo, ASN, and AS organization. +The install identifier lives in `install.json` under the configured ctx data +root and represents that local index. The device identifier is a random UUID +created only when analytics are enabled and an event is sent; it lives outside +the ctx data root in OS user state, such as `$XDG_STATE_HOME/ctx/device.json` or +`~/.local/state/ctx/device.json` on Linux. + `ctx sql` and MCP do not send first-party analytics events. To disable analytics, add: diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index e4508f61f..b10deee70 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -12,7 +12,7 @@ Confirm the provider keeps history on this machine and pass an explicit path if needed: ```bash -ctx import --path ~/.codex/sessions +ctx import --provider codex --path ~/.codex/sessions ``` ## Search Misses Recent Work diff --git a/docs/upgrade.md b/docs/upgrade.md new file mode 100644 index 000000000..e192655ec --- /dev/null +++ b/docs/upgrade.md @@ -0,0 +1,48 @@ +# Upgrade + +`ctx upgrade` checks and applies signed ctx CLI releases for binaries installed +by the official hosted installer. + +```bash +ctx upgrade status +ctx upgrade status --json +ctx upgrade check +ctx upgrade check --json +ctx upgrade --dry-run +ctx upgrade +ctx upgrade disable +ctx upgrade enable +``` + +The installer writes a sidecar marker next to the binary, such as +`~/.local/bin/ctx.install.json`, recording the managed install path, platform, +version, channel, binary SHA-256, metadata URL, and artifact URL. Source builds, +`cargo install`, package-manager installs, copied binaries, and mismatched +sidecars are treated as unmanaged and will not self-upgrade. +`ctx upgrade status --json` also lists every `ctx` binary found on `PATH` and +warns when another binary shadows the managed install. + +Official installer-managed installs default to background auto-upgrade after +successful normal commands when signed release metadata explicitly allows +auto-upgrade. Background checks never run for `--json` commands, MCP, +`ctx docs`, `ctx sql`, `ctx upgrade`, CI, unmanaged installs, or process-level +opt-outs. They write state and logs under the ctx data root and do not write to +stdout or stderr. + +Use `CTX_UPGRADE_OFF=1` or `CTX_DISABLE_AUTO_UPGRADE=1` for process-level +opt-out, or `ctx upgrade disable` to write `upgrade.auto = "off"` in +`config.toml`. Use `ctx upgrade enable` to restore managed background +auto-upgrade for installer-managed binaries. + +Manual `ctx upgrade` verifies signed release metadata, explicit self-upgrade +policy, artifact SHA-256, the current managed install marker, and the staged +binary's `ctx --version` output before replacing the installed binary. + +On Windows, replacement may be scheduled by a helper that finishes after the +running `ctx.exe` exits; JSON reports `status: "scheduled"` and +`applied: false` until replacement completes. + +Background checks write `upgrade-state.json` and `logs/upgrade.log` under the +ctx data root. `ctx upgrade status` reads that local state. Upgrade metadata +checks do not send provider transcript text, search queries, result snippets, +source paths, repository names, or command output. diff --git a/plugins/ctx-agent-history-search/skills/ctx-agent-history-search/SKILL.md b/plugins/ctx-agent-history-search/skills/ctx-agent-history-search/SKILL.md index 1bec9819d..364ecd52f 100644 --- a/plugins/ctx-agent-history-search/skills/ctx-agent-history-search/SKILL.md +++ b/plugins/ctx-agent-history-search/skills/ctx-agent-history-search/SKILL.md @@ -32,16 +32,28 @@ Use this skill in two modes: ## Workflow -1. Search with normal language first. Add terms or filters when useful: +1. Confirm ctx is ready when starting from a cold context: + + ```bash + ctx status + ctx sources + ``` + + Use `ctx status --json` or `ctx sources --json` only when a script needs + exact fields. + +2. Search with normal language first. Add terms or filters when useful: ```bash ctx search "" + ctx search "" --refresh off ctx search "" --provider codex ctx search "" --workspace ctx search "" --file ctx search "" --since 30d ctx search "" --term "" --term "" ctx search "" --session + ctx search "" --verbose ``` Use default text output for agent reading. Do not add `--json` for @@ -51,9 +63,17 @@ Use this skill in two modes: When the prompt asks for a topic history or report across multiple sessions, run several `ctx search` queries with different wording and filters to find - promising sessions. Use scoped `ctx search ... --session ` - when a session looks relevant and you need dense event-level matches from - that session. + promising sessions. Use scoped + `ctx search "" --session ` when a session looks + relevant and you need dense event-level matches from that session. + + Default search returns primary-agent sessions so human intent and decisions + stay prominent. Use `--include-subagents` when implementation details, code + review notes, test output, or failure traces from subagent sessions are + likely to matter. + + Use `--verbose` when you need full ctx IDs, provider IDs, citations, and + copyable follow-up commands without switching to JSON. You can write a session transcript to a temporary file, check the file size, and then read the relevant parts: @@ -68,27 +88,51 @@ Use this skill in two modes: dominate historical retrieval. Use `--include-current-session` only when the active session tree is the target. -2. Inspect relevant results before relying on them: +3. Inspect relevant results before relying on them: ```bash ctx show event --window 5 ctx show session ``` -3. Locate original provider material when source identity or resume hints matter: +4. Locate original provider material when source identity or resume hints matter: ```bash ctx locate event ctx locate session ``` -4. Write a transcript of relevant sessions when you, the human, or another +5. Write a transcript of relevant sessions when you, the human, or another agent needs a file: ```bash ctx show session --format markdown --out ``` +## When Search Is Not Enough + +Use `ctx sql` only when normal search cannot express the question, such as +counts, joins, audits, or scripts over stable local views. Do not use SQL for +broad transcript text search; `ctx search` is built for that. + +Start with the bundled SQL docs: + +```bash +ctx docs show sql +ctx docs search "stable views" +``` + +Common SQL examples: + +```bash +ctx sql "SELECT provider, COUNT(*) AS sessions FROM ctx_sessions GROUP BY provider" +ctx sql "SELECT event_type, COUNT(*) AS events FROM ctx_events GROUP BY event_type ORDER BY events DESC" +ctx sql "SELECT path, provider, provider_session_id FROM ctx_files_touched WHERE path LIKE '%AGENTS.md%' LIMIT 20" +``` + +`ctx sql` is read-only and queries the existing index. It does not refresh, +import, initialize, or migrate ctx storage. + ## History Research Reports When asked to research a historical topic, stay read-only unless the user also @@ -100,10 +144,12 @@ material. chronology, alternatives, or detailed evidence. 2. Run several targeted searches. Vary query terms across user wording, file or module names, error text, commands, branch names, and decision terms. Start - with default `ctx search`, then broaden with `--term` or narrow with + with `ctx search ""`, then broaden with `--term` or narrow with `--workspace`, `--provider`, `--file`, `--since`, or `--session `. - Add `--refresh off` when the report must not update the local ctx index. + Use `--include-subagents` when reviews, implementation attempts, test output, + or failure traces are likely to live in delegated sessions. Add + `--refresh off` when the report must not update the local ctx index. 3. Inspect focused sources before drawing conclusions. Prefer `ctx show event` for a hit plus nearby turns, and `ctx show session` when the whole session arc matters: diff --git a/scripts/audit-search-mvp-package.sh b/scripts/audit-search-mvp-package.sh index 3675347df..f5f90832f 100755 --- a/scripts/audit-search-mvp-package.sh +++ b/scripts/audit-search-mvp-package.sh @@ -75,11 +75,11 @@ if tracked_files | grep -E '^(\.ctx/exec-plans|docs/exec-plans|.*exec[_-]plan.*\ fail 'execution plans are present in package-visible source' fi -if tracked_files | grep -E '^(examples|assets)/' | grep -E -i 'dashboard|work-[r]ecord|ctx-records|capture-spool|evidence|link-pr|publish|shim' >/dev/null; then +if tracked_files | grep -E '^(examples|assets)/' | grep -E -i 'dashboard|work-[r]ecord|ctx-records|capture-spool|evidence|link-pr|publish|shim|provider-live|completion-certificate|freebsd-native-release-proof|r2-' >/dev/null; then fail 'tracked examples or assets contain removed product-surface material' fi -if grep_files 'dashboard|shim|shims|pull request|pull-request|pr evidence|pr-evidence|ctx publish|ctx evidence|ctx pr|ctx link-pr|ctx context|ctx update|ctx uninstall|\bADE\b|\b[Aa]mp\b|[Aa]mpcode|normalized-only|normalized only|normalized_import_only|normalized provider JSONL|CTX_PROVIDER_NORMALIZED_IMPORT_DEV|[W]ork Recorder|[w]ork recorder|\bwork-[r]ecord\b' \ +if grep_files 'dashboard|shim|shims|pull request|pull-request|pr evidence|pr-evidence|ctx publish|ctx evidence|ctx pr|ctx link-pr|ctx context|ctx update|ctx uninstall|\bADE\b|\b[Aa]mp\b|[Aa]mpcode|normalized-only|normalized only|normalized_import_only|normalized provider JSONL|CTX_PROVIDER_NORMALIZED_IMPORT_DEV|provider-live|completion-certificate|freebsd-native-release-proof|r2-|[W]ork Recorder|[w]ork recorder|\bwork-[r]ecord\b' \ "${public_user_docs[@]}" >/dev/null 2>&1; then fail 'public docs contain removed product-surface wording' fi @@ -93,7 +93,7 @@ if ! diff -u skills/ctx-agent-history-search/SKILL.md plugins/ctx-agent-history- fail 'plugin skill copy differs from public skill source' fi -if grep_files '[W]ork Recorder|[w]ork recorder|ctx publish|ctx evidence|ctx pr|ctx link-pr|ctx context|ctx update|ctx uninstall|update checks|auto-update|update-state|auto_update|CTX_UPDATE|release manifest|dashboard export|gh CLI|GhCli|upsert_github|write-shim-command|write_shim_command|capture_shim_command|shim_command_envelope|\bADE\b|\b[Aa]mp\b|[Aa]mpcode' \ +if grep_files '[W]ork Recorder|[w]ork recorder|ctx publish|ctx evidence|ctx pr|ctx link-pr|ctx context|ctx update|ctx uninstall|update checks|auto-update|update-state|auto_update|CTX_UPDATE|release manifest|provider-live|completion-certificate|freebsd-native-release-proof|r2-|dashboard export|gh CLI|GhCli|upsert_github|write-shim-command|write_shim_command|capture_shim_command|shim_command_envelope|\bADE\b|\b[Aa]mp\b|[Aa]mpcode' \ .bazelignore .bazelrc .bazelversion .buildkite .gitignore README.md SECURITY.md docs skills scripts crates/ctx-cli/src >/dev/null 2>&1; then fail 'public docs/help/release path contains removed product-surface text' fi diff --git a/scripts/bazel-test.sh b/scripts/bazel-test.sh index fa133624f..cade129c3 100755 --- a/scripts/bazel-test.sh +++ b/scripts/bazel-test.sh @@ -120,6 +120,9 @@ case "${mode}" in provider_fixture_e2e) run_cargo_test -p ctx --test cli codex_cli_provider_oracle_covers_retrieval_and_claimed_fidelity run_cargo_test -p ctx --test cli pi_cli_import_search_flow + run_cargo_test -p ctx --test cli native_provider_cli_flow_imports_new_supported_provider_paths + run_cargo_test -p ctx --test cli native_provider_cli_requires_existing_history_or_explicit_path + run_cargo_test -p ctx --test cli antigravity_cli_imports_native_transcript_tree ;; local_transcript_oracle) run_cargo_test -p ctx --test cli local_transcript_oracle_preserves_cli_json_and_sqlite diff --git a/scripts/build-public-cli-artifact.sh b/scripts/build-public-cli-artifact.sh index 4a7cd0c64..a86d5c9e7 100755 --- a/scripts/build-public-cli-artifact.sh +++ b/scripts/build-public-cli-artifact.sh @@ -1,6 +1,11 @@ #!/usr/bin/env bash set -euo pipefail +ZIG_VERSION="0.14.1" +ZIG_LINUX_X64_URL="https://ziglang.org/download/${ZIG_VERSION}/zig-x86_64-linux-${ZIG_VERSION}.tar.xz" +ZIG_LINUX_X64_SHA256="24aeeec8af16c381934a6cd7d95c807a8cb2cf7df9fa40d359aa884195c4716c" +CARGO_ZIGBUILD_VERSION="0.23.0" + usage() { cat >&2 <<'USAGE' Usage: scripts/build-public-cli-artifact.sh PLATFORM @@ -46,17 +51,82 @@ esac root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "${root_dir}" +ensure_zig_for_linux_x64() { + if command -v zig >/dev/null 2>&1; then + return + fi + + if [[ "$(uname -s)" != "Linux" ]]; then + echo "error: zig is required to cross-build ${platform} from $(uname -s)" >&2 + exit 127 + fi + case "$(uname -m)" in + x86_64|amd64) ;; + *) + echo "error: automatic Zig bootstrap only supports Linux x86_64, got $(uname -m)" >&2 + exit 127 + ;; + esac + + for required_tool in curl tar; do + if ! command -v "${required_tool}" >/dev/null 2>&1; then + echo "error: ${required_tool} is required to bootstrap Zig ${ZIG_VERSION}" >&2 + exit 127 + fi + done + + toolchain_dir="${CTX_PUBLIC_CLI_TOOLCHAIN_DIR:-target/public-cli-toolchain}" + install_dir="${toolchain_dir}/zig-x86_64-linux-${ZIG_VERSION}" + if [[ ! -x "${install_dir}/zig" ]]; then + mkdir -p "${toolchain_dir}" + archive="${toolchain_dir}/zig-x86_64-linux-${ZIG_VERSION}.tar.xz" + tmp_archive="${archive}.tmp" + curl -fsSL "${ZIG_LINUX_X64_URL}" -o "${tmp_archive}" + if command -v sha256sum >/dev/null 2>&1; then + actual_sha="$(sha256sum "${tmp_archive}" | awk '{ print $1 }')" + elif command -v shasum >/dev/null 2>&1; then + actual_sha="$(shasum -a 256 "${tmp_archive}" | awk '{ print $1 }')" + else + echo "error: sha256sum or shasum is required to verify Zig ${ZIG_VERSION}" >&2 + exit 127 + fi + if [[ "${actual_sha}" != "${ZIG_LINUX_X64_SHA256}" ]]; then + echo "error: Zig ${ZIG_VERSION} checksum mismatch: expected ${ZIG_LINUX_X64_SHA256}, got ${actual_sha}" >&2 + exit 1 + fi + mv "${tmp_archive}" "${archive}" + rm -rf "${install_dir}" + tar -C "${toolchain_dir}" -xf "${archive}" + fi + export PATH="${install_dir}:${PATH}" +} + +ensure_darwin_cross_tools() { + if ! command -v cargo-zigbuild >/dev/null 2>&1; then + cargo install cargo-zigbuild --version "${CARGO_ZIGBUILD_VERSION}" --locked + fi + ensure_zig_for_linux_x64 + command -v zig >/dev/null 2>&1 || { + echo "error: zig is required to cross-build ${platform} from $(uname -s)" >&2 + exit 127 + } +} + version="$(cargo metadata --no-deps --format-version 1 | python3 -c 'import json,sys; data=json.load(sys.stdin); print(next(pkg["version"] for pkg in data["packages"] if pkg["name"] == "ctx"))')" -if [[ "${version}" != "0.12.0" ]]; then - echo "error: ctx package version must be 0.12.0 for this release, got ${version}" >&2 +if [[ -z "${version}" ]]; then + echo "error: could not determine ctx package version from Cargo metadata" >&2 exit 1 fi +echo "building ctx ${version} for ${platform}" rustup target add "${target}" >/dev/null out_dir="${CTX_PUBLIC_CLI_ARTIFACT_DIR:-target/public-cli-artifacts}" mkdir -p "${out_dir}" -if [[ "${platform}" == "freebsd-x64" ]]; then +if [[ "${platform}" == macos-* && "$(uname -s)" != "Darwin" ]]; then + ensure_darwin_cross_tools + cargo zigbuild -p ctx --release --target "${target}" --locked +elif [[ "${platform}" == "freebsd-x64" ]]; then if ! command -v cross >/dev/null 2>&1; then cargo install cross --locked fi @@ -87,12 +157,12 @@ fi case "${platform}" in linux-x64) "${staged}" --version | tee "${staged}.version" - grep -Fx "ctx 0.12.0" "${staged}.version" >/dev/null + grep -Fx "ctx ${version}" "${staged}.version" >/dev/null ;; macos-arm64) if [[ "$(uname -s)" == "Darwin" && "$(uname -m)" == "arm64" ]]; then "${staged}" --version | tee "${staged}.version" - grep -Fx "ctx 0.12.0" "${staged}.version" >/dev/null + grep -Fx "ctx ${version}" "${staged}.version" >/dev/null else printf 'not run on this host: %s\n' "${platform}" > "${staged}.version" fi @@ -100,7 +170,7 @@ case "${platform}" in macos-x64) if [[ "$(uname -s)" == "Darwin" ]] && /usr/bin/arch -x86_64 /usr/bin/true >/dev/null 2>&1; then /usr/bin/arch -x86_64 "${staged}" --version | tee "${staged}.version" - grep -Fx "ctx 0.12.0" "${staged}.version" >/dev/null + grep -Fx "ctx ${version}" "${staged}.version" >/dev/null else printf 'not run on this host: %s\n' "${platform}" > "${staged}.version" fi @@ -110,4 +180,6 @@ case "${platform}" in ;; esac +scripts/check-public-cli-artifact.sh "${platform}" "${out_dir}" + printf 'built %s for %s sha256=%s\n' "${staged}" "${platform}" "$(cat "${sha_file}")" diff --git a/scripts/check-buildkite-pipeline.sh b/scripts/check-buildkite-pipeline.sh index 70705d20d..18f1d6bb0 100755 --- a/scripts/check-buildkite-pipeline.sh +++ b/scripts/check-buildkite-pipeline.sh @@ -34,6 +34,14 @@ if command -v ruby >/dev/null 2>&1; then next unless step.is_a?(Hash) abort "artifact step #{step["key"]} must be gated" unless step["if"].to_s.include?("CTX_PUBLIC_CLI_ARTIFACT_MATRIX") end + %w[public-cli-macos-arm64 public-cli-macos-x64].each do |key| + step = steps.find { |candidate| candidate.is_a?(Hash) && candidate["key"] == key } + abort "missing macOS artifact step #{key}" unless step + abort "#{key} must cross-build on release-linux-managed" unless step.dig("agents", "queue") == "release-linux-managed" + abort "#{key} must run on linux" unless step.dig("agents", "os") == "linux" + abort "#{key} must run on x86_64" unless step.dig("agents", "arch") == "x86_64" + abort "#{key} must not serialize on the Mac GUI queue" if step.key?("concurrency_group") + end ' "${pipeline}" else top_level_steps="$( @@ -62,13 +70,42 @@ for required in \ 'scripts/build-public-cli-artifact.sh windows-x64' \ 'scripts/build-public-cli-artifact.sh freebsd-x64' \ 'scripts/build-public-cli-artifact.sh macos-arm64' \ - 'scripts/build-public-cli-artifact.sh macos-x64'; do + 'scripts/build-public-cli-artifact.sh macos-x64' \ + 'cargo zigbuild -p ctx --release --target "${target}" --locked' \ + 'CARGO_ZIGBUILD_VERSION' \ + 'ZIG_LINUX_X64_SHA256'; do if ! grep -F -q "${required}" "${pipeline}"; then - printf 'pipeline missing required snippet: %s\n' "${required}" >&2 - exit 1 + if ! grep -F -q "${required}" scripts/build-public-cli-artifact.sh; then + printf 'pipeline or artifact script missing required snippet: %s\n' "${required}" >&2 + exit 1 + fi + continue fi done +if grep -F -q 'ctx-mac-gui-shared-arm64' "${pipeline}"; then + printf 'public CLI artifact matrix must not use the scarce Mac GUI queue\n' >&2 + exit 1 +fi + +for mac_step in public-cli-macos-arm64 public-cli-macos-x64; do + for required in \ + 'queue: "release-linux-managed"' \ + 'ctx-runner-class: "release-linux-control"' \ + 'os: "linux"' \ + 'arch: "x86_64"'; do + if ! awk ' + index($0, "key: \"" step "\"") { in_step = 1 } + in_step && /^ - label:/ && index($0, step) == 0 { in_step = 0 } + in_step && index($0, needle) { found = 1 } + END { exit found ? 0 : 1 } + ' step="${mac_step}" needle="${required}" "${pipeline}"; then + printf '%s artifact step missing required Linux runner snippet: %s\n' "${mac_step}" "${required}" >&2 + exit 1 + fi + done +done + if grep -E -q 'release-artifact|r2-|provider-live|OpenRouter|completion-certificate|freebsd-native-release-proof|CTX_PUBLIC_CLI_PERF_GATES|--mode=perf|public-perf' "${pipeline}"; then printf 'pipeline contains non-smoke release or provider-live wiring\n' >&2 exit 1 diff --git a/scripts/check-docs.sh b/scripts/check-docs.sh index 8805128e2..514cfa170 100755 --- a/scripts/check-docs.sh +++ b/scripts/check-docs.sh @@ -41,6 +41,7 @@ done if command -v jq >/dev/null 2>&1; then jq empty docs/provider-support-matrix.json fi +python3 scripts/check-provider-support-matrix.py public_docs=( README.md diff --git a/scripts/check-provider-support-matrix.py b/scripts/check-provider-support-matrix.py new file mode 100755 index 000000000..87f2cbe55 --- /dev/null +++ b/scripts/check-provider-support-matrix.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python3 +"""Validate the public provider support matrix. + +This is a public truthfulness gate. It checks that documented provider support +has public docs, public tests, and any claimed fixture paths in the repository. +It intentionally does not require live provider runs, private fixture +provenance, release evidence, or network access. +""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path +from typing import Any + + +REPO_ROOT = Path(__file__).resolve().parents[1] +MATRIX_PATH = REPO_ROOT / "docs/provider-support-matrix.json" +ALLOWED_STATUSES = { + "local_import", + "local_import_when_supported", + "fixture_only", + "detected_unsupported", + "blocked", +} +ALLOWED_PATH_KINDS = { + "native_import", + "fixture_import", + "detected_unsupported", + "blocked", +} +ALLOWED_FIDELITY = { + "imported", + "partial", + "fixture_only", + "detected_unsupported", + "blocked", +} +REQUIRED_FIDELITY_FIELDS = { + "user_prompts", + "assistant_messages", + "tool_calls", + "tool_output", + "command_output", + "files_touched", + "artifacts", + "model_identity", + "costs", + "token_usage", + "parent_child_session_edges", +} +PROVIDER_ID_RE = re.compile(r"^[a-z0-9][a-z0-9_]*$") +PRIVATE_TEXT_MARKERS = ("/home/", "ctx-" + "private", "ctx-multi" + "-repo-workspace") +SUPPORT_DOC_PATH = REPO_ROOT / "docs/provider-support.md" + + +class MatrixError(Exception): + pass + + +def fail(message: str) -> None: + raise MatrixError(message) + + +def expect_type(value: Any, expected_type: type, field: str) -> Any: + if not isinstance(value, expected_type): + fail(f"{field} must be {expected_type.__name__}") + return value + + +def require_non_empty_string(value: Any, field: str) -> str: + text = expect_type(value, str, field) + if not text.strip(): + fail(f"{field} must be non-empty") + return text + + +def require_string_list(value: Any, field: str, *, allow_empty: bool = False) -> list[str]: + items = expect_type(value, list, field) + if not allow_empty and not items: + fail(f"{field} must not be empty") + for index, item in enumerate(items): + require_non_empty_string(item, f"{field}[{index}]") + return items + + +def require_repo_path(value: str, field: str) -> Path: + if value.startswith("/") or ".." in Path(value).parts: + fail(f"{field} must be a relative repository path") + path = REPO_ROOT / value + if not path.exists(): + fail(f"{field} does not exist: {value}") + return path + + +def scan_private_text(value: Any, field: str) -> None: + if isinstance(value, str): + if any(token in value for token in PRIVATE_TEXT_MARKERS): + fail(f"{field} contains private path wording") + return + if isinstance(value, list): + for index, item in enumerate(value): + scan_private_text(item, f"{field}[{index}]") + return + if isinstance(value, dict): + for key, item in value.items(): + scan_private_text(item, f"{field}.{key}") + + +def text_mentions_provider(text: str, provider: dict[str, Any]) -> bool: + needles = { + str(provider["id"]), + str(provider["capture_provider"]), + str(provider["capture_provider"]).replace("_", "-"), + str(provider["display_name"]), + str(provider["display_name"]).lower(), + } + lowered = text.lower() + return any(needle and needle.lower() in lowered for needle in needles) + + +def validate_implemented_path(path: Any, provider_id: str, index: int) -> None: + label = f"providers[{provider_id}].implemented_paths[{index}]" + expect_type(path, dict, label) + + kind = require_non_empty_string(path.get("kind"), f"{label}.kind") + if kind not in ALLOWED_PATH_KINDS: + fail(f"{label}.kind has unsupported value: {kind}") + + source_format = require_non_empty_string(path.get("source_format"), f"{label}.source_format") + if any(token in source_format for token in PRIVATE_TEXT_MARKERS): + fail(f"{label}.source_format contains private path wording") + + fidelity = require_non_empty_string(path.get("fidelity"), f"{label}.fidelity") + if fidelity not in ALLOWED_FIDELITY: + fail(f"{label}.fidelity has unsupported value: {fidelity}") + + proof = require_string_list(path.get("proof"), f"{label}.proof") + if not any("ctx " in item or item.startswith("cargo test") or item == "ctx sources" for item in proof): + fail(f"{label}.proof must name a public ctx command or cargo test") + + notes = require_string_list(path.get("notes", []), f"{label}.notes", allow_empty=True) + for note_index, note in enumerate(notes): + if any(token in note for token in PRIVATE_TEXT_MARKERS): + fail(f"{label}.notes[{note_index}] contains private path wording") + + +def validate_provider(provider: Any, index: int, seen_ids: set[str]) -> None: + label = f"providers[{index}]" + expect_type(provider, dict, label) + + provider_id = require_non_empty_string(provider.get("id"), f"{label}.id") + if not PROVIDER_ID_RE.fullmatch(provider_id): + fail(f"{label}.id must use lowercase snake_case") + if provider_id in seen_ids: + fail(f"duplicate provider id: {provider_id}") + seen_ids.add(provider_id) + scan_private_text(provider, f"providers[{provider_id}]") + + require_non_empty_string(provider.get("display_name"), f"providers[{provider_id}].display_name") + require_non_empty_string(provider.get("priority"), f"providers[{provider_id}].priority") + require_non_empty_string(provider.get("capture_provider"), f"providers[{provider_id}].capture_provider") + + status = require_non_empty_string(provider.get("status"), f"providers[{provider_id}].status") + if status not in ALLOWED_STATUSES: + fail(f"providers[{provider_id}].status has unsupported value: {status}") + + public_docs = require_non_empty_string(provider.get("public_docs"), f"providers[{provider_id}].public_docs") + public_doc_path = require_repo_path(public_docs, f"providers[{provider_id}].public_docs") + public_doc_text = public_doc_path.read_text(encoding="utf-8") + if provider["display_name"] not in public_doc_text and provider_id not in public_doc_text: + fail(f"providers[{provider_id}].public_docs does not mention the provider") + + support_doc_text = SUPPORT_DOC_PATH.read_text(encoding="utf-8") + support_row = f"| {provider['display_name']} | `{status}` |" + if support_row not in support_doc_text: + fail(f"docs/provider-support.md is missing matrix row for {provider_id} with status {status}") + + tests = require_string_list(provider.get("tests"), f"providers[{provider_id}].tests") + provider_specific_test = False + for test_index, test_path in enumerate(tests): + resolved_test_path = require_repo_path(test_path, f"providers[{provider_id}].tests[{test_index}]") + if resolved_test_path.is_file() and text_mentions_provider( + resolved_test_path.read_text(encoding="utf-8", errors="ignore"), + provider, + ): + provider_specific_test = True + + fixture_paths = require_string_list( + provider.get("fixture_paths", []), + f"providers[{provider_id}].fixture_paths", + allow_empty=True, + ) + for fixture_index, fixture_path in enumerate(fixture_paths): + require_repo_path(fixture_path, f"providers[{provider_id}].fixture_paths[{fixture_index}]") + + implemented_paths = expect_type( + provider.get("implemented_paths", []), + list, + f"providers[{provider_id}].implemented_paths", + ) + if not implemented_paths and status not in {"detected_unsupported", "blocked"}: + fail(f"providers[{provider_id}].implemented_paths must not be empty") + for path_index, implemented_path in enumerate(implemented_paths): + validate_implemented_path(implemented_path, provider_id, path_index) + + imports_existing_history = provider.get("imports_existing_history") + if not isinstance(imports_existing_history, bool): + fail(f"providers[{provider_id}].imports_existing_history must be boolean") + if status.startswith("local_import") and not imports_existing_history: + fail(f"providers[{provider_id}] is {status} but imports_existing_history is false") + if imports_existing_history and not implemented_paths: + fail(f"providers[{provider_id}] imports history but has no implemented_paths") + + for bool_field in ("captures_new_runs_passively", "child_sessions_supported"): + if not isinstance(provider.get(bool_field), bool): + fail(f"providers[{provider_id}].{bool_field} must be boolean") + + fidelity = expect_type(provider.get("fidelity"), dict, f"providers[{provider_id}].fidelity") + missing_fidelity = REQUIRED_FIDELITY_FIELDS.difference(fidelity) + if missing_fidelity: + fail(f"providers[{provider_id}].fidelity missing fields: {', '.join(sorted(missing_fidelity))}") + for field in REQUIRED_FIDELITY_FIELDS: + if not isinstance(fidelity[field], bool): + fail(f"providers[{provider_id}].fidelity.{field} must be boolean") + + if status == "local_import" and not fixture_paths: + fail(f"providers[{provider_id}] is local_import but has no fixture_paths") + if status.startswith("local_import") and "crates/ctx-cli/tests/cli.rs" not in tests: + fail(f"providers[{provider_id}] needs public CLI coverage in crates/ctx-cli/tests/cli.rs") + if status.startswith("local_import") and not provider_specific_test: + fail(f"providers[{provider_id}] has no provider-specific public test references") + + +def main() -> int: + try: + matrix = json.loads(MATRIX_PATH.read_text(encoding="utf-8")) + expect_type(matrix, dict, "provider support matrix") + scan_private_text(matrix, "provider support matrix") + if matrix.get("schema_version") != 1: + fail("schema_version must be 1") + require_non_empty_string(matrix.get("scope"), "scope") + providers = expect_type(matrix.get("providers"), list, "providers") + if not providers: + fail("providers must not be empty") + + seen_ids: set[str] = set() + for index, provider in enumerate(providers): + validate_provider(provider, index, seen_ids) + except (OSError, json.JSONDecodeError, MatrixError) as exc: + print(f"provider support matrix check failed: {exc}", file=sys.stderr) + return 1 + + print("provider support matrix ok") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check-public-cli-artifact.sh b/scripts/check-public-cli-artifact.sh new file mode 100755 index 000000000..b223aecb7 --- /dev/null +++ b/scripts/check-public-cli-artifact.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat >&2 <<'USAGE' +Usage: scripts/check-public-cli-artifact.sh PLATFORM [ARTIFACT_DIR] + +Checks one locally staged public ctx CLI artifact. This validates only local +public release outputs: artifact presence, SHA-256 sidecar consistency, and +version sidecar contents. +USAGE +} + +platform="${1:-}" +artifact_dir="${2:-target/public-cli-artifacts}" +if [[ -z "${platform}" || "${platform}" == "-h" || "${platform}" == "--help" ]]; then + usage + exit 2 +fi + +case "${platform}" in + linux-x64) + binary_name="ctx" + ;; + macos-arm64) + binary_name="ctx-macos-arm64" + ;; + macos-x64) + binary_name="ctx-macos-x64" + ;; + windows-x64) + binary_name="ctx.exe" + ;; + freebsd-x64) + binary_name="ctx-freebsd-x64" + ;; + *) + usage + exit 2 + ;; +esac + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${repo_root}" + +version="$(cargo metadata --no-deps --format-version 1 | python3 -c 'import json,sys; data=json.load(sys.stdin); print(next(pkg["version"] for pkg in data["packages"] if pkg["name"] == "ctx"))')" +artifact="${artifact_dir%/}/${binary_name}" +sha_file="${artifact}.sha256" +version_file="${artifact}.version" + +if [[ ! -f "${artifact}" ]]; then + printf 'public CLI artifact missing: %s\n' "${artifact}" >&2 + exit 1 +fi + +if [[ ! -s "${sha_file}" ]]; then + printf 'public CLI artifact SHA-256 sidecar missing or empty: %s\n' "${sha_file}" >&2 + exit 1 +fi + +expected_sha="$(tr -d '[:space:]' < "${sha_file}")" +if [[ ! "${expected_sha}" =~ ^[0-9a-fA-F]{64}$ ]]; then + printf 'public CLI artifact SHA-256 sidecar is not a digest: %s\n' "${sha_file}" >&2 + exit 1 +fi + +if command -v sha256sum >/dev/null 2>&1; then + actual_sha="$(sha256sum "${artifact}" | awk '{ print $1 }')" +else + actual_sha="$(shasum -a 256 "${artifact}" | awk '{ print $1 }')" +fi + +actual_sha_lower="$(printf '%s' "${actual_sha}" | tr 'A-F' 'a-f')" +expected_sha_lower="$(printf '%s' "${expected_sha}" | tr 'A-F' 'a-f')" +if [[ "${actual_sha_lower}" != "${expected_sha_lower}" ]]; then + printf 'public CLI artifact checksum mismatch for %s: expected %s got %s\n' \ + "${artifact}" "${expected_sha}" "${actual_sha}" >&2 + exit 1 +fi + +if [[ ! -s "${version_file}" ]]; then + printf 'public CLI artifact version sidecar missing or empty: %s\n' "${version_file}" >&2 + exit 1 +fi + +actual_version="$(tr -d '\r' < "${version_file}" | sed 's/[[:space:]]*$//' | tail -n 1)" +can_run_on_host=0 +case "${platform}" in + linux-x64) + if [[ "$(uname -s 2>/dev/null || true)" == "Linux" ]]; then + case "$(uname -m 2>/dev/null || true)" in + x86_64|amd64) can_run_on_host=1 ;; + esac + fi + ;; + macos-arm64) + if [[ "$(uname -s 2>/dev/null || true)" == "Darwin" && "$(uname -m 2>/dev/null || true)" == "arm64" ]]; then + can_run_on_host=1 + fi + ;; + macos-x64) + if [[ "$(uname -s 2>/dev/null || true)" == "Darwin" ]] && /usr/bin/arch -x86_64 /usr/bin/true >/dev/null 2>&1; then + can_run_on_host=1 + fi + ;; + freebsd-x64) + if [[ "$(uname -s 2>/dev/null || true)" == "FreeBSD" ]]; then + case "$(uname -m 2>/dev/null || true)" in + x86_64|amd64) can_run_on_host=1 ;; + esac + fi + ;; +esac + +case "${actual_version}" in + "ctx ${version}") ;; + "not run on this host: ${platform}") + if [[ "${can_run_on_host}" == "1" ]]; then + printf 'public CLI artifact version sidecar skipped a runnable host platform: %s\n' "${platform}" >&2 + exit 1 + fi + ;; + *) + printf 'public CLI artifact version sidecar has unexpected content: %s\n' "${actual_version}" >&2 + exit 1 + ;; +esac + +printf 'public CLI artifact ok: %s sha256=%s\n' "${platform}" "${actual_sha}" diff --git a/sdks/dotnet/README.md b/sdks/dotnet/README.md index d74a9c025..1b95fbf5f 100644 --- a/sdks/dotnet/README.md +++ b/sdks/dotnet/README.md @@ -55,7 +55,7 @@ Console.WriteLine(results.ToJsonObject().ToJsonString()); - `SourcesAsync()` - `ImportHistoryAsync(ImportOptions?)` - `SyncAsync(ImportOptions?)` -- `SearchAsync(SearchOptions?)` +- `SearchAsync(SearchOptions)` with a query, term, or file option - `ShowEventAsync(string, ShowEventOptions?)` - `ShowSessionAsync(string, ShowSessionOptions?)` - `ShowSessionAsync(ShowSessionOptions)` @@ -81,7 +81,7 @@ future fields remain additive and accessible. SDK failures derive from - `ctx setup --json` - `ctx sources --json` - `ctx import --json` -- `ctx search ... --json` +- `ctx search |--term |--file --json` - `ctx show event ... --format json` - `ctx show session ... --format json` - `ctx locate event ... --format json` diff --git a/sdks/dotnet/src/Ctx.AgentHistory/AgentHistoryClient.cs b/sdks/dotnet/src/Ctx.AgentHistory/AgentHistoryClient.cs index c01c10584..b06362c5e 100644 --- a/sdks/dotnet/src/Ctx.AgentHistory/AgentHistoryClient.cs +++ b/sdks/dotnet/src/Ctx.AgentHistory/AgentHistoryClient.cs @@ -71,6 +71,7 @@ public async Task SyncAsync(ImportOptions? options = null, Cance public async Task SearchAsync(SearchOptions? options = null, CancellationToken cancellationToken = default) { options ??= new SearchOptions(); + RequireSearchIntent(options); var args = new List { "search" }; if (!string.IsNullOrWhiteSpace(options.Query)) { @@ -252,6 +253,18 @@ private static List BuildSessionLookupArgs(string command, string kind, return args; } + private static void RequireSearchIntent(SearchOptions options) + { + if (!string.IsNullOrWhiteSpace(options.Query) + || !string.IsNullOrWhiteSpace(options.File) + || (options.Terms?.Any(term => !string.IsNullOrWhiteSpace(term)) ?? false)) + { + return; + } + + throw new CtxAgentHistoryValidationException("search requires a query, term, or file option"); + } + private static void RequireValue(string value, string name) { if (string.IsNullOrWhiteSpace(value)) diff --git a/sdks/dotnet/tests/Ctx.AgentHistory.Tests/Program.cs b/sdks/dotnet/tests/Ctx.AgentHistory.Tests/Program.cs index ae2ec2355..2241ab281 100644 --- a/sdks/dotnet/tests/Ctx.AgentHistory.Tests/Program.cs +++ b/sdks/dotnet/tests/Ctx.AgentHistory.Tests/Program.cs @@ -12,6 +12,7 @@ private static async Task Main() ("builds local CLI operation arguments", BuildsOperationArguments), ("normalizes setup init status", NormalizesSetupInitStatus), ("builds search flags", BuildsSearchFlags), + ("rejects search without intent", RejectsSearchWithoutIntent), ("wraps show and locate commands", WrapsShowAndLocate), ("reports versioning metadata", ReportsVersioning), ("uses agent-history-v1 error codes", UsesAgentHistoryV1ErrorCodes), @@ -127,6 +128,25 @@ private static async Task BuildsSearchFlags() Equal("off", response.Search.Freshness!.Mode ?? ""); } + private static async Task RejectsSearchWithoutIntent() + { + var transport = new RecordingTransport("""{"schema_version":1,"results":[]}"""); + var client = new AgentHistoryClient(transport); + + await ThrowsAsync(() => client.SearchAsync()); + await ThrowsAsync(() => client.SearchAsync(new SearchOptions + { + Refresh = "off", + Limit = 5 + })); + await ThrowsAsync(() => client.SearchAsync(new SearchOptions + { + Query = " " + })); + + Equal(0, transport.Calls.Count); + } + private static async Task WrapsShowAndLocate() { var transport = new RecordingTransport("""{"schema_version":1,"events":[],"source":{"path":"/tmp/source.jsonl"},"ctx_session_id":"session-1","provider":"codex"}"""); @@ -227,7 +247,7 @@ private static async Task LoadsSharedFixtures() } break; case "search": - _ = (await ClientFor(node["search"]).SearchAsync()).Search.Results; + _ = (await ClientFor(node["search"]).SearchAsync(new SearchOptions { Query = "fixture search" })).Search.Results; break; case "showEvent": _ = (await ClientFor(node["event"]).ShowEventAsync("event-1")).Event.Events; diff --git a/sdks/go/README.md b/sdks/go/README.md index 302dcbd3d..4d0ce6fba 100644 --- a/sdks/go/README.md +++ b/sdks/go/README.md @@ -58,8 +58,9 @@ client := ctxagenthistory.NewLocalClient( ``` The adapter runs JSON-producing CLI commands such as `ctx status --json`, -`ctx search --json`, and `ctx show event --format json`, then normalizes CLI -JSON into `agent-history-v1` wrappers with `contractVersion` and `schemaVersion`. +`ctx search |--term |--file --json`, and +`ctx show event --format json`, then normalizes CLI JSON into +`agent-history-v1` wrappers with `contractVersion` and `schemaVersion`. ## Errors diff --git a/sdks/go/client.go b/sdks/go/client.go index 46924c1ba..c1445f511 100644 --- a/sdks/go/client.go +++ b/sdks/go/client.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "strconv" + "strings" ) // Operation is the adapter-neutral command executed by a transport. @@ -156,6 +157,9 @@ func (c *Client) Sync(ctx context.Context, opts ImportOptions) (*ImportResponse, } func (c *Client) Search(ctx context.Context, opts SearchOptions) (*SearchResponse, error) { + if !opts.hasIntent() { + return nil, sdkError(ErrorKindInvalidArgument, "search requires a query, term, or file option", nil) + } args := []string{"search"} if opts.Query != "" { args = append(args, opts.Query) @@ -198,6 +202,18 @@ func (c *Client) Search(ctx context.Context, opts SearchOptions) (*SearchRespons return &out, nil } +func (opts SearchOptions) hasIntent() bool { + if strings.TrimSpace(opts.Query) != "" || strings.TrimSpace(opts.File) != "" { + return true + } + for _, term := range opts.Terms { + if strings.TrimSpace(term) != "" { + return true + } + } + return false +} + func (c *Client) ShowSession(ctx context.Context, opts ShowSessionOptions) (*ShowSessionResponse, error) { args := []string{"show", "session"} if opts.ID != "" { diff --git a/sdks/go/client_test.go b/sdks/go/client_test.go index 5835acb15..9202f25ec 100644 --- a/sdks/go/client_test.go +++ b/sdks/go/client_test.go @@ -91,6 +91,27 @@ func TestSearchBuildsAgentHistoryV1Operation(t *testing.T) { } } +func TestSearchRequiresQueryTermOrFileBeforeTransport(t *testing.T) { + transport := &recordingTransport{response: `{"schema_version":1,"results":[]}`} + client := NewClient(WithTransport(transport)) + + for name, opts := range map[string]SearchOptions{ + "empty": {}, + "filters only": {Refresh: "off", Limit: 5}, + "blank query": {Query: " "}, + "blank terms": {Terms: []string{"", " "}}, + } { + t.Run(name, func(t *testing.T) { + if _, err := client.Search(context.Background(), opts); !IsErrorKind(err, ErrorKindInvalidArgument) { + t.Fatalf("Search error kind mismatch: %v", err) + } + }) + } + if transport.op.Args != nil { + t.Fatalf("Search invoked transport despite invalid input: %#v", transport.op.Args) + } +} + func TestShowAndLocateValidateRequiredEventID(t *testing.T) { client := NewClient(WithTransport(fakeTransport{response: `{}`})) if _, err := client.ShowEvent(context.Background(), ShowEventOptions{}); !IsErrorKind(err, ErrorKindInvalidArgument) { diff --git a/sdks/jvm/src/main/java/rs/ctx/agenthistory/AgentHistoryClient.java b/sdks/jvm/src/main/java/rs/ctx/agenthistory/AgentHistoryClient.java index bde02bea9..62e36a3ed 100644 --- a/sdks/jvm/src/main/java/rs/ctx/agenthistory/AgentHistoryClient.java +++ b/sdks/jvm/src/main/java/rs/ctx/agenthistory/AgentHistoryClient.java @@ -84,6 +84,7 @@ public SearchResponse search(String query) { public SearchResponse search(AgentHistoryOptions.Search options) { AgentHistoryOptions.Search safe = options == null ? AgentHistoryOptions.search() : options; + requireSearchIntent(safe); List args = new ArrayList<>(); args.add("search"); if (safe.query() != null && !safe.query().isEmpty()) { @@ -112,6 +113,18 @@ public SearchResponse search(AgentHistoryOptions.Search options) { return new SearchResponse(executeEnvelope("search", args)); } + private static void requireSearchIntent(AgentHistoryOptions.Search options) { + if (hasText(options.query()) || hasText(options.file())) { + return; + } + for (String term : options.terms()) { + if (hasText(term)) { + return; + } + } + throw new CtxAgentHistoryException.Validation("search requires a query, term, or file option"); + } + public ShowEventResponse showEvent(String id, AgentHistoryOptions.ShowEvent options) { if (id == null || id.isEmpty()) { throw new CtxAgentHistoryException.Validation("event id is required"); @@ -234,6 +247,10 @@ private static void add(List args, String flag, String value) { } } + private static boolean hasText(String value) { + return value != null && !value.trim().isEmpty(); + } + private static void addInt(List args, String flag, Integer value) { if (value != null) { args.add(flag); diff --git a/sdks/jvm/src/test/java/rs/ctx/agenthistory/AgentHistoryClientTest.java b/sdks/jvm/src/test/java/rs/ctx/agenthistory/AgentHistoryClientTest.java index 2aaaf4a6c..8dfbf8936 100644 --- a/sdks/jvm/src/test/java/rs/ctx/agenthistory/AgentHistoryClientTest.java +++ b/sdks/jvm/src/test/java/rs/ctx/agenthistory/AgentHistoryClientTest.java @@ -15,6 +15,7 @@ public static void main(String[] args) throws Exception { decodesAllCanonicalFixturesThroughTypedResponses(); normalizesRawShowAndLocateResponses(); buildsSearchCommand(); + searchRequiresIntent(); hostedIsExplicitlyUnsupported(); } @@ -173,6 +174,21 @@ private static void buildsSearchCommand() { assertContainsInOrder(transport.lastOperation.args(), "--refresh", "off"); } + private static void searchRequiresIntent() { + FakeTransport transport = new FakeTransport( + "local-cli", + "{\"schema_version\":1,\"query\":\"client\",\"results\":[]}"); + AgentHistoryClient client = AgentHistoryClient.withTransport(transport); + + assertValidation(() -> client.search()); + assertValidation(() -> client.search(AgentHistoryOptions.search().refresh("off").limit(5))); + assertValidation(() -> client.search(" ")); + assertValidation(() -> client.search(AgentHistoryOptions.search().term(" "))); + if (transport.lastOperation != null) { + throw new AssertionError("invalid search invoked transport: " + transport.lastOperation.args()); + } + } + private static void hostedIsExplicitlyUnsupported() { AgentHistoryClient client = AgentHistoryClient.hosted(HostedConfig.builder().baseUrl("https://ctx.example.invalid").build()); try { @@ -214,6 +230,16 @@ private static void assertEquals(Object want, Object got) { } } + private static void assertValidation(Runnable action) { + try { + action.run(); + } catch (CtxAgentHistoryException.Validation error) { + assertEquals("invalid_request", error.code()); + return; + } + throw new AssertionError("expected validation error"); + } + private static final class FakeTransport implements AgentHistoryTransport { private final String name; private final String response; diff --git a/sdks/python/README.md b/sdks/python/README.md index 3e106844f..94441ae06 100644 --- a/sdks/python/README.md +++ b/sdks/python/README.md @@ -39,7 +39,7 @@ The public methods mirror the agent-history-v1 client surface: - `init()` for `ctx setup --json` - `sources()` - `import_()` and `sync()` (`import` is a reserved Python keyword) -- `search()` +- `search()` with a query, term, or file option - `show_event()` / `showEvent()` - `show_session()` / `showSession()` - `locate_event()` / `locateEvent()` diff --git a/sdks/python/src/ctx_agent_history/__init__.py b/sdks/python/src/ctx_agent_history/__init__.py index 23089272f..425396e3a 100644 --- a/sdks/python/src/ctx_agent_history/__init__.py +++ b/sdks/python/src/ctx_agent_history/__init__.py @@ -7,6 +7,7 @@ CtxAgentHistoryError, CtxAgentHistoryProtocolError, CtxAgentHistoryTimeoutError, + CtxAgentHistoryValidationError, HostedTransportNotImplementedError, ) from .types import ( @@ -35,6 +36,7 @@ "CtxAgentHistoryError", "CtxAgentHistoryProtocolError", "CtxAgentHistoryTimeoutError", + "CtxAgentHistoryValidationError", "ErrorResponse", "HostedConfig", "HostedTransportNotImplementedError", diff --git a/sdks/python/src/ctx_agent_history/client.py b/sdks/python/src/ctx_agent_history/client.py index 0531a0bc0..5a4cf9156 100644 --- a/sdks/python/src/ctx_agent_history/client.py +++ b/sdks/python/src/ctx_agent_history/client.py @@ -20,6 +20,7 @@ StatusResponse, SyncResponse, ) +from .validation import validate_search_intent from .version import API_VERSION, SDK_VERSION, VersionInfo Pathish = Union[str, Path] @@ -134,13 +135,15 @@ def search( refresh: Optional[str] = None, include_current_session: bool = False, ) -> SearchResponse: + file_value = str(file) if file is not None else None + validate_search_intent(query=query, terms=terms, file=file_value) return self._transport.search( query=query, provider=provider, workspace=workspace, since=since, event_type=event_type, - file=str(file) if file is not None else None, + file=file_value, session=session, terms=list(terms) if terms is not None else None, events=events, diff --git a/sdks/python/src/ctx_agent_history/errors.py b/sdks/python/src/ctx_agent_history/errors.py index d9ff9c934..ab8622621 100644 --- a/sdks/python/src/ctx_agent_history/errors.py +++ b/sdks/python/src/ctx_agent_history/errors.py @@ -84,6 +84,25 @@ def __init__( ) +class CtxAgentHistoryValidationError(CtxAgentHistoryError): + """Raised before invoking ctx for invalid SDK input.""" + + def __init__( + self, + message: str, + *, + details: Optional[Mapping[str, Any]] = None, + cause: Optional[BaseException] = None, + ) -> None: + super().__init__( + message, + code="invalid_request", + details=details, + retryable=False, + cause=cause, + ) + + class CtxAgentHistoryTimeoutError(CtxAgentHistoryError): """Raised when the local ctx CLI exceeds the configured timeout.""" diff --git a/sdks/python/src/ctx_agent_history/transport.py b/sdks/python/src/ctx_agent_history/transport.py index fd2d10a95..a302b6531 100644 --- a/sdks/python/src/ctx_agent_history/transport.py +++ b/sdks/python/src/ctx_agent_history/transport.py @@ -40,6 +40,7 @@ StatusResponse, SyncResponse, ) +from .validation import validate_search_intent class AgentHistoryTransport(Protocol): @@ -235,6 +236,7 @@ def search( refresh: Optional[str] = None, include_current_session: bool = False, ) -> SearchResponse: + validate_search_intent(query=query, terms=terms, file=file) args = ["search", "--json"] if query is not None: args.append(query) diff --git a/sdks/python/src/ctx_agent_history/validation.py b/sdks/python/src/ctx_agent_history/validation.py new file mode 100644 index 000000000..6f9584edc --- /dev/null +++ b/sdks/python/src/ctx_agent_history/validation.py @@ -0,0 +1,41 @@ +"""SDK input validation helpers.""" + +from __future__ import annotations + +from typing import Optional, Sequence + +from .errors import CtxAgentHistoryValidationError + + +def validate_search_intent( + *, + query: Optional[str], + terms: Optional[Sequence[str]], + file: Optional[str], +) -> None: + if _has_text(query) or _has_text(file) or _has_term(terms): + return + raise CtxAgentHistoryValidationError( + "search requires a query, term, or file option", + details={"query": query, "terms": _term_details(terms), "file": file}, + ) + + +def _has_term(terms: Optional[Sequence[str]]) -> bool: + if terms is None: + return False + if isinstance(terms, str): + return _has_text(terms) + return any(_has_text(term) for term in terms) + + +def _has_text(value: object) -> bool: + return isinstance(value, str) and bool(value.strip()) + + +def _term_details(terms: Optional[Sequence[str]]) -> list[str]: + if terms is None: + return [] + if isinstance(terms, str): + return [terms] + return list(terms) diff --git a/sdks/python/tests/test_client.py b/sdks/python/tests/test_client.py index fc3e1282d..5bf1808a6 100644 --- a/sdks/python/tests/test_client.py +++ b/sdks/python/tests/test_client.py @@ -22,7 +22,7 @@ AgentHistoryClient, ) from ctx_agent_history.errors import CtxAgentHistoryCliError, CtxAgentHistoryProtocolError -from ctx_agent_history.errors import CtxAgentHistoryTimeoutError +from ctx_agent_history.errors import CtxAgentHistoryTimeoutError, CtxAgentHistoryValidationError from ctx_agent_history.types import AgentHistoryErrorCode import dogfood_local @@ -64,7 +64,10 @@ def test_init_sources_import_sync_search_and_inspect_methods(self) -> None: self.assertEqual(client.init(catalog_only=True)["operation"], "init") self.assertEqual(client.sources()["operation"], "sources") self.assertEqual(client.import_(provider="codex", resume=True)["operation"], "import") - self.assertEqual(client.sync(path="/tmp/history.jsonl")["operation"], "sync") + self.assertEqual( + client.sync(provider="codex", path="/tmp/history.jsonl")["operation"], + "sync", + ) self.assertEqual( client.search( "sqlite", @@ -96,6 +99,20 @@ def test_init_sources_import_sync_search_and_inspect_methods(self) -> None: self.assertEqual(client.locate_session("session-1")["operation"], "locateSession") self.assertEqual(client.locateSession("session-1")["operation"], "locateSession") + def test_search_requires_query_term_or_file_before_cli(self) -> None: + with fake_ctx(fail=True) as cli: + client = AgentHistoryClient.local(ctx_binary=str(cli)) + + for call in ( + lambda: client.search(), + lambda: client.search(refresh="off", limit=5), + lambda: client.search(" "), + ): + with self.subTest(call=call): + with self.assertRaises(CtxAgentHistoryValidationError) as raised: + call() + self.assertEqual(raised.exception.code, "invalid_request") + def test_versioning_reports_sdk_api_transport_and_ctx_version(self) -> None: with fake_ctx() as cli: client = AgentHistoryClient.local(ctx_binary=str(cli)) diff --git a/sdks/swift/README.md b/sdks/swift/README.md index af40ae4fe..40ff51203 100644 --- a/sdks/swift/README.md +++ b/sdks/swift/README.md @@ -45,7 +45,7 @@ The public client mirrors the `agent-history-v1` operations: - `sources()` - `importHistory()` - `sync()` -- `search()` +- `search()` with a query, term, or file option - `showEvent()` - `showSession()` - `locateEvent()` diff --git a/sdks/swift/Sources/CtxAgentHistory/AgentHistoryClient.swift b/sdks/swift/Sources/CtxAgentHistory/AgentHistoryClient.swift index 7fe272024..1d76caf37 100644 --- a/sdks/swift/Sources/CtxAgentHistory/AgentHistoryClient.swift +++ b/sdks/swift/Sources/CtxAgentHistory/AgentHistoryClient.swift @@ -70,6 +70,7 @@ public struct AgentHistoryClient: Sendable { } public func search(_ query: String? = nil, options: SearchOptions = SearchOptions()) throws -> SearchResponse { + try requireSearchIntent(query: query, options: options) var arguments = ["search"] if let query { arguments.append(query) @@ -293,6 +294,23 @@ private func appendOption(_ arguments: inout [String], _ name: String, _ value: } } +private func requireSearchIntent(query: String?, options: SearchOptions) throws { + if hasSearchText(query) || hasSearchText(options.file) || options.terms.contains(where: { hasSearchText($0) }) { + return + } + throw CtxAgentHistorySDKError( + code: .invalidRequest, + message: "search requires a query, term, or file option" + ) +} + +private func hasSearchText(_ value: String?) -> Bool { + guard let value else { + return false + } + return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty +} + private func requireID(_ name: String, _ id: String) throws { if id.isEmpty { throw CtxAgentHistorySDKError(code: .invalidRequest, message: "\(name) is required") diff --git a/sdks/swift/Tests/CtxAgentHistoryTests/CtxAgentHistoryTests.swift b/sdks/swift/Tests/CtxAgentHistoryTests/CtxAgentHistoryTests.swift index 20f965219..7af773cab 100644 --- a/sdks/swift/Tests/CtxAgentHistoryTests/CtxAgentHistoryTests.swift +++ b/sdks/swift/Tests/CtxAgentHistoryTests/CtxAgentHistoryTests.swift @@ -188,6 +188,12 @@ final class CtxAgentHistoryTests: XCTestCase { XCTAssertThrowsError(try parse.showSession(ShowSessionOptions(provider: "codex"))) { error in XCTAssertEqual((error as? CtxAgentHistorySDKError)?.code, .invalidRequest) } + XCTAssertThrowsError(try parse.search(options: SearchOptions(refresh: "off"))) { error in + XCTAssertEqual((error as? CtxAgentHistorySDKError)?.code, .invalidRequest) + } + XCTAssertThrowsError(try parse.search(" ")) { error in + XCTAssertEqual((error as? CtxAgentHistorySDKError)?.code, .invalidRequest) + } } func testAllStructuredErrorCodesRoundTripThroughContractError() throws { diff --git a/sdks/typescript/README.md b/sdks/typescript/README.md index e6233f579..b23361219 100644 --- a/sdks/typescript/README.md +++ b/sdks/typescript/README.md @@ -21,7 +21,8 @@ const results = await client.search("sqlite storage", { refresh: "off" }); - `sources()` wraps `ctx sources --json`. - `import(options)` wraps `ctx import --json`. - `sync(options)` is an alias for `import(options)`. -- `search(query, options)` and `search(options)` wrap `ctx search --json`. +- `search(query, options)` and file/term-based `search(options)` wrap + `ctx search |--term |--file --json`. - `showEvent(id, { before, after, window })` wraps `ctx show event --format json`. - `showSession(id, { mode })` wraps `ctx show session --format json`. - `showSession({ provider, providerSession, mode })` looks up by provider-owned session ID. diff --git a/sdks/typescript/src/index.d.ts b/sdks/typescript/src/index.d.ts index 68e9a22b6..5c2b2f3de 100644 --- a/sdks/typescript/src/index.d.ts +++ b/sdks/typescript/src/index.d.ts @@ -91,6 +91,13 @@ export interface SearchOptions { includeCurrentSession?: boolean; } +export type SearchIntentOptions = SearchOptions & ( + | { query: string } + | { term: string | string[] } + | { terms: [string, ...string[]] } + | { file: string } +); + export interface ShowEventOptions { before?: number; after?: number; @@ -408,8 +415,8 @@ export declare class LocalAgentHistoryClient { sources(): Promise; import(options?: ImportOptions): Promise>; sync(options?: ImportOptions): Promise>; - search(query?: string, options?: SearchOptions): Promise; - search(options?: SearchOptions): Promise; + search(query: string, options?: Omit): Promise; + search(options: SearchIntentOptions): Promise; showEvent(id: string, options?: ShowEventOptions): Promise; showSession(id: string, options?: Omit): Promise; showSession(options: ShowSessionOptions): Promise; diff --git a/sdks/typescript/src/index.js b/sdks/typescript/src/index.js index fcdafd86c..8eeca89ea 100644 --- a/sdks/typescript/src/index.js +++ b/sdks/typescript/src/index.js @@ -163,6 +163,7 @@ export class LocalAgentHistoryClient { typeof queryOrOptions === "string" ? { ...maybeOptions, query: queryOrOptions } : { ...queryOrOptions }; + validateSearchIntent(options); const args = ["search"]; if (options.query) { args.push(options.query); @@ -431,6 +432,27 @@ function appendSearchArgs(args, options) { appendFlag(args, "--include-current-session", options.includeCurrentSession); } +function validateSearchIntent(options) { + if (hasSearchText(options.query) || hasSearchText(options.file) || hasSearchTerm(options)) { + return; + } + throw new CtxValidationError("search requires a query, term, or file option", { + details: { options }, + }); +} + +function hasSearchTerm(options) { + const value = options.terms ?? options.term; + if (Array.isArray(value)) { + return value.some(hasSearchText); + } + return hasSearchText(value); +} + +function hasSearchText(value) { + return typeof value === "string" && value.trim().length > 0; +} + function appendSessionLookupArgs(args, options) { if (options.id) { args.push(options.id); diff --git a/sdks/typescript/test/client.test.js b/sdks/typescript/test/client.test.js index b7df0ede4..badfbb96e 100644 --- a/sdks/typescript/test/client.test.js +++ b/sdks/typescript/test/client.test.js @@ -178,6 +178,18 @@ test("builds search flags and normalizes nested CLI search output", async () => ]); }); +test("rejects search without query, term, or file before invoking CLI", async () => { + const { client, calls } = mockClient(() => { + throw new Error("runner should not be called"); + }); + + await assert.rejects(() => client.search(), CtxValidationError); + await assert.rejects(() => client.search({ refresh: "off", limit: 5 }), CtxValidationError); + await assert.rejects(() => client.search(" "), CtxValidationError); + + assert.equal(calls.length, 0); +}); + test("wraps show and locate commands by ctx id and provider session id", async () => { const { client, calls } = mockClient(() => "{}"); diff --git a/sdks/typescript/test/types.test.ts b/sdks/typescript/test/types.test.ts index 20eaa1b3f..f7166b749 100644 --- a/sdks/typescript/test/types.test.ts +++ b/sdks/typescript/test/types.test.ts @@ -44,6 +44,15 @@ expectType(search.search.results[0]!.ctxEventId); // @ts-expect-error search results expose ctxEventId, not ctx_event_id. search.search.results[0]!.ctx_event_id; +const termSearch = await client.search({ terms: ["local agent history"], refresh: "off" }); +expectType(termSearch); +const fileSearch = await client.search({ file: "src/lib.rs", refresh: "off" }); +expectType(fileSearch); +// @ts-expect-error search requires a query, term, or file option. +await client.search(); +// @ts-expect-error search filters alone are not a search intent. +await client.search({ refresh: "off", limit: 5 }); + const shown = await client.showEvent("11111111-1111-4111-8111-111111111111"); expectType(shown); expectType(shown.event.events[0]!.ctxSessionId); diff --git a/skills/ctx-agent-history-search/SKILL.md b/skills/ctx-agent-history-search/SKILL.md index 1bec9819d..364ecd52f 100644 --- a/skills/ctx-agent-history-search/SKILL.md +++ b/skills/ctx-agent-history-search/SKILL.md @@ -32,16 +32,28 @@ Use this skill in two modes: ## Workflow -1. Search with normal language first. Add terms or filters when useful: +1. Confirm ctx is ready when starting from a cold context: + + ```bash + ctx status + ctx sources + ``` + + Use `ctx status --json` or `ctx sources --json` only when a script needs + exact fields. + +2. Search with normal language first. Add terms or filters when useful: ```bash ctx search "" + ctx search "" --refresh off ctx search "" --provider codex ctx search "" --workspace ctx search "" --file ctx search "" --since 30d ctx search "" --term "" --term "" ctx search "" --session + ctx search "" --verbose ``` Use default text output for agent reading. Do not add `--json` for @@ -51,9 +63,17 @@ Use this skill in two modes: When the prompt asks for a topic history or report across multiple sessions, run several `ctx search` queries with different wording and filters to find - promising sessions. Use scoped `ctx search ... --session ` - when a session looks relevant and you need dense event-level matches from - that session. + promising sessions. Use scoped + `ctx search "" --session ` when a session looks + relevant and you need dense event-level matches from that session. + + Default search returns primary-agent sessions so human intent and decisions + stay prominent. Use `--include-subagents` when implementation details, code + review notes, test output, or failure traces from subagent sessions are + likely to matter. + + Use `--verbose` when you need full ctx IDs, provider IDs, citations, and + copyable follow-up commands without switching to JSON. You can write a session transcript to a temporary file, check the file size, and then read the relevant parts: @@ -68,27 +88,51 @@ Use this skill in two modes: dominate historical retrieval. Use `--include-current-session` only when the active session tree is the target. -2. Inspect relevant results before relying on them: +3. Inspect relevant results before relying on them: ```bash ctx show event --window 5 ctx show session ``` -3. Locate original provider material when source identity or resume hints matter: +4. Locate original provider material when source identity or resume hints matter: ```bash ctx locate event ctx locate session ``` -4. Write a transcript of relevant sessions when you, the human, or another +5. Write a transcript of relevant sessions when you, the human, or another agent needs a file: ```bash ctx show session --format markdown --out ``` +## When Search Is Not Enough + +Use `ctx sql` only when normal search cannot express the question, such as +counts, joins, audits, or scripts over stable local views. Do not use SQL for +broad transcript text search; `ctx search` is built for that. + +Start with the bundled SQL docs: + +```bash +ctx docs show sql +ctx docs search "stable views" +``` + +Common SQL examples: + +```bash +ctx sql "SELECT provider, COUNT(*) AS sessions FROM ctx_sessions GROUP BY provider" +ctx sql "SELECT event_type, COUNT(*) AS events FROM ctx_events GROUP BY event_type ORDER BY events DESC" +ctx sql "SELECT path, provider, provider_session_id FROM ctx_files_touched WHERE path LIKE '%AGENTS.md%' LIMIT 20" +``` + +`ctx sql` is read-only and queries the existing index. It does not refresh, +import, initialize, or migrate ctx storage. + ## History Research Reports When asked to research a historical topic, stay read-only unless the user also @@ -100,10 +144,12 @@ material. chronology, alternatives, or detailed evidence. 2. Run several targeted searches. Vary query terms across user wording, file or module names, error text, commands, branch names, and decision terms. Start - with default `ctx search`, then broaden with `--term` or narrow with + with `ctx search ""`, then broaden with `--term` or narrow with `--workspace`, `--provider`, `--file`, `--since`, or `--session `. - Add `--refresh off` when the report must not update the local ctx index. + Use `--include-subagents` when reviews, implementation attempts, test output, + or failure traces are likely to live in delegated sessions. Add + `--refresh off` when the report must not update the local ctx index. 3. Inspect focused sources before drawing conclusions. Prefer `ctx show event` for a hit plus nearby turns, and `ctx show session` when the whole session arc matters: diff --git a/tests/fixtures/custom-history-jsonl/basic.jsonl b/tests/fixtures/custom-history-jsonl/basic.jsonl new file mode 100644 index 000000000..74615e278 --- /dev/null +++ b/tests/fixtures/custom-history-jsonl/basic.jsonl @@ -0,0 +1,8 @@ +{"record_type":"manifest","schema_version":"ctx-history-jsonl-v1","metadata":{"fixture":"basic"}} +{"record_type":"source","source_id":"demo-source","provider_key":"demo-agent","source_format":"demo-jsonl","raw_source_path":"/tmp/demo-history.jsonl","fingerprint":"sha256:demo","importer_version":"1.0.0","observed_at":"2026-06-23T12:00:10Z","machine_id":"fixture-host","cursor":{"after":{"stream":"demo-agent:demo-source","cursor":"5","observed_at":"2026-06-23T12:00:10Z"}},"metadata":{"purpose":"fixture"}} +{"record_type":"session","source_id":"demo-source","session_id":"demo-session","native_session_id":"native-demo-session","cwd":"/workspace/demo","started_at":"2026-06-23T12:00:00Z","ended_at":"2026-06-23T12:05:00Z","agent_type":"primary","role_hint":"developer","is_primary":true,"status":"completed","metadata":{"model":"fixture-model"}} +{"record_type":"event","source_id":"demo-source","session_id":"demo-session","event_index":0,"event_id":"evt-0","native_cursor":"line:3","event_type":"message","role":"user","occurred_at":"2026-06-23T12:00:01Z","payload":{"text":"Add a parser test."},"preview":"Add a parser test.","metadata":{"source":"fixture"}} +{"record_type":"event","source_id":"demo-source","session_id":"demo-session","event_index":1,"event_id":"evt-1","native_cursor":"line:4","event_type":"tool_call","role":"assistant","occurred_at":"2026-06-23T12:00:02Z","payload":{"tool":"exec","args":{"cmd":"cargo test -p ctx-history"}},"preview":"cargo test -p ctx-history","metadata":{"source":"fixture"}} +{"record_type":"file_touch","source_id":"demo-source","session_id":"demo-session","touch_index":0,"event_index":1,"path":"crates/history/src/import.rs","change_kind":"modified","line_count_delta":18,"confidence":"explicit","occurred_at":"2026-06-23T12:00:03Z","metadata":{"source":"fixture"}} +{"record_type":"session","source_id":"demo-source","session_id":"demo-session-worker","parent_session_id":"demo-session","root_session_id":"demo-session","cwd":"/workspace/demo","started_at":"2026-06-23T12:01:00Z","agent_type":"subagent","role_hint":"worker","is_primary":false,"status":"completed","metadata":{"model":"fixture-model"}} +{"record_type":"edge","source_id":"demo-source","from_session_id":"demo-session","to_session_id":"demo-session-worker","edge_type":"spawned","edge_id":"edge-0","confidence":"explicit","occurred_at":"2026-06-23T12:01:00Z","metadata":{"source":"fixture"}} diff --git a/tests/fixtures/custom-history-jsonl/malformed-partial.jsonl b/tests/fixtures/custom-history-jsonl/malformed-partial.jsonl new file mode 100644 index 000000000..b59593713 --- /dev/null +++ b/tests/fixtures/custom-history-jsonl/malformed-partial.jsonl @@ -0,0 +1,5 @@ +{"record_type":"manifest","schema_version":"ctx-history-jsonl-v1","metadata":{"fixture":"malformed-partial"}} +{"record_type":"source","source_id":"partial-source","provider_key":"partial-agent","source_format":"partial-jsonl","cursor":{"after":{"stream":"partial-agent:partial-source","cursor":"1","observed_at":"2026-06-23T13:00:00Z"}}} +{"record_type":"session","source_id":"partial-source","session_id":"partial-session","cwd":"/workspace/demo","started_at":"2026-06-23T13:00:00Z","agent_type":"primary","role_hint":"developer","is_primary":true,"status":"active"} +{"record_type":"event","source_id":"partial-source","session_id":"partial-session","event_index":0,"event_type":"message","role":"user","occurred_at":"2026-06-23T13:00:01Z","payload":{"text":"Valid event before malformed record."},"preview":"Valid event before malformed record."} +{"record_type":"event","source_id":"partial-source","session_id":"partial-session","event_index":"not-a-u64","event_type":"message","role":"assistant","occurred_at":"2026-06-23T13:00:02Z","payload":{"text":"This record is valid JSONL but semantically invalid."},"preview":"This record is valid JSONL but semantically invalid."}