Skip to content

Commit d642ddf

Browse files
devwhodevsclaude
andcommitted
chore: v1.0.0 — intelligence: candle runtime, orchestrator, reranker, query expansion
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a67849a commit d642ddf

8 files changed

Lines changed: 237 additions & 31 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "engraph"
3-
version = "0.7.0"
3+
version = "1.0.0"
44
edition = "2024"
55
description = "Local knowledge graph for AI agents. Hybrid search + MCP server for Obsidian vaults."
66
license = "MIT"

src/config.rs

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
use anyhow::{Context, Result};
2-
use serde::Deserialize;
3-
use std::path::PathBuf;
2+
use serde::{Deserialize, Serialize};
3+
use std::path::{Path, PathBuf};
44

55
/// Model override configuration.
6-
#[derive(Debug, Clone, Default, Deserialize)]
6+
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
77
#[serde(default)]
88
pub struct ModelConfig {
99
/// Override embedding model URI (e.g., "hf:repo/file.gguf").
@@ -15,7 +15,7 @@ pub struct ModelConfig {
1515
}
1616

1717
/// Application configuration, loaded from `~/.engraph/config.toml` with CLI overrides.
18-
#[derive(Debug, Clone, Deserialize)]
18+
#[derive(Debug, Clone, Serialize, Deserialize)]
1919
#[serde(default)]
2020
pub struct Config {
2121
/// Path to the Obsidian vault to index.
@@ -91,6 +91,29 @@ impl Config {
9191
pub fn intelligence_enabled(&self) -> bool {
9292
self.intelligence.unwrap_or(false)
9393
}
94+
95+
/// Save config to a specific path.
96+
pub fn save_to(&self, path: &Path) -> Result<()> {
97+
let content = toml::to_string_pretty(self).context("serializing config")?;
98+
std::fs::write(path, content).with_context(|| format!("writing {}", path.display()))?;
99+
Ok(())
100+
}
101+
102+
/// Load config from a specific path.
103+
pub fn load_from(path: &Path) -> Result<Self> {
104+
let contents =
105+
std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
106+
let config: Config =
107+
toml::from_str(&contents).with_context(|| format!("parsing {}", path.display()))?;
108+
Ok(config)
109+
}
110+
111+
/// Save to the default config path (`~/.engraph/config.toml`).
112+
pub fn save(&self) -> Result<()> {
113+
let path = Self::data_dir()?.join("config.toml");
114+
std::fs::create_dir_all(path.parent().unwrap())?;
115+
self.save_to(&path)
116+
}
94117
}
95118

96119
#[cfg(test)]
@@ -192,4 +215,23 @@ rerank = "hf:ggml-org/Qwen3-Reranker-0.6B-Q8_0-GGUF/qwen3-reranker-0.6b-q8_0.ggu
192215
assert_eq!(cfg.intelligence, Some(false));
193216
assert!(!cfg.intelligence_enabled());
194217
}
218+
219+
#[test]
220+
fn test_config_roundtrip_with_intelligence() {
221+
let dir = tempfile::tempdir().unwrap();
222+
let config_path = dir.path().join("config.toml");
223+
224+
let mut cfg = Config::default();
225+
cfg.intelligence = Some(true);
226+
cfg.models.embed = Some("hf:custom/model/embed.gguf".into());
227+
228+
cfg.save_to(&config_path).unwrap();
229+
230+
let loaded = Config::load_from(&config_path).unwrap();
231+
assert_eq!(loaded.intelligence, Some(true));
232+
assert_eq!(
233+
loaded.models.embed,
234+
Some("hf:custom/model/embed.gguf".into())
235+
);
236+
}
195237
}

src/indexer.rs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -446,9 +446,7 @@ pub fn run_index(vault_path: &Path, config: &Config, rebuild: bool) -> Result<In
446446
let model_dim = embedder.dim();
447447
let mut rebuild = rebuild;
448448
if store.has_dimension_mismatch(model_dim)? {
449-
eprintln!(
450-
"Embedding model upgraded. Re-indexing vault (this may take a few minutes)..."
451-
);
449+
eprintln!("Embedding model upgraded. Re-indexing vault (this may take a few minutes)...");
452450
store.reset_for_reindex(model_dim)?;
453451
rebuild = true; // Force full rebuild
454452
}

