use std::path::Path;
use uuid::Uuid;
use crate::config::Config;
use crate::error::{Error, Result};
use crate::project::ProjectLayout;
use crate::store::graph::EndpointRef;
use crate::store::hierarchy::Hierarchy;
use crate::store::Store;
fn parse_uuid(s: &str, what: &str) -> Result<Uuid> {
Uuid::parse_str(s).map_err(|e| Error::Config(format!("invalid {what} uuid `{s}`: {e}")))
}
fn open(project: &Path) -> Result<Store> {
Ok(open_with_cfg(project)?.0)
}
fn open_with_cfg(project: &Path) -> Result<(Store, Config)> {
let layout = ProjectLayout::new(project);
layout.require_initialized()?;
let cfg = Config::load_layered(&layout.config_path())?;
let store = Store::open(layout, &cfg)?;
Ok((store, cfg))
}
pub fn stats(project: &Path) -> Result<()> {
let store = open(project)?;
let s = store.graph_stats()?;
println!("nodes: {}", s.nodes);
println!("edges: {}", s.edges);
if s.by_kind.is_empty() {
println!("(no edges yet — the graph is populated by the SEMNET migrations, P1+)");
} else {
println!("by kind:");
for (kind, n) in &s.by_kind {
println!(" {kind:<16} {n}");
}
}
Ok(())
}
pub fn rebuild(project: &Path) -> Result<()> {
let (store, cfg) = open_with_cfg(project)?;
let r = store.graph_rebuild(&cfg)?;
println!("graph rebuild: cleared {} derivable edge(s), re-derived {}", r.cleared, r.added);
let s = store.graph_stats()?;
println!("graph now holds {} edge(s) across {} node(s)", s.edges, s.nodes);
if !s.by_kind.is_empty() {
for (kind, n) in &s.by_kind {
println!(" {kind:<16} {n}");
}
}
Ok(())
}
pub fn contradicting(project: &Path, node: &str) -> Result<()> {
let store = open(project)?;
let id = parse_uuid(node, "node")?;
let edges = store.contradicting(id)?;
if edges.is_empty() {
println!("no contradictions recorded for {id}");
return Ok(());
}
let here = EndpointRef::Node(id);
for e in &edges {
let (k, r) = e.other_endpoint(&here).as_columns();
let reason = e.reason.as_deref().unwrap_or("");
let sep = if reason.is_empty() { "" } else { " — " };
println!("{} [{}·{}] {k}:{r}{sep}{reason}", e.id, e.kind.as_str(), e.origin.as_str());
}
Ok(())
}
pub fn promote(project: &Path, edge: &str) -> Result<()> {
let store = open(project)?;
let id = parse_uuid(edge, "edge")?;
if store.promote_edge(id)? {
println!("promoted edge {id} (kept across rebuilds)");
} else {
println!("no edge with id {id}");
}
Ok(())
}
pub fn dismiss(project: &Path, edge: &str) -> Result<()> {
let store = open(project)?;
let id = parse_uuid(edge, "edge")?;
store.dismiss_edge(id)?;
println!("dismissed edge {id}");
Ok(())
}
pub fn neighbors(project: &Path, node: &str) -> Result<()> {
let store = open(project)?;
let id = parse_uuid(node, "node")?;
let edges = store.subgraph(id, 1, &[])?;
let h = Hierarchy::load(&store)?;
let label = |ep: &EndpointRef| -> String {
match ep {
EndpointRef::Node(u) => h
.get(*u)
.map(|n| n.title.clone())
.filter(|t| !t.trim().is_empty())
.unwrap_or_else(|| format!("node {}", &u.to_string()[..8])),
EndpointRef::Extern(_) => {
let (k, r) = ep.as_columns();
format!("{k} {r}")
}
}
};
print!("{}", crate::store::graph::render_neighbourhood(id, &edges, label));
Ok(())
}
fn endpoint_label(ep: &EndpointRef, h: &Hierarchy) -> String {
match ep {
EndpointRef::Node(u) => h
.get(*u)
.map(|n| n.title.clone())
.filter(|t| !t.trim().is_empty())
.unwrap_or_else(|| format!("node {}", &u.to_string()[..8])),
EndpointRef::Extern(_) => {
let (k, r) = ep.as_columns();
format!("{k} {r}")
}
}
}
pub fn link(project: &Path, node: &str) -> Result<()> {
let (store, cfg) = open_with_cfg(project)?;
let id = parse_uuid(node, "node")?;
let ai = crate::ai::AiClient::from_config(&cfg.llm)
.map_err(|e| Error::Config(format!("LLM provider: {e:#}")))?;
let (model, _env) = ai
.resolve_provider(&cfg.llm, None)
.map_err(|e| Error::Config(format!("LLM provider: {e:#}")))?;
let h = Hierarchy::load(&store)?;
let Some(facts_book) = h
.iter()
.find(|n| n.system_tag.as_deref() == Some(crate::store::SYSTEM_TAG_FACTS))
.map(|n| n.id)
else {
return Err(Error::Config("this project has no Facts book to link against".into()));
};
let body = store
.raw()
.get_content(id)
.map_err(|e| Error::Store(e.to_string()))?
.map(|b| String::from_utf8_lossy(&b).into_owned())
.ok_or_else(|| Error::Config(format!("no content for node {id}")))?;
let iso = crate::ai::prompts::iso_from_long(&cfg.language);
let language = crate::inner_editor::prompt::language_name(iso).to_string();
let n = crate::research::graph_link::link_fact(
&store, &h, &cfg, &ai, &model, facts_book, id, &body, &language,
);
if n == 0 {
println!("no stance edges proposed (no related facts, or all silent)");
} else {
println!("proposed {n} advisory edge(s) — triage with `graph pending` (`graph promote`/`dismiss`)");
}
Ok(())
}
pub fn pending(project: &Path) -> Result<()> {
let store = open(project)?;
let edges = store.pending_edges()?;
if edges.is_empty() {
println!("no pending edges — the graph's advisory layer is clear");
return Ok(());
}
let h = Hierarchy::load(&store)?;
println!(
"{} pending edge(s) — `graph promote <id>` to keep (across rebuilds), `graph dismiss <id>` to reject:",
edges.len()
);
for e in &edges {
let src = endpoint_label(&e.src, &h);
let dst = endpoint_label(&e.dst, &h);
let reason = e
.reason
.as_deref()
.filter(|r| !r.is_empty())
.map(|r| format!(" — {r}"))
.unwrap_or_default();
println!(" {} [{}] {src} ⇢ {dst}{reason}", e.id, e.kind.as_str());
}
Ok(())
}
pub fn loci(project: &Path, node: &str) -> Result<()> {
use crate::store::graph::EdgeKind;
let store = open(project)?;
let id = parse_uuid(node, "node")?;
let edges = store.edges_out(id, &[EdgeKind::CitesLocus])?;
if edges.is_empty() {
println!("{id} cites no primary-source loci");
return Ok(());
}
for e in &edges {
let (_k, r) = e.dst.as_columns();
let key = e.attrs.get("key").and_then(|v| v.as_str()).unwrap_or("");
println!("@{key} {r}");
}
Ok(())
}
pub fn ask(project: &Path, question: &str) -> Result<()> {
let (store, cfg) = open_with_cfg(project)?;
let question = question.trim();
if question.is_empty() {
return Err(Error::Config(
"give a question, e.g. `graph ask \"what contradicts the storm scene?\"`".into(),
));
}
let ai = crate::ai::AiClient::from_config(&cfg.llm)
.map_err(|e| Error::Config(format!("LLM provider: {e:#}")))?;
let (model, _env) = ai
.resolve_provider(&cfg.llm, None)
.map_err(|e| Error::Config(format!("LLM provider: {e:#}")))?;
let h = Hierarchy::load(&store)?;
let iso = crate::ai::prompts::iso_from_long(&cfg.language);
let system = crate::graph_rag::ask::system_prompt(iso).to_string();
let oracle = crate::graph_rag::oracle::StoreOracle { store: &store, hierarchy: &h };
let max_steps = cfg.graph.ask_max_steps.max(1);
let search_limit = cfg.graph.ask_search_width.max(1);
let client = ai.client.clone();
let modelname = model.to_string();
eprintln!("» graph ask: {question}");
let outcome = crate::graph_rag::ask::ask(
&oracle,
|prompt| {
crate::ai::stream::collect_blocking(
client.clone(),
modelname.clone(),
Some(system.clone()),
prompt.to_string(),
)
},
question,
max_steps,
search_limit,
)
.map_err(|e| Error::Config(format!("graph ask: {e}")))?;
for s in &outcome.steps {
eprintln!("{s}");
}
eprintln!(
"» {} step(s), {} model turn(s){}",
outcome.steps.len(),
outcome.llm_calls,
if outcome.forced { " · forced final answer (step budget spent)" } else { "" }
);
println!("{}", outcome.answer);
Ok(())
}
pub fn lexical(project: &Path) -> Result<()> {
let (store, cfg) = open_with_cfg(project)?;
let r = store.rebuild_lexical(&cfg)?;
if !r.installed {
let code = crate::ai::prompts::iso_from_long(&cfg.language);
println!("no `{code}` wordnet installed — run `inkhaven wordnet fetch {code}` first");
return Ok(());
}
println!("lexical bridge: cleared {} prior edge(s), imported {}", r.cleared, r.added);
let s = store.graph_stats()?;
for (kind, n) in &s.by_kind {
println!(" {kind:<16} {n}");
}
Ok(())
}
pub fn paths(project: &Path, from: &str, to: &str) -> Result<()> {
use crate::store::graph::EdgeKind;
let store = open(project)?;
let a = parse_uuid(from, "from")?;
let b = parse_uuid(to, "to")?;
match store.paths(a, b, &[EdgeKind::Cites, EdgeKind::LinksTo], 8)? {
Some(path) => {
let hops = path.len().saturating_sub(1);
println!("path found ({hops} hop(s)):");
for id in &path {
println!(" {id}");
}
}
None => println!("no path from {a} to {b} within 8 hops"),
}
Ok(())
}