From d77747023e2fd6fb5bc2fcdb9016df2957d8ed1c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 06:25:26 +0100 Subject: [PATCH] huginn sources: CLI door for SearchSources Co-Authored-By: Claude Sonnet 5.5 --- crates/eureka-state/src/bin/huginn/main.rs | 34 +++++++++++- crates/eureka-state/src/bin/huginn/render.rs | 57 +++++++++++++++++++- crates/eureka-state/tests/common/mod.rs | 6 +++ 3 files changed, 95 insertions(+), 2 deletions(-) diff --git a/crates/eureka-state/src/bin/huginn/main.rs b/crates/eureka-state/src/bin/huginn/main.rs index b1d7f7a..4a5d159 100644 --- a/crates/eureka-state/src/bin/huginn/main.rs +++ b/crates/eureka-state/src/bin/huginn/main.rs @@ -31,7 +31,7 @@ use cultnet_rs::{PROJECTION_DOCUMENT, Selection}; use huginn_mind::eureka_pipeline::{Line, PipelineDocument, PipelineRef, Short, Slug, pipeline_key}; use huginn_mind::{ BATCH_MAX, Faculty, HuginnMindRequest, HuginnMindResponse, MindRefusal, PipelineAdmissionBatch, PipelineAdmissionOutcome, - PipelinePageItems, PipelineProvenance, PipelineSelectionPage, SemanticQuery, + PipelinePageItems, PipelineProvenance, PipelineSelectionPage, SemanticQuery, SourceQuery, }; use serde_json::Value; @@ -83,6 +83,20 @@ enum Command { #[arg(long)] documents: bool, }, + /// Search the public forge repos' code and docs by meaning. + Sources { + /// What to find, in words. + text: String, + /// Look in this repo alone (its forge name). + #[arg(long)] + repo: Option, + /// How many chunks (default 10, at most 50). + #[arg(long)] + top_k: Option, + /// Print the answer as JSON. + #[arg(long)] + json: bool, + }, #[command(flatten)] Recipe(Recipe), /// Admit a batch of documents, whole or not at all, and read it back. @@ -168,6 +182,7 @@ fn run(settings: &Settings, command: Command) -> Result { let semantic = near.map(|text| SemanticQuery { text: Line(text), top_k: top_k.unwrap_or(DEFAULT_TOP_K) }); query(settings, selection, semantic, None, documents) } + Command::Sources { text, repo, top_k, json } => sources(settings, text, repo, top_k, json), Command::Recipe(recipe) => { let ask = recipe.ask()?; query(settings, door::selection_value(&ask.selection)?, ask.semantic, ask.page.cursor, ask.page.documents) @@ -249,6 +264,22 @@ fn query( } } +fn sources(settings: &Settings, text: String, repo: Option, top_k: Option, json: bool) -> Result { + let query = SourceQuery { text: Line(text), top_k: top_k.unwrap_or(DEFAULT_TOP_K), repo: repo.map(Short) }; + match settings.ask(|instance| HuginnMindRequest::SearchSources { instance: instance.clone(), query })? { + HuginnMindResponse::Sources(found) => { + if json { + say(serde_json::to_string_pretty(&found).map_err(|error| Trouble::Internal(error.to_string()))?); + } else { + say(render::source_hits(&found)); + } + Ok(0) + } + HuginnMindResponse::Refused(refusal) => Ok(refused(&refusal)), + other => Err(unexpected(&other)), + } +} + fn show(page: &PipelineSelectionPage) { match &page.items { PipelinePageItems::Headers(headers) => { @@ -392,6 +423,7 @@ fn unexpected(answer: &HuginnMindResponse) -> Trouble { HuginnMindResponse::Query(_) => "query", HuginnMindResponse::Persona(_) => "a persona", HuginnMindResponse::PersonaPut(_) => "a persona put", + HuginnMindResponse::Sources(_) => "source hits", HuginnMindResponse::Refused(_) => "a refusal", HuginnMindResponse::Deferred(_) => "a deferral", }; diff --git a/crates/eureka-state/src/bin/huginn/render.rs b/crates/eureka-state/src/bin/huginn/render.rs index dfef1ec..c437a19 100644 --- a/crates/eureka-state/src/bin/huginn/render.rs +++ b/crates/eureka-state/src/bin/huginn/render.rs @@ -2,7 +2,7 @@ use std::fmt::Write; -use huginn_mind::{IndexStatus, PersonaStatus, PipelineDocumentSummary, PipelineDocumentView, PipelineStatus, PipelineStatusSummary}; +use huginn_mind::{SourceHits, IndexStatus, PersonaStatus, PipelineDocumentSummary, PipelineDocumentView, PipelineStatus, PipelineStatusSummary}; use serde::Serialize; use serde_json::Value; @@ -96,6 +96,32 @@ pub fn header(item: &PipelineDocumentSummary) -> String { line.chars().take(HEADER_MAX_CHARS).collect() } +/// Source hits as the CLI prints them: per hit a citation line, +/// `repo:path:start-end @commit7 score`, then the chunk's text, and a blank line +/// between hits. Nothing for no hits. +pub fn source_hits(found: &SourceHits) -> String { + found + .hits + .iter() + .map(|hit| { + format!( + "{}:{}:{}-{} @{} {:.3} +{} +", + hit.repo, + hit.path, + hit.line_start, + hit.line_end, + &hit.commit[..hit.commit.len().min(7)], + hit.score, + hit.text + ) + }) + .collect::>() + .join(" +") +} + /// A fact as one token: a string, a number, a flag, a reference's id, or a /// list of those joined by commas. Anything deeper is not a header fact. fn scalar(value: &Value) -> Option { @@ -121,6 +147,35 @@ mod tests { use super::*; + /// The citation line is what an agent copies: repo, path, the inclusive + /// line range and the first seven of the commit. + #[test] + fn a_source_hit_prints_as_a_citation_then_its_text() { + let hit = |path: &str, text: &str| huginn_mind::SourceHit { + repo: "Huginn".into(), + path: path.into(), + commit: "0123456789abcdef".into(), + line_start: 3, + line_end: 9, + score: 0.87654, + text: text.into(), + }; + let found = SourceHits { + indexed_under: huginn_mind::SourceModel { name: "m".into(), digest: "d".into() }, + hits: vec![hit("a.rs", "one"), hit("b/c.rs", "two")], + }; + assert_eq!(source_hits(&found), "Huginn:a.rs:3-9 @0123456 0.877 +one + +Huginn:b/c.rs:3-9 @0123456 0.877 +two +"); + let empty = SourceHits { indexed_under: found.indexed_under.clone(), hits: vec![] }; + assert_eq!(source_hits(&empty), ""); + let json = serde_json::to_string(&found).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), found, "--json round-trips"); + } + #[test] fn a_fact_is_one_token_or_nothing() { assert_eq!(scalar(&json!("")), None); diff --git a/crates/eureka-state/tests/common/mod.rs b/crates/eureka-state/tests/common/mod.rs index b2e2be8..eb1ca0d 100644 --- a/crates/eureka-state/tests/common/mod.rs +++ b/crates/eureka-state/tests/common/mod.rs @@ -92,6 +92,12 @@ impl IndexSink for Inert { fn search(&mut self, _text: &str, _top_k: u32, _among: Vec) -> Result { Err("this index cannot search".into()) } + fn search_sources(&mut self, _query: &huginn_mind::SourceQuery) -> Result { + Err("this index cannot search".into()) + } + fn sources_searched(&mut self) -> Vec<(SearchTicket, Result)> { + Vec::new() + } fn search_deadline(&self) -> Duration { Duration::ZERO }