use crate::filter::Filter;
use crate::fleet::Fleet;
use anyhow::Result;
use serde_json::{Value, json};
use std::io::{BufRead, Write};
use std::path::PathBuf;
use std::sync::Mutex;
use std::time::Duration;
pub const SUPPORTED: [&str; 4] = ["2026-07-28", "2025-11-25", "2025-06-18", "2024-11-05"];
const MODERN: &str = "2026-07-28";
const LEGACY_NEWEST: &str = "2025-11-25";
const LEGACY_OLDEST: &str = "2024-11-05";
const META_VERSION: &str = "io.modelcontextprotocol/protocolVersion";
const META_SERVER_INFO: &str = "io.modelcontextprotocol/serverInfo";
const UNSUPPORTED_VERSION: i64 = -32022;
const TTL_MS: u64 = 3_600_000;
const FILE_LIMIT: usize = 200;
const INDEX_BUDGET: Duration = Duration::from_secs(45);
type Fail = (i64, String, Option<Value>);
pub trait Writer: Send + Sync {
fn index(&self, store: Option<&str>, paths: &[PathBuf]) -> Result<String, String>;
fn forget(&self, store: Option<&str>, path: &str) -> Result<String, String>;
fn add(&self, store: Option<&str>, url: &str) -> Result<String, String>;
}
pub fn answer(
stores: &mut Fleet,
writer: Option<&dyn Writer>,
request: &Value,
session: &mut Session,
) -> Option<Value> {
let id = request.get("id").cloned()?;
let method = request.get("method").and_then(Value::as_str).unwrap_or("");
let params = request.get("params").cloned().unwrap_or(json!({}));
Some(reply(
&id,
dispatch(stores, writer, method, ¶ms, session),
))
}
#[derive(Debug, Clone)]
pub struct Session {
pub client: String,
pub version: String,
pub id: String,
}
impl Session {
pub fn new(id: impl Into<String>) -> Self {
Session {
client: "mcp".to_string(),
version: String::new(),
id: id.into(),
}
}
fn who(&self) -> crate::ledger::Who<'_> {
crate::ledger::Who {
client: &self.client,
session: &self.id,
version: &self.version,
}
}
}
pub const INSTRUCTIONS_LIMIT: usize = 600;
pub fn instructions(roots: &[std::path::PathBuf]) -> String {
const ROUTES: &str = " For a code question there, use semlith before grep, rg, find or cat: \
semlith_brief to understand how something works, semlith_search to locate (exact: true \
for every line matching a string or regex), \
semlith_impact for what breaks if a symbol changes, semlith_trace for how A reaches B, \
semlith_read for a span or a whole definition, semlith_files with tree: true for a \
directory. Paths in answers are relative to the root named above them.";
let lead = "semlith indexes ";
let mut named = String::new();
let mut left = roots.len();
for root in roots {
let text = crate::plain(&root.to_string_lossy());
let more = match left - 1 {
0 => String::new(),
1 => " and 1 more folder".to_string(),
n => format!(" and {n} more folders"),
};
let sep = if named.is_empty() { "" } else { ", " };
if lead.len() + named.len() + sep.len() + text.len() + more.len() + 1 + ROUTES.len()
> INSTRUCTIONS_LIMIT
{
break;
}
named.push_str(sep);
named.push_str(&text);
left -= 1;
}
if named.is_empty() && left == 0 {
return format!("semlith searches the local stores this server was opened on.{ROUTES}");
}
if left > 0 {
let sep = if named.is_empty() { "" } else { " and " };
named.push_str(&format!(
"{sep}{left} more folder{}",
if left == 1 { "" } else { "s" }
));
}
format!("{lead}{named}.{ROUTES}")
}
pub fn serve(
stores: &Mutex<Fleet>,
labels: &str,
input: impl BufRead,
mut output: impl Write,
) -> Result<()> {
let roots = {
let mut fleet = stores.lock().unwrap_or_else(|e| e.into_inner());
fleet.quiet = true;
fleet.roots()
};
let mut session = Session::new(format!("stdio-{}", std::process::id()));
for line in input.lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
let req: Value = match serde_json::from_str(&line) {
Ok(v) => v,
Err(e) => {
respond(
&mut output,
&json!(null),
Err((-32700, e.to_string(), None)),
)?;
continue;
}
};
let Some(id) = req.get("id").cloned() else {
continue;
};
let method = req.get("method").and_then(Value::as_str).unwrap_or("");
let params = req.get("params").cloned().unwrap_or(json!({}));
let result = match without_stores(labels, &roots, method, ¶ms, &mut session) {
Some(answered) => answered,
None => {
let mut fleet = stores.lock().unwrap_or_else(|e| e.into_inner());
dispatch(&mut fleet, None, method, ¶ms, &mut session)
}
};
respond(&mut output, &id, result)?;
}
Ok(())
}
fn declared_version(params: &Value) -> Option<&str> {
params.get("_meta")?.get(META_VERSION)?.as_str()
}
fn server_info() -> Value {
json!({ "name": "semlith", "version": env!("CARGO_PKG_VERSION") })
}
fn modernize(mut result: Value, cacheable: Option<&str>) -> Value {
result["resultType"] = json!("complete");
result["_meta"] = json!({ META_SERVER_INFO: server_info() });
if let Some(scope) = cacheable {
result["ttlMs"] = json!(TTL_MS);
result["cacheScope"] = json!(scope);
}
result
}
fn dispatch(
stores: &mut Fleet,
writer: Option<&dyn Writer>,
method: &str,
params: &Value,
session: &mut Session,
) -> Result<Value, Fail> {
if let Some(answered) = without_stores(
&stores.labels().join(", "),
&stores.roots(),
method,
params,
session,
) {
return answered;
}
let declared = declared_version(params);
let modern = declared == Some(MODERN);
match method {
"tools/call" => {
let called = call_tool(stores, writer, params, session)?;
Ok(if modern {
modernize(called, None)
} else {
called
})
}
other => Err((-32601, format!("unknown method: {other}"), None)),
}
}
fn without_stores(
labels: &str,
roots: &[std::path::PathBuf],
method: &str,
params: &Value,
session: &mut Session,
) -> Option<Result<Value, Fail>> {
let declared = declared_version(params);
if let Some(version) = declared
&& !SUPPORTED.contains(&version)
{
return Some(Err((
UNSUPPORTED_VERSION,
"Unsupported protocol version".into(),
Some(json!({ "supported": SUPPORTED, "requested": version })),
)));
}
let modern = declared == Some(MODERN);
Some(match method {
"server/discover" => Ok(modernize(
json!({
"supportedVersions": SUPPORTED,
"capabilities": { "tools": {} },
"instructions": instructions(roots),
}),
Some("public"),
)),
"initialize" => {
let info = params.get("clientInfo");
session.client = crate::clientid::label(
info.and_then(|c| c.get("name")).and_then(Value::as_str),
crate::clientid::host(),
);
let name = info.and_then(|c| c.get("name")).and_then(Value::as_str);
if let Some(version) = info
.and_then(|c| c.get("version"))
.and_then(Value::as_str)
.filter(|_| !name.is_some_and(crate::clientid::generic))
{
session.version = version.chars().take(64).collect();
}
let asked = params.get("protocolVersion").and_then(Value::as_str);
let version = negotiate(asked);
match asked {
Some(a) if a != version => {
eprintln!("semlith: client asked for MCP {a}; answering {version}")
}
_ => eprintln!("semlith: MCP {version}"),
}
Ok(json!({
"protocolVersion": version,
"capabilities": { "tools": {} },
"serverInfo": server_info(),
"instructions": instructions(roots),
}))
}
"ping" => Ok(json!({})),
"tools/list" => {
let listed = json!({ "tools": tool_defs(labels) });
Ok(if modern {
modernize(listed, Some("private"))
} else {
listed
})
}
_ => return None,
})
}
fn negotiate(requested: Option<&str>) -> &'static str {
match requested {
Some(v) => SUPPORTED
.iter()
.find(|s| **s == v && **s != MODERN)
.copied()
.unwrap_or(LEGACY_NEWEST),
None => LEGACY_OLDEST,
}
}
pub fn tool_names() -> Vec<String> {
tool_list().into_iter().map(|(name, _)| name).collect()
}
pub fn tool_list() -> Vec<(String, String)> {
tool_defs("")
.as_array()
.map(|defs| {
defs.iter()
.filter_map(|tool| {
let name = tool["name"].as_str()?.to_string();
let about = tool["annotations"]["title"]
.as_str()
.or_else(|| tool["description"].as_str())
.unwrap_or_default()
.to_string();
Some((name, about))
})
.collect()
})
.unwrap_or_default()
}
pub fn tool_list_bytes() -> usize {
serde_json::to_string(&tool_defs("default"))
.map(|json| json.len())
.unwrap_or(0)
}
pub fn locate_bytes(hits: &[crate::Hit], query: &str) -> usize {
locate(hits, query, DEFAULT_LOCATE_TOKENS, &crate::plain).len()
}
fn tool_defs(open: &str) -> Value {
let store_arg = format!("Open: {open}.");
let write_store_arg = format!("Open: {open}. Required when several are.");
json!([
{
"name": "semlith_search",
"description": "Where is X: ranked spans with file:line and definition. exact: true lists every matching line, as grep -E.",
"inputSchema": {
"type": "object",
"properties": {
"query": { "type": "string" },
"k": { "type": "integer", "description": "Default 8.", "minimum": 1, "maximum": 50 },
"format": { "type": "string", "enum": ["locate", "excerpt"], "description": "Default locate." },
"max_tokens": { "type": "integer", "description": "Default 1500.", "minimum": 200 },
"path": { "type": "array", "items": { "type": "string" }, "description": "Globs; ! excludes (also on ext, lang)." },
"ext": { "type": "array", "items": { "type": "string" } },
"lang": { "type": "array", "items": { "type": "string" }, "description": "See semlith_languages." },
"prefer": { "type": "string", "enum": ["code", "docs", "any"], "description": "Default any." },
"exact": { "type": "boolean" },
"store": { "type": "array", "items": { "type": "string" }, "description": store_arg }
},
"required": ["query"]
},
"annotations": { "readOnlyHint": true }
},
{
"name": "semlith_brief",
"description": "How does X work: spans, the best code span's text and one-hop callers/callees.",
"inputSchema": {
"type": "object",
"properties": {
"question": { "type": "string" },
"budget": { "type": "integer", "description": "Tokens. Default 4000; text is dropped first.", "minimum": 1 },
"store": { "type": "string" }
},
"required": ["question"]
},
"annotations": { "readOnlyHint": true }
},
{
"name": "semlith_read",
"description": "Show me this: a path:start-end span, or a symbol name (Type::method) read whole.",
"inputSchema": {
"type": "object",
"properties": {
"target": { "type": "string", "description": "path:start-end, path:line, or a symbol." },
"store": { "type": "array", "items": { "type": "string" } }
},
"required": ["target"]
},
"annotations": { "readOnlyHint": true }
},
{
"name": "semlith_pattern",
"description": "What shape does X take: a tree-sitter query over one language's indexed files.",
"inputSchema": {
"type": "object",
"properties": {
"query": { "type": "string", "description": "(call_expression function: (identifier) @f)" },
"lang": { "type": "string" },
"path": { "type": "array", "items": { "type": "string" } },
"offset": { "type": "integer" },
"store": { "type": "array", "items": { "type": "string" } }
},
"required": ["query", "lang"]
},
"annotations": { "readOnlyHint": true }
},
{
"name": "semlith_stats",
"description": "What is indexed: files, chunks, languages and graph coverage per store.",
"inputSchema": { "type": "object", "properties": {} },
"annotations": { "readOnlyHint": true }
},
{
"name": "semlith_languages",
"description": "Which languages the lang filter accepts, and which carry a graph.",
"inputSchema": { "type": "object", "properties": {} },
"annotations": { "readOnlyHint": true }
},
{
"name": "semlith_files",
"description": "What is in this folder: files, or tree: true for a directory view with symbols.",
"inputSchema": {
"type": "object",
"properties": {
"path": { "type": "array", "items": { "type": "string" } },
"ext": { "type": "array", "items": { "type": "string" } },
"lang": { "type": "array", "items": { "type": "string" } },
"store": { "type": "array", "items": { "type": "string" } },
"limit": { "type": "integer" },
"tree": { "type": "boolean", "description": "Directory view: counts, symbols, not indexed." },
"depth": { "type": "integer", "description": "Tree depth. Default 2." },
"sort": { "type": "string", "description": "name, size, symbols or recent." }
}
},
"annotations": { "readOnlyHint": true }
},
{
"name": "semlith_index",
"description": "Index paths, only when the user asks. Only changes re-embed.",
"inputSchema": {
"type": "object",
"properties": {
"path": { "type": "array", "items": { "type": "string" } },
"store": { "type": "string", "description": write_store_arg }
},
"required": ["path"]
},
},
{
"name": "semlith_add",
"description": "Fetch one https URL. No crawling. Refused under --airgap.",
"inputSchema": {
"type": "object",
"properties": {
"url": { "type": "string" },
"store": { "type": "string", "description": write_store_arg }
},
"required": ["url"]
},
"annotations": { "openWorldHint": true }
},
{
"name": "semlith_forget",
"description": "Drop one file from a store, only when the user asks.",
"inputSchema": {
"type": "object",
"properties": {
"path": { "type": "string" },
"store": { "type": "string" }
},
"required": ["path"]
},
"annotations": { "destructiveHint": true }
},
{
"name": "semlith_symbol",
"description": "Where is X defined, and what touches it: definition, callers, callees.",
"inputSchema": {
"type": "object",
"properties": {
"name": { "type": "string" },
"names": { "type": "array", "items": { "type": "string" }, "items": { "type": "string" }, "description": "Up to 20; a row each." },
"k": { "type": "integer", "description": "Default 20." },
"history": { "type": "boolean", "description": "What it used to be." },
"store": { "type": "string" }
}
},
"annotations": { "readOnlyHint": true }
},
{
"name": "semlith_neighbors",
"description": "Who calls X and what does X call. Only resolved edges are certain.",
"inputSchema": {
"type": "object",
"properties": {
"name": { "type": "string" },
"kind": { "type": "array", "items": { "type": "string" } },
"all": { "type": "boolean", "description": "Expand collapsed rows and missing targets." },
"store": { "type": "string" }
},
"required": ["name"]
},
"annotations": { "readOnlyHint": true }
},
{
"name": "semlith_report",
"description": "One of five local reports: savings, access, change, health, gaps.",
"inputSchema": {
"type": "object",
"properties": {
"kind": { "type": "string" },
"format": { "type": "string", "description": "markdown, csv, json, html." },
"model": { "type": "string" },
"store": { "type": "string" }
},
"required": ["kind"]
},
"annotations": { "readOnlyHint": true }
},
{
"name": "semlith_impact",
"description": "What breaks if X changes: every caller and call site. Type::method narrows.",
"inputSchema": {
"type": "object",
"properties": {
"name": { "type": "string" },
"kind": { "type": "array", "items": { "type": "string" } },
"depth": { "type": "integer", "description": "Default 3." },
"all_edges": { "type": "boolean", "description": "Cross ambiguous names; a hypothesis." },
"strict": { "type": "boolean" },
"store": { "type": "string" }
},
"required": ["name"]
},
"annotations": { "readOnlyHint": true }
},
{
"name": "semlith_trace",
"description": "How does A reach B: the chain, a source line per hop.",
"inputSchema": {
"type": "object",
"properties": {
"from": { "type": "string" },
"to": { "type": "string" },
"depth": { "type": "integer", "description": "Default 6." },
"all_edges": { "type": "boolean", "description": "Cross ambiguous names; a hypothesis." },
"strict": { "type": "boolean" },
"evidence": { "type": "boolean", "description": "The plain block to paste." },
"store": { "type": "string" }
},
"required": ["from", "to"]
},
"annotations": { "readOnlyHint": true }
},
{
"name": "semlith_path",
"description": "Is A connected to B: the shortest chain, or none.",
"inputSchema": {
"type": "object",
"properties": {
"from": { "type": "string" },
"to": { "type": "string" },
"depth": { "type": "integer", "description": "Default 6." },
"all_edges": { "type": "boolean", "description": "Cross ambiguous names; a hypothesis." },
"strict": { "type": "boolean" },
"store": { "type": "string" }
},
"required": ["from", "to"]
},
"annotations": { "readOnlyHint": true }
}
])
}
fn call_tool(
stores: &mut Fleet,
writer: Option<&dyn Writer>,
params: &Value,
session: &Session,
) -> Result<Value, Fail> {
let name = params.get("name").and_then(Value::as_str).unwrap_or("");
let args = params.get("arguments").cloned().unwrap_or(json!({}));
let started = std::time::Instant::now();
let body = match name {
"semlith_search" => {
let Some(query) = args.get("query").and_then(Value::as_str) else {
return Err((-32602, "missing required argument: query".into(), None));
};
let k = args.get("k").and_then(Value::as_u64).unwrap_or(8) as usize;
let filter = match filter_of(&args) {
Ok(f) => f,
Err(e) => return Ok(tool_error(&e)),
};
let only = strings(&args, "store");
let selected = if filter.is_empty() {
1
} else {
match stores.matching_files(&filter) {
Ok(n) => n,
Err(e) => return Ok(tool_error(&format!("search failed: {e}"))),
}
};
if selected == 0 {
crate::fleet::FILTER_SELECTED_NOTHING.to_string()
} else if args.get("exact").and_then(Value::as_bool) == Some(true) {
let offset = args.get("offset").and_then(Value::as_u64).unwrap_or(0) as usize;
match stores.grep_in(Some(&only), query, &filter, offset) {
Ok(found) => {
let paths = stores.shortener();
paths.with_header(render_grep(
&found,
offset,
args.get("max_tokens")
.and_then(Value::as_u64)
.map_or(DEFAULT_LOCATE_TOKENS, |v| v as usize)
.max(200)
* 4,
&|p| paths.short(p),
))
}
Err(e) => return Ok(tool_error(&e.to_string())),
}
} else {
let excerpts = args
.get("format")
.and_then(Value::as_str)
.is_some_and(|f| f.eq_ignore_ascii_case("excerpt"));
let max_tokens = args
.get("max_tokens")
.and_then(Value::as_u64)
.map(|v| v as usize)
.unwrap_or(DEFAULT_LOCATE_TOKENS);
let asked = match args.get("prefer").and_then(Value::as_str) {
Some(raw) => match crate::Prefer::parse(raw) {
Ok(p) => Some(p),
Err(e) => return Ok(tool_error(&e.to_string())),
},
None => None,
};
let prefer = asked.unwrap_or_else(|| match only.as_slice() {
[one] => stores.lean(one).unwrap_or_default(),
_ => crate::Prefer::default(),
});
match stores.search_leaning(Some(&only), query, k.clamp(1, 50), &filter, asked) {
Ok(hits) if hits.is_empty() => stores.no_match_reason(&filter),
Ok(hits) if excerpts => {
format!("{}\n{}", reading(query, prefer), render(&hits))
}
Ok(hits) => search_reply(stores, &hits, query, prefer, max_tokens).text,
Err(e) => return Ok(tool_error(&e.to_string())),
}
}
}
"semlith_brief" => {
let Some(question) = args.get("question").and_then(Value::as_str) else {
return Err((-32602, "missing required argument: question".into(), None));
};
let only = strings(&args, "store");
let filter = crate::Filter::default();
let budget = args
.get("budget")
.and_then(Value::as_i64)
.unwrap_or(crate::brief::DEFAULT_BUDGET)
.max(1);
match crate::brief::brief_leaning(stores, Some(&only), question, budget, &filter, None)
{
Ok(brief) => brief_reply(stores, &brief, &filter),
Err(e) => return Ok(tool_error(&e.to_string())),
}
}
"semlith_read" => {
let Some(raw) = args.get("target").and_then(Value::as_str) else {
return Err((-32602, "missing required argument: target".into(), None));
};
let target = crate::Target::parse(raw);
let only = strings(&args, "store");
let paths = stores.shortener();
let one = |span: &crate::Span| {
let named = match (&span.symbol, &span.symbol_kind) {
(Some(name), Some(kind)) => format!(" · {name} {kind}"),
(Some(name), None) => format!(" · {name}"),
_ => String::new(),
};
let state = if span.from_disk {
" · read from disk, not yet re-indexed"
} else if !span.fresh {
" · stale"
} else {
""
};
format!(
"{}{}:{}-{}{named}{state}\n{}",
label_of(&span.store),
paths.short(&span.path),
span.start_line,
span.end_line,
span.text
)
};
let read = stores.read_in(Some(&only), &target, &crate::filter::Filter::default());
let whole_file = |span: &crate::Span| match &target {
crate::Target::Symbol(name) => {
let path = crate::plain(&span.path);
let name = name.trim_start_matches("./");
path.len() > name.len()
&& path.ends_with(name)
&& path[..path.len() - name.len()].ends_with(['/', '\\'])
}
_ => false,
};
let body = match read {
Ok(Some(crate::Read::One(span)))
if whole_file(&span) && span.text.len() > WHOLE_FILE_CHARS =>
{
let defs = stores
.outline_in(span.store.as_ref().map(std::slice::from_ref), &span.path)
.unwrap_or_default();
let mut out = format!(
"{}{} is {} lines, {} characters; its definitions, to read by path:start-end or by name:",
label_of(&span.store),
paths.short(&span.path),
span.end_line,
span.text.len()
);
for (start, end, name, kind) in &defs {
out.push_str(&format!("\n {start}-{end} {kind} {name}"));
}
if defs.is_empty() {
out.push_str("\n none parsed; ask for a line range");
}
out
}
Ok(None) => {
format!("Nothing indexed at {raw:?}. semlith_files says what is indexed.")
}
Ok(Some(crate::Read::Choose(rows))) if rows.len() <= READ_WHOLE => {
let mut out = String::new();
let mut left = rows.len();
for row in &rows {
let span = crate::Target::Span {
path: row.path.clone(),
start: row.start_line,
end: row.end_line,
};
let text = match stores.read_in(
row.store.as_ref().map(std::slice::from_ref),
&span,
&crate::filter::Filter::default(),
) {
Ok(Some(crate::Read::One(span))) => one(&span),
_ => continue,
};
if !out.is_empty() && out.len() + text.len() > READ_CHARS {
out.push_str(&format!(
"\n\nstopped at the {READ_CHARS}-character cap; {left} more definition{} of this name. Ask for Type::name to read one.",
if left == 1 { "" } else { "s" }
));
break;
}
if !out.is_empty() {
out.push_str("\n\n");
}
out.push_str(&text);
left -= 1;
}
format!(
"{} definitions of this name, each whole:\n{out}",
rows.len()
)
}
Ok(Some(crate::Read::Choose(rows))) => {
let mut out = format!("{} definitions of this name:\n", rows.len());
for row in &rows {
out.push_str(&format!(
" {} ({}) {}{}:{}-{}\n",
row.qualified,
row.kind,
label_of(&row.store),
paths.short(&row.path),
row.start_line,
row.end_line
));
}
out.trim_end().to_string()
}
Ok(Some(crate::Read::One(span))) => {
let text = one(&span);
if text.len() > READ_CHARS {
let cut: String = text.chars().take(READ_CHARS).collect();
let cut = cut
.rsplit_once('\n')
.map_or(cut.clone(), |(a, _)| a.to_string());
let shown = cut.lines().count().saturating_sub(1) as u32;
format!(
"{cut}\n\nstopped at the {READ_CHARS}-character cap, at line {}; ask for a line range to read on.",
span.start_line + shown.saturating_sub(1)
)
} else {
text
}
}
Err(e) => return Ok(tool_error(&e.to_string())),
};
paths.with_header(body)
}
"semlith_pattern" => {
let Some(query) = args.get("query").and_then(Value::as_str) else {
return Err((-32602, "missing required argument: query".into(), None));
};
let Some(lang) = args.get("lang").and_then(Value::as_str) else {
return Err((-32602, "missing required argument: lang".into(), None));
};
let only = strings(&args, "store");
let filter = match filter_of(&args) {
Ok(f) => f,
Err(e) => return Ok(tool_error(&e)),
};
let offset = args
.get("offset")
.and_then(Value::as_u64)
.map(|n| n as usize)
.unwrap_or(0);
match stores.pattern_in(Some(&only), lang, query, &filter, offset) {
Ok(found) if found.files == 0 => {
format!("No indexed file is {}.", found.language)
}
Ok(found) if found.matches.is_empty() => {
format!("No match in {} {} files.", found.files, found.language)
}
Ok(found) => {
let mut out = String::new();
for m in &found.matches {
out.push_str(&format!(
"{}{}:{}-{} @{} {}\n",
label_of(&m.store),
crate::plain(&m.path),
m.start_line,
m.end_line,
m.capture,
m.text
));
}
if found.truncated {
out.push_str(&format!(
"truncated at {} matches — call again with offset: {} for the rest\n",
found.matches.len(),
offset + found.matches.len()
));
}
out.trim_end().to_string()
}
Err(e) => return Ok(tool_error(&e.to_string())),
}
}
"semlith_stats" => {
let many = stores.len() > 1;
let mut lines = Vec::new();
for (label, store) in stores.each() {
let (files, chunks, bytes) = match store.stats() {
Ok(s) => s,
Err(e) => return Ok(tool_error(&format!("stats failed: {e}"))),
};
let mut body = format!(
"{files} files, {chunks} chunks, {} indexed, model {} ({} dim)",
crate::human_bytes(bytes),
store.model(),
store.dim(),
);
if let Ok(disk) = store.footprint(crate::compact::retention_in_force()) {
body.push_str(&format!(
", {} on disk ({} reclaimable)",
crate::human_bytes(disk.total() as i64),
crate::human_bytes(disk.reclaimable as i64),
));
}
match crate::store::coverage_by_language(store.db()) {
Ok(coverage) => {
const FEW: usize = 5;
let (tail_langs, tail_files) = coverage
.iter()
.filter(|r| r.definitions > 0 && r.files < FEW)
.fold((0usize, 0usize), |(l, f), r| (l + 1, f + r.files));
for row in coverage
.iter()
.filter(|r| r.definitions > 0 && r.files >= FEW)
{
body.push_str(&format!(
"\n {}: {} files ({} unparsed), {} definitions, call edges \
{} extracted / {} resolved / {} ambiguous / {} unresolved \
({} % settled)",
row.language,
row.files,
row.parser_failed,
row.definitions,
row.extracted,
row.resolved,
row.ambiguous,
row.unresolved,
row.settled_share(),
));
}
if tail_langs > 0 {
body.push_str(&format!(
"\n + {tail_langs} more language{}, {tail_files} files",
if tail_langs == 1 { "" } else { "s" }
));
}
}
Err(e) => return Ok(tool_error(&format!("stats failed: {e}"))),
}
let variants = store.variants();
if !variants.is_empty() {
let parts: Vec<String> =
variants.iter().map(|(v, n)| format!("{n} {v}")).collect();
body.push_str(&format!("\n variants: {}", parts.join(", ")));
}
lines.push(if many {
format!("{label}: {body}")
} else {
body
});
}
let lanes = crate::accel::enabled().named();
lines.push(format!("embedding lanes on: {}", lanes.join(", ")));
lines.push(format!("vector cache: {}", crate::cache::stats().line()));
lines.join("\n")
}
"semlith_files" => {
let filter = match filter_of(&args) {
Ok(f) => f,
Err(e) => return Ok(tool_error(&e)),
};
let only = strings(&args, "store");
let limit = args
.get("limit")
.and_then(Value::as_u64)
.map(|n| n as usize)
.unwrap_or(FILE_LIMIT)
.max(1);
if args.get("tree").and_then(Value::as_bool).unwrap_or(false) {
let depth = args
.get("depth")
.and_then(Value::as_u64)
.unwrap_or(2)
.clamp(1, 8) as usize;
let sort = match crate::tree::Sort::parse(
args.get("sort").and_then(Value::as_str).unwrap_or(""),
) {
Ok(s) => s,
Err(e) => return Ok(tool_error(&e.to_string())),
};
match crate::tree::render(stores, Some(&only), &filter, depth, sort) {
Ok(text) => match stores.roots().as_slice() {
[one] => format!("root {}\n{text}", crate::plain(&one.to_string_lossy())),
_ => text,
},
Err(e) => return Ok(tool_error(&e.to_string())),
}
} else {
match stores.paths_in(Some(&only), &filter, limit) {
Ok((paths, _)) if paths.is_empty() => {
"No file in the semlith store matches that.".to_string()
}
Ok((paths, left_out)) => {
let mut out = paths
.iter()
.map(|p| crate::plain(p))
.collect::<Vec<_>>()
.join("\n");
if left_out > 0 {
out.push_str(&format!(
"\n… and {left_out} more. Narrow with path/ext/lang, or raise limit."
));
}
out
}
Err(e) => return Ok(tool_error(&e.to_string())),
}
}
}
"semlith_index" => {
let roots: Vec<PathBuf> = strings(&args, "path").iter().map(PathBuf::from).collect();
if roots.is_empty() {
return Err((-32602, "missing required argument: path".into(), None));
}
let (roots, unreadable) = crate::check_roots(&roots);
if !unreadable.is_empty() {
let named = unreadable
.iter()
.map(|(path, why)| format!("{}: {why}", path.display()))
.collect::<Vec<_>>()
.join("; ");
return Ok(tool_error(&format!("cannot index {named}")));
}
if let Some(writer) = writer {
let named = strings(&args, "store");
return Ok(
match writer.index(named.first().map(String::as_str), &roots) {
Ok(text) => json!({ "content": [{ "type": "text", "text": text }] }),
Err(e) => tool_error(&e),
},
);
}
let store = match stores.writable(&strings(&args, "store")) {
Ok(s) => s,
Err(e) => return Ok(tool_error(&e.to_string())),
};
store.boundary = crate::Boundary::within(crate::home::index_roots(store.dir()));
match store.index_paths_within(&roots, index_budget(), |_, _| {}) {
Err(e) => return Ok(tool_error(&e.to_string())),
Ok(report) => {
let by_reason = if report.skipped_reasons.is_empty() {
String::new()
} else {
let mut parts: Vec<(usize, &str)> = report
.skipped_reasons
.iter()
.map(|(kind, n)| (*n, kind.as_str()))
.collect();
parts.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(b.1)));
format!(
" ({})",
parts
.iter()
.map(|(n, kind)| format!("{n} {kind}"))
.collect::<Vec<_>>()
.join(", ")
)
};
let mut out = format!(
"{} indexed, {} unchanged, {} skipped{by_reason}, {} removed ({} chunks)",
report.indexed,
report.unchanged,
report.skipped,
report.removed,
report.chunks,
);
if report.remaining > 0 {
out.push_str(&format!(
"\nStopped at the time limit with {} paths remaining. \
Call semlith_index again with the same arguments to continue; \
nothing already indexed is redone.",
report.remaining
));
}
for (path, why) in &report.refused {
out.push_str(&format!("\nrefused: {} — {why}", crate::plain(path)));
}
for (path, why) in &report.failed {
out.push_str(&format!("\nfailed: {} — {why}", crate::plain(path)));
}
let waiting = crate::store::refusals(store.db())
.map(|rows| {
rows.iter()
.filter(|r| r.reviewable && r.accepted.is_none())
.count()
})
.unwrap_or(0);
if waiting > 0 {
out.push_str(&format!(
"\n{waiting} file{} await{} the owner's review (semlith refused, or the portal's Files ▸ Not indexed).",
if waiting == 1 { "" } else { "s" },
if waiting == 1 { "s" } else { "" }
));
}
out
}
}
}
"semlith_add" => {
let urls = strings(&args, "url");
let url = match urls.as_slice() {
[one] => one.clone(),
[] => return Err((-32602, "missing required argument: url".into(), None)),
many => {
return Ok(tool_error(&format!(
"semlith_add fetches one URL, not {}: {}",
many.len(),
many.join(", ")
)));
}
};
let named = strings(&args, "store");
if let Some(writer) = writer {
return Ok(match writer.add(named.first().map(String::as_str), &url) {
Ok(text) => json!({ "content": [{ "type": "text", "text": text }] }),
Err(e) => tool_error(&e),
});
}
let store = match stores.writable(&named) {
Ok(s) => s,
Err(e) => return Ok(tool_error(&e.to_string())),
};
let fetched = match crate::add::fetch(&url, store.dir()) {
Ok(fetched) => fetched,
Err(e) => return Ok(tool_error(&format!("{e:#}"))),
};
match store.index_paths_within(
std::slice::from_ref(&fetched.path),
index_budget(),
|_, _| {},
) {
Err(e) => return Ok(tool_error(&e.to_string())),
Ok(report) => format!(
"Fetched {} into {} and indexed {} chunks.",
fetched.url,
fetched.path.display(),
report.chunks
),
}
}
"semlith_forget" => {
let paths = strings(&args, "path");
let path = match paths.as_slice() {
[one] => one.clone(),
[] => return Err((-32602, "missing required argument: path".into(), None)),
many => {
return Ok(tool_error(&format!(
"semlith_forget removes one file, not {}: {}",
many.len(),
many.join(", ")
)));
}
};
if let Some(writer) = writer {
let named = strings(&args, "store");
return Ok(
match writer.forget(named.first().map(String::as_str), &path) {
Ok(text) => json!({ "content": [{ "type": "text", "text": text }] }),
Err(e) => tool_error(&e),
},
);
}
let store = match stores.writable(&strings(&args, "store")) {
Ok(s) => s,
Err(e) => return Ok(tool_error(&e.to_string())),
};
match store.forget(std::path::Path::new(&path)) {
Ok((0, 0)) => format!("{path} was not indexed; nothing removed."),
Ok((0, images)) => format!("Removed {images} image vector(s) for {path}."),
Ok((chunks, 0)) => format!("Removed {chunks} chunks for {path}."),
Ok((chunks, images)) => {
format!("Removed {chunks} chunks and {images} image vector(s) for {path}.")
}
Err(e) => return Ok(tool_error(&e.to_string())),
}
}
"semlith_symbol" => {
let mut names = strings(&args, "names");
if let Some(name) = args.get("name").and_then(Value::as_str)
&& !names.iter().any(|n| n == name)
{
names.insert(0, name.to_string());
}
let Some(name) = names.first().cloned() else {
return Err((
-32602,
"missing required argument: name or names".into(),
None,
));
};
let name = name.as_str();
let k = args.get("k").and_then(Value::as_u64).unwrap_or(20) as usize;
let only = strings(&args, "store");
if names.len() > 1 {
if names.len() > crate::graph::NAMES_LIMIT {
return Ok(tool_error(&format!(
"at most {} names at once; {} given",
crate::graph::NAMES_LIMIT,
names.len()
)));
}
let paths = stores.shortener();
match stores.signatures_in(Some(&only), &names) {
Ok(rows) if rows.is_empty() => empty_graph(stores, &names.join(", ")),
Ok(rows) => {
let text = crate::graph::render_signatures(&rows, &|p| paths.short(p));
let missing: Vec<&String> = names
.iter()
.filter(|n| !rows.iter().any(|r| r.asked == **n))
.collect();
let text = if missing.is_empty() {
text
} else {
format!(
"{text}\nno definition: {}",
missing
.iter()
.map(|m| m.as_str())
.collect::<Vec<_>>()
.join(", ")
)
};
paths.with_header(crate::graph::fit(text, "ask for fewer names"))
}
Err(e) => return Ok(tool_error(&e.to_string())),
}
} else if args
.get("history")
.and_then(Value::as_bool)
.unwrap_or(false)
{
match stores.past_in(Some(&only), name, k.clamp(1, 200)) {
Ok(past) if past.is_empty() => format!(
"No earlier definition of {name} is recorded. \
A store keeps history from its first index pass under 0.23.0."
),
Ok(past) => past
.iter()
.map(|row| {
format!(
"{} {} {}:{}-{} until {} hash {}",
row.kind,
row.qualified,
crate::plain(&row.path),
row.start_line,
row.end_line,
crate::clock::local_stamp(row.retired_at),
&row.content_hash[..row.content_hash.len().min(12)],
)
})
.collect::<Vec<_>>()
.join("\n"),
Err(e) => return Ok(tool_error(&e.to_string())),
}
} else {
match stores.evidence_in(
Some(&only),
name,
&crate::graph::dependency_kinds(),
k.clamp(1, 200),
false,
) {
Ok(found) if found.definitions.is_empty() => empty_graph(stores, name),
Ok(found) => {
let paths = stores.shortener();
paths.with_header(crate::graph::fit(
found.render("", "", &|p| paths.short(p)),
"ask with a qualified name such as Type::method, or a smaller k",
))
}
Err(e) => return Ok(tool_error(&e.to_string())),
}
}
}
"semlith_languages" => {
let with_edges: Vec<&str> = crate::graph::languages();
let names: Vec<String> = crate::filter::LANGUAGES
.iter()
.map(|entry| {
if with_edges.contains(&entry.name) {
format!("{} (graph)", entry.name)
} else {
entry.name.to_string()
}
})
.collect();
format!(
"{} languages. Those marked (graph) also carry symbols and edges, so \
semlith_symbol, semlith_neighbors and semlith_path work on them.\n{}",
names.len(),
names.join(", "),
)
}
"semlith_neighbors" => {
let Some(name) = args.get("name").and_then(Value::as_str) else {
return Err((-32602, "missing required argument: name".into(), None));
};
let kinds = strings(&args, "kind");
if let Some(bad) = kinds
.iter()
.find(|k| !crate::graph::KINDS.contains(&k.as_str()))
{
return Ok(tool_error(&format!(
"unknown edge kind {bad:?}; the kinds are {}",
crate::graph::KINDS.join(", ")
)));
}
let only = strings(&args, "store");
let all = args.get("all").and_then(Value::as_bool).unwrap_or(false);
match stores.neighbours_in(Some(&only), name, &kinds, all) {
Ok(n)
if n.callers.is_empty()
&& n.callees.is_empty()
&& n.hidden == 0
&& n.unresolved.is_empty() =>
{
empty_graph(stores, name)
}
Ok(n) => {
let paths = stores.shortener();
let mut body = format!(
"callers of {name} ({}):\n{}\n\ncallees of {name} ({}):\n{}",
n.callers.len(),
render_ends(&n.callers, &|p| paths.short(p)),
n.callees.len(),
render_ends(&n.callees, &|p| paths.short(p)),
);
if n.hidden > 0 {
body.push_str(&format!(
"\n\n{} target{} outside this store, not listed. Ask with all: true.",
n.hidden,
if n.hidden == 1 { "" } else { "s" },
));
}
if !n.unresolved.is_empty() {
let outside = n
.unresolved
.iter()
.map(|u| format!(" {} via {}", u.name, u.kind))
.collect::<Vec<_>>()
.join("\n");
body.push_str(&format!("\n\noutside this store:\n{outside}"));
}
paths.with_header(crate::graph::fit(
body,
"ask with a qualified name such as Type::method, or a kind",
))
}
Err(e) => return Ok(tool_error(&e.to_string())),
}
}
"semlith_report" => {
let Some(kind) = args.get("kind").and_then(Value::as_str) else {
return Err((-32602, "missing required argument: kind".into(), None));
};
let format = args
.get("format")
.and_then(Value::as_str)
.unwrap_or("markdown");
let model = args
.get("model")
.and_then(Value::as_str)
.unwrap_or(crate::report::DEFAULT_MODEL);
match crate::report::generate(stores, kind, model).and_then(|r| r.render(format)) {
Ok(text) => text,
Err(e) => return Ok(tool_error(&e.to_string())),
}
}
"semlith_impact" => {
let Some(name) = args.get("name").and_then(Value::as_str) else {
return Err((-32602, "missing required argument: name".into(), None));
};
let kinds = strings(&args, "kind");
let depth = args.get("depth").and_then(Value::as_u64).unwrap_or(3) as u32;
let depth = depth.clamp(1, 10);
let only = strings(&args, "store");
let strict = args.get("strict").and_then(Value::as_bool).unwrap_or(false);
let all_edges = args
.get("all_edges")
.and_then(Value::as_bool)
.unwrap_or(false)
&& !strict;
match stores.impact_in(Some(&only), name, &kinds, depth, all_edges) {
Ok(impact) => {
let paths = stores.shortener();
paths.with_header(impact.render("", "", &|p| paths.short(p)))
}
Err(e) => return Ok(tool_error(&e.to_string())),
}
}
"semlith_trace" => {
let (Some(from), Some(to)) = (
args.get("from").and_then(Value::as_str),
args.get("to").and_then(Value::as_str),
) else {
return Err((-32602, "missing required arguments: from, to".into(), None));
};
let depth = args.get("depth").and_then(Value::as_u64).unwrap_or(6) as u32;
let depth = depth.clamp(1, 20);
let only = strings(&args, "store");
let strict = args.get("strict").and_then(Value::as_bool).unwrap_or(false);
let all_edges = args
.get("all_edges")
.and_then(Value::as_bool)
.unwrap_or(false)
&& !strict;
let evidence = args
.get("evidence")
.and_then(Value::as_bool)
.unwrap_or(false);
match stores.trace_in(Some(&only), from, to, depth, all_edges, &crate::plain) {
Ok(trace) => {
if evidence {
trace.evidence(&crate::plain)
} else {
trace.render("", "", &crate::plain)
}
}
Err(e) => return Ok(tool_error(&e.to_string())),
}
}
"semlith_path" => {
let (Some(from), Some(to)) = (
args.get("from").and_then(Value::as_str),
args.get("to").and_then(Value::as_str),
) else {
return Err((-32602, "missing required arguments: from, to".into(), None));
};
let depth = args.get("depth").and_then(Value::as_u64).unwrap_or(6) as u32;
let only = strings(&args, "store");
let strict = args.get("strict").and_then(Value::as_bool).unwrap_or(false);
let all_edges = args
.get("all_edges")
.and_then(Value::as_bool)
.unwrap_or(false)
&& !strict;
let depth = depth.clamp(1, 20);
match stores.path_in(Some(&only), from, to, depth, all_edges) {
Ok(Some(chain)) if chain.steps.is_empty() => format!("{from} is {to}."),
Ok(Some(chain)) => chain.render("", "", &crate::plain),
Ok(None) => crate::graph::not_connected(from, to, depth, all_edges),
Err(e) => return Ok(tool_error(&e.to_string())),
}
}
other => return Err((-32602, format!("unknown tool: {other}"), None)),
};
record(stores, session, name, &args, &body, started.elapsed());
Ok(json!({ "content": [{ "type": "text", "text": body }] }))
}
fn record(
stores: &Fleet,
session: &Session,
tool: &str,
args: &Value,
body: &str,
elapsed: std::time::Duration,
) {
let who = session.who();
match tool {
"semlith_search" => {
let Some(query) = args.get("query").and_then(Value::as_str) else {
return;
};
crate::ledger::reply(
stores,
&who,
"search",
query,
body,
&strings(args, "store"),
elapsed,
);
}
"semlith_brief" => {
let Some(question) = args.get("question").and_then(Value::as_str) else {
return;
};
crate::ledger::reply(
stores,
&who,
"brief",
question,
body,
&strings(args, "store"),
elapsed,
);
}
"semlith_pattern" => {
let subject = args.get("query").and_then(Value::as_str).unwrap_or("");
let found = !body.starts_with("No match") && !body.starts_with("No indexed");
crate::ledger::graph(stores, &who, "pattern", subject, "", found, elapsed);
}
"semlith_read" => {
let subject = args.get("target").and_then(Value::as_str).unwrap_or("");
let found = !body.starts_with("Nothing indexed");
crate::ledger::graph(stores, &who, "read", subject, "", found, elapsed);
}
"semlith_neighbors" | "semlith_path" | "semlith_symbol" => {
let Some(subject) = args
.get("name")
.or_else(|| args.get("from"))
.and_then(Value::as_str)
else {
return;
};
let short = tool.trim_start_matches("semlith_");
let found = !body.contains("not connected") && !body.contains("nothing");
crate::ledger::graph(stores, &who, short, subject, body, found, elapsed);
}
_ => {}
}
}
fn empty_graph(stores: &Fleet, name: &str) -> String {
let symbols: i64 = stores
.each()
.map(|(_, store)| {
crate::store::graph_stats(store.db())
.map(|(s, _)| s)
.unwrap_or(0)
})
.sum();
if symbols == 0 {
"This store has no symbols yet: its graph is built as files are indexed. Call \
semlith_index on the corpus, then ask again."
.to_string()
} else {
format!(
"No symbol named {name} in the graph. Only Rust, TypeScript, Python, Go, Java \
and C carry symbols; semlith_search still finds text in everything else."
)
}
}
pub fn render_grep(
found: &crate::pattern::Matches,
offset: usize,
budget: usize,
shorten: &dyn Fn(&str) -> String,
) -> String {
let total = found.matches.len();
let mut out = format!(
"{}{total} matching lines in {} files searched",
if found.truncated { "at least " } else { "" },
found.files
);
let mut shown = 0;
let mut rest: Vec<(String, usize)> = Vec::new();
let mut last = None;
for m in &found.matches {
let file = (&m.store, &m.path);
let name = format!("{}{}", label_of(&m.store), shorten(&m.path));
if !rest.is_empty() || out.len() > budget {
match rest.last_mut() {
Some((n, count)) if *n == name => *count += 1,
_ => rest.push((name, 1)),
}
continue;
}
if last != Some(file) {
out.push_str(&format!("\n{name}"));
last = Some(file);
}
let def = if m.capture.is_empty() {
String::new()
} else {
format!(" {}", m.capture)
};
out.push_str(&format!("\n {}{def} | {}", m.start_line, m.text));
shown += 1;
}
if !rest.is_empty() {
out.push_str(&format!("\n{} more lines, past the budget:", total - shown));
for (name, count) in &rest {
out.push_str(&format!("\n {name} {count}"));
}
}
if shown < total || found.truncated {
out.push_str(&format!(
"\nnarrow with path, or call again with offset: {} for the rest",
offset + shown
));
}
out
}
fn label_of(store: &Option<String>) -> String {
match store {
Some(label) => format!("[{label}] "),
None => String::new(),
}
}
fn render_ends(ends: &[crate::store::EdgeEnd], shorten: &dyn Fn(&str) -> String) -> String {
if ends.is_empty() {
return " none".to_string();
}
ends.iter()
.map(|e| {
if e.confidence == crate::graph::AMBIGUOUS {
return format!(
" {} via {} ({}) · {} definitions{}",
e.symbol.name,
e.kind,
e.confidence,
e.definitions,
crate::graph::call_site(e, shorten)
);
}
format!(
" {} via {} ({}) {}{}:{}{}",
e.symbol.name,
e.kind,
e.confidence,
label_of(&e.symbol.store),
shorten(&e.symbol.path),
e.symbol.start_line,
crate::graph::call_site(e, shorten)
)
})
.collect::<Vec<_>>()
.join("\n")
}
fn index_budget() -> Duration {
match std::env::var("SEMLITH_MCP_INDEX_BUDGET") {
Ok(v) => match v.trim().parse::<u64>() {
Ok(seconds) => Duration::from_secs(seconds),
Err(_) => INDEX_BUDGET,
},
Err(_) => INDEX_BUDGET,
}
}
fn filter_of(args: &Value) -> Result<Filter, String> {
Filter::new(
&strings(args, "path"),
&strings(args, "ext"),
&strings(args, "lang"),
)
.map_err(|e| e.to_string())
}
fn strings(args: &Value, key: &str) -> Vec<String> {
match args.get(key) {
Some(Value::String(s)) => vec![s.clone()],
Some(Value::Array(items)) => items
.iter()
.filter_map(|i| i.as_str().map(str::to_string))
.collect(),
_ => Vec::new(),
}
}
fn tool_error(message: &str) -> Value {
json!({ "content": [{ "type": "text", "text": message }], "isError": true })
}
fn locate(
hits: &[crate::Hit],
query: &str,
max_tokens: usize,
shorten: &dyn Fn(&str) -> String,
) -> String {
locate_cut(hits, query, max_tokens, shorten).0
}
pub struct SearchReply {
pub text: String,
pub kept: Vec<bool>,
pub lines: Vec<String>,
pub tokens: usize,
}
pub fn search_reply(
fleet: &Fleet,
hits: &[crate::Hit],
query: &str,
prefer: crate::Prefer,
max_tokens: usize,
) -> SearchReply {
let paths = fleet.shortener();
let (rows, kept) = locate_cut(hits, query, max_tokens, &|p| paths.short(p));
let note = fleet
.pending_note()
.map(|n| format!("\n{n}"))
.unwrap_or_default();
let text = paths.with_header(format!("{}\n{rows}{note}", reading(query, prefer)));
let terms = query_terms(query);
let lines = hits
.iter()
.map(|hit| match hit.image {
Some(px) => format!("image {}x{} px", px.width, px.height),
None => best_line(&hit.text, &terms),
})
.collect();
SearchReply {
tokens: tokens(&text),
text,
kept,
lines,
}
}
pub fn brief_reply(fleet: &Fleet, brief: &crate::brief::Brief, filter: &crate::Filter) -> String {
if brief.spans.is_empty() {
return fleet.no_match_reason(filter);
}
let paths = fleet.shortener();
paths.with_header(render_brief(brief, &|p| paths.short(p)))
}
fn query_terms(query: &str) -> Vec<String> {
query
.split(|c: char| !c.is_alphanumeric() && c != '_')
.filter(|t| t.len() > 2)
.map(|t| t.to_lowercase())
.collect()
}
fn locate_cut(
hits: &[crate::Hit],
query: &str,
max_tokens: usize,
shorten: &dyn Fn(&str) -> String,
) -> (String, Vec<bool>) {
if hits.is_empty() {
return (String::new(), Vec::new());
}
let budget = max_tokens.max(MIN_LOCATE_TOKENS);
let terms = query_terms(query);
let mut files: Vec<(String, Vec<(usize, &crate::Hit)>)> = Vec::new();
for (at, hit) in hits.iter().enumerate() {
let label = match &hit.store {
Some(store) => format!("{store} {}", shorten(&hit.path)),
None => shorten(&hit.path),
};
match files.iter_mut().find(|(name, _)| *name == label) {
Some((_, group)) => group.push((at, hit)),
None => files.push((label, vec![(at, hit)])),
}
}
let mut blocks: Vec<(Vec<usize>, String)> = Vec::new();
for (label, group) in &files {
let mut block = format!("{label}\n");
for (_, hit) in group {
block.push_str(&locate_row(hit, &terms, shorten));
}
blocks.push((group.iter().map(|(at, _)| *at).collect(), block));
}
let total = hits.len();
let mut out = String::new();
let mut kept = vec![false; total];
let mut shown = 0usize;
for (members, block) in blocks {
if !out.is_empty() && tokens(&out) + tokens(&block) > budget {
break;
}
out.push_str(&block);
shown += members.len();
for at in members {
kept[at] = true;
}
}
if shown < total {
out.push_str(&format!("truncated: {shown} of {total}\n"));
}
(out.trim_end().to_string(), kept)
}
fn reading(query: &str, prefer: crate::Prefer) -> String {
let shape = crate::shape_of(query);
match prefer {
crate::Prefer::Any => format!("{} · {}", shape.as_str(), shape.weighting()),
chosen => format!(
"{} · {} · prefer {}",
shape.as_str(),
shape.weighting(),
chosen.as_str()
),
}
}
fn locate_row(hit: &crate::Hit, terms: &[String], shorten: &dyn Fn(&str) -> String) -> String {
if let Some(px) = hit.image {
return format!(" image {}x{} px\n", px.width, px.height);
}
let mut marks = Vec::new();
if !hit.lists.is_empty() {
match (&hit.provenance, hit.lists.as_slice()) {
(Some(tier), ["graph"]) => marks.push(format!("graph({tier})")),
_ => marks.push(hit.lists.join("+")),
}
}
if !hit.fresh {
marks.push("stale".to_string());
}
if !hit.copies.is_empty() {
let copies: Vec<String> = hit.copies.iter().map(|p| shorten(p)).collect();
marks.push(format!(
"also in {} cop{}: {}",
copies.len(),
if copies.len() == 1 { "y" } else { "ies" },
copies.join(", ")
));
}
let at = match hit.symbol_line {
Some(line) => format!(" @{line}"),
None => String::new(),
};
let named = match (&hit.symbol, &hit.symbol_kind) {
(Some(name), Some(kind)) => format!(" {name} {kind}{at}"),
(Some(name), None) => format!(" {name}{at}"),
_ => String::new(),
};
let marked = if marks.is_empty() {
String::new()
} else {
format!(" \u{00b7} {}", marks.join(" \u{00b7} "))
};
format!(
" {}-{}{named}{marked} | {}\n",
hit.start_line,
hit.end_line,
best_line(&hit.text, terms),
)
}
fn best_line(text: &str, terms: &[String]) -> String {
const WIDTH: usize = 96;
let scored = text
.lines()
.map(|line| {
let lowered = line.to_lowercase();
let hits = terms.iter().filter(|t| lowered.contains(*t)).count();
(hits, line.trim())
})
.filter(|(_, line)| !line.is_empty())
.max_by_key(|(hits, _)| *hits);
let line = match scored {
Some((0, _)) | None => text.lines().map(str::trim).find(|l| !l.is_empty()),
Some((_, line)) => Some(line),
}
.unwrap_or_default();
if line.chars().count() <= WIDTH {
return line.to_string();
}
let cut: String = line.chars().take(WIDTH).collect();
format!("{cut}\u{2026}")
}
const READ_CHARS: usize = 32_000;
const WHOLE_FILE_CHARS: usize = 8_000;
const READ_WHOLE: usize = 4;
const MIN_LOCATE_TOKENS: usize = 200;
const DEFAULT_LOCATE_TOKENS: usize = 1_500;
fn tokens(text: &str) -> usize {
text.len().div_ceil(4)
}
fn render_brief(brief: &crate::brief::Brief, shorten: &dyn Fn(&str) -> String) -> String {
let mut out = String::new();
for span in &brief.spans {
let via = if span.lists.is_empty() {
String::new()
} else {
format!(" [{}]", span.lists.join("+"))
};
let what = match (&span.symbol, span.symbol_line) {
(Some(name), Some(line)) => format!(" {name} @{line}"),
(Some(name), None) => format!(" {name}"),
_ => String::new(),
};
out.push_str(&format!(
"{}{}:{}-{}{what}{via}\n",
label_of(&span.store),
shorten(&span.path),
span.start_line,
span.end_line
));
match &span.text {
Some(text) => {
for line in text.lines() {
out.push_str(" ");
out.push_str(line);
out.push('\n');
}
}
None if span.over_budget => out.push_str(" (text left out for the budget)\n"),
None => out.push_str(" (text: top span only)\n"),
}
}
for symbol in &brief.symbols {
out.push_str(&format!(
"{}{} [graph]\n",
label_of(&symbol.store),
symbol.name
));
for edge in &symbol.callers {
out.push_str(&format!(
" called by {} {}:{}\n",
edge.symbol.name,
shorten(&edge.symbol.path),
edge.symbol.start_line
));
}
for edge in &symbol.callees {
out.push_str(&format!(
" calls {} {}:{}\n",
edge.symbol.name,
shorten(&edge.symbol.path),
edge.symbol.start_line
));
}
if symbol.hidden > 0 {
out.push_str(&format!(" and {} more edges\n", symbol.hidden));
}
}
out.push_str(&format!(
"{} tokens of {}, counted with {}",
brief.tokens, brief.budget, brief.counted_with
));
if !brief.cut.is_empty() {
let mut dropped = Vec::new();
if brief.cut.span_text > 0 {
dropped.push(format!("{} spans left without text", brief.cut.span_text));
}
if brief.cut.symbols > 0 {
dropped.push(format!("{} symbols' edges", brief.cut.symbols));
}
out.push_str(&format!(" -- dropped: {}", dropped.join(", ")));
}
out.push('\n');
out
}
fn render(hits: &[crate::Hit]) -> String {
let mut out = String::new();
for (i, h) in hits.iter().enumerate() {
let from = match &h.store {
Some(label) => format!("{label} "),
None => String::new(),
};
let via = if h.lists.is_empty() {
String::new()
} else {
format!(" via {}", h.lists.join("+"))
};
if let Some(px) = h.image {
out.push_str(&format!(
"[{}] {from}{} — image, {}x{} px (score {:.3}{via})\n\n",
i + 1,
crate::plain(&h.path),
px.width,
px.height,
h.score,
));
continue;
}
out.push_str(&format!(
"[{}] {from}{}:{}-{} (score {:.3}{via})\n{}\n\n",
i + 1,
crate::plain(&h.path),
h.start_line,
h.end_line,
h.score,
h.text.trim_end()
));
}
out.trim_end().to_string()
}
fn respond(out: &mut impl Write, id: &Value, result: Result<Value, Fail>) -> std::io::Result<()> {
writeln!(out, "{}", reply(id, result))?;
out.flush()
}
fn reply(id: &Value, result: Result<Value, Fail>) -> Value {
match result {
Ok(value) => json!({ "jsonrpc": "2.0", "id": id, "result": value }),
Err((code, message, data)) => {
let mut error = json!({ "code": code, "message": message });
if let Some(data) = data {
error["data"] = data;
}
json!({ "jsonrpc": "2.0", "id": id, "error": error })
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_instructions_stay_within_their_limit_for_any_fleet() {
for count in 0..=40 {
for width in [8, 40, 75, 120, 300, 700] {
let roots: Vec<std::path::PathBuf> = (0..count)
.map(|i| std::path::PathBuf::from(format!("/{i}{}", "x".repeat(width))))
.collect();
let said = instructions(&roots);
assert!(
said.len() <= INSTRUCTIONS_LIMIT,
"{count} roots of {width} bytes: {} bytes: {said}",
said.len()
);
}
}
}
#[test]
fn a_revision_we_do_not_implement_is_never_echoed() {
assert_eq!(negotiate(Some("1900-01-01")), LEGACY_NEWEST);
assert_eq!(negotiate(Some("2029-11-05")), LEGACY_NEWEST);
assert_eq!(negotiate(Some(MODERN)), LEGACY_NEWEST);
}
#[test]
fn a_revision_we_do_implement_is_answered_with_itself() {
for revision in ["2025-11-25", "2025-06-18", "2024-11-05"] {
assert_eq!(negotiate(Some(revision)), revision);
}
assert_eq!(negotiate(None), LEGACY_OLDEST);
}
#[test]
fn only_modern_requests_declare_a_version() {
let modern = json!({ "_meta": { META_VERSION: MODERN } });
assert_eq!(declared_version(&modern), Some(MODERN));
assert_eq!(declared_version(&json!({ "name": "semlith_stats" })), None);
}
fn hit(path: &str, start: u32, end: u32, text: &str) -> crate::Hit {
crate::Hit {
score: 0.5,
path: path.to_string(),
start_line: start,
end_line: end,
text: text.to_string(),
store: None,
lists: vec!["vector", "keyword"],
image: None,
fresh: true,
symbol: Some("edges_out".to_string()),
symbol_kind: Some("fn".to_string()),
symbol_line: None,
provenance: None,
copies: Vec::new(),
}
}
#[test]
fn a_locate_row_is_an_address_rather_than_its_contents() {
let hits: Vec<crate::Hit> = (0..8)
.map(|i| {
hit(
&format!("src/file{i}.rs"),
100 + i * 10,
120 + i * 10,
"fn edges_out(db: &Connection, name: &str) -> Result<Vec<EdgeEnd>> {\n\
let filter = kind_predicate(kinds, \"e.kind\");\n\
// A long chunk of source, of the kind 0.14.0 sent whole.\n",
)
})
.collect();
let reply = locate(&hits, "edges_out", DEFAULT_LOCATE_TOKENS, &crate::plain);
let per_hit = reply.len() / hits.len();
assert!(
per_hit < 200,
"a locate row costs {per_hit} bytes; the whole reply was:\n{reply}"
);
assert!(reply.contains("edges_out fn"), "{reply}");
assert!(reply.contains("100-120"), "{reply}");
}
#[test]
fn a_small_budget_cuts_the_reply_and_says_it_did() {
let hits: Vec<crate::Hit> = (0..40)
.map(|i| hit(&format!("src/file{i}.rs"), 10, 40, "fn a() { helper(); }\n"))
.collect();
let full = locate(&hits, "helper", 10_000, &crate::plain);
assert!(!full.contains("truncated:"), "nothing was cut: {full}");
let cut = locate(&hits, "helper", MIN_LOCATE_TOKENS, &crate::plain);
assert!(cut.contains("truncated:"), "{cut}");
assert!(cut.len() < full.len(), "the budget did not cut anything");
assert!(
cut.contains("of 40"),
"the reader is told what the total was: {cut}"
);
let (text, kept) = locate_cut(&hits, "helper", MIN_LOCATE_TOKENS, &crate::plain);
assert_eq!(text, cut);
let shown = kept.iter().filter(|k| **k).count();
assert!(cut.contains(&format!("truncated: {shown} of 40")), "{cut}");
for (hit, kept) in hits.iter().zip(&kept) {
assert_eq!(
cut.contains(&format!("{}\n", hit.path)),
*kept,
"{}",
hit.path
);
}
}
#[test]
fn an_impossible_budget_still_returns_the_best_file() {
let hits = vec![hit("src/store.rs", 948, 970, "fn edges_out() {}\n")];
let reply = locate(&hits, "edges_out", 1, &crate::plain);
assert!(reply.contains("src/store.rs"), "{reply}");
}
#[test]
fn a_stale_hit_is_marked_and_a_graph_reached_one_carries_its_tier() {
let mut stale = hit("src/lib.rs", 10, 20, "fn a() {}\n");
stale.fresh = false;
let mut reached = hit("src/graph.rs", 30, 40, "fn b() {}\n");
reached.lists = vec!["graph"];
reached.provenance = Some(crate::graph::RESOLVED.to_string());
let reply = locate(&[stale, reached], "a", DEFAULT_LOCATE_TOKENS, &crate::plain);
assert!(reply.contains("stale"), "{reply}");
assert!(reply.contains("graph(resolved)"), "{reply}");
}
#[test]
fn an_exact_search_past_its_budget_lists_the_rest_as_counts() {
let row = |path: &str, line: u32| crate::pattern::Match {
path: path.to_string(),
capture: "f".to_string(),
start_line: line,
end_line: line,
text: "x".repeat(60),
store: None,
};
let mut matches: Vec<_> = (1..=30).map(|l| row("a.rs", l)).collect();
matches.extend((1..=5).map(|l| row("b.rs", l)));
let found = crate::pattern::Matches {
language: String::new(),
matches,
files: 2,
truncated: false,
skipped: 0,
};
let all = render_grep(&found, 0, usize::MAX, &|p| p.to_string());
assert_eq!(all.lines().filter(|l| l.contains(" f | ")).count(), 35);
let cut = render_grep(&found, 0, 800, &|p| p.to_string());
assert!(cut.len() < 1200, "{} chars: {cut}", cut.len());
assert!(cut.starts_with("35 matching lines"), "{cut}");
assert!(cut.contains("\n b.rs 5"), "{cut}");
let shown = cut.lines().filter(|l| l.contains(" f | ")).count();
assert!(cut.contains(&format!("offset: {shown} ")), "{cut}");
}
#[test]
fn every_array_parameter_says_what_it_holds() {
fn walk(at: &str, schema: &Value, bad: &mut Vec<String>) {
if schema["type"] == "array" && schema.get("items").is_none() {
bad.push(at.to_string());
}
if let Some(props) = schema["properties"].as_object() {
for (name, sub) in props {
walk(&format!("{at}.{name}"), sub, bad);
}
}
if let Some(items) = schema.get("items") {
walk(&format!("{at}[]"), items, bad);
}
}
let mut bad = Vec::new();
for tool in tool_defs("default").as_array().unwrap() {
walk(
tool["name"].as_str().unwrap(),
&tool["inputSchema"],
&mut bad,
);
}
assert!(bad.is_empty(), "arrays without items: {bad:?}");
}
#[test]
fn the_tool_list_stays_small() {
let size = serde_json::to_string(&tool_defs("default")).unwrap().len();
let each: Vec<String> = tool_defs("default")
.as_array()
.unwrap()
.iter()
.map(|t| {
format!(
"{}={}",
t["name"].as_str().unwrap(),
serde_json::to_string(t).unwrap().len()
)
})
.collect();
assert!(
size <= 6_736,
"tools/list is {size} bytes, over the 6 736 the 1 685-token gate allows: {}",
each.join(" ")
);
}
}