fix: better handling of dev brain

This commit is contained in:
Georg Bauer
2026-07-27 21:42:58 +02:00
parent 9a55a49b00
commit 6001e7312b
5 changed files with 421 additions and 282 deletions

View File

@@ -98,16 +98,16 @@ To set it up:
Useful requests include “Build Dev Brain for these projects,” “Refresh Dev
Brain after my recent changes,” “What does Dev Brain know about the cache
invariants?”, and “Check the wiki, then verify this against the source.” Search
and reads use a disposable local SQLite full-text index. The index checks vault
files and cited project evidence before use, so no separate indexing service or
manual refresh button is needed.
invariants?”, and “Check the wiki, then verify this against the source.” The
agent gets the real vault folder from Dev Brain, then uses its normal list,
read, search, write, and edit tools on the Markdown. A dedicated validation tool
checks the result and refreshes the disposable local SQLite search index.
The vault remains ordinary Obsidian Markdown:
- **purpose.md** records its scope, priorities, and recurring questions.
- **schema.md** is the detailed contract the agent follows when maintaining it.
- **index.md** catalogs every managed topic page.
- **index.md** is a generated catalog of every managed topic page.
- **log.md** is an append-only history of material updates.
- Topic pages live under **projects/**, **subsystems/**, **concepts/**,
**decisions/**, **invariants/**, and **workflows/** as needed.
@@ -115,17 +115,20 @@ The vault remains ordinary Obsidian Markdown:
Every topic cites exact project files and a Git revision or content hash.
**Verified** pages may support answers. **Stale** pages are navigation leads
whose evidence changed, and **needs-review** pages contain visible uncertainty;
the agent must recheck project sources before relying on either. Wiki updates
are validated as a complete batch before publication, so a broken link,
out-of-bounds path, invalid citation, or stale verified claim leaves the last
valid wiki untouched.
the agent must recheck project sources before relying on either. After editing,
the agent runs validation. Unsafe paths and invalid page structure or provenance
are errors to repair in place. Broken links are reported as warnings: the valid
page stays on disk, so the agent can make a small edit or create the missing
page without regenerating its work. Validation rebuilds **index.md**
deterministically instead of asking the model to keep it synchronized.
DS4Server may modify only the four contract files and topic pages explicitly
marked as Dev Brain-managed. It does not modify Obsidian settings, attachments,
trash, hidden files, or unrelated notes. You can inspect, edit, link, and
version the managed Markdown normally; invalid manual edits are reported rather
than silently trusted. Disabling Dev Brain removes its tools and instructions
from the built-in coding agent without deleting the vault.
marked as Dev Brain-managed. Standard agent file tools can access the vault but
cannot modify Obsidian settings, hidden files, or unrelated notes. You can
inspect, edit, link, and version the managed Markdown normally; invalid manual
edits are reported rather than silently trusted. Disabling Dev Brain removes its
folder, search, and validation guidance from the built-in coding agent without
deleting the vault.
## Views and sidebar

View File

@@ -190,7 +190,7 @@ const TOOL_SCHEMAS: &str = r#"{"type":"function","function":{"name":"google_sear
{"type":"function","function":{"name":"more","description":"Continue the previous read-like output.","parameters":{"type":"object","properties":{"count":{"type":"number"}}}}}
{"type":"function","function":{"name":"write","description":"Create or overwrite a text file.","parameters":{"type":"object","properties":{"path":{"type":"string"},"content":{"type":"string"}},"required":["path","content"]}}}
{"type":"function","function":{"name":"edit","description":"Replace exactly one old text match; old may contain [upto] between unique head and tail anchors.","parameters":{"type":"object","properties":{"path":{"type":"string"},"old":{"type":"string"},"new":{"type":"string"}},"required":["path","old","new"]}}}
{"type":"function","function":{"name":"search","description":"Search files and return compact edit-friendly matches.","parameters":{"type":"object","properties":{"query":{"type":"string"},"path":{"type":"string"},"mode":{"type":"string"},"glob":{"type":"string"},"context":{"type":"number"},"max_results":{"type":"number"},"case_sensitive":{"type":"boolean"}},"required":["query"]}}}
{"type":"function","function":{"name":"search","description":"Search files and return compact edit-friendly matches. Search is literal by default; set mode to regex for patterns such as foo|bar.","parameters":{"type":"object","properties":{"query":{"type":"string"},"path":{"type":"string"},"mode":{"type":"string","enum":["literal","regex"]},"glob":{"type":"string"},"context":{"type":"number"},"max_results":{"type":"number"},"case_sensitive":{"type":"boolean"}},"required":["query"]}}}
{"type":"function","function":{"name":"list","description":"List one directory compactly.","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}}}"#;
#[derive(Clone, Debug, PartialEq)]
@@ -343,9 +343,9 @@ impl Tools {
"bash_stop" => self.bash_observe(call, true, cancel),
"google_search" => self.google_search(call, cancel),
"visit_page" => self.visit_page(call, cancel),
"dev_brain_info" => self.dev_brain_info(),
"dev_brain_search" => self.dev_brain_search(call),
"dev_brain_read" => self.dev_brain_read(call),
"dev_brain_publish" => self.dev_brain_publish(call),
"dev_brain_validate" => self.dev_brain_validate(),
name => Err(format!("unknown tool: {name}")),
};
match result {
@@ -373,42 +373,19 @@ impl Tools {
.search(query, limit, authoritative)
}
fn dev_brain_read(&mut self, call: &ToolCall) -> Result<String, String> {
let path = required_string(call, "path")?;
let authoritative = boolean(call, "authoritative", false);
self.dev_brain
.as_mut()
fn dev_brain_info(&self) -> Result<String, String> {
Ok(self
.dev_brain
.as_ref()
.ok_or_else(|| "Dev Brain is disabled for this session.".to_owned())?
.read(path, authoritative)
.info())
}
fn dev_brain_publish(&mut self, call: &ToolCall) -> Result<String, String> {
let files = call
.arguments
.get("files")
.and_then(Value::as_object)
.ok_or_else(|| "dev_brain_publish requires a files object.".to_owned())?;
let remove = call
.arguments
.get("remove")
.map(|value| {
value
.as_array()
.ok_or_else(|| "remove must be an array of paths.".to_owned())?
.iter()
.map(|path| {
path.as_str()
.map(str::to_owned)
.ok_or_else(|| "remove paths must be strings.".to_owned())
})
.collect::<Result<Vec<_>, String>>()
})
.transpose()?
.unwrap_or_default();
fn dev_brain_validate(&mut self) -> Result<String, String> {
self.dev_brain
.as_mut()
.ok_or_else(|| "Dev Brain is disabled for this session.".to_owned())?
.publish(files, &remove)
.validate()
}
fn result_limit(&self) -> usize {
@@ -456,7 +433,7 @@ impl Tools {
let path = path
.canonicalize()
.map_err(|error| format!("open {value}: {error}"))?;
self.inside_project(path, value)
self.inside_readable_root(path, value)
}
fn writable_path(&self, value: &str) -> Result<PathBuf, String> {
@@ -472,7 +449,8 @@ impl Tools {
self.root.join(value)
};
if fs::symlink_metadata(&path).is_ok() {
return self.existing_path(value);
let path = self.existing_path(value)?;
return self.inside_writable_root(path, value);
}
let mut ancestor = path.as_path();
let mut suffix = Vec::new();
@@ -488,21 +466,54 @@ impl Tools {
let mut resolved = ancestor
.canonicalize()
.map_err(|error| format!("open ancestor of {value}: {error}"))?;
self.inside_project(resolved.clone(), value)?;
self.inside_readable_root(resolved.clone(), value)?;
for name in suffix.into_iter().rev() {
resolved.push(name);
}
Ok(resolved)
self.inside_writable_root(resolved, value)
}
fn inside_project(&self, path: PathBuf, original: &str) -> Result<PathBuf, String> {
fn inside_readable_root(&self, path: PathBuf, original: &str) -> Result<PathBuf, String> {
if path.starts_with(&self.root) {
return Ok(path);
}
if let Some(brain) = &self.dev_brain
&& let Ok(relative) = path.strip_prefix(brain.folder())
&& !relative
.components()
.any(|component| component.as_os_str().to_string_lossy().starts_with('.'))
{
return Ok(path);
}
Err(format!(
"path is outside the project and managed Dev Brain folder: {original}"
))
}
fn inside_writable_root(&self, path: PathBuf, original: &str) -> Result<PathBuf, String> {
if path.starts_with(&self.root)
|| self
.dev_brain
.as_ref()
.is_some_and(|brain| brain.allows_tool_write(&path))
{
Ok(path)
} else {
Err(format!("path is outside the project: {original}"))
Err(format!(
"path is not a managed project or Dev Brain file: {original}"
))
}
}
fn validate_dev_brain_content(&self, path: &Path, content: &str) -> Result<(), String> {
if let Some(brain) = &self.dev_brain
&& path.starts_with(brain.folder())
{
brain.validate_tool_content(path, content)?;
}
Ok(())
}
fn default_lines(&self) -> usize {
match self.context_tokens {
..=8192 => 120,
@@ -628,6 +639,16 @@ impl Tools {
return Err(format!("content exceeds {MAX_FILE_BYTES} bytes"));
}
let path = self.writable_path(display)?;
self.validate_dev_brain_content(&path, content)?;
if self
.dev_brain
.as_ref()
.is_some_and(|brain| path.starts_with(brain.folder()))
&& let Some(parent) = path.parent()
{
fs::create_dir_all(parent)
.map_err(|error| format!("create parent for {display}: {error}"))?;
}
fs::write(&path, content).map_err(|error| format!("write {display}: {error}"))?;
Ok(format!("Wrote {} bytes to {display}\n", content.len()))
}
@@ -640,6 +661,7 @@ impl Tools {
return Err("edit requires non-empty old text".into());
}
let path = self.existing_path(display)?;
self.inside_writable_root(path.clone(), display)?;
if path.metadata().map_err(|error| error.to_string())?.len() > MAX_FILE_BYTES {
return Err(format!(
"file too large: {display} exceeds {MAX_FILE_BYTES} bytes"
@@ -654,6 +676,7 @@ impl Tools {
output.push_str(&data[..start]);
output.push_str(new);
output.push_str(&data[end..]);
self.validate_dev_brain_content(&path, &output)?;
fs::write(&path, output).map_err(|error| format!("write {display}: {error}"))?;
Ok(format!(
"Edited {display} using {} replacement\n",
@@ -676,8 +699,15 @@ impl Tools {
.filter_map(Result::ok)
.collect::<Vec<_>>();
entries.sort_by_key(|entry| entry.file_name());
let dev_brain_path = self
.dev_brain
.as_ref()
.is_some_and(|brain| path.starts_with(brain.folder()));
let mut output = format!("{display}:\n");
for entry in entries.iter().take(300) {
if dev_brain_path && entry.file_name().to_string_lossy().starts_with('.') {
continue;
}
let metadata = fs::symlink_metadata(entry.path()).map_err(|error| error.to_string())?;
let kind = if metadata.file_type().is_symlink() {
'l'
@@ -713,7 +743,12 @@ impl Tools {
context,
limit,
};
search_path(&self.root, &path, &options)
let root = self
.dev_brain
.as_ref()
.filter(|brain| path.starts_with(brain.folder()))
.map_or(self.root.as_path(), |brain| brain.folder());
search_path(root, &path, &options, root != self.root.as_path())
}
fn bash(&mut self, call: &ToolCall, cancel: &AtomicBool) -> Result<String, String> {
@@ -948,9 +983,14 @@ struct SearchOptions<'a> {
limit: usize,
}
fn search_path(root: &Path, path: &Path, options: &SearchOptions<'_>) -> Result<String, String> {
fn search_path(
root: &Path,
path: &Path,
options: &SearchOptions<'_>,
skip_hidden: bool,
) -> Result<String, String> {
let mut files = Vec::new();
collect_search_files(path, 0, &mut files)?;
collect_search_files(path, 0, skip_hidden, &mut files)?;
let mut matches = 0;
let mut body = String::new();
for file in files {
@@ -991,6 +1031,7 @@ fn search_path(root: &Path, path: &Path, options: &SearchOptions<'_>) -> Result<
fn collect_search_files(
path: &Path,
depth: usize,
skip_hidden: bool,
output: &mut Vec<PathBuf>,
) -> Result<(), String> {
if depth > 24 {
@@ -1015,10 +1056,12 @@ fn collect_search_files(
.collect::<Vec<_>>();
entries.sort_by_key(|entry| entry.file_name());
for entry in entries {
if entry.file_name() == ".git" {
if entry.file_name() == ".git"
|| skip_hidden && entry.file_name().to_string_lossy().starts_with('.')
{
continue;
}
collect_search_files(&entry.path(), depth + 1, output)?;
collect_search_files(&entry.path(), depth + 1, skip_hidden, output)?;
}
Ok(())
}
@@ -1748,7 +1791,11 @@ mod tests {
.contains("[System prompt reminder follows.]")
);
assert!(!prompt.contains("dev_brain_search"));
assert!(system_prompt(ModelChoice::DeepSeekV4Flash, "", true).contains("dev_brain_search"));
let dev_brain_prompt = system_prompt(ModelChoice::DeepSeekV4Flash, "", true);
for name in ["dev_brain_info", "dev_brain_search", "dev_brain_validate"] {
assert!(dev_brain_prompt.contains(name));
}
assert!(!dev_brain_prompt.contains("dev_brain_publish"));
assert!(datetime_context().starts_with("Current local date and time at session start:"));
assert!(!prompt_reminder_due(49_999, 0));
assert!(prompt_reminder_due(50_000, 0));
@@ -1809,6 +1856,77 @@ mod tests {
fs::remove_dir_all(outside).unwrap();
}
#[test]
fn standard_file_tools_can_maintain_only_managed_dev_brain_pages() {
let directory = std::env::temp_dir().join(format!(
"ds4-agent-brain-{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let project = directory.join("project");
let vault = directory.join("vault");
fs::create_dir_all(&project).unwrap();
fs::create_dir_all(vault.join(".obsidian")).unwrap();
fs::write(project.join("source.rs"), "source\n").unwrap();
let mut tools = Tools::new(&project, 4096).unwrap();
tools
.enable_dev_brain(
&crate::config::DevBrainConfig {
enabled: true,
vault_path: Some(vault.to_string_lossy().into_owned()),
},
&[crate::database::Project {
id: 1,
name: "Fixture".into(),
path: project.to_string_lossy().into_owned(),
collapsed: false,
}],
)
.unwrap();
assert!(
tools
.existing_path(vault.join("schema.md").to_str().unwrap())
.is_ok()
);
assert!(
tools
.writable_path(vault.join("concepts/new.md").to_str().unwrap())
.is_ok()
);
let nested = vault.join("subsystems/inference/modes.md");
let mut arguments = Map::new();
arguments.insert(
"path".into(),
Value::String(nested.to_string_lossy().into_owned()),
);
arguments.insert(
"content".into(),
Value::String("---\ndev_brain: true\n---\n# Modes\n".into()),
);
tools
.write(&ToolCall {
name: "write".into(),
arguments,
})
.unwrap();
assert!(nested.is_file());
assert!(
tools
.existing_path(vault.join(".obsidian").to_str().unwrap())
.is_err()
);
fs::write(vault.join("concepts/private.md"), "# Private\n").unwrap();
assert!(
tools
.writable_path(vault.join("concepts/private.md").to_str().unwrap())
.is_err()
);
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn risky_shell_commands_require_one_time_approval() {
let root = Path::new("/tmp/project");

View File

@@ -380,6 +380,19 @@ fn title_context(messages: impl IntoIterator<Item = ChatTurn>) -> Vec<ChatTurn>
.collect()
}
#[cfg(any(target_os = "macos", test))]
fn agents_prompt_for_turn(
opening_turn: bool,
opening_prompt: Option<String>,
stored_prompt: Option<&str>,
) -> Option<String> {
if opening_turn {
opening_prompt
} else {
stored_prompt.map(str::to_owned)
}
}
impl App {
fn chat_system_prompt(&self, model: ModelChoice, prompt: &str, agents: Option<&str>) -> String {
let mut prompt = crate::agent::system_prompt(model, prompt, self.config.dev_brain.enabled);
@@ -445,7 +458,7 @@ impl App {
#[cfg(target_os = "macos")]
let opening_turn = self.selected_session.is_none();
#[cfg(target_os = "macos")]
let agents = if opening_turn {
let opening_agents = if opening_turn {
let Some(project) = self
.projects
.iter()
@@ -464,6 +477,9 @@ impl App {
} else {
None
};
#[cfg(target_os = "macos")]
let agents =
agents_prompt_for_turn(opening_turn, opening_agents, self.session_agents_prompt());
self.a2ui_auto_switch_pending = true;
self.context_notice = None;
#[cfg(target_os = "macos")]
@@ -1860,10 +1876,10 @@ pub(super) fn session_title(reply: &str) -> Option<String> {
#[cfg(test)]
mod tests {
use super::{
ChatMessage, TOOL_PROTOCOL_CORRECTION, TurnSummary, chat_turn, compacted_context_start,
correction_already_sent, has_chat_after_last_compaction, has_misplaced_tool_call,
is_empty_response, project_agents, promote_legacy_turn_summaries, queued_prompt,
sync_a2ui_message, title_context,
ChatMessage, TOOL_PROTOCOL_CORRECTION, TurnSummary, agents_prompt_for_turn, chat_turn,
compacted_context_start, correction_already_sent, has_chat_after_last_compaction,
has_misplaced_tool_call, is_empty_response, project_agents, promote_legacy_turn_summaries,
queued_prompt, sync_a2ui_message, title_context,
};
use crate::engine::ChatTurn;
use crate::model::ModelChoice;
@@ -1919,6 +1935,19 @@ mod tests {
std::fs::remove_dir_all(directory).unwrap();
}
#[test]
fn user_continuations_reuse_the_stored_agents_prompt() {
let stored = "Project AGENTS.md instructions:\nkeep this exact";
assert_eq!(
agents_prompt_for_turn(false, None, Some(stored)).as_deref(),
Some(stored)
);
assert_eq!(
agents_prompt_for_turn(true, Some("opening".into()), Some(stored)).as_deref(),
Some("opening")
);
}
#[test]
fn turn_summary_accumulates_model_continuations() {
let mut summary = TurnSummary::new();

View File

@@ -164,7 +164,7 @@ impl App {
checkbox(self.preference_draft.dev_brain_enabled)
.label("Enable project-backed LLM wiki")
.on_toggle(Message::PreferenceDevBrainEnabledChanged),
"Adds validated search, read, and batch-publication tools for a dedicated Obsidian vault. Disabled means no Dev Brain tools or prompt instructions.",
"Lets the agent use its normal file tools on managed pages in a dedicated Obsidian vault, with indexed search and validation. Disabled means no Dev Brain access or prompt instructions.",
),
row![
text_input(

View File

@@ -2,7 +2,7 @@ use diesel::connection::SimpleConnection;
use diesel::prelude::*;
use diesel::sql_types::{BigInt, Double, Text};
use serde::Deserialize;
use serde_json::{Map, Value};
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::collections::{BTreeSet, HashMap, HashSet};
use std::fs;
@@ -30,11 +30,12 @@ const TOPIC_DIRS: [&str; 6] = [
// readers; use per-vault locks only if concurrent vault throughput matters.
static VAULT_LOCK: RwLock<()> = RwLock::new(());
pub(crate) const PROMPT: &str = "Dev Brain is enabled for this session. It is a project-backed Obsidian wiki, not generic memory. Read dev_brain_read(path=\"schema.md\") before maintaining it. Search verified knowledge with dev_brain_search before broad source exploration; stale and needs-review pages are leads only and must be checked against project sources. Publish wiki changes only through dev_brain_publish so the complete candidate is validated and applied as one batch.";
pub(crate) const PROMPT: &str = r#"# Dev Brain
Dev Brain is a managed Obsidian wiki, not a project directory or generic memory. Call dev_brain_info to get its real folder, then use the ordinary list, read, search, write, and edit tools on those Markdown files. Do not invent a .brain path or use bash for wiki maintenance. Read schema.md before maintaining pages and append material changes to log.md. Call dev_brain_validate after edits; it rebuilds index.md and reports broken links as repairable warnings without discarding content. Fix warnings with ordinary edits or by creating the missing page."#;
pub(crate) const TOOL_SCHEMAS: &str = r#"{"type":"function","function":{"name":"dev_brain_search","description":"Search the validated Dev Brain wiki and return ranked pages, graph context, and project evidence. Authoritative search excludes stale and needs-review pages.","parameters":{"type":"object","properties":{"query":{"type":"string"},"limit":{"type":"number"},"authoritative":{"type":"boolean"}},"required":["query"]}}}
{"type":"function","function":{"name":"dev_brain_read","description":"Read one indexed Dev Brain page with freshness status and project evidence.","parameters":{"type":"object","properties":{"path":{"type":"string"},"authoritative":{"type":"boolean"}},"required":["path"]}}}
{"type":"function","function":{"name":"dev_brain_publish","description":"Validate and publish one candidate batch of managed wiki changes. Files overlays the current managed snapshot; remove retires managed topic pages. The whole resulting wiki is validated before any live file changes.","parameters":{"type":"object","properties":{"files":{"type":"object","additionalProperties":{"type":"string"}},"remove":{"type":"array","items":{"type":"string"}}},"required":["files"]}}}"#;
pub(crate) const TOOL_SCHEMAS: &str = r#"{"type":"function","function":{"name":"dev_brain_info","description":"Return the Dev Brain folder and its managed layout. Use ordinary file tools on the returned paths.","parameters":{"type":"object","properties":{}}}}
{"type":"function","function":{"name":"dev_brain_search","description":"Search the validated Dev Brain index with freshness and project evidence. Use ordinary search for literal or regex file search.","parameters":{"type":"object","properties":{"query":{"type":"string"},"limit":{"type":"number"},"authoritative":{"type":"boolean"}},"required":["query"]}}}
{"type":"function","function":{"name":"dev_brain_validate","description":"Validate managed pages after ordinary file edits, deterministically rebuild index.md, refresh search, and report repairable link warnings.","parameters":{"type":"object","properties":{}}}}"#;
const DEFAULT_PURPOSE: &str = "# Dev Brain purpose\n\n\
Dev Brain compiles durable, source-backed knowledge from the registered projects.\n\n\
@@ -92,7 +93,7 @@ When evidence changes, re-read the affected code, documentation, and tests. Upda
## Publication
Use `dev_brain_publish` for every managed change. It builds a candidate snapshot, checks structure, paths, graph resolution, unique identities, index coverage, provenance, and freshness, then publishes the batch. `log.md` is append-only.
Call `dev_brain_info`, then maintain these pages with the ordinary file tools. Append material updates to `log.md`; `index.md` is generated and should not be hand-maintained. Finish with `dev_brain_validate`. Structural or provenance errors must be repaired, while broken links are warnings so valid work remains editable instead of being discarded.
"#;
#[derive(Clone)]
@@ -219,6 +220,7 @@ pub(crate) struct DevBrain {
projects: Vec<RegisteredProject>,
connection: SqliteConnection,
fingerprint: Vec<Fingerprint>,
index_error: Option<String>,
}
impl DevBrain {
@@ -259,11 +261,59 @@ impl DevBrain {
projects,
connection,
fingerprint: Vec::new(),
index_error: None,
};
brain.rebuild()?;
brain.index_error = brain.rebuild().err();
Ok(brain)
}
pub(crate) fn folder(&self) -> &Path {
&self.vault
}
pub(crate) fn info(&self) -> String {
let mut info = format!(
"Dev Brain folder: {}\nManaged roots: purpose.md, schema.md, index.md, log.md, {}/\nUse ordinary file tools with these absolute paths. Topic pages need dev_brain: true frontmatter. Run dev_brain_validate after changes; index.md is generated.\n",
self.vault.display(),
TOPIC_DIRS.join("/, ")
);
if let Some(error) = &self.index_error {
info.push_str(&format!(
"The current wiki needs repair before indexed search: {error}\n"
));
}
info
}
pub(crate) fn allows_tool_write(&self, path: &Path) -> bool {
let Ok(relative) = path.strip_prefix(&self.vault) else {
return false;
};
let display = path_to_string(relative);
if ROOT_PAGES.contains(&display.as_str()) {
return true;
}
if normalize_managed_path(&display).is_err() {
return false;
}
!path.exists()
|| fs::read_to_string(path)
.is_ok_and(|content| is_managed_topic(&content) || content.contains("dev_brain:"))
}
pub(crate) fn validate_tool_content(&self, path: &Path, content: &str) -> Result<(), String> {
let relative = path
.strip_prefix(&self.vault)
.map_err(|_| "Path is outside the Dev Brain folder.".to_owned())?;
if is_topic_path(relative) && !is_managed_topic(content) {
return Err(format!(
"Dev Brain topic {} needs valid YAML frontmatter with dev_brain: true.",
relative.display()
));
}
Ok(())
}
pub(crate) fn search(
&mut self,
query: &str,
@@ -273,6 +323,11 @@ impl DevBrain {
let _guard = VAULT_LOCK
.read()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if let Some(error) = &self.index_error {
return Err(format!(
"Dev Brain needs repair before indexed search: {error}. Use ordinary file tools, then dev_brain_validate."
));
}
self.refresh()?;
let query = fts_query(query);
if query.is_empty() {
@@ -320,14 +375,6 @@ impl DevBrain {
Ok(output)
}
pub(crate) fn read(&mut self, value: &str, authoritative: bool) -> Result<String, String> {
let _guard = VAULT_LOCK
.read()
.unwrap_or_else(|poisoned| poisoned.into_inner());
self.refresh()?;
self.read_indexed(value, authoritative)
}
fn read_indexed(&mut self, value: &str, authoritative: bool) -> Result<String, String> {
let normalized = normalize_managed_path(value)
.map(|path| path_to_string(&path))
@@ -367,86 +414,40 @@ impl DevBrain {
Ok(output)
}
pub(crate) fn publish(
&mut self,
files: &Map<String, Value>,
remove: &[String],
) -> Result<String, String> {
pub(crate) fn validate(&mut self) -> Result<String, String> {
let _guard = VAULT_LOCK
.write()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if files.is_empty() && remove.is_empty() {
return Err("Dev Brain publication batch is empty.".into());
let pages = load_and_validate_pages(&self.vault, &self.vault, &self.projects, true)?;
let index = render_index(&pages);
let index_path = self.vault.join("index.md");
let index_changed =
fs::read_to_string(&index_path).map_or(true, |current| current != index);
if index_changed {
fs::write(&index_path, index)
.map_err(|error| format!("Could not update Dev Brain index: {error}"))?;
}
let current = managed_files(&self.vault)?;
let old_log = fs::read_to_string(self.vault.join("log.md")).unwrap_or_default();
let staging = temporary_sibling(&self.vault, "candidate")?;
fs::create_dir(&staging).map_err(|error| error.to_string())?;
let result = (|| {
for path in &current {
let target = staging.join(path);
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).map_err(|error| error.to_string())?;
}
fs::copy(self.vault.join(path), target).map_err(|error| error.to_string())?;
}
let mut changed = BTreeSet::new();
for (raw_path, value) in files {
let path = normalize_managed_path(raw_path)?;
let content = value.as_str().ok_or_else(|| {
format!("Dev Brain file content must be a string: {raw_path}")
})?;
if content.len() > 16 * 1024 * 1024 {
return Err(format!("Dev Brain file is too large: {raw_path}"));
}
if is_topic_path(&path) && !is_managed_topic(content) {
return Err(format!(
"Managed topic {raw_path} must have YAML frontmatter with dev_brain: true."
));
}
let target = staging.join(&path);
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).map_err(|error| error.to_string())?;
}
fs::write(target, content).map_err(|error| error.to_string())?;
changed.insert(path);
}
for raw_path in remove {
let path = normalize_managed_path(raw_path)?;
if ROOT_PAGES.contains(&path_to_string(&path).as_str()) {
return Err(format!(
"Required Dev Brain page cannot be removed: {raw_path}"
));
}
if !current.contains(&path) {
return Err(format!("Refusing to remove an unmanaged note: {raw_path}"));
}
match fs::remove_file(staging.join(&path)) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(error.to_string()),
}
changed.insert(path);
}
let new_log = fs::read_to_string(staging.join("log.md"))
.map_err(|error| format!("Could not read candidate log.md: {error}"))?;
if !new_log.starts_with(&old_log) {
return Err(
"log.md is append-only; the candidate removed or changed history.".into(),
);
}
let pages = load_and_validate_pages(&staging, &self.vault, &self.projects, true)?;
validate_graph(&pages, &staging, &self.vault)?;
commit_batch(&self.vault, &staging, &changed)?;
Ok(changed.len())
})();
let _ = fs::remove_dir_all(&staging);
let count = result?;
let pages = load_and_validate_pages(&self.vault, &self.vault, &self.projects, true)?;
let (_, warnings) = validate_graph(&pages, &self.vault, &self.vault);
self.rebuild()?;
Ok(format!(
"Published {count} managed Dev Brain file change{} after full candidate validation.\n",
if count == 1 { "" } else { "s" }
))
self.index_error = None;
let mut output = format!(
"Dev Brain is structurally valid ({} managed pages); index.md {}.\n",
pages.len(),
if index_changed {
"updated"
} else {
"unchanged"
}
);
if warnings.is_empty() {
output.push_str("No link warnings.\n");
} else {
output.push_str("Repairable warnings (content was not discarded):\n- ");
output.push_str(&warnings.join("\n- "));
output.push('\n');
}
Ok(output)
}
fn refresh(&mut self) -> Result<(), String> {
@@ -458,7 +459,7 @@ impl DevBrain {
fn rebuild(&mut self) -> Result<(), String> {
let pages = load_and_validate_pages(&self.vault, &self.vault, &self.projects, false)?;
let resolved = validate_graph(&pages, &self.vault, &self.vault)?;
let (resolved, _) = validate_graph(&pages, &self.vault, &self.vault);
self.connection
.batch_execute(
"DROP TABLE IF EXISTS page_fts;
@@ -675,34 +676,37 @@ fn ensure_contract(vault: &Path) -> Result<(), String> {
.iter()
.filter(|path| vault.join(path).is_file())
.count();
if present == ROOT_PAGES.len() {
return Ok(());
}
if present != 0 {
if present != 0 && present != ROOT_PAGES.len() {
return Err(
"The selected vault has a partial Dev Brain contract. Provide all of purpose.md, schema.md, index.md, and log.md, or remove the conflicting files."
.into(),
);
}
let staging = temporary_sibling(vault, "contract")?;
fs::create_dir(&staging).map_err(|error| error.to_string())?;
let result = (|| {
for (path, content) in [
("purpose.md", DEFAULT_PURPOSE),
("schema.md", DEFAULT_SCHEMA),
("index.md", DEFAULT_INDEX),
("log.md", DEFAULT_LOG),
] {
fs::write(staging.join(path), content).map_err(|error| error.to_string())?;
}
let changed = ROOT_PAGES
.iter()
.map(PathBuf::from)
.collect::<BTreeSet<_>>();
commit_batch(vault, &staging, &changed)
})();
let _ = fs::remove_dir_all(staging);
result
if present == 0 {
let staging = temporary_sibling(vault, "contract")?;
fs::create_dir(&staging).map_err(|error| error.to_string())?;
let result = (|| {
for (path, content) in [
("purpose.md", DEFAULT_PURPOSE),
("schema.md", DEFAULT_SCHEMA),
("index.md", DEFAULT_INDEX),
("log.md", DEFAULT_LOG),
] {
fs::write(staging.join(path), content).map_err(|error| error.to_string())?;
}
let changed = ROOT_PAGES
.iter()
.map(PathBuf::from)
.collect::<BTreeSet<_>>();
commit_batch(vault, &staging, &changed)
})();
let _ = fs::remove_dir_all(staging);
result?;
}
for directory in TOPIC_DIRS {
fs::create_dir_all(vault.join(directory)).map_err(|error| error.to_string())?;
}
Ok(())
}
pub(crate) fn validate_vault(path: &Path) -> Result<PathBuf, String> {
@@ -889,7 +893,7 @@ fn validate_graph(
pages: &[Page],
content_root: &Path,
vault_root: &Path,
) -> Result<Vec<(String, String, String)>, String> {
) -> (Vec<(String, String, String)>, Vec<String>) {
let by_path = pages
.iter()
.map(|page| (page.path.clone(), page))
@@ -908,10 +912,12 @@ fn validate_graph(
}
}
let mut edges = Vec::new();
let mut errors = Vec::new();
for page in pages {
for link in &page.links {
match resolve_link(page, link, &by_path, &identities, content_root, vault_root)? {
Some(target) => edges.push((
match resolve_link(page, link, &by_path, &identities, content_root, vault_root) {
Err(error) => errors.push(error),
Ok(Some(target)) => edges.push((
page.path.clone(),
target,
if link.kind == LinkType::Embed {
@@ -920,36 +926,56 @@ fn validate_graph(
"wikilink".into()
},
)),
None if link.kind == LinkType::Anchor => {}
None => {
return Err(format!(
"Broken link in {} at line {}: [[{}]]",
page.path, link.line, link.target
));
}
Ok(None) => errors.push(format!(
"Broken link in {} at line {}: [[{}]]",
page.path, link.line, link.target
)),
}
}
}
let indexed = edges
.iter()
.filter(|(source, target, _)| source == "index.md" && is_topic_path(Path::new(target)))
.map(|(_, target, _)| target.clone())
.collect::<BTreeSet<_>>();
(edges, errors)
}
fn render_index(pages: &[Page]) -> String {
let topics = pages
.iter()
.filter(|page| is_topic_path(Path::new(&page.path)))
.map(|page| page.path.clone())
.collect::<BTreeSet<_>>();
if indexed != topics {
let missing = topics.difference(&indexed).cloned().collect::<Vec<_>>();
let extra = indexed.difference(&topics).cloned().collect::<Vec<_>>();
return Err(format!(
"index.md does not match managed topic pages. Missing: [{}]. Extra: [{}].",
missing.join(", "),
extra.join(", ")
));
.collect::<Vec<_>>();
if topics.is_empty() {
return DEFAULT_INDEX.to_owned();
}
Ok(edges)
let mut output = String::from(
"# Dev Brain index\n\n<!-- Generated by dev_brain_validate; edit topic pages, not this list. -->\n",
);
for directory in TOPIC_DIRS {
let mut group = topics
.iter()
.filter(|page| page.path.starts_with(&format!("{directory}/")))
.copied()
.collect::<Vec<_>>();
if group.is_empty() {
continue;
}
group.sort_by(|left, right| left.title.cmp(&right.title));
output.push_str(&format!("\n## {}\n\n", title_case(directory)));
for page in group {
output.push_str(&format!(
"- [[{}|{}]] — {}\n",
page.path,
page.title.replace('|', "-"),
page.status
));
}
}
output
}
fn title_case(value: &str) -> String {
let mut characters = value.chars();
characters
.next()
.map(|first| first.to_uppercase().collect::<String>() + characters.as_str())
.unwrap_or_default()
}
fn resolve_link(
@@ -1468,56 +1494,19 @@ mod tests {
hash_file(&fixture.project.join("source.rs")).unwrap()
}
fn publish_topic(brain: &mut DevBrain, topic: &str) -> Result<String, String> {
let mut files = Map::new();
files.insert("concepts/answer.md".into(), Value::String(topic.into()));
files.insert(
"index.md".into(),
Value::String(
"# Dev Brain index\n\n- [[concepts/answer|Answer]] — Test answer.\n".into(),
),
);
files.insert(
"log.md".into(),
Value::String(format!("{DEFAULT_LOG}\n- Added answer.\n")),
);
brain.publish(&files, &[])
fn write_topic(fixture: &Fixture, brain: &mut DevBrain, topic: &str) -> Result<String, String> {
fs::write(fixture.vault.join("concepts/answer.md"), topic).unwrap();
brain.validate()
}
#[test]
fn path_confinement_and_unrelated_notes_are_preserved() {
let fixture = Fixture::new();
let mut brain = fixture.brain();
fs::create_dir_all(fixture.vault.join("concepts")).unwrap();
let brain = fixture.brain();
fs::write(fixture.vault.join("concepts/private.md"), "# Private\n").unwrap();
let mut files = Map::new();
files.insert("../escape.md".into(), Value::String("bad".into()));
assert!(brain.publish(&files, &[]).is_err());
assert_eq!(
fs::read_to_string(fixture.vault.join("concepts/private.md")).unwrap(),
"# Private\n"
);
let outside = fixture.root.join("outside");
fs::create_dir(&outside).unwrap();
std::os::unix::fs::symlink(&outside, fixture.vault.join("concepts/escape")).unwrap();
let mut files = Map::new();
files.insert(
"concepts/escape/answer.md".into(),
Value::String(fixture.topic("verified", &source_hash(&fixture), "Confined.")),
);
files.insert(
"index.md".into(),
Value::String(
"# Dev Brain index\n\n- [[concepts/escape/answer|Answer]] — Test.\n".into(),
),
);
files.insert(
"log.md".into(),
Value::String(format!("{DEFAULT_LOG}\n- Added confined answer.\n")),
);
assert!(brain.publish(&files, &[]).is_err());
assert!(!outside.join("answer.md").exists());
assert!(!brain.allows_tool_write(&fixture.vault.join("concepts/private.md")));
assert!(brain.allows_tool_write(&brain.folder().join("concepts/new.md")));
assert!(!brain.allows_tool_write(&fixture.root.join("outside.md")));
}
#[test]
@@ -1529,7 +1518,7 @@ mod tests {
&source_hash(&fixture),
"The durable answer is searchable.\n\n`[[Missing]]`\n\n```md\n[[Also Missing]]\n```",
);
publish_topic(&mut brain, &topic).unwrap();
write_topic(&fixture, &mut brain, &topic).unwrap();
let result = brain.search("durable searchable", 10, true).unwrap();
assert!(result.contains("concepts/answer.md"));
assert!(result.contains("Fixture:source.rs"));
@@ -1539,7 +1528,8 @@ mod tests {
fn source_hash_drift_excludes_page_from_authoritative_queries() {
let fixture = Fixture::new();
let mut brain = fixture.brain();
publish_topic(
write_topic(
&fixture,
&mut brain,
&fixture.topic(
"verified",
@@ -1603,7 +1593,7 @@ mod tests {
&format!("revision: {revision}"),
);
let mut brain = fixture.brain();
publish_topic(&mut brain, &topic).unwrap();
write_topic(&fixture, &mut brain, &topic).unwrap();
assert!(
brain
.search("Revision-backed knowledge", 10, true)
@@ -1620,46 +1610,45 @@ mod tests {
}
#[test]
fn broken_links_and_invalid_provenance_leave_live_vault_untouched() {
fn validation_keeps_broken_links_editable_and_reports_provenance_errors() {
let fixture = Fixture::new();
let mut brain = fixture.brain();
let index_before = fs::read_to_string(fixture.vault.join("index.md")).unwrap();
let log_before = fs::read_to_string(fixture.vault.join("log.md")).unwrap();
let bad_topic = fixture.topic(
"verified",
&source_hash(&fixture),
"This has a [[Missing Page]].",
);
assert!(publish_topic(&mut brain, &bad_topic).is_err());
assert_eq!(
fs::read_to_string(fixture.vault.join("index.md")).unwrap(),
index_before
let result = write_topic(&fixture, &mut brain, &bad_topic).unwrap();
assert!(result.contains("Broken link"));
assert!(fixture.vault.join("concepts/answer.md").exists());
assert!(
fs::read_to_string(fixture.vault.join("index.md"))
.unwrap()
.contains("[[concepts/answer.md|Answer]]")
);
assert_eq!(
fs::read_to_string(fixture.vault.join("log.md")).unwrap(),
log_before
);
assert!(!fixture.vault.join("concepts/answer.md").exists());
let missing = fixture
.topic("verified", &source_hash(&fixture), "The linked detail.")
.replace("# Answer", "# Missing Page");
fs::write(fixture.vault.join("concepts/missing.md"), missing).unwrap();
assert!(brain.validate().unwrap().contains("No link warnings"));
let invalid = fixture.topic("verified", &"0".repeat(64), "Wrong evidence.");
assert!(publish_topic(&mut brain, &invalid).is_err());
assert!(!fixture.vault.join("concepts/answer.md").exists());
fs::write(fixture.vault.join("concepts/answer.md"), invalid).unwrap();
assert!(brain.validate().is_err());
let malformed = fixture
.topic("verified", &source_hash(&fixture), "Malformed.")
.replace("verified_at: 2026-07-27T12:00:00Z\n", "");
fs::write(fixture.vault.join("concepts/answer.md"), malformed).unwrap();
drop(brain);
let mut brain = fixture.brain();
assert!(brain.info().contains("needs repair"));
let escaped = fixture
.topic("verified", &source_hash(&fixture), "Escaped evidence.")
.replace("path: source.rs", "path: ../source.rs");
assert!(publish_topic(&mut brain, &escaped).is_err());
assert!(!fixture.vault.join("concepts/answer.md").exists());
let mut files = Map::new();
files.insert(
"log.md".into(),
Value::String("# Dev Brain log\n\nRewritten history.\n".into()),
);
assert!(brain.publish(&files, &[]).is_err());
assert_eq!(
fs::read_to_string(fixture.vault.join("log.md")).unwrap(),
log_before
);
fs::write(
fixture.vault.join("concepts/answer.md"),
fixture.topic("verified", &source_hash(&fixture), "Repaired evidence."),
)
.unwrap();
assert!(brain.validate().unwrap().contains("structurally valid"));
}
}