use crate::daemon::{self, State, Store};
use crate::filter::{Filter, LANGUAGES};
use crate::http::{Handler, Request, Response};
use crate::{chunk, embed, home, store};
use fastembed::TextEmbedding;
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::Ordering;
const FILE_PAGE: i64 = 500;
const FILE_OFFSET_MAX: i64 = 10_000;
pub fn handler(state: Arc<State>) -> Handler {
Arc::new(move |request| route(&state, request))
}
fn route(state: &Arc<State>, request: &Request) -> Response {
let path = request.path.as_str();
let get = request.method == "GET";
let post = request.method == "POST";
if get && let Some((kind, bytes)) = crate::portal::asset(path) {
return Response::asset(kind, bytes);
}
match (get, post, path) {
(true, _, "/") => crate::portal::page(),
#[cfg(debug_assertions)]
(true, _, "/api/panic") => panic!("the route that exists to panic, panicking"),
(true, _, "/api/stores") => stores(state),
(true, _, "/api/files") => files(state, request),
(true, _, "/api/search") => search(state, request),
(true, _, "/api/models") => models(),
(true, _, "/api/languages") => languages(),
(true, _, "/api/dirs") => dirs(request),
(true, _, "/api/privacy") => privacy(state),
(true, _, "/api/about") => about(state),
(true, _, "/api/agents") => agents(state),
(true, _, "/api/pattern") => pattern(state, request),
(true, _, "/api/read") => read(state, request),
(true, _, "/api/symbol") => symbol(state, request),
(true, _, "/api/neighbors") => neighbors(state, request),
(true, _, "/api/path") => shortest_path(state, request),
(true, _, "/api/graph") => graph(state, request),
(true, _, "/api/ledger") => ledger(state),
(true, _, "/api/image") => image_file(state, request),
(true, _, "/api/setup") => setup(),
(_, true, "/api/index") => index(state, request),
(_, true, "/api/add") => add(state, request),
(_, true, "/api/forget") => forget(state, request),
(_, true, "/api/adopt") => adopt(state, request),
(_, true, "/api/trust") => trust(state, request),
(_, true, "/api/agents/reveal") => reveal(state),
(_, true, "/api/rotate") => rotate(state),
(_, true, "/api/mcp") => mcp(state, request),
(_, true, crate::http::MCP_PATH) => mcp(state, request),
(_, true, "/api/endpoint") => endpoint(state, request),
(_, true, "/api/key") => key(state, request),
(_, true, "/api/setup") => fix_setup(request),
(_, true, "/api/root") => root(state, request),
(_, true, "/api/store/delete") => delete_store(state, request),
(_, true, "/api/index/control") => index_control(state, request),
(_, true, "/api/upgrade") => upgrade(request),
(
_,
_,
"/api/index" | "/api/add" | "/api/forget" | "/api/adopt" | "/api/rotate" | "/api/mcp"
| "/api/upgrade",
) => Response::error(405, "wrong method for this route"),
_ => Response::error(404, "no such route"),
}
}
fn stores(state: &Arc<State>) -> Response {
if let Err(e) = state.open_fleet() {
return Response::error(500, &e.to_string());
}
let mut fleet = state.fleet.lock().unwrap_or_else(|e| e.into_inner());
let registry = home::Registry::load().unwrap_or_default();
let mut out = Vec::new();
for handle in state.stores() {
let stats = fleet
.as_mut()
.and_then(|f| {
f.each()
.find(|(_, s)| s.dir() == handle.dir)
.map(|(_, s)| s)
})
.map(|s| {
(
s.stats(),
s.model().to_string(),
s.dim(),
s.len(),
s.shards(),
store::file_facets(s.db(), &[]).unwrap_or_default(),
)
});
let (files, chunks, bytes, model, dim, vectors, shards, facets) = match stats {
Some((Ok((f, c, b)), model, dim, len, shards, facets)) => {
(f, c, b, model, dim, len, shards, facets)
}
_ => (0, 0, 0, String::new(), 0, 0, None, store::Facets::default()),
};
let mut readers: Vec<&'static str> = facets
.extensions
.iter()
.map(|ext| chunk::reader_of(Path::new(&format!("x.{ext}"))))
.collect();
readers.sort_unstable();
readers.dedup();
out.push(json!({
"name": handle.name,
"dir": handle.dir.display().to_string(),
"loose_mode": home::loose_mode(&handle.dir).map(|m| format!("{m:o}")),
"trusted": registry.trusts(&handle.dir),
"roots": handle.roots.iter().map(|r| json!({
"path": r.display().to_string(),
"present": r.exists(),
})).collect::<Vec<_>>(),
"files": files,
"chunks": chunks,
"bytes": bytes,
"model": model,
"dim": dim,
"vectors": vectors,
"shards": shards.map(|(n, max)| json!({ "count": n, "resident": max })),
"lines": facets.lines,
"formats": facets.extensions.len(),
"readers": readers.len(),
"watching": handle.watching.load(Ordering::Relaxed),
"queue": handle.queue_depth(),
"last_write": handle.last_write.load(Ordering::Relaxed),
"events": handle.events(),
}));
}
Response::json(&json!({ "stores": out }))
}
fn files(state: &Arc<State>, request: &Request) -> Response {
let filter = match filter_of(request) {
Ok(f) => f,
Err(e) => return Response::error(400, &e),
};
let only = request.query_all("store");
let offset = request
.query("offset")
.and_then(|v| v.parse::<i64>().ok())
.unwrap_or(0);
if !(0..=FILE_OFFSET_MAX).contains(&offset) {
return Response::error(
400,
&format!("offset must be between 0 and {FILE_OFFSET_MAX}"),
);
}
if let Err(e) = state.open_fleet() {
return Response::error(500, &e.to_string());
}
let mut fleet = state.fleet.lock().unwrap_or_else(|e| e.into_inner());
let Some(fleet) = fleet.as_mut() else {
return Response::json(&json!({ "files": [], "total": 0 }));
};
let sort = request
.query("sort")
.and_then(store::FileSort::parse)
.unwrap_or(store::FileSort::Path);
let desc = request.query("dir") == Some("desc");
let limit = request
.query("limit")
.and_then(|v| v.parse::<i64>().ok())
.unwrap_or(15)
.clamp(1, FILE_PAGE);
let take = offset.saturating_add(limit);
let mut merged: Vec<(String, store::FileRow)> = Vec::new();
let mut total = 0i64;
let mut extensions: Vec<String> = Vec::new();
let mut stores = 0usize;
for (label, opened) in fleet.each() {
if !only.is_empty() && !only.iter().any(|n| n == label) {
continue;
}
stores += 1;
match store::file_count(opened.db(), filter.groups()) {
Ok(count) => total += count,
Err(e) => return Response::error(500, &e.to_string()),
}
match store::file_facets(opened.db(), filter.groups()) {
Ok(facets) => extensions.extend(facets.extensions),
Err(e) => return Response::error(500, &e.to_string()),
}
let listed = match store::file_rows(opened.db(), filter.groups(), sort, desc, take) {
Ok(r) => r,
Err(e) => return Response::error(500, &e.to_string()),
};
merged.extend(listed.into_iter().map(|row| (label.to_string(), row)));
}
merged.sort_by(|(_, a), (_, b)| {
let order = match sort {
store::FileSort::Path => a.path.cmp(&b.path),
store::FileSort::Bytes => a.bytes.cmp(&b.bytes),
store::FileSort::Chunks => a.chunks.cmp(&b.chunks),
store::FileSort::Lines => a.lines.cmp(&b.lines),
store::FileSort::Indexed => a.indexed_at.cmp(&b.indexed_at),
};
let order = if desc { order.reverse() } else { order };
order.then_with(|| a.path.cmp(&b.path))
});
let rows: Vec<Value> = merged
.into_iter()
.skip(offset as usize)
.take(limit as usize)
.map(|(label, row)| {
let as_path = Path::new(&row.path);
json!({
"store": label,
"path": crate::plain(&row.path),
"ext": as_path.extension().and_then(|e| e.to_str()).unwrap_or(""),
"lang": language_of(as_path),
"reader": chunk::reader_of(as_path),
"bytes": row.bytes,
"chunks": row.chunks,
"lines": row.lines,
"indexed_at": row.indexed_at,
})
})
.collect();
extensions.sort();
extensions.dedup();
Response::json(&json!({
"files": rows,
"total": total,
"offset": offset,
"limit": limit,
"sort": request.query("sort").unwrap_or("path"),
"dir": if desc { "desc" } else { "asc" },
"formats": extensions.len(),
"stores": stores,
}))
}
fn search(state: &Arc<State>, request: &Request) -> Response {
let Some(query) = request.query("query").filter(|q| !q.trim().is_empty()) else {
return Response::error(400, "missing query");
};
let k = request
.query("k")
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(8)
.clamp(1, 100);
let filter = match filter_of(request) {
Ok(f) => f,
Err(e) => return Response::error(400, &e),
};
let only = request.query_all("store");
let offset = request
.query("offset")
.and_then(|v| v.parse::<i64>().ok())
.unwrap_or(0);
if !(0..=FILE_OFFSET_MAX).contains(&offset) {
return Response::error(
400,
&format!("offset must be between 0 and {FILE_OFFSET_MAX}"),
);
}
if let Err(e) = state.open_fleet() {
return Response::error(500, &e.to_string());
}
let mut fleet = state.fleet.lock().unwrap_or_else(|e| e.into_inner());
let Some(fleet) = fleet.as_mut() else {
return Response::json(&json!({ "hits": [], "selected": 0 }));
};
let selected = if filter.is_empty() {
None
} else {
match fleet.matching_files(&filter) {
Ok(n) => Some(n),
Err(e) => return Response::error(500, &e.to_string()),
}
};
if selected == Some(0) {
return Response::json(&json!({ "hits": [], "selected": 0, "chunks": fleet.chunks() }));
}
let prefer = match request.query("prefer") {
Some(raw) => match crate::Prefer::parse(raw) {
Ok(p) => p,
Err(e) => return Response::error(400, &e.to_string()),
},
None => crate::Prefer::default(),
};
let started = std::time::Instant::now();
let only = (!only.is_empty()).then_some(only);
let deep = (offset as usize)
.saturating_add(k)
.min(FILE_OFFSET_MAX as usize);
let found = match fleet.search_preferring(only.as_deref(), query, deep, &filter, prefer) {
Ok(h) => h,
Err(e) => return Response::error(500, &e.to_string()),
};
let elapsed = started.elapsed();
let hits: Vec<_> = found.into_iter().skip(offset as usize).take(k).collect();
let single = fleet.len() == 1;
let label = fleet.labels().first().map(|s| s.to_string());
let locate = request
.query("format")
.is_some_and(|f| f.eq_ignore_ascii_case("locate"));
let out: Vec<Value> = hits
.iter()
.map(|h| {
json!({
"score": h.score,
"path": crate::plain(&h.path),
"start_line": h.start_line,
"end_line": h.end_line,
"text": if locate { String::new() } else { h.text.clone() },
"store": h.store.clone().or_else(|| single.then(|| label.clone()).flatten()),
"lists": h.lists,
"image": h.image.map(|px| json!({ "width": px.width, "height": px.height })),
"fresh": h.fresh,
"symbol": h.symbol,
"symbol_kind": h.symbol_kind,
"provenance": h.provenance,
})
})
.collect();
if state.ledger {
crate::ledger::search(
fleet,
&crate::ledger::Who {
client: "portal",
session: "portal",
},
query,
&hits,
elapsed,
);
}
let shape = crate::shape_of(query);
Response::json(&json!({
"hits": out,
"offset": offset,
"selected": selected,
"chunks": fleet.chunks(),
"micros": elapsed.as_micros() as u64,
"shape": shape,
"shape_label": shape.as_str(),
"weighting": shape.weighting(),
"prefer": prefer,
}))
}
fn ledger(state: &Arc<State>) -> Response {
let empty = json!({
"recording": state.ledger,
"queries": 0,
"clients": 0,
"excerpt_tokens": 0,
"whole_file_tokens": 0,
"ratio": null,
"intact": true,
});
let recording = state.ledger;
with_fleet(state, empty, move |fleet| {
let (mut queries, mut clients, mut excerpt, mut whole) = (0, 0, 0, 0);
let mut intact = true;
for (_, store) in fleet.each() {
let (q, c, e, w) = store::ledger_totals(store.db())?;
queries += q;
clients = clients.max(c);
excerpt += e;
whole += w;
intact = intact && store::ledger_break(store.db())?.is_none();
}
let mut net = 0;
let (mut credited, mut total) = (0, 0);
let mut measured = true;
let mut by_client: std::collections::BTreeMap<String, i64> = Default::default();
for (_, store) in fleet.each() {
let savings = store::ledger_savings(store.db())?;
net += savings.net;
credited += savings.credited;
total += savings.total;
measured = measured && savings.measured;
for (client, count) in store::ledger_clients(store.db())? {
*by_client.entry(client).or_default() += count;
}
}
let coverage = if total == 0 {
0
} else {
credited * 100 / total
};
let ratio = (excerpt > 0).then(|| whole as f64 / excerpt as f64);
Ok(json!({
"recording": recording,
"queries": queries,
"clients": clients,
"excerpt_tokens": excerpt,
"whole_file_tokens": whole,
"ratio": ratio,
"intact": intact,
"net_tokens": net,
"credited": credited,
"coverage": coverage,
"tier": if measured && credited > 0 { "measured" } else { "modelled" },
"by_client": by_client,
}))
})
}
fn models() -> Response {
let mut out = vec![
json!({
"name": embed::GRANITE_NAME,
"dim": 384,
"description": "default. IBM Granite R2 small, int8, English",
}),
json!({
"name": crate::image::MODEL_NAME,
"dim": crate::image::DIM,
"description": "images. CLIP ViT-B/32, vision and text, fixed",
"code": "Qdrant/clip-ViT-B-32-vision",
}),
];
for info in TextEmbedding::list_supported_models() {
out.push(json!({
"name": info.model.to_string(),
"dim": info.dim,
"description": info.description,
"code": info.model_code,
}));
}
let cached = cached_model_sizes();
for model in &mut out {
let code = model.get("code").and_then(Value::as_str).unwrap_or("");
let key = code.rsplit('/').next().unwrap_or(code).to_ascii_lowercase();
if let Some(bytes) = cached.get(&key) {
model["bytes"] = json!(bytes);
}
}
Response::json(&json!({ "models": out }))
}
fn cached_model_sizes() -> std::collections::HashMap<String, u64> {
let mut out = std::collections::HashMap::new();
let Ok(entries) = crate::model_cache_dir().and_then(|dir| Ok(std::fs::read_dir(dir)?)) else {
return out;
};
for entry in entries.flatten() {
let Ok(kind) = entry.file_type() else {
continue;
};
if !kind.is_dir() {
continue;
}
let name = entry.file_name().to_string_lossy().to_ascii_lowercase();
let total = walk_bytes(&entry.path());
if total > 0 {
let key = name.rsplit("--").next().unwrap_or(&name).to_string();
*out.entry(key).or_insert(0) += total;
}
}
out
}
fn walk_bytes(dir: &Path) -> u64 {
let Ok(entries) = std::fs::read_dir(dir) else {
return 0;
};
entries
.flatten()
.map(|entry| match entry.file_type() {
Ok(kind) if kind.is_dir() => walk_bytes(&entry.path()),
Ok(_) => entry.metadata().map(|m| m.len()).unwrap_or(0),
Err(_) => 0,
})
.sum()
}
const MAX_IMAGE_BYTES: u64 = 64 * 1024 * 1024;
fn image_file(state: &Arc<State>, request: &Request) -> Response {
let Some(want) = request.query("path") else {
return Response::error(400, "missing path");
};
let path = crate::canonical(Path::new(want));
let looked_up = path.to_string_lossy().into_owned();
if let Err(e) = state.open_fleet() {
return Response::error(500, &e.to_string());
}
let mut fleet = state.fleet.lock().unwrap_or_else(|e| e.into_inner());
let Some(fleet) = fleet.as_mut() else {
return Response::error(404, "no such image");
};
let indexed = fleet.each().any(|(_, store)| {
store
.db()
.query_row(
"SELECT 1 FROM images i JOIN files f ON f.id = i.file_id WHERE f.path = ?1",
rusqlite::params![&looked_up],
|_| Ok(()),
)
.is_ok()
});
if !indexed {
return Response::error(404, "no such image");
}
let Ok(file) = std::fs::File::open(&path) else {
return Response::error(404, "the file is no longer on disk");
};
let Ok(meta) = file.metadata() else {
return Response::error(404, "the file is no longer on disk");
};
if !meta.is_file() {
return Response::error(404, "no such image");
}
if meta.len() > MAX_IMAGE_BYTES {
return Response::error(413, "the image is larger than the portal will draw");
}
use std::io::Read;
let mut bytes = Vec::with_capacity(meta.len() as usize);
if file.take(MAX_IMAGE_BYTES).read_to_end(&mut bytes).is_err() {
return Response::error(404, "the file could not be read");
}
let path = path.as_path();
let kind = match path
.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase)
.as_deref()
{
Some("png") => "image/png",
Some("jpg" | "jpeg") => "image/jpeg",
Some("webp") => "image/webp",
Some("gif") => "image/gif",
_ => return Response::error(404, "no such image"),
};
Response::new(200, kind, bytes)
}
fn languages() -> Response {
let out: Vec<Value> = LANGUAGES
.iter()
.map(|entry| {
json!({
"name": entry.name,
"extensions": entry.extensions,
"filenames": entry.filenames,
})
})
.collect();
Response::json(&json!({ "languages": out }))
}
fn dirs(request: &Request) -> Response {
let home = match crate::home::user_home() {
Ok(home) => crate::canonical(&home),
Err(e) => return Response::error(500, &e.to_string()),
};
let asked = request
.query("path")
.filter(|p| !p.is_empty())
.map(PathBuf::from)
.unwrap_or_else(|| home.clone());
let Ok(resolved) = std::fs::canonicalize(&asked) else {
return Response::error(400, "that path cannot be resolved");
};
if !resolved.starts_with(&home) {
return Response::error(400, "outside the home directory");
}
let mut entries = Vec::new();
let listing = match std::fs::read_dir(&resolved) {
Ok(l) => l,
Err(e) => return Response::error(400, &e.to_string()),
};
for entry in listing.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
if name.starts_with('.') {
continue;
}
let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
entries.push(json!({
"name": name,
"path": entry.path().display().to_string(),
"dir": is_dir,
}));
}
entries.sort_by(|a, b| {
let (ad, bd) = (a["dir"].as_bool(), b["dir"].as_bool());
bd.cmp(&ad).then_with(|| {
a["name"]
.as_str()
.unwrap_or("")
.cmp(b["name"].as_str().unwrap_or(""))
})
});
Response::json(&json!({
"path": resolved.display().to_string(),
"home": home.display().to_string(),
"parent": resolved
.parent()
.filter(|p| p.starts_with(&home))
.map(|p| p.display().to_string()),
"entries": entries,
}))
}
fn privacy(state: &Arc<State>) -> Response {
let cache = crate::model_cache_dir().unwrap_or_default();
Response::json(&json!({
"bind": format!("127.0.0.1:{}", state.server.port()),
"bind_is_fixed": true,
"airgap": state.airgap,
"model_cache": cache.display().to_string(),
"model_cached": cache.exists()
&& std::fs::read_dir(&cache).map(|mut d| d.next().is_some()).unwrap_or(false),
"token_header": crate::http::TOKEN_HEADER,
"token_preview": preview(&state.server.token()),
"key_grace_seconds": state.server.key_grace().map(|left| left.as_secs()),
"host_allowed": ["localhost", "127.0.0.1", "::1"],
"csp": "default-src 'self'",
"cors": false,
"store_home": shown(home::home_or_error()),
"rules": rules(state),
}))
}
fn rules(state: &Arc<State>) -> Value {
let home_dir = home::home_or_error();
let cache = crate::model_cache_dir().unwrap_or_default();
let key_path = home::agent_key_path().unwrap_or_default();
let registry = home::Registry::load().unwrap_or_default();
let store_modes: Vec<String> = state
.stores()
.iter()
.filter_map(|s| home::loose_mode(&s.dir).map(|m| format!("{} is {m:o}", s.name)))
.collect();
let key_mode: Option<u32> = {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(&key_path)
.map(|m| m.permissions().mode() & 0o777)
.ok()
}
#[cfg(not(unix))]
{
None
}
};
json!([
{
"id": "header-borne token",
"rule": format!(
"The session token travels in a {} header, never in a cookie. Every port on localhost is the same site, so a cookie would reach a page served by anything else on this machine.",
crate::http::TOKEN_HEADER
),
"check": "no route sets a cookie and none reads one",
"ok": true,
},
{
"id": "same-origin writes",
"rule": "Every request that is not a GET carries Sec-Fetch-Site: same-origin — or, from a client that sends no fetch metadata, an Origin that matches or none at all — and a JSON content type. Both are checked before the token, so a cross-origin page cannot tell a right guess from a wrong one.",
"check": "enforced in http::answer before any route runs",
"ok": true,
},
{
"id": "store trust",
"rule": "A store outside the store home is opened only after semlith trust has recorded it. A .semlith directory can arrive inside a repository.",
"check": if registry.trusted.is_empty() {
"no store outside the home is trusted".to_string()
} else {
format!("{} trusted outside the home", registry.trusted.len())
},
"ok": true,
},
{
"id": "index boundary",
"rule": "An agent holding the key indexes only under this store's registered roots or the home directory. The command line is not held to this — the person typing it owns the machine.",
"check": "enforced per path in Semlith::index_set",
"ok": true,
},
{
"id": "deny-list",
"rule": "No path under a credential directory, and no file named like a credential, is indexed by an agent or the portal — with the hidden-file rule applied to an explicitly named path too.",
"check": format!(
"{} directories and {} name patterns",
crate::filter::DENIED_DIRS.len(),
crate::filter::DENIED_NAMES.len()
),
"ok": true,
},
{
"id": "private addresses",
"rule": "semlith add resolves every hop and refuses an address that is not on the public internet: loopback, RFC 1918, link-local, carrier-grade NAT, unique local.",
"check": if std::env::var_os(crate::add::ALLOW_PRIVATE_ENV).is_some() {
format!("{} is set, so private addresses are allowed", crate::add::ALLOW_PRIVATE_ENV)
} else {
"on".to_string()
},
"ok": std::env::var_os(crate::add::ALLOW_PRIVATE_ENV).is_none(),
},
{
"id": "pinned models",
"rule": "Every model file is fetched at a pinned commit and verified against a digest recorded in the source and in docs/models.md. The weights are what computes every vector in every store.",
"check": format!("granite at {}", &crate::embed::GRANITE_REVISION[..12]),
"ok": true,
},
{
"id": "model cache",
"rule": "Weights are not loaded from a cache another account owns or can write to.",
"check": match crate::embed::check_cache_dir(&cache) {
Ok(()) => format!("{} is yours alone", cache.display()),
Err(e) => e.to_string(),
},
"ok": crate::embed::check_cache_dir(&cache).is_ok(),
},
{
"id": "directory modes",
"rule": "The store home, every store, the model cache and daemon.json are readable by their owner and nobody else.",
"check": match (&home_dir, store_modes.is_empty()) {
(Err(e), _) => e.to_string(),
(Ok(dir), true) => match home::loose_mode(dir) {
Some(mode) => format!("{} is {mode:o}", dir.display()),
None => format!("{} and every open store are 0700", dir.display()),
},
(Ok(_), false) => store_modes.join(", "),
},
"ok": home_dir
.as_ref()
.is_ok_and(|dir| store_modes.is_empty() && home::loose_mode(dir).is_none()),
},
{
"id": "agent key",
"rule": "The key is in one file, readable by you alone. No client configuration carries it and no command line shows it: every stanza names ${SEMLITH_AGENT_KEY}.",
"check": match key_mode {
Some(mode) if mode & 0o077 == 0 => format!("{} is {mode:o}", key_path.display()),
Some(mode) => format!("{} is {mode:o}", key_path.display()),
None => format!("{} has no mode to read", key_path.display()),
},
"ok": key_mode.is_none_or(|mode| mode & 0o077 == 0),
},
])
}
fn shown(path: anyhow::Result<PathBuf>) -> String {
match path {
Ok(p) => p.display().to_string(),
Err(e) => format!("unresolved — {e}"),
}
}
fn preview(secret: &str) -> String {
let head: String = secret.chars().take(16).collect();
if head.len() < secret.len() {
format!("{head}…")
} else {
head
}
}
fn about(state: &Arc<State>) -> Response {
let binary = std::env::current_exe().unwrap_or_default();
Response::json(&json!({
"version": env!("CARGO_PKG_VERSION"),
"format_version": store::FORMAT_VERSION,
"binary": binary.display().to_string(),
"binary_bytes": std::fs::metadata(&binary).map(|m| m.len()).unwrap_or(0),
"target": format!("{} · {}", std::env::consts::ARCH, std::env::consts::OS),
"bind": format!("127.0.0.1:{}", state.server.port()),
"revisions": crate::mcp::SUPPORTED,
"port": state.server.port(),
"pid": std::process::id(),
"uptime": daemon::uptime(state),
"store_home": shown(home::home_or_error()),
"model_cache": shown(crate::model_cache_dir()),
"models": TextEmbedding::list_supported_models().len() + 1,
"languages": LANGUAGES.len(),
"graph_languages": crate::graph::languages(),
"edge_kinds": crate::graph::KINDS,
"stores": state.stores().len(),
}))
}
fn reveal(state: &Arc<State>) -> Response {
Response::json(&json!({ "key": state.server.agent_key() }))
}
fn agents(state: &Arc<State>) -> Response {
let connections = state.clients();
let key = state.server.agent_key();
Response::json(&json!({
"forwarding": state.proxy_count() > 0,
"connected": connections.len(),
"connections": connections,
"tools": crate::mcp::tool_list()
.into_iter()
.map(|(name, about)| json!({ "name": name, "about": about }))
.collect::<Vec<_>>(),
"tool_list_bytes": crate::mcp::tool_list_bytes(),
"revisions": crate::mcp::SUPPORTED,
"clients": crate::clients::clients(),
"endpoint": {
"url": format!("http://127.0.0.1:{}{}", state.server.port(), crate::http::MCP_PATH),
"open": state.server.mcp_open(),
},
"key_env": crate::setup::KEY_ENV,
"key_set": home::is_agent_key(&key),
"key_path": shown(home::agent_key_path()),
"install": {
"sh": crate::setup::INSTALL_SH,
"ps1": crate::setup::INSTALL_PS1,
},
}))
}
fn setup() -> Response {
Response::json(&json!(crate::setup::status()))
}
fn fix_setup(request: &Request) -> Response {
let body = match request.json() {
Ok(b) => b,
Err(e) => return Response::error(400, &e.to_string()),
};
match body.get("step").and_then(Value::as_str) {
Some("path") => match crate::setup::run_path_step() {
Ok(step) => Response::json(&json!({ "step": step, "status": crate::setup::status() })),
Err(e) => Response::error(500, &e.to_string()),
},
Some(other) => Response::error(400, &format!("{other} is not a step this route runs")),
None => Response::error(400, "missing step"),
}
}
fn root(state: &Arc<State>, request: &Request) -> Response {
let body = match request.json() {
Ok(b) => b,
Err(e) => return Response::error(400, &e.to_string()),
};
let (Some(store), Some(path)) = (
body.get("store").and_then(Value::as_str),
body.get("root").and_then(Value::as_str),
) else {
return Response::error(400, "missing store or root");
};
match home::repoint(store, Path::new(path)) {
Ok(()) => Response::json(&json!({
"store": store,
"root": path,
"message": format!(
"{store} now covers {path}. It is watched from the next start of \
the daemon; nothing was re-embedded."
),
"restart": state.stores().iter().any(|s| s.name == store),
})),
Err(e) => Response::error(409, &e.to_string()),
}
}
fn with_fleet(
state: &Arc<State>,
empty: Value,
read: impl FnOnce(&crate::fleet::Fleet) -> anyhow::Result<Value>,
) -> Response {
if let Err(e) = state.open_fleet() {
return Response::error(500, &e.to_string());
}
let fleet = state.fleet.lock().unwrap_or_else(|e| e.into_inner());
let Some(fleet) = fleet.as_ref() else {
return Response::json(&empty);
};
match read(fleet) {
Ok(value) => Response::json(&value),
Err(e) => Response::error(500, &e.to_string()),
}
}
fn pattern(state: &Arc<State>, request: &Request) -> Response {
let Some(query) = request.query("query").filter(|q| !q.trim().is_empty()) else {
return Response::error(400, "missing query");
};
let Some(lang) = request.query("lang").filter(|l| !l.trim().is_empty()) else {
return Response::error(400, "missing lang");
};
let only = request.query_all("store");
let empty = json!({ "language": lang, "matches": [], "files": 0, "truncated": false });
with_fleet(state, empty, move |fleet| {
let only = (!only.is_empty()).then_some(only);
match fleet.pattern_in(
only.as_deref(),
lang,
query,
&crate::filter::Filter::default(),
) {
Ok(found) => Ok(serde_json::to_value(found)?),
Err(e) => Ok(json!({
"language": lang,
"matches": [],
"files": 0,
"truncated": false,
"error": e.to_string(),
})),
}
})
}
fn read(state: &Arc<State>, request: &Request) -> Response {
let Some(raw) = request.query("target").filter(|t| !t.trim().is_empty()) else {
return Response::error(400, "missing target");
};
let target = crate::Target::parse(raw);
let only = request.query_all("store");
with_fleet(state, json!({ "span": null }), move |fleet| {
let only = (!only.is_empty()).then_some(only);
match fleet.read_in(only.as_deref(), &target, &crate::filter::Filter::default())? {
None => Ok(json!({ "span": null, "definitions": [] })),
Some(crate::Read::One(span)) => Ok(json!({ "span": span, "definitions": [] })),
Some(crate::Read::Choose(rows)) => Ok(json!({ "span": null, "definitions": rows })),
}
})
}
fn symbol(state: &Arc<State>, request: &Request) -> Response {
let Some(name) = request.query("name").filter(|n| !n.trim().is_empty()) else {
return Response::error(400, "missing name");
};
let name = name.to_string();
let k = request
.query("k")
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(20)
.clamp(1, 200);
let only = request.query_all("store");
with_fleet(state, json!({ "symbols": [] }), move |fleet| {
let only = (!only.is_empty()).then_some(only);
let found = fleet.evidence_in(
only.as_deref(),
&name,
&crate::graph::dependency_kinds(),
k,
false,
)?;
Ok(json!({
"symbols": found.definitions,
"callers": found.callers,
"callees": found.callees,
"ego": found.ego,
}))
})
}
fn neighbors(state: &Arc<State>, request: &Request) -> Response {
let Some(name) = request.query("name").filter(|n| !n.trim().is_empty()) else {
return Response::error(400, "missing name");
};
let name = name.to_string();
let kinds = request.query_all("kind");
if let Some(bad) = kinds
.iter()
.find(|k| !crate::graph::KINDS.contains(&k.as_str()))
{
return Response::error(400, &format!("unknown edge kind {bad:?}"));
}
let only = request.query_all("store");
let all = request
.query("all")
.is_some_and(|v| v == "1" || v == "true");
let empty = json!({ "callers": [], "callees": [] });
with_fleet(state, empty, move |fleet| {
let only = (!only.is_empty()).then_some(only);
Ok(json!(fleet.neighbours_in(
only.as_deref(),
&name,
&kinds,
all
)?))
})
}
fn shortest_path(state: &Arc<State>, request: &Request) -> Response {
let (Some(from), Some(to)) = (request.query("from"), request.query("to")) else {
return Response::error(400, "missing from or to");
};
let (from, to) = (from.to_string(), to.to_string());
let depth = request
.query("depth")
.and_then(|v| v.parse::<u32>().ok())
.unwrap_or(6)
.clamp(1, 20);
let only = request.query_all("store");
let all_edges = request
.query("all_edges")
.is_some_and(|v| v == "1" || v == "true");
with_fleet(state, json!({ "path": null }), move |fleet| {
let only = (!only.is_empty()).then_some(only);
let chain = fleet.path_in(only.as_deref(), &from, &to, depth, all_edges)?;
Ok(json!({
"path": chain.as_ref().map(|c| &c.steps),
"summary": chain.as_ref().map(|c| &c.summary),
"all_edges": all_edges,
}))
})
}
fn graph(state: &Arc<State>, request: &Request) -> Response {
let focus = request.query("name").map(str::to_string);
let prefix = request.query("path").map(str::to_string);
let limit = request
.query("limit")
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(70)
.clamp(1, crate::graph::MAX_NODES);
let only = request.query_all("store");
let empty = json!({ "nodes": [], "edges": [], "total": 0, "shown": 0 });
with_fleet(state, empty, move |fleet| {
let only = (!only.is_empty()).then_some(only);
let chosen: Vec<(&str, &crate::Semlith)> = match &only {
Some(names) => fleet
.each()
.filter(|(label, _)| names.iter().any(|n| n == label))
.collect(),
None => fleet.each().collect(),
};
let many = fleet.len() > 1;
crate::graph::scoped(&chosen, focus.as_deref(), prefix.as_deref(), limit, many)
})
}
fn index(state: &Arc<State>, request: &Request) -> Response {
let body = match request.json() {
Ok(b) => b,
Err(e) => return Response::error(400, &e.to_string()),
};
let paths: Vec<PathBuf> = strings(&body, "path")
.into_iter()
.map(PathBuf::from)
.collect();
if paths.is_empty() {
return Response::error(400, "no path given");
}
let (paths, unreadable) = crate::check_roots(&paths);
if !unreadable.is_empty() {
let named = unreadable
.iter()
.map(|(path, why)| format!("{}: {why}", path.display()))
.collect::<Vec<_>>()
.join("; ");
return Response::error(400, &format!("cannot index {named}"));
}
let named = body.get("store").and_then(Value::as_str);
let store = match state.writable(named) {
Ok(s) => s,
Err(e) if named.is_none() && state.stores().is_empty() => {
match first_store(state, &paths[0]) {
Ok(store) => store,
Err(made) => return Response::error(409, &format!("{e}: {made:#}")),
}
}
Err(e) => return Response::error(409, &e.to_string()),
};
match state.index(&store, paths) {
Ok(progress) => stream(progress),
Err(e) => Response::error(409, &e.to_string()),
}
}
fn index_control(state: &Arc<State>, request: &Request) -> Response {
let body = match request.json() {
Ok(b) => b,
Err(e) => return Response::error(400, &e.to_string()),
};
let store = match state.writable(body.get("store").and_then(Value::as_str)) {
Ok(s) => s,
Err(e) => return Response::error(409, &e.to_string()),
};
match body.get("action").and_then(Value::as_str) {
Some("pause") => store
.paused
.store(true, std::sync::atomic::Ordering::Relaxed),
Some("resume") => store
.paused
.store(false, std::sync::atomic::Ordering::Relaxed),
Some("stop") => {
store.cancel_queued();
store
.cancelled
.store(true, std::sync::atomic::Ordering::Relaxed);
store
.paused
.store(false, std::sync::atomic::Ordering::Relaxed);
}
_ => return Response::error(400, "action must be \"pause\", \"resume\" or \"stop\""),
}
Response::json(&json!({
"store": store.name,
"paused": store.paused.load(std::sync::atomic::Ordering::Relaxed),
"stopping": store.cancelled.load(std::sync::atomic::Ordering::Relaxed),
}))
}
fn first_store(state: &Arc<State>, path: &Path) -> Result<Arc<Store>, anyhow::Error> {
let choice = home::resolve(&[], path, None)?;
let dir = choice.one()?;
{
let store = crate::Semlith::open(&dir, None)?;
let model = store.model().to_string();
home::record(&choice, std::slice::from_ref(&path.to_path_buf()), &model)?;
}
state.open_store(&dir)
}
fn add(state: &Arc<State>, request: &Request) -> Response {
let body = match request.json() {
Ok(b) => b,
Err(e) => return Response::error(400, &e.to_string()),
};
let Some(url) = body
.get("url")
.and_then(Value::as_str)
.filter(|u| !u.is_empty())
else {
return Response::error(400, "no url given");
};
let store = match state.writable(body.get("store").and_then(Value::as_str)) {
Ok(s) => s,
Err(e) => return Response::error(409, &e.to_string()),
};
let fetched = match crate::add::fetch(url, &store.dir) {
Ok(fetched) => fetched,
Err(e) => return Response::error(400, &format!("{e:#}")),
};
match state.index(&store, vec![fetched.path.clone()]) {
Ok(progress) => stream(progress),
Err(e) => Response::error(409, &e.to_string()),
}
}
fn upgrade(request: &Request) -> Response {
let body = match request.json() {
Ok(b) => b,
Err(e) => return Response::error(400, &e.to_string()),
};
match body.get("action").and_then(Value::as_str) {
Some("check") => match crate::upgrade::check() {
Ok(found) => Response::json(&json!(found)),
Err(e) => Response::error(502, &e.to_string()),
},
Some("apply") => match crate::upgrade::apply(
body.get("version")
.and_then(Value::as_str)
.map(String::from),
) {
Ok(()) => Response::json(&json!({
"installed": true,
"restart": "semlith start is still running the old binary — restart it",
})),
Err(e) => Response::error(409, &e.to_string()),
},
_ => Response::error(400, "action must be \"check\" or \"apply\""),
}
}
fn forget(state: &Arc<State>, request: &Request) -> Response {
let body = match request.json() {
Ok(b) => b,
Err(e) => return Response::error(400, &e.to_string()),
};
let paths: Vec<String> = match (
body.get("path").and_then(Value::as_str),
body.get("paths").and_then(Value::as_array),
) {
(Some(one), _) => vec![one.to_string()],
(None, Some(many)) => many
.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect(),
(None, None) => return Response::error(400, "no path given"),
};
if paths.is_empty() {
return Response::error(400, "no path given");
}
let store = match state.writable(body.get("store").and_then(Value::as_str)) {
Ok(s) => s,
Err(e) => return Response::error(409, &e.to_string()),
};
let single = paths.len() == 1;
let mut forgot = 0_i64;
let mut images = 0_i64;
let mut missing: Vec<String> = Vec::new();
for path in &paths {
let progress = match state.forget(&store, PathBuf::from(path)) {
Ok(p) => p,
Err(e) => return Response::error(409, &e.to_string()),
};
let value = match progress.recv() {
Ok(v) => v,
Err(_) => return Response::error(500, "the writer stopped before answering"),
};
let chunks = value.get("forgot").and_then(Value::as_i64).unwrap_or(0);
let pictures = value.get("images").and_then(Value::as_i64).unwrap_or(0);
if single {
if chunks == 0 && pictures == 0 {
return Response::error(404, &format!("{path} is not indexed"));
}
return Response::json(&value);
}
if chunks == 0 && pictures == 0 {
missing.push(path.clone());
}
forgot += chunks;
images += pictures;
}
let kept = paths.len() - missing.len();
let body = json!({
"files": kept,
"asked": paths.len(),
"forgot": forgot,
"images": images,
"not_indexed": missing,
"message": format!(
"{kept} file{} forgotten, {forgot} chunk{} removed.",
if kept == 1 { "" } else { "s" },
if forgot == 1 { "" } else { "s" },
),
});
if kept == 0 {
return Response::new(404, "application/json", body.to_string().into_bytes());
}
Response::json(&body)
}
fn delete_store(state: &Arc<State>, request: &Request) -> Response {
let body = match request.json() {
Ok(b) => b,
Err(e) => return Response::error(400, &e.to_string()),
};
let Some(name) = body.get("store").and_then(Value::as_str) else {
return Response::error(400, "no store given");
};
match state.delete_store(name) {
Ok(dir) => Response::json(&json!({
"store": name,
"deleted": dir.display().to_string(),
"message": format!(
"{name} is gone: its vectors, chunks, graph and ledger were deleted and the \
registry no longer lists it. The files it indexed are untouched."
),
})),
Err(e) => Response::error(409, &e.to_string()),
}
}
fn trust(state: &Arc<State>, request: &Request) -> Response {
let body = match request.json() {
Ok(b) => b,
Err(e) => return Response::error(400, &e.to_string()),
};
let Some(dir) = body.get("path").and_then(Value::as_str) else {
return Response::error(400, "no path given");
};
let mut registry = match home::Registry::load() {
Ok(r) => r,
Err(e) => return Response::error(500, &e.to_string()),
};
match registry.trust(Path::new(dir)) {
Ok(dir) => Response::json(&json!({
"dir": dir.display().to_string(),
"trusted": registry.trusted.iter().map(|d| d.display().to_string()).collect::<Vec<_>>(),
"restart_required": !state.stores().iter().any(|s| s.dir == dir),
})),
Err(e) => Response::error(400, &e.to_string()),
}
}
fn adopt(state: &Arc<State>, request: &Request) -> Response {
let body = match request.json() {
Ok(b) => b,
Err(e) => return Response::error(400, &e.to_string()),
};
let Some(dir) = body.get("path").and_then(Value::as_str) else {
return Response::error(400, "no path given");
};
let dir = PathBuf::from(dir);
if state
.stores()
.iter()
.any(|s| s.dir == crate::canonical(&dir))
{
return Response::error(409, "this daemon is already using that store");
}
match home::adopt(&dir, None, None) {
Ok((name, target)) => Response::json(&json!({
"name": name,
"dir": target.display().to_string(),
"restart_required": true,
})),
Err(e) => Response::error(400, &e.to_string()),
}
}
fn mcp(state: &Arc<State>, request: &Request) -> Response {
let proxy = crate::proxy::proxy_pid(request.header("semlith-proxy"));
if let Some(pid) = proxy {
state.saw_proxy(pid);
}
let body = match request.json() {
Ok(b) => b,
Err(e) => return Response::error(400, &e.to_string()),
};
let transport = match proxy {
Some(_) => "stdio proxy",
None if request.path == crate::http::MCP_PATH => "http /mcp",
None => "portal",
};
let method = body.get("method").and_then(Value::as_str).unwrap_or("");
let params = body.get("params");
let name = params
.and_then(|p| p.get("clientInfo"))
.and_then(|c| c.get("name"))
.and_then(Value::as_str);
let revision = params
.and_then(|p| p.get("protocolVersion"))
.and_then(Value::as_str);
let session = request
.header("mcp-session-id")
.filter(|id| is_session_id(id))
.map(str::to_string)
.or_else(|| proxy.map(|pid| pid.to_string()))
.unwrap_or_else(|| {
if method == "initialize" {
new_session()
} else {
String::from("anonymous")
}
});
state.note_client(&session, transport, name, revision, method == "tools/call");
let corpus_free = matches!(method, "initialize" | "tools/list" | "ping")
|| method.starts_with("notifications/");
if let Err(e) = state.open_mcp_fleet()
&& !corpus_free
{
return Response::error(409, &e.to_string());
}
let writer = daemon::Writer(Arc::clone(state));
let mut fleet = state.mcp_fleet.lock().unwrap_or_else(|e| e.into_inner());
let mut nothing = crate::fleet::Fleet::empty();
let fleet = match fleet.as_mut() {
Some(fleet) => fleet,
None if corpus_free => &mut nothing,
None => return Response::error(409, "this daemon has no store open"),
};
let mut mcp_session = crate::mcp::Session::new(session.clone());
if let Some(named) = state.client_name(&session, transport) {
mcp_session.client = named;
}
let response = match crate::mcp::answer(fleet, Some(&writer), &body, &mut mcp_session) {
Some(value) => Response::json(&value),
None => Response::new(200, "application/json; charset=utf-8", Vec::new()),
};
if method == "initialize" {
return response.header("Mcp-Session-Id", session);
}
response
}
fn is_session_id(value: &str) -> bool {
value.len() == 16 && value.bytes().all(|b| b.is_ascii_hexdigit())
}
fn new_session() -> String {
let mut bytes = [0u8; 8];
getrandom::fill(&mut bytes).expect("the OS random source");
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
fn key(state: &Arc<State>, request: &Request) -> Response {
let body = match request.json() {
Ok(b) => b,
Err(e) => return Response::error(400, &e.to_string()),
};
let now = body.get("now").and_then(Value::as_bool).unwrap_or(false);
let previous = crate::home::agent_key().unwrap_or_default();
let fresh = match body.get("key").and_then(Value::as_str) {
Some(key) if crate::home::is_agent_key(key) => key.to_string(),
Some(_) => return Response::error(400, "that is not an agent key"),
None => match crate::home::rotate_agent_key() {
Ok(key) => key,
Err(e) => return Response::error(500, &e.to_string()),
},
};
state.server.rotate_agent(&fresh, now);
let updated: Vec<String> = crate::setup::recarry_key(&previous, &fresh)
.iter()
.map(|p| p.display().to_string())
.collect();
Response::json(&json!({
"key": fresh,
"previous_valid": !now,
"updated": updated,
"stanzas": crate::clients::http_stanzas(&fresh),
}))
}
fn endpoint(state: &Arc<State>, request: &Request) -> Response {
let body = match request.json() {
Ok(b) => b,
Err(e) => return Response::error(400, &e.to_string()),
};
let Some(open) = body.get("open").and_then(Value::as_bool) else {
return Response::error(400, "missing open");
};
state.server.set_mcp_open(open);
Response::json(&json!({
"open": open,
"url": format!("http://127.0.0.1:{}{}", state.server.port(), crate::http::MCP_PATH),
}))
}
fn rotate(state: &Arc<State>) -> Response {
let fresh = state.rotate();
Response::json(&json!({ "token": fresh, "url": daemon::url(state) }))
}
fn stream(progress: std::sync::mpsc::Receiver<Value>) -> Response {
Response::stream(move |chunks| {
for event in progress {
let mut line = event.to_string();
line.push('\n');
chunks.send(&line)?;
}
Ok(())
})
}
fn filter_of(request: &Request) -> Result<Filter, String> {
Filter::new(
&request.query_all("path"),
&request.query_all("ext"),
&request.query_all("lang"),
)
.map_err(|e| e.to_string())
}
fn strings(value: &Value, key: &str) -> Vec<String> {
match value.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 language_of(path: &Path) -> &'static str {
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
LANGUAGES
.iter()
.find(|entry| {
(!ext.is_empty() && entry.extensions.contains(&ext.as_str()))
|| entry
.filenames
.iter()
.any(|pattern| matches(pattern, &name))
})
.map(|entry| entry.name)
.unwrap_or("")
}
fn matches(pattern: &str, name: &str) -> bool {
match pattern.strip_suffix('*') {
Some(prefix) => name.starts_with(prefix),
None => name == pattern,
}
}
#[allow(dead_code)]
fn named(state: &Arc<State>, name: &str) -> Option<Arc<Store>> {
state.store(name)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_file_is_labelled_with_the_reader_that_would_parse_it() {
assert_eq!(chunk::reader_of(Path::new("a/b.docx")), "word");
assert_eq!(chunk::reader_of(Path::new("a/b.ipynb")), "notebook");
assert_eq!(chunk::reader_of(Path::new("a/b.rs")), "text");
assert_eq!(chunk::reader_of(Path::new("a/b.PDF")), "pdf");
}
#[test]
fn a_file_is_labelled_with_the_language_lang_would_select() {
assert_eq!(language_of(Path::new("src/lib.rs")), "rust");
assert_eq!(language_of(Path::new("a/b.MD")), "markdown");
assert_eq!(language_of(Path::new("a/b.unknown")), "");
assert_eq!(language_of(Path::new("LICENSE")), "");
}
#[test]
fn a_path_is_taken_as_a_string_or_an_array() {
assert_eq!(strings(&json!({ "path": "a" }), "path"), vec!["a"]);
assert_eq!(
strings(&json!({ "path": ["a", "b"] }), "path"),
vec!["a", "b"]
);
assert!(strings(&json!({}), "path").is_empty());
}
}