1use eyre::{Result, WrapErr as _, bail};
4use fs_err as fs;
5use std::path::{Component, Path, PathBuf};
6use talon_core::{
7 ChatAdapter, ChatAskConfig, ChatExpansionConfig, ChatSection, ContainerPath, CredentialsConfig,
8 EmbeddingAdapter, EmbeddingConfig, EndpointAuthConfig, RerankAdapter, RerankConfig,
9 TalonConfig,
10};
11
12pub const CONFIG_FILE_NAME: &str = "config.toml";
14
15pub const CONFIG_DIR_NAME: &str = "talon";
17
18#[must_use]
20pub fn default_config_path() -> PathBuf {
21 let base = non_empty_env_os("XDG_CONFIG_HOME").map_or_else(
22 || {
23 dirs::home_dir()
24 .unwrap_or_else(|| PathBuf::from("."))
25 .join(".config")
26 },
27 PathBuf::from,
28 );
29 base.join(CONFIG_DIR_NAME).join(CONFIG_FILE_NAME)
30}
31
32#[must_use]
34pub fn default_db_path() -> PathBuf {
35 default_db_path_for_workspace("default")
36}
37
38#[must_use]
40pub fn default_db_path_for_workspace(workspace: &str) -> PathBuf {
41 dirs::home_dir()
42 .unwrap_or_else(|| PathBuf::from("."))
43 .join(".talon")
44 .join(format!("{}.db", sanitize_workspace_name(workspace)))
45}
46
47pub const CONFIG_TEMPLATE: &str = r#"# Talon configuration.
49# Location: ~/.config/talon/config.toml
50
51vault_path = "/Users/you/path/to/obsidian"
52# Convention: ~/.talon/{workspace}.db. Update this if you rename the vault.
53db_path = "~/.talon/obsidian.db"
54include_patterns = ["**/*.md"]
55ignore_patterns = [".obsidian/**", ".git/**", "templates/**", "*.canvas"]
56
57[indexer]
58chunk_tokens = 512
59chunk_overlap = 64
60chunk_min_tokens = 16
61
62[search]
63candidate_limit = 60
64limit = 10
65cache_size = 200
66rerank_cache_size = 2000
67rerank_batch_size = 4
68rerank_max_tokens = 128
69
70[embedding]
71base_url = "http://localhost:8000"
72adapter = "tei"
73model = "embed"
74document_model = "embed_chunked"
75context_tokens = 512
76
77[rerank]
78base_url = "http://localhost:8000"
79adapter = "minimal"
80model = "rerank"
81score_scale = "normalized"
82truncate = true
83
84[chat.expansion]
85base_url = "http://localhost:8000/v1"
86model = "bonsai"
87context_tokens = 16000
88max_output_tokens = 768
89
90[chat.ask]
91model = "qwen-smol"
92context_tokens = 65536
93max_output_tokens = 4096
94planning_reasoning_effort = "none"
95synthesis_reasoning_effort = "none"
96
97[mcp.hooks]
98recall_deadline_ms = 20000
99
100# ── Scopes ─────────────────────────────────────────────────────────────────
101# Named vault partitions with priority-based ranking.
102# See docs/CONFIG.md for full reference.
103# Uncomment and edit the Karpathy preset below.
104#
105# [scopes.wiki]
106# glob = ["wiki/**", "concepts/**"]
107# priority = "boosted"
108# default = true
109#
110# ... additional scopes ...
111"#;
112
113pub fn load_config_file(path: &Path) -> Result<TalonConfig> {
119 let content = fs::read_to_string(path)
120 .wrap_err_with(|| format!("failed to read config file: {}", path.display()))?;
121
122 let mut config: TalonConfig = toml::from_str(&content)
123 .wrap_err_with(|| format!("failed to parse config file: {}", path.display()))?;
124 resolve_config_paths(&mut config, path)?;
125 if let Err(message) = config.chunker.validate() {
126 bail!("{message}");
127 }
128
129 Ok(config)
130}
131
132pub fn load_config(explicit_path: Option<&Path>) -> Result<TalonConfig> {
138 let path = explicit_path
139 .map(std::path::Path::to_path_buf)
140 .or_else(|| non_empty_env_path("TALON_CONFIG_FILE"))
141 .unwrap_or_else(default_config_path);
142
143 if !path.exists() {
144 bail!(
145 "config not found at {}, run `talon init` first",
146 path.display()
147 );
148 }
149
150 let mut config = load_config_file(&path)?;
151 config.config_file_path = Some(path);
152
153 if let Some(vault_override) = non_empty_env_path("TALON_VAULT") {
156 config.vault_path = absolutize_path(vault_override, &std::env::current_dir()?);
157 }
158
159 Ok(config)
160}
161
162fn non_empty_env_os(key: &str) -> Option<std::ffi::OsString> {
163 std::env::var_os(key).filter(|value| !value.is_empty())
164}
165
166fn non_empty_env_path(key: &str) -> Option<PathBuf> {
167 std::env::var(key).ok().and_then(|value| {
168 if value.trim().is_empty() {
169 None
170 } else {
171 Some(PathBuf::from(value))
172 }
173 })
174}
175
176pub fn init_config() -> Result<bool> {
184 let path = default_config_path();
185
186 if path.exists() {
187 return Ok(false);
188 }
189
190 if let Some(parent) = path.parent() {
191 fs::create_dir_all(parent)
192 .wrap_err_with(|| format!("failed to create config directory: {}", parent.display()))?;
193 }
194
195 fs::write(&path, CONFIG_TEMPLATE)
196 .wrap_err_with(|| format!("failed to write config file: {}", path.display()))?;
197
198 Ok(true)
199}
200
201#[must_use]
203pub fn default_config_for_vault(vault_path: PathBuf) -> TalonConfig {
204 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
205 let vault_path = absolutize_path(vault_path, &cwd);
206 let db_path = default_db_path_for_workspace(&workspace_name_for_vault(&vault_path));
207
208 TalonConfig {
209 vault_path,
210 db_path,
211 config_file_path: None,
212 include_patterns: vec!["**/*.md".to_string()],
213 ignore_patterns: vec![
214 ".obsidian/**".to_string(),
215 ".git/**".to_string(),
216 "templates/**".to_string(),
217 "*.canvas".to_string(),
218 ],
219 credentials: CredentialsConfig::default(),
220 embedding: EmbeddingConfig {
221 base_url: "http://localhost:8000".to_string(),
222 auth: EndpointAuthConfig::default(),
223 adapter: EmbeddingAdapter::Tei,
224 model: "embed".to_string(),
225 document_model: Some("embed_chunked".to_string()),
226 context_tokens: 512,
227 },
228 rerank: RerankConfig {
229 base_url: "http://localhost:8000".to_string(),
230 auth: EndpointAuthConfig::default(),
231 adapter: RerankAdapter::Minimal,
232 model: "rerank".to_string(),
233 score_scale: talon_core::RerankScoreScale::default(),
234 truncate: true,
235 },
236 chat: ChatSection {
237 expansion: ChatExpansionConfig {
238 base_url: "http://localhost:8000/v1".to_string(),
239 auth: EndpointAuthConfig::default(),
240 adapter: ChatAdapter::default(),
241 model: "bonsai".to_string(),
242 context_tokens: 16_000,
243 max_output_tokens: Some(768),
244 },
245 ask: ChatAskConfig::default(),
246 },
247 mcp: talon_core::McpConfig::default(),
248 scopes: default_karpathy_scopes(),
249 search: talon_core::SearchConfig::default(),
250 inspect: talon_core::InspectConfig::default(),
251 chunker: talon_core::ChunkerConfig::default(),
252 }
253}
254
255fn workspace_name_for_vault(vault_path: &Path) -> String {
256 vault_path
257 .file_name()
258 .and_then(|name| name.to_str())
259 .filter(|name| !name.trim().is_empty())
260 .unwrap_or("default")
261 .to_string()
262}
263
264fn sanitize_workspace_name(value: &str) -> String {
265 let mut out = String::with_capacity(value.len());
266 for ch in value.chars() {
267 if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') {
268 out.push(ch.to_ascii_lowercase());
269 } else {
270 out.push('-');
271 }
272 }
273 let trimmed = out.trim_matches('-');
274 if trimmed.is_empty() {
275 "default".to_string()
276 } else {
277 trimmed.to_string()
278 }
279}
280
281fn resolve_config_paths(config: &mut TalonConfig, config_path: &Path) -> Result<()> {
282 let cwd = std::env::current_dir()?;
283 let config_path = absolutize_path(config_path.to_path_buf(), &cwd);
284 let config_dir = config_path.parent().unwrap_or(&cwd);
285
286 config.vault_path = absolutize_path(config.vault_path.clone(), config_dir);
287 config.db_path = absolutize_path(config.db_path.clone(), config_dir);
288 Ok(())
289}
290
291fn absolutize_path(path: PathBuf, base: &Path) -> PathBuf {
292 let path = expand_tilde(path);
293 if path.is_absolute() {
294 path
295 } else {
296 base.join(path)
297 }
298}
299
300fn expand_tilde(path: PathBuf) -> PathBuf {
301 let Some(home) = dirs::home_dir() else {
302 return path;
303 };
304 let mut components = path.components();
305 match components.next() {
306 Some(Component::Normal(component)) if component == "~" => home.join(components.as_path()),
307 _ => path,
308 }
309}
310
311mod karpathy;
312mod refresh;
313use karpathy::default_karpathy_scopes;
314pub use refresh::{
315 RefreshLockPolicy, refresh_index_if_needed, refresh_index_with_lock, sync_lock_path,
316};
317
318#[must_use]
321pub fn vault_container_path(config: Option<&TalonConfig>) -> Option<ContainerPath> {
322 config.and_then(|c| ContainerPath::parse(c.vault_path.to_string_lossy().as_ref()).ok())
323}
324
325#[cfg(test)]
326mod tests;