Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
8 changes: 8 additions & 0 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`.

## 서브 에이전트 설정
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
78 changes: 65 additions & 13 deletions crates/csp/src/bin/csp/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ use csp::indexing::index::{
};
use csp::stats::{clear_savings, default_stats_file, format_savings_report, now_secs};
use csp::types::ContentType;
use csp::utils::{format_results, is_git_url, resolve_chunk};
use csp::utils::{format_results, is_git_url, resolve_chunk, resolve_snippet_lines};

#[derive(Parser)]
#[command(name = "csp", version, about = "Instant local code search for agents")]
Expand Down Expand Up @@ -56,6 +56,10 @@ enum Command {
path: Option<String>,
#[arg(long = "top-k", short = 'k')]
top_k: Option<usize>,
/// 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<i64>,
#[arg(long, value_enum, num_args = 1..)]
content: Vec<ContentFilter>,
/// Path to a pre-built index (bypasses the auto-cache).
Expand All @@ -73,6 +77,10 @@ enum Command {
path: Option<String>,
#[arg(long = "top-k", short = 'k')]
top_k: Option<usize>,
/// 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<i64>,
#[arg(long, value_enum, num_args = 1..)]
content: Vec<ContentFilter>,
#[arg(long)]
Expand Down Expand Up @@ -211,7 +219,12 @@ fn load_index(
}

/// 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<usize>,
) -> String {
let results = index.search(
query,
&QueryOptions {
Expand All @@ -222,7 +235,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()
}
Expand All @@ -233,6 +246,7 @@ fn find_related_output(
file: &str,
line: &str,
top_k: usize,
max_snippet_lines: Option<usize>,
) -> Result<String, String> {
let Ok(line_num) = line.parse::<i64>() else {
return Err(format!("line must be an integer, got: {line}"));
Expand All @@ -257,7 +271,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())
}
Expand Down Expand Up @@ -382,6 +400,7 @@ fn dispatch(command: Command) -> u8 {
query,
path,
top_k,
max_snippet_lines,
content,
index,
git_ref,
Expand All @@ -394,7 +413,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) => {
Expand All @@ -408,6 +435,7 @@ fn dispatch(command: Command) -> u8 {
line,
path,
top_k,
max_snippet_lines,
content,
index,
git_ref,
Expand All @@ -425,7 +453,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
Expand Down Expand Up @@ -519,32 +553,46 @@ 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"));
}

#[test]
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"));
}

Expand Down Expand Up @@ -611,6 +659,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,
Expand All @@ -623,6 +672,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,
Expand All @@ -635,6 +685,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,
Expand All @@ -650,6 +701,7 @@ mod tests {
query: "greet".to_string(),
path: None,
top_k: Some(1),
max_snippet_lines: None,
content: vec![],
index: Some(
missing
Expand Down
37 changes: 36 additions & 1 deletion crates/csp/src/bin/csp/mcp_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ use tokio::sync::Mutex;

use csp::mcp::{find_related_tool, search_tool, IndexCache, SERVER_INSTRUCTIONS};
use csp::types::ContentType;
use csp::utils::resolve_snippet_lines;

/// 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<i64> {
Some(10)
}

/// Parameters for the `search` tool (mirrors the TS MCP tool's args).
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
Expand All @@ -28,6 +35,11 @@ pub struct SearchParams {
pub repo: Option<String>,
/// Maximum number of results (default 5).
pub top_k: Option<u32>,
/// 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<i64>,
}

/// Parameters for the `find_related` tool.
Expand All @@ -41,6 +53,10 @@ pub struct FindRelatedParams {
pub repo: Option<String>,
/// Maximum number of results (default 5).
pub top_k: Option<u32>,
/// 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<i64>,
}

/// MCP server holding the session index cache and the default source.
Expand Down Expand Up @@ -82,6 +98,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)]))
}
Expand All @@ -102,6 +119,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)]))
}
Expand Down Expand Up @@ -152,15 +170,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]
Expand Down Expand Up @@ -208,6 +241,7 @@ mod tests {
query: "greet".to_string(),
repo: None,
top_k: Some(5),
max_snippet_lines: None,
}))
.await
.unwrap();
Expand Down Expand Up @@ -235,6 +269,7 @@ mod tests {
line: 1,
repo: None,
top_k: Some(5),
max_snippet_lines: None,
}))
.await
.unwrap();
Expand Down
5 changes: 3 additions & 2 deletions crates/csp/src/chunking/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -209,8 +209,9 @@ pub fn merge_node<N: AstNode>(node: &N, desired_length: usize) -> Vec<ChunkBound

/// Split `text` into lines preserving the trailing newline on each line —
/// equivalent to Python's `str.splitlines(keepends=True)` for `\n`, `\r\n`,
/// and bare `\r`.
fn split_lines_keep_ends(text: &str) -> Vec<&str> {
/// and bare `\r`. Shared with `utils::result_to_dict` so a snippet cap counts
/// lines exactly the way chunk `start_line`/`end_line` do.
pub fn split_lines_keep_ends(text: &str) -> Vec<&str> {
if text.is_empty() {
return Vec::new();
}
Expand Down
Loading
Loading