next: ai part 6

This commit is contained in:
nym21
2026-07-25 22:18:21 +02:00
parent 6207d5335d
commit 5079f2be41
51 changed files with 7130 additions and 1287 deletions
Generated
+25 -25
View File
@@ -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",
+1 -1
View File
@@ -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"] }
+11 -5
View File
@@ -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
@@ -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::<Vec<_>>();
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::<String, Vec<&Endpoint>>::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::<String>() + chars.as_str())
.unwrap_or_default()
})
.collect::<Vec<_>>()
.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::<Vec<_>>();
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::<Vec<_>>()
.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<String> {
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::<Vec<_>>();
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<String>) {
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::<BTreeSet<_>>()
})
.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::<Vec<_>>()
.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::<Vec<_>>()
.join(" | ");
}
}
"object".to_owned()
}
fn one_line(value: &str) -> String {
value.split_whitespace().collect::<Vec<_>>().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/<id>\""));
}
#[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`"));
}
}
+3 -1
View File
@@ -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;
+21 -7
View File
@@ -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<PathBuf>,
/// Full path to Python client file (e.g., "packages/brk_client/__init__.py")
pub python: Option<PathBuf>,
/// Root directories for generated LLM client bundles.
pub llm: Vec<PathBuf>,
}
impl ClientOutputPaths {
@@ -45,6 +50,11 @@ impl ClientOutputPaths {
self.python = Some(path.into());
self
}
pub fn llm(mut self, root: impl Into<PathBuf>) -> 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(())
}
+1 -1
View File
@@ -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)*
///
+10 -4
View File
@@ -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,
})
}
}
+3 -1
View File
@@ -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)?;
+1 -1
View File
@@ -63,7 +63,7 @@ impl AddrRoutes for ApiRouter<AppState> {
.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::<AddrStats>()
.not_modified()
.bad_request()
+3 -1
View File
@@ -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)
+5 -2
View File
@@ -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,
}
+3 -2
View File
@@ -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)*
*
+4 -2
View File
@@ -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)*
+162
View File
@@ -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();
+1871 -309
View File
File diff suppressed because it is too large Load Diff
+16 -22
View File
@@ -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
+77
View File
@@ -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<Awaited<ReturnType<typeof compactContext>>>) => 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<typeof compactContext> } | 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,
};
}
+30 -18
View File
@@ -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,
};
}
+54 -16
View File
@@ -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);
+6 -4
View File
@@ -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<GenerationResult>} */ (
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 },
});
});
}
+63
View File
@@ -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<string, string | number | boolean | (string | number | boolean)[]>} 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<string, unknown>} */ (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,
});
+241
View File
@@ -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<string, unknown>} */ (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<string, unknown>} 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<string, unknown>} */ (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);
}
+127
View File
@@ -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<string, unknown>} 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,
};
}
+104
View File
@@ -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<string, { resolve: (value: any) => void, reject: (error: Error) => void, onProgress?: () => void }>} */
#pending = new Map();
/** @param {"prewarm" | "search" | "byKey"} type @param {Record<string, unknown>} 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<ApiOperation[]>} */
export function searchApi(queries, limit = 8, onProgress) {
return index.request("search", { queries, limit }, onProgress);
}
/** @param {string} key @returns {Promise<ApiOperation | undefined>} */
export function apiByKey(key) {
return index.request("byKey", { key });
}
export function terminateApiIndex() {
index.terminate();
}
+201
View File
@@ -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<string, any>} */
function isObject(value) {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
/** @param {Record<string, any>} 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<string, any>} spec @param {unknown} value @returns {Record<string, any>} */
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<string, any>} spec
* @param {Record<string, any>} 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<string, any>} 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<string, any>} spec @param {Record<string, any>} 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));
}
+32
View File
@@ -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<string, unknown>} [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;
}
+218
View File
@@ -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<Awaited<ReturnType<typeof buildState>>> | 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<ReturnType<typeof buildState>>} 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<ReturnType<typeof buildState>>} 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),
});
}
});
+19
View File
@@ -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 : [],
};
}
+7 -20
View File
@@ -2,19 +2,6 @@ import { brk } from "../../utils/client.js";
const RANGE_POINTS = 120;
/** @type {Map<string, Promise<{ indexes: string[], type: string }>>} */
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<string, unknown>} action */
/** @param {{ name: string, indexes: string[], type: string, suggestedUnit?: string }} metric @param {Record<string, unknown>} 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,
+2 -1
View File
@@ -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/;
@@ -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;
}
+183 -5
View File
@@ -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<string, unknown> }} [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<string, unknown>, requestPath: string, data: unknown, truncated: boolean }} [apiGrounding]
*/
/**
* @param {import("../model.js").AskModel} model
* @param {NonNullable<ToolOutcome["apiGrounding"]>} 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<string, AskToolSession>} */
@@ -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();
},
};
+11 -11
View File
@@ -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<string, { resolve: (value: any) => void, reject: (error: Error) => void, onProgress?: () => void }>} */
#pending = new Map();
/** @param {"search" | "mentions" | "categories" | "byName" | "byPaths" | "variants"} type @param {Record<string, unknown>} data @param {(() => void) | undefined} [onProgress] */
/** @param {"prewarm" | "search" | "mentions" | "byName" | "byPaths" | "variants"} type @param {Record<string, unknown>} 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<CatalogMetric[]>} */
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<CatalogMetric[]>} */
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<CatalogMetric | undefined>} */
+17 -4
View File
@@ -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 */
+51
View File
@@ -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<string, any>} */
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<string, any>} 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));
}
+46 -104
View File
@@ -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<string, unknown> }} */ (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<string, CatalogMetric>} */
const byName = new Map();
/** @type {Map<string, CatalogMetric>} */
@@ -62,35 +58,14 @@ function buildState() {
const bySearchableName = new Map();
/** @type {Map<string, CatalogMetric>} */
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<string, { path?: string }> }} */ (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<ReturnType<typeof buildState>> | undefined} */
/** @type {Promise<Awaited<ReturnType<typeof buildState>>> | 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<typeof buildState>} index @param {string[]} prefixes */
/** @param {Awaited<ReturnType<typeof buildState>>} 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<typeof buildState>} index @param {string} query @param {number} limit @param {string[]} prefixes */
/** @param {Awaited<ReturnType<typeof buildState>>} 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<typeof buildState>} index @param {string[]} queries @param {number} limit @param {string[]} prefixes */
/** @param {Awaited<ReturnType<typeof buildState>>} 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<typeof buildState>} index @param {string} query */
/** @param {Awaited<ReturnType<typeof buildState>>} 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<typeof buildState>} index */
function categories(index) {
/** @type {Map<string, { path: string, label: string, count: number, branches: Map<string, number>[] }>} */
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<typeof buildState>} index @param {string} name @param {string} query */
/** @param {Awaited<ReturnType<typeof buildState>>} 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;
+19 -6
View File
@@ -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.`,
+1
View File
@@ -1,4 +1,5 @@
const PREFIX = /** @type {const} */ ({
api: "a",
category: "c",
fact: "f",
guide: "g",
+167 -6
View File
@@ -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<string, unknown>} */ (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<string, unknown>, 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);
}
+56 -20
View File
@@ -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() {
File diff suppressed because it is too large Load Diff
Binary file not shown.
+1 -2
View File
@@ -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,
};
}
+5 -1
View File
@@ -7,6 +7,10 @@ export class AskSource {
/** @type {Map<string, { resolve: (value: any) => 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<string, unknown>} data
* @param {((progress: { loaded: number, total: number }) => void) | undefined} [onProgress]
*/
+188
View File
@@ -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<string, number[]>} */
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<typeof createSourceSearchIndex>} 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<typeof createSourceSearchIndex>} 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<number, { weights: number[], queryTokens: string[], matched: number, excerptToken: string, excerptWeight: number }>} */
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);
}
+37 -265
View File
@@ -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<typeof createFormulaIndex> | undefined} */
let formulaIndex;
/** @type {ReturnType<typeof createSourceSearchIndex> | 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)
+9 -3
View File
@@ -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,
+1871 -309
View File
File diff suppressed because it is too large Load Diff
+16 -22
View File
@@ -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
+2 -1
View File
@@ -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);