use crate::args::Args;
use crate::failure::{Failure, R};
use crate::{brief, ops, render, store};
use serde_json::{json, Value};
use std::io::{BufRead, Write};
use std::path::PathBuf;
use std::time::SystemTime;
const PROTOCOL: &str = "2025-06-18";
struct Arg {
name: &'static str,
required: bool,
description: &'static str,
}
struct Tool {
name: &'static str,
description: &'static str,
args: &'static [Arg],
}
const TOOLS: &[Tool] = &[
Tool {
name: "vivac_brief",
description: "Where you are in this project and what NOT to touch right now: \
the focus with its lineage, the parked nodes with the reason each \
was parked for, the decisions that still govern, and the last safe \
point with what you were about to do. Read it before anything else \
when a session opens.",
args: &[],
},
Tool {
name: "vivac_find",
description: "Search the provenance tree. Returns every node whose title, reason, \
note or outcome contains all of the terms, newest first, each with \
the lineage it hangs from. Closed nodes are included: what you look \
for months later is usually finished.",
args: &[Arg {
name: "query",
required: true,
description: "Words to look for. Every one of them has to appear.",
}],
},
Tool {
name: "vivac_why",
description: "Why a node exists: the chain from the goal down to it, what is open \
in parallel, what was born from it, and what blocks it from closing. \
This is the question the whole tool exists to answer.",
args: &[Arg {
name: "id",
required: true,
description: "The node as the tree names it: g1, t12, f74, d29.",
}],
},
Tool {
name: "vivac_open",
description: "The open fronts of this project, each with its lineage: what is \
actually unfinished, rather than everything that was ever written \
down.",
args: &[],
},
];
fn schema(t: &Tool) -> Value {
let mut properties = serde_json::Map::new();
let mut required: Vec<&str> = Vec::new();
for a in t.args {
properties.insert(
a.name.to_string(),
json!({ "type": "string", "description": a.description }),
);
if a.required {
required.push(a.name);
}
}
json!({
"name": t.name,
"description": t.description,
"inputSchema": {
"type": "object",
"properties": Value::Object(properties),
"required": required,
},
})
}
struct State {
root: PathBuf,
project: String,
ctx: ops::Ctx,
seen: (u64, Option<SystemTime>),
}
fn fingerprint(log: &std::path::Path) -> (u64, Option<SystemTime>) {
match std::fs::metadata(log) {
Ok(m) => (m.len(), m.modified().ok()),
Err(_) => (0, None),
}
}
impl State {
fn open(root: PathBuf) -> Result<State, Failure> {
let project = root
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "-".into());
let ctx = ops::Ctx::load(store::Store::open(root.clone())?)?;
let seen = fingerprint(&ctx.store.log());
Ok(State {
root,
project,
ctx,
seen,
})
}
fn current(&mut self) -> Result<&ops::Ctx, Failure> {
let now = fingerprint(&self.ctx.store.log());
if now != self.seen {
self.ctx = ops::Ctx::load(store::Store::open(self.root.clone())?)?;
self.seen = now;
}
Ok(&self.ctx)
}
}
fn ok(id: &Value, result: Value) -> String {
json!({ "jsonrpc": "2.0", "id": id, "result": result }).to_string()
}
fn rpc_error(id: &Value, code: i32, message: &str) -> String {
json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } }).to_string()
}
fn tool_error(id: &Value, message: String) -> String {
ok(
id,
json!({ "content": [{ "type": "text", "text": message }], "isError": true }),
)
}
fn tool_ok(id: &Value, text: String) -> String {
ok(
id,
json!({ "content": [{ "type": "text", "text": text }], "isError": false }),
)
}
fn pretty(v: Value) -> Result<String, Failure> {
serde_json::to_string_pretty(&v).map_err(|e| Failure::Io(std::io::Error::other(e)))
}
fn argument<'a>(params: &'a Value, name: &str) -> Option<&'a str> {
params["arguments"][name].as_str()
}
fn call(state: &mut State, params: &Value) -> Result<String, Failure> {
let name = params["name"].as_str().unwrap_or_default();
let missing = |what: &str| Failure::usage(format!("{name} needs a {what}."));
match name {
"vivac_brief" => {
let empty = Args::default();
let project = state.project.clone();
let ctx = state.current()?;
brief::to_text(&ctx.tree, ctx.anchor.as_ref(), &empty, &project)
}
"vivac_find" => {
let query = argument(params, "query")
.ok_or_else(|| missing("query"))?
.to_string();
pretty(render::find_data(&state.current()?.tree, &query)?)
}
"vivac_why" => {
let id = argument(params, "id")
.ok_or_else(|| missing("id"))?
.to_string();
pretty(render::why_data(&state.current()?.tree, &id)?)
}
"vivac_open" => pretty(render::open_data(&state.current()?.tree)),
other => Err(Failure::usage(format!(
"no such tool: {other}. This server has: {}",
TOOLS.iter().map(|t| t.name).collect::<Vec<_>>().join(", ")
))),
}
}
fn handle(state: &mut State, line: &str) -> Option<String> {
let message: Value = match serde_json::from_str(line) {
Ok(v) => v,
Err(e) => {
return Some(rpc_error(
&Value::Null,
-32700,
&format!("that line is not JSON: {e}"),
))
}
};
let id = message.get("id").cloned()?;
let method = message["method"].as_str().unwrap_or_default();
let params = message.get("params").cloned().unwrap_or(json!({}));
match method {
"initialize" => {
let version = params["protocolVersion"].as_str().unwrap_or(PROTOCOL);
Some(ok(
&id,
json!({
"protocolVersion": version,
"capabilities": { "tools": {} },
"serverInfo": { "name": "vivac", "version": env!("CARGO_PKG_VERSION") },
}),
))
}
"ping" => Some(ok(&id, json!({}))),
"tools/list" => Some(ok(
&id,
json!({ "tools": TOOLS.iter().map(schema).collect::<Vec<_>>() }),
)),
"tools/call" => Some(match call(state, ¶ms) {
Ok(text) => tool_ok(&id, text),
Err(e) => tool_error(&id, e.message()),
}),
other => Some(rpc_error(
&id,
-32601,
&format!("this server does not do {other}"),
)),
}
}
pub fn serve(root: PathBuf) -> R {
let mut state = State::open(root)?;
let input = std::io::stdin();
let mut output = std::io::stdout();
for line in input.lock().lines() {
let line = line.map_err(Failure::Io)?;
if line.trim().is_empty() {
continue;
}
if let Some(reply) = handle(&mut state, &line) {
writeln!(output, "{reply}").map_err(Failure::Io)?;
output.flush().map_err(Failure::Io)?;
}
}
Ok(())
}