src/llm.rs

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1023,8 +1023,8 @@ pub fn heuristic_orchestrate(query: &str) -> OrchestrationResult {
10231023
let mut expansions = vec![trimmed.to_string()];
10241024
if words.len() > 2 {
10251025
let stopwords = [
1026-
"how", "does", "the", "a", "an", "is", "are", "was", "to", "in", "on", "for",
1027-
"with", "what", "when", "where",
1026+
"how", "does", "the", "a", "an", "is", "are", "was", "to", "in", "on", "for", "with",
1027+
"what", "when", "where",
10281028
];
10291029
for word in &words {
10301030
if word.len() > 2 && !stopwords.contains(&word.to_lowercase().as_str()) {
@@ -1150,9 +1150,10 @@ impl CandleOrchestrator {
11501150
.map_err(|e| anyhow::anyhow!("opening GGUF {}: {e}", model_path.display()))?;
11511151
let ct = candle_core::quantized::gguf_file::Content::read(&mut file)
11521152
.map_err(|e| anyhow::anyhow!("reading GGUF {}: {e}", model_path.display()))?;
1153-
let model =
1154-
candle_transformers::models::quantized_qwen3::ModelWeights::from_gguf(ct, &mut file, &device)
1155-
.map_err(|e| anyhow::anyhow!("loading Qwen3 model weights: {e}"))?;
1153+
let model = candle_transformers::models::quantized_qwen3::ModelWeights::from_gguf(
1154+
ct, &mut file, &device,
1155+
)
1156+
.map_err(|e| anyhow::anyhow!("loading Qwen3 model weights: {e}"))?;
11561157

11571158
tracing::info!(
11581159
"loaded CandleOrchestrator from {}, device={:?}",
@@ -1314,9 +1315,7 @@ impl OrchestratorModel for CandleOrchestrator {
13141315
}
13151316
},
13161317
Err(e) => {
1317-
tracing::warn!(
1318-
"orchestrator generation failed, falling back to heuristic: {e:#}"
1319-
);
1318+
tracing::warn!("orchestrator generation failed, falling back to heuristic: {e:#}");
13201319
Ok(heuristic_orchestrate(query))
13211320
}
13221321
}
@@ -1396,9 +1395,10 @@ impl CandleRerank {
13961395
.map_err(|e| anyhow::anyhow!("opening GGUF {}: {e}", model_path.display()))?;
13971396
let ct = candle_core::quantized::gguf_file::Content::read(&mut file)
13981397
.map_err(|e| anyhow::anyhow!("reading GGUF {}: {e}", model_path.display()))?;
1399-
let model =
1400-
candle_transformers::models::quantized_qwen3::ModelWeights::from_gguf(ct, &mut file, &device)
1401-
.map_err(|e| anyhow::anyhow!("loading Qwen3 reranker model weights: {e}"))?;
1398+
let model = candle_transformers::models::quantized_qwen3::ModelWeights::from_gguf(
1399+
ct, &mut file, &device,
1400+
)
1401+
.map_err(|e| anyhow::anyhow!("loading Qwen3 reranker model weights: {e}"))?;
14021402

14031403
tracing::info!(
14041404
"loaded CandleRerank from {}, device={:?}, yes_id={}, no_id={}",
@@ -1712,7 +1712,11 @@ mod tests {
17121712
fn test_heuristic_orchestrate_multi_word() {
17131713
let result = heuristic_orchestrate("how does auth work");
17141714
assert_eq!(result.intent, QueryIntent::Exploratory);
1715-
assert!(result.expansions.contains(&"how does auth work".to_string()));
1715+
assert!(
1716+
result
1717+
.expansions
1718+
.contains(&"how does auth work".to_string())
1719+
);
17161720
assert!(result.expansions.len() > 1);
17171721
}
17181722

src/main.rs

Lines changed: 118 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,20 @@ enum Command {
7272
path: Option<PathBuf>,
7373
},
7474

75-
/// Interactively configure vault profile.
76-
Configure,
75+
/// Configure engraph settings.
76+
Configure {
77+
/// Enable intelligence features.
78+
#[arg(long, conflicts_with = "disable_intelligence")]
79+
enable_intelligence: bool,
80+
81+
/// Disable intelligence features.
82+
#[arg(long, conflicts_with = "enable_intelligence")]
83+
disable_intelligence: bool,
84+
85+
/// Override a model: --model embed|rerank|expand <uri>
86+
#[arg(long, num_args = 2, value_names = &["TYPE", "URI"])]
87+
model: Option<Vec<String>>,
88+
},
7789

7890
/// Manage embedding models.
7991
Models {
@@ -206,6 +218,38 @@ enum ModelsAction {
206218
Info { name: String },
207219
}
208220

221+
/// Prompt user to enable intelligence, download models if yes.
222+
fn prompt_intelligence(data_dir: &std::path::Path) -> Result<bool> {
223+
eprint!(
224+
"\nEnable AI-powered search intelligence?\n\n\
225+
This downloads ~1.3GB of additional models for:\n\
226+
\x20 - Query expansion (rewrites your search into multiple variations)\n\
227+
\x20 - Result reranking (LLM scores each result for relevance)\n\n\
228+
Enable now? [y/N] "
229+
);
230+
io::stderr().flush()?;
231+
let mut answer = String::new();
232+
io::stdin().lock().read_line(&mut answer)?;
233+
let enable = answer.trim().eq_ignore_ascii_case("y");
234+
235+
if enable {
236+
let models_dir = data_dir.join("models");
237+
let defaults = engraph::llm::ModelDefaults::default();
238+
println!("Downloading intelligence models (~1.3GB)...");
239+
let rerank_uri = engraph::llm::HfModelUri::parse(&defaults.rerank_uri)?;
240+
engraph::llm::ensure_model(&rerank_uri, &models_dir)?;
241+
let expand_uri = engraph::llm::HfModelUri::parse(&defaults.expand_uri)?;
242+
engraph::llm::ensure_model(&expand_uri, &models_dir)?;
243+
println!("Done.");
244+
} else {
245+
println!(
246+
"Intelligence disabled. You can enable later with: engraph configure --enable-intelligence"
247+
);
248+
}
249+
250+
Ok(enable)
251+
}
252+
209253
/// Check whether an index has been built by looking for engraph.db in data_dir.
210254
fn index_exists(data_dir: &std::path::Path) -> bool {
211255
data_dir.join("engraph.db").exists()
@@ -294,6 +338,13 @@ async fn main() -> Result<()> {
294338
}
295339
}
296340

341+
// First-run intelligence prompt (only if not yet configured)
342+
if cfg.intelligence.is_none() {
343+
let enable = prompt_intelligence(&data_dir)?;
344+
cfg.intelligence = Some(enable);
345+
cfg.save()?;
346+
}
347+
297348
let result = indexer::run_index(&vault_path, &cfg, rebuild)?;
298349

299350
println!(
@@ -413,11 +464,74 @@ async fn main() -> Result<()> {
413464

414465
println!();
415466
println!("Wrote {}", data_dir.join("vault.toml").display());
467+
468+
// Intelligence onboarding (only if not yet configured)
469+
if cfg.intelligence.is_none() {
470+
let enable = prompt_intelligence(&data_dir)?;
471+
cfg.intelligence = Some(enable);
472+
cfg.save()?;
473+
}
416474
}
417475

418-
Command::Configure => {
476+
Command::Configure {
477+
enable_intelligence,
478+
disable_intelligence,
479+
model,
480+
} => {
481+
let mut cfg = Config::load()?;
482+
483+
if enable_intelligence {
484+
cfg.intelligence = Some(true);
485+
println!("Intelligence enabled. Models will be downloaded on first search.");
486+
let models_dir = data_dir.join("models");
487+
let defaults = engraph::llm::ModelDefaults::default();
488+
println!("Downloading intelligence models (~1.3GB)...");
489+
let rerank_uri = engraph::llm::HfModelUri::parse(
490+
cfg.models.rerank.as_deref().unwrap_or(&defaults.rerank_uri),
491+
)?;
492+
engraph::llm::ensure_model(&rerank_uri, &models_dir)?;
493+
let expand_uri = engraph::llm::HfModelUri::parse(
494+
cfg.models.expand.as_deref().unwrap_or(&defaults.expand_uri),
495+
)?;
496+
engraph::llm::ensure_model(&expand_uri, &models_dir)?;
497+
println!("Done.");
498+
} else if disable_intelligence {
499+
cfg.intelligence = Some(false);
500+
println!("Intelligence disabled. Models remain cached.");
501+
}
502+
503+
if let Some(parts) = model
504+
&& parts.len() == 2
505+
{
506+
let model_type = &parts[0];
507+
let uri = &parts[1];
508+
engraph::llm::HfModelUri::parse(uri)?;
509+
match model_type.as_str() {
510+
"embed" => {
511+
cfg.models.embed = Some(uri.clone());
512+
println!("Embedding model set to: {uri}");
513+
println!("Warning: Next 'engraph index' will re-embed your entire vault.");
514+
}
515+
"rerank" => {
516+
cfg.models.rerank = Some(uri.clone());
517+
println!("Reranker model set to: {uri}");
518+
}
519+
"expand" => {
520+
cfg.models.expand = Some(uri.clone());
521+
println!("Expansion model set to: {uri}");
522+
}
523+
other => {
524+
anyhow::bail!(
525+
"Unknown model type: {other}. Use: embed, rerank, or expand."
526+
);
527+
}
528+
}
529+
}
530+
531+
cfg.save()?;
419532
println!(
420-
"Interactive configuration not yet implemented. Run 'engraph init' for auto-detection."
533+
"Configuration saved to {}",
534+
data_dir.join("config.toml").display()
421535
);
422536
}
423537

src/search.rs

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -98,9 +98,13 @@ pub fn search_with_intelligence(
9898

9999
for expanded_query in &orchestration.expansions {
100100
// Semantic lane
101-
let query_vec = embedder.embed_one(expanded_query).context("embedding query")?;
101+
let query_vec = embedder
102+
.embed_one(expanded_query)
103+
.context("embedding query")?;
102104
let tombstones = std::collections::HashSet::new();
103-
let raw_results = config.store.search_vec(&query_vec, top_n * 3, &tombstones)?;
105+
let raw_results = config
106+
.store
107+
.search_vec(&query_vec, top_n * 3, &tombstones)?;
104108

105109
// Group semantic results by file_path, keeping best per file.
106110
let mut sem_by_file: HashMap<String, RankedResult> = HashMap::new();
@@ -196,8 +200,9 @@ pub fn search_with_intelligence(
196200
let final_fused = if let Some(reranker) = &mut config.reranker {
197201
let mut rerank_results: Vec<RankedResult> = Vec::new();
198202
for candidate in fused_pass1.iter().take(config.rerank_candidates) {
199-
let score =
200-
reranker.rerank_score(query, &candidate.snippet).unwrap_or(0.0) as f64;
203+
let score = reranker
204+
.rerank_score(query, &candidate.snippet)
205+
.unwrap_or(0.0) as f64;
201206
rerank_results.push(RankedResult {
202207
file_path: candidate.file_path.clone(),
203208
file_id: candidate.file_id,
@@ -347,7 +352,11 @@ pub fn run_status(json: bool, data_dir: &Path) -> Result<()> {
347352
let model_name = "all-MiniLM-L6-v2";
348353

349354
let config = crate::config::Config::load().unwrap_or_default();
350-
let intelligence = if config.intelligence_enabled() { "enabled" } else { "disabled" };
355+
let intelligence = if config.intelligence_enabled() {
356+
"enabled"
357+
} else {
358+
"disabled"
359+
};
351360

352361
let output = format_status(&stats, index_size, model_name, intelligence, json);
353362
print!("{output}");

0 commit comments

Comments
 (0)