diff --git a/README.ko.md b/README.ko.md index f82c33e..aba396e 100644 --- a/README.ko.md +++ b/README.ko.md @@ -130,6 +130,12 @@ csp search "database host port" ./my-project --content config csp search "authentication" ./my-project --content all ``` +`--max-snippet-lines N`으로 결과당 반환되는 코드를 제한할 수 있습니다. `10`은 시그니처+본문 앞부분 미리보기, `0`은 파일 경로와 라인 범위만 반환하며, 기본값은 전체 청크입니다. 파일을 열기 전 위치를 확인하는 데는 짧은 미리보기로 충분한 경우가 많아, 약간의 맥락을 토큰 절감과 맞바꿉니다. + +```bash +csp search "authentication" ./my-project --max-snippet-lines 10 +``` + `csp find-related`로 기존 위치와 비슷한 코드를 찾을 수 있습니다 (이전 검색 결과의 `file_path`와 `line`을 사용). ```bash @@ -349,6 +355,8 @@ args = [ | `search` | 자연어 또는 코드 쿼리로 코드베이스를 검색. `repo`는 로컬 디렉터리 경로 또는 https:// git URL. | | `find_related` | 파일 경로와 라인 번호를 받아, 해당 위치의 코드와 의미적으로 유사한 청크를 반환. | +두 도구 모두 결과당 반환 코드를 제한하는 `max_snippet_lines`를 받습니다. 기본값은 `10`으로, 시그니처+본문 앞부분 미리보기라 에이전트가 위치를 싸게 확인한 뒤 전체 맥락은 파일로 이동해 봅니다. 위치만 필요하면 `0`, 미리보기로 부족하면 `null`로 전체 청크를 받습니다. + 기본적으로 MCP 서버는 코드 파일만 인덱싱합니다. 문서/설정/전체를 함께 인덱싱하려면 명령에 `--content docs`, `--content config`, `--content all` 또는 조합(예: `--content code docs`)을 추가하세요. 예를 들어 Claude Code에서는 `claude mcp add csp -s user -- bunx @pleaseai/csp mcp --content all`. ## 서브 에이전트 설정 diff --git a/README.md b/README.md index e764a79..f2e135a 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,12 @@ csp search "database host port" ./my-project --content config csp search "authentication" ./my-project --content all ``` +Use `--max-snippet-lines N` to cap the code returned per result: `10` gives a signature-plus-first-lines preview, `0` returns only the file path and line range. The default returns the full chunk. A short preview is often enough to confirm a location before you open the file, so it trades a little context for fewer tokens. + +```bash +csp search "authentication" ./my-project --max-snippet-lines 10 +``` + Use `csp find-related` to discover code similar to a known location (pass `file_path` and `line` from a prior search result): ```bash @@ -349,6 +355,8 @@ Add to `~/.config/zed/settings.json` (or `.zed/settings.json` in your project): | `search` | Search a codebase with a natural-language or code query. Pass `repo` as a local directory path or an https:// git URL. | | `find_related` | Given a file path and line number, return chunks semantically similar to the code at that location. | +Both tools accept `max_snippet_lines` to cap the code returned per result. It defaults to `10` — a signature-plus-first-lines preview that lets an agent confirm a location cheaply, then navigate to the file for full context. Pass `0` for the location only, or `null` for the full chunk when the preview lacks context. + By default the MCP server indexes only code files. To also index documentation, config, or everything, append `--content docs`, `--content config`, or `--content all` to the server command, or a combination, e.g. `--content code docs`. For example, in Claude Code: `claude mcp add csp -s user -- bunx @pleaseai/csp mcp --content all`. ## Sub-agent setup diff --git a/crates/csp/src/bin/csp/main.rs b/crates/csp/src/bin/csp/main.rs index 50a1ede..e041706 100644 --- a/crates/csp/src/bin/csp/main.rs +++ b/crates/csp/src/bin/csp/main.rs @@ -56,6 +56,10 @@ enum Command { path: Option, #[arg(long = "top-k", short = 'k')] top_k: Option, + /// Lines of source per result (default: full chunk). 10 = signature + + /// body, 0 = no code. + #[arg(long = "max-snippet-lines", value_name = "N")] + max_snippet_lines: Option, #[arg(long, value_enum, num_args = 1..)] content: Vec, /// Path to a pre-built index (bypasses the auto-cache). @@ -73,6 +77,10 @@ enum Command { path: Option, #[arg(long = "top-k", short = 'k')] top_k: Option, + /// Lines of source per result (default: full chunk). 10 = signature + + /// body, 0 = no code. + #[arg(long = "max-snippet-lines", value_name = "N")] + max_snippet_lines: Option, #[arg(long, value_enum, num_args = 1..)] content: Vec, #[arg(long)] @@ -210,8 +218,19 @@ fn load_index( } } +/// Map the CLI `--max-snippet-lines` value to the `Option` cap. Absent +/// (`None`) → full chunk content; a negative value clamps to `0` (no code). +fn resolve_snippet_lines(value: Option) -> Option { + value.map(|n| n.max(0) as usize) +} + /// JSON output for `search` (pure — testable without stdout capture). -fn search_output(index: &CspIndex, query: &str, top_k: usize) -> String { +fn search_output( + index: &CspIndex, + query: &str, + top_k: usize, + max_snippet_lines: Option, +) -> String { let results = index.search( query, &QueryOptions { @@ -222,7 +241,7 @@ fn search_output(index: &CspIndex, query: &str, top_k: usize) -> String { let out = if results.is_empty() { serde_json::json!({ "error": "No results found." }) } else { - format_results(query, &results) + format_results(query, &results, max_snippet_lines) }; out.to_string() } @@ -233,6 +252,7 @@ fn find_related_output( file: &str, line: &str, top_k: usize, + max_snippet_lines: Option, ) -> Result { let Ok(line_num) = line.parse::() else { return Err(format!("line must be an integer, got: {line}")); @@ -257,7 +277,11 @@ fn find_related_output( let out = if related.is_empty() { serde_json::json!({ "error": format!("No related chunks found for {file}:{line_num}.") }) } else { - format_results(&format!("Chunks related to {file}:{line_num}"), &related) + format_results( + &format!("Chunks related to {file}:{line_num}"), + &related, + max_snippet_lines, + ) }; Ok(out.to_string()) } @@ -382,6 +406,7 @@ fn dispatch(command: Command) -> u8 { query, path, top_k, + max_snippet_lines, content, index, git_ref, @@ -394,7 +419,15 @@ fn dispatch(command: Command) -> u8 { git_ref, ) { Ok(idx) => { - println!("{}", search_output(&idx, &query, top_k.unwrap_or(5))); + println!( + "{}", + search_output( + &idx, + &query, + top_k.unwrap_or(5), + resolve_snippet_lines(max_snippet_lines), + ) + ); EXIT_SUCCESS } Err(e) => { @@ -408,6 +441,7 @@ fn dispatch(command: Command) -> u8 { line, path, top_k, + max_snippet_lines, content, index, git_ref, @@ -425,7 +459,13 @@ fn dispatch(command: Command) -> u8 { return EXIT_FAILURE; } }; - match find_related_output(&idx, &file, &line, top_k.unwrap_or(5)) { + match find_related_output( + &idx, + &file, + &line, + top_k.unwrap_or(5), + resolve_snippet_lines(max_snippet_lines), + ) { Ok(out) => { println!("{out}"); EXIT_SUCCESS @@ -519,24 +559,38 @@ mod tests { fn search_output_shapes_results() { let dir = build_index_dir(); let idx = CspIndex::from_path(dir.path(), &LoadOptions::default()).unwrap(); - let out = search_output(&idx, "greet", 5); + let out = search_output(&idx, "greet", 5, None); let value: serde_json::Value = serde_json::from_str(&out).unwrap(); assert!(value.get("results").is_some() || value.get("error").is_some()); if let Some(results) = value.get("results").and_then(|r| r.as_array()) { if let Some(first) = results.first() { - let chunk = &first["chunk"]; - assert!(chunk.get("file_path").is_some()); - assert!(chunk.get("start_line").is_some()); - assert!(chunk.get("location").is_some()); + // Flat wire shape (semble#198): fields at the top level. + assert!(first.get("chunk").is_none()); + assert!(first.get("file_path").is_some()); + assert!(first.get("start_line").is_some()); + // CLI default (None) keeps the full content. + assert!(first.get("content").is_some()); } } } + #[test] + fn search_output_caps_snippet_lines() { + let dir = build_index_dir(); + let idx = CspIndex::from_path(dir.path(), &LoadOptions::default()).unwrap(); + let out = search_output(&idx, "greet", 5, Some(0)); + let value: serde_json::Value = serde_json::from_str(&out).unwrap(); + if let Some(first) = value["results"].as_array().and_then(|r| r.first()) { + assert!(first.get("content").is_none()); + assert!(first.get("file_path").is_some()); + } + } + #[test] fn find_related_rejects_non_integer_line() { let dir = build_index_dir(); let idx = CspIndex::from_path(dir.path(), &LoadOptions::default()).unwrap(); - let err = find_related_output(&idx, "sample.ts", "abc", 5).unwrap_err(); + let err = find_related_output(&idx, "sample.ts", "abc", 5, None).unwrap_err(); assert!(err.contains("line must be an integer")); } @@ -544,7 +598,7 @@ mod tests { fn find_related_no_chunk_at_location() { let dir = build_index_dir(); let idx = CspIndex::from_path(dir.path(), &LoadOptions::default()).unwrap(); - let err = find_related_output(&idx, "nope.ts", "1", 5).unwrap_err(); + let err = find_related_output(&idx, "nope.ts", "1", 5, None).unwrap_err(); assert!(err.contains("No chunk found")); } @@ -611,6 +665,7 @@ mod tests { query: "greet".to_string(), path: None, top_k: Some(5), + max_snippet_lines: None, content: vec![], index: Some(idx_path.clone()), git_ref: None, @@ -623,6 +678,7 @@ mod tests { line: "1".to_string(), path: None, top_k: Some(5), + max_snippet_lines: None, content: vec![], index: Some(idx_path.clone()), git_ref: None, @@ -635,6 +691,7 @@ mod tests { line: "abc".to_string(), path: None, top_k: Some(5), + max_snippet_lines: None, content: vec![], index: Some(idx_path), git_ref: None, @@ -650,6 +707,7 @@ mod tests { query: "greet".to_string(), path: None, top_k: Some(1), + max_snippet_lines: None, content: vec![], index: Some( missing diff --git a/crates/csp/src/bin/csp/mcp_server.rs b/crates/csp/src/bin/csp/mcp_server.rs index d6d7a31..0b47156 100644 --- a/crates/csp/src/bin/csp/mcp_server.rs +++ b/crates/csp/src/bin/csp/mcp_server.rs @@ -18,6 +18,18 @@ use tokio::sync::Mutex; use csp::mcp::{find_related_tool, search_tool, IndexCache, SERVER_INSTRUCTIONS}; use csp::types::ContentType; +/// MCP default: signature + first body lines, enough to confirm a location +/// while spending far fewer tokens than the full chunk (semble#198). +fn default_max_snippet_lines() -> Option { + Some(10) +} + +/// Convert the wire value to the `Option` the handlers take. `None` (the +/// caller passed JSON `null`) → full content; a negative value clamps to `0`. +fn resolve_snippet_lines(value: Option) -> Option { + value.map(|n| n.max(0) as usize) +} + /// Parameters for the `search` tool (mirrors the TS MCP tool's args). #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct SearchParams { @@ -28,6 +40,11 @@ pub struct SearchParams { pub repo: Option, /// Maximum number of results (default 5). pub top_k: Option, + /// Lines of source per result. Default 10 = signature + first body lines, + /// enough to confirm the location. 0 = file path and line range only. Pass + /// `null` for the full chunk when the snippet lacks context. + #[serde(default = "default_max_snippet_lines")] + pub max_snippet_lines: Option, } /// Parameters for the `find_related` tool. @@ -41,6 +58,10 @@ pub struct FindRelatedParams { pub repo: Option, /// Maximum number of results (default 5). pub top_k: Option, + /// Lines of source per result. Default 10 = signature + first body lines. + /// 0 = location only. Pass `null` for the full chunk. + #[serde(default = "default_max_snippet_lines")] + pub max_snippet_lines: Option, } /// MCP server holding the session index cache and the default source. @@ -82,6 +103,7 @@ impl CspMcpServer { &p.query, p.repo.as_deref(), p.top_k.unwrap_or(5) as usize, + resolve_snippet_lines(p.max_snippet_lines), ); Ok(CallToolResult::success(vec![Content::text(out)])) } @@ -102,6 +124,7 @@ impl CspMcpServer { p.line, p.repo.as_deref(), p.top_k.unwrap_or(5) as usize, + resolve_snippet_lines(p.max_snippet_lines), ); Ok(CallToolResult::success(vec![Content::text(out)])) } @@ -152,15 +175,30 @@ mod tests { assert_eq!(minimal.query, "greet"); assert!(minimal.repo.is_none()); assert!(minimal.top_k.is_none()); + // Absent max_snippet_lines → the MCP default of 10. + assert_eq!(minimal.max_snippet_lines, Some(10)); let full: SearchParams = serde_json::from_value(serde_json::json!({ "query": "greet", "repo": "./x", - "top_k": 3 + "top_k": 3, + "max_snippet_lines": 0 })) .unwrap(); assert_eq!(full.repo.as_deref(), Some("./x")); assert_eq!(full.top_k, Some(3)); + assert_eq!(full.max_snippet_lines, Some(0)); + + // Explicit null → None (full chunk), distinct from the absent default. + let nulled: SearchParams = serde_json::from_value(serde_json::json!({ + "query": "greet", + "max_snippet_lines": null + })) + .unwrap(); + assert!(nulled.max_snippet_lines.is_none()); + assert!(resolve_snippet_lines(nulled.max_snippet_lines).is_none()); + assert_eq!(resolve_snippet_lines(Some(3)), Some(3)); + assert_eq!(resolve_snippet_lines(Some(-4)), Some(0)); } #[test] @@ -208,6 +246,7 @@ mod tests { query: "greet".to_string(), repo: None, top_k: Some(5), + max_snippet_lines: None, })) .await .unwrap(); @@ -235,6 +274,7 @@ mod tests { line: 1, repo: None, top_k: Some(5), + max_snippet_lines: None, })) .await .unwrap(); diff --git a/crates/csp/src/mcp.rs b/crates/csp/src/mcp.rs index 5518b45..9447a17 100644 --- a/crates/csp/src/mcp.rs +++ b/crates/csp/src/mcp.rs @@ -172,6 +172,7 @@ pub fn search_tool( query: &str, repo: Option<&str>, top_k: usize, + max_snippet_lines: Option, ) -> String { let index = match get_index(repo, default_source, default_ref, cache) { Ok(idx) => idx, @@ -187,11 +188,14 @@ pub fn search_tool( if results.is_empty() { json!({ "error": "No results found." }).to_string() } else { - format_results(query, &results).to_string() + format_results(query, &results, max_snippet_lines).to_string() } } /// `find_related` tool handler. +// Positional transport params mirror the MCP tool signature; a struct would just +// move the plumbing without clarifying it. +#[allow(clippy::too_many_arguments)] pub fn find_related_tool( cache: &mut IndexCache, default_source: Option<&str>, @@ -200,6 +204,7 @@ pub fn find_related_tool( line: i64, repo: Option<&str>, top_k: usize, + max_snippet_lines: Option, ) -> String { let index = match get_index(repo, default_source, default_ref, cache) { Ok(idx) => idx, @@ -228,7 +233,12 @@ pub fn find_related_tool( if results.is_empty() { json!({ "error": format!("No related chunks found for {file_path}:{line}.") }).to_string() } else { - format_results(&format!("Chunks related to {file_path}:{line}"), &results).to_string() + format_results( + &format!("Chunks related to {file_path}:{line}"), + &results, + max_snippet_lines, + ) + .to_string() } } @@ -392,7 +402,15 @@ mod tests { #[test] fn search_tool_no_results() { let mut cache = IndexCache::with_seam(vec![ContentType::Code], Stub::new()); - let out = search_tool(&mut cache, Some("/tmp/repo"), None, "anything", None, 5); + let out = search_tool( + &mut cache, + Some("/tmp/repo"), + None, + "anything", + None, + 5, + None, + ); assert_eq!(out, json!({ "error": "No results found." }).to_string()); } @@ -411,23 +429,62 @@ mod tests { #[test] fn search_tool_returns_results_json() { let mut cache = IndexCache::with_seam(vec![ContentType::Code], OneChunkSeam); - let out = search_tool(&mut cache, Some("/tmp/repo"), None, "main", None, 5); + let out = search_tool(&mut cache, Some("/tmp/repo"), None, "main", None, 5, None); let value: serde_json::Value = serde_json::from_str(&out).unwrap(); assert!(value.get("query").is_some()); assert!(value["results"].as_array().is_some()); + // Full content by default (max_snippet_lines = None). + assert!(value["results"][0].get("content").is_some()); + } + + #[test] + fn search_tool_respects_max_snippet_lines_zero() { + let mut cache = IndexCache::with_seam(vec![ContentType::Code], OneChunkSeam); + let out = search_tool( + &mut cache, + Some("/tmp/repo"), + None, + "main", + None, + 5, + Some(0), + ); + let value: serde_json::Value = serde_json::from_str(&out).unwrap(); + let entry = &value["results"][0]; + // 0 lines → no content, but the location metadata is still present. + assert!(entry.get("content").is_none()); + assert_eq!(entry["file_path"], "a.ts"); } #[test] fn find_related_no_chunk_message() { let mut cache = IndexCache::with_seam(vec![ContentType::Code], OneChunkSeam); - let out = find_related_tool(&mut cache, Some("/tmp/repo"), None, "nope.ts", 1, None, 5); + let out = find_related_tool( + &mut cache, + Some("/tmp/repo"), + None, + "nope.ts", + 1, + None, + 5, + None, + ); assert!(out.contains("No chunk found at nope.ts:1")); } #[test] fn find_related_returns_json_for_known_chunk() { let mut cache = IndexCache::with_seam(vec![ContentType::Code], OneChunkSeam); - let out = find_related_tool(&mut cache, Some("/tmp/repo"), None, "a.ts", 5, None, 5); + let out = find_related_tool( + &mut cache, + Some("/tmp/repo"), + None, + "a.ts", + 5, + None, + 5, + None, + ); // Either related results or the no-related error — both valid JSON. let value: serde_json::Value = serde_json::from_str(&out).unwrap(); assert!(value.get("query").is_some() || value.get("error").is_some()); diff --git a/crates/csp/src/utils.rs b/crates/csp/src/utils.rs index 5d3ecbe..477d93c 100644 --- a/crates/csp/src/utils.rs +++ b/crates/csp/src/utils.rs @@ -3,32 +3,52 @@ use serde_json::{json, Value}; use crate::search::SearchResult; -use crate::types::{chunk_location, Chunk}; +use crate::types::Chunk; -/// Serialize a search result to the CLI/MCP wire dict — **snake_case** chunk -/// fields plus a derived `location` (matching the TS `SearchResult.toDict`, which -/// differs from the camelCase `ChunkDict` used for on-disk persistence). -pub fn result_to_dict(result: &SearchResult) -> Value { +/// Serialize a search result to the flat CLI/MCP wire dict — **snake_case** +/// fields (`file_path`, `start_line`, `end_line`, `score`, optional `content`), +/// matching semble `utils.format_results` after semble#198. +/// +/// `max_snippet_lines` caps the `content` field so agents can spend fewer tokens +/// confirming a location before navigating to the file: +/// - `None` → full chunk content +/// - `Some(0)` → omit `content` entirely (path + line range only) +/// - `Some(n)` → the first `n` lines of content +pub fn result_to_dict(result: &SearchResult, max_snippet_lines: Option) -> Value { let c = &result.chunk; - json!({ - "chunk": { - "content": c.content, - "file_path": c.file_path, - "start_line": c.start_line, - "end_line": c.end_line, - "language": c.language, - "location": chunk_location(c), - }, + let mut entry = json!({ + "file_path": c.file_path, + "start_line": c.start_line, + "end_line": c.end_line, "score": result.score, - }) + }); + match max_snippet_lines { + None => { + entry["content"] = json!(c.content); + } + Some(0) => {} + Some(n) => { + let snippet: Vec<&str> = c.content.lines().take(n).collect(); + entry["content"] = json!(snippet.join("\n")); + } + } + entry } /// Build the `{ query, results }` payload the CLI prints and the MCP server -/// returns. Port of `utils.formatResults`. -pub fn format_results(query: &str, results: &[SearchResult]) -> Value { +/// returns. Port of `utils.format_results`. `max_snippet_lines` is forwarded to +/// [`result_to_dict`] to cap each result's `content`. +pub fn format_results( + query: &str, + results: &[SearchResult], + max_snippet_lines: Option, +) -> Value { json!({ "query": query, - "results": results.iter().map(result_to_dict).collect::>(), + "results": results + .iter() + .map(|r| result_to_dict(r, max_snippet_lines)) + .collect::>(), }) } @@ -124,6 +144,55 @@ mod tests { } } + fn result(content: &str) -> SearchResult { + SearchResult { + chunk: Chunk { + content: content.to_string(), + file_path: "a.ts".to_string(), + start_line: 1, + end_line: 3, + language: Some("ts".to_string()), + }, + score: 0.5, + } + } + + #[test] + fn format_results_flat_shape_with_full_content() { + let out = format_results("q", &[result("line1\nline2\nline3")], None); + let entry = &out["results"][0]; + // Flat shape: fields live at the top level, no nested `chunk`. + assert!(entry.get("chunk").is_none()); + assert_eq!(entry["file_path"], "a.ts"); + assert_eq!(entry["start_line"], 1); + assert_eq!(entry["end_line"], 3); + assert_eq!(entry["score"], 0.5); + assert_eq!(entry["content"], "line1\nline2\nline3"); + assert_eq!(out["query"], "q"); + } + + #[test] + fn result_to_dict_truncates_to_n_lines() { + let entry = result_to_dict(&result("line1\nline2\nline3\nline4"), Some(2)); + assert_eq!(entry["content"], "line1\nline2"); + } + + #[test] + fn result_to_dict_omits_content_when_zero() { + let entry = result_to_dict(&result("line1\nline2"), Some(0)); + assert!(entry.get("content").is_none()); + // Location metadata is still present so the agent can navigate. + assert_eq!(entry["file_path"], "a.ts"); + assert_eq!(entry["start_line"], 1); + assert_eq!(entry["end_line"], 3); + } + + #[test] + fn result_to_dict_more_lines_than_content_returns_all() { + let entry = result_to_dict(&result("only\ntwo"), Some(10)); + assert_eq!(entry["content"], "only\ntwo"); + } + #[test] fn recognises_scheme_git_urls() { for url in [