use std::collections::HashMap;
use std::io::Read;
use serde_json::{json, Value};
use tiny_http::{Header, Method, Request, Response};
use super::assets;
use crate::KnowledgeBase;
pub(crate) struct Ctx {
pub kb: KnowledgeBase,
pub token: Option<String>,
pub bind: String,
pub port: u16,
}
pub(crate) struct Resp {
pub status: u16,
content_type: &'static str,
pub body: String,
}
fn json_resp(status: u16, v: Value) -> Resp {
Resp {
status,
content_type: "application/json; charset=utf-8",
body: v.to_string(),
}
}
fn err(status: u16, msg: &str) -> Resp {
json_resp(status, json!({ "error": msg }))
}
fn asset(content_type: &'static str, body: &str) -> Resp {
Resp {
status: 200,
content_type,
body: body.to_string(),
}
}
pub(crate) fn handle(ctx: &Ctx, mut request: Request) {
let method = request.method().clone();
let raw_url = request.url().to_string();
let (path, query) = split_url(&raw_url);
let mut headers: HashMap<String, String> = HashMap::new();
for h in request.headers() {
headers.insert(
h.field.as_str().as_str().to_ascii_lowercase(),
h.value.as_str().to_string(),
);
}
let mut body = String::new();
let _ = request
.as_reader()
.take(64 * 1024)
.read_to_string(&mut body);
let resp = route(ctx, &method, path, query, &headers, &body);
let header = Header::from_bytes(&b"Content-Type"[..], resp.content_type.as_bytes())
.expect("static content-type header is valid");
let response = Response::from_string(resp.body)
.with_status_code(resp.status)
.with_header(header);
let _ = request.respond(response);
}
pub(crate) fn route(
ctx: &Ctx,
method: &Method,
path: &str,
query: &str,
headers: &HashMap<String, String>,
body: &str,
) -> Resp {
let segs: Vec<&str> = path.trim_matches('/').split('/').collect();
let is_api = segs.first() == Some(&"api");
if is_api && !super::is_loopback(&ctx.bind) && !token_ok(ctx, headers) {
return err(403, "missing or invalid token");
}
match (method, segs.as_slice()) {
(Method::Get, [""]) | (Method::Get, ["index.html"]) => {
asset("text/html; charset=utf-8", assets::INDEX_HTML)
}
(Method::Get, ["app.js"]) => asset("application/javascript; charset=utf-8", assets::APP_JS),
(Method::Get, ["style.css"]) => asset("text/css; charset=utf-8", assets::STYLE_CSS),
(Method::Get, ["api", "inspect"]) => match ctx.kb.inspect() {
Ok(v) => json_resp(200, v),
Err(e) => err(500, &e.to_string()),
},
(Method::Get, ["api", "chunks"]) => list_chunks(ctx, query),
(Method::Get, ["api", "governance"]) => list_governance(ctx, query),
(Method::Get, ["api", "metrics", "trend"]) => metrics_trend(ctx, query),
(Method::Get, ["api", "llm-traces"]) => list_llm_traces(query),
(Method::Get, ["api", "search"]) => search(ctx, query),
(Method::Get, ["api", "playground"]) => playground(ctx, query),
(Method::Get, ["api", "daemon"]) => daemon_status(),
(Method::Get, ["api", "sessions"]) => list_sessions(ctx, query),
(Method::Get, ["api", "chunk", id]) => match ctx.kb.inspect_id(id) {
Ok(v) => json_resp(200, v),
Err(e) => err(404, &e.to_string()),
},
(Method::Get, ["api", "chunks", id, "provenance"]) => chunk_provenance(ctx, id, query),
(Method::Post, ["api", "chunk", id, action]) => governance(ctx, headers, id, action, body),
(Method::Post, ["api", "daemon", "restart"]) => daemon_restart(ctx, headers),
_ => err(404, "not found"),
}
}
fn metrics_trend(ctx: &Ctx, query: &str) -> Resp {
let limit = parse_query(query)
.get("limit")
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(30)
.clamp(1, 365);
match ctx.kb.storage.recent_snapshots(limit) {
Ok(rows) => json_resp(200, json!({ "snapshots": rows, "limit": limit })),
Err(e) => err(500, &e.to_string()),
}
}
fn list_chunks(ctx: &Ctx, query: &str) -> Resp {
let params = parse_query(query);
let state = params
.get("state")
.map(String::as_str)
.filter(|s| !s.is_empty());
let origin = params
.get("origin")
.map(String::as_str)
.filter(|s| !s.is_empty());
let limit = params
.get("limit")
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(50)
.min(500);
let offset = params
.get("offset")
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(0);
match ctx.kb.storage.list_chunks(state, origin, limit, offset) {
Ok(rows) => json_resp(
200,
json!({ "chunks": rows, "limit": limit, "offset": offset }),
),
Err(e) => err(500, &e.to_string()),
}
}
fn list_governance(ctx: &Ctx, query: &str) -> Resp {
let params = parse_query(query);
let state = params
.get("state")
.map(String::as_str)
.filter(|s| !s.is_empty())
.unwrap_or("pending");
let limit = params
.get("limit")
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(100)
.min(500);
match ctx.kb.storage.list_governance_proposals(state, limit) {
Ok(rows) => json_resp(200, json!({ "proposals": rows, "state": state })),
Err(e) => err(500, &e.to_string()),
}
}
fn list_llm_traces(query: &str) -> Resp {
let params = parse_query(query);
let kind = params
.get("kind")
.map(String::as_str)
.filter(|s| !s.is_empty());
let status = params
.get("status")
.map(String::as_str)
.filter(|s| !s.is_empty());
let limit = params
.get("limit")
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(200)
.min(2000);
match crate::llm_trace::read_recent(limit, kind, status) {
Ok(traces) => json_resp(200, json!({ "traces": traces, "limit": limit })),
Err(e) => err(500, &e.to_string()),
}
}
fn search(ctx: &Ctx, query: &str) -> Resp {
let params = parse_query(query);
let q = params.get("q").map(String::as_str).unwrap_or("").trim();
if q.is_empty() {
return json_resp(200, json!({ "query": "", "results": [] }));
}
let limit = params
.get("limit")
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(20)
.clamp(1, 100);
let hits = match ctx.kb.storage.search_lexical(q, limit) {
Ok(h) => h,
Err(e) => return err(500, &e.to_string()),
};
let ids: Vec<&str> = hits.iter().map(|(id, _)| id.as_str()).collect();
let chunks = match ctx.kb.storage.get_chunks_by_ids(&ids) {
Ok(c) => c,
Err(e) => return err(500, &e.to_string()),
};
let results: Vec<Value> = hits
.iter()
.filter_map(|(id, score)| {
chunks.get(id).map(|chunk| {
let r3 = (f64::from(*score) * 1000.0).round() / 1000.0;
json!({ "score": r3, "chunk": chunk })
})
})
.collect();
json_resp(200, json!({ "query": q, "results": results, "limit": limit }))
}
fn playground(ctx: &Ctx, query: &str) -> Resp {
let params = parse_query(query);
let q = params.get("q").map(String::as_str).unwrap_or("").trim();
if q.is_empty() {
return json_resp(200, json!({ "query": "", "knowledge": [], "sparks": [] }));
}
let budget = params
.get("budget")
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(6000)
.clamp(200, 60_000);
let top = params.get("top").and_then(|v| v.parse::<usize>().ok());
let include_sparks = params
.get("include_sparks")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
let result = ctx.kb.recall(crate::kb::RecallParams {
query: q,
budget,
trace: false,
include_sparks,
top,
source: "cli",
expand_deps: "false",
allow_trim: false,
refine_mode: "off",
min_score: None,
session_only: false,
rerank: false,
});
match result {
Ok(r) => json_resp(
200,
json!({
"query": q,
"trace_id": r.trace_id,
"empty": r.empty,
"knowledge": r.knowledge,
"sparks": r.sparks,
}),
),
Err(e) => err(500, &e.to_string()),
}
}
fn daemon_status() -> Resp {
let health = crate::daemon::health(
&crate::paths::daemon_state_path(),
&crate::paths::daemon_pid_path(),
&crate::utils::utc_now_iso(),
);
json_resp(200, health)
}
fn daemon_restart(ctx: &Ctx, headers: &HashMap<String, String>) -> Resp {
if !origin_ok(ctx, headers) {
return err(403, "cross-origin request rejected");
}
if !token_ok(ctx, headers) {
return err(403, "missing or invalid token");
}
if !cfg!(target_os = "linux") {
return err(400, "the daemon is only supported on Linux");
}
let pid_file = crate::paths::daemon_pid_path();
if crate::daemon::is_running(&pid_file) {
if let Err(e) = crate::daemon::stop(&pid_file) {
return err(500, &format!("failed to stop daemon: {e}"));
}
}
let settings = crate::settings::load().unwrap_or_default();
let mut watch_dirs: Vec<std::path::PathBuf> = crate::settings::resolved_watch_dirs(&settings)
.into_iter()
.map(std::path::PathBuf::from)
.collect();
if watch_dirs.is_empty() {
watch_dirs.push(crate::paths::sessions_dir());
}
if let Err(e) = crate::daemon::start(
&watch_dirs,
&ctx.kb.storage.db_path,
&pid_file,
&crate::paths::daemon_state_path(),
&crate::paths::daemon_log_path(),
) {
return err(500, &format!("failed to start daemon: {e}"));
}
json_resp(
200,
crate::daemon::health(
&crate::paths::daemon_state_path(),
&pid_file,
&crate::utils::utc_now_iso(),
),
)
}
fn chunk_provenance(ctx: &Ctx, id: &str, query: &str) -> Resp {
let limit = parse_query(query)
.get("limit")
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(20)
.clamp(1, 100);
match ctx.kb.storage.chunk_provenance(id, limit) {
Ok(v) => json_resp(200, v),
Err(e) => err(500, &e.to_string()),
}
}
fn list_sessions(ctx: &Ctx, query: &str) -> Resp {
let limit = parse_query(query)
.get("limit")
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(50)
.clamp(1, 200);
match ctx.kb.storage.recent_episodic_logs(limit) {
Ok(rows) => json_resp(200, json!({ "sessions": rows, "limit": limit })),
Err(e) => err(500, &e.to_string()),
}
}
fn governance(
ctx: &Ctx,
headers: &HashMap<String, String>,
id: &str,
action: &str,
body: &str,
) -> Resp {
if !origin_ok(ctx, headers) {
return err(403, "cross-origin request rejected");
}
if !token_ok(ctx, headers) {
return err(403, "missing or invalid token");
}
let reason = serde_json::from_str::<Value>(body)
.ok()
.and_then(|v| v.get("reason").and_then(|r| r.as_str()).map(String::from))
.unwrap_or_default();
let result = match action {
"approve" => ctx.kb.approve(id),
"restore" => ctx.kb.restore(id),
"archive" => {
if reason.trim().is_empty() {
return err(400, "archive requires a non-empty reason");
}
ctx.kb.archive(id, &reason)
}
"invalidate" => {
if reason.trim().is_empty() {
return err(400, "invalidate requires a non-empty reason");
}
ctx.kb.invalidate(id, &reason)
}
_ => return err(404, "unknown governance action"),
};
match result {
Ok(()) => json_resp(200, json!({ "ok": true, "id": id, "action": action })),
Err(e) => err(400, &e.to_string()),
}
}
fn token_ok(ctx: &Ctx, headers: &HashMap<String, String>) -> bool {
match &ctx.token {
None => true, Some(expected) => headers
.get("x-innate-token")
.is_some_and(|got| got == expected),
}
}
fn origin_ok(ctx: &Ctx, headers: &HashMap<String, String>) -> bool {
match headers.get("origin") {
None => true,
Some(o) => {
let mut allowed = vec![
format!("http://127.0.0.1:{}", ctx.port),
format!("http://localhost:{}", ctx.port),
format!("http://{}:{}", ctx.bind, ctx.port),
];
if let Some(host) = headers.get("host") {
allowed.push(format!("http://{host}"));
allowed.push(format!("https://{host}"));
}
allowed.iter().any(|a| a == o)
}
}
}
fn split_url(url: &str) -> (&str, &str) {
match url.split_once('?') {
Some((p, q)) => (p, q),
None => (url, ""),
}
}
fn parse_query(query: &str) -> HashMap<String, String> {
let mut map = HashMap::new();
for pair in query.split('&').filter(|s| !s.is_empty()) {
let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
map.insert(url_decode(k), url_decode(v));
}
map
}
fn url_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'+' => out.push(b' '),
b'%' if i + 2 < bytes.len() => {
let hi = (bytes[i + 1] as char).to_digit(16);
let lo = (bytes[i + 2] as char).to_digit(16);
if let (Some(h), Some(l)) = (hi, lo) {
out.push((h * 16 + l) as u8);
i += 3;
continue;
}
out.push(b'%');
}
b => out.push(b),
}
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}