diff --git a/.changeset/th-knowledge-search.md b/.changeset/th-knowledge-search.md new file mode 100644 index 00000000..10142f6a --- /dev/null +++ b/.changeset/th-knowledge-search.md @@ -0,0 +1,9 @@ +--- +"@smooai/smooth": minor +--- + +`th knowledge` — promote knowledge to a top-level command with semantic search. + +`th knowledge search ""` runs the SAME retrieval an agent does over the org's OWN knowledge base (embed → dense+sparse hybrid → RRF), returning the most relevant passages. Scope to a single document with `--doc `, cap with `--max`, or get raw JSON with `--json`. It rounds out the agentic-coding trio alongside `th search` (the web) and `th crawl` (a page). + +The full knowledge surface (`list` / `show` / `content` / `upload` / `website` / `process` / `update` / `delete`) is now available top-level as `th knowledge` (alias `kb`), same as `th api knowledge`. Backed by the new authed `POST /organizations/:org_id/knowledge/search` endpoint (SMOODEV-2610). diff --git a/crates/smooth-cli/src/main.rs b/crates/smooth-cli/src/main.rs index 821decdb..3612227b 100644 --- a/crates/smooth-cli/src/main.rs +++ b/crates/smooth-cli/src/main.rs @@ -272,6 +272,17 @@ enum Commands { #[command(subcommand)] cmd: smooai::files::Cmd, }, + /// Smoo AI knowledge base — `th knowledge search ` runs the SAME + /// semantic retrieval an agent does over the org's OWN documents (scope to one + /// with `--doc`), plus `list` / `show` / `content` / `upload` / `website` / + /// `process` / `update` / `delete`. Same commands as `th api knowledge`, + /// promoted to the top level as an agentic-coding primitive alongside + /// `th search` (the web) and `th crawl` (a page). + #[command(visible_alias = "kb")] + Knowledge { + #[command(subcommand)] + cmd: smooai::knowledge::Cmd, + }, /// Run a pearl through a Smooth operative — dispatches to Big Smooth /// (`th up` must be running) and streams agent events to stdout. Run { @@ -1600,6 +1611,7 @@ async fn main() -> Result<()> { Some(Commands::Widgets { cmd }) => smooai::widgets::cmd(cmd).await, Some(Commands::Crawl { cmd }) => smooai::crawl::cmd(cmd).await, Some(Commands::Search { args }) => smooai::websearch::run(args).await, + Some(Commands::Knowledge { cmd }) => smooai::knowledge::cmd(cmd).await, Some(Commands::WebSearch { cmd }) => smooai::websearch::cmd(cmd).await, Some(Commands::Llm { cmd }) => smooai::llm_gateway::cmd(cmd).await, Some(Commands::Notify { diff --git a/crates/smooth-cli/src/smooai/knowledge.rs b/crates/smooth-cli/src/smooai/knowledge.rs index 22cb2c84..6e8456be 100644 --- a/crates/smooth-cli/src/smooai/knowledge.rs +++ b/crates/smooth-cli/src/smooai/knowledge.rs @@ -7,6 +7,25 @@ use super::{print_json, print_list_envelope, read_body, require_active_org, requ #[derive(Subcommand)] pub enum Cmd { + /// Semantic search over the org's OWN knowledge base — the same retrieval an + /// agent runs. Returns the most relevant passages (name + content + score). + /// Give it a question or a few keywords; scope to one document with `--doc`. + Search { + /// What to look for in the org's knowledge (a question or keywords). + query: String, + /// Max passages to return (1-20). + #[arg(long, value_name = "N")] + max: Option, + /// Scope the search to a single knowledge document (id from `th knowledge list`). + #[arg(long = "doc", visible_alias = "document-id", value_name = "DOC_ID")] + doc: Option, + /// Print the full JSON response instead of the compact passage list. + #[arg(long)] + json: bool, + /// Override the active org. Falls back to `SMOOAI_ORG_ID` then the credentials file's `active_org_id`. + #[arg(long = "org-id", visible_alias = "org")] + org: Option, + }, /// List the knowledge documents in the active (or `--org-id`) organization. List { /// Override the active org. Falls back to `SMOOAI_ORG_ID` then the credentials file's `active_org_id`. @@ -98,6 +117,31 @@ pub enum Cmd { pub async fn cmd(cmd: Cmd) -> Result<()> { let client = require_authed().await?; match cmd { + Cmd::Search { + query, + max, + doc, + json: as_json, + org, + } => { + let o = require_active_org(&client, org)?; + let mut body = serde_json::json!({ "query": query }); + if let Some(m) = max { + body["maxResults"] = serde_json::json!(m); + } + if let Some(d) = doc { + body["documentId"] = serde_json::json!(d); + } + let resp = client + .post(&format!("/organizations/{o}/knowledge/search"), Some(&body)) + .await + .context("POST knowledge search")?; + if as_json { + print_json(&resp); + } else { + print_knowledge_results(&resp); + } + } Cmd::List { org } => { let o = require_active_org(&client, org)?; print_list_envelope( @@ -196,3 +240,27 @@ pub async fn cmd(cmd: Cmd) -> Result<()> { } Ok(()) } + +/// Compact rendering of `POST /knowledge/search`: a numbered list of +/// `name` with the passage below each, most-relevant first. Falls back to JSON on +/// an unexpected shape. +fn print_knowledge_results(resp: &serde_json::Value) { + match resp.get("results").and_then(|r| r.as_array()) { + Some(results) if !results.is_empty() => { + for (i, r) in results.iter().enumerate() { + let name = r.get("name").and_then(|v| v.as_str()).unwrap_or("(untitled)"); + println!("{}. {name}", i + 1); + if let Some(content) = r.get("content").and_then(|v| v.as_str()) { + // Indent the passage under its heading; keep it whole (unlike + // web search, knowledge passages are the answer, not a teaser). + for line in content.lines() { + println!(" {line}"); + } + } + println!(); + } + } + Some(_) => println!("No matching knowledge found for that query."), + None => print_json(resp), + } +}