#![cfg_attr(not(feature = "autoresearch"), allow(dead_code))]
use std::collections::{HashMap, HashSet};
use chrono::Utc;
use serde_json::json;
#[cfg(all(feature = "autoresearch", feature = "provider-claude"))]
use crate::autoresearch::claude::ClaudeProvider;
#[cfg(all(feature = "autoresearch", feature = "provider-codex"))]
use crate::autoresearch::codex::CodexProvider;
#[cfg(feature = "autoresearch")]
use crate::autoresearch::provider::{AgentProvider, FakeProvider, ProviderError};
use crate::output::Envelope;
use crate::session::{active, config, layout, wiki};
#[cfg(feature = "autoresearch")]
use crate::session::{event::SessionEvent, log};
const CMD: &str = "research wiki query";
const DEFAULT_TOP_N: usize = 5;
pub fn run(
question: &str,
slug_arg: Option<&str>,
save_as: Option<&str>,
format: Option<&str>,
provider_name: &str,
) -> Envelope {
if question.trim().is_empty() {
return Envelope::fail(CMD, "INVALID_ARGUMENT", "question must not be empty");
}
let slug = match resolve_slug(slug_arg) {
Ok(s) => s,
Err(e) => return e,
};
let format = format.unwrap_or("prose");
if !matches!(format, "prose" | "comparison" | "table") {
return Envelope::fail(
CMD,
"INVALID_ARGUMENT",
format!("unknown --format '{format}' (expected prose|comparison|table)"),
);
}
let pages = wiki::list_pages(&slug);
if pages.is_empty() {
return Envelope::fail(
CMD,
"WIKI_EMPTY",
format!("session '{slug}' has no wiki pages yet"),
)
.with_context(json!({ "session": slug }));
}
let bodies = load_bodies(&slug, &pages);
#[cfg_attr(not(feature = "autoresearch"), allow(unused_variables))]
let relevant = pick_relevant(question, &pages, &bodies, DEFAULT_TOP_N);
#[cfg(not(feature = "autoresearch"))]
{
let _ = (provider_name, save_as);
return Envelope::fail(
CMD,
"FEATURE_DISABLED",
"wiki query requires the `autoresearch` feature",
)
.with_context(json!({ "session": slug }));
}
#[cfg(feature = "autoresearch")]
{
let provider = match make_provider(provider_name) {
Ok(p) => p,
Err(env) => return env.with_context(json!({ "session": slug })),
};
let system = build_system_prompt(format);
let user = build_user_prompt(question, &relevant, &bodies);
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => {
return Envelope::fail(CMD, "IO_ERROR", format!("build tokio runtime: {e}"))
.with_context(json!({ "session": slug }));
}
};
let answer = match rt.block_on(provider.ask(&system, &user)) {
Ok(s) => s,
Err(ProviderError::NotAvailable(m)) => {
return Envelope::fail(CMD, "PROVIDER_NOT_AVAILABLE", m)
.with_context(json!({ "session": slug }));
}
Err(ProviderError::CallFailed(m)) => {
return Envelope::fail(CMD, "PROVIDER_CALL_FAILED", m)
.with_context(json!({ "session": slug }));
}
Err(ProviderError::EmptyResponse) => {
return Envelope::fail(
CMD,
"PROVIDER_EMPTY_RESPONSE",
"provider returned empty text",
)
.with_context(json!({ "session": slug }));
}
};
let answer_chars = answer.chars().count() as u32;
let mut saved_path: Option<String> = None;
let mut answer_slug: Option<String> = None;
if let Some(target) = save_as {
if let Err(e) = wiki::validate_slug(target) {
return Envelope::fail(CMD, "INVALID_ARGUMENT", format!("--save-as {e}"))
.with_context(json!({ "session": slug }));
}
let page_body = render_save_page(target, question, &relevant, &answer);
match wiki::replace_page(&slug, target, &page_body) {
Ok(p) => {
saved_path = Some(p.display().to_string());
answer_slug = Some(target.to_string());
}
Err(e) => {
return Envelope::fail(CMD, "IO_ERROR", format!("write wiki page: {e}"))
.with_context(json!({ "session": slug }));
}
}
}
let ev = SessionEvent::WikiQuery {
timestamp: Utc::now(),
question: question.to_string(),
relevant_pages: relevant.clone(),
answer_slug: answer_slug.clone(),
answer_chars,
note: None,
};
if let Err(e) = log::append(&slug, &ev) {
eprintln!("⚠ warning: could not append wiki_query event: {e}");
}
Envelope::ok(
CMD,
json!({
"question": question,
"relevant_pages": relevant,
"format": format,
"answer": answer,
"answer_chars": answer_chars,
"answer_slug": answer_slug,
"saved_path": saved_path,
}),
)
.with_context(json!({ "session": slug }))
}
}
#[allow(clippy::result_large_err)]
fn resolve_slug(slug_arg: Option<&str>) -> Result<String, Envelope> {
let slug = match slug_arg {
Some(s) => s.to_string(),
None => match active::get_active() {
Some(s) => s,
None => {
return Err(Envelope::fail(
CMD,
"NO_ACTIVE_SESSION",
"no active session — pass --slug or run `research new` first",
));
}
},
};
if !config::exists(&slug) {
return Err(
Envelope::fail(CMD, "SESSION_NOT_FOUND", format!("no session '{slug}'"))
.with_context(json!({ "session": slug })),
);
}
Ok(slug)
}
#[cfg(feature = "autoresearch")]
#[allow(clippy::result_large_err)]
fn make_provider(provider_name: &str) -> Result<Box<dyn AgentProvider>, Envelope> {
match provider_name {
"fake" => {
let resp = std::env::var("ACTIONBOOK_FAKE_QUERY_RESPONSE").unwrap_or_else(|_| {
"FAKE-ANSWER (no ACTIONBOOK_FAKE_QUERY_RESPONSE env)".to_string()
});
Ok(Box::new(FakeProvider::new([resp])))
}
#[cfg(feature = "provider-claude")]
"claude" => Ok(Box::new(ClaudeProvider::new())),
#[cfg(not(feature = "provider-claude"))]
"claude" => Err(Envelope::fail(
CMD,
"PROVIDER_NOT_AVAILABLE",
"provider 'claude' requires the `provider-claude` feature",
)),
#[cfg(feature = "provider-codex")]
"codex" => Ok(Box::new(CodexProvider::new())),
#[cfg(not(feature = "provider-codex"))]
"codex" => Err(Envelope::fail(
CMD,
"PROVIDER_NOT_AVAILABLE",
"provider 'codex' requires the `provider-codex` feature",
)),
other => Err(Envelope::fail(
CMD,
"PROVIDER_NOT_AVAILABLE",
format!("unknown provider '{other}' (expected fake|claude|codex)"),
)),
}
}
fn load_bodies(slug: &str, pages: &[String]) -> HashMap<String, String> {
pages
.iter()
.filter_map(|p| {
let path = layout::session_wiki_page(slug, p);
std::fs::read_to_string(&path).ok().map(|b| (p.clone(), b))
})
.collect()
}
fn pick_relevant(
question: &str,
pages: &[String],
bodies: &HashMap<String, String>,
top_n: usize,
) -> Vec<String> {
let q_tokens = tokenize(question);
if q_tokens.is_empty() {
return pages.iter().take(top_n).cloned().collect();
}
let mut scored: Vec<(usize, String)> = pages
.iter()
.map(|p| {
let body = bodies.get(p).map(String::as_str).unwrap_or("");
let body_tokens = tokenize(body);
let slug_tokens = tokenize(p);
let mut score = 0usize;
for t in &q_tokens {
if slug_tokens.contains(t) {
score += 2;
}
if body_tokens.contains(t) {
score += 1;
}
}
(score, p.clone())
})
.filter(|(s, _)| *s > 0)
.collect();
scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
let mut seeds: Vec<String> = scored.into_iter().take(top_n).map(|(_, p)| p).collect();
let mut seen: HashSet<String> = seeds.iter().cloned().collect();
let cap = top_n * 2;
let mut added: Vec<String> = Vec::new();
for seed in &seeds {
if seeds.len() + added.len() >= cap {
break;
}
let body = bodies.get(seed).map(String::as_str).unwrap_or("");
for link in extract_wiki_links(body) {
if seeds.len() + added.len() >= cap {
break;
}
if seen.insert(link.clone()) && bodies.contains_key(&link) {
added.push(link);
}
}
}
seeds.extend(added);
seeds
}
fn tokenize(s: &str) -> Vec<String> {
s.split(|c: char| !c.is_ascii_alphanumeric() && c != '_' && c != '-')
.filter(|t| t.chars().count() > 2)
.map(|t| t.to_ascii_lowercase())
.collect()
}
fn extract_wiki_links(body: &str) -> Vec<String> {
let mut out = Vec::new();
let bytes = body.as_bytes();
let mut i = 0;
while i + 3 < bytes.len() {
if &bytes[i..i + 2] == b"[["
&& let Some(end) = body[i + 2..].find("]]")
{
let slug = &body[i + 2..i + 2 + end];
if !slug.is_empty()
&& slug
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
{
out.push(slug.to_string());
}
i += 2 + end + 2;
continue;
}
i += 1;
}
out
}
fn build_system_prompt(format: &str) -> String {
let shape = match format {
"comparison" => {
"- Structure the answer as a comparison table or parallel-bullet list when two+ things are being contrasted."
}
"table" => {
"- Prefer a markdown table when answering. Put the citations in a trailing paragraph."
}
_ => "- Write 1–4 short paragraphs of prose.",
};
format!(
r#"You answer research questions by reading a user's private wiki.
{shape}
Ground rules (non-negotiable):
- Cite the wiki page(s) by `[[slug]]` whenever you use a claim from them.
- If the wiki doesn't cover something, say so explicitly — never fabricate.
- If multiple pages disagree, surface the disagreement rather than picking one.
- Stay terse. The user already knows the domain — don't rehash definitions.
- Output markdown only (no code fences wrapping the whole answer)."#
)
}
fn build_user_prompt(
question: &str,
relevant: &[String],
bodies: &HashMap<String, String>,
) -> String {
let mut out = String::new();
out.push_str("## Question\n\n");
out.push_str(question.trim());
out.push_str("\n\n## Retrieved wiki pages\n\n");
for slug in relevant {
out.push_str(&format!("### [[{slug}]]\n\n"));
if let Some(body) = bodies.get(slug) {
let (fm, rest) = wiki::split_frontmatter(body);
if let Some(kind) = &fm.kind {
out.push_str(&format!("_kind: {kind}_\n\n"));
}
let snippet: String = rest.chars().take(4000).collect();
out.push_str(&snippet);
if rest.chars().count() > 4000 {
out.push_str("\n\n… (truncated)\n");
}
}
out.push_str("\n\n");
}
out
}
fn render_save_page(save_slug: &str, question: &str, cited: &[String], answer: &str) -> String {
let today = Utc::now().format("%Y-%m-%d");
let sources_line = if cited.is_empty() {
"sources: []".to_string()
} else {
let list = cited
.iter()
.map(|s| format!("wiki:{s}"))
.collect::<Vec<_>>()
.join(", ");
format!("sources: [{list}]")
};
let related_line = if cited.is_empty() {
"related: []".to_string()
} else {
format!("related: [{}]", cited.join(", "))
};
format!(
"---\nkind: analysis\n{sources_line}\n{related_line}\nupdated: {today}\n---\n# {save_slug}\n\n> Generated from `research wiki query`.\n>\n> Question: {question}\n\n{answer}\n"
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tokenize_drops_short_tokens_and_lowercases() {
let toks = tokenize("The Scheduler Balances Work ");
assert_eq!(toks, vec!["the", "scheduler", "balances", "work"]);
}
#[test]
fn extract_wiki_links_finds_valid_slugs_only() {
let body = "see [[scheduler]] and [[work-stealing]], but not [[Bad Slug]] or []].";
let mut links = extract_wiki_links(body);
links.sort();
assert_eq!(links, vec!["scheduler", "work-stealing"]);
}
#[test]
fn pick_relevant_ranks_slug_name_matches_higher_than_body() {
let pages = vec![
"scheduler".to_string(),
"task-system".to_string(),
"misc".to_string(),
];
let mut bodies = HashMap::new();
bodies.insert("scheduler".into(), "body body body".into());
bodies.insert("task-system".into(), "mentions scheduler here once".into());
bodies.insert("misc".into(), "nothing relevant".into());
let chosen = pick_relevant("scheduler", &pages, &bodies, 2);
assert_eq!(chosen.first().map(String::as_str), Some("scheduler"));
}
#[test]
fn pick_relevant_includes_bfs_hop_of_top_seed() {
let pages = vec![
"scheduler".to_string(),
"worker".to_string(),
"idle".to_string(),
];
let mut bodies = HashMap::new();
bodies.insert(
"scheduler".into(),
"main sched loop. see [[worker]] for details.".into(),
);
bodies.insert("worker".into(), "does the job".into());
bodies.insert("idle".into(), "unrelated".into());
let chosen = pick_relevant("sched", &pages, &bodies, 1);
assert!(chosen.contains(&"scheduler".to_string()));
assert!(chosen.contains(&"worker".to_string()));
assert!(!chosen.contains(&"idle".to_string()));
}
#[test]
fn render_save_page_has_analysis_frontmatter() {
let page = render_save_page(
"scheduler-balancing",
"how does X?",
&["scheduler".into(), "work-stealing".into()],
"The scheduler balances via [[scheduler]].",
);
assert!(page.starts_with("---\nkind: analysis\n"));
assert!(page.contains("sources: [wiki:scheduler, wiki:work-stealing]"));
assert!(page.contains("related: [scheduler, work-stealing]"));
assert!(page.contains("# scheduler-balancing"));
}
#[test]
fn build_user_prompt_includes_question_and_pages() {
let mut bodies = HashMap::new();
bodies.insert(
"scheduler".into(),
"---\nkind: concept\n---\nThe scheduler coordinates workers.".into(),
);
let prompt = build_user_prompt("how?", &["scheduler".into()], &bodies);
assert!(prompt.contains("## Question"));
assert!(prompt.contains("how?"));
assert!(prompt.contains("[[scheduler]]"));
assert!(prompt.contains("kind: concept"));
assert!(prompt.contains("scheduler coordinates workers"));
}
#[test]
fn build_system_prompt_switches_on_format() {
assert!(build_system_prompt("prose").contains("prose"));
assert!(build_system_prompt("comparison").contains("comparison"));
assert!(build_system_prompt("table").contains("markdown table"));
}
}