Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,205 changes: 925 additions & 280 deletions Cargo.lock

Large diffs are not rendered by default.

12 changes: 9 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "engraph"
version = "0.7.0"
version = "1.0.0"
edition = "2024"
description = "Local knowledge graph for AI agents. Hybrid search + MCP server for Obsidian vaults."
license = "MIT"
Expand All @@ -20,12 +20,10 @@ anyhow = "1"
rusqlite = { version = "0.32", features = ["bundled"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
ort = { version = "2.0.0-rc.12", features = ["ndarray"] }
tokenizers = { version = "0.22", default-features = false, features = ["fancy-regex"] }
sha2 = "0.10"
ureq = "2.12"
indicatif = "0.17"
ndarray = "0.17"
sqlite-vec = "0.1.8-alpha.1"
zerocopy = { version = "0.7", features = ["derive"] }
rayon = "1"
Expand All @@ -36,6 +34,14 @@ rmcp = { version = "1.2", features = ["transport-io"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
notify = "7.0"
notify-debouncer-full = "0.4"
candle-core = "0.9"
candle-nn = "0.9"
candle-transformers = "0.9"

[features]
default = []
metal = ["candle-core/metal"]
cuda = ["candle-core/cuda"]

[dev-dependencies]
tempfile = "3"
42 changes: 42 additions & 0 deletions assets/demo-search.tape
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
Output assets/demo-search.gif
Set Shell zsh
Set FontSize 14
Set Width 1000
Set Height 600
Set Padding 20
Set Theme "Catppuccin Mocha"
Set TypingSpeed 40ms

Type "# Index an Obsidian vault"
Enter
Sleep 500ms

Type "engraph index ~/vault"
Enter
Sleep 3s

Type ""
Enter
Sleep 300ms

Type "# Search across notes — 3-lane hybrid (semantic + keyword + graph)"
Enter
Sleep 500ms

Type "engraph search 'how does authentication work' --explain"
Enter
Sleep 4s

Type ""
Enter
Sleep 300ms

Type "# Get rich context for an AI agent"
Enter
Sleep 500ms

Type "engraph context who 'Steve Barbera'"
Enter
Sleep 3s

Sleep 2s
102 changes: 99 additions & 3 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,21 @@
use anyhow::{Context, Result};
use serde::Deserialize;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};

/// Model override configuration.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct ModelConfig {
/// Override embedding model URI (e.g., "hf:repo/file.gguf").
pub embed: Option<String>,
/// Override reranker model URI.
pub rerank: Option<String>,
/// Override expansion/orchestrator model URI.
pub expand: Option<String>,
}

/// Application configuration, loaded from `~/.engraph/config.toml` with CLI overrides.
#[derive(Debug, Clone, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
/// Path to the Obsidian vault to index.
Expand All @@ -14,6 +26,10 @@ pub struct Config {
pub exclude: Vec<String>,
/// Number of files to process per embedding batch.
pub batch_size: usize,
/// Whether intelligence features are enabled. None = not yet configured.
pub intelligence: Option<bool>,
/// Model override URIs.
pub models: ModelConfig,
}

impl Default for Config {
Expand All @@ -23,6 +39,8 @@ impl Default for Config {
top_n: 5,
exclude: vec![".obsidian/".to_string()],
batch_size: 64,
intelligence: None,
models: ModelConfig::default(),
}
}
}
Expand Down Expand Up @@ -68,6 +86,34 @@ impl Config {
let dir = Self::data_dir()?;
crate::profile::load_vault_toml(&dir)
}

/// Whether intelligence is enabled (defaults to false if not configured).
pub fn intelligence_enabled(&self) -> bool {
self.intelligence.unwrap_or(false)
}

/// Save config to a specific path.
pub fn save_to(&self, path: &Path) -> Result<()> {
let content = toml::to_string_pretty(self).context("serializing config")?;
std::fs::write(path, content).with_context(|| format!("writing {}", path.display()))?;
Ok(())
}

/// Load config from a specific path.
pub fn load_from(path: &Path) -> Result<Self> {
let contents =
std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
let config: Config =
toml::from_str(&contents).with_context(|| format!("parsing {}", path.display()))?;
Ok(config)
}

/// Save to the default config path (`~/.engraph/config.toml`).
pub fn save(&self) -> Result<()> {
let path = Self::data_dir()?.join("config.toml");
std::fs::create_dir_all(path.parent().unwrap())?;
self.save_to(&path)
}
}

#[cfg(test)]
Expand Down Expand Up @@ -138,4 +184,54 @@ batch_size = 128
let cfg = Config::load().unwrap();
assert_eq!(cfg.batch_size, 64);
}

#[test]
fn parse_intelligence_config() {
let toml_str = r#"
intelligence = true

[models]
embed = "hf:ggml-org/embeddinggemma-300M-GGUF/embeddinggemma-300M-Q8_0.gguf"
rerank = "hf:ggml-org/Qwen3-Reranker-0.6B-Q8_0-GGUF/qwen3-reranker-0.6b-q8_0.gguf"
"#;
let cfg: Config = toml::from_str(toml_str).unwrap();
assert_eq!(cfg.intelligence, Some(true));
assert!(cfg.models.embed.is_some());
assert!(cfg.models.rerank.is_some());
assert!(cfg.models.expand.is_none());
}

#[test]
fn intelligence_defaults_to_none() {
let cfg = Config::default();
assert!(cfg.intelligence.is_none());
assert!(cfg.models.embed.is_none());
}

#[test]
fn intelligence_false_disables_features() {
let toml_str = r#"intelligence = false"#;
let cfg: Config = toml::from_str(toml_str).unwrap();
assert_eq!(cfg.intelligence, Some(false));
assert!(!cfg.intelligence_enabled());
}

#[test]
fn test_config_roundtrip_with_intelligence() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");

let mut cfg = Config::default();
cfg.intelligence = Some(true);
cfg.models.embed = Some("hf:custom/model/embed.gguf".into());

cfg.save_to(&config_path).unwrap();

let loaded = Config::load_from(&config_path).unwrap();
assert_eq!(loaded.intelligence, Some(true));
assert_eq!(
loaded.models.embed,
Some("hf:custom/model/embed.gguf".into())
);
}
}
2 changes: 1 addition & 1 deletion src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -633,7 +633,7 @@ pub fn context_topic_with_search(
params: &ContextParams,
topic: &str,
max_chars: usize,
embedder: &mut crate::embedder::Embedder,
embedder: &mut impl crate::llm::EmbedModel,
) -> Result<ContextBundle> {
let search_output = crate::search::search_internal(topic, 5, params.store, embedder)?;
context_topic_from_results(params, topic, &search_output.results, max_chars)
Expand Down
Loading
Loading