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 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