use clap::{Parser, Subcommand};
mod config;
mod init;
mod review;
#[derive(Parser)]
#[command(
name = "roteiro",
version,
about = "Provenance-tagged codebase knowledge graph",
long_about = "Roteiro — the pilot book for your codebase.\n\n\
One SQLite store holding structure, intent, and context as a single \
provenance-tagged knowledge graph, queryable by humans and AI agents \
alike. Subcommands are scaffolds while the graph core lands; see \
ADR-0001 and docs/BUILD_PLAN.md for the roadmap.",
arg_required_else_help = true,
propagate_version = true
)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Init {
#[arg(long)]
fetch: bool,
},
Sync {
#[arg(long)]
json: bool,
#[arg(long)]
committed: bool,
},
Review {
#[arg(long)]
json: bool,
#[arg(long)]
base: Option<String>,
},
Check {
#[arg(long)]
json: bool,
#[arg(long, conflicts_with = "staged")]
committed: bool,
#[arg(long)]
staged: bool,
},
Query {
key: Option<String>,
#[arg(long, conflicts_with = "key")]
kind: Option<String>,
#[arg(long)]
json: bool,
},
Context {
key: Option<String>,
#[arg(long, conflicts_with = "key")]
refresh: bool,
#[arg(long)]
json: bool,
},
Config {
#[arg(long)]
json: bool,
},
Debt {
#[arg(long, value_name = "CATEGORY")]
kind: Vec<String>,
#[arg(long)]
json: bool,
},
Path {
from: String,
to: String,
#[arg(long)]
json: bool,
},
Export {
#[arg(long)]
out: Option<String>,
},
Load {
file: String,
#[arg(long)]
force: bool,
},
Import {
#[arg(long)]
from: String,
path: String,
#[arg(long)]
json: bool,
},
Render {
target: String,
#[arg(long)]
out: Option<String>,
},
Spec {
#[command(subcommand)]
action: SpecAction,
},
#[cfg(feature = "inference")]
Infer {
#[arg(long)]
min_confidence: Option<f64>,
#[arg(long)]
top_k: Option<usize>,
#[arg(long, value_name = "NAME")]
model: Option<String>,
#[arg(long)]
json: bool,
},
#[cfg(feature = "inference")]
#[command(visible_alias = "dup")]
Duplicates {
#[arg(long)]
min_similarity: Option<f64>,
#[arg(long)]
limit: Option<usize>,
#[arg(long)]
json: bool,
},
#[cfg(feature = "models")]
Model {
#[command(subcommand)]
action: ModelAction,
},
#[cfg(any(feature = "mcp", feature = "serve"))]
Serve {
#[arg(long)]
models: bool,
#[arg(long, value_name = "ADDR")]
http: Option<String>,
#[arg(long, value_name = "ADDR")]
addr: Option<String>,
#[arg(long, value_name = "FILE")]
tls_cert: Option<String>,
#[arg(long, value_name = "FILE")]
tls_key: Option<String>,
},
}
#[derive(Subcommand)]
enum SpecAction {
Context {
topic: String,
#[arg(long, default_value_t = 10)]
limit: usize,
#[arg(long)]
json: bool,
},
Scaffold {
topic: String,
#[arg(long)]
title: Option<String>,
#[arg(long, default_value = "adr")]
kind: String,
#[arg(long)]
out: Option<String>,
},
Draft {
topic: String,
#[arg(long)]
title: Option<String>,
#[arg(long, default_value = "adr")]
kind: String,
#[arg(long)]
out: Option<String>,
},
}
#[cfg(feature = "models")]
#[derive(Subcommand)]
enum ModelAction {
List,
Pull {
name: String,
#[arg(long)]
yes: bool,
},
}
fn expand_tilde(path: &str) -> std::path::PathBuf {
let home = || std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"));
if path == "~"
&& let Some(h) = home()
{
return std::path::PathBuf::from(h);
}
if let Some(rest) = path.strip_prefix("~/")
&& let Some(h) = home()
{
return std::path::Path::new(&h).join(rest);
}
std::path::PathBuf::from(path)
}
fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
let cwd = std::env::current_dir()?;
let cfg = config::load(&cwd)?;
let ingest = cfg.effective.ingest.resolve();
let debt_ignore: &[String] = cfg.effective.debt.ignore.as_deref().unwrap_or(&[]);
if let Some(dir) = cfg.effective.paths.model_store.as_deref() {
let dir = expand_tilde(dir);
#[cfg(feature = "models")]
rto_graph::set_model_store(dir);
#[cfg(not(feature = "models"))]
{
let _ = dir;
eprintln!(
"warning: `[paths] model_store` is set but this build lacks the \
`models` feature; the setting has no effect"
);
}
}
match cli.command {
Command::Sync { json, committed } => run_sync(ingest, json, committed),
Command::Check {
json,
committed,
staged,
} => run_check(ingest, json, committed, staged, debt_ignore),
Command::Review { json, base } => run_review(ingest, json, base.as_deref()),
Command::Query { key, kind, json } => run_query(ingest, key, kind, json),
Command::Context { key, refresh, json } => run_context(ingest, key, refresh, json),
Command::Debt { kind, json } => run_debt(ingest, &kind, json, debt_ignore),
Command::Path { from, to, json } => run_path(ingest, &from, &to, json),
Command::Export { out } => run_export(ingest, out),
Command::Load { file, force } => run_load(&file, force),
Command::Init { fetch } => run_init(ingest, fetch),
Command::Render { target, out } => run_render(ingest, &target, out),
Command::Import { from, path, json } => run_import(ingest, &from, &path, json),
Command::Spec { action } => run_spec(&cfg.effective, ingest, action),
Command::Config { json } => run_config(&cfg, json),
#[cfg(feature = "inference")]
Command::Infer {
min_confidence,
top_k,
model,
json,
} => run_infer(&cfg.effective, ingest, min_confidence, top_k, model, json),
#[cfg(feature = "inference")]
Command::Duplicates {
min_similarity,
limit,
json,
} => run_duplicates(&cfg.effective, ingest, min_similarity, limit, json),
#[cfg(feature = "models")]
Command::Model { action } => run_model(action),
#[cfg(any(feature = "mcp", feature = "serve"))]
Command::Serve {
models,
http,
addr,
tls_cert,
tls_key,
} => run_serve(
ingest,
&cfg.effective,
models,
http,
addr,
tls_cert,
tls_key,
),
}
}
fn provenance(proj: bool, usr: bool) -> &'static str {
if proj {
"project"
} else if usr {
"user"
} else {
"default"
}
}
fn emit_json<T: serde::Serialize>(value: &T) -> anyhow::Result<()> {
println!("{}", serde_json::to_string_pretty(value)?);
Ok(())
}
fn run_config(loaded: &config::Loaded, json: bool) -> anyhow::Result<()> {
if json {
emit_json(&loaded.effective)?;
return Ok(());
}
println!(
"project config: {}",
loaded
.project_path
.as_deref()
.map_or_else(|| "(none)".to_owned(), |p| p.display().to_string())
);
println!(
"user config: {}",
loaded
.user_path
.as_deref()
.map_or_else(|| "(none)".to_owned(), |p| p.display().to_string())
);
print_config_sections(loaded);
println!("\n(unset values fall back to built-in defaults; a CLI flag overrides config)");
Ok(())
}
fn print_config_sections(loaded: &config::Loaded) {
let source = provenance;
let e = &loaded.effective;
let (p, u) = (&loaded.project, &loaded.user);
println!("\n[models]");
println!(
" embedding = {:?} ({})",
e.models.embedding,
source(p.models.embedding.is_some(), u.models.embedding.is_some())
);
println!(
" generative = {:?} ({})",
e.models.generative,
source(p.models.generative.is_some(), u.models.generative.is_some())
);
println!("[infer]");
println!(
" min_confidence = {:?} ({})",
e.infer.min_confidence,
source(
p.infer.min_confidence.is_some(),
u.infer.min_confidence.is_some()
)
);
println!(
" top_k = {:?} ({})",
e.infer.top_k,
source(p.infer.top_k.is_some(), u.infer.top_k.is_some())
);
println!("[duplicates]");
println!(
" min_similarity = {:?} ({})",
e.duplicates.min_similarity,
source(
p.duplicates.min_similarity.is_some(),
u.duplicates.min_similarity.is_some()
)
);
println!(
" limit = {:?} ({})",
e.duplicates.limit,
source(p.duplicates.limit.is_some(), u.duplicates.limit.is_some())
);
println!("[ingest]");
println!(
" prose = {:?} ({})",
e.ingest.prose,
source(p.ingest.prose.is_some(), u.ingest.prose.is_some())
);
println!(
" pdf = {:?} ({})",
e.ingest.pdf,
source(p.ingest.pdf.is_some(), u.ingest.pdf.is_some())
);
println!(
" ocr = {:?} ({})",
e.ingest.ocr,
source(p.ingest.ocr.is_some(), u.ingest.ocr.is_some())
);
println!(
" vision = {:?} ({})",
e.ingest.vision,
source(p.ingest.vision.is_some(), u.ingest.vision.is_some())
);
println!(
" audio = {:?} ({})",
e.ingest.audio,
source(p.ingest.audio.is_some(), u.ingest.audio.is_some())
);
println!("[serve]");
println!(
" addr = {:?} ({})",
e.serve.addr,
source(p.serve.addr.is_some(), u.serve.addr.is_some())
);
println!(
" models = {:?} ({})",
e.serve.models,
source(p.serve.models.is_some(), u.serve.models.is_some())
);
println!(
" tools = {:?} ({})",
e.serve.tools,
source(p.serve.tools.is_some(), u.serve.tools.is_some())
);
}
fn run_sync(
ingest: rto_graph::IngestConfig,
json: bool,
committed_only: bool,
) -> anyhow::Result<()> {
use rto_graph::{ObjectCache, Registry, Repo, Store, sync, sync_worktree};
let cwd = std::env::current_dir()?;
let repo = Repo::discover(&cwd)?;
let store_dir = repo.git_dir().join("roteiro");
std::fs::create_dir_all(&store_dir)?;
let mut store = Store::open(&store_dir.join("graph.db"))?;
let cache = ObjectCache::open(repo.common_dir().join("roteiro").join("objects"))?;
let registry = Registry::new(ingest);
let report = if committed_only {
sync(&mut store, &repo, &cache, ®istry)?
} else {
sync_worktree(&mut store, &repo, &cache, ®istry)?
};
if json {
emit_json(&report)?;
} else {
let tree = &report.tree[..report.tree.len().min(12)];
let dirty = if report.blobs_dirty > 0 {
format!(" +{} uncommitted", report.blobs_dirty)
} else {
String::new()
};
if report.no_op {
println!(
"up to date (tree {tree}{dirty}) — {} nodes, {} edges",
report.nodes, report.edges
);
} else {
println!(
"synced tree {tree}{dirty} — {} blobs ({} extracted, {} cached) → {} nodes, {} edges",
report.blobs_total,
report.blobs_extracted,
report.blobs_cached,
report.nodes,
report.edges
);
}
}
Ok(())
}
fn open_graph() -> anyhow::Result<(rto_graph::Repo, rto_graph::Store, rto_graph::ObjectCache)> {
use rto_graph::{ObjectCache, Repo, Store};
let cwd = std::env::current_dir()?;
let repo = Repo::discover(&cwd)?;
let store_dir = repo.git_dir().join("roteiro");
std::fs::create_dir_all(&store_dir)?;
let store = Store::open(&store_dir.join("graph.db"))?;
let cache = ObjectCache::open(repo.common_dir().join("roteiro").join("objects"))?;
Ok((repo, store, cache))
}
fn read_source(
repo: &rto_graph::Repo,
blob: &rto_graph::BlobRef,
source: GraphSource,
) -> anyhow::Result<Option<Vec<u8>>> {
match source {
GraphSource::Worktree => match repo.workdir() {
Some(workdir) => match std::fs::read(workdir.join(&blob.path)) {
Ok(bytes) => Ok(Some(bytes)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e.into()),
},
None => Ok(Some(repo.read_blob(&blob.oid)?)),
},
GraphSource::Committed | GraphSource::Index => Ok(Some(repo.read_blob(&blob.oid)?)),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum GraphSource {
Committed,
Worktree,
Index,
}
fn build_graph(
repo: &rto_graph::Repo,
store: &mut rto_graph::Store,
cache: &rto_graph::ObjectCache,
ingest: rto_graph::IngestConfig,
source: GraphSource,
) -> anyhow::Result<rto_spec::CheckReport> {
use rto_graph::{Registry, sync, sync_index, sync_worktree};
let registry = Registry::new(ingest);
match source {
GraphSource::Committed => sync(store, repo, cache, ®istry)?,
GraphSource::Worktree => sync_worktree(store, repo, cache, ®istry)?,
GraphSource::Index => sync_index(store, repo, cache, ®istry)?,
};
let blobs = match source {
GraphSource::Index => repo.index_files()?,
GraphSource::Committed | GraphSource::Worktree => repo.walk_blobs()?,
};
let mut docs = Vec::new();
let mut blueprints = Vec::new();
let mut annotations = Vec::new();
let mut malformed = Vec::new();
for blob in blobs {
let Some(bytes) = read_source(repo, &blob, source)? else {
continue;
};
let text = String::from_utf8_lossy(&bytes);
let file = std::path::Path::new(&blob.path);
let is_md = file
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("md"));
let name = file
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default();
let is_adr = blob.path.starts_with("docs/adr/") && is_md && name != "README.md";
if is_adr {
match rto_spec::parse_adr(&blob.path, &text) {
Ok(doc) => docs.push(doc),
Err(e) => malformed.push(rto_spec::Violation {
kind: rto_spec::ViolationKind::MalformedAdr,
message: format!("{}: cannot parse ADR: {e}", blob.path),
}),
}
} else if is_md && rto_spec::is_blueprint(&blob.path, &text) {
blueprints.push(rto_spec::parse_blueprint(&blob.path, &text));
} else {
annotations.extend(rto_spec::scan_annotations(&blob.path, &text));
}
}
let mut report = rto_spec::run(store, &docs, &blueprints, &annotations)?;
report.violations.extend(malformed);
store.reapply_imports()?;
Ok(report)
}
fn run_check(
ingest: rto_graph::IngestConfig,
json: bool,
committed: bool,
staged: bool,
debt_ignore: &[String],
) -> anyhow::Result<()> {
let source = if staged {
GraphSource::Index
} else if committed {
GraphSource::Committed
} else {
GraphSource::Worktree
};
let (repo, mut store, cache) = open_graph()?;
let report = build_graph(&repo, &mut store, &cache, ingest, source)?;
if json {
emit_json(&report)?;
} else {
for v in &report.violations {
eprintln!("drift [{}]: {}", v.kind.label(), v.message);
}
println!(
"checked {} ADR(s), {} blueprint(s): {} link(s) ok, {} annotation(s) ok, {} violation(s)",
report.adrs,
report.blueprints,
report.links_ok,
report.annotations_ok,
report.violations.len(),
);
println!(
"{}",
debt_summary(&rto_graph::debt(&store, &[], debt_ignore)?)
);
}
if report.has_violations() {
std::process::exit(1);
}
Ok(())
}
fn run_review(
ingest: rto_graph::IngestConfig,
json: bool,
base: Option<&str>,
) -> anyhow::Result<()> {
let (repo, mut store, cache) = open_graph()?;
let source = if base.is_some() {
GraphSource::Committed
} else {
GraphSource::Worktree
};
let report = build_graph(&repo, &mut store, &cache, ingest, source)?;
let changed =
if let Some(base) = base {
repo.changed_between(base)?
} else {
let mut changed = repo.changed_files()?;
changed.extend(repo.untracked_files()?.into_iter().map(|path| {
rto_graph::ChangedFile {
path,
deleted: false,
}
}));
changed.sort_by(|a, b| a.path.cmp(&b.path));
changed.dedup_by(|a, b| a.path == b.path);
changed
};
let review = review::build(&store, &changed, &report.violations)?;
if json {
emit_json(&review)?;
} else {
print_review(&review, base);
}
if review.has_drift() {
std::process::exit(1);
}
Ok(())
}
fn print_review(review: &review::ReviewReport, base: Option<&str>) {
if review.changed_files == 0 {
match base {
Some(base) => println!("no changes in {base}..HEAD to review"),
None => println!("no working-tree changes to review"),
}
return;
}
for file in &review.files {
println!("\n{} [{}]", file.path, file.status);
for sym in &file.symbols {
println!(" {} {}", sym.kind, sym.name);
let show = |label: &str, keys: &[String]| {
if !keys.is_empty() {
println!(" {label}: {}", keys.join(", "));
}
};
show("called by", &sym.callers);
show("calls", &sym.callees);
show("governed by", &sym.governed_by);
if !sym.related.is_empty() {
let rel: Vec<String> = sym.related.iter().map(|r| r.node.clone()).collect();
println!(" related: {}", rel.join(", "));
}
}
if !file.debt.is_empty() {
println!(" intent-debt: {}", file.debt.len());
}
}
if !review.impacted.is_empty() {
let names: Vec<&str> = review.impacted.iter().map(|i| i.key.as_str()).collect();
println!("\nimpacted (blast radius): {}", names.join(", "));
}
if review.has_drift() {
println!("\ndrift introduced by this change:");
for d in &review.drift {
println!(" [{}] {}", d.kind, d.message);
}
} else {
println!("\nno authored-layer drift introduced");
}
println!(
"\nreviewed {} changed file(s), {} impacted node(s), {} drift item(s)",
review.changed_files,
review.impacted.len(),
review.drift.len()
);
}
fn run_init(ingest: rto_graph::IngestConfig, fetch: bool) -> anyhow::Result<()> {
let (repo, mut store, cache) = open_graph()?;
let report = build_graph(&repo, &mut store, &cache, ingest, GraphSource::Committed)?;
let nodes = store.node_count()?;
let edges = store.edge_count()?;
let hooks_dir = repo.hooks_dir();
for name in init::MANAGED_HOOKS {
match init::install_hook(&hooks_dir, name, fetch)? {
init::HookOutcome::Installed => println!("installed hook: {name}"),
init::HookOutcome::Updated => println!("refreshed hook: {name}"),
init::HookOutcome::SkippedForeign => {
let advice = if *name == "pre-commit" {
"add `roteiro check` to it to gate drift-introducing commits"
} else {
"add `roteiro sync --committed` to it to keep the graph fresh"
};
eprintln!("warning: existing non-Roteiro `{name}` hook left untouched; {advice}");
}
}
}
if let Some(workdir) = repo.workdir() {
let path = workdir.join("AGENTS.md");
if init::ensure_agents(&path)? {
println!("wrote Roteiro section to {}", path.display());
}
}
println!("roteiro initialised — graph has {nodes} nodes, {edges} edges");
if report.has_violations() {
eprintln!(
"note: {} authored-layer violation(s); run `roteiro check` for details",
report.violations.len()
);
}
Ok(())
}
#[cfg(feature = "inference-local-models")]
fn config_embedding_model(name: Option<&str>) -> Option<String> {
name.map(str::to_owned)
}
#[cfg(all(feature = "inference", not(feature = "inference-local-models")))]
fn config_embedding_model(name: Option<&str>) -> Option<String> {
if let Some(name) = name {
eprintln!(
"warning: config `[models] embedding = {name:?}` needs the \
`inference-local-models` feature; this build ignores it and uses \
the offline default (pass `--model` to force an error instead)"
);
}
None
}
#[cfg(feature = "inference")]
fn run_infer(
cfg: &config::Config,
ingest: rto_graph::IngestConfig,
min_confidence: Option<f64>,
top_k: Option<usize>,
model: Option<String>,
json: bool,
) -> anyhow::Result<()> {
use rto_graph::{FactSet, InferenceConfig};
let min_confidence = min_confidence.or(cfg.infer.min_confidence).unwrap_or(0.4);
let top_k = top_k.or(cfg.infer.top_k).unwrap_or(5);
let model = model.or_else(|| config_embedding_model(cfg.models.embedding.as_deref()));
if !(0.0..=1.0).contains(&min_confidence) {
anyhow::bail!("--min-confidence must be in 0.0..=1.0 (got {min_confidence})");
}
let (repo, mut store, cache) = open_graph()?;
build_graph(&repo, &mut store, &cache, ingest, GraphSource::Committed)?;
store.delete_edges_by_src_ref(rto_graph::EMBED_REF)?;
let config = InferenceConfig {
min_confidence,
top_k,
};
let (edges, embedder_label) = infer_with_embedder(&store, config, model.as_deref())?;
let count = edges.len();
store.apply_factset(&FactSet {
nodes: vec![],
edges,
})?;
if json {
let report = serde_json::json!({
"min_confidence": min_confidence,
"top_k": top_k,
"embedder": embedder_label,
"inferred_edges": count,
});
emit_json(&report)?;
} else {
println!(
"inferred {count} similarity edge(s) via {embedder_label} \
(min-confidence {min_confidence}, top-k {top_k}); \
query them with `roteiro query <key>`",
);
}
Ok(())
}
#[cfg(feature = "inference")]
fn run_duplicates(
cfg: &config::Config,
ingest: rto_graph::IngestConfig,
min_similarity: Option<f64>,
limit: Option<usize>,
json: bool,
) -> anyhow::Result<()> {
use rto_graph::DuplicateConfig;
let min_similarity = min_similarity
.or(cfg.duplicates.min_similarity)
.unwrap_or(0.9);
let limit = limit.or(cfg.duplicates.limit).unwrap_or(50);
if !(0.0..=1.0).contains(&min_similarity) {
anyhow::bail!("--min-similarity must be in 0.0..=1.0 (got {min_similarity})");
}
let (repo, mut store, cache) = open_graph()?;
build_graph(&repo, &mut store, &cache, ingest, GraphSource::Committed)?;
let report = rto_graph::duplicates(
&store,
DuplicateConfig {
min_similarity,
limit,
},
)?;
if json {
emit_json(&report)?;
} else if report.pairs.is_empty() {
println!("no duplicate content found (min-similarity {min_similarity})");
} else {
let shown = if report.total > report.pairs.len() {
format!(" (showing top {})", report.pairs.len())
} else {
String::new()
};
println!("{} duplicate pair(s){shown}:", report.total);
for p in &report.pairs {
let tag = if p.exact { "exact" } else { "~sim " };
println!(" [{tag} {:.2}] {} <-> {}", p.similarity, p.a, p.b);
}
}
Ok(())
}
#[cfg(all(feature = "inference", not(feature = "inference-local-models")))]
fn infer_with_embedder(
store: &rto_graph::Store,
config: rto_graph::InferenceConfig,
model: Option<&str>,
) -> anyhow::Result<(Vec<rto_graph::Edge>, String)> {
use rto_graph::infer_edges_with;
if model.is_some() {
anyhow::bail!(
"--model requires the `inference-local-models` feature; \
rebuild with `--features inference-local-models`"
);
}
Ok((
infer_edges_with(store, config, &rto_graph::HashEmbedder)?,
"hashing embedder (offline default)".to_owned(),
))
}
#[cfg(feature = "inference-local-models")]
fn infer_with_embedder(
store: &rto_graph::Store,
config: rto_graph::InferenceConfig,
model: Option<&str>,
) -> anyhow::Result<(Vec<rto_graph::Edge>, String)> {
use rto_graph::{HashEmbedder, Platform, infer_edges_with};
let Some(name) = model else {
return Ok((
infer_edges_with(store, config, &HashEmbedder)?,
"hashing embedder (offline default)".to_owned(),
));
};
let spec = rto_graph::find_model(name)
.ok_or_else(|| anyhow::anyhow!("unknown model `{name}` (see `roteiro model list`)"))?;
if spec.kind != rto_graph::ModelKind::Embedding {
anyhow::bail!(
"model `{name}` is a {} model, not an embedding model — \
`infer --model` needs an embedding model (see `roteiro model list`)",
spec.kind.as_str()
);
}
let variant = spec
.variant_for(Platform::host())
.ok_or_else(|| anyhow::anyhow!("no variant of `{name}` for this platform"))?;
if !rto_graph::is_installed(name, variant) {
anyhow::bail!(
"model `{name}` is not installed — run `roteiro model pull {name}` \
(or omit --model to use the offline default)"
);
}
let embedder = LlamaEmbedder::new(name)?;
let edges = infer_edges_with(store, config, &embedder)?;
Ok((edges, format!("local model `{name}` (llama.cpp)")))
}
#[cfg(feature = "inference-local-models")]
struct LlamaEmbedder {
engine: rto_llama::llama::LlamaEngine,
model: String,
}
#[cfg(feature = "inference-local-models")]
impl LlamaEmbedder {
fn new(name: &str) -> anyhow::Result<Self> {
let engine = rto_llama::llama::LlamaEngine::new(
vec![rto_llama::llama::Served {
name: name.to_owned(),
path: rto_graph::model_dir(name).join("model.gguf"),
mmproj: None,
}],
0,
)
.map_err(|e| anyhow::anyhow!("loading model `{name}`: {e}"))?;
Ok(Self {
engine,
model: name.to_owned(),
})
}
}
#[cfg(feature = "inference-local-models")]
impl rto_graph::Embedder for LlamaEmbedder {
fn embed(&self, text: &str) -> Vec<f32> {
use rto_llama::Engine as _;
self.engine
.embed(&self.model, &[text.to_owned()])
.ok()
.and_then(|mut v| v.pop())
.unwrap_or_default()
}
}
#[cfg(feature = "models")]
fn run_model(action: ModelAction) -> anyhow::Result<()> {
match action {
ModelAction::List => {
run_model_list();
Ok(())
}
ModelAction::Pull { name, yes } => run_model_pull(&name, yes),
}
}
#[cfg(feature = "models")]
fn run_model_list() {
use rto_graph::{ModelKind, Platform, REGISTRY, ResourceTier};
let host = Platform::host();
println!(
"platform: {} model store: {}",
host.as_str(),
rto_graph::store_root().display()
);
println!("(the built-in hashing embedder is always available with no model)");
let sections = [
(ModelKind::Embedding, "Embedding (`roteiro infer --model`)"),
(ModelKind::Generative, "Generative (`roteiro spec draft`)"),
(
ModelKind::Ocr,
"OCR — image text (`roteiro sync` with --features image-ocr)",
),
(
ModelKind::Vision,
"Vision — image description (`roteiro sync` with --features image-vision)",
),
(
ModelKind::Audio,
"Audio — speech transcription (`roteiro sync` with --features audio-transcribe)",
),
];
let tiers = [
(ResourceTier::Low, "low (any laptop)"),
(ResourceTier::Mid, "mid (~16 GB)"),
(ResourceTier::High, "high (workstation / 64 GB)"),
];
for (kind, heading) in sections {
println!("\n{heading}:");
for (tier, tier_label) in tiers {
for spec in REGISTRY.iter().filter(|s| s.kind == kind && s.tier == tier) {
let variant = spec.variant_for(host);
let installed = variant.is_some_and(|v| rto_graph::is_installed(spec.name, v));
let mark = if installed {
"✓ installed"
} else {
" available"
};
let dim = if spec.dim > 0 {
format!(", dim {}", spec.dim)
} else {
String::new()
};
let role = spec
.role
.as_str()
.map(|r| format!(", {r}"))
.unwrap_or_default();
println!(
" [{tier_label}] {mark} {name} ({licence}{role}{dim}, ~{size} MiB)\n {desc}",
name = spec.name,
licence = spec.licence,
size = spec.size_mib,
desc = spec.description,
);
}
}
}
}
#[cfg(feature = "models")]
fn run_model_pull(name: &str, yes: bool) -> anyhow::Result<()> {
use rto_graph::{Platform, ensure_model_dir, find_model};
use std::io::Write as _;
let spec = find_model(name)
.ok_or_else(|| anyhow::anyhow!("unknown model `{name}` (see `roteiro model list`)"))?;
let variant = spec
.variant_for(Platform::host())
.ok_or_else(|| anyhow::anyhow!("no variant of `{name}` for this platform"))?;
let stdin_is_tty = std::io::IsTerminal::is_terminal(&std::io::stdin());
if !yes {
eprintln!(
"roteiro would download model `{name}` (~{} MiB, {}) from:",
spec.size_mib, spec.licence
);
for f in variant.files {
eprintln!(" {}", f.url);
}
if !stdin_is_tty {
eprintln!(
"\nnon-interactive: not downloading. Re-run with `--yes`, or fetch manually into {}",
rto_graph::model_dir(name).display()
);
anyhow::bail!("download declined (non-interactive)");
}
eprint!("Download now? [y/N] ");
std::io::stderr().flush().ok();
let mut answer = String::new();
std::io::stdin().read_line(&mut answer)?;
if !matches!(answer.trim(), "y" | "Y" | "yes" | "Yes") {
anyhow::bail!("download declined");
}
}
let dir = ensure_model_dir(name)?;
for f in variant.files {
let dest = dir.join(f.name);
eprintln!("fetching {} …", f.name);
if f.sha256.is_empty() {
eprintln!(
" warning: no checksum pinned for {} — integrity NOT verified",
f.name
);
}
let reader = http_reader(f.url)?;
rto_graph::download_verified(reader, &dest, f.sha256)
.map_err(|e| anyhow::anyhow!("downloading {}: {e}", f.name))?;
}
let use_hint = match spec.kind {
rto_graph::ModelKind::Embedding => format!("roteiro infer --model {name}"),
rto_graph::ModelKind::Generative => "roteiro spec draft <topic>".to_owned(),
rto_graph::ModelKind::Ocr => {
"roteiro sync (a build with --features image-ocr OCRs images)".to_owned()
}
rto_graph::ModelKind::Vision => {
"roteiro sync (a build with --features image-vision describes images)".to_owned()
}
rto_graph::ModelKind::Audio => {
"roteiro sync (a build with --features audio-transcribe transcribes audio)".to_owned()
}
};
println!(
"installed `{name}` → {} (use it with `{use_hint}`)",
dir.display()
);
Ok(())
}
#[cfg(feature = "models")]
fn http_reader(url: &str) -> anyhow::Result<impl std::io::Read> {
Ok(ureq::get(url)
.call()
.map_err(|e| anyhow::anyhow!("GET {url}: {e}"))?
.into_body()
.into_reader())
}
fn run_import(
ingest: rto_graph::IngestConfig,
from: &str,
path: &str,
json: bool,
) -> anyhow::Result<()> {
match from {
"graphify" => run_import_graphify(ingest, path, json),
"lat" => run_import_lat(ingest, path, json),
"codegraph" => run_compare_codegraph(ingest, path, json),
other => {
anyhow::bail!("unknown import source `{other}` (expected: graphify | lat | codegraph)")
}
}
}
fn run_compare_codegraph(
ingest: rto_graph::IngestConfig,
path: &str,
json: bool,
) -> anyhow::Result<()> {
let (repo, mut store, cache) = open_graph()?;
build_graph(&repo, &mut store, &cache, ingest, GraphSource::Committed)?;
let report = rto_graph::compare_codegraph(std::path::Path::new(path), &store)?;
if json {
emit_json(&report)?;
} else {
if let Some(commit) = &report.source_commit {
let short = &commit[..commit.len().min(12)];
println!("codegraph oracle — snapshot indexed at {short}");
}
println!(
"symbols: {} matched, {} scope-only diffs (same symbol, different \
module scope), {} codegraph-only, {} roteiro-only \
(codegraph {}, roteiro {}; {} constants are a known Roteiro gap)",
report.symbols_matched,
report.symbols_scope_diff,
report.codegraph_only,
report.roteiro_only,
report.symbols_codegraph,
report.symbols_roteiro,
report.constants_codegraph,
);
println!(
"calls: {}/{} codegraph internal calls agree ({} not re-derived — \
Roteiro links only unambiguous calls)",
report.calls_agree, report.calls_codegraph, report.calls_codegraph_only,
);
for key in report.codegraph_only_sample.iter().take(10) {
println!(" codegraph-only: {key}");
}
for key in report.roteiro_only_sample.iter().take(10) {
println!(" roteiro-only: {key}");
}
}
Ok(())
}
fn run_import_lat(ingest: rto_graph::IngestConfig, path: &str, json: bool) -> anyhow::Result<()> {
let (repo, mut store, cache) = open_graph()?;
let root = repo
.workdir()
.ok_or_else(|| anyhow::anyhow!("cannot import into a bare repository"))?;
let cwd = std::env::current_dir()?;
let dir = {
let p = std::path::Path::new(path);
if p.is_absolute() {
p.to_path_buf()
} else {
cwd.join(p)
}
};
if !dir.is_dir() {
anyhow::bail!(
"lat directory not found: {} (expected a lat.md/ dir)",
dir.display()
);
}
let mut files = Vec::new();
collect_markdown(&dir, root, &mut files)?;
files.sort_by(|a, b| a.0.cmp(&b.0));
if files.is_empty() {
anyhow::bail!("no .md files under {}", dir.display());
}
let mut imported = rto_spec::import_lat(&files);
let mut backlinks = Vec::new();
for blob in repo.walk_blobs()? {
let bytes = repo.read_blob(&blob.oid)?;
let text = String::from_utf8_lossy(&bytes);
backlinks.extend(rto_spec::scan_lat_annotations(&blob.path, &text));
}
let (backlink_edges, unresolved) = rto_spec::import_lat_backlinks(&files, &backlinks);
imported.report.backlinks_resolved = backlink_edges.len();
imported.report.backlinks_unresolved = unresolved;
imported.facts.edges.extend(backlink_edges);
build_graph(&repo, &mut store, &cache, ingest, GraphSource::Committed)?;
let applied = store.apply_import_layer(rto_spec::LAT_REF, &imported.facts)?;
let r = &imported.report;
if json {
let mut report = serde_json::to_value(r)?;
report["edges_applied"] = serde_json::json!(applied.edges_applied);
report["edges_pruned_stale"] = serde_json::json!(applied.edges_pruned);
report["durable"] = serde_json::json!(true);
emit_json(&report)?;
} else {
println!(
"imported lat.md: {} file(s), {} section(s), {} link(s) \
({} to sections, {} to code), {} backlink(s) ({} unresolved); \
{} edge(s) applied, {} stale pruned — persisted (durable)",
r.files,
r.sections,
r.links_total,
r.links_to_sections,
r.links_to_code,
r.backlinks_resolved,
r.backlinks_unresolved,
applied.edges_applied,
applied.edges_pruned,
);
}
Ok(())
}
fn collect_markdown(
dir: &std::path::Path,
root: &std::path::Path,
out: &mut Vec<(String, String)>,
) -> anyhow::Result<()> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let file_type = entry.file_type()?;
if file_type.is_symlink() {
continue;
}
let path = entry.path();
if file_type.is_dir() {
collect_markdown(&path, root, out)?;
} else if file_type.is_file() && path.extension().and_then(|e| e.to_str()) == Some("md") {
let rel = path.strip_prefix(root).map_err(|_| {
anyhow::anyhow!(
"lat file {} is outside the repository ({}); the lat.md \
directory must live inside the repo",
path.display(),
root.display()
)
})?;
let rel = rel.to_string_lossy().replace('\\', "/");
out.push((rel, std::fs::read_to_string(&path)?));
}
}
Ok(())
}
fn run_import_graphify(
ingest: rto_graph::IngestConfig,
path: &str,
json: bool,
) -> anyhow::Result<()> {
use rto_graph::{Edge, EdgeKind};
let p = std::path::Path::new(path);
let graph_json = if p.is_dir() {
p.join("graph.json")
} else {
p.to_path_buf()
};
let text = std::fs::read_to_string(&graph_json)
.map_err(|e| anyhow::anyhow!("reading {}: {e}", graph_json.display()))?;
let imported = rto_spec::import_graphify(&text)?;
let (repo, mut store, cache) = open_graph()?;
build_graph(&repo, &mut store, &cache, ingest, GraphSource::Committed)?;
let mut facts = imported.facts.clone();
let mut linked = 0usize;
for node in &imported.facts.nodes {
if let Some(path) = &node.path {
let file_key = format!("file:{path}");
if store.get_node(&file_key)?.is_some() {
let mut edge =
Edge::inferred(node.key.clone(), file_key, EdgeKind::References, 0.9);
edge.src_ref = Some(rto_spec::GRAPHIFY_REF.to_owned());
facts.edges.push(edge);
linked += 1;
}
}
}
let applied = store.apply_import_layer(rto_spec::GRAPHIFY_REF, &facts)?;
let r = &imported.report;
if json {
let mut report = serde_json::to_value(r)?;
report["docs_linked_to_files"] = serde_json::json!(linked);
report["edges_pruned_stale"] = serde_json::json!(applied.edges_pruned);
report["durable"] = serde_json::json!(true);
emit_json(&report)?;
} else {
println!(
"imported graphify: {} node(s) ({} dropped as code), {} inferred edge(s) \
({} ast dropped, {} dangling skipped), {} hyperedge group(s); \
{linked} doc(s) linked to files, {} stale pruned — persisted (durable)",
r.nodes_imported,
r.nodes_dropped_code,
r.edges_imported,
r.edges_dropped_ast,
r.edges_skipped_dangling,
r.hyperedges_imported,
applied.edges_pruned,
);
}
Ok(())
}
fn run_spec(
cfg: &config::Config,
ingest: rto_graph::IngestConfig,
action: SpecAction,
) -> anyhow::Result<()> {
match action {
SpecAction::Context { topic, limit, json } => run_spec_context(ingest, &topic, limit, json),
SpecAction::Scaffold {
topic,
title,
kind,
out,
} => run_spec_scaffold(ingest, &topic, title.as_deref(), &kind, out.as_deref()),
SpecAction::Draft {
topic,
title,
kind,
out,
} => run_spec_draft(cfg, ingest, &topic, title.as_deref(), &kind, out.as_deref()),
}
}
fn build_scaffold(
ingest: rto_graph::IngestConfig,
topic: &str,
title: Option<&str>,
kind: &str,
) -> anyhow::Result<(String, String, rto_spec::SpecContext)> {
if kind != "adr" && kind != "blueprint" {
anyhow::bail!("unknown --kind `{kind}` (expected: adr | blueprint)");
}
let (repo, mut store, cache) = open_graph()?;
build_graph(&repo, &mut store, &cache, ingest, GraphSource::Committed)?;
let root = repo
.workdir()
.ok_or_else(|| anyhow::anyhow!("cannot scaffold in a bare repository"))?;
let ctx = rto_spec::context(&store, topic, 10)?;
let (md, label) = if kind == "adr" {
let adr_id = next_adr_id(&root.join("docs/adr"));
(
rto_spec::scaffold_adr(topic, title, &adr_id, &today_utc(), &ctx),
format!("ADR-{adr_id}"),
)
} else {
(
rto_spec::scaffold_blueprint(topic, title, &ctx),
"blueprint".to_owned(),
)
};
Ok((md, label, ctx))
}
fn emit_artifact(md: &str, label: &str, out: Option<&str>) -> anyhow::Result<()> {
match out {
Some(path) => {
std::fs::write(path, md)?;
eprintln!("wrote {label} → {path}");
}
None => print!("{md}"),
}
Ok(())
}
fn run_spec_scaffold(
ingest: rto_graph::IngestConfig,
topic: &str,
title: Option<&str>,
kind: &str,
out: Option<&str>,
) -> anyhow::Result<()> {
let (md, label, _ctx) = build_scaffold(ingest, topic, title, kind)?;
emit_artifact(&md, &format!("{label} scaffold"), out)
}
#[cfg(any(feature = "serve", feature = "inference-local-models"))]
fn run_spec_draft(
cfg: &config::Config,
ingest: rto_graph::IngestConfig,
topic: &str,
title: Option<&str>,
kind: &str,
out: Option<&str>,
) -> anyhow::Result<()> {
use rto_graph::{
ModelKind, ModelRole, Platform, REGISTRY, ResourceTier, find_model, is_installed,
};
let (scaffold, label, ctx) = build_scaffold(ingest, topic, title, kind)?;
let Some(spec) = cfg
.models
.generative
.as_deref()
.and_then(find_model)
.filter(|m| m.kind == ModelKind::Generative)
.or_else(|| {
REGISTRY.iter().find(|m| {
m.kind == ModelKind::Generative
&& m.role == ModelRole::Instruct
&& m.tier == ResourceTier::Low
})
})
else {
anyhow::bail!("no generative model in the registry");
};
let installed = spec
.variant_for(Platform::host())
.is_some_and(|v| is_installed(spec.name, v));
if !installed {
eprintln!(
"note: generative model `{0}` is not installed — emitting the scaffold. \
Draft prose with: roteiro model pull {0}",
spec.name
);
return emit_artifact(&scaffold, &format!("{label} scaffold"), out);
}
if cfg!(debug_assertions) {
eprintln!(
"note: unoptimized build — local generation is very slow; use a \
release build (`cargo build --release`) for usable speed."
);
}
let drafts = draft_sections(spec.name, &scaffold, topic, &ctx)?;
eprintln!(
"drafted {} section(s) with {} (via {GEN_BACKEND})",
drafts.len(),
spec.name
);
let md = rto_spec::apply_drafts(&scaffold, &drafts);
emit_artifact(&md, &format!("{label} draft"), out)
}
#[cfg(any(feature = "serve", feature = "inference-local-models"))]
const GEN_BACKEND: &str = "llama.cpp";
#[cfg(any(feature = "serve", feature = "inference-local-models"))]
const DRAFT_MAX_TOKENS: u32 = 800;
#[cfg(any(feature = "serve", feature = "inference-local-models"))]
fn draft_sections(
model: &str,
scaffold: &str,
topic: &str,
ctx: &rto_spec::SpecContext,
) -> anyhow::Result<Vec<(String, String)>> {
use rto_llama::Engine as _;
let engine = rto_llama::llama::LlamaEngine::new(
vec![rto_llama::llama::Served {
name: model.to_owned(),
path: rto_graph::model_dir(model).join("model.gguf"),
mmproj: None,
}],
0,
)
.map_err(|e| anyhow::anyhow!("starting llama.cpp: {e}"))?;
let mut drafts = Vec::new();
for (heading, hint) in rto_spec::draft_targets(scaffold) {
let prompt = rto_spec::draft_prompt(topic, ctx, &heading, &hint);
let completion = engine
.chat(&rto_llama::ChatRequest {
model: model.to_owned(),
messages: vec![rto_llama::Message {
role: "user".to_owned(),
content: prompt,
}],
images: vec![],
audio: vec![],
temperature: 0.0,
max_tokens: DRAFT_MAX_TOKENS,
})
.map_err(|e| anyhow::anyhow!("drafting `{heading}`: {e}"))?;
let prose = strip_thinking(&completion.content);
if !prose.trim().is_empty() {
drafts.push((heading, prose));
}
}
Ok(drafts)
}
#[cfg(any(feature = "serve", feature = "inference-local-models"))]
fn strip_thinking(text: &str) -> String {
match text.find("</think>") {
Some(end) => text[end + "</think>".len()..].trim_start().to_owned(),
None => text.to_owned(),
}
}
#[cfg(not(any(feature = "serve", feature = "inference-local-models")))]
fn run_spec_draft(
_cfg: &config::Config,
_ingest: rto_graph::IngestConfig,
_topic: &str,
_title: Option<&str>,
_kind: &str,
_out: Option<&str>,
) -> anyhow::Result<()> {
anyhow::bail!(
"`spec draft` needs a generation backend: build with `--features serve` \
or `--features inference-local-models` (both llama.cpp), then \
`roteiro model pull qwen3-0.6b`. (`spec scaffold` works with no model.)"
)
}
fn next_adr_id(adr_dir: &std::path::Path) -> String {
let mut max = 0u32;
if let Ok(entries) = std::fs::read_dir(adr_dir) {
for entry in entries.flatten() {
if let Some(name) = entry.file_name().to_str() {
let digits: String = name.chars().take_while(char::is_ascii_digit).collect();
if let Ok(n) = digits.parse::<u32>() {
max = max.max(n);
}
}
}
}
format!("{:04}", max + 1)
}
fn today_utc() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
let days = i64::try_from(secs / 86_400).unwrap_or(0) + 719_468;
let era = if days >= 0 { days } else { days - 146_096 } / 146_097;
let doe = days - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let year = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = year + i64::from(month <= 2);
format!("{year:04}-{month:02}-{day:02}")
}
fn run_spec_context(
ingest: rto_graph::IngestConfig,
topic: &str,
limit: usize,
json: bool,
) -> anyhow::Result<()> {
let (repo, mut store, cache) = open_graph()?;
build_graph(&repo, &mut store, &cache, ingest, GraphSource::Committed)?;
let ctx = rto_spec::context(&store, topic, limit)?;
if json {
emit_json(&ctx)?;
} else {
println!("context for \"{}\":", ctx.topic);
if ctx.symbols.is_empty() && ctx.docs.is_empty() {
println!(" (nothing in the graph matches — try `roteiro query --kind fn` to browse)");
}
if !ctx.symbols.is_empty() {
println!(" symbols:");
for s in &ctx.symbols {
println!(" {} ({})", s.node.key, s.node.kind);
if let Some(c) = &s.container {
println!(" in: {c}");
}
if !s.called_by.is_empty() {
println!(" called by: {}", s.called_by.join(", "));
}
if !s.calls.is_empty() {
println!(" calls: {}", s.calls.join(", "));
}
if !s.authored_by.is_empty() {
println!(" governed by: {}", s.authored_by.join(", "));
}
}
}
if !ctx.docs.is_empty() {
println!(" docs:");
for d in &ctx.docs {
println!(" {} {}", d.key, d.name);
}
}
if !ctx.related_adrs.is_empty() {
println!(" related ADRs: {}", ctx.related_adrs.join(", "));
}
}
Ok(())
}
fn run_query(
ingest: rto_graph::IngestConfig,
key: Option<String>,
kind: Option<String>,
json: bool,
) -> anyhow::Result<()> {
use rto_graph::{NodeKind, explain, list_kind};
let (repo, mut store, cache) = open_graph()?;
build_graph(&repo, &mut store, &cache, ingest, GraphSource::Committed)?;
match (key, kind) {
(Some(key), _) => {
let Some(ex) = explain(&store, &key)? else {
anyhow::bail!(
"no node with key `{key}` (try `roteiro query --kind <kind>` to list nodes)"
);
};
if json {
emit_json(&ex)?;
} else {
println!("{} ({}) {}", ex.node.key, ex.node.kind, ex.node.name);
if let Some(path) = &ex.node.path {
println!(" path: {path}");
}
if !ex.outgoing.is_empty() {
println!(" outgoing:");
for e in &ex.outgoing {
println!(" -[{}/{}]-> {}", e.kind, e.provenance, e.node);
}
}
if !ex.incoming.is_empty() {
println!(" incoming:");
for e in &ex.incoming {
println!(" <-[{}/{}]- {}", e.kind, e.provenance, e.node);
}
}
}
}
(None, Some(kind)) => {
let listing = list_kind(&store, &NodeKind::from_token(&kind))?;
if json {
emit_json(&listing)?;
} else {
println!("{} ({}):", listing.kind, listing.nodes.len());
for n in &listing.nodes {
println!(" {} {}", n.key, n.name);
}
}
}
(None, None) => {
anyhow::bail!("provide a node key to explain, or `--kind <kind>` to list nodes");
}
}
Ok(())
}
fn run_context(
ingest: rto_graph::IngestConfig,
key: Option<String>,
refresh: bool,
json: bool,
) -> anyhow::Result<()> {
use rto_graph::{context, refresh_contexts};
let (repo, mut store, cache) = open_graph()?;
build_graph(&repo, &mut store, &cache, ingest, GraphSource::Committed)?;
if refresh {
let report = refresh_contexts(&store)?;
if json {
emit_json(&report)?;
} else {
println!(
"context cache refreshed: {} rebuilt, {} reused, {} pruned",
report.rebuilt, report.reused, report.pruned
);
}
return Ok(());
}
let Some(key) = key else {
anyhow::bail!("provide a node key, or `--refresh` to refresh all cached contexts");
};
let Some(ctx) = context(&store, &key)? else {
anyhow::bail!("no node with key `{key}` (try `roteiro query --kind <kind>` to list nodes)");
};
if json {
emit_json(&ctx)?;
} else {
println!("{} ({}) {}", ctx.node.key, ctx.node.kind, ctx.node.name);
println!(" fingerprint: {}", ctx.fingerprint);
if !ctx.outgoing.is_empty() {
println!(" outgoing:");
for e in &ctx.outgoing {
println!(" -[{}/{}]-> {}", e.kind, e.provenance, e.node);
}
}
if !ctx.incoming.is_empty() {
println!(" incoming:");
for e in &ctx.incoming {
println!(" <-[{}/{}]- {}", e.kind, e.provenance, e.node);
}
}
}
Ok(())
}
fn run_debt(
ingest: rto_graph::IngestConfig,
kinds: &[String],
json: bool,
debt_ignore: &[String],
) -> anyhow::Result<()> {
let (repo, mut store, cache) = open_graph()?;
build_graph(&repo, &mut store, &cache, ingest, GraphSource::Committed)?;
let report = rto_graph::debt(&store, kinds, debt_ignore)?;
if json {
emit_json(&report)?;
} else {
for item in &report.items {
let loc = match (&item.path, item.line) {
(Some(p), Some(l)) => format!("{p}:{l}"),
(Some(p), None) => p.clone(),
_ => item.key.clone(),
};
println!(" [{}] {loc} {}", item.category, item.text);
}
println!("{}", debt_summary(&report));
}
Ok(())
}
fn debt_summary(report: &rto_graph::DebtReport) -> String {
if report.total == 0 {
return "intent debt: none".to_owned();
}
let breakdown: Vec<String> = report
.by_category
.iter()
.map(|(cat, n)| format!("{cat} {n}"))
.collect();
format!(
"intent debt: {} marker(s) ({})",
report.total,
breakdown.join(", ")
)
}
fn run_path(
ingest: rto_graph::IngestConfig,
from: &str,
to: &str,
json: bool,
) -> anyhow::Result<()> {
let (repo, mut store, cache) = open_graph()?;
build_graph(&repo, &mut store, &cache, ingest, GraphSource::Committed)?;
let result = rto_graph::path(&store, from, to)?;
if json {
emit_json(&result)?;
} else if result.found {
println!("{from}");
for hop in &result.hops {
let arrow = if hop.direction == "outgoing" {
"->"
} else {
"<-"
};
println!(" {arrow}[{}/{}] {}", hop.kind, hop.provenance, hop.node);
}
println!("({} hop(s))", result.length);
} else {
eprintln!("no path from `{from}` to `{to}`");
}
if !result.found {
std::process::exit(1);
}
Ok(())
}
fn run_export(ingest: rto_graph::IngestConfig, out: Option<String>) -> anyhow::Result<()> {
use rto_graph::GraphArtifact;
let (repo, mut store, cache) = open_graph()?;
build_graph(&repo, &mut store, &cache, ingest, GraphSource::Committed)?;
let artifact = GraphArtifact::from_store(&store)?;
let json = artifact.to_json()?;
let out = out.unwrap_or_else(|| "roteiro-graph.json".to_owned());
if out == "-" {
println!("{json}");
} else {
std::fs::write(&out, format!("{json}\n"))?;
eprintln!(
"exported {} nodes, {} edges → {out}",
artifact.facts.nodes.len(),
artifact.facts.edges.len()
);
}
Ok(())
}
fn run_load(file: &str, force: bool) -> anyhow::Result<()> {
use rto_graph::{GraphArtifact, Repo, Store};
let json = if file == "-" {
std::io::read_to_string(std::io::stdin())?
} else {
std::fs::read_to_string(file)?
};
let artifact = GraphArtifact::from_json(&json)?;
let cwd = std::env::current_dir()?;
let repo = Repo::discover(&cwd)?;
if !force {
let head = repo.head_tree_id()?;
let short = |t: &str| t[..t.len().min(12)].to_owned();
match artifact.tree.as_deref() {
Some(tree) if tree == head => {}
Some(tree) => anyhow::bail!(
"artifact tree {} does not match HEAD tree {} — refusing to load a mismatched graph (pass --force to override, or run `roteiro sync`)",
short(tree),
short(&head)
),
None => anyhow::bail!(
"artifact records no tree, so it cannot be verified against HEAD — pass --force to load it anyway, or run `roteiro sync`"
),
}
}
let store_dir = repo.git_dir().join("roteiro");
std::fs::create_dir_all(&store_dir)?;
let mut store = Store::open(&store_dir.join("graph.db"))?;
artifact.load_into(&mut store)?;
println!(
"loaded {} nodes, {} edges from {file}",
store.node_count()?,
store.edge_count()?
);
Ok(())
}
#[cfg(any(feature = "mcp", feature = "serve"))]
fn run_serve(
ingest: rto_graph::IngestConfig,
cfg: &config::Config,
models: bool,
http: Option<String>,
addr: Option<String>,
tls_cert: Option<String>,
tls_key: Option<String>,
) -> anyhow::Result<()> {
if models {
serve_models_endpoint(cfg, ingest, addr, tls_cert, tls_key)
} else {
serve_mcp(ingest, http)
}
}
#[cfg(feature = "mcp")]
fn serve_mcp(ingest: rto_graph::IngestConfig, http: Option<String>) -> anyhow::Result<()> {
let (repo, mut store, cache) = open_graph()?;
build_graph(&repo, &mut store, &cache, ingest, GraphSource::Committed)?;
match http {
Some(addr) => {
let addr: std::net::SocketAddr = addr
.parse()
.map_err(|e| anyhow::anyhow!("invalid --http address `{addr}`: {e}"))?;
eprintln!("roteiro MCP server listening on http://{addr}/mcp");
rto_render::mcp::serve_http(store, addr).map_err(|e| anyhow::anyhow!("{e}"))
}
None => rto_render::mcp::serve_stdio(store).map_err(|e| anyhow::anyhow!("{e}")),
}
}
#[cfg(all(not(feature = "mcp"), feature = "serve"))]
fn serve_mcp(_ingest: rto_graph::IngestConfig, _http: Option<String>) -> anyhow::Result<()> {
anyhow::bail!(
"MCP serving needs the `mcp` feature (build with `--features mcp`); \
use `--models` for the OpenAI-compatible model endpoint"
)
}
#[cfg(feature = "serve")]
fn serve_models_endpoint(
cfg: &config::Config,
ingest: rto_graph::IngestConfig,
addr: Option<String>,
tls_cert: Option<String>,
tls_key: Option<String>,
) -> anyhow::Result<()> {
use rto_graph::{ModelKind, Platform, REGISTRY, is_installed, model_dir};
let host = Platform::host();
let wanted = cfg.serve.models.as_deref();
let has_file = |m: &rto_graph::ModelSpec, name: &str| {
m.variant_for(host)
.is_some_and(|v| v.files.iter().any(|f| f.name == name))
};
let served: Vec<rto_serve::llama::Served> = REGISTRY
.iter()
.filter(|m| wanted.is_none_or(|w| w.iter().any(|n| n == m.name)))
.filter(|m| has_file(m, "model.gguf"))
.filter(|m| match m.kind {
ModelKind::Generative | ModelKind::Embedding => true,
ModelKind::Vision => has_file(m, "mmproj.gguf"),
ModelKind::Ocr | ModelKind::Audio => false,
})
.filter(|m| m.variant_for(host).is_some_and(|v| is_installed(m.name, v)))
.map(|m| rto_serve::llama::Served {
name: m.name.to_owned(),
path: model_dir(m.name).join("model.gguf"),
mmproj: has_file(m, "mmproj.gguf").then(|| model_dir(m.name).join("mmproj.gguf")),
})
.collect();
if served.is_empty() {
anyhow::bail!(
"no installed GGUF models to serve — pull one first \
(`roteiro model pull qwen3-0.6b` for chat, \
`roteiro model pull bge-small-en-v1.5-gguf` for embeddings, or \
`roteiro model pull smolvlm-500m-gguf` for vision; \
see `roteiro model list`)"
);
}
let addr = addr
.or_else(|| cfg.serve.addr.clone())
.unwrap_or_else(|| "127.0.0.1:8017".to_owned());
let socket: std::net::SocketAddr = addr
.parse()
.map_err(|e| anyhow::anyhow!("invalid serve address `{addr}`: {e}"))?;
if !socket.ip().is_loopback() {
eprintln!(
"warning: binding a non-loopback address ({socket}) — the endpoint \
has no auth; front it with a reverse proxy (ADR-0006)"
);
}
let names = served
.iter()
.map(|s| s.name.clone())
.collect::<Vec<_>>()
.join(", ");
let budget_bytes = cfg
.serve
.memory_budget_mb
.unwrap_or(0)
.saturating_mul(1024 * 1024);
let engine = rto_serve::llama::LlamaEngine::new_with_budget(served, 0, budget_bytes)
.map_err(|e| anyhow::anyhow!("starting llama.cpp: {e}"))?;
let engine: std::sync::Arc<dyn rto_serve::Engine> = std::sync::Arc::new(engine);
let tls = match (
tls_cert.or_else(|| cfg.serve.tls_cert.clone()),
tls_key.or_else(|| cfg.serve.tls_key.clone()),
) {
(Some(cert), Some(key)) => Some((
std::path::PathBuf::from(cert),
std::path::PathBuf::from(key),
)),
(Some(_), None) | (None, Some(_)) => {
anyhow::bail!(
"TLS needs both a certificate and a key — set both `--tls-cert`/`--tls-key` \
(or `[serve] tls_cert`/`tls_key`), or neither for plain HTTP"
);
}
(None, None) => None,
};
let scheme = if tls.is_some() { "https" } else { "http" };
let tools: Option<std::sync::Arc<dyn rto_serve::ToolRegistry>> =
if cfg.serve.tools.unwrap_or(true) {
let (repo, mut store, cache) = open_graph()?;
build_graph(&repo, &mut store, &cache, ingest, GraphSource::Committed)?;
Some(std::sync::Arc::new(GraphToolRegistry::new(store)))
} else {
None
};
let tools_note = if tools.is_some() {
" (graph tools on)"
} else {
""
};
eprintln!(
"roteiro model server listening on {scheme}://{socket}/v1{tools_note} — serving: {names}"
);
match tls {
Some((cert, key)) => rto_serve::serve_blocking_tls(engine, tools, socket, &cert, &key),
None => match tools {
Some(tools) => rto_serve::serve_blocking_with_tools(engine, tools, socket),
None => rto_serve::serve_blocking(engine, socket),
},
}
}
#[cfg(feature = "serve")]
struct GraphToolRegistry {
store: std::sync::Mutex<rto_graph::Store>,
}
#[cfg(feature = "serve")]
impl GraphToolRegistry {
fn new(store: rto_graph::Store) -> Self {
Self {
store: std::sync::Mutex::new(store),
}
}
}
#[cfg(feature = "serve")]
impl rto_serve::ToolRegistry for GraphToolRegistry {
fn tools(&self) -> Vec<rto_serve::ToolDef> {
use serde_json::json;
vec![
rto_serve::ToolDef {
name: "explain".to_owned(),
description: "Explain a graph node by key (its record and immediate \
neighbours), e.g. `fn:foo` or `file:src/main.rs`."
.to_owned(),
parameters: json!({
"type": "object",
"properties": { "key": { "type": "string" } },
"required": ["key"],
}),
},
rto_serve::ToolDef {
name: "search".to_owned(),
description: "Search graph nodes by text; returns the top matches with keys."
.to_owned(),
parameters: json!({
"type": "object",
"properties": {
"query": { "type": "string" },
"limit": { "type": "integer", "minimum": 1, "maximum": 25 },
},
"required": ["query"],
}),
},
rto_serve::ToolDef {
name: "path".to_owned(),
description: "Find a shortest path between two node keys.".to_owned(),
parameters: json!({
"type": "object",
"properties": {
"from": { "type": "string" },
"to": { "type": "string" },
},
"required": ["from", "to"],
}),
},
rto_serve::ToolDef {
name: "debt".to_owned(),
description: "List intent-debt markers (todo/fixme/hack/stub/deferred), \
optionally filtered by category."
.to_owned(),
parameters: json!({
"type": "object",
"properties": {
"categories": { "type": "array", "items": { "type": "string" } },
},
}),
},
]
}
fn call(&self, name: &str, args: &serde_json::Value) -> Result<String, String> {
let store = self
.store
.lock()
.map_err(|_| "store mutex poisoned".to_owned())?;
let str_arg = |k: &str| args.get(k).and_then(serde_json::Value::as_str);
match name {
"explain" => {
let key = str_arg("key").ok_or("`explain` needs a string `key`")?;
let r = rto_graph::explain(&store, key).map_err(|e| e.to_string())?;
serde_json::to_string(&r).map_err(|e| e.to_string())
}
"search" => {
let query = str_arg("query").ok_or("`search` needs a string `query`")?;
let limit = args
.get("limit")
.and_then(serde_json::Value::as_u64)
.and_then(|n| usize::try_from(n).ok())
.unwrap_or(10)
.clamp(1, 25);
let r = rto_graph::search(&store, query, limit).map_err(|e| e.to_string())?;
serde_json::to_string(&r).map_err(|e| e.to_string())
}
"path" => {
let from = str_arg("from").ok_or("`path` needs a string `from`")?;
let to = str_arg("to").ok_or("`path` needs a string `to`")?;
let r = rto_graph::path(&store, from, to).map_err(|e| e.to_string())?;
serde_json::to_string(&r).map_err(|e| e.to_string())
}
"debt" => {
let categories: Vec<String> = args
.get("categories")
.and_then(serde_json::Value::as_array)
.map(|a| {
a.iter()
.filter_map(|x| x.as_str().map(str::to_owned))
.collect()
})
.unwrap_or_default();
let r = rto_graph::debt(&store, &categories, &[]).map_err(|e| e.to_string())?;
serde_json::to_string(&r).map_err(|e| e.to_string())
}
other => Err(format!("unknown tool `{other}`")),
}
}
}
#[cfg(all(not(feature = "serve"), feature = "mcp"))]
fn serve_models_endpoint(
_cfg: &config::Config,
_ingest: rto_graph::IngestConfig,
_addr: Option<String>,
_tls_cert: Option<String>,
_tls_key: Option<String>,
) -> anyhow::Result<()> {
anyhow::bail!(
"`serve --models` needs the `serve` feature (build with `--features serve`, \
which pulls the llama.cpp engine)"
)
}
fn run_render(
ingest: rto_graph::IngestConfig,
target: &str,
out: Option<String>,
) -> anyhow::Result<()> {
match rto_render::Target::parse(target) {
Some(rto_render::Target::DocsSite) => render_docs(out),
Some(rto_render::Target::ObsidianVault) => render_obsidian(ingest, out),
None => anyhow::bail!("unknown render target `{target}` (expected: docs | obsidian)"),
}
}
fn render_docs(out: Option<String>) -> anyhow::Result<()> {
let cwd = std::env::current_dir()?;
let repo = rto_graph::Repo::discover(&cwd)?;
let root = repo
.workdir()
.ok_or_else(|| anyhow::anyhow!("cannot render docs in a bare repository"))?;
let out = out.map_or_else(|| root.join("website/dist"), std::path::PathBuf::from);
if out.exists() {
std::fs::remove_dir_all(&out)?;
}
std::fs::create_dir_all(out.join("adr"))?;
copy_dir(&root.join("website/public"), &out)?;
let adr_dir = root.join("docs/adr");
let mut files: Vec<_> = std::fs::read_dir(&adr_dir)?
.filter_map(Result::ok)
.map(|e| e.path())
.filter(|p| p.extension().and_then(|e| e.to_str()) == Some("md"))
.filter(|p| p.file_name().and_then(|n| n.to_str()) != Some("README.md"))
.collect();
files.sort();
let mut entries = Vec::new();
for path in &files {
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("adr");
let md = std::fs::read_to_string(path)?;
let rendered = rto_render::render_adr(&md, stem);
std::fs::write(out.join("adr").join(format!("{stem}.html")), &rendered.html)?;
entries.push(rto_render::IndexEntry {
href: format!("{stem}.html"),
title: rendered.title,
});
}
let mut lifetime = Vec::new();
let build_plan = root.join("docs/BUILD_PLAN.md");
if build_plan.is_file() {
let md = std::fs::read_to_string(&build_plan)?;
let rendered = rto_render::render_doc(&md, "Build Plan");
std::fs::write(out.join("build-plan.html"), &rendered.html)?;
lifetime.push(rto_render::IndexEntry {
href: "../build-plan.html".to_owned(),
title: rendered.title,
});
}
for dir in ["docs/blueprint", "docs/blueprints"] {
let bp_dir = root.join(dir);
if !bp_dir.is_dir() {
continue;
}
let mut bps: Vec<_> = std::fs::read_dir(&bp_dir)?
.filter_map(Result::ok)
.map(|e| e.path())
.filter(|p| p.extension().and_then(|e| e.to_str()) == Some("md"))
.filter(|p| p.file_name().and_then(|n| n.to_str()) != Some("README.md"))
.collect();
bps.sort();
for path in &bps {
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("blueprint");
let md = std::fs::read_to_string(path)?;
let rendered = rto_render::render_doc(&md, stem);
std::fs::write(out.join(format!("{stem}.html")), &rendered.html)?;
lifetime.push(rto_render::IndexEntry {
href: format!("../{stem}.html"),
title: rendered.title,
});
}
}
std::fs::write(
out.join("adr").join("index.html"),
rto_render::render_adr_index(&lifetime, &entries),
)?;
println!(
"rendered docs → {} ({} ADR page(s), {} lifetime doc(s))",
out.display(),
entries.len(),
lifetime.len(),
);
Ok(())
}
fn render_obsidian(ingest: rto_graph::IngestConfig, out: Option<String>) -> anyhow::Result<()> {
let (repo, mut store, cache) = open_graph()?;
build_graph(&repo, &mut store, &cache, ingest, GraphSource::Committed)?;
let out = out.map_or_else(
|| std::path::PathBuf::from("vault"),
std::path::PathBuf::from,
);
if out.exists() {
std::fs::remove_dir_all(&out)?;
}
std::fs::create_dir_all(&out)?;
let mut count = 0usize;
for key in store.all_keys()? {
if let Some(ex) = rto_graph::explain(&store, &key)? {
let note = rto_render::render_note(&ex);
std::fs::write(out.join(¬e.filename), ¬e.content)?;
count += 1;
}
}
let home = rto_render::render_home(&vault_summary(&repo, &store)?);
std::fs::write(out.join(&home.filename), &home.content)?;
println!(
"rendered obsidian vault → {} ({count} note(s) + {})",
out.display(),
rto_render::HOME_NOTE
);
Ok(())
}
fn vault_summary(
repo: &rto_graph::Repo,
store: &rto_graph::Store,
) -> anyhow::Result<rto_render::VaultSummary> {
use rto_graph::{NodeKind, Provenance};
let project = repo
.workdir()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
.unwrap_or("this project")
.to_owned();
let mut by_kind: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
for node in store.all_nodes()? {
*by_kind.entry(node.kind.as_str().to_owned()).or_default() += 1;
}
let mut node_counts: Vec<(String, usize)> = by_kind.into_iter().collect();
node_counts.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
let mut edge_provenance = Vec::new();
for p in [
Provenance::Derived,
Provenance::Authored,
Provenance::Inferred,
] {
let n = store.edges_by_provenance(p)?.len();
if n > 0 {
edge_provenance.push((p.as_str().to_owned(), n));
}
}
let adrs = store
.nodes_by_kind(&NodeKind::Adr)?
.into_iter()
.map(|n| rto_render::AdrEntry {
key: n.key,
name: n.name,
status: n
.meta
.get("status")
.and_then(|v| v.as_str())
.map(ToOwned::to_owned),
})
.collect();
let debt = rto_graph::debt(store, &[], &[])?
.by_category
.into_iter()
.collect();
Ok(rto_render::VaultSummary {
project,
total_nodes: usize::try_from(store.node_count()?)?,
total_edges: usize::try_from(store.edge_count()?)?,
node_counts,
edge_provenance,
adrs,
debt,
})
}
fn copy_dir(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let from = entry.path();
let to = dst.join(entry.file_name());
if entry.file_type()?.is_dir() {
copy_dir(&from, &to)?;
} else {
std::fs::copy(&from, &to)?;
}
}
Ok(())
}