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

@@ -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"));
}
}