Compare commits

...
5 Commits
Author SHA1 Message Date
Dark-Alex-17 f44722df04 lint: Removed accidental session file
CI / All (ubuntu-latest) (push) Failing after 32s
CI / All (macos-latest) (push) Canceled after 0s
CI / All (windows-latest) (push) Canceled after 0s
2026-08-19 12:59:50 -06:00
Dark-Alex-17 3eaae0e652 feat: Dynamically detect RAG embedding model dimension for any given model 2026-08-18 19:57:11 -06:00
Dark-Alex-17 b12829db39 fix: drain crossterm characters in zellij in kitty contexts to prevent DA1 responses from entering prompt 2026-08-18 15:30:07 -06:00
Dark-Alex-17 dffaf6b9db fix: drain tty input when displaying inquire prompts to prevent unintentional escapes 2026-08-18 15:10:30 -06:00
Dark-Alex-17 a9a4ccca88 fix: breaking iwe MCP server changes with newest version 2026-08-18 11:29:36 -06:00
9 changed files with 301 additions and 57 deletions
+1 -2
View File
@@ -21,8 +21,7 @@
}, },
"iwe": { "iwe": {
"type": "stdio", "type": "stdio",
"command": "iwec", "command": "iwec"
"args": ["--project", "."]
} }
} }
} }
+1 -1
View File
@@ -2,7 +2,7 @@
description: Navigate and curate markdown knowledge bases (plan repos, spec repos, companion docs) with IWE graph tools. Load when the workspace is or contains a markdown knowledge base and the task involves finding, reading, or reorganizing plans, specs, designs, or notes. Activates the iwe MCP server rooted at the current directory. description: Navigate and curate markdown knowledge bases (plan repos, spec repos, companion docs) with IWE graph tools. Load when the workspace is or contains a markdown knowledge base and the task involves finding, reading, or reorganizing plans, specs, designs, or notes. Activates the iwe MCP server rooted at the current directory.
enabled_mcp_servers: iwe enabled_mcp_servers: iwe
--- ---
You are working with a markdown knowledge base through IWE, a graph-based knowledge tool. The `iwe` MCP server is rooted at the current working directory (`--project .`), so the knowledge base is the directory Coyote was launched in. IWE derives structure from links: a link on its own line is an *inclusion link* (parent-child hierarchy); a link inside text is an *inline reference* (cross-reference, produces backlinks). The server watches the filesystem, so external edits are picked up automatically — never ask for a restart. You are working with a markdown knowledge base through IWE, a graph-based knowledge tool. The `iwe` MCP server is rooted at the current working directory, so the knowledge base is the directory Coyote was launched in. IWE derives structure from links: a link on its own line is an *inclusion link* (parent-child hierarchy); a link inside text is an *inline reference* (cross-reference, produces backlinks). The server watches the filesystem, so external edits are picked up automatically — never ask for a restart.
## When to use this (and when not) ## When to use this (and when not)
+13 -9
View File
@@ -1,6 +1,6 @@
use crate::client::{ClientConfig, Model, ModelType, list_models}; use crate::client::{ClientConfig, Model, ModelType, list_models};
use crate::render::{MarkdownRender, RenderOptions}; use crate::render::{MarkdownRender, RenderOptions};
use crate::utils::{IS_STDOUT_TERMINAL, NO_COLOR, decode_bin, get_env_name}; use crate::utils::{IS_STDOUT_TERMINAL, NO_COLOR, decode_bin, drain_stale_tty_input, get_env_name};
use super::paths; use super::paths;
use anyhow::{Context, Result, anyhow, bail}; use anyhow::{Context, Result, anyhow, bail};
@@ -616,14 +616,18 @@ impl AppConfig {
if self.highlight && self.theme.is_none() { if self.highlight && self.theme.is_none() {
if let Some(v) = super::read_env_value::<String>(&get_env_name("theme")) { if let Some(v) = super::read_env_value::<String>(&get_env_name("theme")) {
self.theme = v; self.theme = v;
} else if *IS_STDOUT_TERMINAL } else if *IS_STDOUT_TERMINAL {
&& let Ok(color_scheme) = color_scheme(QueryOptions::default()) if let Ok(color_scheme) = color_scheme(QueryOptions::default()) {
{ let theme = match color_scheme {
let theme = match color_scheme { ColorScheme::Dark => "dark",
ColorScheme::Dark => "dark", ColorScheme::Light => "light",
ColorScheme::Light => "light", };
}; self.theme = Some(theme.into());
self.theme = Some(theme.into()); }
// The OSC/DA1 reply can arrive after colorsaurus stops reading
// (observed under zellij-in-kitty). Drain any late reply bytes so
// they are neither echoed nor read as line-editor input.
drain_stale_tty_input();
} }
} }
if let Some(v) = super::read_env_value::<String>(&get_env_name("left_prompt")) { if let Some(v) = super::read_env_value::<String>(&get_env_name("left_prompt")) {
+33 -33
View File
@@ -151,7 +151,7 @@ impl Rag {
println!("⚙ Initializing RAG..."); println!("⚙ Initializing RAG...");
let mut data = Self::resolve_init_data(app, config)?; let mut data = Self::resolve_init_data(app, config)?;
data.driver = config.driver.clone().unwrap_or_else(|| "yaml".to_string()); data.driver = config.driver.clone().unwrap_or_else(|| "yaml".to_string());
let mut rag = Self::create(app, name, save_path, data)?; let mut rag = Self::create(app, name, save_path, data).await?;
let loaders = app.document_loaders.clone(); let loaders = app.document_loaders.clone();
let (spinner, spinner_rx) = Spinner::create(""); let (spinner, spinner_rx) = Spinner::create("");
abortable_run_with_spinner_rx( abortable_run_with_spinner_rx(
@@ -298,7 +298,7 @@ impl Rag {
}, },
); );
data.driver = driver; data.driver = driver;
let mut rag = Self::create(app, name, save_path, data)?; let mut rag = Self::create(app, name, save_path, data).await?;
let mut paths = doc_paths.to_vec(); let mut paths = doc_paths.to_vec();
if paths.is_empty() { if paths.is_empty() {
paths = add_documents()?; paths = add_documents()?;
@@ -317,12 +317,12 @@ impl Rag {
Ok(rag) Ok(rag)
} }
pub fn load(app: &AppConfig, name: &str, path: &Path) -> Result<Self> { pub async fn load(app: &AppConfig, name: &str, path: &Path) -> Result<Self> {
let err = || format!("Failed to load rag '{name}' at '{}'", path.display()); let err = || format!("Failed to load rag '{name}' at '{}'", path.display());
let content = fs::read_to_string(path).with_context(err)?; let content = fs::read_to_string(path).with_context(err)?;
let data: RagData = serde_yaml::from_str(&content).with_context(err)?; let data: RagData = serde_yaml::from_str(&content).with_context(err)?;
data.validate().with_context(err)?; data.validate().with_context(err)?;
Self::create(app, name, path, data) Self::create(app, name, path, data).await
} }
/// Loads a RAG from a YAML file. External drivers need an async constructor /// Loads a RAG from a YAML file. External drivers need an async constructor
@@ -372,7 +372,7 @@ impl Rag {
last_sources: RwLock::new(None), last_sources: RwLock::new(None),
}) })
} }
_ => Self::load(app, name, path), _ => Self::load(app, name, path).await,
} }
} }
@@ -537,14 +537,22 @@ impl Rag {
Ok(rag) Ok(rag)
} }
pub fn create(app: &AppConfig, name: &str, path: &Path, mut data: RagData) -> Result<Self> { pub async fn create(
app: &AppConfig,
name: &str,
path: &Path,
mut data: RagData,
) -> Result<Self> {
// Deliberately does NOT call rebuild_indexes: both callers construct the Rag // Deliberately does NOT call rebuild_indexes: both callers construct the Rag
// before any documents are added, so rebuilding empty data would be a no-op. // before any documents are added, so rebuilding empty data would be a no-op.
// Actual population happens later via sync_documents. // Actual population happens later via sync_documents.
let (provider, bm25): (Box<dyn RagProvider>, _) = match data.driver.as_str() { let (provider, bm25): (Box<dyn RagProvider>, _) = match data.driver.as_str() {
"duckdb" => { "duckdb" => {
let db_path = providers::duckdb_path_from_yaml(path); let db_path = providers::duckdb_path_from_yaml(path);
let dim = embedding_dim_for_model(&data.embedding_model); let dim = match DuckDbProvider::introspect_dim(&db_path)? {
Some(existing) => existing,
None => probe_embedding_dim(app, &data.embedding_model).await?,
};
let duck = DuckDbProvider::open(&db_path, dim)?; let duck = DuckDbProvider::open(&db_path, dim)?;
// HYDRATE — mandatory, not an optimization. The YAML file for a duckdb // HYDRATE — mandatory, not an optimization. The YAML file for a duckdb
// RAG deliberately omits `vectors`, so `data.vectors` arrives empty from // RAG deliberately omits `vectors`, so `data.vectors` arrives empty from
@@ -2119,21 +2127,25 @@ fn reciprocal_rank_fusion(
.collect() .collect()
} }
/// Map an embedding model id to its vector dimension. async fn probe_embedding_dim(app: &AppConfig, model_id: &str) -> Result<usize> {
/// let model = Model::retrieve_model(app, model_id, ModelType::Embedding)?;
/// The DuckDB `FLOAT[N]` column type and its HNSW index are fixed at schema-creation let client = init_client(&Arc::new(app.clone()), model)?;
/// time, so this value must be decided before the first insert. An unrecognized model let out = client
/// falls back to 1536; if that is wrong, DuckDB raises a dimension-mismatch error on .embeddings(&EmbeddingsData::new(vec!["dimension probe".into()], false))
/// the first insert rather than silently corrupting the schema, and the recovery is to .await
/// delete the sidecar and re-ingest from source. .with_context(|| {
fn embedding_dim_for_model(model_id: &str) -> usize { format!(
match model_id { "Failed to probe the embedding dimension of model '{model_id}'. \
m if m.contains("3-large") => 3072, Creating a duckdb RAG requires one call to the embedding endpoint."
m if m.contains("3-small") || m.contains("ada-002") => 1536, )
m if m.contains("nomic-embed-text") || m.contains("all-minilm") => 768, })?;
m if m.contains("jina-embeddings-v2") => 1024, let dim = out.first().map(|v| v.len()).unwrap_or(0);
_ => 1536,
if dim == 0 {
bail!("Embedding model '{model_id}' returned an empty vector during the dimension probe");
} }
Ok(dim)
} }
/// True only for "the vault does not hold this key". /// True only for "the vault does not hold this key".
@@ -2673,18 +2685,6 @@ mod tests {
assert_eq!(data.attached_source_label(), "[external collection]"); assert_eq!(data.attached_source_label(), "[external collection]");
} }
#[test]
fn embedding_dim_for_model_maps_known_models() {
assert_eq!(embedding_dim_for_model("text-embedding-3-large"), 3072);
assert_eq!(embedding_dim_for_model("text-embedding-3-small"), 1536);
assert_eq!(embedding_dim_for_model("text-embedding-ada-002"), 1536);
assert_eq!(embedding_dim_for_model("nomic-embed-text"), 768);
assert_eq!(embedding_dim_for_model("all-minilm"), 768);
assert_eq!(embedding_dim_for_model("jina-embeddings-v2-base-en"), 1024);
// Unknown models fall back to the OpenAI-compatible default.
assert_eq!(embedding_dim_for_model("some-unknown-model"), 1536);
}
#[test] #[test]
fn document_id_round_trip() { fn document_id_round_trip() {
let id = DocumentId::new(5, 17); let id = DocumentId::new(5, 17);
+211 -9
View File
@@ -5,7 +5,7 @@ use std::collections::HashMap;
use anyhow::{Context, Result, anyhow, bail}; use anyhow::{Context, Result, anyhow, bail};
use async_trait::async_trait; use async_trait::async_trait;
use duckdb::types::Value; use duckdb::types::Value;
use duckdb::{AccessMode, Config, Connection}; use duckdb::{AccessMode, Config, Connection, OptionalExt};
use indexmap::IndexMap; use indexmap::IndexMap;
use log::warn; use log::warn;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -30,6 +30,11 @@ pub(crate) fn duckdb_path_from_yaml(yaml_path: &Path) -> PathBuf {
yaml_path.with_extension("duckdb") yaml_path.with_extension("duckdb")
} }
fn parse_float_array_dim(data_type: &str) -> Option<usize> {
let inner = data_type.strip_prefix("FLOAT[")?.strip_suffix(']')?;
inner.parse::<usize>().ok().filter(|&n| n > 0)
}
/// The shared connection together with the access mode it was opened with. /// The shared connection together with the access mode it was opened with.
/// ///
/// `conn` is an `Option` only so that an upgrade can DROP the read-only connection /// `conn` is an `Option` only so that an upgrade can DROP the read-only connection
@@ -42,6 +47,11 @@ struct ConnHandle {
/// `duplicate()` clone sharing the `Arc` observes an upgrade performed through any /// `duplicate()` clone sharing the `Arc` observes an upgrade performed through any
/// other handle instead of keeping its own stale copy of the mode. /// other handle instead of keeping its own stale copy of the mode.
writable: bool, writable: bool,
/// Embedding dimension the `FLOAT[N]` column was opened (or last rebuilt) with.
/// Shared for the same reason as `writable`: a self-healing rebuild through one
/// handle updates the width, and every `duplicate()` clone must cast with the new
/// width instead of erroring on a healthy store with its stale copy.
dim: usize,
} }
impl ConnHandle { impl ConnHandle {
@@ -68,8 +78,6 @@ impl ConnHandle {
pub struct DuckDbProvider { pub struct DuckDbProvider {
path: PathBuf, path: PathBuf,
conn: Arc<Mutex<ConnHandle>>, conn: Arc<Mutex<ConnHandle>>,
/// Embedding dimension; fixed at open time because the `FLOAT[N]` column depends on it.
dim: usize,
/// True once an FTS index has been built on `documents`. Until then /// True once an FTS index has been built on `documents`. Until then
/// `fts_main_documents.match_bm25` does not exist and any keyword query would /// `fts_main_documents.match_bm25` does not exist and any keyword query would
/// fail with a DuckDB catalog error. Backs `has_native_keyword_search`. /// fail with a DuckDB catalog error. Backs `has_native_keyword_search`.
@@ -96,8 +104,8 @@ impl DuckDbProvider {
conn: Arc::new(Mutex::new(ConnHandle { conn: Arc::new(Mutex::new(ConnHandle {
conn: Some(conn), conn: Some(conn),
writable, writable,
dim,
})), })),
dim,
fts_ready: AtomicBool::new(fts_exists), fts_ready: AtomicBool::new(fts_exists),
}) })
} }
@@ -164,6 +172,35 @@ impl DuckDbProvider {
Ok(conn) Ok(conn)
} }
pub fn introspect_dim(db_path: &Path) -> Result<Option<usize>> {
if !db_path.exists() {
return Ok(None);
}
let conn = Self::open_read_only(db_path).with_context(|| {
format!(
"Cannot inspect the existing RAG store at '{}'",
db_path.display()
)
})?;
let ty: Option<String> = conn
.query_row(
"SELECT data_type FROM duckdb_columns() \
WHERE table_name = 'vectors' AND column_name = 'embedding'",
[],
|r| r.get(0),
)
.optional()
.with_context(|| {
format!(
"Failed to introspect the embedding dimension of the DuckDB store \
at '{}'",
db_path.display()
)
})?;
Ok(ty.and_then(|t| parse_float_array_dim(&t)))
}
/// Open the store read-write and make sure its schema exists. Exactly one process /// Open the store read-write and make sure its schema exists. Exactly one process
/// may hold such a handle, and no reader from another process may hold it meanwhile. /// may hold such a handle, and no reader from another process may hold it meanwhile.
fn open_read_write(db_path: &Path, dim: usize) -> Result<Connection> { fn open_read_write(db_path: &Path, dim: usize) -> Result<Connection> {
@@ -245,7 +282,7 @@ impl DuckDbProvider {
return Ok(()); return Ok(());
} }
drop(handle.conn.take()); drop(handle.conn.take());
match Self::open_read_write(&self.path, self.dim) { match Self::open_read_write(&self.path, handle.dim) {
Ok(conn) => { Ok(conn) => {
handle.conn = Some(conn); handle.conn = Some(conn);
handle.writable = true; handle.writable = true;
@@ -428,13 +465,33 @@ impl RagProvider for DuckDbProvider {
if embedding.iter().any(|f| !f.is_finite()) { if embedding.iter().any(|f| !f.is_finite()) {
bail!("Query embedding contains a non-finite value (NaN or infinity)"); bail!("Query embedding contains a non-finite value (NaN or infinity)");
} }
let handle = self.lock_conn()?;
let dim = handle.dim;
if embedding.len() != dim {
let rows: i64 = handle
.conn()?
.query_row("SELECT count(*) FROM vectors", [], |r| r.get(0))
.context("Failed to count vectors before a dimension-mismatch query")?;
if rows == 0 {
// A never-synced store answers "nothing", not a cast error.
return Ok(Vec::new());
}
bail!(
"RAG store at '{}' was built with {dim}-dim embeddings, but the \
embedding model now returns {}-dim vectors. The embedding model \
changed since ingestion. Re-embed the documents, or delete the \
sidecar file and re-ingest.",
self.path.display(),
embedding.len()
);
}
let vals: String = embedding let vals: String = embedding
.iter() .iter()
.map(|f| f.to_string()) .map(|f| f.to_string())
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(", "); .join(", ");
let dim = self.dim;
let handle = self.lock_conn()?;
// array_cosine_distance requires a FLOAT[N] ARRAY, not the LIST type FLOAT[]. // array_cosine_distance requires a FLOAT[N] ARRAY, not the LIST type FLOAT[].
// ORDER BY distance ASC is required for the planner to use hnsw_idx; the // ORDER BY distance ASC is required for the planner to use hnsw_idx; the
// similarity form (DESC) does NOT trigger the ANN index. Distance is converted // similarity form (DESC) does NOT trigger the ANN index. Distance is converted
@@ -554,7 +611,28 @@ impl RagProvider for DuckDbProvider {
); );
} }
} }
let dim = self.dim; let dim = match data.vectors.first() {
None => self.lock_conn()?.dim,
Some((_, first)) => {
let dim = first.len();
if let Some((doc_id, other)) = data.vectors.iter().find(|(_, e)| e.len() != dim) {
let matching = data.vectors.values().filter(|e| e.len() == dim).count();
bail!(
"Refusing to rebuild the RAG store at '{}': the rebuild batch \
mixes {dim}-dim and {}-dim vectors ({matching} vs {} vectors; \
first mismatch: document {}). Re-embed the documents, or \
delete the sidecar file and re-ingest.",
self.path.display(),
other.len(),
data.vectors.len() - matching,
doc_id.0
);
}
dim
}
};
// THE write path. Everything above this line only reads, so the upgrade happens // THE write path. Everything above this line only reads, so the upgrade happens
// here, after both guards have had their say: a rebuild that is going to be // here, after both guards have had their say: a rebuild that is going to be
// refused must not first take the exclusive lock away from other processes. // refused must not first take the exclusive lock away from other processes.
@@ -664,6 +742,8 @@ impl RagProvider for DuckDbProvider {
// fall back to local BM25, which is also empty, and therefore correct. // fall back to local BM25, which is also empty, and therefore correct.
self.fts_ready.store(doc_count > 0, Ordering::Relaxed); self.fts_ready.store(doc_count > 0, Ordering::Relaxed);
handle.dim = dim;
Ok(()) Ok(())
} }
@@ -729,10 +809,11 @@ impl RagProvider for DuckDbProvider {
// Sharing the Arc also shares the ACCESS MODE, which lives inside the ConnHandle // Sharing the Arc also shares the ACCESS MODE, which lives inside the ConnHandle
// rather than beside it: when one handle upgrades itself to read-write, every // rather than beside it: when one handle upgrades itself to read-write, every
// clone is upgraded with it and none is left holding a stale "read-only" belief. // clone is upgraded with it and none is left holding a stale "read-only" belief.
// The embedding dimension lives there too, so a self-healing rebuild through
// one handle updates the width every clone casts with.
Box::new(DuckDbProvider { Box::new(DuckDbProvider {
path: self.path.clone(), path: self.path.clone(),
conn: Arc::clone(&self.conn), conn: Arc::clone(&self.conn),
dim: self.dim,
fts_ready: AtomicBool::new(self.fts_ready.load(Ordering::Relaxed)), fts_ready: AtomicBool::new(self.fts_ready.load(Ordering::Relaxed)),
}) })
} }
@@ -1074,6 +1155,127 @@ mod tests {
.expect("a fresh RAG with nothing indexed must rebuild cleanly"); .expect("a fresh RAG with nothing indexed must rebuild cleanly");
} }
#[test]
fn parse_float_array_dim_handles_arrays_lists_and_scalars() {
assert_eq!(parse_float_array_dim("FLOAT[768]"), Some(768));
assert_eq!(parse_float_array_dim("FLOAT[]"), None);
assert_eq!(parse_float_array_dim("VARCHAR"), None);
}
#[test]
fn introspect_dim_round_trips_the_open_dim() {
let db = TempDb::new("introspect");
assert_eq!(
DuckDbProvider::introspect_dim(&db.path).unwrap(),
None,
"a file that does not exist has no dim"
);
{
let _provider = DuckDbProvider::open(&db.path, 5).unwrap();
}
assert_eq!(DuckDbProvider::introspect_dim(&db.path).unwrap(), Some(5));
}
#[test]
fn introspect_dim_propagates_an_unopenable_existing_file() {
let db = TempDb::new("introspectgarbage");
fs::write(&db.path, b"not a duckdb database").unwrap();
let err = DuckDbProvider::introspect_dim(&db.path).unwrap_err();
assert!(
format!("{err:#}").contains(&format!(
"Cannot inspect the existing RAG store at '{}'",
db.path.display()
)),
"an existing-but-unopenable file must be an error naming the path; got: {err:#}"
);
}
#[tokio::test]
async fn rebuild_indexes_self_heals_dim_from_the_vectors_it_writes() {
let db = TempDb::new("selfheal");
let mut provider = DuckDbProvider::open(&db.path, 5).unwrap();
let mut data = minimal_rag_data();
data.vectors.insert(DocumentId(0), vec![0.1, 0.2, 0.3]);
provider.rebuild_indexes(&data, true).await.unwrap();
let results = provider
.vector_search(&[0.1, 0.2, 0.3], 5, 0.0)
.await
.unwrap();
assert_eq!(results.len(), 1);
drop(provider);
assert_eq!(DuckDbProvider::introspect_dim(&db.path).unwrap(), Some(3));
}
#[tokio::test]
async fn a_self_healed_dim_is_visible_through_duplicate_clones() {
let db = TempDb::new("dimdup");
let mut provider = DuckDbProvider::open(&db.path, 5).unwrap();
let dup = provider.duplicate(&minimal_rag_data());
let mut data = minimal_rag_data();
data.vectors.insert(DocumentId(0), vec![0.1, 0.2, 0.3]);
provider.rebuild_indexes(&data, true).await.unwrap();
let results = dup.vector_search(&[0.1, 0.2, 0.3], 5, 0.0).await.unwrap();
assert_eq!(
results.len(),
1,
"a duplicate() clone must observe the dim written by a rebuild through \
the original"
);
}
#[tokio::test]
async fn rebuild_indexes_rejects_mixed_dim_vectors() {
let db = TempDb::new("mixeddim");
let mut provider = DuckDbProvider::open(&db.path, 3).unwrap();
let mut data = minimal_rag_data();
data.vectors.insert(DocumentId(0), vec![0.1, 0.2, 0.3]);
data.vectors.insert(DocumentId(1), vec![0.1, 0.2, 0.3, 0.4]);
let err = provider.rebuild_indexes(&data, true).await.unwrap_err();
assert!(
err.to_string().contains("rebuild batch mixes"),
"got: {err}"
);
}
#[tokio::test]
async fn vector_search_dim_mismatch_on_empty_store_returns_nothing() {
let db = TempDb::new("dimempty");
let provider = DuckDbProvider::open(&db.path, 3).unwrap();
let results = provider.vector_search(&[0.1, 0.2], 5, 0.0).await.unwrap();
assert!(
results.is_empty(),
"a never-synced store must answer 'nothing', not a cast error"
);
}
#[tokio::test]
async fn vector_search_dim_mismatch_on_populated_store_errors() {
let db = TempDb::new("dimfull");
let mut provider = DuckDbProvider::open(&db.path, 3).unwrap();
let mut data = minimal_rag_data();
data.vectors.insert(DocumentId(0), vec![0.1, 0.2, 0.3]);
provider.rebuild_indexes(&data, true).await.unwrap();
let err = provider
.vector_search(&[0.1, 0.2], 5, 0.0)
.await
.unwrap_err();
assert!(err.to_string().contains("was built with"), "got: {err}");
}
#[tokio::test] #[tokio::test]
async fn duplicate_shares_the_same_connection() { async fn duplicate_shares_the_same_connection() {
let db = TempDb::new("dup"); let db = TempDb::new("dup");
+6 -2
View File
@@ -19,8 +19,8 @@ use crate::config::{AssetCategory, paths};
use crate::function::supervisor::{GuardrailAction, check_pending_agents_guardrail}; use crate::function::supervisor::{GuardrailAction, check_pending_agents_guardrail};
use crate::render::render_error; use crate::render::render_error;
use crate::utils::{ use crate::utils::{
AbortSignal, SHELL, abortable_run_with_spinner, create_abort_signal, dimmed_text, run_command, AbortSignal, SHELL, abortable_run_with_spinner, create_abort_signal, dimmed_text,
set_text, temp_file, drain_stale_tty_input, run_command, set_text, temp_file,
}; };
use crate::sandbox::SANDBOX_ENV_FLAG; use crate::sandbox::SANDBOX_ENV_FLAG;
@@ -411,6 +411,10 @@ Type ".help" for additional help.
} }
} }
// Discard any stray terminal-query reply bytes (e.g. late colorsaurus
// OSC 11 / DA1 responses) so they don't get injected into the prompt.
drain_stale_tty_input();
loop { loop {
if self.abort_signal.aborted_ctrld() { if self.abort_signal.aborted_ctrld() {
break; break;
+29 -1
View File
@@ -35,6 +35,7 @@ use nu_ansi_term::Color;
use serde_json::Value; use serde_json::Value;
use std::borrow::Cow; use std::borrow::Cow;
use std::collections::VecDeque; use std::collections::VecDeque;
use std::io;
use std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicBool;
use std::sync::{LazyLock, Mutex, OnceLock}; use std::sync::{LazyLock, Mutex, OnceLock};
use std::{cmp, env, path::PathBuf, process}; use std::{cmp, env, path::PathBuf, process};
@@ -45,7 +46,7 @@ pub static CODE_BLOCK_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?ms)```\w*(.*)```").unwrap()); LazyLock::new(|| Regex::new(r"(?ms)```\w*(.*)```").unwrap());
pub static THINK_TAG_RE: LazyLock<Regex> = pub static THINK_TAG_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?s)^\s*<think>.*?</think>(\s*|$)").unwrap()); LazyLock::new(|| Regex::new(r"(?s)^\s*<think>.*?</think>(\s*|$)").unwrap());
pub static IS_STDOUT_TERMINAL: LazyLock<bool> = LazyLock::new(|| std::io::stdout().is_terminal()); pub static IS_STDOUT_TERMINAL: LazyLock<bool> = LazyLock::new(|| io::stdout().is_terminal());
pub static HEADLESS: AtomicBool = AtomicBool::new(false); pub static HEADLESS: AtomicBool = AtomicBool::new(false);
pub static ACP_SERVER: AtomicBool = AtomicBool::new(false); pub static ACP_SERVER: AtomicBool = AtomicBool::new(false);
@@ -133,6 +134,33 @@ pub fn parse_bool(value: &str) -> Option<bool> {
} }
} }
pub fn drain_stale_tty_input() {
use crossterm::event::{poll, read};
use std::time::{Duration, Instant};
if !io::stdin().is_terminal() {
return;
}
if crossterm::terminal::enable_raw_mode().is_err() {
return;
}
let deadline = Instant::now() + Duration::from_millis(100);
while Instant::now() < deadline {
match poll(Duration::from_millis(10)) {
Ok(true) => {
if read().is_err() {
break;
}
}
_ => break,
}
}
let _ = crossterm::terminal::disable_raw_mode();
}
pub fn estimate_token_length(text: &str) -> usize { pub fn estimate_token_length(text: &str) -> usize {
let weighted: usize = text.chars().map(|c| if c.is_ascii() { 1 } else { 2 }).sum(); let weighted: usize = text.chars().map(|c| if c.is_ascii() { 1 } else { 2 }).sum();
weighted.div_ceil(4) weighted.div_ceil(4)
+3
View File
@@ -12,6 +12,7 @@ pub use utils::prompt_provider_choice;
use crate::cli::Cli; use crate::cli::Cli;
use crate::config::AppConfig; use crate::config::AppConfig;
use crate::utils::drain_stale_tty_input;
use crate::vault::utils::ensure_password_file_initialized; use crate::vault::utils::ensure_password_file_initialized;
use anyhow::{Context, Result, anyhow, bail}; use anyhow::{Context, Result, anyhow, bail};
use fancy_regex::Regex; use fancy_regex::Regex;
@@ -151,6 +152,7 @@ impl Vault {
"Vault management is disabled in sandbox mode. Use `coyote --add-secret` on your host." "Vault management is disabled in sandbox mode. Use `coyote --add-secret` on your host."
); );
} }
drain_stale_tty_input();
let secret_value = Password::new("Enter the secret value:") let secret_value = Password::new("Enter the secret value:")
.with_validator(required!()) .with_validator(required!())
.with_display_mode(PasswordDisplayMode::Masked) .with_display_mode(PasswordDisplayMode::Masked)
@@ -190,6 +192,7 @@ impl Vault {
"Vault management is disabled in sandbox mode. Use `coyote --add-secret` on your host." "Vault management is disabled in sandbox mode. Use `coyote --add-secret` on your host."
); );
} }
drain_stale_tty_input();
let secret_value = Password::new("Enter the secret value:") let secret_value = Password::new("Enter the secret value:")
.with_validator(required!()) .with_validator(required!())
.with_display_mode(PasswordDisplayMode::Masked) .with_display_mode(PasswordDisplayMode::Masked)
+4
View File
@@ -1,5 +1,6 @@
use crate::config::ensure_parent_exists; use crate::config::ensure_parent_exists;
use crate::sandbox::{SANDBOX_ENV_FLAG, sandbox_secret_env_var}; use crate::sandbox::{SANDBOX_ENV_FLAG, sandbox_secret_env_var};
use crate::utils::drain_stale_tty_input;
use crate::vault::{SECRET_RE, Vault}; use crate::vault::{SECRET_RE, Vault};
use anyhow::Result; use anyhow::Result;
use anyhow::anyhow; use anyhow::anyhow;
@@ -68,6 +69,7 @@ pub fn create_vault_password_file(vault: &mut Vault) -> Result<()> {
} }
} }
drain_stale_tty_input();
let ans = Confirm::new( let ans = Confirm::new(
format!( format!(
"The configured password file '{}' is empty. Create a password?", "The configured password file '{}' is empty. Create a password?",
@@ -107,6 +109,7 @@ pub fn create_vault_password_file(vault: &mut Vault) -> Result<()> {
} }
} }
} else { } else {
drain_stale_tty_input();
let ans = Confirm::new("No password file configured. Do you want to create one now?") let ans = Confirm::new("No password file configured. Do you want to create one now?")
.with_default(true) .with_default(true)
.prompt()?; .prompt()?;
@@ -185,6 +188,7 @@ pub fn create_vault_password_file(vault: &mut Vault) -> Result<()> {
} }
pub fn prompt_provider_choice() -> Result<Option<SupportedProvider>> { pub fn prompt_provider_choice() -> Result<Option<SupportedProvider>> {
drain_stale_tty_input();
let choices = vec![ let choices = vec![
"local - encrypted file on this machine", "local - encrypted file on this machine",
"aws_secrets_manager - AWS Secrets Manager", "aws_secrets_manager - AWS Secrets Manager",