diff --git a/Cargo.lock b/Cargo.lock index 7113f7869..7b912d607 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -679,7 +679,7 @@ version = "0.3.6" dependencies = [ "proc-macro2", "quote", - "syn 3.0.2", + "syn 3.0.3", ] [[package]] @@ -1367,13 +1367,13 @@ dependencies = [ [[package]] name = "foreign-types-macros" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -1481,9 +1481,9 @@ checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" [[package]] name = "glob" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] name = "half" @@ -1949,9 +1949,9 @@ checksum = "803ec87c9cfb29b9d2633f20cba1f488db3fd53f2158b1024cbefb47ba05d413" [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libloading" @@ -2507,7 +2507,7 @@ checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" dependencies = [ "proc-macro2", "quote", - "syn 3.0.2", + "syn 3.0.3", ] [[package]] @@ -2613,9 +2613,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.15.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "zeroize", ] @@ -2753,7 +2753,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.2", + "syn 3.0.3", ] [[package]] @@ -2960,9 +2960,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.2" +version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" dependencies = [ "proc-macro2", "quote", @@ -3016,7 +3016,7 @@ checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" dependencies = [ "proc-macro2", "quote", - "syn 3.0.2", + "syn 3.0.3", ] [[package]] @@ -3070,9 +3070,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.53.0" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "libc", "mio", @@ -3095,9 +3095,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", @@ -3684,9 +3684,9 @@ checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "xxhash-rust" -version = "0.8.17" +version = "0.8.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985eec839aaf2a1270af8f4ebcf63cf9401cfd90f0902f97c28d9f104ffbde72" +checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6" [[package]] name = "yeslogic-fontconfig-sys" @@ -3724,18 +3724,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.54" +version = "0.8.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.54" +version = "0.8.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 48e7d780f..590ef2de5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -79,7 +79,7 @@ serde_bytes = "0.11.19" serde_derive = "1.0.229" serde_json = { version = "1.0.151", features = ["float_roundtrip", "preserve_order"] } smallvec = "1.15.2" -tokio = { version = "1.53.0", features = ["rt-multi-thread"] } +tokio = { version = "1.53.1", features = ["rt-multi-thread"] } tower-http = { version = "0.7.0", features = ["catch-panic", "compression-br", "compression-gzip", "compression-zstd", "cors", "normalize-path", "timeout", "trace"] } tower-layer = "0.3" tracing = { version = "0.1", default-features = false, features = ["std"] } diff --git a/crates/brk_bindgen/README.md b/crates/brk_bindgen/README.md index 26bd6315a..f6bb8397e 100644 --- a/crates/brk_bindgen/README.md +++ b/crates/brk_bindgen/README.md @@ -4,15 +4,15 @@ Code generation for BRK client libraries. ## What It Enables -Generate typed client libraries for Rust, JavaScript, and Python from the OpenAPI specification. Keeps frontend code in sync with available metrics and API endpoints without manual maintenance. +Generate clients for Rust, JavaScript, Python, and LLMs from the OpenAPI specification and metric tree. Keeps every consumer in sync with available metrics and API endpoints without manual maintenance. ## Key Features -- **Multi-language**: Generates Rust, JavaScript, and Python clients +- **Multi-client**: Generates Rust, JavaScript, Python, and LLM clients - **OpenAPI-driven**: Extracts endpoints and schemas from the OpenAPI spec - **Metric catalog**: Includes all metric IDs and their supported indexes - **Type definitions**: Generates types/interfaces from JSON Schema -- **Selective output**: Generate only the languages you need +- **Selective output**: Generate only the clients you need ## Core API @@ -22,7 +22,9 @@ use brk_bindgen::{generate_clients, ClientOutputPaths}; let paths = ClientOutputPaths::new() .rust("crates/brk_client/src/lib.rs") .javascript("modules/brk-client/index.js") - .python("packages/brk_client/brk_client/__init__.py"); + .python("packages/brk_client/brk_client/__init__.py") + .llm("website") + .llm("website_next"); generate_clients(&vecs, &openapi_json, &paths)?; ``` @@ -34,12 +36,16 @@ generate_clients(&vecs, &openapi_json, &paths)?; | Rust | Typed API client using `brk_types`, metric catalog | | JavaScript | ES module with JSDoc types, metric catalog, fetch helpers | | Python | Typed client with dataclasses, metric catalog | +| LLM | Concise discovery and complete plain-text API references | -Each client includes: +Language clients include: - All REST API endpoints as typed functions - Complete metric catalog with index information - Type definitions for request/response schemas +The LLM client emits the standard discovery files and links to the live +OpenAPI and series endpoints instead of duplicating their catalogs. + ## Built On - `brk_query` for metric enumeration diff --git a/crates/brk_bindgen/src/generators/llm/mod.rs b/crates/brk_bindgen/src/generators/llm/mod.rs new file mode 100644 index 000000000..76b8e2b96 --- /dev/null +++ b/crates/brk_bindgen/src/generators/llm/mod.rs @@ -0,0 +1,486 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + fmt::Write, + fs::create_dir_all, + io, + path::{Path, PathBuf}, +}; + +use brk_types::TreeNode; +use oas3::Spec; +use serde_json::Value; + +use crate::{ClientMetadata, Endpoint, ResponseKind, TypeSchemas}; + +use super::write_if_changed; + +const BASE_URL: &str = "https://bitview.space"; + +pub fn generate_llm_clients( + metadata: &ClientMetadata, + spec: &Spec, + endpoints: &[Endpoint], + schemas: &TypeSchemas, + roots: &[PathBuf], +) -> io::Result<()> { + let metric_count = count_metrics(&metadata.catalog); + let generated = endpoints + .iter() + .filter(|endpoint| endpoint.should_generate()) + .collect::>(); + let llms = render_llms( + &spec.info.title, + &spec.info.version, + metric_count, + &generated, + ); + let llms_full = render_llms_full( + &spec.info.title, + &spec.info.version, + metric_count, + &generated, + schemas, + ); + + for root in roots { + create_dir_all(root)?; + write_output(&root.join("llms.txt"), &llms)?; + write_output(&root.join("llms-full.txt"), &llms_full)?; + } + Ok(()) +} + +fn write_output(path: &Path, content: &str) -> io::Result<()> { + if let Some(parent) = path.parent() { + create_dir_all(parent)?; + } + write_if_changed(path, content) +} + +fn count_metrics(node: &TreeNode) -> usize { + match node { + TreeNode::Leaf(_) => 1, + TreeNode::Branch(children) => children.values().map(count_metrics).sum(), + } +} + +fn render_llms(title: &str, version: &str, metric_count: usize, endpoints: &[&Endpoint]) -> String { + format!( + "# {title} (BRK)\n\n\ +> Free, open-source Bitcoin analytics API and block explorer. {metric_count} on-chain time-series and {} API operations. No authentication required.\n\n\ +## API\n\n\ +- Version: `{version}`\n\ +- Base URL: {BASE_URL}\n\ +- [Full plain-text reference]({BASE_URL}/llms-full.txt)\n\ +- [Compact OpenAPI]({BASE_URL}/api.json)\n\ +- [Full OpenAPI]({BASE_URL}/openapi.json)\n\ +- [Series catalog]({BASE_URL}/api/series)\n\ +- [Interactive documentation]({BASE_URL}/api)\n\n\ +Use OpenAPI for tool construction, `/api/series` for complete series metadata, and `llms-full.txt` for a readable reference.\n\n\ +## Clients\n\n\ +- [JavaScript](https://www.npmjs.com/package/brk-client)\n\ +- [Python](https://pypi.org/project/brk-client/)\n\ +- [Rust](https://crates.io/crates/brk_client)\n\n\ +## Source\n\n\ +- [GitHub](https://github.com/bitcoinresearchkit/brk)\n\ +- MIT licensed\n", + endpoints.len(), + ) +} + +fn render_llms_full( + title: &str, + version: &str, + metric_count: usize, + endpoints: &[&Endpoint], + schemas: &TypeSchemas, +) -> String { + let mut output = String::new(); + writeln!(output, "# {title} (BRK) — Full API Reference\n").unwrap(); + writeln!( + output, + "> Generated from BRK's OpenAPI specification and metric tree. Do not edit this file manually.\n" + ) + .unwrap(); + writeln!(output, "- Version: `{version}`").unwrap(); + writeln!(output, "- Base URL: {BASE_URL}").unwrap(); + writeln!(output, "- Metrics: {metric_count}").unwrap(); + writeln!(output, "- Operations: {}\n", endpoints.len()).unwrap(); + writeln!( + output, + "For machine-readable tool construction, use [{BASE_URL}/openapi.json]({BASE_URL}/openapi.json). For the complete source-derived series tree, use [{BASE_URL}/api/series]({BASE_URL}/api/series).\n" + ) + .unwrap(); + + let mut groups = BTreeMap::>::new(); + for endpoint in endpoints { + groups + .entry(endpoint_group(&endpoint.path)) + .or_default() + .push(endpoint); + } + for group in groups.values_mut() { + group.sort_unstable_by(|left, right| { + left.path + .cmp(&right.path) + .then_with(|| left.method.cmp(&right.method)) + }); + } + + writeln!(output, "## Operations\n").unwrap(); + for (group, operations) in groups { + writeln!(output, "### {group}\n").unwrap(); + for endpoint in operations { + render_endpoint(&mut output, endpoint); + } + } + + let referenced = referenced_schemas(endpoints, schemas); + if !referenced.is_empty() { + writeln!(output, "## Schemas\n").unwrap(); + for name in referenced { + let Some(schema) = schemas.get(&name) else { + continue; + }; + render_schema(&mut output, &name, schema); + } + } + output +} + +fn endpoint_group(path: &str) -> String { + let segment = path + .split('/') + .find(|part| !part.is_empty() && *part != "api" && *part != "v1") + .unwrap_or("server"); + title_case(segment) +} + +fn title_case(value: &str) -> String { + value + .split(['-', '_']) + .filter(|part| !part.is_empty()) + .map(|part| { + let mut chars = part.chars(); + chars + .next() + .map(|first| first.to_uppercase().collect::() + chars.as_str()) + .unwrap_or_default() + }) + .collect::>() + .join(" ") +} + +fn render_endpoint(output: &mut String, endpoint: &Endpoint) { + writeln!(output, "#### {} `{}`\n", endpoint.method, endpoint.path).unwrap(); + if let Some(summary) = endpoint + .summary + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + writeln!(output, "{}\n", one_line(summary)).unwrap(); + } + if let Some(description) = endpoint + .description + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + let description = one_line(description); + if endpoint.summary.as_deref().map(one_line).as_deref() != Some(description.as_str()) { + writeln!(output, "{description}\n").unwrap(); + } + } + + let parameters = endpoint + .path_params + .iter() + .map(|parameter| ("path", parameter)) + .chain( + endpoint + .query_params + .iter() + .map(|parameter| ("query", parameter)), + ) + .collect::>(); + if !parameters.is_empty() { + writeln!(output, "Parameters:").unwrap(); + for (location, parameter) in parameters { + let requirement = if parameter.required { + "required" + } else { + "optional" + }; + write!( + output, + "- `{}` ({location}, {}, {requirement})", + parameter.name, parameter.param_type + ) + .unwrap(); + if let Some(description) = parameter + .description + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + write!(output, ": {}", one_line(description)).unwrap(); + } + writeln!(output).unwrap(); + } + writeln!(output).unwrap(); + } + + if let Some(body) = &endpoint.request_body { + writeln!( + output, + "Request body: `{}` ({})\n", + body.body_type, + if body.required { + "required" + } else { + "optional" + } + ) + .unwrap(); + } + writeln!( + output, + "Returns: {}\n", + response_label(&endpoint.response_kind) + ) + .unwrap(); + writeln!(output, "```bash").unwrap(); + writeln!(output, "{}", curl_example(endpoint)).unwrap(); + writeln!(output, "```\n").unwrap(); +} + +fn response_label(response: &ResponseKind) -> String { + match response { + ResponseKind::Json(name) => format!("JSON `{name}`"), + ResponseKind::Text(Some(schema)) => format!("text `{}`", schema.name), + ResponseKind::Text(None) => "text".to_owned(), + ResponseKind::Binary => "binary data".to_owned(), + } +} + +fn curl_example(endpoint: &Endpoint) -> String { + let mut path = endpoint.path.clone(); + for parameter in &endpoint.path_params { + path = path.replace( + &format!("{{{}}}", parameter.name), + &format!("<{}>", parameter.name), + ); + } + if !endpoint.query_params.is_empty() { + path.push('?'); + path.push_str( + &endpoint + .query_params + .iter() + .map(|parameter| format!("{}=<{}>", parameter.name, parameter.name)) + .collect::>() + .join("&"), + ); + } + if let Some(body) = &endpoint.request_body { + format!( + "curl -s -X {} --data '<{}>' \"{BASE_URL}{path}\"", + endpoint.method, body.body_type + ) + } else { + format!("curl -s \"{BASE_URL}{path}\"") + } +} + +fn referenced_schemas(endpoints: &[&Endpoint], schemas: &TypeSchemas) -> BTreeSet { + let mut names = BTreeSet::new(); + for endpoint in endpoints { + if let Some(name) = endpoint.schema_name().filter(|name| *name != "*") { + names.insert(name.to_owned()); + } + if let Some(body) = &endpoint.request_body + && schemas.contains_key(&body.body_type) + { + names.insert(body.body_type.clone()); + } + } + + let mut pending = names.iter().cloned().collect::>(); + while let Some(name) = pending.pop() { + let Some(schema) = schemas.get(&name) else { + continue; + }; + let mut refs = BTreeSet::new(); + collect_refs(schema, &mut refs); + for referenced in refs { + if schemas.contains_key(&referenced) && names.insert(referenced.clone()) { + pending.push(referenced); + } + } + } + names +} + +fn collect_refs(value: &Value, refs: &mut BTreeSet) { + match value { + Value::Object(object) => { + if let Some(reference) = object.get("$ref").and_then(Value::as_str) + && let Some(name) = reference.rsplit('/').next() + { + refs.insert(name.to_owned()); + } + for child in object.values() { + collect_refs(child, refs); + } + } + Value::Array(values) => { + for child in values { + collect_refs(child, refs); + } + } + _ => {} + } +} + +fn render_schema(output: &mut String, name: &str, schema: &Value) { + writeln!(output, "### `{name}`\n").unwrap(); + let required = schema + .get("required") + .and_then(Value::as_array) + .map(|values| { + values + .iter() + .filter_map(Value::as_str) + .collect::>() + }) + .unwrap_or_default(); + if let Some(properties) = schema.get("properties").and_then(Value::as_object) { + for (property, shape) in properties { + write!( + output, + "- `{property}`: `{}`{}", + schema_type(shape), + if required.contains(property.as_str()) { + " (required)" + } else { + "" + } + ) + .unwrap(); + if let Some(description) = shape + .get("description") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + { + write!(output, " — {}", one_line(description)).unwrap(); + } + writeln!(output).unwrap(); + } + } else { + writeln!(output, "`{}`", schema_type(schema)).unwrap(); + } + writeln!(output).unwrap(); +} + +fn schema_type(schema: &Value) -> String { + if let Some(reference) = schema.get("$ref").and_then(Value::as_str) { + return reference.rsplit('/').next().unwrap_or(reference).to_owned(); + } + if let Some(values) = schema.get("enum").and_then(Value::as_array) { + return values + .iter() + .map(|value| { + value + .as_str() + .map_or_else(|| value.to_string(), str::to_owned) + }) + .collect::>() + .join(" | "); + } + if let Some(kind) = schema.get("type").and_then(Value::as_str) { + if kind == "array" { + return format!( + "{}[]", + schema + .get("items") + .map(schema_type) + .unwrap_or_else(|| "value".to_owned()) + ); + } + return kind.to_owned(); + } + for key in ["oneOf", "anyOf", "allOf"] { + if let Some(values) = schema.get(key).and_then(Value::as_array) { + return values + .iter() + .map(schema_type) + .collect::>() + .join(" | "); + } + } + "object".to_owned() +} + +fn one_line(value: &str) -> String { + value.split_whitespace().collect::>().join(" ") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Parameter; + + fn endpoint(path: &str, method: &str) -> Endpoint { + Endpoint { + method: method.to_owned(), + path: path.to_owned(), + operation_id: None, + summary: Some("Read a thing".to_owned()), + description: None, + path_params: vec![Parameter { + name: "id".to_owned(), + required: true, + param_type: "string".to_owned(), + description: Some("Thing identifier".to_owned()), + }], + query_params: Vec::new(), + request_body: None, + response_kind: ResponseKind::Json("Thing".to_owned()), + deprecated: false, + supports_csv: false, + } + } + + #[test] + fn full_reference_lists_every_operation_once() { + let first = endpoint("/api/thing/{id}", "GET"); + let second = endpoint("/api/thing/{id}/status", "GET"); + let endpoints = [&first, &second]; + let output = render_llms_full("BRK", "v1", 12, &endpoints, &BTreeMap::new()); + + assert_eq!(output.matches("#### GET `/api/thing/{id}`\n").count(), 1); + assert_eq!( + output + .matches("#### GET `/api/thing/{id}/status`\n") + .count(), + 1 + ); + assert!(output.contains("curl -s \"https://bitview.space/api/thing/\"")); + } + + #[test] + fn schema_renderer_keeps_required_fields_and_references() { + let schema = serde_json::json!({ + "type": "object", + "required": ["tx"], + "properties": { + "tx": { "$ref": "#/components/schemas/Transaction" }, + "height": { "type": "integer" } + } + }); + let mut output = String::new(); + + render_schema(&mut output, "Result", &schema); + + assert!(output.contains("`tx`: `Transaction` (required)")); + assert!(output.contains("`height`: `integer`")); + } +} diff --git a/crates/brk_bindgen/src/generators/mod.rs b/crates/brk_bindgen/src/generators/mod.rs index 608c25ed1..397be141a 100644 --- a/crates/brk_bindgen/src/generators/mod.rs +++ b/crates/brk_bindgen/src/generators/mod.rs @@ -1,6 +1,6 @@ //! Code generators for client libraries. //! -//! Each language has its own submodule with focused files: +//! Each client has its own submodule. Language clients use focused files: //! - `types.rs` - Type definitions //! - `client.rs` - Base client and pattern factories //! - `tree.rs` - Tree structure generation @@ -10,10 +10,12 @@ use std::{fmt::Write, fs, io, path::Path}; pub mod javascript; +pub mod llm; pub mod python; pub mod rust; pub use javascript::generate_javascript_client; +pub use llm::generate_llm_clients; pub use python::generate_python_client; pub use rust::generate_rust_client; diff --git a/crates/brk_bindgen/src/lib.rs b/crates/brk_bindgen/src/lib.rs index ca3acf729..b8bdddc58 100644 --- a/crates/brk_bindgen/src/lib.rs +++ b/crates/brk_bindgen/src/lib.rs @@ -4,17 +4,20 @@ use std::{collections::btree_map::Entry, fs::create_dir_all, io, path::PathBuf}; use brk_query::Vecs; -/// Output path configuration for each language client. +/// Output path configuration for each client. /// -/// Each path should be the full path to the output file, not just a directory. -/// Parent directories will be created automatically if they don't exist. +/// Rust, JavaScript, and Python take a full output file path. LLM clients take +/// a root directory and generate their complete bundle inside it. Parent +/// directories will be created automatically if they don't exist. /// /// # Example /// ```ignore /// let paths = ClientOutputPaths::new() /// .rust("crates/brk_client/src/lib.rs") /// .javascript("modules/brk-client/index.js") -/// .python("packages/brk_client/__init__.py"); +/// .python("packages/brk_client/__init__.py") +/// .llm("website") +/// .llm("website_next"); /// ``` #[derive(Debug, Clone, Default)] pub struct ClientOutputPaths { @@ -24,6 +27,8 @@ pub struct ClientOutputPaths { pub javascript: Option, /// Full path to Python client file (e.g., "packages/brk_client/__init__.py") pub python: Option, + /// Root directories for generated LLM client bundles. + pub llm: Vec, } impl ClientOutputPaths { @@ -45,6 +50,11 @@ impl ClientOutputPaths { self.python = Some(path.into()); self } + + pub fn llm(mut self, root: impl Into) -> Self { + self.llm.push(root.into()); + self + } } mod analysis; @@ -67,15 +77,17 @@ pub const VERSION: &str = env!("CARGO_PKG_VERSION"); /// Generate all client libraries from the query vecs and OpenAPI JSON. /// -/// Uses `ClientOutputPaths` to specify the output file path for each language. -/// Only languages with a configured path will be generated. +/// Uses `ClientOutputPaths` to specify the output location for each client. +/// Only clients with a configured location will be generated. /// /// # Example /// ```ignore /// let paths = ClientOutputPaths::new() /// .rust("crates/brk_client/src/lib.rs") /// .javascript("modules/brk-client/index.js") -/// .python("packages/brk_client/__init__.py"); +/// .python("packages/brk_client/__init__.py") +/// .llm("website") +/// .llm("website_next"); /// /// generate_clients(&vecs, &openapi_json, &paths)?; /// ``` @@ -125,6 +137,8 @@ pub fn generate_clients( generate_python_client(&metadata, &endpoints, &schemas, python_path)?; } + generate_llm_clients(&metadata, &spec, &endpoints, &schemas, &output_paths.llm)?; + Ok(()) } diff --git a/crates/brk_client/src/lib.rs b/crates/brk_client/src/lib.rs index 2927e3b2d..e28c00ce5 100644 --- a/crates/brk_client/src/lib.rs +++ b/crates/brk_client/src/lib.rs @@ -10359,7 +10359,7 @@ impl BrkClient { /// Address information /// - /// Retrieve address information including balance and transaction counts. Supports all standard Bitcoin address types (P2PKH, P2SH, P2WPKH, P2WSH, P2TR). + /// Retrieve address information including current balance and transaction counts. Supports all standard Bitcoin address types (P2PKH, P2SH, P2WPKH, P2WSH, P2TR). /// /// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address)* /// diff --git a/crates/brk_query/src/impl/addr/stats.rs b/crates/brk_query/src/impl/addr/stats.rs index 45c7eec67..656998ec8 100644 --- a/crates/brk_query/src/impl/addr/stats.rs +++ b/crates/brk_query/src/impl/addr/stats.rs @@ -57,6 +57,14 @@ impl Query { } }; + let mempool_stats = self + .mempool() + .and_then(|m| m.addr_stats(&bytes)) + .unwrap_or_default(); + let balance = addr_data.received + mempool_stats.funded_txo_sum + - addr_data.sent + - mempool_stats.spent_txo_sum; + Ok(AddrStats { addr, addr_type: output_type, @@ -69,10 +77,8 @@ impl Query { tx_count: addr_data.tx_count, realized_price, }, - mempool_stats: self - .mempool() - .and_then(|m| m.addr_stats(&bytes)) - .unwrap_or_default(), + mempool_stats, + balance, }) } } diff --git a/crates/brk_server/examples/bindgen.rs b/crates/brk_server/examples/bindgen.rs index 59ae46a58..e0f1d66ee 100644 --- a/crates/brk_server/examples/bindgen.rs +++ b/crates/brk_server/examples/bindgen.rs @@ -27,7 +27,9 @@ pub fn main() -> color_eyre::Result<()> { let output_paths = brk_bindgen::ClientOutputPaths::new() .rust(workspace_root.join("crates/brk_client/src/lib.rs")) .javascript(workspace_root.join("website/scripts/modules/brk-client/index.js")) - .python(workspace_root.join("packages/brk_client/brk_client/__init__.py")); + .python(workspace_root.join("packages/brk_client/brk_client/__init__.py")) + .llm(workspace_root.join("website")) + .llm(workspace_root.join("website_next")); generate_bindings(&vecs, &openapi, &output_paths)?; diff --git a/crates/brk_server/src/api/addrs.rs b/crates/brk_server/src/api/addrs.rs index 393fe6730..01f830298 100644 --- a/crates/brk_server/src/api/addrs.rs +++ b/crates/brk_server/src/api/addrs.rs @@ -63,7 +63,7 @@ impl AddrRoutes for ApiRouter { .id("get_address") .addrs_tag() .summary("Address information") - .description("Retrieve address information including balance and transaction counts. Supports all standard Bitcoin address types (P2PKH, P2SH, P2WPKH, P2WSH, P2TR).\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-address)*") + .description("Retrieve address information including current balance and transaction counts. Supports all standard Bitcoin address types (P2PKH, P2SH, P2WPKH, P2WSH, P2TR).\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-address)*") .json_response::() .not_modified() .bad_request() diff --git a/crates/brk_server/src/lib.rs b/crates/brk_server/src/lib.rs index 09777101d..831d6da85 100644 --- a/crates/brk_server/src/lib.rs +++ b/crates/brk_server/src/lib.rs @@ -286,7 +286,9 @@ impl Server { let output_paths = brk_bindgen::ClientOutputPaths::new() .rust(workspace_root.join("crates/brk_client/src/lib.rs")) .javascript(workspace_root.join("modules/brk-client/index.js")) - .python(workspace_root.join("packages/brk_client/brk_client/__init__.py")); + .python(workspace_root.join("packages/brk_client/brk_client/__init__.py")) + .llm(workspace_root.join("website")) + .llm(workspace_root.join("website_next")); let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { generate_bindings(vecs, &openapi, &output_paths) diff --git a/crates/brk_types/src/addr_stats.rs b/crates/brk_types/src/addr_stats.rs index 93c4e761e..e4a44a4a1 100644 --- a/crates/brk_types/src/addr_stats.rs +++ b/crates/brk_types/src/addr_stats.rs @@ -1,8 +1,8 @@ -use crate::{Addr, AddrChainStats, AddrMempoolStats, OutputType}; +use crate::{Addr, AddrChainStats, AddrMempoolStats, OutputType, Sats}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -/// Address information compatible with mempool.space API format +/// Address information compatible with mempool.space API format. #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct AddrStats { /// Bitcoin address string @@ -20,4 +20,7 @@ pub struct AddrStats { /// Statistics for unconfirmed transactions in the mempool pub mempool_stats: AddrMempoolStats, + + /// Current balance in satoshis, including unconfirmed mempool changes + pub balance: Sats, } diff --git a/modules/brk-client/index.js b/modules/brk-client/index.js index 7cfcaf293..904aaabb6 100644 --- a/modules/brk-client/index.js +++ b/modules/brk-client/index.js @@ -60,13 +60,14 @@ * @property {Addr} address */ /** - * Address information compatible with mempool.space API format + * Address information compatible with mempool.space API format. * * @typedef {Object} AddrStats * @property {Addr} address - Bitcoin address string * @property {OutputType} addrType - Address type (p2pkh, p2sh, v0_p2wpkh, v0_p2wsh, v1_p2tr, etc.) * @property {AddrChainStats} chainStats - Statistics for confirmed transactions on the blockchain * @property {AddrMempoolStats} mempoolStats - Statistics for unconfirmed transactions in the mempool + * @property {Sats} balance - Current balance in satoshis, including unconfirmed mempool changes */ /** * Address validation result @@ -12012,7 +12013,7 @@ class BrkClient extends BrkClientBase { /** * Address information * - * Retrieve address information including balance and transaction counts. Supports all standard Bitcoin address types (P2PKH, P2SH, P2WPKH, P2WSH, P2TR). + * Retrieve address information including current balance and transaction counts. Supports all standard Bitcoin address types (P2PKH, P2SH, P2WPKH, P2WSH, P2TR). * * *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address)* * diff --git a/packages/brk_client/brk_client/__init__.py b/packages/brk_client/brk_client/__init__.py index 20a605e5d..d883ea397 100644 --- a/packages/brk_client/brk_client/__init__.py +++ b/packages/brk_client/brk_client/__init__.py @@ -336,18 +336,20 @@ class AddrParam(TypedDict): class AddrStats(TypedDict): """ - Address information compatible with mempool.space API format + Address information compatible with mempool.space API format. Attributes: address: Bitcoin address string addr_type: Address type (p2pkh, p2sh, v0_p2wpkh, v0_p2wsh, v1_p2tr, etc.) chain_stats: Statistics for confirmed transactions on the blockchain mempool_stats: Statistics for unconfirmed transactions in the mempool + balance: Current balance in satoshis, including unconfirmed mempool changes """ address: Addr addr_type: OutputType chain_stats: AddrChainStats mempool_stats: AddrMempoolStats + balance: Sats class AddrValidation(TypedDict): """ @@ -8768,7 +8770,7 @@ class BrkClient(BrkClientBase): def get_address(self, address: Addr) -> AddrStats: """Address information. - Retrieve address information including balance and transaction counts. Supports all standard Bitcoin address types (P2PKH, P2SH, P2WPKH, P2WSH, P2TR). + Retrieve address information including current balance and transaction counts. Supports all standard Bitcoin address types (P2PKH, P2SH, P2WPKH, P2WSH, P2TR). *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address)* diff --git a/scripts/build-ask-source.mjs b/scripts/build-ask-source.mjs new file mode 100644 index 000000000..ea52ca9e2 --- /dev/null +++ b/scripts/build-ask-source.mjs @@ -0,0 +1,162 @@ +import { mkdir, rename, unlink, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { gzipSync } from "node:zlib"; + +const repository = "bitcoinresearchkit/brk"; +const revision = process.argv[2] ?? "main"; +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const output = resolve( + process.argv[3] ?? `${root}/website_next/ask/tools/source/catalog.jsonl.gz`, +); +const temporaryOutput = `${output}.${process.pid}.tmp`; +const maxFileSize = 128_000; +const concurrency = 12; +const extensions = new Set([ + "css", + "html", + "js", + "json", + "md", + "mjs", + "py", + "rs", + "sh", + "toml", + "ts", + "yaml", + "yml", +]); +const excludedPrefixes = [ + ".git/", + ".github/", + "docs/ai/", + "modules/", + "packages/brk_client/brk_client/", + "target/", + "website/assets/", + "website_next/modules/", +]; +const excludedFiles = new Set([ + "crates/brk_server/src/api/scalar.js", + "docs/CHANGELOG.md", + "website/scripts/options/scalar.js", +]); +const headers = { + Accept: "application/vnd.github+json", + "User-Agent": "brk-ask-source-builder", +}; + +async function fetchResponse(url, options = {}) { + let lastError; + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + const response = await fetch(url, options); + if (response.ok) return response; + lastError = new Error(`${response.status} ${response.statusText}: ${url}`); + if (response.status < 500 && response.status !== 429) break; + } catch (error) { + lastError = error; + } + await new Promise((resolveDelay) => + setTimeout(resolveDelay, 250 * 2 ** attempt), + ); + } + throw lastError; +} + +async function fetchJson(url) { + return fetchResponse(url, { headers }).then((response) => response.json()); +} + +function isUsefulSource(entry) { + if (entry.type !== "blob" || !entry.size || entry.size > maxFileSize) { + return false; + } + const extension = entry.path.slice(entry.path.lastIndexOf(".") + 1).toLowerCase(); + return ( + extensions.has(extension) && + !excludedFiles.has(entry.path) && + !excludedPrefixes.some((prefix) => entry.path.startsWith(prefix)) + ); +} + +function rawUrl(commit, filePath) { + const encodedPath = filePath.split("/").map(encodeURIComponent).join("/"); + return `https://raw.githubusercontent.com/${repository}/${commit}/${encodedPath}`; +} + +async function mapConcurrent(items, mapper) { + const results = new Array(items.length); + let nextIndex = 0; + async function worker() { + while (nextIndex < items.length) { + const index = nextIndex; + nextIndex += 1; + results[index] = await mapper(items[index], index); + } + } + await Promise.all( + Array.from({ length: Math.min(concurrency, items.length) }, worker), + ); + return results; +} + +async function main() { + const commitInfo = await fetchJson( + `https://api.github.com/repos/${repository}/commits/${encodeURIComponent(revision)}`, + ); + const commit = commitInfo.sha; + if (!commit) throw new Error(`Could not resolve revision: ${revision}`); + + const treeInfo = await fetchJson( + `https://api.github.com/repos/${repository}/git/trees/${commit}?recursive=1`, + ); + if (treeInfo.truncated) { + throw new Error("GitHub returned a truncated repository tree"); + } + + const entries = treeInfo.tree.filter(isUsefulSource); + entries.sort((left, right) => left.path.localeCompare(right.path)); + const files = await mapConcurrent(entries, async (entry, index) => { + const response = await fetchResponse(rawUrl(commit, entry.path)); + const source = await response.text(); + if ((index + 1) % 250 === 0 || index + 1 === entries.length) { + console.error(`Fetched ${index + 1}/${entries.length}`); + } + return JSON.stringify([entry.path, source]); + }); + + const header = JSON.stringify({ + repository, + revision: commit, + count: files.length, + }); + const raw = Buffer.from([header, ...files].join("\n")); + const compressed = gzipSync(raw, { level: 9 }); + + await mkdir(dirname(output), { recursive: true }); + try { + await writeFile(temporaryOutput, compressed); + await rename(temporaryOutput, output); + } catch (error) { + await unlink(temporaryOutput).catch(() => {}); + throw error; + } + + console.log( + JSON.stringify( + { + revision: commit, + files: files.length, + rawBytes: raw.length, + compressedBytes: compressed.length, + output, + }, + null, + 2, + ), + ); +} + +await main(); diff --git a/website/llms-full.txt b/website/llms-full.txt index 373f95b62..85ed48b34 100644 --- a/website/llms-full.txt +++ b/website/llms-full.txt @@ -1,497 +1,2059 @@ # Bitcoin Research Kit (BRK) — Full API Reference -> Free, open-source Bitcoin on-chain analytics API. 49,000+ time-series, block explorer, address index, mempool, mining stats — all computed from a Bitcoin Core node. No auth required. +> Generated from BRK's OpenAPI specification and metric tree. Do not edit this file manually. -Base URL: https://bitview.space -GitHub: https://github.com/bitcoinresearchkit/brk -License: MIT +- Version: `v0.3.6` +- Base URL: https://bitview.space +- Metrics: 55667 +- Operations: 97 -## Quick Start +For machine-readable tool construction, use [https://bitview.space/openapi.json](https://bitview.space/openapi.json). For the complete source-derived series tree, use [https://bitview.space/api/series](https://bitview.space/api/series). - # Search for series by keyword - curl -s "https://bitview.space/api/series/search?q=price" +## Operations - # Get Bitcoin closing price for the last 30 days - curl -s "https://bitview.space/api/series/price/day?start=-30" +### Address - # Get just the latest price - curl -s "https://bitview.space/api/series/price/day/latest" +#### GET `/api/address/hash-prefix/{addr_type}/{prefix}` - # Bulk query multiple series - curl -s "https://bitview.space/api/series/bulk?index=day&series=price,market_cap&start=-7" +Address hash-prefix matches - # Get the genesis block - curl -s "https://bitview.space/api/block-height/0" +Find addresses by address type and by the first 1-16 hex nibbles of RapidHash v3 over the raw address payload bytes. Intended for privacy-preserving client-side wallet discovery without sending raw addresses or xpubs. Fetch metadata for the returned addresses through `/api/address/{address}`. - # Get an address balance - curl -s "https://bitview.space/api/address/1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa" +Parameters: +- `addr_type` (path, OutputType, required) +- `prefix` (path, string, required) - # Get current fee estimates - curl -s "https://bitview.space/api/v1/fees/recommended" +Returns: JSON `AddrHashPrefixMatches` - # Get the live mempool-derived price - curl -s "https://bitview.space/api/mempool/price" +```bash +curl -s "https://bitview.space/api/address/hash-prefix//" +``` ---- +#### GET `/api/address/{address}` -## Response Format +Address information -All endpoints return JSON by default. Series endpoints also support CSV via `?format=csv`. +Retrieve address information including current balance and transaction counts. Supports all standard Bitcoin address types (P2PKH, P2SH, P2WPKH, P2WSH, P2TR). *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address)* -Errors return: +Parameters: +- `address` (path, Addr, required) - { - "error": { - "type": "not_found", - "code": "series_not_found", - "message": "'foo' not found, did you mean 'bar'?", - "doc_url": "/api" - } - } +Returns: JSON `AddrStats` -Error types: `invalid_request` (400), `forbidden` (403), `not_found` (404), `unavailable` (503), `internal` (500). +```bash +curl -s "https://bitview.space/api/address/
" +``` -## Range Parameters +#### GET `/api/address/{address}/txs` -`start` and `end` are optional on all series data endpoints. They accept: -- Dates: `2025-01-01` -- Integers: block height or absolute index -- Negative integers: relative offset from latest (`-30` = last 30 entries) -- ISO 8601 timestamps +Address transactions -## Indexes +Get transaction history for an address, newest first. Returns up to 50 mempool transactions plus a confirmed page sized to fill the response to 50 total (chain floor of 25, so 25-50 confirmed depending on mempool weight). To paginate further confirmed history, use `/address/{address}/txs/chain/{last_seen_txid}`. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions)* -Series are queryable by time-based and block-based indexes. Common aliases are accepted (e.g. `day` for `day1`, `week` for `week1`). +Parameters: +- `address` (path, Addr, required) -Time-based: `minute10`, `minute30`, `hour1`, `hour4`, `hour12`, `day1`, `day3`, `week1`, `month1`, `month3`, `month6`, `year1`, `year10`, `halving`, `epoch` -Block-based: `height` +Returns: JSON `Transaction[]` -Common aliases: `day` = `day1`, `week` = `week1`, `month` = `month1`, `hour` = `hour1`, `h` = `height` +```bash +curl -s "https://bitview.space/api/address/
/txs" +``` -Not all series support all indexes. Use `GET /api/series/{name}` to see which indexes a series supports. +#### GET `/api/address/{address}/txs/chain` ---- +Address confirmed transactions -## Server +Get the first 25 confirmed transactions for an address. For pagination, use the path-style form `/txs/chain/{last_seen_txid}`. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-chain)* -### GET https://bitview.space/health +Parameters: +- `address` (path, Addr, required) -Health check. +Returns: JSON `Transaction[]` - → {"status": "healthy", "service": "brk", "version": "0.2.1", "indexed_height": 941908, "blocks_behind": 0, "uptime_seconds": 11806, ...} +```bash +curl -s "https://bitview.space/api/address/
/txs/chain" +``` -### GET https://bitview.space/version +#### GET `/api/address/{address}/txs/chain/{after_txid}` -API version string. +Address confirmed transactions (paginated) - → "0.2.1" +Get the next 25 confirmed transactions strictly older than `after_txid` (Esplora-canonical pagination form, matches mempool.space). *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-chain)* -### GET https://bitview.space/api/server/sync +Parameters: +- `address` (path, Addr, required) +- `after_txid` (path, Txid, required): Last txid from the previous page (return transactions strictly older than this) -Sync status. +Returns: JSON `Transaction[]` - → {"indexed_height": 941908, "computed_height": 941908, "tip_height": 941908, "blocks_behind": 0, "last_indexed_at": "2026-03-23T19:13:32Z"} +```bash +curl -s "https://bitview.space/api/address/
/txs/chain/" +``` -### GET https://bitview.space/api/server/disk +#### GET `/api/address/{address}/txs/mempool` -Disk usage for BRK data and Bitcoin Core data directories. +Address mempool transactions ---- +Get unconfirmed transactions for an address from the mempool, newest first (up to 50). *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-mempool)* -## Series +Parameters: +- `address` (path, Addr, required) -The core feature. 49,000+ on-chain time-series. +Returns: JSON `Transaction[]` -### GET https://bitview.space/api/series/search?q={query} +```bash +curl -s "https://bitview.space/api/address/
/txs/mempool" +``` -Fuzzy search series by name. Returns an array of matching series names. +#### GET `/api/address/{address}/utxo` - → ["price", "price_ath", "price_low", "price_sats", "price_high", "price_ohlc", ...] +Address UTXOs -### GET https://bitview.space/api/series/{series}/{index} +Get unspent transaction outputs (UTXOs) for an address. Returns txid, vout, value, and confirmation status for each UTXO. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-utxo)* -Fetch series data. Parameters: `format` (json|csv), `start`, `end`. +Parameters: +- `address` (path, Addr, required) -The `data` array contains raw values. Their position maps to the index range `start..end`. For date-based indexes, compute dates from the index type and position. +Returns: JSON `Utxo[]` - GET /api/series/price/day?start=-3 - → { - "version": 69, - "index": "day1", - "type": "Dollars", - "total": 6291, - "start": 6288, - "end": 6291, - "stamp": "2026-03-23T19:06:40Z", - "data": [70077.35, 68301.76, 70788.45] - } +```bash +curl -s "https://bitview.space/api/address/
/utxo" +``` -Some series return compound values per entry (e.g. OHLC): +### Api.json - GET /api/series/price_ohlc/day?start=-1 - → { ..., "type": "OHLCDollars", "data": [[68251.03, 71455.61, 67682.26, 70788.45]] } +#### GET `/api.json` -CSV format returns the series name as header, then one value per line: +Compact OpenAPI specification - GET /api/series/price/day?start=-3&format=csv - → price - 70077.35 - 68301.76 - 70788.45 +Compact OpenAPI specification optimized for LLM consumption. Removes redundant fields while preserving essential API information. Full spec available at `/openapi.json`. -### GET https://bitview.space/api/series/{series}/{index}/data +Returns: JSON `*` -Raw data array only (no metadata wrapper). Same parameters. +```bash +curl -s "https://bitview.space/api.json" +``` - GET /api/series/price/day/data?start=-3 - → [70077.35, 68301.76, 70788.45] +### Block -### GET https://bitview.space/api/series/{series}/{index}/latest +#### GET `/api/block/{hash}` -Most recent value only. +Block information - GET /api/series/price/day/latest - → 70788.45 +Retrieve block information by block hash. Returns block metadata including height, timestamp, difficulty, size, weight, and transaction count. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block)* -### GET https://bitview.space/api/series/{series} +Parameters: +- `hash` (path, BlockHash, required) -Series metadata: available indexes and value type. +Returns: JSON `BlockInfo` - GET /api/series/price - → { - "indexes": ["minute10", "minute30", "hour1", "hour4", "hour12", - "day1", "day3", "week1", "month1", "month3", "month6", - "year1", "year10", "halving", "epoch", "height"], - "type": "Dollars" - } +```bash +curl -s "https://bitview.space/api/block/" +``` -### GET https://bitview.space/api/series +#### GET `/api/block/{hash}/header` -Full hierarchical catalog of all series as a tree. +Block header -### GET https://bitview.space/api/series/list +Returns the hex-encoded 80-byte block header. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-header)* -Paginated list of all series. Supports `?page=N` (default 0, 1000 per page). +Parameters: +- `hash` (path, BlockHash, required) - → {"current_page": 0, "max_page": 49, "total_count": 49259, "per_page": 1000, "has_more": true, "series": ["fee", "nvt", ...]} +Returns: text `Hex` -### GET https://bitview.space/api/series/count +```bash +curl -s "https://bitview.space/api/block//header" +``` -Series count by category. Returns totals and per-database breakdowns. +#### GET `/api/block/{hash}/raw` -### GET https://bitview.space/api/series/indexes +Raw block -List all available indexes with their aliases. +Returns the raw block data in binary format. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-raw)* - → [{"index": "day1", "aliases": ["1d", "d", "day", "date", "daily", "day1", "dateindex"]}, ...] +Parameters: +- `hash` (path, BlockHash, required) -### GET https://bitview.space/api/series/{series}/{index}/len +Returns: binary data -Number of data points in the series. +```bash +curl -s "https://bitview.space/api/block//raw" +``` -### GET https://bitview.space/api/series/{series}/{index}/version +#### GET `/api/block/{hash}/status` -Version number (increments when data changes). +Block status -### GET https://bitview.space/api/series/bulk?index={index}&series={s1},{s2} +Retrieve the status of a block. Returns whether the block is in the best chain and, if so, its height and the hash of the next block. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-status)* -Fetch multiple series at once. Parameters: `series` (comma-separated, required), `index` (required), `format`, `start`, `end`. +Parameters: +- `hash` (path, BlockHash, required) - GET /api/series/bulk?index=day&series=price,market_cap&start=-1 - → [ - {"version": 69, "index": "day1", "type": "Dollars", "total": 6291, "start": 6290, "end": 6291, "stamp": "...", "data": [70788.45]}, - {"version": 83, "index": "day1", "type": "Dollars", "total": 6291, "start": 6290, "end": 6291, "stamp": "...", "data": [1416174345666.71]} - ] +Returns: JSON `BlockStatus` -CSV bulk format uses one column per series: +```bash +curl -s "https://bitview.space/api/block//status" +``` - GET /api/series/bulk?index=day&series=price,market_cap&start=-1&format=csv - → price,market_cap - 70788.45,1416174345666.71 +#### GET `/api/block/{hash}/txid/{index}` -### Cost Basis Distribution +Transaction ID at index -#### GET https://bitview.space/api/series/cost-basis -List available cohorts. +Retrieve a single transaction ID at a specific index within a block. Returns plain text txid. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-transaction-id)* -#### GET https://bitview.space/api/series/cost-basis/{cohort}/dates -Available snapshot dates for a cohort. +Parameters: +- `hash` (path, BlockHash, required): Bitcoin block hash +- `index` (path, BlockTxIndex, required): Transaction index within the block (0-based) -#### GET https://bitview.space/api/series/cost-basis/{cohort}/{date} -Distribution data. Parameters: `bucket` (raw|lin200|lin500|lin1000|log10|log50|log100), `value` (supply|realized|unrealized). +Returns: text `Txid` ---- +```bash +curl -s "https://bitview.space/api/block//txid/" +``` -## Blocks +#### GET `/api/block/{hash}/txids` -Mempool.space compatible. +Block transaction IDs -### GET https://bitview.space/api/block-height/{height} +Retrieve all transaction IDs in a block. Returns an array of txids in block order. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-transaction-ids)* -Single block by height. +Parameters: +- `hash` (path, BlockHash, required) - GET /api/block-height/0 - → { - "id": "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f", - "height": 0, - "tx_count": 1, - "size": 285, - "weight": 1140, - "timestamp": 1231006505, - "difficulty": 1.0 - } +Returns: JSON `Txid[]` -### GET https://bitview.space/api/block/{hash} +```bash +curl -s "https://bitview.space/api/block//txids" +``` -Single block by hash. Same response shape. +#### GET `/api/block/{hash}/txs` -### GET https://bitview.space/api/blocks +Block transactions -Last 10 blocks. Returns array of block objects. +Retrieve transactions in a block by block hash. Returns up to 25 transactions starting from index 0. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-transactions)* -### GET https://bitview.space/api/blocks/{height} +Parameters: +- `hash` (path, BlockHash, required) -Up to 10 blocks ending at `{height}` (descending). +Returns: JSON `Transaction[]` -### GET https://bitview.space/api/block/{hash}/status +```bash +curl -s "https://bitview.space/api/block//txs" +``` - → {"in_best_chain": true, "height": 916656, "next_best": "0000..."} +#### GET `/api/block/{hash}/txs/{start_index}` -### GET https://bitview.space/api/block/{hash}/txids +Block transactions (paginated) -Array of all transaction IDs in the block. +Retrieve transactions in a block by block hash, starting from the specified index. Returns up to 25 transactions at a time. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-transactions)* -### GET https://bitview.space/api/block/{hash}/txs/{start_index} +Parameters: +- `hash` (path, BlockHash, required): Bitcoin block hash +- `start_index` (path, BlockTxIndex, required): Starting transaction index within the block (0-based) -Paginated transactions in the block. +Returns: JSON `Transaction[]` -### GET https://bitview.space/api/block/{hash}/txid/{index} +```bash +curl -s "https://bitview.space/api/block//txs/" +``` -Single txid at position `index`. +#### GET `/api/v1/block/{hash}` -### GET https://bitview.space/api/block/{hash}/raw +Block (v1) -Raw block bytes. +Returns block details with extras by hash. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-v1)* -### GET https://bitview.space/api/v1/mining/blocks/timestamp/{timestamp} +Parameters: +- `hash` (path, BlockHash, required) -Block closest to a UNIX timestamp. +Returns: JSON `BlockInfoV1` ---- +```bash +curl -s "https://bitview.space/api/v1/block/" +``` -## Transactions +### Block Height -Mempool.space compatible. +#### GET `/api/block-height/{height}` -### GET https://bitview.space/api/tx/{txid} +Block hash by height -Full transaction data. Values in satoshis (1 BTC = 100,000,000 sats). +Retrieve the block hash at a given height. Returns the hash as plain text. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-height)* - → { - "index": 0, - "txid": "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b", - "version": 1, - "locktime": 0, - "size": 204, - "weight": 816, - "sigops": 4, - "fee": 0, - "vin": [{ - "txid": "0000000000000000000000000000000000000000000000000000000000000000", - "vout": 65535, - "prevout": null, - "scriptsig": "04ffff001d...", - "scriptsig_asm": "OP_PUSHBYTES_4 ...", - "is_coinbase": true, - "sequence": 4294967295 - }], - "vout": [{ - "scriptpubkey": "4104678a...", - "scriptpubkey_asm": "OP_PUSHBYTES_65 ... OP_CHECKSIG", - "scriptpubkey_type": "p2pk65", - "scriptpubkey_address": "04678afdb0...", - "value": 5000000000 - }], - "status": { - "confirmed": true, - "block_height": 0, - "block_hash": "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f", - "block_time": 1231006505 - } - } +Parameters: +- `height` (path, Height, required) -### GET https://bitview.space/api/tx/{txid}/status +Returns: text `BlockHash` - → {"confirmed": true, "block_height": 0, "block_hash": "0000...", "block_time": 1231006505} +```bash +curl -s "https://bitview.space/api/block-height/" +``` -### GET https://bitview.space/api/tx/{txid}/hex +### Blocks -Raw transaction hex string. +#### GET `/api/blocks` -### GET https://bitview.space/api/tx/{txid}/outspend/{vout} +Recent blocks - → {"spent": true, "txid": "...", "vin": 0, "status": {...}} +Retrieve the last 10 blocks. Returns block metadata for each block. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks)* -### GET https://bitview.space/api/tx/{txid}/outspends +Returns: JSON `BlockInfo[]` -Array of spend status for all outputs. +```bash +curl -s "https://bitview.space/api/blocks" +``` ---- +#### GET `/api/blocks/tip/hash` -## Addresses +Block tip hash -Mempool.space compatible. Supports P2PKH, P2SH, P2WPKH, P2WSH, P2TR. +Returns the hash of the last block. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-tip-hash)* -### GET https://bitview.space/api/address/{address} +Returns: text `BlockHash` -Address summary. Values in satoshis. +```bash +curl -s "https://bitview.space/api/blocks/tip/hash" +``` - GET /api/address/1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa - → { - "address": "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", - "chain_stats": { - "funded_txo_count": 73262, - "funded_txo_sum": 5715642026, - "spent_txo_count": 0, - "spent_txo_sum": 0, - "tx_count": 62198, - "type_index": 371955 - }, - "mempool_stats": { - "funded_txo_count": 0, - "funded_txo_sum": 0, - "spent_txo_count": 0, - "spent_txo_sum": 0, - "tx_count": 0 - } - } +#### GET `/api/blocks/tip/height` -### GET https://bitview.space/api/address/{address}/txs +Block tip height -Transaction history (up to 75 per page). Paginate with `?after_txid={txid}`. +Returns the height of the last block. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-tip-height)* -### GET https://bitview.space/api/address/{address}/txs/chain +Returns: text `Height` -Confirmed transactions only (25 per page). Paginate with `?after_txid={txid}`. +```bash +curl -s "https://bitview.space/api/blocks/tip/height" +``` -### GET https://bitview.space/api/address/{address}/txs/mempool +#### GET `/api/blocks/{height}` -Unconfirmed transactions (up to 50). +Blocks from height -### GET https://bitview.space/api/address/{address}/utxo +Retrieve up to 10 blocks going backwards from the given height. For example, height=100 returns blocks 100, 99, 98, ..., 91. Height=0 returns only block 0. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks)* -Unspent outputs. +Parameters: +- `height` (path, Height, required) - → [{"txid": "...", "vout": 0, "status": {...}, "value": 5000000000}] +Returns: JSON `BlockInfo[]` -### GET https://bitview.space/api/v1/validate-address/{address} +```bash +curl -s "https://bitview.space/api/blocks/" +``` - → {"isvalid": true, "address": "...", "scriptPubKey": "...", "isscript": false, "iswitness": true, "witness_version": 0, "witness_program": "..."} +#### GET `/api/v1/blocks` ---- +Recent blocks with extras -## Mempool +Retrieve the last 15 blocks with extended data including pool identification and fee statistics. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks-v1)* -### GET https://bitview.space/api/mempool/price +Returns: JSON `BlockInfoV1[]` -Live BTC/USD price derived from on-chain round-dollar transaction patterns. +```bash +curl -s "https://bitview.space/api/v1/blocks" +``` - → 70817.64 +#### GET `/api/v1/blocks/{height}` -### GET https://bitview.space/api/mempool/info +Blocks from height with extras - → {"count": 24611, "vsize": 2185871, "total_fee": 6250465} +Retrieve up to 15 blocks with extended data going backwards from the given height. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks-v1)* -### GET https://bitview.space/api/mempool/txids +Parameters: +- `height` (path, Height, required) -Array of all transaction IDs in the mempool. +Returns: JSON `BlockInfoV1[]` -### GET https://bitview.space/api/v1/fees/recommended +```bash +curl -s "https://bitview.space/api/v1/blocks/" +``` -Fee rate recommendations in sat/vB. +### Cpfp - → {"fastestFee": 3.0, "halfHourFee": 0.135, "hourFee": 0.111, "economyFee": 0.106, "minimumFee": 0.1} +#### GET `/api/v1/cpfp/{txid}` -### GET https://bitview.space/api/v1/fees/mempool-blocks +CPFP info -Projected mempool blocks. +Returns ancestors and descendants for a CPFP (Child Pays For Parent) transaction, including the effective fee rate of the package. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-children-pay-for-parent)* - → [{"blockSize": 3999892, "blockVSize": 999973.0, "nTx": 3743, "totalFees": 4148496, "medianFee": 3.0, "feeRange": [2.0, 2.0, 2.173, 3.0, 3.965, 5.969, 347.223]}] +Parameters: +- `txid` (path, Txid, required) ---- +Returns: JSON `CpfpInfo` -## Mining +```bash +curl -s "https://bitview.space/api/v1/cpfp/" +``` -Mempool.space compatible. Time periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. +### Difficulty Adjustment -### GET https://bitview.space/api/v1/mining/pools +#### GET `/api/v1/difficulty-adjustment` -All known mining pools. +Difficulty adjustment -### GET https://bitview.space/api/v1/mining/pools/{time_period} +Get current difficulty adjustment progress and estimates. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-difficulty-adjustment)* -Pool statistics for a time period. +Returns: JSON `DifficultyAdjustment` -### GET https://bitview.space/api/v1/mining/pool/{slug} +```bash +curl -s "https://bitview.space/api/v1/difficulty-adjustment" +``` -Detailed info for a specific pool. +### Fees -### GET https://bitview.space/api/v1/difficulty-adjustment +#### GET `/api/v1/fees/mempool-blocks` -Current difficulty epoch: progress, estimated adjustment, remaining blocks/time. +Projected mempool blocks -### GET https://bitview.space/api/v1/mining/difficulty-adjustments +Projected blocks for fee estimation. Block 0 reflects Bitcoin Core's actual next-block selection; blocks 1+ are a fee-tier approximation. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mempool-blocks-fees)* -All historical difficulty adjustments. +Returns: JSON `MempoolBlock[]` -### GET https://bitview.space/api/v1/mining/difficulty-adjustments/{time_period} +```bash +curl -s "https://bitview.space/api/v1/fees/mempool-blocks" +``` -Difficulty adjustments for a given time period. +#### GET `/api/v1/fees/precise` -### GET https://bitview.space/api/v1/mining/hashrate +Precise recommended fees -All-time hashrate and difficulty data. +Recommended fee rates with sub-integer precision. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-recommended-fees-precise)* -### GET https://bitview.space/api/v1/mining/hashrate/{time_period} +Returns: JSON `RecommendedFees` -Hashrate and difficulty for a given time period. +```bash +curl -s "https://bitview.space/api/v1/fees/precise" +``` -### GET https://bitview.space/api/v1/mining/blocks/fees/{time_period} +#### GET `/api/v1/fees/recommended` -Average block fees over time. +Recommended fees -### GET https://bitview.space/api/v1/mining/blocks/rewards/{time_period} +Recommended fee rates by confirmation target. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-recommended-fees)* -Average block rewards (subsidy + fees) over time. +Returns: JSON `RecommendedFees` -### GET https://bitview.space/api/v1/mining/blocks/sizes-weights/{time_period} +```bash +curl -s "https://bitview.space/api/v1/fees/recommended" +``` -Average block sizes and weights over time. +### Fullrbf -### GET https://bitview.space/api/v1/mining/reward-stats/{block_count} +#### GET `/api/v1/fullrbf/replacements` -Reward statistics for the last N blocks. +Recent full-RBF replacements ---- +Like `/api/v1/replacements`, but limited to trees where at least one predecessor was non-signaling (full-RBF). *[Mempool.space docs](https://mempool.space/docs/api/rest#get-fullrbf-replacements)* -## Series Categories +Returns: JSON `ReplacementNode[]` -49,000+ series across these categories. Use `/api/series/search?q={keyword}` to discover, or `/api/series` for the full tree. +```bash +curl -s "https://bitview.space/api/v1/fullrbf/replacements" +``` -- **Market**: price, market_cap, realized_cap, mvrv, nvt, thermocap, and variants (SMA, rolling) -- **Supply**: circulating, issued, inflation_rate, subsidy -- **Mining**: hashrate, difficulty, revenue, fees, block size/weight stats -- **Network activity**: transaction counts, volumes, active addresses -- **UTXO age bands**: HODL waves, realized cap by age cohort -- **Cointime economics**: liveliness, vaultedness, activity-to-vaultedness ratio -- **Holder cohorts**: by balance range (plankton to whale), by holding duration (short/long-term) -- **Cost basis**: UTXO realized price distributions by cohort -- **Addresses**: total, new, active, empty, by balance range, by type +### Health ---- +#### GET `/health` -## Client Libraries +Health check + +Liveness probe. Returns server identity, uptime, and indexed/computed heights from local state only (no bitcoind round-trip). For real chain-tip catch-up, see `/api/server/sync`. + +Returns: JSON `Health` + +```bash +curl -s "https://bitview.space/health" +``` + +### Historical Price + +#### GET `/api/v1/historical-price` + +Historical price + +Get historical BTC/USD price. Optionally specify a UNIX timestamp to get the price at that time. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-historical-price)* + +Parameters: +- `timestamp` (query, Timestamp, optional) + +Returns: JSON `HistoricalPrice` + +```bash +curl -s "https://bitview.space/api/v1/historical-price?timestamp=" +``` + +### Mempool + +#### GET `/api/mempool` + +Mempool statistics + +Get current mempool statistics including transaction count, total vsize, total fees, and fee histogram. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mempool)* + +Returns: JSON `MempoolInfo` + +```bash +curl -s "https://bitview.space/api/mempool" +``` + +#### GET `/api/mempool/hash` + +Mempool content hash + +Returns an opaque hash that changes whenever the projected next block changes. Same value as the mempool ETag. Useful as a freshness/liveness signal: if it stays constant for tens of seconds on a live network, the mempool sync loop has stalled. + +Returns: JSON `NextBlockHash` + +```bash +curl -s "https://bitview.space/api/mempool/hash" +``` + +#### GET `/api/mempool/price` + +Live BTC/USD price + +Returns the current BTC/USD price in dollars, derived from on-chain round-dollar output patterns in the last 12 blocks plus mempool. + +Returns: JSON `Dollars` + +```bash +curl -s "https://bitview.space/api/mempool/price" +``` + +#### GET `/api/mempool/recent` + +Recent mempool transactions + +Get the last 10 transactions to enter the mempool. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mempool-recent)* + +Returns: JSON `MempoolRecentTx[]` + +```bash +curl -s "https://bitview.space/api/mempool/recent" +``` + +#### GET `/api/mempool/txids` + +Mempool transaction IDs + +Get all transaction IDs currently in the mempool. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mempool-transaction-ids)* + +Returns: JSON `Txid[]` + +```bash +curl -s "https://bitview.space/api/mempool/txids" +``` + +#### GET `/api/v1/mempool/block-template` + +Projected next block template + +Bitcoin Core's `getblocktemplate` selection: full transaction bodies in GBT order with aggregate stats. The returned `hash` is an opaque content token; pass it as `` on `/api/v1/mempool/block-template/diff/{hash}` to fetch deltas instead of refetching the whole template. + +Returns: JSON `BlockTemplate` + +```bash +curl -s "https://bitview.space/api/v1/mempool/block-template" +``` + +#### GET `/api/v1/mempool/block-template/diff/{hash}` + +Block template diff since hash + +Delta of the projected next block since ``. `order` is the full new template in order: each entry is either a number (index into the prior template the client cached at ``) or a transaction object (new body to insert at this position). Walk `order` once to rebuild; `removed` is a convenience list of txids that left so clients can evict cached bodies. After applying, use the response `hash` as `` on the next call to keep iterating. Returns `404` when `` has aged out of server history; clients should fall back to `/api/v1/mempool/block-template`. + +Parameters: +- `hash` (path, NextBlockHash, required) + +Returns: JSON `BlockTemplateDiff` + +```bash +curl -s "https://bitview.space/api/v1/mempool/block-template/diff/" +``` + +### Mining + +#### GET `/api/v1/mining/blocks/fee-rates/{time_period}` + +Block fee rates + +Get block fee rate percentiles (min, 10th, 25th, median, 75th, 90th, max) for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-feerates)* + +Parameters: +- `time_period` (path, TimePeriod, required) + +Returns: JSON `BlockFeeRatesEntry[]` + +```bash +curl -s "https://bitview.space/api/v1/mining/blocks/fee-rates/" +``` + +#### GET `/api/v1/mining/blocks/fees/{time_period}` + +Block fees + +Get average total fees per block for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-fees)* + +Parameters: +- `time_period` (path, TimePeriod, required) + +Returns: JSON `BlockFeesEntry[]` + +```bash +curl -s "https://bitview.space/api/v1/mining/blocks/fees/" +``` + +#### GET `/api/v1/mining/blocks/rewards/{time_period}` + +Block rewards + +Get average coinbase reward (subsidy + fees) per block for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-rewards)* + +Parameters: +- `time_period` (path, TimePeriod, required) + +Returns: JSON `BlockRewardsEntry[]` + +```bash +curl -s "https://bitview.space/api/v1/mining/blocks/rewards/" +``` + +#### GET `/api/v1/mining/blocks/sizes-weights/{time_period}` + +Block sizes and weights + +Get average block sizes and weights for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-sizes-weights)* + +Parameters: +- `time_period` (path, TimePeriod, required) + +Returns: JSON `BlockSizesWeights` + +```bash +curl -s "https://bitview.space/api/v1/mining/blocks/sizes-weights/" +``` + +#### GET `/api/v1/mining/blocks/timestamp/{timestamp}` + +Block by timestamp + +Find the block closest to a given UNIX timestamp. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-timestamp)* + +Parameters: +- `timestamp` (path, Timestamp, required) + +Returns: JSON `BlockTimestamp` + +```bash +curl -s "https://bitview.space/api/v1/mining/blocks/timestamp/" +``` + +#### GET `/api/v1/mining/difficulty-adjustments` + +Difficulty adjustments (all time) + +Get historical difficulty adjustments including timestamp, block height, difficulty value, and percentage change. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-difficulty-adjustments)* + +Returns: JSON `DifficultyAdjustmentEntry[]` + +```bash +curl -s "https://bitview.space/api/v1/mining/difficulty-adjustments" +``` + +#### GET `/api/v1/mining/difficulty-adjustments/{time_period}` + +Difficulty adjustments + +Get historical difficulty adjustments for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-difficulty-adjustments)* + +Parameters: +- `time_period` (path, TimePeriod, required) + +Returns: JSON `DifficultyAdjustmentEntry[]` + +```bash +curl -s "https://bitview.space/api/v1/mining/difficulty-adjustments/" +``` + +#### GET `/api/v1/mining/hashrate` + +Network hashrate (all time) + +Get network hashrate and difficulty data for all time. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-hashrate)* + +Returns: JSON `HashrateSummary` + +```bash +curl -s "https://bitview.space/api/v1/mining/hashrate" +``` + +#### GET `/api/v1/mining/hashrate/pools` + +All pools hashrate (all time) + +Get hashrate data for all mining pools. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pool-hashrates)* + +Returns: JSON `PoolHashrateEntry[]` + +```bash +curl -s "https://bitview.space/api/v1/mining/hashrate/pools" +``` + +#### GET `/api/v1/mining/hashrate/pools/{time_period}` + +All pools hashrate + +Get hashrate data for all mining pools for a time period. Valid periods: `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pool-hashrates)* + +Parameters: +- `time_period` (path, TimePeriod, required) + +Returns: JSON `PoolHashrateEntry[]` + +```bash +curl -s "https://bitview.space/api/v1/mining/hashrate/pools/" +``` + +#### GET `/api/v1/mining/hashrate/{time_period}` + +Network hashrate + +Get network hashrate and difficulty data for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-hashrate)* + +Parameters: +- `time_period` (path, TimePeriod, required) + +Returns: JSON `HashrateSummary` + +```bash +curl -s "https://bitview.space/api/v1/mining/hashrate/" +``` + +#### GET `/api/v1/mining/pool/{slug}` + +Mining pool details + +Get detailed information about a specific mining pool including block counts and shares for different time periods. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pool)* + +Parameters: +- `slug` (path, PoolSlug, required) + +Returns: JSON `PoolDetail` + +```bash +curl -s "https://bitview.space/api/v1/mining/pool/" +``` + +#### GET `/api/v1/mining/pool/{slug}/blocks` + +Mining pool blocks + +Get the 10 most recent blocks mined by a specific pool. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pool-blocks)* + +Parameters: +- `slug` (path, PoolSlug, required) + +Returns: JSON `BlockInfoV1[]` + +```bash +curl -s "https://bitview.space/api/v1/mining/pool//blocks" +``` + +#### GET `/api/v1/mining/pool/{slug}/blocks/{height}` + +Mining pool blocks from height + +Get 10 blocks mined by a specific pool before (and including) the given height. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pool-blocks)* + +Parameters: +- `slug` (path, PoolSlug, required) +- `height` (path, Height, required) + +Returns: JSON `BlockInfoV1[]` + +```bash +curl -s "https://bitview.space/api/v1/mining/pool//blocks/" +``` + +#### GET `/api/v1/mining/pool/{slug}/hashrate` + +Mining pool hashrate + +Get hashrate history for a specific mining pool. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pool-hashrate)* + +Parameters: +- `slug` (path, PoolSlug, required) + +Returns: JSON `PoolHashrateEntry[]` + +```bash +curl -s "https://bitview.space/api/v1/mining/pool//hashrate" +``` + +#### GET `/api/v1/mining/pools` + +List all mining pools + +Get list of all known mining pools with their identifiers. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pools)* + +Returns: JSON `PoolInfo[]` + +```bash +curl -s "https://bitview.space/api/v1/mining/pools" +``` + +#### GET `/api/v1/mining/pools/{time_period}` + +Mining pool statistics + +Get mining pool statistics for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pools)* + +Parameters: +- `time_period` (path, TimePeriod, required) + +Returns: JSON `PoolsSummary` + +```bash +curl -s "https://bitview.space/api/v1/mining/pools/" +``` + +#### GET `/api/v1/mining/reward-stats/{block_count}` + +Mining reward statistics + +Get mining reward statistics for the last N blocks including total rewards, fees, and transaction count. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-reward-stats)* + +Parameters: +- `block_count` (path, integer, required): Number of recent blocks to include + +Returns: JSON `RewardStats` + +```bash +curl -s "https://bitview.space/api/v1/mining/reward-stats/" +``` + +### Openapi.json + +#### GET `/openapi.json` + +OpenAPI specification + +Full OpenAPI 3.1 specification for this API. + +Returns: text + +```bash +curl -s "https://bitview.space/openapi.json" +``` + +### Oracle + +#### GET `/api/oracle/histogram/outputs/live` + +Live output value histogram + +Live unfiltered output value histogram for the forming mempool block. Every live output is binned by value on the oracle log scale; no oracle payment filters are applied. A flat array of log-scale bins, all zero when no mempool is configured. + +Returns: JSON `integer[]` + +```bash +curl -s "https://bitview.space/api/oracle/histogram/outputs/live" +``` + +#### GET `/api/oracle/histogram/outputs/{point}` + +Output value histogram at height or day + +Unfiltered output value histogram for a confirmed point. A block height (`840000`) gives every output in that block, coinbase included, binned by value on the oracle log scale; a calendar date (`YYYY-MM-DD`) sums every block that day. A flat array of log-scale bins. + +Parameters: +- `point` (path, string, required) + +Returns: JSON `integer[]` + +```bash +curl -s "https://bitview.space/api/oracle/histogram/outputs/" +``` + +#### GET `/api/oracle/histogram/payments/live` + +Live payment output histogram + +Live smoothed histogram of oracle-eligible payment outputs, binned by output value on the oracle log scale. It combines the committed oracle window with the forming mempool block. A flat array of log-scale bins. + +Returns: JSON `integer[]` + +```bash +curl -s "https://bitview.space/api/oracle/histogram/payments/live" +``` + +#### GET `/api/oracle/histogram/payments/{point}` + +Payment output histogram at height or day + +Smoothed histogram of oracle-eligible payment outputs for a confirmed point. A block height (`840000`) gives that block's oracle payment histogram; a calendar date (`YYYY-MM-DD`) gives the average of that day's per-block payment histograms. A flat array of log-scale bins. + +Parameters: +- `point` (path, string, required) + +Returns: JSON `integer[]` + +```bash +curl -s "https://bitview.space/api/oracle/histogram/payments/" +``` + +#### GET `/api/oracle/price` + +Live BTC/USD price + +Current BTC/USD price in dollars. Same value as `/api/mempool/price`. Confirmed per-height history is available at `/api/vecs/height-to-price`. + +Returns: JSON `Dollars` + +```bash +curl -s "https://bitview.space/api/oracle/price" +``` + +### Prices + +#### GET `/api/v1/prices` + +Current BTC price + +Returns bitcoin latest price (on-chain derived, USD only). *[Mempool.space docs](https://mempool.space/docs/api/rest#get-price)* + +Returns: JSON `Prices` + +```bash +curl -s "https://bitview.space/api/v1/prices" +``` + +### Replacements + +#### GET `/api/v1/replacements` + +Recent RBF replacements + +Returns up to 25 most-recent RBF replacement trees across the whole mempool. Each entry has the same shape as `tx_rbf().replacements`. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-replacements)* + +Returns: JSON `ReplacementNode[]` + +```bash +curl -s "https://bitview.space/api/v1/replacements" +``` + +### Series + +#### GET `/api/series` + +Series catalog + +Returns the complete hierarchical catalog of available series organized as a tree structure. Series are grouped by categories and subcategories. + +Returns: JSON `TreeNode` + +```bash +curl -s "https://bitview.space/api/series" +``` + +#### GET `/api/series/bulk` + +Bulk series data + +Fetch multiple series in a single request. Supports filtering by index and date range. Returns an array of SeriesData objects. For a single series, use `get_series` instead. + +Parameters: +- `series` (query, SeriesList, required): Requested series +- `index` (query, Index, required): Index to query +- `start` (query, RangeIndex, optional): Inclusive start: integer index, date (YYYY-MM-DD), or timestamp (ISO 8601). Negative integers count from end. Aliases: `from`, `f`, `s` +- `end` (query, RangeIndex, optional): Exclusive end: integer index, date (YYYY-MM-DD), or timestamp (ISO 8601). Negative integers count from end. Aliases: `to`, `t`, `e` +- `limit` (query, Limit, optional): Maximum number of values to return (ignored if `end` is set). Aliases: `count`, `c`, `l` +- `format` (query, Format, optional): Format of the output + +Returns: JSON `SeriesData[]` + +```bash +curl -s "https://bitview.space/api/series/bulk?series=&index=&start=&end=&limit=&format=" +``` + +#### GET `/api/series/count` + +Series count + +Returns the number of series available per index type. + +Returns: JSON `SeriesCount[]` + +```bash +curl -s "https://bitview.space/api/series/count" +``` + +#### GET `/api/series/indexes` + +List available indexes + +Returns all available indexes with their accepted query aliases. Use any alias when querying series. + +Returns: JSON `IndexInfo[]` + +```bash +curl -s "https://bitview.space/api/series/indexes" +``` + +#### GET `/api/series/list` + +Series list + +Paginated flat list of all available series names. Use `page` query param for pagination. + +Parameters: +- `page` (query, integer, optional): Pagination index +- `per_page` (query, integer, optional): Results per page (default: 1000, max: 1000) + +Returns: JSON `PaginatedSeries` + +```bash +curl -s "https://bitview.space/api/series/list?page=&per_page=" +``` + +#### GET `/api/series/search` + +Search series + +Fuzzy search for series by name. Supports partial matches and typos. + +Parameters: +- `q` (query, SeriesName, required): Search query string +- `limit` (query, Limit, optional): Maximum number of results + +Returns: JSON `string[]` + +```bash +curl -s "https://bitview.space/api/series/search?q=&limit=" +``` + +#### GET `/api/series/{series}` + +Get series info + +Returns the supported indexes and value type for the specified series. + +Parameters: +- `series` (path, SeriesName, required) + +Returns: JSON `SeriesInfo` + +```bash +curl -s "https://bitview.space/api/series/" +``` + +#### GET `/api/series/{series}/{index}` + +Get series data + +Fetch data for a specific series at the given index. Use query parameters to filter by date range and format (json/csv). + +Parameters: +- `series` (path, SeriesName, required): Series name +- `index` (path, Index, required): Aggregation index +- `start` (query, RangeIndex, optional): Inclusive start: integer index, date (YYYY-MM-DD), or timestamp (ISO 8601). Negative integers count from end. Aliases: `from`, `f`, `s` +- `end` (query, RangeIndex, optional): Exclusive end: integer index, date (YYYY-MM-DD), or timestamp (ISO 8601). Negative integers count from end. Aliases: `to`, `t`, `e` +- `limit` (query, Limit, optional): Maximum number of values to return (ignored if `end` is set). Aliases: `count`, `c`, `l` +- `format` (query, Format, optional): Format of the output + +Returns: JSON `SeriesData` + +```bash +curl -s "https://bitview.space/api/series//?start=&end=&limit=&format=" +``` + +#### GET `/api/series/{series}/{index}/data` + +Get raw series data + +Returns just the data array without the SeriesData wrapper. Supports the same range and format parameters as the standard endpoint. + +Parameters: +- `series` (path, SeriesName, required): Series name +- `index` (path, Index, required): Aggregation index +- `start` (query, RangeIndex, optional): Inclusive start: integer index, date (YYYY-MM-DD), or timestamp (ISO 8601). Negative integers count from end. Aliases: `from`, `f`, `s` +- `end` (query, RangeIndex, optional): Exclusive end: integer index, date (YYYY-MM-DD), or timestamp (ISO 8601). Negative integers count from end. Aliases: `to`, `t`, `e` +- `limit` (query, Limit, optional): Maximum number of values to return (ignored if `end` is set). Aliases: `count`, `c`, `l` +- `format` (query, Format, optional): Format of the output + +Returns: JSON `boolean[]` + +```bash +curl -s "https://bitview.space/api/series///data?start=&end=&limit=&format=" +``` + +#### GET `/api/series/{series}/{index}/latest` + +Get latest series value + +Returns the single most recent value for a series, unwrapped (not inside a SeriesData object). + +Parameters: +- `series` (path, SeriesName, required): Series name +- `index` (path, Index, required): Aggregation index + +Returns: JSON `*` + +```bash +curl -s "https://bitview.space/api/series///latest" +``` + +#### GET `/api/series/{series}/{index}/len` + +Get series data length + +Returns the total number of data points for a series at the given index. + +Parameters: +- `series` (path, SeriesName, required): Series name +- `index` (path, Index, required): Aggregation index + +Returns: JSON `integer` + +```bash +curl -s "https://bitview.space/api/series///len" +``` + +#### GET `/api/series/{series}/{index}/version` + +Get series version + +Returns the current version of a series. Changes when the series data is updated. + +Parameters: +- `series` (path, SeriesName, required): Series name +- `index` (path, Index, required): Aggregation index + +Returns: JSON `Version` + +```bash +curl -s "https://bitview.space/api/series///version" +``` + +### Server + +#### GET `/api/server/disk` + +Disk usage + +Returns the disk space used by BRK and Bitcoin data. + +Returns: JSON `DiskUsage` + +```bash +curl -s "https://bitview.space/api/server/disk" +``` + +#### GET `/api/server/sync` + +Sync status + +Returns the sync status of the indexer, including indexed height, tip height, blocks behind, and last indexed timestamp. + +Returns: JSON `SyncStatus` + +```bash +curl -s "https://bitview.space/api/server/sync" +``` + +### Transaction Times + +#### GET `/api/v1/transaction-times` + +Transaction first-seen times + +Returns timestamps when transactions were first seen in the mempool. Returns 0 for mined or unknown transactions. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-transaction-times)* + +Parameters: +- `txId[]` (query, Txid[], required): Transaction IDs to look up (max 250 per request). + +Returns: JSON `integer[]` + +```bash +curl -s "https://bitview.space/api/v1/transaction-times?txId[]=" +``` + +### Tx + +#### POST `/api/tx` + +Broadcast transaction + +Broadcast a raw transaction to the network. The transaction should be provided as hex in the request body. The txid will be returned on success. *[Mempool.space docs](https://mempool.space/docs/api/rest#post-transaction)* + +Request body: `string` (required) + +Returns: JSON `Txid` + +```bash +curl -s -X POST --data '' "https://bitview.space/api/tx" +``` + +#### GET `/api/tx/{txid}` + +Transaction information + +Retrieve complete transaction data by transaction ID (txid). Returns inputs, outputs, fee, size, and confirmation status. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-transaction)* + +Parameters: +- `txid` (path, Txid, required) + +Returns: JSON `Transaction` + +```bash +curl -s "https://bitview.space/api/tx/" +``` + +#### GET `/api/tx/{txid}/hex` + +Transaction hex + +Retrieve the raw transaction as a hex-encoded string. Returns the serialized transaction in hexadecimal format. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-transaction-hex)* + +Parameters: +- `txid` (path, Txid, required) + +Returns: text `Hex` + +```bash +curl -s "https://bitview.space/api/tx//hex" +``` + +#### GET `/api/tx/{txid}/merkle-proof` + +Transaction merkle proof + +Get the merkle inclusion proof for a transaction. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-transaction-merkle-proof)* + +Parameters: +- `txid` (path, Txid, required) + +Returns: JSON `MerkleProof` + +```bash +curl -s "https://bitview.space/api/tx//merkle-proof" +``` + +#### GET `/api/tx/{txid}/merkleblock-proof` + +Transaction merkleblock proof + +Get the merkleblock proof for a transaction (BIP37 format, hex encoded). *[Mempool.space docs](https://mempool.space/docs/api/rest#get-transaction-merkleblock-proof)* + +Parameters: +- `txid` (path, Txid, required) + +Returns: text `Hex` + +```bash +curl -s "https://bitview.space/api/tx//merkleblock-proof" +``` + +#### GET `/api/tx/{txid}/outspend/{vout}` + +Output spend status + +Get the spending status of a transaction output. Returns whether the output has been spent and, if so, the spending transaction details. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-transaction-outspend)* + +Parameters: +- `txid` (path, Txid, required): Transaction ID +- `vout` (path, Vout, required): Output index + +Returns: JSON `TxOutspend` + +```bash +curl -s "https://bitview.space/api/tx//outspend/" +``` + +#### GET `/api/tx/{txid}/outspends` + +All output spend statuses + +Get the spending status of all outputs in a transaction. Returns an array with the spend status for each output. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-transaction-outspends)* + +Parameters: +- `txid` (path, Txid, required) + +Returns: JSON `TxOutspend[]` + +```bash +curl -s "https://bitview.space/api/tx//outspends" +``` + +#### GET `/api/tx/{txid}/raw` + +Transaction raw + +Returns a transaction as binary data. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-transaction-raw)* + +Parameters: +- `txid` (path, Txid, required) + +Returns: binary data + +```bash +curl -s "https://bitview.space/api/tx//raw" +``` + +#### GET `/api/tx/{txid}/status` + +Transaction status + +Retrieve the confirmation status of a transaction. Returns whether the transaction is confirmed and, if so, the block height, hash, and timestamp. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-transaction-status)* + +Parameters: +- `txid` (path, Txid, required) + +Returns: JSON `TxStatus` + +```bash +curl -s "https://bitview.space/api/tx//status" +``` + +#### GET `/api/v1/tx/{txid}/rbf` + +RBF replacement history + +Returns the RBF replacement tree for a transaction, if any. Both `replacements` and `replaces` are null when the tx has no known RBF history within the mempool monitor's retention window. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-transaction-rbf-history)* + +Parameters: +- `txid` (path, Txid, required) + +Returns: JSON `RbfResponse` + +```bash +curl -s "https://bitview.space/api/v1/tx//rbf" +``` + +### Tx Index + +#### GET `/api/tx-index/{index}` + +Txid by index + +Retrieve the transaction ID (txid) at a given global transaction index. Returns the txid as plain text. + +Parameters: +- `index` (path, TxIndex, required) + +Returns: text `Txid` + +```bash +curl -s "https://bitview.space/api/tx-index/" +``` + +### Urpd + +#### GET `/api/urpd` + +Available URPD cohorts + +Cohorts for which URPD data is available. Returns names like `all`, `sth`, `lth`, `utxos_under_1h_old`. + +Returns: JSON `Cohort[]` + +```bash +curl -s "https://bitview.space/api/urpd" +``` + +#### GET `/api/urpd/{cohort}` + +Latest URPD + +URPD for the most recent available date in the cohort. The response's `date` field echoes which date was served. See the URPD tag description for the response shape and `agg` options. + +Parameters: +- `cohort` (path, Cohort, required) +- `agg` (query, UrpdAggregation, optional): Aggregation strategy. Default: raw (no aggregation). Accepts `bucket` as alias. + +Returns: JSON `Urpd` + +```bash +curl -s "https://bitview.space/api/urpd/?agg=" +``` + +#### GET `/api/urpd/{cohort}/dates` + +Available URPD dates + +Dates for which a URPD snapshot is available for the cohort. One entry per UTC day, sorted ascending. + +Parameters: +- `cohort` (path, Cohort, required) + +Returns: JSON `Date[]` + +```bash +curl -s "https://bitview.space/api/urpd//dates" +``` + +#### GET `/api/urpd/{cohort}/{date}` + +URPD at date + +URPD for a (cohort, date) pair. Returns `{ cohort, date, aggregation, close, total_supply, buckets }` where each bucket is `{ price_floor, supply, realized_cap, unrealized_pnl }`. See the URPD tag description for unit conventions and `agg` options. + +Parameters: +- `cohort` (path, Cohort, required) +- `date` (path, string, required) +- `agg` (query, UrpdAggregation, optional): Aggregation strategy. Default: raw (no aggregation). Accepts `bucket` as alias. + +Returns: JSON `Urpd` + +```bash +curl -s "https://bitview.space/api/urpd//?agg=" +``` + +### Validate Address + +#### GET `/api/v1/validate-address/{address}` + +Validate address + +Validate a Bitcoin address and get information about its type and scriptPubKey. Returns `isvalid: false` with an error message for invalid addresses. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-validate)* + +Parameters: +- `address` (path, string, required): Bitcoin address to validate (can be any string) + +Returns: JSON `AddrValidation` + +```bash +curl -s "https://bitview.space/api/v1/validate-address/
" +``` + +### Version + +#### GET `/version` + +API version + +Returns the current version of the API server + +Returns: JSON `string` + +```bash +curl -s "https://bitview.space/version" +``` + +## Schemas + +### `Addr` + +`string` + +### `AddrChainStats` + +- `funded_txo_count`: `integer` (required) — Total number of transaction outputs that funded this address +- `funded_txo_sum`: `Sats` (required) — Total amount in satoshis received by this address across all funded outputs +- `spent_txo_count`: `integer` (required) — Total number of transaction outputs spent from this address +- `spent_txo_sum`: `Sats` (required) — Total amount in satoshis spent from this address +- `tx_count`: `integer` (required) — Total number of confirmed transactions involving this address +- `type_index`: `TypeIndex` (required) — Index of this address within its type on the blockchain +- `realized_price`: `Dollars` (required) — Realized price (average cost basis) in USD + +### `AddrHashPrefixMatches` + +- `addr_type`: `OutputType` (required) +- `prefix`: `string` (required) +- `truncated`: `boolean` (required) +- `addresses`: `Addr[]` (required) + +### `AddrMempoolStats` + +- `funded_txo_count`: `integer` (required) — Number of unconfirmed transaction outputs funding this address +- `funded_txo_sum`: `Sats` (required) — Total amount in satoshis being received in unconfirmed transactions +- `spent_txo_count`: `integer` (required) — Number of unconfirmed transaction inputs spending from this address +- `spent_txo_sum`: `Sats` (required) — Total amount in satoshis being spent in unconfirmed transactions +- `tx_count`: `integer` (required) — Number of unconfirmed transactions involving this address + +### `AddrStats` + +- `address`: `Addr` (required) — Bitcoin address string +- `addr_type`: `OutputType` (required) — Address type (p2pkh, p2sh, v0_p2wpkh, v0_p2wsh, v1_p2tr, etc.) +- `chain_stats`: `AddrChainStats` (required) — Statistics for confirmed transactions on the blockchain +- `mempool_stats`: `AddrMempoolStats` (required) — Statistics for unconfirmed transactions in the mempool +- `balance`: `Sats` (required) — Current balance in satoshis, including unconfirmed mempool changes + +### `AddrValidation` + +- `isvalid`: `boolean` (required) — Whether the address is valid +- `address`: `object` — The validated address +- `scriptPubKey`: `object` — The scriptPubKey in hex +- `isscript`: `object` — Whether this is a script address (P2SH) +- `iswitness`: `object` — Whether this is a witness address +- `witness_version`: `object` — Witness version (0 for P2WPKH/P2WSH, 1 for P2TR) +- `witness_program`: `object` — Witness program in hex +- `error_locations`: `object` — Error locations (empty array for most errors) +- `error`: `object` — Error message for invalid addresses + +### `Bitcoin` + +`number` + +### `BlockExtras` + +- `totalFees`: `Sats` (required) — Total fees in satoshis +- `medianFee`: `FeeRate` (required) — Median fee rate in sat/vB +- `feeRange`: `FeeRate[]` (required) — Fee rate range: [min, 10%, 25%, 50%, 75%, 90%, max] +- `reward`: `Sats` (required) — Total block reward (subsidy + fees) in satoshis +- `pool`: `BlockPool` (required) — Mining pool that mined this block +- `avgFee`: `Sats` (required) — Average fee per transaction in satoshis +- `avgFeeRate`: `FeeRate` (required) — Average fee rate in sat/vB +- `coinbaseRaw`: `string` (required) — Raw coinbase transaction scriptsig as hex +- `coinbaseAddress`: `object` — Primary coinbase output address +- `coinbaseAddresses`: `string[]` (required) — All coinbase output addresses +- `coinbaseSignature`: `string` (required) — Coinbase output script in ASM format +- `coinbaseSignatureAscii`: `string` (required) — Coinbase scriptsig decoded as ASCII +- `avgTxSize`: `number` (required) — Average transaction size in bytes +- `totalInputs`: `integer` (required) — Total number of inputs (excluding coinbase) +- `totalOutputs`: `integer` (required) — Total number of outputs +- `totalOutputAmt`: `Sats` (required) — Total output amount in satoshis +- `medianFeeAmt`: `Sats` (required) — Median fee amount in satoshis +- `feePercentiles`: `Sats[]` (required) — Fee amount percentiles in satoshis: [min, 10%, 25%, 50%, 75%, 90%, max] +- `segwitTotalTxs`: `integer` (required) — Number of segwit transactions +- `segwitTotalSize`: `integer` (required) — Total size of segwit transactions in bytes +- `segwitTotalWeight`: `Weight` (required) — Total weight of segwit transactions +- `header`: `string` (required) — Raw 80-byte block header as hex +- `utxoSetChange`: `integer` (required) — UTXO set change (total outputs - total inputs, includes unspendable like OP_RETURN). Note: intentionally differs from utxo_set_size diff which excludes unspendable outputs. Matches mempool.space/bitcoin-cli behavior. +- `utxoSetSize`: `integer` (required) — Total spendable UTXO set size at this height (excludes OP_RETURN and other unspendable outputs) +- `totalInputAmt`: `Sats` (required) — Total input amount in satoshis +- `virtualSize`: `number` (required) — Virtual size in vbytes +- `firstSeen`: `object` — Timestamp when the block was first seen (always null, not yet supported) +- `orphans`: `string[]` (required) — Orphaned blocks (always empty) +- `price`: `Dollars` (required) — USD price at block height + +### `BlockHash` + +`string` + +### `BlockInfo` + +- `id`: `BlockHash` (required) — Block hash +- `height`: `Height` (required) — Block height +- `version`: `integer` (required) — Block version +- `timestamp`: `Timestamp` (required) — Block timestamp (Unix time) +- `bits`: `integer` (required) — Compact target (bits) +- `nonce`: `integer` (required) — Nonce +- `difficulty`: `number` (required) — Block difficulty +- `merkle_root`: `string` (required) — Merkle root of the transaction tree +- `tx_count`: `integer` (required) — Number of transactions +- `size`: `integer` (required) — Block size in bytes +- `weight`: `Weight` (required) — Block weight in weight units +- `previousblockhash`: `BlockHash` (required) — Previous block hash +- `mediantime`: `Timestamp` (required) — Median time of the last 11 blocks + +### `BlockInfoV1` + +- `id`: `BlockHash` (required) — Block hash +- `height`: `Height` (required) — Block height +- `version`: `integer` (required) — Block version +- `timestamp`: `Timestamp` (required) — Block timestamp (Unix time) +- `bits`: `integer` (required) — Compact target (bits) +- `nonce`: `integer` (required) — Nonce +- `difficulty`: `number` (required) — Block difficulty +- `merkle_root`: `string` (required) — Merkle root of the transaction tree +- `tx_count`: `integer` (required) — Number of transactions +- `size`: `integer` (required) — Block size in bytes +- `weight`: `Weight` (required) — Block weight in weight units +- `previousblockhash`: `BlockHash` (required) — Previous block hash +- `mediantime`: `Timestamp` (required) — Median time of the last 11 blocks +- `stale`: `boolean` — Whether this block has been replaced by a longer chain +- `extras`: `BlockExtras` (required) — Extended block data + +### `BlockPool` + +- `id`: `integer` (required) — Unique pool identifier +- `name`: `string` (required) — Pool name +- `slug`: `PoolSlug` (required) — URL-friendly pool identifier +- `blockNumber`: `integer` (required) — This block's ordinal among blocks attributed to this pool +- `minerNames`: `object` — Miner name tags found in coinbase scriptsig + +### `BlockSizeEntry` + +- `avgHeight`: `Height` (required) — Average block height in this window +- `timestamp`: `Timestamp` (required) — Unix timestamp at the window midpoint +- `avgSize`: `integer` (required) — Rolling 24h median block size (bytes) + +### `BlockSizesWeights` + +- `sizes`: `BlockSizeEntry[]` (required) — Block size data points +- `weights`: `BlockWeightEntry[]` (required) — Block weight data points + +### `BlockStatus` + +- `in_best_chain`: `boolean` (required) — Whether this block is in the best chain +- `height`: `Height | null` — Block height (only if in best chain) +- `next_best`: `BlockHash | null` — Hash of the next block in the best chain (null if tip) + +### `BlockTemplate` + +- `hash`: `NextBlockHash` (required) — Pass back as `` on `/api/v1/mempool/block-template/diff/{hash}` to fetch deltas. +- `stats`: `MempoolBlock` (required) — Aggregate stats for this block (size, vsize, fee range, ...). +- `transactions`: `Transaction[]` (required) — Full transaction bodies in `getblocktemplate` order. + +### `BlockTemplateDiff` + +- `hash`: `NextBlockHash` (required) — Current next-block hash. Use as `since` on the next diff call. +- `since`: `NextBlockHash` (required) — Echoed prior hash the diff was computed against. +- `order`: `BlockTemplateDiffEntry[]` (required) — New template in order. Each entry is either an index into the prior template's transactions or a full transaction body. +- `removed`: `Txid[]` (required) — Txids that left the projected next block since `since` (confirmed, evicted, replaced, or pushed past block 0). + +### `BlockTemplateDiffEntry` + +`integer | Transaction` + +### `BlockTimestamp` + +- `height`: `Height` (required) — Block height +- `hash`: `BlockHash` (required) — Block hash +- `timestamp`: `string` (required) — Block timestamp in ISO 8601 format + +### `BlockWeightEntry` + +- `avgHeight`: `Height` (required) — Average block height in this window +- `timestamp`: `Timestamp` (required) — Unix timestamp at the window midpoint +- `avgWeight`: `Weight` (required) — Rolling 24h median block weight (weight units) + +### `Cohort` + +`all | sth | lth | utxos_under_1h_old | utxos_1h_to_1d_old | utxos_1d_to_1w_old | utxos_1w_to_1m_old | utxos_1m_to_2m_old | utxos_2m_to_3m_old | utxos_3m_to_4m_old | utxos_4m_to_5m_old | utxos_5m_to_6m_old | utxos_6m_to_1y_old | utxos_1y_to_2y_old | utxos_2y_to_3y_old | utxos_3y_to_4y_old | utxos_4y_to_5y_old | utxos_5y_to_6y_old | utxos_6y_to_7y_old | utxos_7y_to_8y_old | utxos_8y_to_10y_old | utxos_10y_to_12y_old | utxos_12y_to_15y_old | utxos_over_15y_old` + +### `CpfpCluster` + +- `txs`: `CpfpClusterTx[]` (required) — All txs in the cluster, in topological order (parents before children). +- `chunks`: `CpfpClusterChunk[]` (required) — SFL-emitted chunks ordered by descending feerate. +- `chunkIndex`: `integer` (required) — Index into `chunks` of the chunk containing the seed tx. + +### `CpfpClusterChunk` + +- `txs`: `CpfpClusterTxIndex[]` (required) +- `feerate`: `FeeRate` (required) + +### `CpfpClusterTx` + +- `txid`: `Txid` (required) +- `weight`: `Weight` (required) +- `fee`: `Sats` (required) +- `parents`: `CpfpClusterTxIndex[]` (required) — In-cluster parents of this tx. + +### `CpfpClusterTxIndex` + +`integer` + +### `CpfpEntry` + +- `txid`: `Txid` (required) +- `weight`: `Weight` (required) +- `fee`: `Sats` (required) + +### `CpfpInfo` + +- `ancestors`: `CpfpEntry[]` (required) — Ancestor transactions in the CPFP chain. +- `bestDescendant`: `CpfpEntry | null` — Best (highest fee rate) descendant, if any. +- `descendants`: `CpfpEntry[]` (required) — Descendant transactions in the CPFP chain. +- `effectiveFeePerVsize`: `FeeRate` (required) — Effective fee rate considering CPFP relationships (sat/vB). This is the seed's chunk feerate after lift-merging, i.e. the rate Core/mempool.space would surface for this tx. +- `sigops`: `SigOps` (required) — BIP-141 sigop cost for the seed tx (witness sigops count as 1, legacy and P2SH-redeem sigops count as 4). +- `fee`: `Sats` (required) — Transaction fee (sats). +- `vsize`: `VSize` (required) — Virtual size of the seed tx (vbytes). +- `adjustedVsize`: `VSize` (required) — Policy-adjusted virtual size: `max(vsize, sigops * 5)`. +- `cluster`: `CpfpCluster | null` — Cluster the seed belongs to: full tx list, SFL-linearized chunks, and the seed's chunk index. Omitted when the seed has no ancestors and no descendants (matches mempool.space). + +### `Date` + +`integer` + +### `DifficultyAdjustment` + +- `progressPercent`: `number` (required) — Progress through current difficulty epoch (0-100%) +- `difficultyChange`: `number` (required) — Estimated difficulty change at next retarget (%) +- `estimatedRetargetDate`: `integer` (required) — Estimated timestamp of next retarget (milliseconds) +- `remainingBlocks`: `integer` (required) — Blocks remaining until retarget +- `remainingTime`: `integer` (required) — Estimated time until retarget (milliseconds) +- `previousRetarget`: `number` (required) — Previous difficulty adjustment (%) +- `previousTime`: `Timestamp` (required) — Timestamp of most recent retarget (seconds) +- `nextRetargetHeight`: `Height` (required) — Height of next retarget +- `timeAvg`: `integer` (required) — Average block time in current epoch (milliseconds) +- `adjustedTimeAvg`: `integer` (required) — Time-adjusted average (milliseconds) +- `timeOffset`: `integer` (required) — Time offset from expected schedule (seconds) +- `expectedBlocks`: `number` (required) — Expected blocks based on wall clock time since epoch start + +### `DifficultyEntry` + +- `time`: `Timestamp` (required) — Unix timestamp of the difficulty adjustment +- `height`: `Height` (required) — Block height of the adjustment +- `difficulty`: `number` (required) — Difficulty value +- `adjustment`: `number` (required) — Adjustment ratio (new/previous, e.g. 1.068 = +6.8%) + +### `DiskUsage` + +- `brk`: `string` (required) — Human-readable brk data size (e.g., "48.8 GiB") +- `brk_bytes`: `integer` (required) — brk data size in bytes +- `bitcoin`: `string` (required) — Human-readable Bitcoin blocks directory size +- `bitcoin_bytes`: `integer` (required) — Bitcoin blocks directory size in bytes +- `ratio`: `number` (required) — brk as percentage of Bitcoin data + +### `Dollars` + +`number` + +### `ExchangeRates` + +`object` + +### `FeeRate` + +`number` + +### `HashrateEntry` + +- `timestamp`: `Timestamp` (required) — Unix timestamp +- `avgHashrate`: `integer` (required) — Average hashrate (H/s) + +### `HashrateSummary` + +- `hashrates`: `HashrateEntry[]` (required) — Historical hashrate data points +- `difficulty`: `DifficultyEntry[]` (required) — Historical difficulty adjustments +- `currentHashrate`: `integer` (required) — Current network hashrate (H/s) +- `currentDifficulty`: `number` (required) — Current network difficulty + +### `Health` + +- `status`: `string` (required) — Health status ("healthy") +- `service`: `string` (required) — Service name +- `version`: `string` (required) — Server version +- `timestamp`: `string` (required) — Current server time (ISO 8601) +- `started_at`: `string` (required) — Server start time (ISO 8601) +- `uptime_seconds`: `integer` (required) — Uptime in seconds +- `indexed_height`: `Height` (required) — Height of the last indexed block +- `computed_height`: `Height` (required) — Height of the last computed block (series) +- `tip_height`: `Height` (required) — Height of the chain tip (from Bitcoin node) +- `blocks_behind`: `Height` (required) — Number of blocks behind the tip +- `last_indexed_at`: `string` (required) — Human-readable timestamp of the last indexed block (ISO 8601) +- `last_indexed_at_unix`: `Timestamp` (required) — Unix timestamp of the last indexed block + +### `Height` + +`integer` + +### `Hex` + +`string` + +### `HistoricalPrice` + +- `prices`: `HistoricalPriceEntry[]` (required) — Price data points +- `exchangeRates`: `ExchangeRates` (required) — Exchange rates (currently empty) + +### `HistoricalPriceEntry` + +- `time`: `Timestamp` (required) — Unix timestamp +- `USD`: `Dollars` (required) — BTC/USD price + +### `Index` + +`minute10 | minute30 | hour1 | hour4 | hour12 | day1 | day3 | week1 | month1 | month3 | month6 | year1 | year10 | halving | epoch | height | tx_index | txin_index | txout_index | empty_output_index | op_return_index | p2a_addr_index | p2ms_output_index | p2pk33_addr_index | p2pk65_addr_index | p2pkh_addr_index | p2sh_addr_index | p2tr_addr_index | p2wpkh_addr_index | p2wsh_addr_index | unknown_output_index | funded_addr_index | empty_addr_index` + +### `MempoolBlock` + +- `blockSize`: `integer` (required) — Total serialized block size in bytes (witness + non-witness). +- `blockVSize`: `number` (required) — Total block virtual size in vbytes +- `nTx`: `integer` (required) — Number of transactions in the projected block +- `totalFees`: `Sats` (required) — Total fees in satoshis +- `medianFee`: `FeeRate` (required) — Median fee rate in sat/vB +- `feeRange`: `FeeRate[]` (required) — Fee rate range: [min, 10%, 25%, 50%, 75%, 90%, max] + +### `MempoolInfo` + +- `count`: `integer` (required) — Number of transactions in the mempool +- `vsize`: `VSize` (required) — Total virtual size of all transactions in the mempool (vbytes) +- `total_fee`: `Sats` (required) — Total fees of all transactions in the mempool (satoshis) +- `fee_histogram`: `object` (required) — Fee histogram: `[[fee_rate, vsize], ...]` sorted by descending fee rate + +### `MerkleProof` + +- `block_height`: `Height` (required) — Block height containing the transaction +- `merkle`: `string[]` (required) — Merkle proof path (hex-encoded hashes) +- `pos`: `integer` (required) — Transaction position in the block (0-indexed) + +### `NextBlockHash` + +`integer` + +### `OutputType` + +`p2pk | p2pk | p2pkh | multisig | p2sh | op_return | v0_p2wpkh | v0_p2wsh | v1_p2tr | p2a | empty | unknown` + +### `PaginatedSeries` + +- `current_page`: `integer` (required) — Current page number (0-indexed) +- `max_page`: `integer` (required) — Maximum valid page index (0-indexed) +- `total_count`: `integer` (required) — Total number of series +- `per_page`: `integer` (required) — Results per page +- `has_more`: `boolean` (required) — Whether more pages are available after the current one +- `series`: `string[]` (required) — List of series names + +### `PoolBlockCounts` + +- `all`: `integer` (required) — Total blocks mined (all time) +- `24h`: `integer` (required) — Blocks mined in last 24 hours +- `1w`: `integer` (required) — Blocks mined in last week + +### `PoolBlockShares` + +- `all`: `number` (required) — Share of all blocks (0.0 - 1.0) +- `24h`: `number` (required) — Share of blocks in last 24 hours (0.0 - 1.0) +- `1w`: `number` (required) — Share of blocks in last week (0.0 - 1.0) + +### `PoolDetail` + +- `pool`: `PoolDetailInfo` (required) — Pool information +- `blockCount`: `PoolBlockCounts` (required) — Block counts for different time periods +- `blockShare`: `PoolBlockShares` (required) — Pool's share of total blocks for different time periods +- `estimatedHashrate`: `integer` (required) — Estimated hashrate based on blocks mined (H/s) +- `reportedHashrate`: `object` — Self-reported hashrate (if available, H/s) +- `totalReward`: `Sats | null` — Total reward earned by this pool (sats, all time; None for minor pools) + +### `PoolDetailInfo` + +- `id`: `integer` (required) — Pool identifier +- `name`: `string` (required) — Pool name +- `link`: `string` (required) — Pool website URL +- `addresses`: `string[]` (required) — Known payout addresses +- `regexes`: `string[]` (required) — Coinbase tag patterns (regexes) +- `slug`: `PoolSlug` (required) — URL-friendly pool identifier +- `unique_id`: `integer` (required) — Unique pool identifier + +### `PoolSlug` + +`unknown | blockfills | ultimuspool | terrapool | luxor | 1thash | btccom | bitfarms | huobipool | wayicn | canoepool | btctop | bitcoincom | 175btc | gbminers | axbt | asicminer | bitminter | bitcoinrussia | btcserv | simplecoinus | btcguild | eligius | ozcoin | eclipsemc | maxbtc | triplemining | coinlab | 50btc | ghashio | stminingcorp | bitparking | mmpool | polmine | kncminer | bitalo | f2pool | hhtt | megabigpower | mtred | nmcbit | yourbtcnet | givemecoins | braiinspool | antpool | multicoinco | bcpoolio | cointerra | kanopool | solock | ckpool | nicehash | bitclub | bitcoinaffiliatenetwork | btcc | bwpool | exxbw | bitsolo | bitfury | 21inc | digitalbtc | 8baochi | mybtccoinpool | tbdice | hashpool | nexious | bravomining | hotpool | okexpool | bcmonster | 1hash | bixin | tatmaspool | viabtc | connectbtc | batpool | waterhole | dcexploration | dcex | btpool | 58coin | bitcoinindia | shawnp0wers | phashio | rigpool | haozhuzhu | 7pool | miningkings | hashbx | dpool | rawpool | haominer | helix | bitcoinukraine | poolin | secretsuperstar | tigerpoolnet | sigmapoolcom | okpooltop | hummerpool | tangpool | bytepool | spiderpool | novablock | miningcity | binancepool | minerium | lubiancom | okkong | aaopool | emcdpool | foundryusa | sbicrypto | arkpool | purebtccom | marapool | kucoinpool | entrustcharitypool | okminer | titan | pegapool | btcnuggets | cloudhashing | digitalxmintsy | telco214 | btcpoolparty | multipool | transactioncoinmining | btcdig | trickysbtcpool | btcmp | eobot | unomp | patels | gogreenlight | bitcoinindiapool | ekanembtc | canoe | tiger | 1m1x | zulupool | secpool | ocean | whitepool | wiz | wk057 | futurebitapollosolo | carbonnegative | portlandhodl | phoenix | neopool | maxipool | bitfufupool | gdpool | miningdutch | publicpool | miningsquared | innopolistech | btclab | parasite | redrockpool | est3lar | braiinssolo | solopoolcom | noderunners` + +### `PoolStats` + +- `poolId`: `integer` (required) — Unique pool identifier +- `name`: `string` (required) — Pool name +- `link`: `string` (required) — Pool website URL +- `blockCount`: `integer` (required) — Number of blocks mined in the time period +- `rank`: `integer` (required) — Pool ranking by block count (1 = most blocks) +- `emptyBlocks`: `integer` (required) — Number of empty blocks mined +- `slug`: `PoolSlug` (required) — URL-friendly pool identifier +- `share`: `number` (required) — Pool's share of total blocks (0.0 - 1.0) +- `poolUniqueId`: `integer` (required) — Unique pool identifier + +### `PoolsSummary` + +- `pools`: `PoolStats[]` (required) — List of pools sorted by block count descending +- `blockCount`: `integer` (required) — Total blocks in the time period +- `lastEstimatedHashrate`: `integer` (required) — Estimated network hashrate (H/s) +- `lastEstimatedHashrate3d`: `integer` (required) — Estimated network hashrate over last 3 days (H/s) +- `lastEstimatedHashrate1w`: `integer` (required) — Estimated network hashrate over last 1 week (H/s) + +### `Prices` + +- `time`: `Timestamp` (required) — Unix timestamp +- `USD`: `Dollars` (required) — BTC/USD price + +### `RawLockTime` + +`integer` + +### `RbfResponse` + +- `replacements`: `ReplacementNode | null` +- `replaces`: `object` + +### `RbfTx` + +- `txid`: `Txid` (required) +- `fee`: `Sats` (required) +- `vsize`: `VSize` (required) +- `value`: `Sats` (required) — Sum of output amounts. +- `rate`: `FeeRate` (required) +- `time`: `Timestamp` (required) +- `rbf`: `boolean` (required) — BIP-125 signaling: at least one input has sequence < 0xffffffff-1. +- `fullRbf`: `object` — Only populated on the root `tx` of an RBF response. `true` iff this tx displaced at least one non-signaling predecessor. + +### `RecommendedFees` + +- `fastestFee`: `FeeRate` (required) — Fee rate for fastest confirmation (next block) +- `halfHourFee`: `FeeRate` (required) — Fee rate for confirmation within ~30 minutes (3 blocks) +- `hourFee`: `FeeRate` (required) — Fee rate for confirmation within ~1 hour (6 blocks) +- `economyFee`: `FeeRate` (required) — Fee rate for economical confirmation +- `minimumFee`: `FeeRate` (required) — Minimum relay fee rate + +### `ReplacementNode` + +- `tx`: `RbfTx` (required) +- `time`: `Timestamp` (required) — First-seen timestamp, duplicated here to match mempool.space's on-the-wire shape. +- `fullRbf`: `boolean` (required) — Any predecessor in this subtree was non-signaling. +- `interval`: `object` — Seconds between this node's `time` and the successor that replaced it. Omitted on the root of an RBF response. +- `mined`: `object` — `Some(true)` iff this node's tx is currently confirmed. Absent on serialization otherwise. +- `replaces`: `ReplacementNode[]` (required) + +### `RewardStats` + +- `startBlock`: `Height` (required) — First block in the range +- `endBlock`: `Height` (required) — Last block in the range +- `totalReward`: `Sats` (required) — Total coinbase rewards (subsidy + fees) in sats +- `totalFee`: `Sats` (required) — Total transaction fees in sats +- `totalTx`: `integer` (required) — Total number of transactions + +### `Sats` + +`integer` + +### `SeriesData` + +- `version`: `Version` (required) — Version of the series data +- `index`: `Index` (required) — The index type used for this query +- `type`: `string` — Value type (e.g. "f32", "u64", "Sats") +- `start`: `integer` (required) — Start index (inclusive) of the returned range +- `end`: `integer` (required) — End index (exclusive) of the returned range +- `stamp`: `string` (required) — ISO 8601 timestamp of when the response was generated +- `data`: `object[]` (required) — The series data + +### `SeriesInfo` + +- `indexes`: `Index[]` (required) — Available indexes +- `type`: `string` (required) — Value type (e.g. "f32", "u64", "Sats") + +### `SeriesLeafWithSchema` + +- `name`: `string` (required) — The series name/identifier +- `kind`: `string` (required) — The Rust type (e.g., "Sats", "StoredF64") +- `indexes`: `Index[]` (required) — Available indexes for this series +- `type`: `string` (required) — JSON Schema type (e.g., "integer", "number", "string", "boolean", "array", "object") + +### `SigOps` + +`integer` + +### `SyncStatus` + +- `indexed_height`: `Height` (required) — Height of the last indexed block +- `computed_height`: `Height` (required) — Height of the last computed block (series) +- `tip_height`: `Height` (required) — Height of the chain tip (from Bitcoin node) +- `blocks_behind`: `Height` (required) — Number of blocks behind the tip +- `last_indexed_at`: `string` (required) — Human-readable timestamp of the last indexed block (ISO 8601) +- `last_indexed_at_unix`: `Timestamp` (required) — Unix timestamp of the last indexed block + +### `Timestamp` + +`integer` + +### `Transaction` + +- `index`: `TxIndex | null` — Internal transaction index (brk-specific, not in mempool.space) +- `txid`: `Txid` (required) — Transaction ID +- `version`: `TxVersionRaw` (required) — Transaction version (raw i32 from Bitcoin protocol, may contain non-standard values in coinbase txs) +- `locktime`: `RawLockTime` (required) — Transaction lock time +- `vin`: `TxIn[]` (required) — Transaction inputs +- `vout`: `TxOut[]` (required) — Transaction outputs +- `size`: `integer` (required) — Transaction size in bytes +- `weight`: `Weight` (required) — Transaction weight +- `sigops`: `SigOps` (required) — Number of signature operations +- `fee`: `Sats` (required) — Transaction fee in satoshis +- `status`: `TxStatus` (required) — Confirmation status (confirmed, block height/hash/time) + +### `TreeNode` + +`object | SeriesLeafWithSchema` + +### `TxIn` + +- `txid`: `Txid` (required) — Transaction ID of the output being spent +- `vout`: `Vout` (required) — Output index being spent (u16: coinbase is 65535, mempool.space uses u32: 4294967295) +- `prevout`: `TxOut | null` — Information about the previous output being spent +- `scriptsig`: `string` (required) — Signature script (hex, for non-SegWit inputs) +- `scriptsig_asm`: `string` (required) — Signature script in assembly format +- `witness`: `Witness` (required) — Witness data (stack items, present for SegWit inputs; hex-encoded on the wire) +- `is_coinbase`: `boolean` (required) — Whether this input is a coinbase (block reward) input +- `sequence`: `integer` (required) — Input sequence number +- `inner_redeemscript_asm`: `string` (required) — Inner redeemscript in assembly (for P2SH-wrapped SegWit: scriptsig + witness both present) +- `inner_witnessscript_asm`: `string` (required) — Inner witnessscript in assembly (for P2WSH: last witness item decoded as script) + +### `TxIndex` + +`integer` + +### `TxOut` + +- `scriptpubkey`: `string` (required) — Script pubkey (locking script) +- `value`: `Sats` (required) — Value of the output in satoshis + +### `TxOutspend` + +- `spent`: `boolean` (required) — Whether the output has been spent +- `txid`: `Txid | null` — Transaction ID of the spending transaction (only present if spent) +- `vin`: `Vin | null` — Input index in the spending transaction (only present if spent) +- `status`: `TxStatus | null` — Status of the spending transaction (only present if spent) + +### `TxStatus` + +- `confirmed`: `boolean` (required) — Whether the transaction is confirmed +- `block_height`: `Height | null` — Block height (only present if confirmed) +- `block_hash`: `BlockHash | null` — Block hash (only present if confirmed) +- `block_time`: `Timestamp | null` — Block timestamp (only present if confirmed) + +### `TxVersionRaw` + +`integer` + +### `Txid` + +`string` + +### `TypeIndex` + +`integer` + +### `Urpd` + +- `cohort`: `Cohort` (required) +- `date`: `Date` (required) +- `aggregation`: `UrpdAggregation` (required) — Aggregation strategy applied to the buckets. +- `close`: `Dollars` (required) — Close price on `date`, in USD. Anchor for `unrealized_pnl`. +- `total_supply`: `Bitcoin` (required) — Sum of `supply` across all buckets, in BTC. +- `buckets`: `UrpdBucket[]` (required) + +### `UrpdAggregation` + +`raw | lin200 | lin500 | lin1000 | log10 | log50 | log100 | log200 | log500 | log1000 | log2000` + +### `UrpdBucket` + +- `price_floor`: `Dollars` (required) — Lower bound of the bucket, in USD. Equals the exact realized price for `Raw`. +- `supply`: `Bitcoin` (required) — Supply held with a last-move price inside this bucket, in BTC. +- `realized_cap`: `Dollars` (required) — Realized cap contribution in USD: sum of `realized_price * supply` over the coins in this bucket. +- `unrealized_pnl`: `Dollars` (required) — Unrealized P&L in USD against the close on the snapshot date: `close * supply - realized_cap`. Can be negative. + +### `VSize` + +`integer` + +### `Version` + +`integer` + +### `Vin` + +`integer` + +### `Vout` + +`integer` + +### `Weight` + +`integer` + +### `Witness` + +`string[]` -- JavaScript: https://www.npmjs.com/package/brk-client -- Python: https://pypi.org/project/brk-client/ -- Rust: https://crates.io/crates/brk_client diff --git a/website/llms.txt b/website/llms.txt index 0014d735c..39c9700fd 100644 --- a/website/llms.txt +++ b/website/llms.txt @@ -1,32 +1,26 @@ # Bitcoin Research Kit (BRK) -> Free, open-source Bitcoin on-chain analytics API at https://bitview.space. 49,000+ time-series (price, hashrate, supply, MVRV, HODL waves, and more), block explorer, address index, mempool stats, mining data. No auth required. JSON and CSV output. +> Free, open-source Bitcoin analytics API and block explorer. 55667 on-chain time-series and 97 API operations. No authentication required. -## API Documentation +## API -- [Full API reference (plain text)](https://bitview.space/llms-full.txt): Every endpoint, parameter, and response shape -- [OpenAPI spec (compact, LLM-optimized)](https://bitview.space/api.json): Machine-readable, minimal spec for tool use -- [OpenAPI spec (full)](https://bitview.space/openapi.json): Complete OpenAPI 3.1 specification -- [Interactive docs](https://bitview.space/api): Scalar API explorer +- Version: `v0.3.6` +- Base URL: https://bitview.space +- [Full plain-text reference](https://bitview.space/llms-full.txt) +- [Compact OpenAPI](https://bitview.space/api.json) +- [Full OpenAPI](https://bitview.space/openapi.json) +- [Series catalog](https://bitview.space/api/series) +- [Interactive documentation](https://bitview.space/api) -## Quick Start +Use OpenAPI for tool construction, `/api/series` for complete series metadata, and `llms-full.txt` for a readable reference. -- [Search series](https://bitview.space/api/series/search?q=price): `GET /api/series/search?q={query}` -- [Get series data](https://bitview.space/api/series/price/day?start=-30): `GET /api/series/{name}/{index}?start=-30` -- [Latest value](https://bitview.space/api/series/price/day/latest): `GET /api/series/{name}/{index}/latest` -- [Bulk query](https://bitview.space/api/series/bulk?index=day&series=price,market_cap&start=-7): `GET /api/series/bulk?index={index}&series={s1},{s2}` -- [Block by height](https://bitview.space/api/block-height/0): `GET /api/block-height/{height}` -- [Transaction](https://bitview.space/api/tx/4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b): `GET /api/tx/{txid}` -- [Address](https://bitview.space/api/address/1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa): `GET /api/address/{address}` -- [Fee estimates](https://bitview.space/api/v1/fees/recommended): `GET /api/v1/fees/recommended` -- [Live price](https://bitview.space/api/mempool/price): `GET /api/mempool/price` +## Clients -## Client Libraries - -- [JavaScript](https://www.npmjs.com/package/brk-client): npm install brk-client -- [Python](https://pypi.org/project/brk-client/): pip install brk-client -- [Rust](https://crates.io/crates/brk_client): cargo add brk_client +- [JavaScript](https://www.npmjs.com/package/brk-client) +- [Python](https://pypi.org/project/brk-client/) +- [Rust](https://crates.io/crates/brk_client) ## Source -- [GitHub](https://github.com/bitcoinresearchkit/brk): MIT licensed +- [GitHub](https://github.com/bitcoinresearchkit/brk) +- MIT licensed diff --git a/website_next/ask/compactor.js b/website_next/ask/compactor.js new file mode 100644 index 000000000..b22a11c98 --- /dev/null +++ b/website_next/ask/compactor.js @@ -0,0 +1,77 @@ +import { compactContext } from "./context.js"; + +/** @param {() => void} callback */ +function whenIdle(callback) { + return requestIdleCallback(callback); +} + +/** @param {number | undefined} scheduled */ +function cancelIdle(scheduled) { + if (scheduled !== undefined) cancelIdleCallback(scheduled); +} + +/** + * @param {Object} options + * @param {import("./model.js").AskModel} options.model + * @param {readonly unknown[]} options.tools + * @param {(id: string, update: NonNullable>>) => void} options.onCompacted + */ +export function createAskCompactor({ model, tools, onCompacted }) { + /** @type {import("./storage.js").StoredChat | undefined} */ + let pending; + /** @type {number | undefined} */ + let scheduled; + /** @type {{ controller: AbortController, promise: ReturnType } | undefined} */ + let active; + + function queue() { + if (!pending || scheduled || active) return; + scheduled = whenIdle(() => { + scheduled = undefined; + void run(); + }); + } + + async function run() { + const target = pending; + if (!target) return; + pending = undefined; + const controller = new AbortController(); + const promise = compactContext(target, model, tools, controller.signal); + const task = { controller, promise }; + active = task; + + try { + const update = await promise; + if (update) onCompacted(target.id, update); + } catch { + // Background memory is optional; the next foreground request remains usable. + } finally { + if (active === task) active = undefined; + queue(); + } + } + + function stop() { + pending = undefined; + cancelIdle(scheduled); + scheduled = undefined; + if (!active) return; + active.controller.abort(); + model.stop(); + } + + return { + /** @param {import("./storage.js").StoredChat} chat */ + schedule(chat) { + pending = chat; + queue(); + }, + async cancel() { + const running = active?.promise; + stop(); + await running?.catch(() => {}); + }, + stop, + }; +} diff --git a/website_next/ask/context.js b/website_next/ask/context.js index 623e3aa64..ea8773b19 100644 --- a/website_next/ask/context.js +++ b/website_next/ask/context.js @@ -4,6 +4,8 @@ const KEEP_RECENT_MESSAGES = 5; const BASE_INSTRUCTIONS = `You are the Bitview assistant for the BRK Bitcoin research project. Answer clearly and concisely. +If essential context is missing, ask one brief clarification instead of guessing. +BRK is the software repository, not a cryptocurrency or token. For BRK questions, current repository evidence supplied by tools is authoritative. Use only that evidence for source-defined behavior, APIs, terminology, and metrics. Mention relevant repository paths. In BRK source evidence, sats means Bitcoin satoshis, not products, sales, or shares. Do not reinterpret formulas or add financial meaning that the evidence does not establish. Use metric search before building charts. Build an inline chart when it materially helps answer a quantitative Bitcoin question.`; @@ -17,15 +19,15 @@ Return only the updated memory, using short factual bullet points.`; /** @typedef {import("./storage.js").StoredChat} StoredChat */ /** @typedef {import("./model.js").AskModel} AskModel */ -/** @param {StoredChat} chat */ -function messagesFor(chat) { +/** @param {StoredChat} chat @param {number} [start] */ +function messagesFor(chat, start = chat.compactedCount) { const instructions = chat.memory ? `${BASE_INSTRUCTIONS}\n\nEarlier conversation memory:\n${chat.memory}` : BASE_INSTRUCTIONS; return [ { role: /** @type {const} */ ("system"), content: instructions }, - ...chat.messages.slice(chat.compactedCount).map(({ role, content }) => ({ + ...chat.messages.slice(start).map(({ role, content }) => ({ role, content, })), @@ -54,36 +56,46 @@ function compactionPrompt(memory, messages) { * @param {StoredChat} chat * @param {AskModel} model * @param {readonly unknown[]} tools - * @param {() => void} onCompacting */ -export async function prepareContext(chat, model, tools, onCompacting) { +export async function prepareContext(chat, model, tools) { const messages = messagesFor(chat); const tokenCount = await model.countTokens(messages, tools); - const compactThrough = chat.messages.length - KEEP_RECENT_MESSAGES; + if (tokenCount <= MAX_INPUT_TOKENS) return { chat, messages }; + const recentStart = Math.max( + chat.compactedCount, + chat.messages.length - KEEP_RECENT_MESSAGES, + ); + return { chat, messages: messagesFor(chat, recentStart) }; +} + +/** + * @param {StoredChat} chat + * @param {AskModel} model + * @param {readonly unknown[]} tools + * @param {AbortSignal} signal + */ +export async function compactContext(chat, model, tools, signal) { + const tokenCount = await model.countTokens(messagesFor(chat), tools); + signal.throwIfAborted(); + const compactThrough = chat.messages.length - KEEP_RECENT_MESSAGES; if ( tokenCount <= MAX_INPUT_TOKENS || compactThrough <= chat.compactedCount - ) { - return { chat, messages, compacted: false }; - } + ) return undefined; - onCompacting(); const memory = await model.compact( compactionPrompt( chat.memory, chat.messages.slice(chat.compactedCount, compactThrough), ), ); - const compactedChat = { - ...chat, + signal.throwIfAborted(); + return { + messageCount: chat.messages.length, + previousCompactedCount: chat.compactedCount, + previousMemory: chat.memory, memory: memory.trim(), compactedCount: compactThrough, }; - - return { - chat: compactedChat, - messages: messagesFor(compactedChat), - compacted: true, - }; } diff --git a/website_next/ask/index.js b/website_next/ask/index.js index c329204fe..c9e46db5d 100644 --- a/website_next/ask/index.js +++ b/website_next/ask/index.js @@ -1,4 +1,5 @@ import { createAskComposer } from "./composer/index.js"; +import { createAskCompactor } from "./compactor.js"; import { prepareContext } from "./context.js"; import { createAskConversation } from "./conversation/index.js"; import { createAskHero } from "./hero/index.js"; @@ -27,6 +28,16 @@ export function createAskPage() { let checking = false; let loading = false; let followingOutput = false; + const compactor = createAskCompactor({ + model, + tools: assistant.toolsFor(), + onCompacted(id, update) { + const saved = askStorage.saveMemory(id, update); + if (!saved || chat.id !== id) return; + chat = saved; + workspace = { ...workspace, activeChat: saved }; + }, + }); const sidebarPreference = localStorage.getItem(SIDEBAR_PREFERENCE_KEY); let sidebarCollapsed = sidebarPreference === null ? workspace.chats.length <= 1 @@ -168,10 +179,12 @@ export function createAskPage() { setLoading(cached); try { - await model.load( + const modelLoading = model.load( (update) => loader.reportProgress(update, cached), (status) => loader.reportStatus(status, cached), ); + void assistant.prewarm().catch(() => {}); + await modelLoading; setReady(); composer.focus(); } catch (error) { @@ -200,8 +213,13 @@ export function createAskPage() { } } - function openNewChat() { + async function openNewChat() { if (busy) return; + busy = true; + syncControls(); + await compactor.cancel(); + if (main.inert) return; + busy = false; if (chat.messages.length) { const wasSingleChat = workspace.chats.length === 1; @@ -222,16 +240,21 @@ export function createAskPage() { if (loaded) { setReady(); composer.focus(); - } + } else syncControls(); } /** @param {string} id */ - function selectChat(id) { + async function selectChat(id) { if (busy || id === chat.id) { setSidebarOpen(false); syncSidebar(); return; } + busy = true; + syncControls(); + await compactor.cancel(); + if (main.inert) return; + busy = false; workspace = askStorage.select(id); chat = workspace.activeChat; @@ -250,11 +273,16 @@ export function createAskPage() { } /** @param {string} id */ - function removeChat(id) { + async function removeChat(id) { if (busy) return; const item = workspace.chats.find((candidate) => candidate.id === id); if (!item) return; + busy = true; + syncControls(); + await compactor.cancel(); + if (main.inert) return; + busy = false; workspace = askStorage.remove(id); chat = workspace.activeChat; @@ -285,13 +313,6 @@ export function createAskPage() { const questionMessage = conversation.append("user", question); const answerMessage = conversation.append("assistant", ""); const timer = createResponseTimer(answerMessage.setSteps); - const draft = { - ...chat, - messages: [ - ...chat.messages, - { role: /** @type {const} */ ("user"), content: question }, - ], - }; busy = true; followingOutput = true; @@ -303,7 +324,21 @@ export function createAskPage() { try { timer.set("Preparing context"); - const { output, artifacts, metricPaths, chat: preparedChat } = await assistant.answer({ + await compactor.cancel(); + const draft = { + ...chat, + messages: [ + ...chat.messages, + { role: /** @type {const} */ ("user"), content: question }, + ], + }; + const { + output, + artifacts = [], + metricPaths, + apiContext, + chat: preparedChat, + } = await assistant.answer({ chatId: chat.id, question, history: draft.messages, @@ -314,7 +349,6 @@ export function createAskPage() { draft, model, assistant.toolsFor(), - () => timer.set("Compacting conversation"), ); timer.set("Routing request"); return prepared; @@ -330,7 +364,8 @@ export function createAskPage() { }); const { elapsedMs, steps } = timer.finish(); - conversation.setContent(answerMessage.content, "assistant", output); + const response = output ?? ""; + conversation.setContent(answerMessage.content, "assistant", response); answerMessage.setArtifacts(artifacts); answerMessage.setElapsed(elapsedMs); if (followingOutput) conversation.scrollToBottom(); @@ -341,10 +376,11 @@ export function createAskPage() { ...answeredChat.messages, { role: /** @type {const} */ ("assistant"), - content: output, + content: response, elapsedMs, steps, metricPaths, + ...(apiContext ? { apiContext } : {}), ...(artifacts.length ? { artifacts } : {}), }, ], @@ -353,6 +389,7 @@ export function createAskPage() { renderChatList(); setReady(); composer.focus(); + compactor.schedule(chat); } catch (error) { questionMessage.item.remove(); answerMessage.item.remove(); @@ -383,6 +420,7 @@ export function createAskPage() { mobileSidebar.addEventListener("change", syncSidebar); main.addEventListener("pageactive", () => void activateModel()); main.addEventListener("pageinactive", () => { + compactor.stop(); model.terminate(); assistant.terminate(); setSidebarOpen(false); diff --git a/website_next/ask/model.js b/website_next/ask/model.js index 022f6d6ab..4fd0500d1 100644 --- a/website_next/ask/model.js +++ b/website_next/ask/model.js @@ -61,10 +61,11 @@ export class AskModel { * @param {(update: TokenUpdate) => void} onToken * @param {readonly unknown[]} [tools] * @param {ToolChoice} [toolChoice] + * @param {{ maxTokens?: number }} [options] */ - generate(messages, onToken, tools = [], toolChoice = "auto") { + generate(messages, onToken, tools = [], toolChoice = "auto", options = {}) { return /** @type {Promise} */ ( - this.#request("generate", messages, onToken, tools, toolChoice) + this.#request("generate", messages, onToken, tools, toolChoice, options) ); } @@ -105,8 +106,9 @@ export class AskModel { * @param {((update: TokenUpdate) => void) | undefined} [onToken] * @param {readonly unknown[]} [tools] * @param {ToolChoice} [toolChoice] + * @param {{ maxTokens?: number }} [options] */ - #request(type, messages, onToken, tools = [], toolChoice = "auto") { + #request(type, messages, onToken, tools = [], toolChoice = "auto", options = {}) { if (!this.#worker) throw new Error("Model is not loaded"); this.#onToken = onToken; @@ -115,7 +117,7 @@ export class AskModel { this.#reject = reject; this.#worker?.postMessage({ type, - data: { messages, tools, toolChoice }, + data: { messages, tools, toolChoice, ...options }, }); }); } diff --git a/website_next/ask/storage.js b/website_next/ask/storage.js index bb885c40e..ec69b3918 100644 --- a/website_next/ask/storage.js +++ b/website_next/ask/storage.js @@ -36,6 +36,11 @@ const CHART_COLORS = new Set([ * @property {StoredResponseStep[]} [steps] * @property {StoredArtifact[]} [artifacts] * @property {string[]} [metricPaths] + * @property {ApiContext} [apiContext] + * + * @typedef {Object} ApiContext + * @property {string} key + * @property {Record} arguments * * @typedef {Object} StoredResponseStep * @property {string} label @@ -140,6 +145,36 @@ function readResponseStep(value) { return { label: step.label, elapsedMs: step.elapsedMs }; } +/** @param {unknown} value @returns {ApiContext | undefined} */ +function readApiContext(value) { + if (!value || typeof value !== "object") return undefined; + const context = /** @type {Record} */ (value); + if (typeof context.key !== "string" || !context.key.startsWith("GET /")) return undefined; + const rawArguments = context.arguments; + if (!rawArguments || typeof rawArguments !== "object" || Array.isArray(rawArguments)) { + return { key: context.key, arguments: {} }; + } + const arguments_ = Object.fromEntries( + Object.entries(rawArguments) + .filter(([key, item]) => + key.length <= 64 && + ( + typeof item === "string" || + typeof item === "number" || + typeof item === "boolean" || + Array.isArray(item) && + item.every((part) => + typeof part === "string" || + typeof part === "number" || + typeof part === "boolean" + ) + ) + ) + .slice(0, 16), + ); + return /** @type {ApiContext} */ ({ key: context.key, arguments: arguments_ }); +} + /** @param {unknown} value @returns {StoredMessage | undefined} */ function readMessage(value) { if (!value || typeof value !== "object") return undefined; @@ -173,6 +208,7 @@ function readMessage(value) { (/** @type {unknown} */ path) => typeof path === "string" && path.trim(), )))].slice(0, 6) : []; + const apiContext = readApiContext(message.apiContext); return { role: message.role, content: message.content, @@ -180,6 +216,7 @@ function readMessage(value) { ...(steps.length ? { steps } : {}), ...(artifacts.length ? { artifacts } : {}), ...(hasMetricPaths ? { metricPaths } : {}), + ...(apiContext ? { apiContext } : {}), }; } @@ -353,6 +390,31 @@ function save(chat) { return { chats, activeChat: saved }; } +/** + * @param {string} id + * @param {{ messageCount: number, previousCompactedCount: number, previousMemory: string, memory: string, compactedCount: number }} update + */ +function saveMemory(id, update) { + const index = readIndex() ?? initialize(); + const meta = index.chats.find((chat) => chat.id === id); + if (!meta) return undefined; + + const chat = readChat(meta); + if ( + chat.messages.length !== update.messageCount || + chat.compactedCount !== update.previousCompactedCount || + chat.memory !== update.previousMemory + ) return undefined; + + const saved = { + ...chat, + memory: update.memory, + compactedCount: update.compactedCount, + }; + writeChat(saved); + return saved; +} + /** @param {string} id */ function remove(id) { const index = readIndex() ?? initialize(); @@ -377,5 +439,6 @@ export const askStorage = /** @type {const} */ ({ create, select, save, + saveMemory, remove, }); diff --git a/website_next/ask/tools/api/answer.js b/website_next/ask/tools/api/answer.js new file mode 100644 index 000000000..92d2f4cd5 --- /dev/null +++ b/website_next/ask/tools/api/answer.js @@ -0,0 +1,241 @@ +import { renderApiAnswer } from "../render.js"; +import { normalize } from "../text.js"; +import { focusApiData } from "./result.js"; + +const MAX_FIELDS = 64; + +/** + * @typedef {Object} ApiNumericField + * @property {string} ref + * @property {string} name + * @property {string} type + * @property {string} [description] + * @property {number} value + * + * @typedef {Object} ApiAnswerSpec + * @property {ApiNumericField[]} fields + * @property {any} tool + */ + +/** @param {unknown} value @param {string[]} path */ +function valueAt(value, path) { + let current = value; + for (const key of path) { + if (!current || typeof current !== "object" || !Object.hasOwn(current, key)) { + return undefined; + } + current = /** @type {Record} */ (current)[key]; + } + return current; +} + +/** @param {any} grounding @returns {ApiAnswerSpec} */ +export function createApiAnswerTool(grounding) { + const data = focusApiData(grounding.data, grounding.arguments); + const responseFields = /** @type {{ name: string, type: string, description?: string }[]} */ ( + grounding.operation.response.fields ?? [] + ); + const fields = responseFields + .map((field) => ({ + ...field, + value: valueAt(data, field.name.split(".")), + })) + .filter((field) => typeof field.value === "number") + .slice(0, MAX_FIELDS) + .map((field, index) => ({ + ...field, + value: /** @type {number} */ (field.value), + ref: `n${index + 1}`, + })); + const fieldDescription = fields + .map((field) => + `${field.ref}=${field.name} (${field.type}): ${field.value}${field.description ? ` — ${field.description}` : ""}` + ) + .join("; "); + + return { + fields, + tool: { + type: "function", + function: { + name: "answer_from_api", + description: "Answer only from verified API data. Use calculate whenever the requested numeric result combines fields.", + parameters: { + type: "object", + properties: { + action: { type: "string", enum: ["calculate", "answer"] }, + label: { + type: "string", + description: "Short user-facing name for a calculated result.", + }, + terms: { + type: "array", + minItems: 1, + maxItems: 12, + items: { + type: "object", + properties: { + ref: { + type: "string", + enum: fields.map(({ ref }) => ref), + description: `Verified numeric fields: ${fieldDescription}`, + }, + sign: { type: "string", enum: ["add", "subtract"] }, + }, + required: ["ref", "sign"], + additionalProperties: false, + }, + description: "Exact arithmetic expression, one signed term per source field.", + }, + text: { + type: "string", + description: "For answer only: concise answer copied or summarized from verified data, with no invented values.", + }, + }, + required: ["action"], + additionalProperties: false, + }, + }, + }, + }; +} + +/** @param {string} value */ +function words(value) { + return normalize(value).split(" ").filter(Boolean); +} + +/** + * @param {string} phrase + * @param {string} context + * @param {ApiNumericField} field + */ +function fieldScore(phrase, context, field) { + const name = normalize(field.name); + const description = normalize(field.description ?? ""); + const document = new Set(words(`${name} ${description}`)); + const phraseWords = words(phrase); + if (!phraseWords.length || !phraseWords.every((word) => document.has(word))) return 0; + + let score = phraseWords.reduce( + (sum, word) => sum + (new Set(words(name)).has(word) ? 8 : 3), + 0, + ); + const normalizedPhrase = normalize(phrase); + if (name.includes(normalizedPhrase)) score += 12; + if (description.includes(normalizedPhrase)) score += 5; + for (const word of new Set(words(context))) { + if (document.has(word)) score += name.includes(word) ? 2 : 1; + } + return score; +} + +/** + * Resolve only explicit two-operand subtraction from OpenAPI-derived numeric + * fields. Ambiguous matches fall back to the model. + * + * @param {string} question + * @param {ApiNumericField[]} fields + * @param {any} grounding + */ +export function directApiCalculation(question, fields, grounding) { + /** @type {{ left: string, right: string, context: string, label: string } | undefined} */ + let expression; + const minus = question.match(/^(.*?)(?:,\s*)?([^,;?.]+?)\s+minus\s+([^,;?.]+)[?.]*$/i); + if (minus) { + expression = { + context: minus[1], + left: minus[2], + right: minus[3], + label: `${minus[2].trim()} minus ${minus[3].trim()}`, + }; + } else { + const difference = question.match( + /^(.*?)\bdifference\s+between\s+([^,;?.]+?)\s+and\s+([^,;?.]+)[?.]*$/i, + ); + if (difference) { + expression = { + context: difference[1], + left: difference[2], + right: difference[3], + label: `difference between ${difference[2].trim()} and ${difference[3].trim()}`, + }; + } else { + const subtract = question.match( + /^(.*?)\bsubtract\s+([^,;?.]+?)\s+from\s+([^,;?.]+)[?.]*$/i, + ); + if (subtract) { + expression = { + context: subtract[1], + left: subtract[3], + right: subtract[2], + label: `${subtract[3].trim()} minus ${subtract[2].trim()}`, + }; + } + } + } + if (!expression) return undefined; + + /** @param {string} phrase */ + const select = (phrase) => { + const ranked = fields + .map((field) => ({ + field, + score: fieldScore(phrase, expression.context, field), + })) + .filter(({ score }) => score > 0) + .sort((left, right) => right.score - left.score); + if (!ranked.length || ranked[1]?.score === ranked[0].score) return undefined; + return ranked[0].field; + }; + const left = select(expression.left); + const right = select(expression.right); + if (!left || !right || left.ref === right.ref) return undefined; + + return finishApiAnswer( + { + action: "calculate", + label: expression.label, + terms: [ + { ref: left.ref, sign: "add" }, + { ref: right.ref, sign: "subtract" }, + ], + }, + fields, + grounding, + ); +} + +/** @param {Record} action @param {ApiNumericField[]} fields @param {any} grounding */ +export function finishApiAnswer(action, fields, grounding) { + if (action.action === "answer") { + const text = typeof action.text === "string" ? action.text.trim() : ""; + if (!text) throw new Error("The AI returned an empty API answer"); + return renderApiAnswer(text, grounding.operation); + } + if (action.action !== "calculate" || !Array.isArray(action.terms) || !action.terms.length) { + throw new Error("The AI returned an invalid API calculation"); + } + const byRef = new Map(fields.map((field) => [field.ref, field])); + const selected = action.terms.map((raw) => { + if (!raw || typeof raw !== "object") throw new Error("Invalid calculation term"); + const term = /** @type {Record} */ (raw); + const field = byRef.get(String(term.ref)); + if (!field) throw new Error("Unknown calculation field"); + if (term.sign !== "add" && term.sign !== "subtract") { + throw new Error("Invalid calculation sign"); + } + return { field, sign: term.sign }; + }); + const value = selected.reduce( + (sum, { field, sign }) => sum + (sign === "add" ? field.value : -field.value), + 0, + ); + const types = new Set(selected.map(({ field }) => field.type)); + const unit = types.size === 1 ? ` ${selected[0].field.type}` : ""; + const label = typeof action.label === "string" && action.label.trim() + ? action.label.trim() + : "result"; + const formatted = new Intl.NumberFormat("en-US", { maximumFractionDigits: 8 }).format(value); + return renderApiAnswer(`**${label}**: ${formatted}${unit}`, grounding.operation); +} diff --git a/website_next/ask/tools/api/execute.js b/website_next/ask/tools/api/execute.js new file mode 100644 index 000000000..5e834905f --- /dev/null +++ b/website_next/ask/tools/api/execute.js @@ -0,0 +1,127 @@ +import { brk } from "../../../utils/client.js"; + +const MAX_TEXT = 2_000; +const MAX_ARRAY = 8; +const ARRAY_SAMPLE = 4; +const MAX_KEYS = 64; +const MAX_DEPTH = 6; + +/** @param {unknown} value */ +function hasValue(value) { + return value !== undefined && value !== null && value !== ""; +} + +/** @param {unknown} value @param {number} depth @param {{ truncated: boolean }} state @returns {unknown} */ +function compact(value, depth, state) { + if (depth >= MAX_DEPTH) { + state.truncated = true; + return "[nested value omitted]"; + } + if (typeof value === "string" && value.length > MAX_TEXT) { + state.truncated = true; + return `${value.slice(0, MAX_TEXT)}…`; + } + if (Array.isArray(value)) { + if (value.length <= MAX_ARRAY) { + return value.map((item) => compact(item, depth + 1, state)); + } + state.truncated = true; + return { + count: value.length, + sample: value.slice(0, ARRAY_SAMPLE).map((item) => compact(item, depth + 1, state)), + }; + } + if (value && typeof value === "object") { + const entries = Object.entries(value); + if (entries.length > MAX_KEYS) state.truncated = true; + return Object.fromEntries( + entries.slice(0, MAX_KEYS).map(([key, item]) => [ + key, + compact(item, depth + 1, state), + ]), + ); + } + return value; +} + +/** @param {unknown} value @param {import("./index.js").ApiParameter} parameter */ +function parameterValue(value, parameter) { + const type = parameter.valueType ?? parameter.type; + if (type.includes("integer")) { + const number = Number(value); + if (!Number.isInteger(number)) throw new Error(`${parameter.name} must be an integer`); + return String(number); + } + if (type.includes("number")) { + const number = Number(value); + if (!Number.isFinite(number)) throw new Error(`${parameter.name} must be a number`); + return String(number); + } + if (type.includes("boolean")) { + if (value !== true && value !== false && value !== "true" && value !== "false") { + throw new Error(`${parameter.name} must be true or false`); + } + return String(value); + } + const string = Array.isArray(value) ? value.join(",") : String(value); + if (parameter.enum?.length && !parameter.enum.map(String).includes(string)) { + throw new Error(`${parameter.name} must be one of: ${parameter.enum.join(", ")}`); + } + return string; +} + +/** + * @param {import("./index.js").ApiOperation} operation + * @param {Record} arguments_ + * @param {AbortSignal} signal + */ +export async function executeApi(operation, arguments_, signal) { + if (operation.method !== "GET" || !operation.path.startsWith("/")) { + throw new Error("Only generated read-only API operations are allowed"); + } + const allowed = new Set(operation.parameters.map((parameter) => parameter.name)); + for (const key of Object.keys(arguments_)) { + if (!allowed.has(key)) throw new Error(`Unexpected API parameter: ${key}`); + } + + let path = operation.path; + const query = new URLSearchParams(); + for (const parameter of operation.parameters) { + const value = arguments_[parameter.name]; + if (!hasValue(value)) { + if (parameter.required) throw new Error(`${parameter.name} is required`); + continue; + } + const encoded = parameterValue(value, parameter); + if (parameter.in === "path") { + path = path.replace(`{${parameter.name}}`, encodeURIComponent(encoded)); + } else if (parameter.in === "query") { + query.set(parameter.name, encoded); + } + } + if (/\{[^}]+\}/.test(path)) throw new Error("A required path parameter is missing"); + if (query.size) path += `?${query}`; + + const response = await brk.get(path, { signal }); + const contentType = response.headers.get("content-type") ?? operation.response.contentType; + const raw = contentType.includes("json") ? await response.json() : await response.text(); + const state = { truncated: false }; + const data = compact(raw, 0, state); + return { + operation: { + key: operation.key, + method: operation.method, + path: operation.path, + summary: operation.summary || operation.label, + description: operation.description, + parameters: operation.parameters, + response: operation.response, + }, + arguments: Object.fromEntries( + Object.entries(arguments_).filter(([, value]) => hasValue(value)), + ), + requestPath: path, + data, + truncated: state.truncated, + }; +} diff --git a/website_next/ask/tools/api/index.js b/website_next/ask/tools/api/index.js new file mode 100644 index 000000000..c0c97fc38 --- /dev/null +++ b/website_next/ask/tools/api/index.js @@ -0,0 +1,104 @@ +import { BRK_BASE_URL } from "../../../utils/client.js"; + +const WORKER_URL = import.meta.resolve("./worker.js"); +const OPENAPI_URL = `${BRK_BASE_URL}/openapi.json`; + +/** + * @typedef {Object} ApiParameter + * @property {string} name + * @property {"path" | "query"} in + * @property {boolean} required + * @property {string} type + * @property {string} [valueType] + * @property {unknown[]} [enum] + * @property {string} description + * + * @typedef {Object} ApiOperation + * @property {string} key + * @property {"GET"} method + * @property {string} path + * @property {string} label + * @property {string} summary + * @property {string} description + * @property {ApiParameter[]} parameters + * @property {{ contentType: string, type: string, description: string, fields: { name: string, type: string, required: boolean, description: string }[] }} response + * @property {string} [matchedQuery] + * @property {number} [matchedTerms] + * @property {number} [score] + */ + +class ApiIndex { + /** @type {Worker | undefined} */ + #worker; + + /** @type {Map void, reject: (error: Error) => void, onProgress?: () => void }>} */ + #pending = new Map(); + + /** @param {"prewarm" | "search" | "byKey"} type @param {Record} data @param {(() => void) | undefined} [onProgress] */ + request(type, data, onProgress) { + this.#ensureWorker(); + const id = crypto.randomUUID(); + return new Promise((resolve, reject) => { + this.#pending.set(id, { resolve, reject, onProgress }); + this.#worker?.postMessage({ id, type, data: { ...data, url: OPENAPI_URL } }); + }); + } + + #ensureWorker() { + if (this.#worker) return; + this.#worker = new Worker(WORKER_URL, { type: "module" }); + this.#worker.addEventListener("message", this.#handleMessage); + this.#worker.addEventListener("error", this.#handleError); + } + + terminate() { + const error = new Error("API search stopped"); + for (const request of this.#pending.values()) request.reject(error); + this.#pending.clear(); + this.#worker?.terminate(); + this.#worker = undefined; + } + + /** @param {MessageEvent} event */ + #handleMessage = (event) => { + const message = event.data; + const request = this.#pending.get(message.id); + if (!request) return; + if (message.status === "progress") { + request.onProgress?.(); + return; + } + this.#pending.delete(message.id); + if (message.status === "complete") request.resolve(message.data); + else request.reject(new Error(message.data)); + }; + + /** @param {ErrorEvent} event */ + #handleError = (event) => { + const error = new Error(event.message || "The API index failed"); + for (const request of this.#pending.values()) request.reject(error); + this.#pending.clear(); + this.#worker?.terminate(); + this.#worker = undefined; + }; +} + +const index = new ApiIndex(); + +export function prewarmApiIndex() { + return index.request("prewarm", {}); +} + +/** @param {string[]} queries @param {number} [limit] @param {(() => void) | undefined} [onProgress] @returns {Promise} */ +export function searchApi(queries, limit = 8, onProgress) { + return index.request("search", { queries, limit }, onProgress); +} + +/** @param {string} key @returns {Promise} */ +export function apiByKey(key) { + return index.request("byKey", { key }); +} + +export function terminateApiIndex() { + index.terminate(); +} diff --git a/website_next/ask/tools/api/openapi.js b/website_next/ask/tools/api/openapi.js new file mode 100644 index 000000000..f330d9afe --- /dev/null +++ b/website_next/ask/tools/api/openapi.js @@ -0,0 +1,201 @@ +const MAX_DESCRIPTION = 600; +const MAX_FIELD_DESCRIPTION = 240; +const MAX_PARAMETER_DESCRIPTION = 300; +const MAX_FIELDS = 32; + +/** @param {unknown} value @returns {value is Record} */ +function isObject(value) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +/** @param {Record} spec @param {string} reference */ +function resolveRef(spec, reference) { + if (!reference.startsWith("#/")) return undefined; + return reference + .slice(2) + .split("/") + .map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~")) + .reduce((value, key) => isObject(value) ? value[key] : undefined, spec); +} + +/** @param {Record} spec @param {unknown} value @returns {Record} */ +function dereference(spec, value) { + if (!isObject(value)) return {}; + const resolved = typeof value.$ref === "string" + ? resolveRef(spec, value.$ref) + : undefined; + if (isObject(resolved)) return resolved; + if (Array.isArray(value.allOf) && value.allOf.length === 1) { + return dereference(spec, value.allOf[0]); + } + return value; +} + +/** @param {unknown} schema @returns {string} */ +function schemaName(schema) { + if (!isObject(schema)) return "value"; + if (typeof schema.$ref === "string") return schema.$ref.split("/").at(-1) || "value"; + for (const key of ["allOf", "oneOf", "anyOf"]) { + if (Array.isArray(schema[key])) return schema[key].map(schemaName).join(" | "); + } + if (schema.type === "array") return `${schemaName(schema.items)}[]`; + if (Array.isArray(schema.type)) { + return schema.type.filter((value) => typeof value === "string").join(" | "); + } + if (typeof schema.type === "string") return schema.type; + if (Array.isArray(schema.enum)) { + return schema.enum + .map((value) => typeof value === "string" ? value : JSON.stringify(value)) + .join(" | "); + } + return "value"; +} + +/** @param {unknown} value @param {number} limit */ +function compactText(value, limit) { + if (typeof value !== "string") return ""; + const text = value.split(/\s+/).filter(Boolean).join(" "); + const characters = [...text]; + return characters.length <= limit + ? text + : `${characters.slice(0, Math.max(0, limit - 1)).join("")}…`; +} + +/** + * @param {Record} spec + * @param {Record} shape + * @param {string} prefix + * @param {number} depth + * @returns {{ name: string, type: string, required: boolean, description: string }[]} + */ +function schemaFields(spec, shape, prefix = "", depth = 0, context = "") { + const required = new Set(Array.isArray(shape.required) ? shape.required : []); + const fields = []; + + for (const [name, raw] of Object.entries( + isObject(shape.properties) ? shape.properties : {}, + )) { + const resolved = dereference(spec, raw); + const path = prefix ? `${prefix}.${name}` : name; + const ownDescription = compactText( + isObject(raw) && raw.description !== undefined + ? raw.description + : resolved.description, + MAX_FIELD_DESCRIPTION, + ); + const description = compactText( + [context, ownDescription].filter(Boolean).join(". "), + MAX_FIELD_DESCRIPTION, + ); + fields.push({ + name: path, + type: schemaName(raw), + required: required.has(name), + description, + }); + if (depth < 1 && isObject(resolved.properties)) { + fields.push(...schemaFields(spec, resolved, path, depth + 1, description)); + } + if (fields.length >= MAX_FIELDS) break; + } + return fields.slice(0, MAX_FIELDS); +} + +/** + * @param {Record} spec + * @param {unknown} raw + * @returns {import("./index.js").ApiParameter | undefined} + */ +function parameterDetails(spec, raw) { + const parameter = dereference(spec, raw); + if (typeof parameter.name !== "string" || typeof parameter.in !== "string") { + return undefined; + } + const location = parameter.in; + if (location !== "path" && location !== "query") return undefined; + const rawSchema = isObject(parameter.schema) ? parameter.schema : {}; + const schema = dereference(spec, rawSchema); + return { + name: parameter.name, + in: location, + required: location === "path" || parameter.required === true, + type: schemaName(rawSchema), + valueType: schemaName(schema), + ...(Array.isArray(schema.enum) ? { enum: schema.enum.slice(0, 64) } : {}), + description: compactText( + parameter.description !== undefined ? parameter.description : schema.description, + MAX_PARAMETER_DESCRIPTION, + ), + }; +} + +/** @param {Record} spec @param {Record} operation */ +function responseDetails(spec, operation) { + if (!isObject(operation.responses)) return undefined; + const raw = operation.responses["200"] ?? + Object.entries(operation.responses) + .find(([status]) => status.length === 3 && /^2\d\d$/.test(status))?.[1]; + const response = dereference(spec, raw); + if (!isObject(response.content)) return undefined; + const content = Object.entries(response.content); + const selected = content.find(([type]) => type.includes("json")) ?? + content.find(([type]) => type.startsWith("text/")); + if (!selected) return undefined; + + const [contentType, media] = selected; + const rawSchema = isObject(media) && isObject(media.schema) ? media.schema : {}; + const schema = dereference(spec, rawSchema); + const itemSchema = schema.type === "array" ? schema.items : undefined; + const shape = dereference(spec, itemSchema ?? schema); + return { + contentType, + type: schemaName(rawSchema), + description: compactText(shape.description, MAX_DESCRIPTION), + fields: schemaFields(spec, shape), + }; +} + +/** + * Convert BRK's OpenAPI source of truth into the flat read-only operations + * consumed by the browser search index. + * + * @param {unknown} value + * @returns {import("./index.js").ApiOperation[]} + */ +export function operationsFromOpenApi(value) { + if (!isObject(value) || !isObject(value.paths)) { + throw new Error("Unsupported OpenAPI document"); + } + /** @type {import("./index.js").ApiOperation[]} */ + const operations = []; + for (const [path, pathItem] of Object.entries(value.paths)) { + if (!isObject(pathItem) || !isObject(pathItem.get) || pathItem.get.deprecated === true) { + continue; + } + const operation = pathItem.get; + const response = responseDetails(value, operation); + if (!response) continue; + /** @type {import("./index.js").ApiParameter[]} */ + const parameters = []; + for (const raw of [ + ...(Array.isArray(pathItem.parameters) ? pathItem.parameters : []), + ...(Array.isArray(operation.parameters) ? operation.parameters : []), + ]) { + const parameter = parameterDetails(value, raw); + if (parameter) parameters.push(parameter); + } + const summary = compactText(operation.summary, MAX_DESCRIPTION); + const key = `GET ${path}`; + operations.push({ + key, + method: "GET", + path, + label: summary || key, + summary, + description: compactText(operation.description, MAX_DESCRIPTION), + parameters, + response, + }); + } + return operations.sort((left, right) => left.path.localeCompare(right.path)); +} diff --git a/website_next/ask/tools/api/result.js b/website_next/ask/tools/api/result.js new file mode 100644 index 000000000..3a2c017bf --- /dev/null +++ b/website_next/ask/tools/api/result.js @@ -0,0 +1,32 @@ +/** @param {unknown} value */ +function isObject(value) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +/** @param {unknown} left @param {unknown} right */ +function equalValue(left, right) { + return String(left).trim() === String(right).trim(); +} + +/** + * Some APIs return a page containing the requested resource. When a unique + * item carries the same source-derived parameter name and value, focus that + * item before selecting response fields. + * + * @param {unknown} data + * @param {Record} [arguments_] + */ +export function focusApiData(data, arguments_ = {}) { + if (!Array.isArray(data)) return data; + if (data.length === 1) return data[0]; + + const supplied = Object.entries(arguments_); + if (!supplied.length) return data; + const matches = data.filter((item) => + isObject(item) && + supplied.every(([name, value]) => + Object.hasOwn(item, name) && equalValue(item[name], value) + ) + ); + return matches.length === 1 ? matches[0] : data; +} diff --git a/website_next/ask/tools/api/worker.js b/website_next/ask/tools/api/worker.js new file mode 100644 index 000000000..99b07e090 --- /dev/null +++ b/website_next/ask/tools/api/worker.js @@ -0,0 +1,218 @@ +import { QuickMatch, QuickMatchConfig } from "../../../modules/quickmatch-js/0.5.0/src/index.js"; +import { operationsFromOpenApi } from "./openapi.js"; + +const SEARCH_CANDIDATES = 256; + +/** + * @typedef {import("./index.js").ApiOperation} ApiOperation + * @typedef {ApiOperation & { document: string, tokens: string[], titleTokens: string[] }} IndexedOperation + */ + +/** @param {string} value */ +function searchable(value) { + return value + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/[_./{}|:-]+/g, " ") + .replace(/\s+/g, " ") + .trim() + .toLowerCase(); +} + +/** @param {ApiOperation} operation @returns {IndexedOperation} */ +function indexOperation(operation) { + const { summary, description, parameters, response } = operation; + const fields = response.fields + .flatMap((field) => [field.name, field.description]) + .filter(Boolean) + .join(" "); + const parameterText = parameters + .flatMap((parameter) => [ + parameter.name, + parameter.type, + parameter.valueType, + parameter.description, + ...(parameter.enum ?? []), + ]) + .filter(Boolean) + .join(" "); + const titleDocument = searchable( + `${operation.key} ${summary} ${parameters.flatMap((parameter) => [parameter.name, parameter.type]).join(" ")}`, + ); + const document = searchable( + `${operation.key} ${summary} ${description} ${parameterText} ${response.type} ${response.description} ${fields}`, + ); + return { + ...operation, + document, + tokens: [...new Set(document.split(" ").filter(Boolean))], + titleTokens: [...new Set(titleDocument.split(" ").filter(Boolean))], + }; +} + +/** @param {IndexedOperation} operation @returns {ApiOperation} */ +function publicOperation(operation) { + const { document, tokens, titleTokens, ...value } = operation; + return value; +} + +/** @param {string} url */ +async function buildState(url) { + const response = await fetch(url); + if (!response.ok) throw new Error(`OpenAPI unavailable (${response.status})`); + const operations = operationsFromOpenApi(await response.json()).map(indexOperation); + operations.sort((left, right) => left.path.localeCompare(right.path)); + const config = new QuickMatchConfig() + .withLimit(SEARCH_CANDIDATES) + .withTrigramBudget(0) + .withMinScore(2) + .withSeparators("_- :/.|{}"); + const byDocument = new Map(operations.map((operation) => [operation.document, operation])); + const byKey = new Map(operations.map((operation) => [operation.key, operation])); + const documentFrequency = new Map(); + for (const operation of operations) { + for (const token of operation.tokens) { + documentFrequency.set(token, (documentFrequency.get(token) ?? 0) + 1); + } + } + const matcher = new QuickMatch(operations.map((operation) => operation.document), config); + return { operations, config, byDocument, byKey, documentFrequency, matcher }; +} + +/** @type {Promise>> | undefined} */ +let statePromise; +let stateUrl = ""; + +/** @param {string} id @param {string} url */ +function state(id, url) { + if (!statePromise || url !== stateUrl) { + stateUrl = url; + self.postMessage({ id, status: "progress" }); + statePromise = buildState(url); + } + return statePromise; +} + +/** @param {Awaited>} index @param {string} query @param {number} limit */ +function searchOne(index, query, limit) { + const normalized = searchable(query) + .split(" ") + .filter((word) => word.length < 32) + .join(" "); + if (!normalized) return []; + const words = [...new Set(normalized.split(" ").filter(Boolean))]; + const full = index.matcher.matchesWith( + normalized, + index.config.withLimit(SEARCH_CANDIDATES), + ); + const fullRanks = new Map(full.map((document, rank) => [document, rank])); + const lexical = index.operations + .map((operation) => { + const tokens = new Set(operation.tokens); + const titleTokens = new Set(operation.titleTokens); + let score = 0; + let matched = 0; + for (const word of words) { + if (!tokens.has(word)) continue; + matched += 1; + const frequency = index.documentFrequency.get(word) ?? index.operations.length; + const idf = Math.log((index.operations.length + 1) / (frequency + 1)) + 1; + score += idf * (titleTokens.has(word) ? 3 : 1); + } + return { operation, matched, score }; + }) + .filter(({ matched }) => matched > 0) + .sort((left, right) => + right.score - left.score || + right.matched - left.matched || + (fullRanks.get(left.operation.document) ?? SEARCH_CANDIDATES) - + (fullRanks.get(right.operation.document) ?? SEARCH_CANDIDATES) || + left.operation.path.localeCompare(right.operation.path) + ); + if (lexical.length) { + return lexical.slice(0, limit).map(({ operation, matched, score }, rank) => ({ + ...publicOperation(operation), + matchedQuery: query, + matchedTerms: matched, + score: Math.round(score * 1_000) - rank, + })); + } + + const scores = new Map(); + for (const word of words) { + if (!word) continue; + const matches = index.matcher.matchesWith( + word, + index.config.withLimit(SEARCH_CANDIDATES), + ); + for (const [rank, document] of matches.entries()) { + const score = scores.get(document) ?? { matched: 0, ranks: 0 }; + score.matched += 1; + score.ranks += rank; + scores.set(document, score); + } + } + return [...new Set([...full, ...scores.keys()])] + .sort((left, right) => { + const a = scores.get(left) ?? { matched: 0, ranks: SEARCH_CANDIDATES }; + const b = scores.get(right) ?? { matched: 0, ranks: SEARCH_CANDIDATES }; + return b.matched - a.matched || + (fullRanks.get(left) ?? SEARCH_CANDIDATES) - + (fullRanks.get(right) ?? SEARCH_CANDIDATES) || + a.ranks - b.ranks || + left.localeCompare(right); + }) + .slice(0, limit) + .map((document, rank) => ({ + ...publicOperation( + /** @type {IndexedOperation} */ (index.byDocument.get(document)), + ), + matchedQuery: query, + matchedTerms: 0, + score: 1_000 - rank, + })); +} + +/** @param {Awaited>} index @param {string[]} queries @param {number} limit */ +function search(index, queries, limit) { + const groups = queries.map((query) => searchOne(index, query, limit)); + const output = []; + const seen = new Set(); + for (let rank = 0; output.length < limit; rank += 1) { + let added = false; + for (const group of groups) { + const operation = group[rank]; + if (!operation || seen.has(operation.key)) continue; + seen.add(operation.key); + output.push(operation); + added = true; + if (output.length === limit) break; + } + if (!added) break; + } + return output; +} + +self.addEventListener("message", async (event) => { + const { id, type, data } = event.data; + try { + const index = await state(id, data.url); + let result; + if (type === "prewarm") { + result = true; + } else if (type === "search") { + result = search(index, data.queries, data.limit); + } else if (type === "byKey") { + const operation = index.byKey.get(data.key); + result = operation ? publicOperation(operation) : undefined; + } else { + throw new Error(`Unknown API request: ${type}`); + } + self.postMessage({ id, status: "complete", data: result }); + } catch (error) { + self.postMessage({ + id, + status: "error", + data: error instanceof Error ? error.message : String(error), + }); + } +}); diff --git a/website_next/ask/tools/chart/units.js b/website_next/ask/tools/chart/units.js new file mode 100644 index 000000000..3393dd517 --- /dev/null +++ b/website_next/ask/tools/chart/units.js @@ -0,0 +1,19 @@ +/** + * @param {{ suggestedUnit?: string }[]} metrics + * @param {string | undefined} existingUnit + * @param {string} operation + */ +export function resolveChartUnit(metrics, existingUnit, operation) { + const units = metrics.flatMap((metric) => + metric.suggestedUnit ? [metric.suggestedUnit] : [] + ); + if (existingUnit && operation !== "replace" && operation !== "remove") { + units.unshift(existingUnit); + } + + const distinct = [...new Set(units)]; + return { + unit: operation === "remove" ? existingUnit : distinct[0] ?? existingUnit, + conflicts: distinct.length > 1 ? distinct : [], + }; +} diff --git a/website_next/ask/tools/data.js b/website_next/ask/tools/data.js index 7c6e5e375..b7570d29c 100644 --- a/website_next/ask/tools/data.js +++ b/website_next/ask/tools/data.js @@ -2,19 +2,6 @@ import { brk } from "../../utils/client.js"; const RANGE_POINTS = 120; -/** @type {Map>} */ -const infoCache = new Map(); - -/** @param {string} name */ -function seriesInfo(name) { - let request = infoCache.get(name); - if (!request) { - request = brk.getSeriesInfo(name); - infoCache.set(name, request); - } - return request; -} - /** @param {string} type */ export function unitFromType(type) { const value = type.toLowerCase(); @@ -45,21 +32,21 @@ export function formatValue(value, unit) { return affix === "$" ? `$${number}` : `${number}${affix}`; } -/** @param {{ name: string, suggestedUnit?: string }} metric @param {Record} action */ +/** @param {{ name: string, indexes: string[], type: string, suggestedUnit?: string }} metric @param {Record} action */ export async function readMetric(metric, action) { - const info = await seriesInfo(metric.name); const rawIndex = typeof action.index === "string" ? action.index : ""; const indexLooksLikeValue = /^-?\d+$/.test(rawIndex) || /^\d{4}-\d{2}-\d{2}$/.test(rawIndex); const at = action.at ?? (indexLooksLikeValue ? rawIndex : undefined); const dateLike = typeof at === "string" && !/^-?\d+$/.test(at); const preferredIndex = indexLooksLikeValue ? "" : rawIndex; - const index = info.indexes.includes(preferredIndex) + const index = metric.indexes.includes(preferredIndex) ? preferredIndex - : dateLike && info.indexes.includes("day1") + : dateLike && metric.indexes.includes("day1") ? "day1" - : info.indexes.includes("height") + : metric.indexes.includes("height") ? "height" - : info.indexes[0]; + : metric.indexes[0]; + if (!index) throw new Error(`No supported index for ${metric.name}`); const mode = typeof action.mode === "string" ? action.mode : "latest"; let response; @@ -97,7 +84,7 @@ export async function readMetric(metric, action) { return { name: metric.name, label: metric.name.replaceAll("_", " "), - unit: metric.suggestedUnit ?? unitFromType(info.type), + unit: metric.suggestedUnit ?? unitFromType(metric.type), index: response.index, start: response.start, end: response.end, diff --git a/website_next/ask/tools/direct/chart.js b/website_next/ask/tools/direct/chart.js index 8914c7417..21380d3a2 100644 --- a/website_next/ask/tools/direct/chart.js +++ b/website_next/ask/tools/direct/chart.js @@ -1,6 +1,7 @@ import { normalize } from "../text.js"; -const CHART_REQUEST = /\b(?:chart|graph|plot)\b/; +const CHART_REQUEST = + /\b(?:chart|graph|plot|trend|visualize|visualise)\b|\b(?:over|through)\s+time\b|\btime\s+series\b/; const ADD_REQUEST = /\b(?:add|include|overlay)\b/; const REMOVE_REQUEST = /\b(?:remove|drop)\b/; diff --git a/website_next/ask/tools/direct/evidence.js b/website_next/ask/tools/direct/evidence.js index 2fad40d55..a9d8f2b17 100644 --- a/website_next/ask/tools/direct/evidence.js +++ b/website_next/ask/tools/direct/evidence.js @@ -2,11 +2,18 @@ import { normalize } from "../text.js"; const VARIANTS = /\b(?:availability|available|cohorts?|variants?)\b/; const IMPLEMENTATION = /\b(?:calculated?|calculation|code|formula|implemented?|implementation|source)\b/; +const PRODUCT = /\b(?:bitview|brk)\b/; +const PRODUCT_QUESTION = /^(?:how|what|where|which|why)\b/; +const DATA_REQUEST = + /\b(?:chart|current|graph|historical|history|latest|now|plot|today|trend|value|visualize|visualise)\b|\b(?:over|through)\s+time\b/; /** @param {string} request */ export function directEvidenceFocus(request) { const text = normalize(request); if (IMPLEMENTATION.test(text)) return "implementation"; if (VARIANTS.test(text)) return "variants"; + if (PRODUCT.test(text) && PRODUCT_QUESTION.test(text) && !DATA_REQUEST.test(text)) { + return "implementation"; + } return undefined; } diff --git a/website_next/ask/tools/index.js b/website_next/ask/tools/index.js index 9d97e47c8..138673bc7 100644 --- a/website_next/ask/tools/index.js +++ b/website_next/ask/tools/index.js @@ -1,9 +1,69 @@ import { searchTool } from "./schemas.js"; import { AskToolSession } from "./session.js"; import { AskSource } from "./source/index.js"; -import { terminateMetricIndex } from "./metrics/index.js"; +import { + createApiAnswerTool, + directApiCalculation, + finishApiAnswer, +} from "./api/answer.js"; +import { prewarmApiIndex, terminateApiIndex } from "./api/index.js"; +import { prewarmMetricIndex, terminateMetricIndex } from "./metrics/index.js"; +import { renderDirectApiAnswer, renderEvidence } from "./render.js"; const MAX_TOOL_ROUNDS = 8; +const GATE_PROMPT = `You are the front door for Bitview's local Bitcoin assistant. +Respond directly in at most 60 words when ordinary Bitcoin knowledge, conversation, or writing is enough. +When an essential subject or previous topic is missing, output only one clarification question of at most 15 words. Never guess it, explain possibilities, or list examples. +If and only if the request needs current or historical Bitview data, a concrete public blockchain record, server/API state, metric lookup, charts, cohorts, variants, or BRK repository evidence, return exactly: +TOOLS +BRK is software, not a cryptocurrency or token. + +Examples: +User: Which holder group? +Assistant: Which metric or Bitcoin concept do you mean? +User: Why does Bitcoin have a fixed supply? +Assistant: Bitcoin's consensus rules cap issuance at 21 million BTC. The block subsidy halves roughly every four years, so new issuance declines until the cap is approached. +User: Chart capitalized price. +Assistant: TOOLS +User: What fee did transaction 4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b pay? +Assistant: TOOLS`; + +/** @param {import("../model.js").AskModel} model @param {import("../model.js").ChatMessage[]} messages */ +async function useFrontDoor(model, messages) { + const [, ...dialogue] = messages; + const result = await model.generate( + [ + { + role: "system", + content: GATE_PROMPT, + }, + ...dialogue, + ], + () => {}, + [], + "none", + { maxTokens: 96 }, + ); + const text = result.text.trim(); + return text === "TOOLS" + ? { kind: "tools" } + : { kind: "answer", text }; +} + +/** @param {AskToolSession} session */ +function modelStatus(session) { + if (session.stage === "rewrite") return "Refining search…"; + if (session.stage === "resolve") { + if (session.outcome === "read_api") return "Selecting API…"; + if (session.outcome === "explain_from_verified_facts") { + return session.options.some((option) => option.kind === "source") + ? "Selecting source…" + : "Selecting evidence…"; + } + return "Selecting metrics…"; + } + return "Understanding request…"; +} /** * @typedef {Object} ToolOutcome @@ -12,8 +72,51 @@ const MAX_TOOL_ROUNDS = 8; * @property {string} [output] * @property {import("../storage.js").StoredArtifact[]} [artifacts] * @property {string[]} [metricPaths] + * @property {{ key: string, arguments: Record }} [apiContext] + * @property {{ question: string, excerpts: { revision: string, path: string, startLine: number, endLine?: number, content: string }[] }} [grounding] + * @property {{ question: string, operation: { key: string, method: string, path: string, summary: string, description: string, parameters: { name: string }[], response: { fields?: { name: string, type: string, description?: string }[] } }, arguments: Record, requestPath: string, data: unknown, truncated: boolean }} [apiGrounding] */ +/** + * @param {import("../model.js").AskModel} model + * @param {NonNullable} grounding + * @param {(status: string) => void} onStatus + */ +async function answerFromApi(model, grounding, onStatus) { + const direct = renderDirectApiAnswer(grounding); + if (direct) return direct; + + onStatus("Answering from API…"); + const apiAnswer = createApiAnswerTool(grounding); + const calculation = directApiCalculation( + grounding.question, + apiAnswer.fields, + grounding, + ); + if (calculation) return calculation; + const answer = await model.generate( + [ + { + role: "system", + content: "Answer the user's exact question using only the verified API result and schema. Call answer_from_api once. Choose calculate whenever the requested numeric value requires combining fields; encode the complete arithmetic expression with signed source-field refs so the app computes it exactly. Choose answer only when no arithmetic is required. Preserve identifiers and units. Never invent missing values.", + }, + { + role: "user", + content: JSON.stringify(grounding), + }, + ], + () => {}, + [apiAnswer.tool], + { name: "answer_from_api" }, + { maxTokens: 128 }, + ); + const call = answer.toolCalls[0]; + if (!call || call.name !== "answer_from_api") { + throw new Error("The AI did not produce a valid API answer"); + } + return finishApiAnswer(call.arguments, apiAnswer.fields, grounding); +} + export function createAskTools() { const source = new AskSource(); /** @type {Map} */ @@ -32,6 +135,14 @@ export function createAskTools() { } return { + prewarm() { + return Promise.all([ + prewarmApiIndex(), + prewarmMetricIndex(), + source.prewarm(), + ]); + }, + toolsFor() { return [searchTool()]; }, @@ -53,19 +164,41 @@ export function createAskTools() { await session.begin( question, history, - () => onStatus("Indexing metrics…"), + () => onStatus("Indexing tools…"), ); try { - const direct = await session.tryDirect(onStatus); + const direct = /** @type {ToolOutcome | undefined} */ ( + await session.tryDirect(onStatus, signal) + ); + if (direct?.apiGrounding) { + return { + output: await answerFromApi(model, direct.apiGrounding, onStatus), + artifacts: [], + metricPaths: session.metricPaths(), + apiContext: session.apiContext(), + }; + } if (direct) return { ...direct, metricPaths: session.metricPaths() }; const prepared = await prepare(); const { messages } = prepared; + if (!session.requiresTools) { + onStatus("Understanding request…"); + const frontDoor = await useFrontDoor(model, messages); + if (frontDoor.kind === "answer") { + return { + output: frontDoor.text, + artifacts: [], + metricPaths: [], + chat: prepared.chat, + }; + } + } for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) { signal.throwIfAborted(); - onStatus("Thinking…"); + onStatus(modelStatus(session)); const newestUser = messages.findLast((message) => message.role === "user"); const stagedMessages = [ { role: /** @type {const} */ ("system"), content: session.instruction() }, @@ -82,6 +215,7 @@ export function createAskTools() { () => {}, [await session.tool()], { name: "next_action" }, + { maxTokens: 64 }, ); const call = result.toolCalls[0]; if (!call || call.name !== "next_action") { @@ -90,7 +224,7 @@ export function createAskTools() { signal.throwIfAborted(); const outcome = /** @type {ToolOutcome} */ ( - await session.execute(call.arguments, onStatus) + await session.execute(call.arguments, onStatus, signal) ); if (!outcome.done) continue; if (outcome.general) { @@ -108,10 +242,53 @@ export function createAskTools() { chat: prepared.chat, }; } + if (outcome.grounding) { + onStatus("Answering from source…"); + const answer = await model.generate( + [ + { + role: "system", + content: "Answer in at most 45 words using only the supplied source excerpt. Describe operations in source order. Preserve the exact subject, object, and identifiers of each relationship; never merge separate statements. Do not add background knowledge, guesses, or uncited details.", + }, + { + role: "user", + content: JSON.stringify(outcome.grounding), + }, + ], + onToken, + [], + "none", + { maxTokens: 64 }, + ); + return { + output: renderEvidence({ + facts: [answer.text.trim()], + sources: outcome.grounding.excerpts, + excerpts: [], + }), + artifacts: [], + metricPaths: session.metricPaths(), + chat: prepared.chat, + }; + } + if (outcome.apiGrounding) { + return { + output: await answerFromApi( + model, + outcome.apiGrounding, + onStatus, + ), + artifacts: [], + metricPaths: session.metricPaths(), + apiContext: session.apiContext(), + chat: prepared.chat, + }; + } return { output: outcome.output ?? "", artifacts: outcome.artifacts ?? [], metricPaths: session.metricPaths(), + apiContext: session.apiContext(), chat: prepared.chat, }; } @@ -132,6 +309,7 @@ export function createAskTools() { controller = undefined; sessions.clear(); source.terminate(); + terminateApiIndex(); terminateMetricIndex(); }, }; diff --git a/website_next/ask/tools/metrics/index.js b/website_next/ask/tools/metrics/index.js index 0f3d07e32..da6f12b28 100644 --- a/website_next/ask/tools/metrics/index.js +++ b/website_next/ask/tools/metrics/index.js @@ -1,10 +1,11 @@ -import { brk } from "../../../utils/client.js"; -import { expandMetricQueries } from "./language.js"; +import { BRK_BASE_URL, brk } from "../../../utils/client.js"; +import { canonicalMetricQuery, expandMetricQueries } from "./language.js"; const WORKER_URL = import.meta.resolve("./worker.js"); +const SERIES_URL = `${BRK_BASE_URL}/api/series`; const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]); -/** @typedef {{ path: string, name: string, endpoint: string, suggestedUnit?: string, matchedQuery?: string, score?: number }} CatalogMetric */ +/** @typedef {{ path: string, name: string, endpoint: string, indexes: string[], type: string, suggestedUnit?: string, matchedQuery?: string, score?: number }} CatalogMetric */ /** @param {unknown} value */ function isMetric(value) { @@ -65,14 +66,14 @@ class MetricIndex { /** @type {Map void, reject: (error: Error) => void, onProgress?: () => void }>} */ #pending = new Map(); - /** @param {"search" | "mentions" | "categories" | "byName" | "byPaths" | "variants"} type @param {Record} data @param {(() => void) | undefined} [onProgress] */ + /** @param {"prewarm" | "search" | "mentions" | "byName" | "byPaths" | "variants"} type @param {Record} data @param {(() => void) | undefined} [onProgress] */ request(type, data, onProgress) { this.#ensureWorker(); const id = crypto.randomUUID(); return new Promise((resolve, reject) => { this.#pending.set(id, { resolve, reject, onProgress }); - this.#worker?.postMessage({ id, type, data }); + this.#worker?.postMessage({ id, type, data: { ...data, url: SERIES_URL } }); }); } @@ -119,6 +120,10 @@ class MetricIndex { const index = new MetricIndex(); +export function prewarmMetricIndex() { + return index.request("prewarm", {}); +} + /** @param {string[]} queries @param {number} [limit] @param {string[]} [prefixes] @param {(() => void) | undefined} [onProgress] @returns {Promise} */ export function searchMetrics(queries, limit = 16, prefixes = [], onProgress) { return index.request( @@ -130,12 +135,7 @@ export function searchMetrics(queries, limit = 16, prefixes = [], onProgress) { /** @param {string} query @param {(() => void) | undefined} [onProgress] @returns {Promise} */ export function mentionedMetrics(query, onProgress) { - return index.request("mentions", { query }, onProgress); -} - -/** @returns {Promise<{ path: string, label: string, count: number, examples: string[] }[]>} */ -export function metricCategories() { - return index.request("categories", {}); + return index.request("mentions", { query: canonicalMetricQuery(query) }, onProgress); } /** @param {string} name @returns {Promise} */ diff --git a/website_next/ask/tools/metrics/language.js b/website_next/ask/tools/metrics/language.js index 3e8534b34..b44e7e016 100644 --- a/website_next/ask/tools/metrics/language.js +++ b/website_next/ask/tools/metrics/language.js @@ -1,9 +1,13 @@ /** @type {[RegExp, string][]} */ const ALIASES = [ [/\bcapitalised\b/g, "capitalized"], + [/\bcap price\b/g, "capitalized price"], [/\ball time high\b/g, "ath"], [/\blong term holders?\b/g, "lth"], [/\bshort term holders?\b/g, "sth"], + [/\blong term\b/g, "lth"], + [/\bshort term\b/g, "sth"], + [/\b(lth|sth)\s+holders?\b/g, "$1"], [/\b(?:one )?(?:bitcoin|btc) worth\b/g, "bitcoin spot price"], ]; @@ -12,10 +16,19 @@ function expand(query) { return ALIASES.reduce( (value, [pattern, replacement]) => value.replace(pattern, replacement), query.toLowerCase().replace(/[-_]+/g, " "), - ).replace( - /\b(\d+(?:\.\d+)?)\s+to\s+(\d+(?:\.\d+)?)\s*(btc|sats?)\b/g, - "$1$3 to $2$3", - ); + ) + .replace(/\b(?:over|through)\s+time\b|\btime\s+series\b/g, " ") + .replace( + /\b(\d+(?:\.\d+)?)\s+to\s+(\d+(?:\.\d+)?)\s*(btc|sats?)\b/g, + "$1$3 to $2$3", + ) + .replace(/\s+/g, " ") + .trim(); +} + +/** @param {string} query */ +export function canonicalMetricQuery(query) { + return expand(query); } /** @param {string[]} queries */ diff --git a/website_next/ask/tools/metrics/series.js b/website_next/ask/tools/metrics/series.js new file mode 100644 index 000000000..a713aa2a6 --- /dev/null +++ b/website_next/ask/tools/metrics/series.js @@ -0,0 +1,51 @@ +/** @param {string} value */ +function toCamelCase(value) { + const pascal = value + .replaceAll("-", "_") + .split("_") + .map((word) => word ? `${word[0].toUpperCase()}${word.slice(1)}` : "") + .join(""); + const result = pascal ? `${pascal[0].toLowerCase()}${pascal.slice(1)}` : ""; + return /^\d/.test(result) ? `_${result}` : result; +} + +/** @param {unknown} value @returns {value is Record} */ +function isObject(value) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +/** + * Flatten the server's source-derived series tree into JavaScript client paths. + * + * @param {unknown} tree + * @returns {{ path: string, name: string, indexes: string[], type: string }[]} + */ +export function metricsFromSeries(tree) { + if (!isObject(tree)) throw new Error("Unsupported series catalog"); + /** @type {{ path: string, name: string, indexes: string[], type: string }[]} */ + const metrics = []; + + /** @param {Record} node @param {string[]} path */ + function visit(node, path) { + if ( + typeof node.name === "string" && + typeof node.kind === "string" && + Array.isArray(node.indexes) + ) { + metrics.push({ + path: path.join("."), + name: node.name, + indexes: node.indexes, + type: node.kind, + }); + return; + } + for (const [name, child] of Object.entries(node)) { + if (!isObject(child)) throw new Error(`Invalid series catalog entry: ${name}`); + visit(child, [...path, toCamelCase(name)]); + } + } + + visit(tree, []); + return metrics.sort((left, right) => left.path.localeCompare(right.path)); +} diff --git a/website_next/ask/tools/metrics/worker.js b/website_next/ask/tools/metrics/worker.js index 6ce96d2de..739d1d60b 100644 --- a/website_next/ask/tools/metrics/worker.js +++ b/website_next/ask/tools/metrics/worker.js @@ -1,24 +1,9 @@ import { QuickMatch, QuickMatchConfig } from "../../../modules/quickmatch-js/0.5.0/src/index.js"; -import { brk } from "../../../utils/client.js"; +import { metricsFromSeries } from "./series.js"; const SEARCH_CANDIDATES = 1_024; -/** @typedef {{ path: string, name: string, endpoint: string, document: string }} CatalogMetric */ - -/** @param {unknown} value */ -function isMetric(value) { - if (!value || typeof value !== "object") return false; - const by = /** @type {{ by?: Record }} */ (value).by; - return Boolean( - by && - Object.values(by).some( - (endpoint) => - endpoint && - typeof endpoint === "object" && - typeof /** @type {{ fetch?: unknown }} */ (endpoint).fetch === "function", - ), - ); -} +/** @typedef {{ path: string, name: string, indexes: string[], type: string, document: string }} CatalogMetric */ /** @param {string} value */ function searchable(value) { @@ -30,8 +15,14 @@ function searchable(value) { .toLowerCase(); } -/** @param {string} path */ -function suggestedUnit(path) { +/** @param {string} path @param {string} type */ +function suggestedUnit(path, type) { + if (/(dollar|usd|cents)/i.test(type)) return "usd"; + if (/(bitcoin|btc)/i.test(type)) return "btc"; + if (/(percent|ratio)/i.test(type)) return "percent"; + if (/address/i.test(type)) return "addresses"; + if (/(utxo|output)/i.test(type)) return "utxos"; + if (/(block|height)/i.test(type)) return "blocks"; if (/(percent|ratio|dominance|rate)/i.test(path)) return "percent"; if (/(usd|price|cap)/i.test(path)) return "usd"; if (/(btc|supply|value)/i.test(path)) return "btc"; @@ -51,9 +42,14 @@ function createConfig(limit = SEARCH_CANDIDATES) { .withSeparators("_- :/.|"); } -function buildState() { - /** @type {CatalogMetric[]} */ - const items = []; +/** @param {string} url */ +async function buildState(url) { + const response = await fetch(url); + if (!response.ok) throw new Error(`Series catalog unavailable (${response.status})`); + const items = metricsFromSeries(await response.json()).map((metric) => ({ + ...metric, + document: searchable(`${metric.name} ${metric.path}`), + })); /** @type {Map} */ const byName = new Map(); /** @type {Map} */ @@ -62,35 +58,14 @@ function buildState() { const bySearchableName = new Map(); /** @type {Map} */ const byDocument = new Map(); - const seen = new WeakSet(); - /** @type {{ value: unknown, keys: string[] }[]} */ - const pending = [{ value: brk.series, keys: [] }]; - - while (pending.length) { - const { value, keys } = /** @type {{ value: unknown, keys: string[] }} */ (pending.pop()); - if (!value || typeof value !== "object" || seen.has(value)) continue; - seen.add(value); - - if (isMetric(value)) { - const raw = /** @type {{ name?: string, by: Record }} */ (value); - const path = keys.join("."); - const name = raw.name ?? keys.at(-1) ?? ""; - const endpoint = Object.values(raw.by).find((item) => item.path)?.path ?? ""; - const document = searchable(`${name} | ${path} | ${endpoint}`); - const metric = { path, name, endpoint, document }; - items.push(metric); - if (!byName.has(name)) byName.set(name, metric); - byPath.set(path, metric); - const nameKey = searchable(name); - const named = bySearchableName.get(nameKey) ?? []; - named.push(metric); - bySearchableName.set(nameKey, named); - byDocument.set(document, metric); - } else { - for (const [key, child] of Object.entries(value)) { - if (key !== "by") pending.push({ value: child, keys: [...keys, key] }); - } - } + for (const metric of items) { + if (!byName.has(metric.name)) byName.set(metric.name, metric); + byPath.set(metric.path, metric); + const nameKey = searchable(metric.name); + const named = bySearchableName.get(nameKey) ?? []; + named.push(metric); + bySearchableName.set(nameKey, named); + if (!byDocument.has(metric.document)) byDocument.set(metric.document, metric); } const config = createConfig(); @@ -100,14 +75,16 @@ function buildState() { return { items, byName, byPath, bySearchableName, byDocument, matcher, config, scoped }; } -/** @type {Promise> | undefined} */ +/** @type {Promise>> | undefined} */ let statePromise; +let stateUrl = ""; -/** @param {string} id */ -function state(id) { - if (!statePromise) { +/** @param {string} id @param {string} url */ +function state(id, url) { + if (!statePromise || url !== stateUrl) { + stateUrl = url; self.postMessage({ id, status: "progress" }); - statePromise = Promise.resolve().then(buildState); + statePromise = buildState(url); } return statePromise; } @@ -117,12 +94,13 @@ function publicMetric(metric) { return { path: metric.path, name: metric.name, - endpoint: metric.endpoint, - suggestedUnit: suggestedUnit(metric.path), + indexes: metric.indexes, + type: metric.type, + suggestedUnit: suggestedUnit(metric.path, metric.type), }; } -/** @param {ReturnType} index @param {string[]} prefixes */ +/** @param {Awaited>} index @param {string[]} prefixes */ function scopedIndex(index, prefixes) { const inScope = (/** @type {CatalogMetric} */ metric) => !prefixes.length || prefixes.some((prefix) => @@ -141,7 +119,7 @@ function scopedIndex(index, prefixes) { return { ...scoped, inScope }; } -/** @param {ReturnType} index @param {string} query @param {number} limit @param {string[]} prefixes */ +/** @param {Awaited>} index @param {string} query @param {number} limit @param {string[]} prefixes */ function searchOne(index, query, limit, prefixes) { const scope = scopedIndex(index, prefixes); const normalizedQuery = searchable(query); @@ -187,7 +165,7 @@ function searchOne(index, query, limit, prefixes) { })); } -/** @param {ReturnType} index @param {string[]} queries @param {number} limit @param {string[]} prefixes */ +/** @param {Awaited>} index @param {string[]} queries @param {number} limit @param {string[]} prefixes */ function search(index, queries, limit, prefixes) { const groups = queries.map((query) => searchOne(index, query, limit, prefixes)); const output = []; @@ -208,7 +186,7 @@ function search(index, queries, limit, prefixes) { return output; } -/** @param {ReturnType} index @param {string} query */ +/** @param {Awaited>} index @param {string} query */ function mentions(index, query) { const words = searchable(query).match(/[a-z0-9]+/g) ?? []; /** @type {{ start: number, end: number, metric: CatalogMetric }[]} */ @@ -233,42 +211,7 @@ function mentions(index, query) { ).values()]; } -/** @param {ReturnType} index */ -function categories(index) { - /** @type {Map[] }>} */ - const values = new Map(); - for (const metric of index.items) { - const [root, branch] = metric.path.split("."); - if (!root) continue; - const category = values.get(root) ?? { - path: root, - label: searchable(root), - count: 0, - branches: [new Map(), new Map()], - }; - category.count += 1; - if (branch) { - category.branches[0].set(branch, (category.branches[0].get(branch) ?? 0) + 1); - const selector = metric.path.split(".").slice(1, 3).join(" / "); - if (selector !== branch) { - category.branches[1].set(selector, (category.branches[1].get(selector) ?? 0) + 1); - } - } - values.set(root, category); - } - return [...values.values()] - .map(({ branches, ...category }) => ({ - ...category, - examples: branches.flatMap((level, index) => [...level] - .sort((left, right) => right[1] - left[1]) - .slice(0, index === 0 ? 4 : 8) - .map(([name]) => searchable(name))) - .slice(0, 8), - })) - .sort((left, right) => left.label.localeCompare(right.label)); -} - -/** @param {ReturnType} index @param {string} name @param {string} query */ +/** @param {Awaited>} index @param {string} name @param {string} query */ function variants(index, name, query) { const suffix = `_${name}`; const candidates = index.items.filter((candidate) => @@ -319,10 +262,9 @@ function variants(index, name, query) { return { totalSeries: ranked.length, groups: [...groups.values()].slice(0, 8), - series: ranked.slice(0, 16).map(({ path, name: metricName, endpoint, suggestedUnit }) => ({ + series: ranked.slice(0, 16).map(({ path, name: metricName, suggestedUnit }) => ({ path, name: metricName, - endpoint, suggestedUnit, })), }; @@ -331,14 +273,14 @@ function variants(index, name, query) { self.addEventListener("message", async (event) => { const { id, type, data } = event.data; try { - const index = await state(id); + const index = await state(id, data.url); let result; - if (type === "search") { + if (type === "prewarm") { + result = true; + } else if (type === "search") { result = search(index, data.queries, data.limit, data.prefixes); } else if (type === "mentions") { result = mentions(index, data.query); - } else if (type === "categories") { - result = categories(index); } else if (type === "byName") { const metric = index.byName.get(data.name); result = metric ? publicMetric(metric) : undefined; diff --git a/website_next/ask/tools/prompts.js b/website_next/ask/tools/prompts.js index add5440ed..205dcc82c 100644 --- a/website_next/ask/tools/prompts.js +++ b/website_next/ask/tools/prompts.js @@ -1,27 +1,35 @@ const COMMON = `You route one step for Bitview's small on-device Bitcoin assistant. -Call next_action exactly once. Never answer directly, invent refs, alter returned refs, or repeat a ref.`; +Call next_action exactly once. Never answer directly, invent refs, alter returned refs, or repeat a ref. +Choose clarify when none of the available references match the user's meaning. Similar spelling alone is not a semantic match. Ask one short question; never force an unrelated result.`; export const ASK_STAGE_PROMPTS = /** @type {const} */ ({ search: `You route one user request for Bitview's Bitcoin data assistant. +First decide whether the request needs Bitview/BRK evidence or tools at all. +Choose answer_general for ordinary Bitcoin knowledge, explanation, conversation, or writing that the model can answer without current site data or repository evidence. A related metric existing does not by itself make the request a metric lookup. +Choose clarify_request when the request depends on a missing subject or missing prior context. Ask for that subject; never search source merely to guess it. +Only return catalog queries for outcomes that actually need metric, API, source, data, or chart tools. Omit queries, context, and cardinality for answer_general and clarify_request. +Examples: +- With no previous topic, "Which holder group?" is clarify_request. +- After a verified capitalized price answer, "Which holder groups have it?" reuses the previous topic and explains verified variants. +- "Why does Bitcoin have a fixed supply?" is answer_general. +- "Write a haiku about Bitcoin" is answer_general. Choose the requested outcome and translate the user's meaning into terse catalog-style Bitcoin or BRK metric names or technical noun phrases. Never copy a question or include request verbs, pronouns, time words, or punctuation in a query. Use one query for one metric. For X vs Y, return separate complete X and Y metric phrases; never leave vs or both sides inside one query. Set cardinality to multiple for every comparison or request involving more than one distinct metric, even if you accidentally return one query. Choose reuse_previous when the newest request asks another question about the previous verified topic, including its variants, cohorts, source, value, or chart. Choose extend_previous only when it adds a distinct new metric. Do not turn properties of the previous answer into new metric queries. Choose read_requested_value for a current or historical number. +Choose read_api for a concrete blockchain record or server resource that should be read from Bitview's API, such as a transaction, address, block, mempool, fee estimate, or server status. Do not choose it for time-series metrics or ordinary Bitcoin knowledge. Choose build_requested_chart for a graph, trend, history, comparison over time, or a request to show quantitative metrics over time—even when the user does not say chart. When an active chart is supplied, choose edit_existing_chart only to add, remove, or replace series on that chart. Choose explain_from_verified_facts for what or why questions, meaning, availability, cohorts, variants, or source code. -Choose answer_general only when the request needs no repository evidence, live data, metric lookup, or chart. +Choose answer_general when the request needs no repository evidence, live data, metric lookup, or chart. +Choose clarify_request when essential context is absent or multiple materially different interpretations remain and choosing one would change the result. Put one concise question in clarification. Never clarify merely because wording is informal. Interpret ordinary wording by meaning. BRK means the software repository, not a coin. Call next_action exactly once.`, explain: `${COMMON} Choose the smallest sufficient set of returned references by semantic fit and call answer. Prefer recommended references when they answer the request.`, - navigate: `${COMMON} -Choose one to three source-derived metric families that could contain the user's intended meaning. Use their names and examples; normally choose one. -Prefer the narrowest ordinary family whose name directly expresses the request. Choose a specialized family only when the user explicitly asks for that specialized concept.`, - rewrite: `${COMMON} Rewrite the newest request as concise conventional Bitcoin or BRK metric or source-search phrases. Translate colloquial meaning into standard technical terminology. Keep independently requested metrics as separate queries. Return exactly one rewritten query for every supplied unmatched query, in the same order. Never merge comparison sides. @@ -31,6 +39,11 @@ Return only those searches through next_action.`, Choose the smallest exact metric set that answers the requested value and call read_data. Use latest for the present. Use at for a specific block or date, put that block or date in at, and choose height for a block.`, + api: `${COMMON} +Choose the single read-only API operation that directly answers the request and call call_api. +Copy identifiers and parameter values exactly from the newest user request. Reuse previous verified arguments only for a dependent follow-up on the same resource. +If a required argument is absent, clarify instead of inventing it.`, + chart: `${COMMON} Build the requested chart from the smallest exact set of returned metric references. Use multiple references only when the user requested a comparison.`, diff --git a/website_next/ask/tools/refs.js b/website_next/ask/tools/refs.js index accb51f71..9b9f31c42 100644 --- a/website_next/ask/tools/refs.js +++ b/website_next/ask/tools/refs.js @@ -1,4 +1,5 @@ const PREFIX = /** @type {const} */ ({ + api: "a", category: "c", fact: "f", guide: "g", diff --git a/website_next/ask/tools/render.js b/website_next/ask/tools/render.js index 28ef10557..d22b91f22 100644 --- a/website_next/ask/tools/render.js +++ b/website_next/ask/tools/render.js @@ -1,4 +1,6 @@ import { formatValue } from "./data.js"; +import { focusApiData } from "./api/result.js"; +import { normalize } from "./text.js"; /** * @typedef {Object} MetricRead @@ -10,17 +12,45 @@ import { formatValue } from "./data.js"; * @property {unknown[]} values */ -/** @param {{ facts: string[], sources: string[], excerpts: { path: string, startLine: number, content: string }[] }} evidence */ +/** + * @typedef {Object} SourceEvidence + * @property {string} revision + * @property {string} path + * @property {number} startLine + * @property {number} [endLine] + */ + +const SOURCE_URL = "https://github.com/bitcoinresearchkit/brk/blob"; + +/** @param {SourceEvidence} source */ +function sourceKey(source) { + return `${source.revision}:${source.path}:${source.startLine}:${source.endLine ?? source.startLine}`; +} + +/** @param {SourceEvidence} source */ +function sourceLink(source) { + const end = source.endLine && source.endLine !== source.startLine + ? `-${source.endLine}` + : ""; + const path = source.path.split("/").map(encodeURIComponent).join("/"); + const lines = `#L${source.startLine}${end ? `-L${source.endLine}` : ""}`; + const url = `${SOURCE_URL}/${encodeURIComponent(source.revision)}/${path}${lines}`; + return `[\`${source.path}:${source.startLine}${end}\`](${url})`; +} + +/** @param {{ facts: string[], sources: SourceEvidence[], excerpts: (SourceEvidence & { content: string })[] }} evidence */ export function renderEvidence(evidence) { const sections = [...new Set(evidence.facts)].filter(Boolean); + const cited = new Set(); for (const excerpt of evidence.excerpts) { - sections.push(`\`${excerpt.path}:${excerpt.startLine}\`\n\n\`\`\`\n${excerpt.content}\n\`\`\``); + sections.push(`${sourceLink(excerpt)}\n\n\`\`\`\n${excerpt.content}\n\`\`\``); + cited.add(sourceKey(excerpt)); } - const sources = [...new Set(evidence.sources)].filter( - (source) => !sections.some((section) => section.includes(source)), - ); + const sources = [...new Map( + evidence.sources.map((source) => [sourceKey(source), source]), + ).values()].filter((source) => !cited.has(sourceKey(source))); if (sources.length) { - sections.push(`Source${sources.length === 1 ? "" : "s"}: ${sources.map((source) => `\`${source}\``).join(", ")}`); + sections.push(`Source${sources.length === 1 ? "" : "s"}: ${sources.map(sourceLink).join(", ")}`); } return sections.join("\n\n") || "I could not find enough verified evidence to answer that."; } @@ -43,3 +73,134 @@ export function renderData(results) { return `**${result.label}**: ${values.length} values; latest ${formatValue(values[values.length - 1], result.unit)}.`; }).join("\n"); } + +/** @param {string} answer @param {{ method: string, path: string }} operation */ +export function renderApiAnswer(answer, operation) { + return `${answer.trim()}\n\nData: \`${operation.method} ${operation.path}\``; +} + +/** @param {unknown} value @param {string[]} path */ +function valueAt(value, path) { + let current = value; + for (const key of path) { + if (!current || typeof current !== "object" || !Object.hasOwn(current, key)) { + return undefined; + } + current = /** @type {Record} */ (current)[key]; + } + return current; +} + +/** @param {unknown} value */ +function scalar(value) { + return value === null || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean"; +} + +/** @param {unknown} value */ +function displayScalar(value) { + if (typeof value === "number") { + return new Intl.NumberFormat("en-US", { maximumFractionDigits: 8 }).format(value); + } + if (typeof value === "string") return value; + return JSON.stringify(value); +} + +/** @param {{ field: { name: string, type: string }, value: unknown }} candidate */ +function renderApiField(candidate) { + const genericTypes = new Set([ + "boolean", + "integer", + "null", + "number", + "object", + "string", + "value", + ]); + const types = candidate.field.type + .split("|") + .map((type) => type.trim()); + const semanticTypes = types.filter((type) => !genericTypes.has(type.toLowerCase())); + const unit = semanticTypes.length === 1 ? ` ${semanticTypes[0]}` : ""; + const label = candidate.field.name.replaceAll("_", " ").replaceAll(".", " · "); + return `**${label}**: ${displayScalar(candidate.value)}${unit}`; +} + +/** + * Render scalar fields selected directly by exact OpenAPI field-name overlap. + * Equal-scoring fields are returned together rather than asking the model to + * choose, which is both faster and safer for questions such as "which block?". + * Field names, parameter names, and units all come from OpenAPI. + * @param {{ question: string, data: unknown, arguments?: Record, operation: { method: string, path: string, parameters?: { name: string }[], response: { type?: string, fields?: { name: string, type: string, description?: string }[] } } }} grounding + */ +export function renderDirectApiAnswer(grounding) { + if ( + /\b(?:add(?:ed)?|combined?|difference|minus|net|plus|subtract(?:ed)?|sum)\b/i + .test(grounding.question) + ) return undefined; + + const data = focusApiData(grounding.data, grounding.arguments); + const responseFields = grounding.operation.response.fields ?? []; + if (scalar(data) && !responseFields.length) { + const type = grounding.operation.response.type ?? "value"; + const label = normalize(type) || "value"; + return renderApiAnswer(`**${label}**: ${displayScalar(data)}`, grounding.operation); + } + + const words = new Set( + normalize(grounding.question).match(/[a-z0-9]+/g) ?? [], + ); + const parameters = new Set( + (grounding.operation.parameters ?? []).map(({ name }) => normalize(name)), + ); + const fields = responseFields.map((field) => { + const nameTokens = new Set(normalize(field.name).match(/[a-z0-9]+/g) ?? []); + const tokens = new Set( + normalize(`${field.name} ${field.description ?? ""}`).match(/[a-z0-9]+/g) ?? [], + ); + return { field, nameTokens, tokens }; + }); + const frequencies = new Map(); + for (const { tokens } of fields) { + for (const token of tokens) { + frequencies.set(token, (frequencies.get(token) ?? 0) + 1); + } + } + const candidates = fields + .map((field) => { + const path = field.field.name.split("."); + const leaf = path.at(-1) ?? ""; + let matches = 0; + let score = 0; + for (const word of words) { + if (!field.tokens.has(word)) continue; + matches += 1; + const frequency = frequencies.get(word) ?? fields.length; + const idf = Math.log((fields.length + 1) / (frequency + 1)) + 1; + score += idf * (field.nameTokens.has(word) ? 3 : 1); + } + return { + field: field.field, + path, + value: valueAt(data, path), + matches, + score, + parameter: parameters.has(normalize(leaf)), + }; + }) + .filter(({ matches, parameter, value }) => + matches > 0 && !parameter && scalar(value) && value !== null + ) + .sort((left, right) => right.score - left.score || right.matches - left.matches); + if (!candidates.length) return undefined; + + const selected = candidates + .filter(({ score }) => score === candidates[0].score) + .slice(0, 6); + const answer = selected.length === 1 + ? renderApiField(selected[0]) + : selected.map((candidate) => `- ${renderApiField(candidate)}`).join("\n"); + return renderApiAnswer(answer, grounding.operation); +} diff --git a/website_next/ask/tools/schemas.js b/website_next/ask/tools/schemas.js index f9bf6395e..9b2fcd82a 100644 --- a/website_next/ask/tools/schemas.js +++ b/website_next/ask/tools/schemas.js @@ -42,27 +42,19 @@ export function searchTool(hasActiveChart = false, hasPrevious = false) { type: "string", enum: [ "read_requested_value", + "read_api", "build_requested_chart", ...(hasActiveChart ? ["edit_existing_chart"] : []), "explain_from_verified_facts", "answer_general", + "clarify_request", ], }, - }, ["action", "context", "queries", "cardinality", "outcome"]); -} - -/** @param {{ ref: string, label: string }[]} options */ -export function navigateTool(options) { - return actionTool({ - action: { type: "string", enum: ["navigate"] }, - refs: { - type: "array", - minItems: 1, - maxItems: 3, - items: { type: "string", enum: options.map(({ ref }) => ref) }, - description: `Metric families: ${options.map(({ ref, label }) => `${ref}=${label}`).join("; ")}`, + clarification: { + type: "string", + description: "Only for clarify_request: one short question that distinguishes the materially different interpretations.", }, - }, ["action", "refs"]); + }, ["action", "outcome"]); } /** @param {string[]} queries */ @@ -79,6 +71,44 @@ export function rewriteTool(queries) { }, ["action", "queries"]); } +/** @param {{ ref: string, label: string, operation: import("./api/index.js").ApiOperation }[]} options */ +export function apiResolveTool(options) { + const parameters = new Map(); + for (const { operation } of options) { + for (const parameter of operation.parameters) { + const current = parameters.get(parameter.name); + const descriptions = [ + current?.description, + `${parameter.in}${parameter.required ? ", required" : ""} for ${operation.path}${parameter.description ? `: ${parameter.description}` : ""}`, + ].filter(Boolean); + parameters.set(parameter.name, { + type: "string", + description: [...new Set(descriptions)].join(" "), + }); + } + } + return actionTool({ + action: { type: "string", enum: ["call_api", "clarify"] }, + ref: { + type: "string", + enum: options.map(({ ref }) => ref), + description: `Read-only operations: ${options.map(({ ref, label, operation }) => { + const params = operation.parameters + .map((parameter) => `${parameter.name}${parameter.required ? "*" : ""}`) + .join(", "); + return `${ref}=${label} [${params || "no parameters"}]`; + }).join("; ")}`, + }, + arguments: { + type: "object", + properties: Object.fromEntries(parameters), + additionalProperties: false, + description: "Arguments copied from the user's request. Include every required parameter for the selected operation.", + }, + text: { type: "string", description: "For clarify only: one short question." }, + }, ["action"]); +} + /** * @param {{ ref: string, label: string }[]} options * @param {string} outcome @@ -95,14 +125,15 @@ export function resolveTool(options, outcome, maxItems = 3) { if (outcome === "explain_from_verified_facts") { return actionTool({ - action: { type: "string", enum: ["answer"] }, + action: { type: "string", enum: ["answer", "clarify"] }, refs, - }, ["action", "refs"]); + text: { type: "string", description: "For clarify only: one short question." }, + }, ["action"]); } if (outcome === "read_requested_value") { return actionTool({ - action: { type: "string", enum: ["read_data"] }, + action: { type: "string", enum: ["read_data", "clarify"] }, refs, mode: { type: "string", enum: ["latest", "at", "range"] }, index: { type: "string", description: "Index such as height or day1." }, @@ -110,12 +141,16 @@ export function resolveTool(options, outcome, maxItems = 3) { start: { type: "string" }, end: { type: "string" }, points: { type: "integer", minimum: 1, maximum: 120 }, - }, ["action", "refs", "mode"]); + text: { type: "string", description: "For clarify only: one short question." }, + }, ["action"]); } const editing = outcome === "edit_existing_chart"; return actionTool({ - action: { type: "string", enum: [editing ? "edit_chart" : "build_chart"] }, + action: { + type: "string", + enum: [editing ? "edit_chart" : "build_chart", "clarify"], + }, refs, title: { type: "string" }, operation: { @@ -123,7 +158,8 @@ export function resolveTool(options, outcome, maxItems = 3) { enum: ["add", "remove", "replace"], description: "For edit_chart, make exactly the requested change.", }, - }, ["action", "refs"]); + text: { type: "string", description: "For clarify only: one short question." }, + }, ["action"]); } export function clarifyTool() { diff --git a/website_next/ask/tools/session.js b/website_next/ask/tools/session.js index c27d5b07b..40a391fdb 100644 --- a/website_next/ask/tools/session.js +++ b/website_next/ask/tools/session.js @@ -1,22 +1,25 @@ import { createChartArtifact } from "./chart.js"; +import { resolveChartUnit } from "./chart/units.js"; +import { apiByKey, searchApi } from "./api/index.js"; +import { executeApi } from "./api/execute.js"; import { readMetric } from "./data.js"; import { directChartCommand } from "./direct/chart.js"; import { directEvidenceFocus } from "./direct/evidence.js"; import { searchLearn } from "./learn.js"; import { metricByName, - metricCategories, metricVariants, metricsByPaths, mentionedMetrics, searchMetrics, } from "./metrics/index.js"; +import { canonicalMetricQuery } from "./metrics/language.js"; import { ASK_STAGE_PROMPTS } from "./prompts.js"; import { AskRefs } from "./refs.js"; import { renderData, renderEvidence } from "./render.js"; import { + apiResolveTool, clarifyTool, - navigateTool, resolveTool, rewriteTool, searchTool, @@ -24,7 +27,8 @@ import { import { normalize } from "./text.js"; const MAX_OPTIONS = 12; -const MAX_CATEGORIES = 24; +const MAX_API_OPTIONS = 6; +const MAX_API_HINTS = 12; /** @typedef {import("../storage.js").ChartArtifact} ChartArtifact */ @@ -69,9 +73,15 @@ function isExplicitComparison(value) { return /\b(?:vs\.?|versus|compare|compared|comparison)\b/i.test(value); } +/** @param {string} value */ +function mayRequestMultiple(value) { + return isExplicitComparison(value) || /\b(?:and|both|together)\b/i.test(value); +} + /** @param {string} value */ function referencesPrevious(value) { - return /\b(?:it|its|that|this|they|their|them|those|these|same)\b/i.test(value); + return /\b(?:it|its|that|this|they|their|them|those|these|same)\b/i.test(value) || + /^(?:and|also|what about)\b/i.test(value.trim()); } /** @param {string} value */ @@ -79,6 +89,144 @@ function referencesSingular(value) { return /\b(?:it|its|that|this)\b/i.test(value); } +/** @param {string} value */ +function referencesPlural(value) { + return /\b(?:they|their|them|those|these)\b/i.test(value); +} + +/** @param {string} value */ +function literalArguments(value) { + return [...new Set( + (value.match(/\b(?=[A-Za-z0-9:_-]*\d)[A-Za-z0-9][A-Za-z0-9:_-]*\b/g) ?? []) + .filter((item) => item.length > 1 || /^\d$/.test(item)), + )]; +} + +/** @param {string} request @param {import("./api/index.js").ApiOperation} operation */ +function matchesApiResponse(request, operation) { + const words = new Set(normalize(request).match(/[a-z0-9]+/g) ?? []); + return operation.response.fields.some((field) => { + const names = normalize(field.name).match(/[a-z0-9]+/g) ?? []; + if (names.some((word) => words.has(word))) return true; + const description = normalize(field.description).match(/[a-z0-9]+/g) ?? []; + return description.some((word) => word.length >= 4 && words.has(word)); + }); +} + +/** @param {string} request @param {import("./api/index.js").ApiOperation} operation */ +function matchesApiIntent(request, operation) { + if (matchesApiResponse(request, operation)) return true; + const words = new Set( + (normalize(request).match(/[a-z0-9]+/g) ?? []).filter((word) => word.length >= 3), + ); + const document = new Set( + (normalize(`${operation.summary} ${operation.path}`).match(/[a-z0-9]+/g) ?? []) + .filter((word) => word.length >= 3), + ); + return [...words].filter((word) => document.has(word)).length >= 2; +} + +/** @param {string} value */ +function literalType(value) { + if (/^-?\d+(?:\.\d+)?$/.test(value) && value.replace(/[^0-9]/g, "").length <= 15) { + return "number"; + } + if (value === "true" || value === "false") return "boolean"; + return "string"; +} + +/** @param {import("./api/index.js").ApiParameter} parameter */ +function parameterType(parameter) { + const type = normalize(parameter.valueType ?? parameter.type); + if (/\b(?:integer|number)\b/.test(type)) return "number"; + if (/\bboolean\b/.test(type)) return "boolean"; + if (/\bstring\b/.test(type)) return "string"; + return "unknown"; +} + +/** + * Prefer candidates whose source-derived parameter schemas fit the supplied + * literals, while retaining catalog rank for equal fits. + * @param {string[]} values + * @param {import("./api/index.js").ApiOperation} operation + * @param {string} [request] + */ +function argumentAffinity(values, operation, request = "") { + const required = operation.parameters.filter((parameter) => parameter.required); + if (required.length !== values.length) return -1; + const requestTokens = normalize(request).split(" "); + return required.reduce((score, parameter, index) => { + const expected = parameterType(parameter); + const actual = literalType(values[index]); + let next = expected === actual ? score + 2 : expected === "unknown" ? score + 1 : score; + const valueIndex = requestTokens.indexOf(normalize(values[index])); + const context = valueIndex > 0 ? requestTokens[valueIndex - 1] : ""; + const placeholder = `{${parameter.name}}`; + const parts = operation.path.split("/"); + const parameterIndex = parts.indexOf(placeholder); + const pathContext = parameterIndex > 0 ? normalize(parts[parameterIndex - 1]) : ""; + if (context && pathContext.split(" ").includes(context)) next += 2; + return next; + }, 0); +} + +/** + * @param {import("./api/index.js").ApiOperation[]} hints + * @param {string[]} values + * @param {string} request + */ +function directApiCandidate(hints, values, request) { + if (!values.length) { + return hints.find((operation) => + operation.parameters.every((parameter) => !parameter.required) && + (operation.matchedTerms ?? 0) >= 2 && + matchesApiIntent(request, operation) + ); + } + const ranked = hints + .map((operation, rank) => ({ + operation, + rank, + affinity: argumentAffinity(values, operation, request), + })) + .filter(({ operation, affinity }) => + affinity >= 0 && (operation.matchedTerms ?? 0) > 0 + ) + .sort((left, right) => + right.affinity - left.affinity || left.rank - right.rank + ); + const [first, second] = ranked; + if (!first || !matchesApiIntent(request, first.operation)) return undefined; + const numericAmbiguity = values.some((value) => literalType(value) === "number") && + second?.affinity === first.affinity && + second.operation.parameters + .filter((parameter) => parameter.required) + .map((parameter) => parameter.name) + .join("|") !== first.operation.parameters + .filter((parameter) => parameter.required) + .map((parameter) => parameter.name) + .join("|"); + return numericAmbiguity ? undefined : first.operation; +} + +/** + * @param {import("./api/index.js").ApiOperation} operation + * @param {{ operation: import("./api/index.js").ApiOperation, arguments: Record } | undefined} previous + */ +function reusableArguments(operation, previous) { + if (!previous) return undefined; + const required = operation.parameters.filter((parameter) => parameter.required); + if (!required.length && previous.operation.key !== operation.key) return undefined; + if (!required.every((parameter) => Object.hasOwn(previous.arguments, parameter.name))) { + return undefined; + } + return Object.fromEntries( + operation.parameters + .filter((parameter) => Object.hasOwn(previous.arguments, parameter.name)) + .map((parameter) => [parameter.name, previous.arguments[parameter.name]]), + ); +} + /** @param {string} request */ function isDirectValueFollowup(request) { const text = normalize(request); @@ -90,6 +238,18 @@ function isDirectValueFollowup(request) { return referencesPrevious(text) && hasPoint && !needsInterpretation; } +/** @param {string} request */ +function isDirectValueRequest(request) { + const text = normalize(request); + const hasPoint = /\b(?:current|currently|latest|now|today)\b/.test(text) || + /\bblock\s+\d{4,}\b/.test(text) || + /\b\d{4}-\d{2}-\d{2}\b/.test(text); + const needsDifferentTool = + /\b(?:available|availability|chart|cohorts?|code|explain|formula|graph|history|plot|source|trend|variants?|visualize|visualise)\b/.test(text) || + /\b(?:over|through)\s+time\b/.test(text); + return hasPoint && !needsDifferentTool; +} + /** @param {string} request @param {string} proposed */ function evidenceFocus(request, proposed) { if (/\b(?:cohorts?|variants?|availability|available)\b/i.test(request)) return "variants"; @@ -119,6 +279,44 @@ async function exactTopicMetrics(topics, onProgress) { return metrics.filter((metric) => names.has(normalize(metric.name))); } +/** + * Resolve coordinated metric wording only when expanding its shared suffix + * produces two exact generated catalog names. + * @param {string} request + * @param {() => void} onProgress + */ +async function coordinatedMetrics(request, onProgress) { + const expression = canonicalMetricQuery(request) + .replace( + /^(?:(?:compare|chart|graph|plot|show|visualize|visualise)(?: me)?|comparison of)\s+/, + "", + ) + .replace(/^both\s+/, "") + .trim(); + const parts = expression + .split(/\s+(?:and|against|versus|vs)\s+/) + .map((part) => part.trim()) + .filter(Boolean); + if (parts.length !== 2) return []; + + const [left, right] = parts; + const candidates = [[left, right]]; + const leftWords = left.split(" "); + const rightWords = right.split(" "); + for (let index = 1; index < rightWords.length; index += 1) { + candidates.push([`${left} ${rightWords.slice(index).join(" ")}`, right]); + } + for (let index = 1; index < leftWords.length; index += 1) { + candidates.push([left, `${right} ${leftWords.slice(index).join(" ")}`]); + } + + for (const topics of candidates) { + const metrics = await exactTopicMetrics(topics, onProgress); + if (new Set(metrics.map((metric) => metric.path)).size === 2) return metrics; + } + return []; +} + /** @param {string} request */ function directReadAction(request) { const block = request.match(/\bblock\s+(\d{4,})\b/i)?.[1]; @@ -166,6 +364,41 @@ function latestMetricPaths(history) { return undefined; } +/** @param {any[]} history */ +function recentMetricPaths(history) { + /** @type {string[]} */ + const paths = []; + for (const message of [...history].reverse()) { + const remembered = Array.isArray(message.metricPaths) + ? message.metricPaths + : message.artifacts?.findLast?.( + (/** @type {any} */ artifact) => artifact.type === "chart", + )?.chart.series.map((/** @type {any} */ item) => item.path); + for (const path of remembered ?? []) { + if (!paths.includes(path)) paths.push(path); + if (paths.length === 6) return paths; + } + } + return paths; +} + +/** @param {any[]} history */ +function latestApiContext(history) { + for (const message of [...history].reverse()) { + if (message.apiContext) return message.apiContext; + } + return undefined; +} + +/** @param {any} formula */ +function formulaCitation(formula) { + return { + revision: formula.revision, + path: formula.fact.path, + startLine: formula.fact.line, + }; +} + /** @param {any[]} items */ function uniqueMetricOptions(items) { return [...new Map(items.map((/** @type {any} */ item) => [item.ref, item])).values()]; @@ -237,6 +470,8 @@ export class AskToolSession { /** @type {any[]} */ previousMetrics = []; /** @type {any[]} */ + recentMetrics = []; + /** @type {any[]} */ contextMetrics = []; query = ""; /** @type {string[]} */ @@ -248,17 +483,27 @@ export class AskToolSession { rewritten = false; rewriteChanged = false; comparison = false; - /** @type {string[]} */ - categoryPaths = []; /** @type {any} */ observation; /** @type {any[]} */ options = []; /** @type {MetricOption[]} */ metricOptions = []; + /** @type {{ ref: string, label: string, operation: import("./api/index.js").ApiOperation }[]} */ + apiOptions = []; + /** @type {import("./api/index.js").ApiOperation[]} */ + apiHints = []; + /** @type {import("./api/index.js").ApiOperation[]} */ + apiCandidates = []; + /** @type {import("./api/index.js").ApiOperation | undefined} */ + directApiHint; + /** @type {{ operation: import("./api/index.js").ApiOperation, arguments: Record } | undefined} */ + previousApi; + reuseApiContext = false; /** @type {any} */ formula; sourceSearched = false; + requiresTools = false; /** @type {ChartArtifact | undefined} */ activeChart; @@ -276,27 +521,105 @@ export class AskToolSession { this.rewriteChanged = false; this.comparison = false; this.contextMetrics = []; - this.categoryPaths = []; this.observation = undefined; this.options = []; this.metricOptions = []; + this.apiOptions = []; + this.apiHints = []; + this.apiCandidates = []; + this.directApiHint = undefined; + this.reuseApiContext = false; this.formula = undefined; this.sourceSearched = false; + this.requiresTools = false; this.activeChart ??= latestChart(history); - const paths = latestMetricPaths(history); - if (paths === undefined) return; + const apiContext = latestApiContext(history); + if (apiContext?.key) { + const operation = await apiByKey(apiContext.key); + this.previousApi = operation + ? { operation, arguments: apiContext.arguments ?? {} } + : undefined; + } else { + this.previousApi = undefined; + } - const current = this.previousMetrics.map((metric) => metric.path); - if (paths.length === current.length && paths.every((path, index) => path === current[index])) { + const dependsOnPrevious = Boolean(this.previousApi) && referencesPrevious(this.request); + const apiQuery = dependsOnPrevious + ? `${this.request} ${this.previousApi?.operation.summary || this.previousApi?.operation.label}` + : this.request; + const literals = literalArguments(this.request); + this.apiHints = await searchApi([apiQuery], MAX_API_HINTS, onProgress).catch(() => []); + this.apiCandidates = literals.length + ? this.apiHints.filter((operation) => + argumentAffinity(literals, operation, this.request) >= 0 && + (operation.matchedTerms ?? 0) > 0 + ) + : []; + this.directApiHint = directApiCandidate(this.apiHints, literals, this.request); + this.requiresTools = Boolean(this.directApiHint) || this.apiCandidates.length > 0; + + if (this.previousApi && referencesPrevious(this.request)) { + this.requiresTools = true; + const contextual = this.apiHints.find((operation) => + (operation.matchedTerms ?? 0) >= 2 && + matchesApiIntent(this.request, operation) && + reusableArguments(operation, this.previousApi) !== undefined + ); + if (contextual) { + this.directApiHint = contextual; + } else if (matchesApiResponse(this.request, this.previousApi.operation)) { + this.directApiHint = this.previousApi.operation; + } + } + + const focusPaths = latestMetricPaths(history); + if (focusPaths === undefined) return; + const recentPaths = recentMetricPaths(history); + const currentFocus = this.previousMetrics.map((metric) => metric.path); + const currentRecent = this.recentMetrics.map((metric) => metric.path); + if ( + focusPaths.length === currentFocus.length && + focusPaths.every((path, index) => path === currentFocus[index]) && + recentPaths.length === currentRecent.length && + recentPaths.every((path, index) => path === currentRecent[index]) + ) { return; } + const paths = [...new Set([...focusPaths, ...recentPaths])]; const metrics = paths.length ? await metricsByPaths(paths, onProgress) : []; - this.rememberMetricValues(metrics); + const byPath = new Map(metrics.map((metric) => [metric.path, metric])); + this.previousMetrics = focusPaths.map((path) => byPath.get(path)).filter(Boolean); + this.previousTopics = this.previousMetrics.map((metric) => label(metric.name)); + this.recentMetrics = recentPaths.map((path) => byPath.get(path)).filter(Boolean); } - /** @param {(status: string) => void} onStatus */ - async tryDirect(onStatus) { + /** @param {(status: string) => void} onStatus @param {AbortSignal} signal */ + async tryDirect(onStatus, signal) { + if (this.directApiHint) { + this.outcome = "read_api"; + this.queries = [this.request]; + this.apiOptions = [{ + ref: this.refs.issue("api", this.directApiHint, this.directApiHint.key), + label: `${this.directApiHint.method} ${this.directApiHint.path} — ${this.directApiHint.label}`, + operation: this.directApiHint, + }]; + try { + const direct = await this.tryDirectApi(onStatus, signal); + if (direct) return direct; + } catch { + this.requiresTools = true; + this.apiOptions = []; + } + } + + if (this.apiCandidates.length) { + this.outcome = "read_api"; + this.queries = [this.request]; + await this.searchApiOperations(onStatus); + return undefined; + } + const evidence = directEvidenceFocus(this.request); const chart = evidence ? undefined @@ -306,11 +629,19 @@ export class AskToolSession { this.request, () => onStatus("Indexing metrics…"), ); + if (metrics.length < 2 && mayRequestMultiple(this.request)) { + metrics = await coordinatedMetrics( + this.request, + () => onStatus("Searching metrics…"), + ); + } if (!metrics.length) { if (this.previousTopics.length > 1 && referencesSingular(this.request)) { return undefined; } - metrics = this.previousMetrics.length + metrics = referencesPlural(this.request) && this.recentMetrics.length + ? this.recentMetrics + : this.previousMetrics.length ? this.previousMetrics : await exactTopicMetrics( this.previousTopics, @@ -318,7 +649,7 @@ export class AskToolSession { ); } - if (metrics.length) { + if (metrics.length && (!mayRequestMultiple(this.request) || metrics.length > 1)) { const refs = metrics.map((metric) => this.refs.issue("metric", metric, metric.path)); this.outcome = chart.kind === "edit" ? "edit_existing_chart" @@ -327,6 +658,7 @@ export class AskToolSession { const result = this.buildChart({ refs, operation: chart.operation }); return { output: result.output, artifacts: result.artifacts }; } + this.requiresTools = true; } if (evidence) { @@ -367,9 +699,38 @@ export class AskToolSession { const result = await this.inspect(refs); return { output: result.output, artifacts: [] }; } + if (evidence === "implementation") { + onStatus("Searching source…"); + const result = await this.source.search( + this.request, + undefined, + ({ loaded, total }) => onStatus(`Indexing source · ${loaded} / ${total}`), + ); + if (result.matches.length) { + const options = result.matches.slice(0, 6).map((/** @type {any} */ match) => { + const value = { ...match, revision: result.revision }; + return { + ref: this.refs.issue("source", value, `${match.path}:${match.startLine}`), + kind: "source", + label: `${match.path}:${match.startLine}`, + detail: match.content.slice(0, 180), + }; + }); + this.outcome = "explain_from_verified_facts"; + this.stage = "resolve"; + this.options = options; + this.observation = { options }; + this.requiresTools = true; + return undefined; + } + } + this.requiresTools = true; } - if (this.previousTopics.length === 1 && isDirectValueFollowup(this.request)) { + if ( + isDirectValueRequest(this.request) || + this.previousTopics.length === 1 && isDirectValueFollowup(this.request) + ) { const action = directReadAction(this.request); if (action) { let metrics = await mentionedMetrics( @@ -391,11 +752,13 @@ export class AskToolSession { this.rememberMetricValues(metrics); return { output: renderData(results), artifacts: [] }; } + this.requiresTools = true; } } if (!isDirectDefinition(this.request)) return undefined; + const explicitlyNamesMetric = /\b(?:indicator|metric|series)\b/i.test(this.request); const mentioned = await mentionedMetrics( this.request, () => onStatus("Indexing metrics…"), @@ -405,6 +768,7 @@ export class AskToolSession { if (!metric && this.previousMetrics.length === 1 && referencesPrevious(this.request)) { [metric] = this.previousMetrics; } + if (!metric && !explicitlyNamesMetric) return undefined; if (!metric) { [metric] = await searchMetrics( [this.request], @@ -413,29 +777,42 @@ export class AskToolSession { () => onStatus("Indexing metrics…"), ); } - if (!metric) return undefined; + if (!metric) { + this.requiresTools = true; + return undefined; + } onStatus("Searching source…"); const formula = await this.source.explain( `${this.request}\n${metric.name}`, ({ loaded, total }) => onStatus(`Indexing source · ${loaded} / ${total}`), ); - if (!formula || normalize(formula.fact.metric) !== normalize(metric.name)) return undefined; + if (!formula || normalize(formula.fact.metric) !== normalize(metric.name)) { + this.requiresTools = true; + return undefined; + } this.rememberMetricValues([metric]); - return { output: formula.answer, artifacts: [] }; + return { + output: renderEvidence({ + facts: [formula.answer], + sources: [formulaCitation(formula)], + excerpts: [], + }), + artifacts: [], + }; } async tool() { if (this.stage === "search") { return searchTool( Boolean(this.activeChart), - this.previousTopics.length > 0, + this.previousTopics.length > 0 || Boolean(this.previousApi), ); } if (this.stage === "rewrite") return rewriteTool(this.queries); - if (this.stage === "navigate") return navigateTool(this.options); if (this.stage === "resolve") { + if (this.outcome === "read_api") return apiResolveTool(this.apiOptions); const maxItems = this.formula ? 1 : this.outcome === "explain_from_verified_facts" @@ -456,14 +833,17 @@ export class AskToolSession { const previous = this.previousTopics.length ? `\nPrevious verified topic${this.previousTopics.length === 1 ? "" : "s"}: ${this.previousTopics.join(", ")}. Reuse only when the newest request depends on it.` : ""; + const previousApi = this.previousApi + ? `\nPrevious verified API resource: ${this.previousApi.operation.label} (${this.previousApi.operation.key}), arguments ${JSON.stringify(this.previousApi.arguments)}. Reuse only for a dependent follow-up on that resource.` + : ""; const chart = this.activeChart ? `\nActive chart: ${this.activeChart.chart.title}; series: ${this.activeChart.chart.series.map((item) => item.label).join(", ")}.` : ""; - return `${ASK_STAGE_PROMPTS.search}${previous}${chart}`; + return `${ASK_STAGE_PROMPTS.search}${previous}${previousApi}${chart}`; } - if (this.stage === "navigate") return ASK_STAGE_PROMPTS.navigate; if (this.stage === "rewrite") return ASK_STAGE_PROMPTS.rewrite; if (this.stage === "resolve") { + if (this.outcome === "read_api") return ASK_STAGE_PROMPTS.api; if (this.outcome === "explain_from_verified_facts") { return this.directMatch ? `${ASK_STAGE_PROMPTS.explain}\nA trusted direct match exists. Use only recommendedRefs.` @@ -479,6 +859,8 @@ export class AskToolSession { /** @param {(status: string) => void} onStatus */ async search(onStatus) { + if (this.outcome === "read_api") return await this.searchApiOperations(onStatus); + const [globalMetrics, rawMetrics, guideGroups] = await Promise.all([ searchMetrics( this.queries, @@ -513,10 +895,18 @@ export class AskToolSession { /** @type {any[]} */ let sources = []; + const hasExactMetric = foundMetrics.some((metric) => + normalize(metric.name) === normalize(metric.matchedQuery ?? "") + ); + const hasGroundedSubject = hasExactMetric || + this.contextMetrics.length > 0 || + this.rewritten && this.rewriteChanged; + if ( this.outcome === "explain_from_verified_facts" && this.focus !== "variants" && - !this.sourceSearched + !this.sourceSearched && + (this.focus === "implementation" || hasGroundedSubject) ) { this.sourceSearched = true; onStatus("Searching source…"); @@ -528,11 +918,15 @@ export class AskToolSession { ({ loaded, total }) => onStatus(`Indexing source · ${loaded} / ${total}`), ); if (!this.formula) { - sources = (await this.source.search( + const result = await this.source.search( this.queries.join(" "), undefined, ({ loaded, total }) => onStatus(`Indexing source · ${loaded} / ${total}`), - )).matches; + ); + sources = result.matches.map((/** @type {any} */ source) => ({ + ...source, + revision: result.revision, + })); } else { const formulaMetric = await metricByName(this.formula.fact.metric); if (formulaMetric && !metrics.some((metric) => metric.path === formulaMetric.path)) { @@ -597,7 +991,7 @@ export class AskToolSession { return; } - const normalizedQueries = this.queries.map(normalize); + const normalizedQueries = this.queries.map((query) => normalize(canonicalMetricQuery(query))); const exactCandidates = this.options.filter((option) => option.kind === "metric" && normalize(option.label) === normalize(option.matchedQuery ?? "") @@ -615,26 +1009,18 @@ export class AskToolSession { this.directMatch = exactByQuery.every((matches) => matches.length === 1); const directOptions = exactByQuery.flat(); const trusted = this.directMatch || - this.comparison && metrics.length > 0 || - this.rewritten && this.rewriteChanged && metrics.length > 0 || Boolean(this.formula) || - sources.length > 0 || - this.outcome !== "explain_from_verified_facts" && guides.length > 0; - if (!trusted && !this.categoryPaths.length) { + sources.length > 0; + if (!trusted) { if (!this.rewritten) { this.stage = "rewrite"; this.observation = { unmatchedQueries: this.queries }; return; } - this.options = (await metricCategories()).slice(0, MAX_CATEGORIES).map((category) => ({ - ref: this.refs.issue("category", category, category.path), - kind: "category", - label: category.label, - detail: `${category.count} series; ${category.examples.join(", ")}`, - })); - this.stage = "navigate"; - this.observation = { options: this.options }; - return; + return { + done: true, + output: "I couldn't find a matching series. What metric should I use instead?", + }; } if ( @@ -656,7 +1042,6 @@ export class AskToolSession { } if (this.directMatch && this.outcome === "build_requested_chart") { const refs = directOptions.map(({ ref }) => ref); - this.rememberMetrics(refs); onStatus("Building chart…"); return this.buildChart({ refs }); } @@ -683,6 +1068,105 @@ export class AskToolSession { await this.prepareMetricOptions(); } + /** @param {(status: string) => void} onStatus */ + async searchApiOperations(onStatus) { + onStatus("Searching API…"); + const values = literalArguments(this.request); + const found = await searchApi( + [this.request, ...this.queries], + MAX_OPTIONS, + () => onStatus("Indexing API…"), + ); + const candidates = [...new Map( + [...this.apiHints, ...found].map((operation) => [operation.key, operation]), + ).values()]; + candidates.sort((left, right) => { + const leftRequired = left.parameters.filter((parameter) => parameter.required).length; + const rightRequired = right.parameters.filter((parameter) => parameter.required).length; + return argumentAffinity(values, right, this.request) - + argumentAffinity(values, left, this.request) || + Number(rightRequired === values.length) - Number(leftRequired === values.length) || + (right.score ?? 0) - (left.score ?? 0); + }); + const operations = this.reuseApiContext && this.previousApi + ? [ + this.previousApi.operation, + ...candidates.filter((operation) => operation.key !== this.previousApi?.operation.key), + ] + : candidates; + this.apiOptions = operations.slice(0, MAX_API_OPTIONS).map((operation) => ({ + ref: this.refs.issue("api", operation, operation.key), + label: `${operation.method} ${operation.path} — ${operation.label}`, + operation, + })); + if (!this.apiOptions.length) { + this.stage = "clarify"; + this.observation = { noApiMatch: true }; + return; + } + this.stage = "resolve"; + this.observation = { + verifiedOperations: this.apiOptions.map(({ ref, operation }) => ({ + ref, + method: operation.method, + path: operation.path, + summary: operation.summary || operation.label, + description: operation.description, + parameters: operation.parameters, + response: { + type: operation.response.type, + description: operation.response.description, + fields: operation.response.fields + .slice(0, 12) + .map(({ name, type, description }) => ({ name, type, description })), + }, + })), + }; + } + + /** @param {(status: string) => void} onStatus @param {AbortSignal} signal */ + async tryDirectApi(onStatus, signal) { + const operation = this.apiOptions[0]?.operation; + if (!operation) return undefined; + const required = operation.parameters.filter((parameter) => parameter.required); + const values = literalArguments(this.request); + if (!values.length) { + const arguments_ = reusableArguments(operation, this.previousApi); + if (arguments_ === undefined && required.length) return undefined; + return await this.callApi( + operation, + arguments_ ?? {}, + onStatus, + signal, + ); + } + if (values.length !== required.length) return undefined; + const arguments_ = Object.fromEntries( + required.map((parameter, index) => [parameter.name, values[index]]), + ); + return await this.callApi(operation, arguments_, onStatus, signal); + } + + /** + * @param {import("./api/index.js").ApiOperation} operation + * @param {Record} arguments_ + * @param {(status: string) => void} onStatus + * @param {AbortSignal} signal + */ + async callApi(operation, arguments_, onStatus, signal) { + onStatus("Reading API…"); + const result = await executeApi(operation, arguments_, signal); + this.previousApi = { operation, arguments: result.arguments }; + return { + done: true, + apiGrounding: { + question: this.request, + queries: this.queries, + ...result, + }, + }; + } + /** @param {string[]} queries @param {(status: string) => void} onStatus */ async rewrite(queries, onStatus) { this.rewriteChanged = queries.length !== this.queries.length || @@ -693,16 +1177,6 @@ export class AskToolSession { return await this.search(onStatus) ?? { done: false }; } - /** @param {string[]} selected @param {(status: string) => void} onStatus */ - async navigate(selected, onStatus) { - const categories = selected.map((ref) => this.refs.get(ref, "category")); - this.categoryPaths = categories.map((category) => category.path); - const prefix = categories.map((category) => category.label).join(" "); - this.queries = this.queries.map((query) => `${prefix} ${query}`); - this.query = this.queries.join(" / "); - return await this.search(onStatus) ?? { done: false }; - } - async prepareMetricOptions() { /** @type {MetricOption[]} */ const metrics = []; @@ -744,8 +1218,8 @@ export class AskToolSession { async inspect(selected) { const evidence = { facts: /** @type {string[]} */ ([]), - sources: /** @type {string[]} */ ([]), - excerpts: /** @type {any[]} */ ([]), + sources: /** @type {{ revision: string, path: string, startLine: number, endLine?: number }[]} */ ([]), + excerpts: /** @type {{ revision: string, path: string, startLine: number, endLine?: number, content: string }[]} */ ([]), }; /** @type {string[]} */ const topics = []; @@ -760,12 +1234,11 @@ export class AskToolSession { const metric = await metricByName(fact.fact.metric); if (metric) rememberedMetrics.push(metric); evidence.facts.push(fact.answer); - evidence.sources.push(`${fact.fact.path}:${fact.fact.line}`); + evidence.sources.push(formulaCitation(fact)); } else if (kind === "source") { const match = this.refs.get(ref, "source"); const excerpt = await this.source.read(match.path, match.startLine, match.endLine); evidence.excerpts.push(excerpt); - evidence.sources.push(`${excerpt.path}:${excerpt.startLine}-${excerpt.endLine}`); } else if (kind === "guide") { const guide = this.refs.get(ref, "guide"); if (guide.description) evidence.facts.push(guide.description); @@ -794,7 +1267,7 @@ export class AskToolSession { if (this.formula && selected.some((ref) => this.refs.kind(ref) === "metric")) { evidence.facts.unshift(this.formula.answer); - evidence.sources.push(`${this.formula.fact.path}:${this.formula.fact.line}`); + evidence.sources.push(formulaCitation(this.formula)); } if (rememberedMetrics.length) { @@ -803,7 +1276,18 @@ export class AskToolSession { this.previousMetrics = []; this.previousTopics = [...new Set(topics.length ? topics : this.queries)].slice(0, 4); } - return { done: true, output: renderEvidence(evidence) }; + return { + done: true, + output: renderEvidence(evidence), + ...(evidence.excerpts.length + ? { + grounding: { + question: this.request, + excerpts: evidence.excerpts, + }, + } + : {}), + }; } /** @param {Record} action */ @@ -844,14 +1328,23 @@ export class AskToolSession { : [...prior, ...added.filter((item) => !prior.some((old) => old.path === item.path))]; if (!series.length) throw new Error("A chart needs at least one series"); - const inferredUnit = chosen.map((metric) => metric.suggestedUnit).find(Boolean); + const { unit, conflicts } = resolveChartUnit( + chosen, + existingChart?.chart.unit, + operation, + ); + if (conflicts.length) { + return { + done: true, + output: `Those metrics use different units (${conflicts.map((value) => `**${value.toUpperCase()}**`).join(" and ")}), so they need separate charts. Which one should I chart?`, + artifacts: [], + }; + } const artifact = createChartArtifact({ title: typeof action.title === "string" && action.title.trim() ? action.title.trim() - : existingChart - ? existingChart.chart.title - : chosen.map((metric) => metric.label ?? label(metric.name)).join(" and "), - unit: existingChart ? existingChart.chart.unit : inferredUnit, + : series.map((item) => item.label).join(" and "), + unit, series, }); this.activeChart = artifact; @@ -869,49 +1362,81 @@ export class AskToolSession { * @param {Record} action * @param {(status: string) => void} onStatus */ - async execute(action, onStatus) { + /** @param {Record} action @param {(status: string) => void} onStatus @param {AbortSignal} signal */ + async execute(action, onStatus, signal) { const name = requiredString(action.action, "action"); + if (name === "clarify") { + const text = typeof action.text === "string" ? action.text.trim() : ""; + return { + done: true, + output: text || "I couldn't find a matching series. Which metric should I use instead?", + }; + } if (this.stage === "search") { if (name !== "search") throw new Error("The AI chose an invalid search action"); - const context = requiredString(action.context, "context"); - const proposed = requiredStrings(action.queries, "queries"); - const cardinality = requiredString(action.cardinality, "cardinality"); + this.outcome = requiredString(action.outcome, "outcome"); + if (this.outcome === "clarify_request") { + return { + done: true, + output: requiredString(action.clarification, "clarification"), + }; + } + if (this.outcome === "answer_general") { + return { done: true, general: true }; + } + const context = typeof action.context === "string" && action.context.trim() + ? action.context.trim() + : (this.previousTopics.length || this.previousApi) && referencesPrevious(this.request) + ? "reuse_previous" + : "new_topic"; + const proposed = Array.isArray(action.queries) && action.queries.length + ? requiredStrings(action.queries, "queries") + : [this.request]; + const cardinality = typeof action.cardinality === "string" + ? action.cardinality + : "single"; const effectiveContext = context; - if (effectiveContext !== "new_topic" && !this.previousTopics.length) { + if ( + effectiveContext !== "new_topic" && + !this.previousTopics.length && + !this.previousApi + ) { throw new Error("There is no previous verified topic to reuse"); } const previousTopics = [...this.previousTopics]; const previousMetrics = [...this.previousMetrics]; - const routedQueries = effectiveContext === "reuse_previous" - ? previousTopics - : effectiveContext === "extend_previous" - ? [...new Set([...previousTopics, ...proposed])] - : proposed; + const routedQueries = this.outcome === "read_api" + ? proposed + : effectiveContext === "reuse_previous" + ? previousTopics + : effectiveContext === "extend_previous" + ? [...new Set([...previousTopics, ...proposed])] + : proposed; this.contextMetrics = effectiveContext === "new_topic" ? [] : previousMetrics; - if (effectiveContext === "new_topic") this.rememberMetricValues([]); - this.outcome = requiredString(action.outcome, "outcome"); - if (this.outcome === "answer_general") { - return { done: true, general: true }; + this.reuseApiContext = effectiveContext !== "new_topic" && Boolean(this.previousApi); + if (effectiveContext === "new_topic") { + this.previousMetrics = []; + this.previousTopics = []; + this.previousApi = undefined; } this.focus = evidenceFocus(this.request, "definition"); this.comparison = cardinality === "multiple" || isExplicitComparison(this.request); this.queries = this.comparison ? completeComparisonQueries(routedQueries) : routedQueries.slice(0, 1); - this.categoryPaths = []; this.query = this.queries.join(" / "); - return await this.search(onStatus) ?? { done: false }; + const searched = await this.search(onStatus); + if (this.outcome === "read_api" && this.apiOptions.length) { + const direct = await this.tryDirectApi(onStatus, signal); + if (direct) return direct; + } + return searched ?? { done: false }; } if (this.stage === "resolve" && this.outcome === "explain_from_verified_facts") { if (name !== "answer") throw new Error("The AI chose an invalid evidence action"); onStatus("Inspecting results…"); return this.inspect(uniqueRefs(action.refs)); } - if (this.stage === "navigate") { - if (name !== "navigate") throw new Error("The AI chose an invalid navigation action"); - onStatus("Narrowing search…"); - return this.navigate(uniqueRefs(action.refs), onStatus); - } if (this.stage === "rewrite") { if (name !== "rewrite") throw new Error("The AI chose an invalid rewrite action"); onStatus("Refining search…"); @@ -923,6 +1448,19 @@ export class AskToolSession { onStatus("Reading data…"); return this.read(action); } + if (this.stage === "resolve" && this.outcome === "read_api") { + if (name !== "call_api") throw new Error("The AI chose an invalid API action"); + const ref = requiredString(action.ref, "ref"); + const operation = this.refs.get(ref, "api"); + const supplied = action.arguments && typeof action.arguments === "object" + ? /** @type {Record} */ (action.arguments) + : {}; + const previousApi = this.previousApi; + const arguments_ = previousApi && previousApi.operation.key === operation.key + ? { ...previousApi.arguments, ...supplied } + : supplied; + return await this.callApi(operation, arguments_, onStatus, signal); + } if (this.stage === "resolve") { if (name !== "build_chart" && name !== "edit_chart") { throw new Error("The AI chose an invalid chart action"); @@ -945,9 +1483,22 @@ export class AskToolSession { metrics.map((metric) => [metric.path, metric]), ).values()].slice(0, 6); this.previousTopics = this.previousMetrics.map((metric) => label(metric.name)); + this.recentMetrics = [...new Map( + [...this.previousMetrics, ...this.recentMetrics] + .map((metric) => [metric.path, metric]), + ).values()].slice(0, 6); } metricPaths() { return this.previousMetrics.map((metric) => metric.path); } + + apiContext() { + return this.previousApi + ? { + key: this.previousApi.operation.key, + arguments: this.previousApi.arguments, + } + : undefined; + } } diff --git a/website_next/ask/tools/source/catalog.jsonl.gz b/website_next/ask/tools/source/catalog.jsonl.gz new file mode 100644 index 000000000..f26c9ddb7 Binary files /dev/null and b/website_next/ask/tools/source/catalog.jsonl.gz differ diff --git a/website_next/ask/tools/source/formula.js b/website_next/ask/tools/source/formula.js index cc24acf46..5a252cd51 100644 --- a/website_next/ask/tools/source/formula.js +++ b/website_next/ask/tools/source/formula.js @@ -160,8 +160,7 @@ export function explainFormula(question, formulas) { return { answer: `${metric} is a weighted average of ${values}. ` + - `Each ${fact.value} is weighted by \`${fact.weight}\`.${consequence}\n\n` + - `Source: \`${fact.path}:${fact.line}\``, + `Each ${fact.value} is weighted by \`${fact.weight}\`.${consequence}`, fact, }; } diff --git a/website_next/ask/tools/source/index.js b/website_next/ask/tools/source/index.js index 6eb6df873..2c8b7a53e 100644 --- a/website_next/ask/tools/source/index.js +++ b/website_next/ask/tools/source/index.js @@ -7,6 +7,10 @@ export class AskSource { /** @type {Map void, reject: (error: Error) => void, onProgress?: (progress: { loaded: number, total: number }) => void }>} */ #pending = new Map(); + prewarm() { + return this.#request("prewarm", {}); + } + /** * @param {string} query * @param {string | undefined} path @@ -34,7 +38,7 @@ export class AskSource { } /** - * @param {"search" | "read" | "explain"} type + * @param {"prewarm" | "search" | "read" | "explain"} type * @param {Record} data * @param {((progress: { loaded: number, total: number }) => void) | undefined} [onProgress] */ diff --git a/website_next/ask/tools/source/search.js b/website_next/ask/tools/source/search.js new file mode 100644 index 000000000..939e30e6b --- /dev/null +++ b/website_next/ask/tools/source/search.js @@ -0,0 +1,188 @@ +const MAX_EXCERPT_CHARACTERS = 500; + +/** @param {string} value */ +function normalize(value) { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +/** @param {string} text @param {number} index */ +function lineAt(text, index) { + return text.slice(0, index).split("\n").length; +} + +/** @param {string} text @param {number} line */ +function excerptAt(text, line) { + const lines = text.split("\n"); + const start = Math.max(1, line - 3); + const end = Math.min(lines.length, line + 3); + return { + startLine: start, + endLine: end, + content: lines + .slice(start - 1, end) + .join("\n") + .slice(0, MAX_EXCERPT_CHARACTERS), + }; +} + +/** @param {{ path: string, text: string }[]} files */ +export function createSourceSearchIndex(files) { + /** @type {Map} */ + const postings = new Map(); + + files.forEach((file, fileIndex) => { + const tokens = new Set( + normalize(`${file.path}\n${file.text}`).split(" ").filter(Boolean), + ); + for (const token of tokens) { + const matches = postings.get(token); + if (matches) matches.push(fileIndex); + else postings.set(token, [fileIndex]); + } + }); + + return { + files, + paths: files.map((file) => normalize(file.path)), + postings, + }; +} + +/** @param {string} left @param {string} right */ +function relatedToken(left, right) { + if (left === right) return true; + const shortest = Math.min(left.length, right.length); + if (shortest < 5) return false; + + let prefix = 0; + while (prefix < shortest && left[prefix] === right[prefix]) prefix += 1; + return left.startsWith(right) || + right.startsWith(left) || + prefix >= Math.max(4, shortest - 2); +} + +/** + * @param {ReturnType} index + * @param {string} token + */ +function candidateFiles(index, token) { + const exact = index.postings.get(token); + const matches = new Set(exact ?? []); + if (token.length < 5) return [...matches]; + + for (const [candidate, files] of index.postings) { + if (candidate === token) continue; + if (!relatedToken(candidate, token)) continue; + for (const file of files) matches.add(file); + } + return [...matches]; +} + +/** + * @param {ReturnType} index + * @param {string} rawQuery + * @param {string} [pathPrefix] + */ +export function searchSource(index, rawQuery, pathPrefix = "") { + const query = normalize(rawQuery); + if (!query) throw new Error("Search query is empty"); + + const tokens = [...new Set(query.split(" "))]; + const tokenMatches = tokens + .map((token) => ({ token, files: candidateFiles(index, token) })) + .filter(({ files }) => files.length) + .map(({ token, files }) => ({ + token, + files, + weight: Math.log2((index.files.length + 1) / (files.length + 1)) + 1, + })); + if (!tokenMatches.length) return []; + + /** @type {Map} */ + const candidates = new Map(); + for (const { token, files, weight } of tokenMatches) { + for (const fileIndex of files) { + const candidate = candidates.get(fileIndex) ?? { + weights: [], + queryTokens: [], + matched: 0, + excerptToken: token, + excerptWeight: 0, + }; + candidate.weights.push(weight); + candidate.queryTokens.push(token); + candidate.matched += 1; + if (weight > candidate.excerptWeight) { + candidate.excerptToken = token; + candidate.excerptWeight = weight; + } + candidates.set(fileIndex, candidate); + } + } + + const matches = []; + for (const [fileIndex, candidate] of candidates) { + const file = index.files[fileIndex]; + if (pathPrefix && !file.path.startsWith(pathPrefix)) continue; + + const normalized = normalize(file.text); + const exact = normalized.includes(query); + const normalizedPath = index.paths[fileIndex]; + const pathTokens = normalizedPath.split(" "); + const pathMatches = tokenMatches.filter(({ token }) => + pathTokens.some((pathToken) => relatedToken(pathToken, token)) + ).length; + const source = file.text.toLowerCase(); + let rawIndex = source.indexOf(candidate.excerptToken); + if (rawIndex < 0) { + for (const { token } of [...tokenMatches].sort((left, right) => right.weight - left.weight)) { + rawIndex = source.indexOf(token); + if (rawIndex >= 0) break; + } + } + const line = lineAt(file.text, Math.max(0, rawIndex)); + const semanticScore = [...candidate.weights] + .sort((left, right) => right - left) + .slice(0, 3) + .reduce((sum, weight) => sum + weight, 0); + matches.push({ + path: file.path, + score: semanticScore + + pathMatches * 6 + + (exact ? 10 : 0) + + (normalizedPath.includes(query) ? 10 : 0), + matched: candidate.matched, + queryTokens: candidate.queryTokens, + pathTokens, + ...excerptAt(file.text, line), + }); + } + + const ranked = matches.sort((left, right) => + right.score - left.score || + right.matched - left.matched || + left.path.localeCompare(right.path) + ); + const diversified = ranked.slice(0, 3); + const salient = [...tokenMatches] + .sort((left, right) => right.weight - left.weight) + .slice(0, 5); + for (const { token } of salient) { + const candidate = ranked + .filter((match) => match.queryTokens.includes(token)) + .sort((left, right) => + Number(right.pathTokens.some((pathToken) => relatedToken(pathToken, token))) - + Number(left.pathTokens.some((pathToken) => relatedToken(pathToken, token))) || + right.score - left.score + )[0]; + if (candidate) diversified.push(candidate); + } + + return [...new Map(diversified.map((match) => [match.path, match])).values()] + .slice(0, 8) + .map(({ score, matched, queryTokens, pathTokens, ...match }) => match); +} diff --git a/website_next/ask/tools/source/worker.js b/website_next/ask/tools/source/worker.js index c9f3d2d04..551578047 100644 --- a/website_next/ask/tools/source/worker.js +++ b/website_next/ask/tools/source/worker.js @@ -1,73 +1,11 @@ import { createFormulaIndex, explainFormula } from "./formula.js"; +import { createSourceSearchIndex, searchSource } from "./search.js"; -const REPOSITORY = "bitcoinresearchkit/brk"; -const TREE_URL = `https://api.github.com/repos/${REPOSITORY}/git/trees/main?recursive=1`; -const RAW_URL = `https://raw.githubusercontent.com/${REPOSITORY}`; -const DATABASE_NAME = "bitview-ask-source-v1"; -const STORE_NAME = "snapshots"; -const MAX_FILE_SIZE = 128_000; +const CATALOG_URL = import.meta.resolve("./catalog.jsonl.gz"); const MAX_READ_LINES = 120; const MAX_READ_CHARACTERS = 2_500; -const MAX_EXCERPT_CHARACTERS = 500; -const CONCURRENCY = 12; -const SEARCH_STOPWORDS = new Set([ - "a", - "about", - "and", - "bitview", - "bitcoin", - "brk", - "code", - "define", - "does", - "explain", - "for", - "how", - "in", - "is", - "mean", - "of", - "repo", - "repository", - "source", - "the", - "what", - "where", - "why", - "work", - "works", -]); -const EXTENSIONS = new Set([ - "css", - "html", - "js", - "json", - "md", - "mjs", - "py", - "rs", - "sh", - "toml", - "ts", - "yaml", - "yml", -]); -const EXCLUDED_PREFIXES = [ - ".git/", - ".github/", - "modules/", - "packages/brk_client/brk_client/", - "target/", - "website/assets/", - "website_next/modules/", -]; -const EXCLUDED_FILES = new Set([ - "docs/CHANGELOG.md", - "website/scripts/options/scalar.js", -]); - -/** @type {Promise<{ sha: string, entries: { path: string, size: number }[] }> | undefined} */ -let treePromise; +const READ_CONTEXT_BEFORE = 4; +const READ_CONTEXT_AFTER = 40; /** @type {Promise<{ sha: string, files: { path: string, text: string }[] }> | undefined} */ let snapshotPromise; @@ -75,140 +13,31 @@ let snapshotPromise; /** @type {ReturnType | undefined} */ let formulaIndex; +/** @type {ReturnType | undefined} */ +let sourceSearchIndex; + /** @param {unknown} error */ function errorMessage(error) { return error instanceof Error ? error.message : String(error); } -/** @param {string} path */ -function extension(path) { - return path.slice(path.lastIndexOf(".") + 1).toLowerCase(); -} - -/** @param {{ type: string, path: string, size?: number }} item */ -function isSource(item) { - return ( - item.type === "blob" && - Number(item.size ?? 0) <= MAX_FILE_SIZE && - EXTENSIONS.has(extension(item.path)) && - !EXCLUDED_FILES.has(item.path) && - !EXCLUDED_PREFIXES.some((prefix) => item.path.startsWith(prefix)) - ); -} - -async function loadTree() { - treePromise ??= fetch(TREE_URL, { - headers: { Accept: "application/vnd.github+json" }, - }).then(async (response) => { - if (!response.ok) { - throw new Error(`GitHub source tree unavailable (${response.status})`); - } - - const data = await response.json(); - if (data.truncated) throw new Error("GitHub returned a truncated source tree"); - - const tree = /** @type {{ type: string, path: string, size?: number }[]} */ ( - data.tree - ); - return { - sha: data.sha, - entries: tree.filter(isSource).map((item) => ({ - path: item.path, - size: Number(item.size ?? 0), - })), - }; - }); - - return treePromise; -} - -/** @param {string} sha @param {string} path */ -async function fetchSource(sha, path) { - const encodedPath = path.split("/").map(encodeURIComponent).join("/"); - const response = await fetch(`${RAW_URL}/${sha}/${encodedPath}`); - if (!response.ok) throw new Error(`Could not fetch ${path}`); - return response.text(); -} - -function openDatabase() { - return new Promise((resolve, reject) => { - const request = indexedDB.open(DATABASE_NAME, 1); - request.addEventListener("upgradeneeded", () => { - request.result.createObjectStore(STORE_NAME, { keyPath: "sha" }); - }); - request.addEventListener("success", () => resolve(request.result)); - request.addEventListener("error", () => reject(request.error)); - }); -} - -/** @param {IDBDatabase} database @param {string} sha */ -function readSnapshot(database, sha) { - return new Promise((resolve, reject) => { - const request = database - .transaction(STORE_NAME, "readonly") - .objectStore(STORE_NAME) - .get(sha); - request.addEventListener("success", () => resolve(request.result)); - request.addEventListener("error", () => reject(request.error)); - }); -} - -/** @param {IDBDatabase} database @param {{ sha: string, files: { path: string, text: string }[] }} snapshot */ -function writeSnapshot(database, snapshot) { - return new Promise((resolve, reject) => { - const transaction = database.transaction(STORE_NAME, "readwrite"); - const store = transaction.objectStore(STORE_NAME); - store.clear(); - store.put(snapshot); - transaction.addEventListener("complete", () => resolve(undefined)); - transaction.addEventListener("error", () => reject(transaction.error)); - }); -} - /** @param {(loaded: number, total: number) => void} reportProgress */ async function loadSnapshot(reportProgress) { - const tree = await loadTree(); - const database = /** @type {IDBDatabase} */ (await openDatabase()); - - try { - const cached = /** @type {{ sha: string, files: { path: string, text: string }[] } | undefined} */ ( - await readSnapshot(database, tree.sha) - ); - if (cached) { - reportProgress(cached.files.length, cached.files.length); - return cached; - } - - const files = new Array(tree.entries.length); - let cursor = 0; - let loaded = 0; - reportProgress(loaded, tree.entries.length); - - async function download() { - while (cursor < tree.entries.length) { - const index = cursor; - cursor += 1; - const entry = tree.entries[index]; - files[index] = { - path: entry.path, - text: await fetchSource(tree.sha, entry.path), - }; - loaded += 1; - if (loaded % 20 === 0 || loaded === tree.entries.length) { - reportProgress(loaded, tree.entries.length); - } - } - } - - await Promise.all( - Array.from({ length: Math.min(CONCURRENCY, tree.entries.length) }, download), - ); - const snapshot = { sha: tree.sha, files }; - await writeSnapshot(database, snapshot); - return snapshot; - } finally { - database.close(); + const response = await fetch(CATALOG_URL); + if (!response.ok || !response.body) { + throw new Error(`Source catalog unavailable (${response.status})`); } + const decompressed = response.body.pipeThrough(new DecompressionStream("gzip")); + const text = await new Response(decompressed).text(); + const [rawHeader, ...lines] = text.split("\n"); + const header = /** @type {{ revision: string, count: number }} */ (JSON.parse(rawHeader)); + reportProgress(0, header.count); + const files = lines.filter(Boolean).map((line) => { + const [path, source] = /** @type {[string, string]} */ (JSON.parse(line)); + return { path, text: source }; + }); + reportProgress(files.length, header.count); + return { sha: header.revision, files }; } /** @param {(loaded: number, total: number) => void} reportProgress */ @@ -220,75 +49,17 @@ function ensureSnapshot(reportProgress) { return snapshotPromise; } -/** @param {string} value */ -function normalize(value) { - return value - .toLowerCase() - .replace(/[^a-z0-9]+/g, " ") - .replace(/\s+/g, " ") - .trim(); -} - -/** @param {string} text @param {number} index */ -function lineAt(text, index) { - return text.slice(0, index).split("\n").length; -} - -/** @param {string} text @param {number} line */ -function excerptAt(text, line) { - const lines = text.split("\n"); - const start = Math.max(1, line - 3); - const end = Math.min(lines.length, line + 3); - return { - startLine: start, - endLine: end, - content: lines - .slice(start - 1, end) - .join("\n") - .slice(0, MAX_EXCERPT_CHARACTERS), - }; -} - /** * @param {{ query: string, path?: string }} args * @param {(loaded: number, total: number) => void} reportProgress */ async function search(args, reportProgress) { - const rawQuery = normalize(args.query); - if (!rawQuery) throw new Error("Search query is empty"); const pathPrefix = String(args.path ?? "").replace(/^\/+/, ""); - const rawTokens = rawQuery.split(" "); - const tokens = rawTokens.filter((token) => !SEARCH_STOPWORDS.has(token)); - const searchTokens = tokens.length ? tokens : rawTokens; - const query = searchTokens.join(" "); const snapshot = await ensureSnapshot(reportProgress); - const matches = []; - - for (const file of snapshot.files) { - if (pathPrefix && !file.path.startsWith(pathPrefix)) continue; - const normalized = normalize(file.text); - let index = normalized.indexOf(query); - let score = 2; - - if (index < 0 && searchTokens.every((token) => normalized.includes(token))) { - index = normalized.indexOf(searchTokens[0]); - score = 1; - } - if (index < 0) continue; - - const rawIndex = file.text.toLowerCase().indexOf(searchTokens[0]); - const line = lineAt(file.text, Math.max(0, rawIndex)); - matches.push({ - path: file.path, - score: score + (normalize(file.path).includes(query) ? 2 : 0), - ...excerptAt(file.text, line), - }); - } - - matches.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path)); + sourceSearchIndex ??= createSourceSearchIndex(snapshot.files); return { revision: snapshot.sha, - matches: matches.slice(0, 4).map(({ score, ...match }) => match), + matches: searchSource(sourceSearchIndex, String(args.query ?? ""), pathPrefix), }; } @@ -306,17 +77,16 @@ async function explain(args, reportProgress) { /** @param {{ path: string, startLine: number, endLine: number }} args */ async function read(args) { const path = String(args.path).replace(/^\/+/, ""); - const startLine = Math.max(1, Math.floor(Number(args.startLine))); - const requestedEnd = Math.max(startLine, Math.floor(Number(args.endLine))); - const endLine = Math.min(requestedEnd, startLine + MAX_READ_LINES - 1); - const tree = await loadTree(); - if (!tree.entries.some((entry) => entry.path === path)) { - throw new Error(`Source file not found: ${path}`); - } - - const snapshot = await snapshotPromise; - const text = snapshot?.files.find((file) => file.path === path)?.text ?? - (await fetchSource(tree.sha, path)); + const selectedStart = Math.max(1, Math.floor(Number(args.startLine))); + const selectedEnd = Math.max(selectedStart, Math.floor(Number(args.endLine))); + const startLine = Math.max(1, selectedStart - READ_CONTEXT_BEFORE); + const endLine = Math.min( + selectedEnd + READ_CONTEXT_AFTER, + startLine + MAX_READ_LINES - 1, + ); + const snapshot = await ensureSnapshot(() => {}); + const text = snapshot.files.find((file) => file.path === path)?.text; + if (text === undefined) throw new Error(`Source file not found: ${path}`); const lines = text.split("\n"); if (startLine > lines.length) { throw new Error(`${path} only has ${lines.length} lines`); @@ -325,7 +95,7 @@ async function read(args) { const content = lines.slice(startLine - 1, lastLine).join("\n"); return { - revision: tree.sha, + revision: snapshot.sha, path, startLine, endLine: lastLine, @@ -341,7 +111,9 @@ self.addEventListener("message", async (event) => { }; try { - const result = type === "search" + const result = type === "prewarm" + ? await explain({ question: "" }, reportProgress) + : type === "search" ? await search(data, reportProgress) : type === "explain" ? await explain(data, reportProgress) diff --git a/website_next/ask/worker.js b/website_next/ask/worker.js index eb49a79b1..d192fb9de 100644 --- a/website_next/ask/worker.js +++ b/website_next/ask/worker.js @@ -50,9 +50,15 @@ async function load() { return; } - if (!navigator.gpu) throw new Error("WebGPU is unavailable in this browser"); + if (!/** @type {any} */ (navigator).gpu) { + throw new Error("WebGPU is unavailable in this browser"); + } self.postMessage({ status: "loading", data: "Loading AI runtime..." }); + // BitGPU 0.19.1's smaller prefill segments keep the UI responsive during + // long grounded prompts. Replace this compatibility hook when BitGPU + // exposes the segment size as a public engine option. + /** @type {any} */ (globalThis).__SEG = 64; const [{ createEngine }, { createChat }] = await Promise.all([ import(ASK_MODEL.runtimeUrl), import(ASK_MODEL.chatUrl), @@ -125,7 +131,7 @@ async function generate(messages, options) { const result = await chat.send(messages, { maxTokens: options.maxTokens, temperature: 0, - repetitionPenalty: 1.05, + repetitionPenalty: 1, signal: generationController.signal, tools, toolChoice: tools ? options.toolChoice : undefined, @@ -172,7 +178,7 @@ self.addEventListener("message", async (event) => { break; case "generate": await generate(data.messages, { - maxTokens: 256, + maxTokens: data.maxTokens ?? 256, stream: true, tools: data.tools, toolChoice: data.toolChoice, diff --git a/website_next/llms-full.txt b/website_next/llms-full.txt index 373f95b62..85ed48b34 100644 --- a/website_next/llms-full.txt +++ b/website_next/llms-full.txt @@ -1,497 +1,2059 @@ # Bitcoin Research Kit (BRK) — Full API Reference -> Free, open-source Bitcoin on-chain analytics API. 49,000+ time-series, block explorer, address index, mempool, mining stats — all computed from a Bitcoin Core node. No auth required. +> Generated from BRK's OpenAPI specification and metric tree. Do not edit this file manually. -Base URL: https://bitview.space -GitHub: https://github.com/bitcoinresearchkit/brk -License: MIT +- Version: `v0.3.6` +- Base URL: https://bitview.space +- Metrics: 55667 +- Operations: 97 -## Quick Start +For machine-readable tool construction, use [https://bitview.space/openapi.json](https://bitview.space/openapi.json). For the complete source-derived series tree, use [https://bitview.space/api/series](https://bitview.space/api/series). - # Search for series by keyword - curl -s "https://bitview.space/api/series/search?q=price" +## Operations - # Get Bitcoin closing price for the last 30 days - curl -s "https://bitview.space/api/series/price/day?start=-30" +### Address - # Get just the latest price - curl -s "https://bitview.space/api/series/price/day/latest" +#### GET `/api/address/hash-prefix/{addr_type}/{prefix}` - # Bulk query multiple series - curl -s "https://bitview.space/api/series/bulk?index=day&series=price,market_cap&start=-7" +Address hash-prefix matches - # Get the genesis block - curl -s "https://bitview.space/api/block-height/0" +Find addresses by address type and by the first 1-16 hex nibbles of RapidHash v3 over the raw address payload bytes. Intended for privacy-preserving client-side wallet discovery without sending raw addresses or xpubs. Fetch metadata for the returned addresses through `/api/address/{address}`. - # Get an address balance - curl -s "https://bitview.space/api/address/1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa" +Parameters: +- `addr_type` (path, OutputType, required) +- `prefix` (path, string, required) - # Get current fee estimates - curl -s "https://bitview.space/api/v1/fees/recommended" +Returns: JSON `AddrHashPrefixMatches` - # Get the live mempool-derived price - curl -s "https://bitview.space/api/mempool/price" +```bash +curl -s "https://bitview.space/api/address/hash-prefix//" +``` ---- +#### GET `/api/address/{address}` -## Response Format +Address information -All endpoints return JSON by default. Series endpoints also support CSV via `?format=csv`. +Retrieve address information including current balance and transaction counts. Supports all standard Bitcoin address types (P2PKH, P2SH, P2WPKH, P2WSH, P2TR). *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address)* -Errors return: +Parameters: +- `address` (path, Addr, required) - { - "error": { - "type": "not_found", - "code": "series_not_found", - "message": "'foo' not found, did you mean 'bar'?", - "doc_url": "/api" - } - } +Returns: JSON `AddrStats` -Error types: `invalid_request` (400), `forbidden` (403), `not_found` (404), `unavailable` (503), `internal` (500). +```bash +curl -s "https://bitview.space/api/address/
" +``` -## Range Parameters +#### GET `/api/address/{address}/txs` -`start` and `end` are optional on all series data endpoints. They accept: -- Dates: `2025-01-01` -- Integers: block height or absolute index -- Negative integers: relative offset from latest (`-30` = last 30 entries) -- ISO 8601 timestamps +Address transactions -## Indexes +Get transaction history for an address, newest first. Returns up to 50 mempool transactions plus a confirmed page sized to fill the response to 50 total (chain floor of 25, so 25-50 confirmed depending on mempool weight). To paginate further confirmed history, use `/address/{address}/txs/chain/{last_seen_txid}`. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions)* -Series are queryable by time-based and block-based indexes. Common aliases are accepted (e.g. `day` for `day1`, `week` for `week1`). +Parameters: +- `address` (path, Addr, required) -Time-based: `minute10`, `minute30`, `hour1`, `hour4`, `hour12`, `day1`, `day3`, `week1`, `month1`, `month3`, `month6`, `year1`, `year10`, `halving`, `epoch` -Block-based: `height` +Returns: JSON `Transaction[]` -Common aliases: `day` = `day1`, `week` = `week1`, `month` = `month1`, `hour` = `hour1`, `h` = `height` +```bash +curl -s "https://bitview.space/api/address/
/txs" +``` -Not all series support all indexes. Use `GET /api/series/{name}` to see which indexes a series supports. +#### GET `/api/address/{address}/txs/chain` ---- +Address confirmed transactions -## Server +Get the first 25 confirmed transactions for an address. For pagination, use the path-style form `/txs/chain/{last_seen_txid}`. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-chain)* -### GET https://bitview.space/health +Parameters: +- `address` (path, Addr, required) -Health check. +Returns: JSON `Transaction[]` - → {"status": "healthy", "service": "brk", "version": "0.2.1", "indexed_height": 941908, "blocks_behind": 0, "uptime_seconds": 11806, ...} +```bash +curl -s "https://bitview.space/api/address/
/txs/chain" +``` -### GET https://bitview.space/version +#### GET `/api/address/{address}/txs/chain/{after_txid}` -API version string. +Address confirmed transactions (paginated) - → "0.2.1" +Get the next 25 confirmed transactions strictly older than `after_txid` (Esplora-canonical pagination form, matches mempool.space). *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-chain)* -### GET https://bitview.space/api/server/sync +Parameters: +- `address` (path, Addr, required) +- `after_txid` (path, Txid, required): Last txid from the previous page (return transactions strictly older than this) -Sync status. +Returns: JSON `Transaction[]` - → {"indexed_height": 941908, "computed_height": 941908, "tip_height": 941908, "blocks_behind": 0, "last_indexed_at": "2026-03-23T19:13:32Z"} +```bash +curl -s "https://bitview.space/api/address/
/txs/chain/" +``` -### GET https://bitview.space/api/server/disk +#### GET `/api/address/{address}/txs/mempool` -Disk usage for BRK data and Bitcoin Core data directories. +Address mempool transactions ---- +Get unconfirmed transactions for an address from the mempool, newest first (up to 50). *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-mempool)* -## Series +Parameters: +- `address` (path, Addr, required) -The core feature. 49,000+ on-chain time-series. +Returns: JSON `Transaction[]` -### GET https://bitview.space/api/series/search?q={query} +```bash +curl -s "https://bitview.space/api/address/
/txs/mempool" +``` -Fuzzy search series by name. Returns an array of matching series names. +#### GET `/api/address/{address}/utxo` - → ["price", "price_ath", "price_low", "price_sats", "price_high", "price_ohlc", ...] +Address UTXOs -### GET https://bitview.space/api/series/{series}/{index} +Get unspent transaction outputs (UTXOs) for an address. Returns txid, vout, value, and confirmation status for each UTXO. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-utxo)* -Fetch series data. Parameters: `format` (json|csv), `start`, `end`. +Parameters: +- `address` (path, Addr, required) -The `data` array contains raw values. Their position maps to the index range `start..end`. For date-based indexes, compute dates from the index type and position. +Returns: JSON `Utxo[]` - GET /api/series/price/day?start=-3 - → { - "version": 69, - "index": "day1", - "type": "Dollars", - "total": 6291, - "start": 6288, - "end": 6291, - "stamp": "2026-03-23T19:06:40Z", - "data": [70077.35, 68301.76, 70788.45] - } +```bash +curl -s "https://bitview.space/api/address/
/utxo" +``` -Some series return compound values per entry (e.g. OHLC): +### Api.json - GET /api/series/price_ohlc/day?start=-1 - → { ..., "type": "OHLCDollars", "data": [[68251.03, 71455.61, 67682.26, 70788.45]] } +#### GET `/api.json` -CSV format returns the series name as header, then one value per line: +Compact OpenAPI specification - GET /api/series/price/day?start=-3&format=csv - → price - 70077.35 - 68301.76 - 70788.45 +Compact OpenAPI specification optimized for LLM consumption. Removes redundant fields while preserving essential API information. Full spec available at `/openapi.json`. -### GET https://bitview.space/api/series/{series}/{index}/data +Returns: JSON `*` -Raw data array only (no metadata wrapper). Same parameters. +```bash +curl -s "https://bitview.space/api.json" +``` - GET /api/series/price/day/data?start=-3 - → [70077.35, 68301.76, 70788.45] +### Block -### GET https://bitview.space/api/series/{series}/{index}/latest +#### GET `/api/block/{hash}` -Most recent value only. +Block information - GET /api/series/price/day/latest - → 70788.45 +Retrieve block information by block hash. Returns block metadata including height, timestamp, difficulty, size, weight, and transaction count. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block)* -### GET https://bitview.space/api/series/{series} +Parameters: +- `hash` (path, BlockHash, required) -Series metadata: available indexes and value type. +Returns: JSON `BlockInfo` - GET /api/series/price - → { - "indexes": ["minute10", "minute30", "hour1", "hour4", "hour12", - "day1", "day3", "week1", "month1", "month3", "month6", - "year1", "year10", "halving", "epoch", "height"], - "type": "Dollars" - } +```bash +curl -s "https://bitview.space/api/block/" +``` -### GET https://bitview.space/api/series +#### GET `/api/block/{hash}/header` -Full hierarchical catalog of all series as a tree. +Block header -### GET https://bitview.space/api/series/list +Returns the hex-encoded 80-byte block header. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-header)* -Paginated list of all series. Supports `?page=N` (default 0, 1000 per page). +Parameters: +- `hash` (path, BlockHash, required) - → {"current_page": 0, "max_page": 49, "total_count": 49259, "per_page": 1000, "has_more": true, "series": ["fee", "nvt", ...]} +Returns: text `Hex` -### GET https://bitview.space/api/series/count +```bash +curl -s "https://bitview.space/api/block//header" +``` -Series count by category. Returns totals and per-database breakdowns. +#### GET `/api/block/{hash}/raw` -### GET https://bitview.space/api/series/indexes +Raw block -List all available indexes with their aliases. +Returns the raw block data in binary format. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-raw)* - → [{"index": "day1", "aliases": ["1d", "d", "day", "date", "daily", "day1", "dateindex"]}, ...] +Parameters: +- `hash` (path, BlockHash, required) -### GET https://bitview.space/api/series/{series}/{index}/len +Returns: binary data -Number of data points in the series. +```bash +curl -s "https://bitview.space/api/block//raw" +``` -### GET https://bitview.space/api/series/{series}/{index}/version +#### GET `/api/block/{hash}/status` -Version number (increments when data changes). +Block status -### GET https://bitview.space/api/series/bulk?index={index}&series={s1},{s2} +Retrieve the status of a block. Returns whether the block is in the best chain and, if so, its height and the hash of the next block. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-status)* -Fetch multiple series at once. Parameters: `series` (comma-separated, required), `index` (required), `format`, `start`, `end`. +Parameters: +- `hash` (path, BlockHash, required) - GET /api/series/bulk?index=day&series=price,market_cap&start=-1 - → [ - {"version": 69, "index": "day1", "type": "Dollars", "total": 6291, "start": 6290, "end": 6291, "stamp": "...", "data": [70788.45]}, - {"version": 83, "index": "day1", "type": "Dollars", "total": 6291, "start": 6290, "end": 6291, "stamp": "...", "data": [1416174345666.71]} - ] +Returns: JSON `BlockStatus` -CSV bulk format uses one column per series: +```bash +curl -s "https://bitview.space/api/block//status" +``` - GET /api/series/bulk?index=day&series=price,market_cap&start=-1&format=csv - → price,market_cap - 70788.45,1416174345666.71 +#### GET `/api/block/{hash}/txid/{index}` -### Cost Basis Distribution +Transaction ID at index -#### GET https://bitview.space/api/series/cost-basis -List available cohorts. +Retrieve a single transaction ID at a specific index within a block. Returns plain text txid. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-transaction-id)* -#### GET https://bitview.space/api/series/cost-basis/{cohort}/dates -Available snapshot dates for a cohort. +Parameters: +- `hash` (path, BlockHash, required): Bitcoin block hash +- `index` (path, BlockTxIndex, required): Transaction index within the block (0-based) -#### GET https://bitview.space/api/series/cost-basis/{cohort}/{date} -Distribution data. Parameters: `bucket` (raw|lin200|lin500|lin1000|log10|log50|log100), `value` (supply|realized|unrealized). +Returns: text `Txid` ---- +```bash +curl -s "https://bitview.space/api/block//txid/" +``` -## Blocks +#### GET `/api/block/{hash}/txids` -Mempool.space compatible. +Block transaction IDs -### GET https://bitview.space/api/block-height/{height} +Retrieve all transaction IDs in a block. Returns an array of txids in block order. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-transaction-ids)* -Single block by height. +Parameters: +- `hash` (path, BlockHash, required) - GET /api/block-height/0 - → { - "id": "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f", - "height": 0, - "tx_count": 1, - "size": 285, - "weight": 1140, - "timestamp": 1231006505, - "difficulty": 1.0 - } +Returns: JSON `Txid[]` -### GET https://bitview.space/api/block/{hash} +```bash +curl -s "https://bitview.space/api/block//txids" +``` -Single block by hash. Same response shape. +#### GET `/api/block/{hash}/txs` -### GET https://bitview.space/api/blocks +Block transactions -Last 10 blocks. Returns array of block objects. +Retrieve transactions in a block by block hash. Returns up to 25 transactions starting from index 0. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-transactions)* -### GET https://bitview.space/api/blocks/{height} +Parameters: +- `hash` (path, BlockHash, required) -Up to 10 blocks ending at `{height}` (descending). +Returns: JSON `Transaction[]` -### GET https://bitview.space/api/block/{hash}/status +```bash +curl -s "https://bitview.space/api/block//txs" +``` - → {"in_best_chain": true, "height": 916656, "next_best": "0000..."} +#### GET `/api/block/{hash}/txs/{start_index}` -### GET https://bitview.space/api/block/{hash}/txids +Block transactions (paginated) -Array of all transaction IDs in the block. +Retrieve transactions in a block by block hash, starting from the specified index. Returns up to 25 transactions at a time. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-transactions)* -### GET https://bitview.space/api/block/{hash}/txs/{start_index} +Parameters: +- `hash` (path, BlockHash, required): Bitcoin block hash +- `start_index` (path, BlockTxIndex, required): Starting transaction index within the block (0-based) -Paginated transactions in the block. +Returns: JSON `Transaction[]` -### GET https://bitview.space/api/block/{hash}/txid/{index} +```bash +curl -s "https://bitview.space/api/block//txs/" +``` -Single txid at position `index`. +#### GET `/api/v1/block/{hash}` -### GET https://bitview.space/api/block/{hash}/raw +Block (v1) -Raw block bytes. +Returns block details with extras by hash. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-v1)* -### GET https://bitview.space/api/v1/mining/blocks/timestamp/{timestamp} +Parameters: +- `hash` (path, BlockHash, required) -Block closest to a UNIX timestamp. +Returns: JSON `BlockInfoV1` ---- +```bash +curl -s "https://bitview.space/api/v1/block/" +``` -## Transactions +### Block Height -Mempool.space compatible. +#### GET `/api/block-height/{height}` -### GET https://bitview.space/api/tx/{txid} +Block hash by height -Full transaction data. Values in satoshis (1 BTC = 100,000,000 sats). +Retrieve the block hash at a given height. Returns the hash as plain text. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-height)* - → { - "index": 0, - "txid": "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b", - "version": 1, - "locktime": 0, - "size": 204, - "weight": 816, - "sigops": 4, - "fee": 0, - "vin": [{ - "txid": "0000000000000000000000000000000000000000000000000000000000000000", - "vout": 65535, - "prevout": null, - "scriptsig": "04ffff001d...", - "scriptsig_asm": "OP_PUSHBYTES_4 ...", - "is_coinbase": true, - "sequence": 4294967295 - }], - "vout": [{ - "scriptpubkey": "4104678a...", - "scriptpubkey_asm": "OP_PUSHBYTES_65 ... OP_CHECKSIG", - "scriptpubkey_type": "p2pk65", - "scriptpubkey_address": "04678afdb0...", - "value": 5000000000 - }], - "status": { - "confirmed": true, - "block_height": 0, - "block_hash": "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f", - "block_time": 1231006505 - } - } +Parameters: +- `height` (path, Height, required) -### GET https://bitview.space/api/tx/{txid}/status +Returns: text `BlockHash` - → {"confirmed": true, "block_height": 0, "block_hash": "0000...", "block_time": 1231006505} +```bash +curl -s "https://bitview.space/api/block-height/" +``` -### GET https://bitview.space/api/tx/{txid}/hex +### Blocks -Raw transaction hex string. +#### GET `/api/blocks` -### GET https://bitview.space/api/tx/{txid}/outspend/{vout} +Recent blocks - → {"spent": true, "txid": "...", "vin": 0, "status": {...}} +Retrieve the last 10 blocks. Returns block metadata for each block. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks)* -### GET https://bitview.space/api/tx/{txid}/outspends +Returns: JSON `BlockInfo[]` -Array of spend status for all outputs. +```bash +curl -s "https://bitview.space/api/blocks" +``` ---- +#### GET `/api/blocks/tip/hash` -## Addresses +Block tip hash -Mempool.space compatible. Supports P2PKH, P2SH, P2WPKH, P2WSH, P2TR. +Returns the hash of the last block. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-tip-hash)* -### GET https://bitview.space/api/address/{address} +Returns: text `BlockHash` -Address summary. Values in satoshis. +```bash +curl -s "https://bitview.space/api/blocks/tip/hash" +``` - GET /api/address/1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa - → { - "address": "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", - "chain_stats": { - "funded_txo_count": 73262, - "funded_txo_sum": 5715642026, - "spent_txo_count": 0, - "spent_txo_sum": 0, - "tx_count": 62198, - "type_index": 371955 - }, - "mempool_stats": { - "funded_txo_count": 0, - "funded_txo_sum": 0, - "spent_txo_count": 0, - "spent_txo_sum": 0, - "tx_count": 0 - } - } +#### GET `/api/blocks/tip/height` -### GET https://bitview.space/api/address/{address}/txs +Block tip height -Transaction history (up to 75 per page). Paginate with `?after_txid={txid}`. +Returns the height of the last block. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-tip-height)* -### GET https://bitview.space/api/address/{address}/txs/chain +Returns: text `Height` -Confirmed transactions only (25 per page). Paginate with `?after_txid={txid}`. +```bash +curl -s "https://bitview.space/api/blocks/tip/height" +``` -### GET https://bitview.space/api/address/{address}/txs/mempool +#### GET `/api/blocks/{height}` -Unconfirmed transactions (up to 50). +Blocks from height -### GET https://bitview.space/api/address/{address}/utxo +Retrieve up to 10 blocks going backwards from the given height. For example, height=100 returns blocks 100, 99, 98, ..., 91. Height=0 returns only block 0. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks)* -Unspent outputs. +Parameters: +- `height` (path, Height, required) - → [{"txid": "...", "vout": 0, "status": {...}, "value": 5000000000}] +Returns: JSON `BlockInfo[]` -### GET https://bitview.space/api/v1/validate-address/{address} +```bash +curl -s "https://bitview.space/api/blocks/" +``` - → {"isvalid": true, "address": "...", "scriptPubKey": "...", "isscript": false, "iswitness": true, "witness_version": 0, "witness_program": "..."} +#### GET `/api/v1/blocks` ---- +Recent blocks with extras -## Mempool +Retrieve the last 15 blocks with extended data including pool identification and fee statistics. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks-v1)* -### GET https://bitview.space/api/mempool/price +Returns: JSON `BlockInfoV1[]` -Live BTC/USD price derived from on-chain round-dollar transaction patterns. +```bash +curl -s "https://bitview.space/api/v1/blocks" +``` - → 70817.64 +#### GET `/api/v1/blocks/{height}` -### GET https://bitview.space/api/mempool/info +Blocks from height with extras - → {"count": 24611, "vsize": 2185871, "total_fee": 6250465} +Retrieve up to 15 blocks with extended data going backwards from the given height. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-blocks-v1)* -### GET https://bitview.space/api/mempool/txids +Parameters: +- `height` (path, Height, required) -Array of all transaction IDs in the mempool. +Returns: JSON `BlockInfoV1[]` -### GET https://bitview.space/api/v1/fees/recommended +```bash +curl -s "https://bitview.space/api/v1/blocks/" +``` -Fee rate recommendations in sat/vB. +### Cpfp - → {"fastestFee": 3.0, "halfHourFee": 0.135, "hourFee": 0.111, "economyFee": 0.106, "minimumFee": 0.1} +#### GET `/api/v1/cpfp/{txid}` -### GET https://bitview.space/api/v1/fees/mempool-blocks +CPFP info -Projected mempool blocks. +Returns ancestors and descendants for a CPFP (Child Pays For Parent) transaction, including the effective fee rate of the package. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-children-pay-for-parent)* - → [{"blockSize": 3999892, "blockVSize": 999973.0, "nTx": 3743, "totalFees": 4148496, "medianFee": 3.0, "feeRange": [2.0, 2.0, 2.173, 3.0, 3.965, 5.969, 347.223]}] +Parameters: +- `txid` (path, Txid, required) ---- +Returns: JSON `CpfpInfo` -## Mining +```bash +curl -s "https://bitview.space/api/v1/cpfp/" +``` -Mempool.space compatible. Time periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. +### Difficulty Adjustment -### GET https://bitview.space/api/v1/mining/pools +#### GET `/api/v1/difficulty-adjustment` -All known mining pools. +Difficulty adjustment -### GET https://bitview.space/api/v1/mining/pools/{time_period} +Get current difficulty adjustment progress and estimates. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-difficulty-adjustment)* -Pool statistics for a time period. +Returns: JSON `DifficultyAdjustment` -### GET https://bitview.space/api/v1/mining/pool/{slug} +```bash +curl -s "https://bitview.space/api/v1/difficulty-adjustment" +``` -Detailed info for a specific pool. +### Fees -### GET https://bitview.space/api/v1/difficulty-adjustment +#### GET `/api/v1/fees/mempool-blocks` -Current difficulty epoch: progress, estimated adjustment, remaining blocks/time. +Projected mempool blocks -### GET https://bitview.space/api/v1/mining/difficulty-adjustments +Projected blocks for fee estimation. Block 0 reflects Bitcoin Core's actual next-block selection; blocks 1+ are a fee-tier approximation. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mempool-blocks-fees)* -All historical difficulty adjustments. +Returns: JSON `MempoolBlock[]` -### GET https://bitview.space/api/v1/mining/difficulty-adjustments/{time_period} +```bash +curl -s "https://bitview.space/api/v1/fees/mempool-blocks" +``` -Difficulty adjustments for a given time period. +#### GET `/api/v1/fees/precise` -### GET https://bitview.space/api/v1/mining/hashrate +Precise recommended fees -All-time hashrate and difficulty data. +Recommended fee rates with sub-integer precision. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-recommended-fees-precise)* -### GET https://bitview.space/api/v1/mining/hashrate/{time_period} +Returns: JSON `RecommendedFees` -Hashrate and difficulty for a given time period. +```bash +curl -s "https://bitview.space/api/v1/fees/precise" +``` -### GET https://bitview.space/api/v1/mining/blocks/fees/{time_period} +#### GET `/api/v1/fees/recommended` -Average block fees over time. +Recommended fees -### GET https://bitview.space/api/v1/mining/blocks/rewards/{time_period} +Recommended fee rates by confirmation target. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-recommended-fees)* -Average block rewards (subsidy + fees) over time. +Returns: JSON `RecommendedFees` -### GET https://bitview.space/api/v1/mining/blocks/sizes-weights/{time_period} +```bash +curl -s "https://bitview.space/api/v1/fees/recommended" +``` -Average block sizes and weights over time. +### Fullrbf -### GET https://bitview.space/api/v1/mining/reward-stats/{block_count} +#### GET `/api/v1/fullrbf/replacements` -Reward statistics for the last N blocks. +Recent full-RBF replacements ---- +Like `/api/v1/replacements`, but limited to trees where at least one predecessor was non-signaling (full-RBF). *[Mempool.space docs](https://mempool.space/docs/api/rest#get-fullrbf-replacements)* -## Series Categories +Returns: JSON `ReplacementNode[]` -49,000+ series across these categories. Use `/api/series/search?q={keyword}` to discover, or `/api/series` for the full tree. +```bash +curl -s "https://bitview.space/api/v1/fullrbf/replacements" +``` -- **Market**: price, market_cap, realized_cap, mvrv, nvt, thermocap, and variants (SMA, rolling) -- **Supply**: circulating, issued, inflation_rate, subsidy -- **Mining**: hashrate, difficulty, revenue, fees, block size/weight stats -- **Network activity**: transaction counts, volumes, active addresses -- **UTXO age bands**: HODL waves, realized cap by age cohort -- **Cointime economics**: liveliness, vaultedness, activity-to-vaultedness ratio -- **Holder cohorts**: by balance range (plankton to whale), by holding duration (short/long-term) -- **Cost basis**: UTXO realized price distributions by cohort -- **Addresses**: total, new, active, empty, by balance range, by type +### Health ---- +#### GET `/health` -## Client Libraries +Health check + +Liveness probe. Returns server identity, uptime, and indexed/computed heights from local state only (no bitcoind round-trip). For real chain-tip catch-up, see `/api/server/sync`. + +Returns: JSON `Health` + +```bash +curl -s "https://bitview.space/health" +``` + +### Historical Price + +#### GET `/api/v1/historical-price` + +Historical price + +Get historical BTC/USD price. Optionally specify a UNIX timestamp to get the price at that time. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-historical-price)* + +Parameters: +- `timestamp` (query, Timestamp, optional) + +Returns: JSON `HistoricalPrice` + +```bash +curl -s "https://bitview.space/api/v1/historical-price?timestamp=" +``` + +### Mempool + +#### GET `/api/mempool` + +Mempool statistics + +Get current mempool statistics including transaction count, total vsize, total fees, and fee histogram. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mempool)* + +Returns: JSON `MempoolInfo` + +```bash +curl -s "https://bitview.space/api/mempool" +``` + +#### GET `/api/mempool/hash` + +Mempool content hash + +Returns an opaque hash that changes whenever the projected next block changes. Same value as the mempool ETag. Useful as a freshness/liveness signal: if it stays constant for tens of seconds on a live network, the mempool sync loop has stalled. + +Returns: JSON `NextBlockHash` + +```bash +curl -s "https://bitview.space/api/mempool/hash" +``` + +#### GET `/api/mempool/price` + +Live BTC/USD price + +Returns the current BTC/USD price in dollars, derived from on-chain round-dollar output patterns in the last 12 blocks plus mempool. + +Returns: JSON `Dollars` + +```bash +curl -s "https://bitview.space/api/mempool/price" +``` + +#### GET `/api/mempool/recent` + +Recent mempool transactions + +Get the last 10 transactions to enter the mempool. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mempool-recent)* + +Returns: JSON `MempoolRecentTx[]` + +```bash +curl -s "https://bitview.space/api/mempool/recent" +``` + +#### GET `/api/mempool/txids` + +Mempool transaction IDs + +Get all transaction IDs currently in the mempool. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mempool-transaction-ids)* + +Returns: JSON `Txid[]` + +```bash +curl -s "https://bitview.space/api/mempool/txids" +``` + +#### GET `/api/v1/mempool/block-template` + +Projected next block template + +Bitcoin Core's `getblocktemplate` selection: full transaction bodies in GBT order with aggregate stats. The returned `hash` is an opaque content token; pass it as `` on `/api/v1/mempool/block-template/diff/{hash}` to fetch deltas instead of refetching the whole template. + +Returns: JSON `BlockTemplate` + +```bash +curl -s "https://bitview.space/api/v1/mempool/block-template" +``` + +#### GET `/api/v1/mempool/block-template/diff/{hash}` + +Block template diff since hash + +Delta of the projected next block since ``. `order` is the full new template in order: each entry is either a number (index into the prior template the client cached at ``) or a transaction object (new body to insert at this position). Walk `order` once to rebuild; `removed` is a convenience list of txids that left so clients can evict cached bodies. After applying, use the response `hash` as `` on the next call to keep iterating. Returns `404` when `` has aged out of server history; clients should fall back to `/api/v1/mempool/block-template`. + +Parameters: +- `hash` (path, NextBlockHash, required) + +Returns: JSON `BlockTemplateDiff` + +```bash +curl -s "https://bitview.space/api/v1/mempool/block-template/diff/" +``` + +### Mining + +#### GET `/api/v1/mining/blocks/fee-rates/{time_period}` + +Block fee rates + +Get block fee rate percentiles (min, 10th, 25th, median, 75th, 90th, max) for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-feerates)* + +Parameters: +- `time_period` (path, TimePeriod, required) + +Returns: JSON `BlockFeeRatesEntry[]` + +```bash +curl -s "https://bitview.space/api/v1/mining/blocks/fee-rates/" +``` + +#### GET `/api/v1/mining/blocks/fees/{time_period}` + +Block fees + +Get average total fees per block for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-fees)* + +Parameters: +- `time_period` (path, TimePeriod, required) + +Returns: JSON `BlockFeesEntry[]` + +```bash +curl -s "https://bitview.space/api/v1/mining/blocks/fees/" +``` + +#### GET `/api/v1/mining/blocks/rewards/{time_period}` + +Block rewards + +Get average coinbase reward (subsidy + fees) per block for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-rewards)* + +Parameters: +- `time_period` (path, TimePeriod, required) + +Returns: JSON `BlockRewardsEntry[]` + +```bash +curl -s "https://bitview.space/api/v1/mining/blocks/rewards/" +``` + +#### GET `/api/v1/mining/blocks/sizes-weights/{time_period}` + +Block sizes and weights + +Get average block sizes and weights for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-sizes-weights)* + +Parameters: +- `time_period` (path, TimePeriod, required) + +Returns: JSON `BlockSizesWeights` + +```bash +curl -s "https://bitview.space/api/v1/mining/blocks/sizes-weights/" +``` + +#### GET `/api/v1/mining/blocks/timestamp/{timestamp}` + +Block by timestamp + +Find the block closest to a given UNIX timestamp. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-block-timestamp)* + +Parameters: +- `timestamp` (path, Timestamp, required) + +Returns: JSON `BlockTimestamp` + +```bash +curl -s "https://bitview.space/api/v1/mining/blocks/timestamp/" +``` + +#### GET `/api/v1/mining/difficulty-adjustments` + +Difficulty adjustments (all time) + +Get historical difficulty adjustments including timestamp, block height, difficulty value, and percentage change. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-difficulty-adjustments)* + +Returns: JSON `DifficultyAdjustmentEntry[]` + +```bash +curl -s "https://bitview.space/api/v1/mining/difficulty-adjustments" +``` + +#### GET `/api/v1/mining/difficulty-adjustments/{time_period}` + +Difficulty adjustments + +Get historical difficulty adjustments for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-difficulty-adjustments)* + +Parameters: +- `time_period` (path, TimePeriod, required) + +Returns: JSON `DifficultyAdjustmentEntry[]` + +```bash +curl -s "https://bitview.space/api/v1/mining/difficulty-adjustments/" +``` + +#### GET `/api/v1/mining/hashrate` + +Network hashrate (all time) + +Get network hashrate and difficulty data for all time. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-hashrate)* + +Returns: JSON `HashrateSummary` + +```bash +curl -s "https://bitview.space/api/v1/mining/hashrate" +``` + +#### GET `/api/v1/mining/hashrate/pools` + +All pools hashrate (all time) + +Get hashrate data for all mining pools. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pool-hashrates)* + +Returns: JSON `PoolHashrateEntry[]` + +```bash +curl -s "https://bitview.space/api/v1/mining/hashrate/pools" +``` + +#### GET `/api/v1/mining/hashrate/pools/{time_period}` + +All pools hashrate + +Get hashrate data for all mining pools for a time period. Valid periods: `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pool-hashrates)* + +Parameters: +- `time_period` (path, TimePeriod, required) + +Returns: JSON `PoolHashrateEntry[]` + +```bash +curl -s "https://bitview.space/api/v1/mining/hashrate/pools/" +``` + +#### GET `/api/v1/mining/hashrate/{time_period}` + +Network hashrate + +Get network hashrate and difficulty data for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-hashrate)* + +Parameters: +- `time_period` (path, TimePeriod, required) + +Returns: JSON `HashrateSummary` + +```bash +curl -s "https://bitview.space/api/v1/mining/hashrate/" +``` + +#### GET `/api/v1/mining/pool/{slug}` + +Mining pool details + +Get detailed information about a specific mining pool including block counts and shares for different time periods. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pool)* + +Parameters: +- `slug` (path, PoolSlug, required) + +Returns: JSON `PoolDetail` + +```bash +curl -s "https://bitview.space/api/v1/mining/pool/" +``` + +#### GET `/api/v1/mining/pool/{slug}/blocks` + +Mining pool blocks + +Get the 10 most recent blocks mined by a specific pool. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pool-blocks)* + +Parameters: +- `slug` (path, PoolSlug, required) + +Returns: JSON `BlockInfoV1[]` + +```bash +curl -s "https://bitview.space/api/v1/mining/pool//blocks" +``` + +#### GET `/api/v1/mining/pool/{slug}/blocks/{height}` + +Mining pool blocks from height + +Get 10 blocks mined by a specific pool before (and including) the given height. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pool-blocks)* + +Parameters: +- `slug` (path, PoolSlug, required) +- `height` (path, Height, required) + +Returns: JSON `BlockInfoV1[]` + +```bash +curl -s "https://bitview.space/api/v1/mining/pool//blocks/" +``` + +#### GET `/api/v1/mining/pool/{slug}/hashrate` + +Mining pool hashrate + +Get hashrate history for a specific mining pool. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pool-hashrate)* + +Parameters: +- `slug` (path, PoolSlug, required) + +Returns: JSON `PoolHashrateEntry[]` + +```bash +curl -s "https://bitview.space/api/v1/mining/pool//hashrate" +``` + +#### GET `/api/v1/mining/pools` + +List all mining pools + +Get list of all known mining pools with their identifiers. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pools)* + +Returns: JSON `PoolInfo[]` + +```bash +curl -s "https://bitview.space/api/v1/mining/pools" +``` + +#### GET `/api/v1/mining/pools/{time_period}` + +Mining pool statistics + +Get mining pool statistics for a time period. Valid periods: `24h`, `3d`, `1w`, `1m`, `3m`, `6m`, `1y`, `2y`, `3y`. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-mining-pools)* + +Parameters: +- `time_period` (path, TimePeriod, required) + +Returns: JSON `PoolsSummary` + +```bash +curl -s "https://bitview.space/api/v1/mining/pools/" +``` + +#### GET `/api/v1/mining/reward-stats/{block_count}` + +Mining reward statistics + +Get mining reward statistics for the last N blocks including total rewards, fees, and transaction count. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-reward-stats)* + +Parameters: +- `block_count` (path, integer, required): Number of recent blocks to include + +Returns: JSON `RewardStats` + +```bash +curl -s "https://bitview.space/api/v1/mining/reward-stats/" +``` + +### Openapi.json + +#### GET `/openapi.json` + +OpenAPI specification + +Full OpenAPI 3.1 specification for this API. + +Returns: text + +```bash +curl -s "https://bitview.space/openapi.json" +``` + +### Oracle + +#### GET `/api/oracle/histogram/outputs/live` + +Live output value histogram + +Live unfiltered output value histogram for the forming mempool block. Every live output is binned by value on the oracle log scale; no oracle payment filters are applied. A flat array of log-scale bins, all zero when no mempool is configured. + +Returns: JSON `integer[]` + +```bash +curl -s "https://bitview.space/api/oracle/histogram/outputs/live" +``` + +#### GET `/api/oracle/histogram/outputs/{point}` + +Output value histogram at height or day + +Unfiltered output value histogram for a confirmed point. A block height (`840000`) gives every output in that block, coinbase included, binned by value on the oracle log scale; a calendar date (`YYYY-MM-DD`) sums every block that day. A flat array of log-scale bins. + +Parameters: +- `point` (path, string, required) + +Returns: JSON `integer[]` + +```bash +curl -s "https://bitview.space/api/oracle/histogram/outputs/" +``` + +#### GET `/api/oracle/histogram/payments/live` + +Live payment output histogram + +Live smoothed histogram of oracle-eligible payment outputs, binned by output value on the oracle log scale. It combines the committed oracle window with the forming mempool block. A flat array of log-scale bins. + +Returns: JSON `integer[]` + +```bash +curl -s "https://bitview.space/api/oracle/histogram/payments/live" +``` + +#### GET `/api/oracle/histogram/payments/{point}` + +Payment output histogram at height or day + +Smoothed histogram of oracle-eligible payment outputs for a confirmed point. A block height (`840000`) gives that block's oracle payment histogram; a calendar date (`YYYY-MM-DD`) gives the average of that day's per-block payment histograms. A flat array of log-scale bins. + +Parameters: +- `point` (path, string, required) + +Returns: JSON `integer[]` + +```bash +curl -s "https://bitview.space/api/oracle/histogram/payments/" +``` + +#### GET `/api/oracle/price` + +Live BTC/USD price + +Current BTC/USD price in dollars. Same value as `/api/mempool/price`. Confirmed per-height history is available at `/api/vecs/height-to-price`. + +Returns: JSON `Dollars` + +```bash +curl -s "https://bitview.space/api/oracle/price" +``` + +### Prices + +#### GET `/api/v1/prices` + +Current BTC price + +Returns bitcoin latest price (on-chain derived, USD only). *[Mempool.space docs](https://mempool.space/docs/api/rest#get-price)* + +Returns: JSON `Prices` + +```bash +curl -s "https://bitview.space/api/v1/prices" +``` + +### Replacements + +#### GET `/api/v1/replacements` + +Recent RBF replacements + +Returns up to 25 most-recent RBF replacement trees across the whole mempool. Each entry has the same shape as `tx_rbf().replacements`. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-replacements)* + +Returns: JSON `ReplacementNode[]` + +```bash +curl -s "https://bitview.space/api/v1/replacements" +``` + +### Series + +#### GET `/api/series` + +Series catalog + +Returns the complete hierarchical catalog of available series organized as a tree structure. Series are grouped by categories and subcategories. + +Returns: JSON `TreeNode` + +```bash +curl -s "https://bitview.space/api/series" +``` + +#### GET `/api/series/bulk` + +Bulk series data + +Fetch multiple series in a single request. Supports filtering by index and date range. Returns an array of SeriesData objects. For a single series, use `get_series` instead. + +Parameters: +- `series` (query, SeriesList, required): Requested series +- `index` (query, Index, required): Index to query +- `start` (query, RangeIndex, optional): Inclusive start: integer index, date (YYYY-MM-DD), or timestamp (ISO 8601). Negative integers count from end. Aliases: `from`, `f`, `s` +- `end` (query, RangeIndex, optional): Exclusive end: integer index, date (YYYY-MM-DD), or timestamp (ISO 8601). Negative integers count from end. Aliases: `to`, `t`, `e` +- `limit` (query, Limit, optional): Maximum number of values to return (ignored if `end` is set). Aliases: `count`, `c`, `l` +- `format` (query, Format, optional): Format of the output + +Returns: JSON `SeriesData[]` + +```bash +curl -s "https://bitview.space/api/series/bulk?series=&index=&start=&end=&limit=&format=" +``` + +#### GET `/api/series/count` + +Series count + +Returns the number of series available per index type. + +Returns: JSON `SeriesCount[]` + +```bash +curl -s "https://bitview.space/api/series/count" +``` + +#### GET `/api/series/indexes` + +List available indexes + +Returns all available indexes with their accepted query aliases. Use any alias when querying series. + +Returns: JSON `IndexInfo[]` + +```bash +curl -s "https://bitview.space/api/series/indexes" +``` + +#### GET `/api/series/list` + +Series list + +Paginated flat list of all available series names. Use `page` query param for pagination. + +Parameters: +- `page` (query, integer, optional): Pagination index +- `per_page` (query, integer, optional): Results per page (default: 1000, max: 1000) + +Returns: JSON `PaginatedSeries` + +```bash +curl -s "https://bitview.space/api/series/list?page=&per_page=" +``` + +#### GET `/api/series/search` + +Search series + +Fuzzy search for series by name. Supports partial matches and typos. + +Parameters: +- `q` (query, SeriesName, required): Search query string +- `limit` (query, Limit, optional): Maximum number of results + +Returns: JSON `string[]` + +```bash +curl -s "https://bitview.space/api/series/search?q=&limit=" +``` + +#### GET `/api/series/{series}` + +Get series info + +Returns the supported indexes and value type for the specified series. + +Parameters: +- `series` (path, SeriesName, required) + +Returns: JSON `SeriesInfo` + +```bash +curl -s "https://bitview.space/api/series/" +``` + +#### GET `/api/series/{series}/{index}` + +Get series data + +Fetch data for a specific series at the given index. Use query parameters to filter by date range and format (json/csv). + +Parameters: +- `series` (path, SeriesName, required): Series name +- `index` (path, Index, required): Aggregation index +- `start` (query, RangeIndex, optional): Inclusive start: integer index, date (YYYY-MM-DD), or timestamp (ISO 8601). Negative integers count from end. Aliases: `from`, `f`, `s` +- `end` (query, RangeIndex, optional): Exclusive end: integer index, date (YYYY-MM-DD), or timestamp (ISO 8601). Negative integers count from end. Aliases: `to`, `t`, `e` +- `limit` (query, Limit, optional): Maximum number of values to return (ignored if `end` is set). Aliases: `count`, `c`, `l` +- `format` (query, Format, optional): Format of the output + +Returns: JSON `SeriesData` + +```bash +curl -s "https://bitview.space/api/series//?start=&end=&limit=&format=" +``` + +#### GET `/api/series/{series}/{index}/data` + +Get raw series data + +Returns just the data array without the SeriesData wrapper. Supports the same range and format parameters as the standard endpoint. + +Parameters: +- `series` (path, SeriesName, required): Series name +- `index` (path, Index, required): Aggregation index +- `start` (query, RangeIndex, optional): Inclusive start: integer index, date (YYYY-MM-DD), or timestamp (ISO 8601). Negative integers count from end. Aliases: `from`, `f`, `s` +- `end` (query, RangeIndex, optional): Exclusive end: integer index, date (YYYY-MM-DD), or timestamp (ISO 8601). Negative integers count from end. Aliases: `to`, `t`, `e` +- `limit` (query, Limit, optional): Maximum number of values to return (ignored if `end` is set). Aliases: `count`, `c`, `l` +- `format` (query, Format, optional): Format of the output + +Returns: JSON `boolean[]` + +```bash +curl -s "https://bitview.space/api/series///data?start=&end=&limit=&format=" +``` + +#### GET `/api/series/{series}/{index}/latest` + +Get latest series value + +Returns the single most recent value for a series, unwrapped (not inside a SeriesData object). + +Parameters: +- `series` (path, SeriesName, required): Series name +- `index` (path, Index, required): Aggregation index + +Returns: JSON `*` + +```bash +curl -s "https://bitview.space/api/series///latest" +``` + +#### GET `/api/series/{series}/{index}/len` + +Get series data length + +Returns the total number of data points for a series at the given index. + +Parameters: +- `series` (path, SeriesName, required): Series name +- `index` (path, Index, required): Aggregation index + +Returns: JSON `integer` + +```bash +curl -s "https://bitview.space/api/series///len" +``` + +#### GET `/api/series/{series}/{index}/version` + +Get series version + +Returns the current version of a series. Changes when the series data is updated. + +Parameters: +- `series` (path, SeriesName, required): Series name +- `index` (path, Index, required): Aggregation index + +Returns: JSON `Version` + +```bash +curl -s "https://bitview.space/api/series///version" +``` + +### Server + +#### GET `/api/server/disk` + +Disk usage + +Returns the disk space used by BRK and Bitcoin data. + +Returns: JSON `DiskUsage` + +```bash +curl -s "https://bitview.space/api/server/disk" +``` + +#### GET `/api/server/sync` + +Sync status + +Returns the sync status of the indexer, including indexed height, tip height, blocks behind, and last indexed timestamp. + +Returns: JSON `SyncStatus` + +```bash +curl -s "https://bitview.space/api/server/sync" +``` + +### Transaction Times + +#### GET `/api/v1/transaction-times` + +Transaction first-seen times + +Returns timestamps when transactions were first seen in the mempool. Returns 0 for mined or unknown transactions. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-transaction-times)* + +Parameters: +- `txId[]` (query, Txid[], required): Transaction IDs to look up (max 250 per request). + +Returns: JSON `integer[]` + +```bash +curl -s "https://bitview.space/api/v1/transaction-times?txId[]=" +``` + +### Tx + +#### POST `/api/tx` + +Broadcast transaction + +Broadcast a raw transaction to the network. The transaction should be provided as hex in the request body. The txid will be returned on success. *[Mempool.space docs](https://mempool.space/docs/api/rest#post-transaction)* + +Request body: `string` (required) + +Returns: JSON `Txid` + +```bash +curl -s -X POST --data '' "https://bitview.space/api/tx" +``` + +#### GET `/api/tx/{txid}` + +Transaction information + +Retrieve complete transaction data by transaction ID (txid). Returns inputs, outputs, fee, size, and confirmation status. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-transaction)* + +Parameters: +- `txid` (path, Txid, required) + +Returns: JSON `Transaction` + +```bash +curl -s "https://bitview.space/api/tx/" +``` + +#### GET `/api/tx/{txid}/hex` + +Transaction hex + +Retrieve the raw transaction as a hex-encoded string. Returns the serialized transaction in hexadecimal format. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-transaction-hex)* + +Parameters: +- `txid` (path, Txid, required) + +Returns: text `Hex` + +```bash +curl -s "https://bitview.space/api/tx//hex" +``` + +#### GET `/api/tx/{txid}/merkle-proof` + +Transaction merkle proof + +Get the merkle inclusion proof for a transaction. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-transaction-merkle-proof)* + +Parameters: +- `txid` (path, Txid, required) + +Returns: JSON `MerkleProof` + +```bash +curl -s "https://bitview.space/api/tx//merkle-proof" +``` + +#### GET `/api/tx/{txid}/merkleblock-proof` + +Transaction merkleblock proof + +Get the merkleblock proof for a transaction (BIP37 format, hex encoded). *[Mempool.space docs](https://mempool.space/docs/api/rest#get-transaction-merkleblock-proof)* + +Parameters: +- `txid` (path, Txid, required) + +Returns: text `Hex` + +```bash +curl -s "https://bitview.space/api/tx//merkleblock-proof" +``` + +#### GET `/api/tx/{txid}/outspend/{vout}` + +Output spend status + +Get the spending status of a transaction output. Returns whether the output has been spent and, if so, the spending transaction details. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-transaction-outspend)* + +Parameters: +- `txid` (path, Txid, required): Transaction ID +- `vout` (path, Vout, required): Output index + +Returns: JSON `TxOutspend` + +```bash +curl -s "https://bitview.space/api/tx//outspend/" +``` + +#### GET `/api/tx/{txid}/outspends` + +All output spend statuses + +Get the spending status of all outputs in a transaction. Returns an array with the spend status for each output. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-transaction-outspends)* + +Parameters: +- `txid` (path, Txid, required) + +Returns: JSON `TxOutspend[]` + +```bash +curl -s "https://bitview.space/api/tx//outspends" +``` + +#### GET `/api/tx/{txid}/raw` + +Transaction raw + +Returns a transaction as binary data. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-transaction-raw)* + +Parameters: +- `txid` (path, Txid, required) + +Returns: binary data + +```bash +curl -s "https://bitview.space/api/tx//raw" +``` + +#### GET `/api/tx/{txid}/status` + +Transaction status + +Retrieve the confirmation status of a transaction. Returns whether the transaction is confirmed and, if so, the block height, hash, and timestamp. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-transaction-status)* + +Parameters: +- `txid` (path, Txid, required) + +Returns: JSON `TxStatus` + +```bash +curl -s "https://bitview.space/api/tx//status" +``` + +#### GET `/api/v1/tx/{txid}/rbf` + +RBF replacement history + +Returns the RBF replacement tree for a transaction, if any. Both `replacements` and `replaces` are null when the tx has no known RBF history within the mempool monitor's retention window. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-transaction-rbf-history)* + +Parameters: +- `txid` (path, Txid, required) + +Returns: JSON `RbfResponse` + +```bash +curl -s "https://bitview.space/api/v1/tx//rbf" +``` + +### Tx Index + +#### GET `/api/tx-index/{index}` + +Txid by index + +Retrieve the transaction ID (txid) at a given global transaction index. Returns the txid as plain text. + +Parameters: +- `index` (path, TxIndex, required) + +Returns: text `Txid` + +```bash +curl -s "https://bitview.space/api/tx-index/" +``` + +### Urpd + +#### GET `/api/urpd` + +Available URPD cohorts + +Cohorts for which URPD data is available. Returns names like `all`, `sth`, `lth`, `utxos_under_1h_old`. + +Returns: JSON `Cohort[]` + +```bash +curl -s "https://bitview.space/api/urpd" +``` + +#### GET `/api/urpd/{cohort}` + +Latest URPD + +URPD for the most recent available date in the cohort. The response's `date` field echoes which date was served. See the URPD tag description for the response shape and `agg` options. + +Parameters: +- `cohort` (path, Cohort, required) +- `agg` (query, UrpdAggregation, optional): Aggregation strategy. Default: raw (no aggregation). Accepts `bucket` as alias. + +Returns: JSON `Urpd` + +```bash +curl -s "https://bitview.space/api/urpd/?agg=" +``` + +#### GET `/api/urpd/{cohort}/dates` + +Available URPD dates + +Dates for which a URPD snapshot is available for the cohort. One entry per UTC day, sorted ascending. + +Parameters: +- `cohort` (path, Cohort, required) + +Returns: JSON `Date[]` + +```bash +curl -s "https://bitview.space/api/urpd//dates" +``` + +#### GET `/api/urpd/{cohort}/{date}` + +URPD at date + +URPD for a (cohort, date) pair. Returns `{ cohort, date, aggregation, close, total_supply, buckets }` where each bucket is `{ price_floor, supply, realized_cap, unrealized_pnl }`. See the URPD tag description for unit conventions and `agg` options. + +Parameters: +- `cohort` (path, Cohort, required) +- `date` (path, string, required) +- `agg` (query, UrpdAggregation, optional): Aggregation strategy. Default: raw (no aggregation). Accepts `bucket` as alias. + +Returns: JSON `Urpd` + +```bash +curl -s "https://bitview.space/api/urpd//?agg=" +``` + +### Validate Address + +#### GET `/api/v1/validate-address/{address}` + +Validate address + +Validate a Bitcoin address and get information about its type and scriptPubKey. Returns `isvalid: false` with an error message for invalid addresses. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-validate)* + +Parameters: +- `address` (path, string, required): Bitcoin address to validate (can be any string) + +Returns: JSON `AddrValidation` + +```bash +curl -s "https://bitview.space/api/v1/validate-address/
" +``` + +### Version + +#### GET `/version` + +API version + +Returns the current version of the API server + +Returns: JSON `string` + +```bash +curl -s "https://bitview.space/version" +``` + +## Schemas + +### `Addr` + +`string` + +### `AddrChainStats` + +- `funded_txo_count`: `integer` (required) — Total number of transaction outputs that funded this address +- `funded_txo_sum`: `Sats` (required) — Total amount in satoshis received by this address across all funded outputs +- `spent_txo_count`: `integer` (required) — Total number of transaction outputs spent from this address +- `spent_txo_sum`: `Sats` (required) — Total amount in satoshis spent from this address +- `tx_count`: `integer` (required) — Total number of confirmed transactions involving this address +- `type_index`: `TypeIndex` (required) — Index of this address within its type on the blockchain +- `realized_price`: `Dollars` (required) — Realized price (average cost basis) in USD + +### `AddrHashPrefixMatches` + +- `addr_type`: `OutputType` (required) +- `prefix`: `string` (required) +- `truncated`: `boolean` (required) +- `addresses`: `Addr[]` (required) + +### `AddrMempoolStats` + +- `funded_txo_count`: `integer` (required) — Number of unconfirmed transaction outputs funding this address +- `funded_txo_sum`: `Sats` (required) — Total amount in satoshis being received in unconfirmed transactions +- `spent_txo_count`: `integer` (required) — Number of unconfirmed transaction inputs spending from this address +- `spent_txo_sum`: `Sats` (required) — Total amount in satoshis being spent in unconfirmed transactions +- `tx_count`: `integer` (required) — Number of unconfirmed transactions involving this address + +### `AddrStats` + +- `address`: `Addr` (required) — Bitcoin address string +- `addr_type`: `OutputType` (required) — Address type (p2pkh, p2sh, v0_p2wpkh, v0_p2wsh, v1_p2tr, etc.) +- `chain_stats`: `AddrChainStats` (required) — Statistics for confirmed transactions on the blockchain +- `mempool_stats`: `AddrMempoolStats` (required) — Statistics for unconfirmed transactions in the mempool +- `balance`: `Sats` (required) — Current balance in satoshis, including unconfirmed mempool changes + +### `AddrValidation` + +- `isvalid`: `boolean` (required) — Whether the address is valid +- `address`: `object` — The validated address +- `scriptPubKey`: `object` — The scriptPubKey in hex +- `isscript`: `object` — Whether this is a script address (P2SH) +- `iswitness`: `object` — Whether this is a witness address +- `witness_version`: `object` — Witness version (0 for P2WPKH/P2WSH, 1 for P2TR) +- `witness_program`: `object` — Witness program in hex +- `error_locations`: `object` — Error locations (empty array for most errors) +- `error`: `object` — Error message for invalid addresses + +### `Bitcoin` + +`number` + +### `BlockExtras` + +- `totalFees`: `Sats` (required) — Total fees in satoshis +- `medianFee`: `FeeRate` (required) — Median fee rate in sat/vB +- `feeRange`: `FeeRate[]` (required) — Fee rate range: [min, 10%, 25%, 50%, 75%, 90%, max] +- `reward`: `Sats` (required) — Total block reward (subsidy + fees) in satoshis +- `pool`: `BlockPool` (required) — Mining pool that mined this block +- `avgFee`: `Sats` (required) — Average fee per transaction in satoshis +- `avgFeeRate`: `FeeRate` (required) — Average fee rate in sat/vB +- `coinbaseRaw`: `string` (required) — Raw coinbase transaction scriptsig as hex +- `coinbaseAddress`: `object` — Primary coinbase output address +- `coinbaseAddresses`: `string[]` (required) — All coinbase output addresses +- `coinbaseSignature`: `string` (required) — Coinbase output script in ASM format +- `coinbaseSignatureAscii`: `string` (required) — Coinbase scriptsig decoded as ASCII +- `avgTxSize`: `number` (required) — Average transaction size in bytes +- `totalInputs`: `integer` (required) — Total number of inputs (excluding coinbase) +- `totalOutputs`: `integer` (required) — Total number of outputs +- `totalOutputAmt`: `Sats` (required) — Total output amount in satoshis +- `medianFeeAmt`: `Sats` (required) — Median fee amount in satoshis +- `feePercentiles`: `Sats[]` (required) — Fee amount percentiles in satoshis: [min, 10%, 25%, 50%, 75%, 90%, max] +- `segwitTotalTxs`: `integer` (required) — Number of segwit transactions +- `segwitTotalSize`: `integer` (required) — Total size of segwit transactions in bytes +- `segwitTotalWeight`: `Weight` (required) — Total weight of segwit transactions +- `header`: `string` (required) — Raw 80-byte block header as hex +- `utxoSetChange`: `integer` (required) — UTXO set change (total outputs - total inputs, includes unspendable like OP_RETURN). Note: intentionally differs from utxo_set_size diff which excludes unspendable outputs. Matches mempool.space/bitcoin-cli behavior. +- `utxoSetSize`: `integer` (required) — Total spendable UTXO set size at this height (excludes OP_RETURN and other unspendable outputs) +- `totalInputAmt`: `Sats` (required) — Total input amount in satoshis +- `virtualSize`: `number` (required) — Virtual size in vbytes +- `firstSeen`: `object` — Timestamp when the block was first seen (always null, not yet supported) +- `orphans`: `string[]` (required) — Orphaned blocks (always empty) +- `price`: `Dollars` (required) — USD price at block height + +### `BlockHash` + +`string` + +### `BlockInfo` + +- `id`: `BlockHash` (required) — Block hash +- `height`: `Height` (required) — Block height +- `version`: `integer` (required) — Block version +- `timestamp`: `Timestamp` (required) — Block timestamp (Unix time) +- `bits`: `integer` (required) — Compact target (bits) +- `nonce`: `integer` (required) — Nonce +- `difficulty`: `number` (required) — Block difficulty +- `merkle_root`: `string` (required) — Merkle root of the transaction tree +- `tx_count`: `integer` (required) — Number of transactions +- `size`: `integer` (required) — Block size in bytes +- `weight`: `Weight` (required) — Block weight in weight units +- `previousblockhash`: `BlockHash` (required) — Previous block hash +- `mediantime`: `Timestamp` (required) — Median time of the last 11 blocks + +### `BlockInfoV1` + +- `id`: `BlockHash` (required) — Block hash +- `height`: `Height` (required) — Block height +- `version`: `integer` (required) — Block version +- `timestamp`: `Timestamp` (required) — Block timestamp (Unix time) +- `bits`: `integer` (required) — Compact target (bits) +- `nonce`: `integer` (required) — Nonce +- `difficulty`: `number` (required) — Block difficulty +- `merkle_root`: `string` (required) — Merkle root of the transaction tree +- `tx_count`: `integer` (required) — Number of transactions +- `size`: `integer` (required) — Block size in bytes +- `weight`: `Weight` (required) — Block weight in weight units +- `previousblockhash`: `BlockHash` (required) — Previous block hash +- `mediantime`: `Timestamp` (required) — Median time of the last 11 blocks +- `stale`: `boolean` — Whether this block has been replaced by a longer chain +- `extras`: `BlockExtras` (required) — Extended block data + +### `BlockPool` + +- `id`: `integer` (required) — Unique pool identifier +- `name`: `string` (required) — Pool name +- `slug`: `PoolSlug` (required) — URL-friendly pool identifier +- `blockNumber`: `integer` (required) — This block's ordinal among blocks attributed to this pool +- `minerNames`: `object` — Miner name tags found in coinbase scriptsig + +### `BlockSizeEntry` + +- `avgHeight`: `Height` (required) — Average block height in this window +- `timestamp`: `Timestamp` (required) — Unix timestamp at the window midpoint +- `avgSize`: `integer` (required) — Rolling 24h median block size (bytes) + +### `BlockSizesWeights` + +- `sizes`: `BlockSizeEntry[]` (required) — Block size data points +- `weights`: `BlockWeightEntry[]` (required) — Block weight data points + +### `BlockStatus` + +- `in_best_chain`: `boolean` (required) — Whether this block is in the best chain +- `height`: `Height | null` — Block height (only if in best chain) +- `next_best`: `BlockHash | null` — Hash of the next block in the best chain (null if tip) + +### `BlockTemplate` + +- `hash`: `NextBlockHash` (required) — Pass back as `` on `/api/v1/mempool/block-template/diff/{hash}` to fetch deltas. +- `stats`: `MempoolBlock` (required) — Aggregate stats for this block (size, vsize, fee range, ...). +- `transactions`: `Transaction[]` (required) — Full transaction bodies in `getblocktemplate` order. + +### `BlockTemplateDiff` + +- `hash`: `NextBlockHash` (required) — Current next-block hash. Use as `since` on the next diff call. +- `since`: `NextBlockHash` (required) — Echoed prior hash the diff was computed against. +- `order`: `BlockTemplateDiffEntry[]` (required) — New template in order. Each entry is either an index into the prior template's transactions or a full transaction body. +- `removed`: `Txid[]` (required) — Txids that left the projected next block since `since` (confirmed, evicted, replaced, or pushed past block 0). + +### `BlockTemplateDiffEntry` + +`integer | Transaction` + +### `BlockTimestamp` + +- `height`: `Height` (required) — Block height +- `hash`: `BlockHash` (required) — Block hash +- `timestamp`: `string` (required) — Block timestamp in ISO 8601 format + +### `BlockWeightEntry` + +- `avgHeight`: `Height` (required) — Average block height in this window +- `timestamp`: `Timestamp` (required) — Unix timestamp at the window midpoint +- `avgWeight`: `Weight` (required) — Rolling 24h median block weight (weight units) + +### `Cohort` + +`all | sth | lth | utxos_under_1h_old | utxos_1h_to_1d_old | utxos_1d_to_1w_old | utxos_1w_to_1m_old | utxos_1m_to_2m_old | utxos_2m_to_3m_old | utxos_3m_to_4m_old | utxos_4m_to_5m_old | utxos_5m_to_6m_old | utxos_6m_to_1y_old | utxos_1y_to_2y_old | utxos_2y_to_3y_old | utxos_3y_to_4y_old | utxos_4y_to_5y_old | utxos_5y_to_6y_old | utxos_6y_to_7y_old | utxos_7y_to_8y_old | utxos_8y_to_10y_old | utxos_10y_to_12y_old | utxos_12y_to_15y_old | utxos_over_15y_old` + +### `CpfpCluster` + +- `txs`: `CpfpClusterTx[]` (required) — All txs in the cluster, in topological order (parents before children). +- `chunks`: `CpfpClusterChunk[]` (required) — SFL-emitted chunks ordered by descending feerate. +- `chunkIndex`: `integer` (required) — Index into `chunks` of the chunk containing the seed tx. + +### `CpfpClusterChunk` + +- `txs`: `CpfpClusterTxIndex[]` (required) +- `feerate`: `FeeRate` (required) + +### `CpfpClusterTx` + +- `txid`: `Txid` (required) +- `weight`: `Weight` (required) +- `fee`: `Sats` (required) +- `parents`: `CpfpClusterTxIndex[]` (required) — In-cluster parents of this tx. + +### `CpfpClusterTxIndex` + +`integer` + +### `CpfpEntry` + +- `txid`: `Txid` (required) +- `weight`: `Weight` (required) +- `fee`: `Sats` (required) + +### `CpfpInfo` + +- `ancestors`: `CpfpEntry[]` (required) — Ancestor transactions in the CPFP chain. +- `bestDescendant`: `CpfpEntry | null` — Best (highest fee rate) descendant, if any. +- `descendants`: `CpfpEntry[]` (required) — Descendant transactions in the CPFP chain. +- `effectiveFeePerVsize`: `FeeRate` (required) — Effective fee rate considering CPFP relationships (sat/vB). This is the seed's chunk feerate after lift-merging, i.e. the rate Core/mempool.space would surface for this tx. +- `sigops`: `SigOps` (required) — BIP-141 sigop cost for the seed tx (witness sigops count as 1, legacy and P2SH-redeem sigops count as 4). +- `fee`: `Sats` (required) — Transaction fee (sats). +- `vsize`: `VSize` (required) — Virtual size of the seed tx (vbytes). +- `adjustedVsize`: `VSize` (required) — Policy-adjusted virtual size: `max(vsize, sigops * 5)`. +- `cluster`: `CpfpCluster | null` — Cluster the seed belongs to: full tx list, SFL-linearized chunks, and the seed's chunk index. Omitted when the seed has no ancestors and no descendants (matches mempool.space). + +### `Date` + +`integer` + +### `DifficultyAdjustment` + +- `progressPercent`: `number` (required) — Progress through current difficulty epoch (0-100%) +- `difficultyChange`: `number` (required) — Estimated difficulty change at next retarget (%) +- `estimatedRetargetDate`: `integer` (required) — Estimated timestamp of next retarget (milliseconds) +- `remainingBlocks`: `integer` (required) — Blocks remaining until retarget +- `remainingTime`: `integer` (required) — Estimated time until retarget (milliseconds) +- `previousRetarget`: `number` (required) — Previous difficulty adjustment (%) +- `previousTime`: `Timestamp` (required) — Timestamp of most recent retarget (seconds) +- `nextRetargetHeight`: `Height` (required) — Height of next retarget +- `timeAvg`: `integer` (required) — Average block time in current epoch (milliseconds) +- `adjustedTimeAvg`: `integer` (required) — Time-adjusted average (milliseconds) +- `timeOffset`: `integer` (required) — Time offset from expected schedule (seconds) +- `expectedBlocks`: `number` (required) — Expected blocks based on wall clock time since epoch start + +### `DifficultyEntry` + +- `time`: `Timestamp` (required) — Unix timestamp of the difficulty adjustment +- `height`: `Height` (required) — Block height of the adjustment +- `difficulty`: `number` (required) — Difficulty value +- `adjustment`: `number` (required) — Adjustment ratio (new/previous, e.g. 1.068 = +6.8%) + +### `DiskUsage` + +- `brk`: `string` (required) — Human-readable brk data size (e.g., "48.8 GiB") +- `brk_bytes`: `integer` (required) — brk data size in bytes +- `bitcoin`: `string` (required) — Human-readable Bitcoin blocks directory size +- `bitcoin_bytes`: `integer` (required) — Bitcoin blocks directory size in bytes +- `ratio`: `number` (required) — brk as percentage of Bitcoin data + +### `Dollars` + +`number` + +### `ExchangeRates` + +`object` + +### `FeeRate` + +`number` + +### `HashrateEntry` + +- `timestamp`: `Timestamp` (required) — Unix timestamp +- `avgHashrate`: `integer` (required) — Average hashrate (H/s) + +### `HashrateSummary` + +- `hashrates`: `HashrateEntry[]` (required) — Historical hashrate data points +- `difficulty`: `DifficultyEntry[]` (required) — Historical difficulty adjustments +- `currentHashrate`: `integer` (required) — Current network hashrate (H/s) +- `currentDifficulty`: `number` (required) — Current network difficulty + +### `Health` + +- `status`: `string` (required) — Health status ("healthy") +- `service`: `string` (required) — Service name +- `version`: `string` (required) — Server version +- `timestamp`: `string` (required) — Current server time (ISO 8601) +- `started_at`: `string` (required) — Server start time (ISO 8601) +- `uptime_seconds`: `integer` (required) — Uptime in seconds +- `indexed_height`: `Height` (required) — Height of the last indexed block +- `computed_height`: `Height` (required) — Height of the last computed block (series) +- `tip_height`: `Height` (required) — Height of the chain tip (from Bitcoin node) +- `blocks_behind`: `Height` (required) — Number of blocks behind the tip +- `last_indexed_at`: `string` (required) — Human-readable timestamp of the last indexed block (ISO 8601) +- `last_indexed_at_unix`: `Timestamp` (required) — Unix timestamp of the last indexed block + +### `Height` + +`integer` + +### `Hex` + +`string` + +### `HistoricalPrice` + +- `prices`: `HistoricalPriceEntry[]` (required) — Price data points +- `exchangeRates`: `ExchangeRates` (required) — Exchange rates (currently empty) + +### `HistoricalPriceEntry` + +- `time`: `Timestamp` (required) — Unix timestamp +- `USD`: `Dollars` (required) — BTC/USD price + +### `Index` + +`minute10 | minute30 | hour1 | hour4 | hour12 | day1 | day3 | week1 | month1 | month3 | month6 | year1 | year10 | halving | epoch | height | tx_index | txin_index | txout_index | empty_output_index | op_return_index | p2a_addr_index | p2ms_output_index | p2pk33_addr_index | p2pk65_addr_index | p2pkh_addr_index | p2sh_addr_index | p2tr_addr_index | p2wpkh_addr_index | p2wsh_addr_index | unknown_output_index | funded_addr_index | empty_addr_index` + +### `MempoolBlock` + +- `blockSize`: `integer` (required) — Total serialized block size in bytes (witness + non-witness). +- `blockVSize`: `number` (required) — Total block virtual size in vbytes +- `nTx`: `integer` (required) — Number of transactions in the projected block +- `totalFees`: `Sats` (required) — Total fees in satoshis +- `medianFee`: `FeeRate` (required) — Median fee rate in sat/vB +- `feeRange`: `FeeRate[]` (required) — Fee rate range: [min, 10%, 25%, 50%, 75%, 90%, max] + +### `MempoolInfo` + +- `count`: `integer` (required) — Number of transactions in the mempool +- `vsize`: `VSize` (required) — Total virtual size of all transactions in the mempool (vbytes) +- `total_fee`: `Sats` (required) — Total fees of all transactions in the mempool (satoshis) +- `fee_histogram`: `object` (required) — Fee histogram: `[[fee_rate, vsize], ...]` sorted by descending fee rate + +### `MerkleProof` + +- `block_height`: `Height` (required) — Block height containing the transaction +- `merkle`: `string[]` (required) — Merkle proof path (hex-encoded hashes) +- `pos`: `integer` (required) — Transaction position in the block (0-indexed) + +### `NextBlockHash` + +`integer` + +### `OutputType` + +`p2pk | p2pk | p2pkh | multisig | p2sh | op_return | v0_p2wpkh | v0_p2wsh | v1_p2tr | p2a | empty | unknown` + +### `PaginatedSeries` + +- `current_page`: `integer` (required) — Current page number (0-indexed) +- `max_page`: `integer` (required) — Maximum valid page index (0-indexed) +- `total_count`: `integer` (required) — Total number of series +- `per_page`: `integer` (required) — Results per page +- `has_more`: `boolean` (required) — Whether more pages are available after the current one +- `series`: `string[]` (required) — List of series names + +### `PoolBlockCounts` + +- `all`: `integer` (required) — Total blocks mined (all time) +- `24h`: `integer` (required) — Blocks mined in last 24 hours +- `1w`: `integer` (required) — Blocks mined in last week + +### `PoolBlockShares` + +- `all`: `number` (required) — Share of all blocks (0.0 - 1.0) +- `24h`: `number` (required) — Share of blocks in last 24 hours (0.0 - 1.0) +- `1w`: `number` (required) — Share of blocks in last week (0.0 - 1.0) + +### `PoolDetail` + +- `pool`: `PoolDetailInfo` (required) — Pool information +- `blockCount`: `PoolBlockCounts` (required) — Block counts for different time periods +- `blockShare`: `PoolBlockShares` (required) — Pool's share of total blocks for different time periods +- `estimatedHashrate`: `integer` (required) — Estimated hashrate based on blocks mined (H/s) +- `reportedHashrate`: `object` — Self-reported hashrate (if available, H/s) +- `totalReward`: `Sats | null` — Total reward earned by this pool (sats, all time; None for minor pools) + +### `PoolDetailInfo` + +- `id`: `integer` (required) — Pool identifier +- `name`: `string` (required) — Pool name +- `link`: `string` (required) — Pool website URL +- `addresses`: `string[]` (required) — Known payout addresses +- `regexes`: `string[]` (required) — Coinbase tag patterns (regexes) +- `slug`: `PoolSlug` (required) — URL-friendly pool identifier +- `unique_id`: `integer` (required) — Unique pool identifier + +### `PoolSlug` + +`unknown | blockfills | ultimuspool | terrapool | luxor | 1thash | btccom | bitfarms | huobipool | wayicn | canoepool | btctop | bitcoincom | 175btc | gbminers | axbt | asicminer | bitminter | bitcoinrussia | btcserv | simplecoinus | btcguild | eligius | ozcoin | eclipsemc | maxbtc | triplemining | coinlab | 50btc | ghashio | stminingcorp | bitparking | mmpool | polmine | kncminer | bitalo | f2pool | hhtt | megabigpower | mtred | nmcbit | yourbtcnet | givemecoins | braiinspool | antpool | multicoinco | bcpoolio | cointerra | kanopool | solock | ckpool | nicehash | bitclub | bitcoinaffiliatenetwork | btcc | bwpool | exxbw | bitsolo | bitfury | 21inc | digitalbtc | 8baochi | mybtccoinpool | tbdice | hashpool | nexious | bravomining | hotpool | okexpool | bcmonster | 1hash | bixin | tatmaspool | viabtc | connectbtc | batpool | waterhole | dcexploration | dcex | btpool | 58coin | bitcoinindia | shawnp0wers | phashio | rigpool | haozhuzhu | 7pool | miningkings | hashbx | dpool | rawpool | haominer | helix | bitcoinukraine | poolin | secretsuperstar | tigerpoolnet | sigmapoolcom | okpooltop | hummerpool | tangpool | bytepool | spiderpool | novablock | miningcity | binancepool | minerium | lubiancom | okkong | aaopool | emcdpool | foundryusa | sbicrypto | arkpool | purebtccom | marapool | kucoinpool | entrustcharitypool | okminer | titan | pegapool | btcnuggets | cloudhashing | digitalxmintsy | telco214 | btcpoolparty | multipool | transactioncoinmining | btcdig | trickysbtcpool | btcmp | eobot | unomp | patels | gogreenlight | bitcoinindiapool | ekanembtc | canoe | tiger | 1m1x | zulupool | secpool | ocean | whitepool | wiz | wk057 | futurebitapollosolo | carbonnegative | portlandhodl | phoenix | neopool | maxipool | bitfufupool | gdpool | miningdutch | publicpool | miningsquared | innopolistech | btclab | parasite | redrockpool | est3lar | braiinssolo | solopoolcom | noderunners` + +### `PoolStats` + +- `poolId`: `integer` (required) — Unique pool identifier +- `name`: `string` (required) — Pool name +- `link`: `string` (required) — Pool website URL +- `blockCount`: `integer` (required) — Number of blocks mined in the time period +- `rank`: `integer` (required) — Pool ranking by block count (1 = most blocks) +- `emptyBlocks`: `integer` (required) — Number of empty blocks mined +- `slug`: `PoolSlug` (required) — URL-friendly pool identifier +- `share`: `number` (required) — Pool's share of total blocks (0.0 - 1.0) +- `poolUniqueId`: `integer` (required) — Unique pool identifier + +### `PoolsSummary` + +- `pools`: `PoolStats[]` (required) — List of pools sorted by block count descending +- `blockCount`: `integer` (required) — Total blocks in the time period +- `lastEstimatedHashrate`: `integer` (required) — Estimated network hashrate (H/s) +- `lastEstimatedHashrate3d`: `integer` (required) — Estimated network hashrate over last 3 days (H/s) +- `lastEstimatedHashrate1w`: `integer` (required) — Estimated network hashrate over last 1 week (H/s) + +### `Prices` + +- `time`: `Timestamp` (required) — Unix timestamp +- `USD`: `Dollars` (required) — BTC/USD price + +### `RawLockTime` + +`integer` + +### `RbfResponse` + +- `replacements`: `ReplacementNode | null` +- `replaces`: `object` + +### `RbfTx` + +- `txid`: `Txid` (required) +- `fee`: `Sats` (required) +- `vsize`: `VSize` (required) +- `value`: `Sats` (required) — Sum of output amounts. +- `rate`: `FeeRate` (required) +- `time`: `Timestamp` (required) +- `rbf`: `boolean` (required) — BIP-125 signaling: at least one input has sequence < 0xffffffff-1. +- `fullRbf`: `object` — Only populated on the root `tx` of an RBF response. `true` iff this tx displaced at least one non-signaling predecessor. + +### `RecommendedFees` + +- `fastestFee`: `FeeRate` (required) — Fee rate for fastest confirmation (next block) +- `halfHourFee`: `FeeRate` (required) — Fee rate for confirmation within ~30 minutes (3 blocks) +- `hourFee`: `FeeRate` (required) — Fee rate for confirmation within ~1 hour (6 blocks) +- `economyFee`: `FeeRate` (required) — Fee rate for economical confirmation +- `minimumFee`: `FeeRate` (required) — Minimum relay fee rate + +### `ReplacementNode` + +- `tx`: `RbfTx` (required) +- `time`: `Timestamp` (required) — First-seen timestamp, duplicated here to match mempool.space's on-the-wire shape. +- `fullRbf`: `boolean` (required) — Any predecessor in this subtree was non-signaling. +- `interval`: `object` — Seconds between this node's `time` and the successor that replaced it. Omitted on the root of an RBF response. +- `mined`: `object` — `Some(true)` iff this node's tx is currently confirmed. Absent on serialization otherwise. +- `replaces`: `ReplacementNode[]` (required) + +### `RewardStats` + +- `startBlock`: `Height` (required) — First block in the range +- `endBlock`: `Height` (required) — Last block in the range +- `totalReward`: `Sats` (required) — Total coinbase rewards (subsidy + fees) in sats +- `totalFee`: `Sats` (required) — Total transaction fees in sats +- `totalTx`: `integer` (required) — Total number of transactions + +### `Sats` + +`integer` + +### `SeriesData` + +- `version`: `Version` (required) — Version of the series data +- `index`: `Index` (required) — The index type used for this query +- `type`: `string` — Value type (e.g. "f32", "u64", "Sats") +- `start`: `integer` (required) — Start index (inclusive) of the returned range +- `end`: `integer` (required) — End index (exclusive) of the returned range +- `stamp`: `string` (required) — ISO 8601 timestamp of when the response was generated +- `data`: `object[]` (required) — The series data + +### `SeriesInfo` + +- `indexes`: `Index[]` (required) — Available indexes +- `type`: `string` (required) — Value type (e.g. "f32", "u64", "Sats") + +### `SeriesLeafWithSchema` + +- `name`: `string` (required) — The series name/identifier +- `kind`: `string` (required) — The Rust type (e.g., "Sats", "StoredF64") +- `indexes`: `Index[]` (required) — Available indexes for this series +- `type`: `string` (required) — JSON Schema type (e.g., "integer", "number", "string", "boolean", "array", "object") + +### `SigOps` + +`integer` + +### `SyncStatus` + +- `indexed_height`: `Height` (required) — Height of the last indexed block +- `computed_height`: `Height` (required) — Height of the last computed block (series) +- `tip_height`: `Height` (required) — Height of the chain tip (from Bitcoin node) +- `blocks_behind`: `Height` (required) — Number of blocks behind the tip +- `last_indexed_at`: `string` (required) — Human-readable timestamp of the last indexed block (ISO 8601) +- `last_indexed_at_unix`: `Timestamp` (required) — Unix timestamp of the last indexed block + +### `Timestamp` + +`integer` + +### `Transaction` + +- `index`: `TxIndex | null` — Internal transaction index (brk-specific, not in mempool.space) +- `txid`: `Txid` (required) — Transaction ID +- `version`: `TxVersionRaw` (required) — Transaction version (raw i32 from Bitcoin protocol, may contain non-standard values in coinbase txs) +- `locktime`: `RawLockTime` (required) — Transaction lock time +- `vin`: `TxIn[]` (required) — Transaction inputs +- `vout`: `TxOut[]` (required) — Transaction outputs +- `size`: `integer` (required) — Transaction size in bytes +- `weight`: `Weight` (required) — Transaction weight +- `sigops`: `SigOps` (required) — Number of signature operations +- `fee`: `Sats` (required) — Transaction fee in satoshis +- `status`: `TxStatus` (required) — Confirmation status (confirmed, block height/hash/time) + +### `TreeNode` + +`object | SeriesLeafWithSchema` + +### `TxIn` + +- `txid`: `Txid` (required) — Transaction ID of the output being spent +- `vout`: `Vout` (required) — Output index being spent (u16: coinbase is 65535, mempool.space uses u32: 4294967295) +- `prevout`: `TxOut | null` — Information about the previous output being spent +- `scriptsig`: `string` (required) — Signature script (hex, for non-SegWit inputs) +- `scriptsig_asm`: `string` (required) — Signature script in assembly format +- `witness`: `Witness` (required) — Witness data (stack items, present for SegWit inputs; hex-encoded on the wire) +- `is_coinbase`: `boolean` (required) — Whether this input is a coinbase (block reward) input +- `sequence`: `integer` (required) — Input sequence number +- `inner_redeemscript_asm`: `string` (required) — Inner redeemscript in assembly (for P2SH-wrapped SegWit: scriptsig + witness both present) +- `inner_witnessscript_asm`: `string` (required) — Inner witnessscript in assembly (for P2WSH: last witness item decoded as script) + +### `TxIndex` + +`integer` + +### `TxOut` + +- `scriptpubkey`: `string` (required) — Script pubkey (locking script) +- `value`: `Sats` (required) — Value of the output in satoshis + +### `TxOutspend` + +- `spent`: `boolean` (required) — Whether the output has been spent +- `txid`: `Txid | null` — Transaction ID of the spending transaction (only present if spent) +- `vin`: `Vin | null` — Input index in the spending transaction (only present if spent) +- `status`: `TxStatus | null` — Status of the spending transaction (only present if spent) + +### `TxStatus` + +- `confirmed`: `boolean` (required) — Whether the transaction is confirmed +- `block_height`: `Height | null` — Block height (only present if confirmed) +- `block_hash`: `BlockHash | null` — Block hash (only present if confirmed) +- `block_time`: `Timestamp | null` — Block timestamp (only present if confirmed) + +### `TxVersionRaw` + +`integer` + +### `Txid` + +`string` + +### `TypeIndex` + +`integer` + +### `Urpd` + +- `cohort`: `Cohort` (required) +- `date`: `Date` (required) +- `aggregation`: `UrpdAggregation` (required) — Aggregation strategy applied to the buckets. +- `close`: `Dollars` (required) — Close price on `date`, in USD. Anchor for `unrealized_pnl`. +- `total_supply`: `Bitcoin` (required) — Sum of `supply` across all buckets, in BTC. +- `buckets`: `UrpdBucket[]` (required) + +### `UrpdAggregation` + +`raw | lin200 | lin500 | lin1000 | log10 | log50 | log100 | log200 | log500 | log1000 | log2000` + +### `UrpdBucket` + +- `price_floor`: `Dollars` (required) — Lower bound of the bucket, in USD. Equals the exact realized price for `Raw`. +- `supply`: `Bitcoin` (required) — Supply held with a last-move price inside this bucket, in BTC. +- `realized_cap`: `Dollars` (required) — Realized cap contribution in USD: sum of `realized_price * supply` over the coins in this bucket. +- `unrealized_pnl`: `Dollars` (required) — Unrealized P&L in USD against the close on the snapshot date: `close * supply - realized_cap`. Can be negative. + +### `VSize` + +`integer` + +### `Version` + +`integer` + +### `Vin` + +`integer` + +### `Vout` + +`integer` + +### `Weight` + +`integer` + +### `Witness` + +`string[]` -- JavaScript: https://www.npmjs.com/package/brk-client -- Python: https://pypi.org/project/brk-client/ -- Rust: https://crates.io/crates/brk_client diff --git a/website_next/llms.txt b/website_next/llms.txt index 0014d735c..39c9700fd 100644 --- a/website_next/llms.txt +++ b/website_next/llms.txt @@ -1,32 +1,26 @@ # Bitcoin Research Kit (BRK) -> Free, open-source Bitcoin on-chain analytics API at https://bitview.space. 49,000+ time-series (price, hashrate, supply, MVRV, HODL waves, and more), block explorer, address index, mempool stats, mining data. No auth required. JSON and CSV output. +> Free, open-source Bitcoin analytics API and block explorer. 55667 on-chain time-series and 97 API operations. No authentication required. -## API Documentation +## API -- [Full API reference (plain text)](https://bitview.space/llms-full.txt): Every endpoint, parameter, and response shape -- [OpenAPI spec (compact, LLM-optimized)](https://bitview.space/api.json): Machine-readable, minimal spec for tool use -- [OpenAPI spec (full)](https://bitview.space/openapi.json): Complete OpenAPI 3.1 specification -- [Interactive docs](https://bitview.space/api): Scalar API explorer +- Version: `v0.3.6` +- Base URL: https://bitview.space +- [Full plain-text reference](https://bitview.space/llms-full.txt) +- [Compact OpenAPI](https://bitview.space/api.json) +- [Full OpenAPI](https://bitview.space/openapi.json) +- [Series catalog](https://bitview.space/api/series) +- [Interactive documentation](https://bitview.space/api) -## Quick Start +Use OpenAPI for tool construction, `/api/series` for complete series metadata, and `llms-full.txt` for a readable reference. -- [Search series](https://bitview.space/api/series/search?q=price): `GET /api/series/search?q={query}` -- [Get series data](https://bitview.space/api/series/price/day?start=-30): `GET /api/series/{name}/{index}?start=-30` -- [Latest value](https://bitview.space/api/series/price/day/latest): `GET /api/series/{name}/{index}/latest` -- [Bulk query](https://bitview.space/api/series/bulk?index=day&series=price,market_cap&start=-7): `GET /api/series/bulk?index={index}&series={s1},{s2}` -- [Block by height](https://bitview.space/api/block-height/0): `GET /api/block-height/{height}` -- [Transaction](https://bitview.space/api/tx/4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b): `GET /api/tx/{txid}` -- [Address](https://bitview.space/api/address/1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa): `GET /api/address/{address}` -- [Fee estimates](https://bitview.space/api/v1/fees/recommended): `GET /api/v1/fees/recommended` -- [Live price](https://bitview.space/api/mempool/price): `GET /api/mempool/price` +## Clients -## Client Libraries - -- [JavaScript](https://www.npmjs.com/package/brk-client): npm install brk-client -- [Python](https://pypi.org/project/brk-client/): pip install brk-client -- [Rust](https://crates.io/crates/brk_client): cargo add brk_client +- [JavaScript](https://www.npmjs.com/package/brk-client) +- [Python](https://pypi.org/project/brk-client/) +- [Rust](https://crates.io/crates/brk_client) ## Source -- [GitHub](https://github.com/bitcoinresearchkit/brk): MIT licensed +- [GitHub](https://github.com/bitcoinresearchkit/brk) +- MIT licensed diff --git a/website_next/utils/client.js b/website_next/utils/client.js index dd3a6ff1e..3e7278f45 100644 --- a/website_next/utils/client.js +++ b/website_next/utils/client.js @@ -1,3 +1,4 @@ import { BrkClient } from "../modules/brk-client/index.js"; -export const brk = new BrkClient("http://localhost:3110"); +export const BRK_BASE_URL = "http://localhost:3110"; +export const brk = new BrkClient(BRK_BASE_URL);