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;
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(),
(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/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/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().expect("the fleet lock");
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(),
"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");
if let Err(e) = state.open_fleet() {
return Response::error(500, &e.to_string());
}
let mut fleet = state.fleet.lock().expect("the fleet lock");
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 offset = request
.query("offset")
.and_then(|v| v.parse::<i64>().ok())
.unwrap_or(0)
.clamp(0, i64::from(u32::MAX));
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": 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");
if let Err(e) = state.open_fleet() {
return Response::error(500, &e.to_string());
}
let mut fleet = state.fleet.lock().expect("the fleet lock");
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 started = std::time::Instant::now();
let only = (!only.is_empty()).then_some(only);
let hits = match fleet.search_in(only.as_deref(), query, k, &filter) {
Ok(h) => h,
Err(e) => return Response::error(500, &e.to_string()),
};
let elapsed = started.elapsed();
let single = fleet.len() == 1;
let label = fleet.labels().first().map(|s| s.to_string());
let out: Vec<Value> = hits
.iter()
.map(|h| {
json!({
"score": h.score,
"path": h.path,
"start_line": h.start_line,
"end_line": h.end_line,
"text": h.text,
"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 })),
})
})
.collect();
if state.ledger {
record(fleet, "portal", query, &hits, elapsed);
}
Response::json(&json!({
"hits": out,
"selected": selected,
"chunks": fleet.chunks(),
"micros": elapsed.as_micros() as u64,
}))
}
fn record(
fleet: &crate::fleet::Fleet,
client: &str,
query: &str,
hits: &[crate::Hit],
elapsed: std::time::Duration,
) {
const CHARS_PER_TOKEN: i64 = 4;
let excerpt: i64 = hits.iter().map(|h| h.text.len() as i64).sum::<i64>() / CHARS_PER_TOKEN;
for (label, store) in fleet.each() {
let mine: Vec<&crate::Hit> = hits
.iter()
.filter(|h| h.store.as_deref().is_none_or(|s| s == label))
.collect();
if mine.is_empty() {
continue;
}
let paths: std::collections::BTreeSet<&str> =
mine.iter().map(|h| h.path.as_str()).collect();
let whole: i64 = paths
.iter()
.filter_map(|p| std::fs::metadata(p).ok())
.map(|m| m.len() as i64 / CHARS_PER_TOKEN)
.sum();
let _ = store::record_retrieval(
store.db(),
client,
query,
mine.len() as i64,
elapsed.as_micros() as i64,
excerpt,
whole,
);
}
}
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 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,
}))
})
}
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) = std::fs::read_dir(crate::model_cache_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()
}
fn image_file(state: &Arc<State>, request: &Request) -> Response {
let Some(want) = request.query("path") else {
return Response::error(400, "missing path");
};
if let Err(e) = state.open_fleet() {
return Response::error(500, &e.to_string());
}
let mut fleet = state.fleet.lock().expect("the fleet lock");
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![want],
|_| Ok(()),
)
.is_ok()
});
if !indexed {
return Response::error(404, "no such image");
}
let path = Path::new(want);
let Ok(bytes) = std::fs::read(path) else {
return Response::error(404, "the file is no longer on disk");
};
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 = std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/"));
let home = crate::canonical(&home);
let asked = request
.query("path")
.filter(|p| !p.is_empty())
.map(PathBuf::from)
.unwrap_or_else(|| home.clone());
let resolved = crate::canonical(&asked);
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();
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_cookie": crate::http::TOKEN_COOKIE,
"token_preview": preview(&state.server.token()),
"host_allowed": ["localhost", "127.0.0.1", "::1"],
"csp": "default-src 'self'",
"cors": false,
"store_home": home::home().display().to_string(),
}))
}
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": home::home().display().to_string(),
"model_cache": crate::model_cache_dir().display().to_string(),
"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 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<_>>(),
"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": key,
"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().expect("the fleet lock");
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 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.symbols_in(only.as_deref(), &name, k)?;
Ok(json!({ "symbols": found }))
})
}
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 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
)?))
})
}
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");
with_fleet(state, json!({ "path": null }), move |fleet| {
let only = (!only.is_empty()).then_some(only);
Ok(json!({ "path": fleet.path_in(only.as_deref(), &from, &to, depth)? }))
})
}
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 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"),
};
if single {
return Response::json(&value);
}
let chunks = value.get("forgot").and_then(Value::as_i64).unwrap_or(0);
if chunks == 0 && value.get("images").and_then(Value::as_i64).unwrap_or(0) == 0 {
missing.push(path.clone());
}
forgot += chunks;
images += value.get("images").and_then(Value::as_i64).unwrap_or(0);
}
let kept = paths.len() - missing.len();
Response::json(&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" },
),
}))
}
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 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")
.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().expect("the mcp fleet lock");
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 response = match crate::mcp::answer(fleet, Some(&writer), &body) {
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 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) })).with_token(&fresh)
}
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());
}
}