Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/th-knowledge-search.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@smooai/smooth": minor
---

`th knowledge` — promote knowledge to a top-level command with semantic search.

`th knowledge search "<query>"` 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 <id>`, 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).
12 changes: 12 additions & 0 deletions crates/smooth-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,17 @@ enum Commands {
#[command(subcommand)]
cmd: smooai::files::Cmd,
},
/// Smoo AI knowledge base — `th knowledge search <query>` 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 {
Expand Down Expand Up @@ -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 {
Expand Down
68 changes: 68 additions & 0 deletions crates/smooth-cli/src/smooai/knowledge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u64>,
/// 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<String>,
/// 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<String>,
},
/// 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`.
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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),
}
}
Loading