From 21220f6d39d1232d9de0f3e56e3af2b6f3c777aa Mon Sep 17 00:00:00 2001 From: th-kim0823 Date: Sat, 9 May 2026 20:02:50 +0900 Subject: [PATCH] feat(cli): kebab search --max-tokens / --snippet-chars / --cursor (fb-34) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JSON output wrapped in search_response.v1 (breaking — agent must adapt). Plain output unchanged + [truncated; use --cursor X] stderr hint when budget tripped. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/kebab-app/src/schema.rs | 1 + crates/kebab-cli/src/main.rs | 52 +++++++++++++++++---- crates/kebab-cli/src/wire.rs | 47 ++++++++++++++----- crates/kebab-cli/tests/wire_search_stale.rs | 19 ++++++-- 4 files changed, 95 insertions(+), 24 deletions(-) diff --git a/crates/kebab-app/src/schema.rs b/crates/kebab-app/src/schema.rs index 42aa137..603b212 100644 --- a/crates/kebab-app/src/schema.rs +++ b/crates/kebab-app/src/schema.rs @@ -63,6 +63,7 @@ pub const SCHEMA_V1_ID: &str = "schema.v1"; const WIRE_SCHEMAS: &[&str] = &[ "answer.v1", "search_hit.v1", + "search_response.v1", "doc_summary.v1", "chunk_inspection.v1", "doctor.v1", diff --git a/crates/kebab-cli/src/main.rs b/crates/kebab-cli/src/main.rs index f3df832..16857e5 100644 --- a/crates/kebab-cli/src/main.rs +++ b/crates/kebab-cli/src/main.rs @@ -108,6 +108,23 @@ enum Cmd { /// future TUI cache-aware search and for explicit intent. #[arg(long)] no_cache: bool, + + /// p9-fb-34: cap result wire JSON size at approximately N tokens + /// (chars/4 estimate). When set, smaller snippets and fewer hits + /// may be returned; check `truncated` in the JSON wire. + #[arg(long)] + max_tokens: Option, + + /// p9-fb-34: per-hit snippet character cap, overrides + /// `config.search.snippet_chars` for this call only. + #[arg(long)] + snippet_chars: Option, + + /// p9-fb-34: opaque cursor from a previous response's + /// `next_cursor` to fetch the next page. Mismatched + /// `corpus_revision` returns `error.v1.code = stale_cursor`. + #[arg(long)] + cursor: Option, }, /// Retrieval-augmented question answering. @@ -515,6 +532,9 @@ fn run(cli: &Cli) -> anyhow::Result<()> { mode, explain: _, no_cache, + max_tokens, + snippet_chars, + cursor, } => { let cfg = kebab_config::Config::load(cli.config.as_deref())?; let q = kebab_core::SearchQuery { @@ -523,16 +543,24 @@ fn run(cli: &Cli) -> anyhow::Result<()> { k: *k, filters: kebab_core::SearchFilters::default(), }; - // p9-fb-19: --no-cache routes to the uncached facade. - // Both calls go through the same App; only the cache - // lookup/insert is skipped. - let hits = if *no_cache { - kebab_app::search_uncached_with_config(cfg, q)? - } else { - kebab_app::search_with_config(cfg, q)? + let opts = kebab_core::SearchOpts { + max_tokens: *max_tokens, + snippet_chars: *snippet_chars, + cursor: cursor.clone(), }; + // p9-fb-34: budget-aware path. --no-cache still bypasses the + // App-level LRU; wire wrapper applies regardless. + let app = kebab_app::App::open_with_config(cfg)?; + if *no_cache { + app.clear_search_cache(); + } + let resp = app.search_with_opts(q, opts)?; + if cli.json { - println!("{}", serde_json::to_string(&wire::wire_search_hits(&hits))?); + println!( + "{}", + serde_json::to_string(&wire::wire_search_response(&resp))? + ); } else { // p9-fb-32: prefix `[stale]` on the doc_path for hits // whose `stale: true`. Yellow on TTY, plain otherwise — @@ -542,7 +570,7 @@ fn run(cli: &Cli) -> anyhow::Result<()> { // lands on); no new dep. use std::io::IsTerminal; let color = std::io::stdout().is_terminal(); - for h in &hits { + for h in &resp.hits { // Show 4-digit score so RRF fused scores (bounded // ~0–0.033 for k_rrf=60) don't all collapse to "0.02". // Append heading_path so multiple chunks from the same @@ -570,6 +598,12 @@ fn run(cli: &Cli) -> anyhow::Result<()> { heading, ); } + // p9-fb-34: truncation hint goes to stderr so it + // doesn't pollute the stdout hit list. + if resp.truncated { + let next = resp.next_cursor.as_deref().unwrap_or("(none)"); + eprintln!("[truncated; use --cursor {next} for the next page]"); + } } Ok(()) } diff --git a/crates/kebab-cli/src/wire.rs b/crates/kebab-cli/src/wire.rs index e1e35d3..649d3f0 100644 --- a/crates/kebab-cli/src/wire.rs +++ b/crates/kebab-cli/src/wire.rs @@ -75,10 +75,18 @@ pub fn wire_search_hit(h: &SearchHit) -> Value { tag_object(v, "search_hit.v1") } -/// Wrap a list of [`SearchHit`] values as a JSON array of `search_hit.v1` -/// objects (one tag per element, per design §2.2). -pub fn wire_search_hits(hits: &[SearchHit]) -> Value { - Value::Array(hits.iter().map(wire_search_hit).collect()) +/// p9-fb-34: tag a `SearchResponse` as `search_response.v1`. Wraps +/// the existing `search_hit.v1[]` array with pagination + truncation +/// metadata. Replaces the previous bare `search_hit.v1[]` top-level +/// array (`wire_search_hits`) — see HOTFIXES / fb-34 for the +/// breaking shape change. +pub fn wire_search_response(r: &kebab_app::SearchResponse) -> Value { + let v = serde_json::json!({ + "hits": r.hits.iter().map(wire_search_hit).collect::>(), + "next_cursor": r.next_cursor, + "truncated": r.truncated, + }); + tag_object(v, "search_response.v1") } /// Wrap an [`Answer`] as `answer.v1`. @@ -234,13 +242,6 @@ mod tests { assert_eq!(v.as_array().unwrap().len(), 0); } - #[test] - fn search_hits_wraps_each_element() { - let v = wire_search_hits(&[]); - assert!(v.is_array()); - assert_eq!(v.as_array().unwrap().len(), 0); - } - #[test] fn tag_object_inserts_into_object() { let v = Value::Object(serde_json::Map::new()); @@ -248,6 +249,30 @@ mod tests { assert_eq!(schema_of(&tagged), Some("x.v1")); } + #[test] + fn search_response_carries_pagination_metadata() { + // p9-fb-34: empty-hits SearchResponse round-trips through the + // wrapper with its `next_cursor` + `truncated` fields preserved + // and the top-level `schema_version` set to `search_response.v1`. + let r = kebab_app::SearchResponse { + hits: vec![], + next_cursor: Some("opaque-cursor-abc".to_string()), + truncated: true, + }; + let v = wire_search_response(&r); + assert_eq!(schema_of(&v), Some("search_response.v1")); + assert!(v.get("hits").and_then(|h| h.as_array()).is_some()); + assert_eq!( + v.get("hits").and_then(|h| h.as_array()).unwrap().len(), + 0 + ); + assert_eq!( + v.get("next_cursor").and_then(|c| c.as_str()), + Some("opaque-cursor-abc") + ); + assert_eq!(v.get("truncated").and_then(|t| t.as_bool()), Some(true)); + } + #[test] fn schema_wrapper_tags_schema_version() { use kebab_app::{Capabilities, Models, SchemaV1, Stats, WireBlock}; diff --git a/crates/kebab-cli/tests/wire_search_stale.rs b/crates/kebab-cli/tests/wire_search_stale.rs index 9347d3e..483c4a8 100644 --- a/crates/kebab-cli/tests/wire_search_stale.rs +++ b/crates/kebab-cli/tests/wire_search_stale.rs @@ -45,10 +45,21 @@ fn search_json_includes_indexed_at_and_stale() { let out = run_search_lexical(&cfg, "apples", true); let stdout = String::from_utf8_lossy(&out.stdout); - let arr: serde_json::Value = serde_json::from_str(stdout.trim()) - .unwrap_or_else(|e| panic!("expected JSON array, got {stdout:?}: {e}")); - let arr = arr.as_array().unwrap_or_else(|| panic!("expected array, got {stdout}")); - let first = arr.first().unwrap_or_else(|| panic!("expected ≥1 hit, got empty array: {stdout}")); + // p9-fb-34: top-level wire is now `search_response.v1` wrapping the + // legacy `search_hit.v1[]` under a `hits` field (with pagination + + // truncation metadata). Hit shape inside `hits` is unchanged. + let resp: serde_json::Value = serde_json::from_str(stdout.trim()) + .unwrap_or_else(|e| panic!("expected JSON object, got {stdout:?}: {e}")); + assert_eq!( + resp.get("schema_version").and_then(|v| v.as_str()), + Some("search_response.v1"), + "expected search_response.v1 wrapper, got {resp}" + ); + let arr = resp + .get("hits") + .and_then(|h| h.as_array()) + .unwrap_or_else(|| panic!("expected hits array, got {stdout}")); + let first = arr.first().unwrap_or_else(|| panic!("expected ≥1 hit, got empty hits: {stdout}")); assert!( first.get("indexed_at").is_some(), "missing indexed_at in {first}"