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}"