diff --git a/src/app/generation.rs b/src/app/generation.rs index 5098112..12dd468 100644 --- a/src/app/generation.rs +++ b/src/app/generation.rs @@ -187,7 +187,9 @@ fn correction_already_sent(conversation: &[ChatMessage], correction: &str) -> bo .iter() .rev() .skip(1) - .take_while(|message| !message.user) + .take_while(|message| { + !message.user && !(message.tool && message.content.starts_with("Tool result ")) + }) .any(|message| message.tool && message.content == correction) } @@ -2089,6 +2091,22 @@ mod tests { &conversation, TOOL_PROTOCOL_CORRECTION )); + + let mut result = assistant(None, "Tool result 1 (bash):\nok\n"); + result.tool = true; + let mut prior_correction = assistant(None, TOOL_PROTOCOL_CORRECTION); + prior_correction.tool = true; + let conversation = vec![ + assistant(Some(call), ""), + prior_correction, + assistant(None, call), + result, + assistant(Some(call), ""), + ]; + assert!(!correction_already_sent( + &conversation, + TOOL_PROTOCOL_CORRECTION + )); } #[test] diff --git a/src/dev_brain.rs b/src/dev_brain.rs index 30f4a64..b2aafe4 100644 --- a/src/dev_brain.rs +++ b/src/dev_brain.rs @@ -32,9 +32,9 @@ const TOPIC_DIRS: [&str; 7] = [ static VAULT_LOCK: RwLock<()> = RwLock::new(()); 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 skills.md and reports broken links as repairable warnings without discarding content. Fix warnings with ordinary edits or by creating the missing page. The system prompt lists verified skills by name, description, and Markdown path. When a task matches a skill, read that complete skill file before acting and follow its instructions."#; +Dev Brain is a managed Obsidian wiki, not a project directory or generic memory. Call dev_brain_info to get both its real folder and the separate registered project folders. Use the Dev Brain folder only for wiki maintenance; use a registered project folder for cited source files and Git commands. 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 skills.md and reports broken links as repairable warnings without discarding content. Fix warnings with ordinary edits or by creating the missing page. The system prompt lists verified skills by name, description, and Markdown path. When a task matches a skill, read that complete skill file before acting and follow its instructions."#; -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":{}}}} +pub(crate) const TOOL_SCHEMAS: &str = r#"{"type":"function","function":{"name":"dev_brain_info","description":"Return the separate Dev Brain wiki folder and registered project source folders. 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 and skills.md, refresh search, and report repairable link warnings.","parameters":{"type":"object","properties":{}}}}"#; @@ -74,13 +74,14 @@ sources: - project: Registered project name path: src/example.rs symbol: optional_symbol - revision: full-or-unique-short-clean-git-revision - # Use `git rev-parse HEAD`, or a unique lowercase hex prefix of at least 7 characters. - # Use hash instead when the worktree is dirty or is not Git. + revision: current-project-head-revision + # Use `git rev-parse HEAD`, or a unique lowercase hex prefix of at least 7 characters, + # when this source file matches HEAD. Use hash when this file differs from HEAD, + # is untracked, or the registered project is not Git. --- ``` -Each source has exactly one evidence version: `revision` or a lowercase SHA-256 `hash`. A revision is the current clean commit's full object ID or a unique lowercase hexadecimal prefix of at least 7 characters. Paths are project-relative and may not escape the registered project. +`project` names a registered project, not this Dev Brain vault; `dev_brain_info` lists the exact registered names and folders. Each source `path` is relative to that registered project's folder; never resolve it inside the vault. Cite only files that support the page's claims, not every dirty file in the project. Each source has exactly one evidence version: `revision` or a lowercase SHA-256 `hash`. A revision is the current project HEAD's full object ID or a unique lowercase hexadecimal prefix of at least 7 characters, and is valid when that specific source file matches HEAD even if unrelated files are dirty. Use a hash when that specific file differs from HEAD, is untracked, or its registered project is not Git. ## Skills @@ -279,10 +280,14 @@ impl DevBrain { pub(crate) fn info(&self) -> String { let mut info = format!( - "Dev Brain folder: {}\nManaged roots: purpose.md, schema.md, index.md, skills.md, log.md, {}/\nUse ordinary file tools with these absolute paths. Topic pages need dev_brain: true frontmatter. Skills use type: skill with name and description. Run dev_brain_validate after changes; index.md and skills.md are generated.\n", + "Dev Brain wiki folder (not a project): {}\nManaged roots: purpose.md, schema.md, index.md, skills.md, log.md, {}/\nRegistered project folders (source and Git roots, separate from the wiki):\n", self.vault.display(), TOPIC_DIRS.join("/, ") ); + for project in &self.projects { + info.push_str(&format!("- {}: {}\n", project.name, project.root.display())); + } + info.push_str("Source paths are relative to the named registered project folder. Use ordinary file tools on these absolute folders. Topic pages need dev_brain: true frontmatter. Skills use type: skill with name and description. Run dev_brain_validate after changes; index.md and skills.md are generated.\n"); if let Some(error) = &self.index_error { info.push_str(&format!( "The current wiki needs repair before indexed search: {error}\n" @@ -1241,9 +1246,11 @@ fn validate_source(source: &SourceRecord, projects: &[RegisteredProject]) -> Res )); } if let Some(revision) = &source.revision { - return Ok(git_state(&project.root).is_some_and(|(current, clean)| { - clean && git_revision_matches(&project.root, revision, ¤t) - })); + return Ok( + git_source_state(&project.root, &resolved).is_some_and(|(current, clean)| { + clean && git_revision_matches(&project.root, revision, ¤t) + }), + ); } let expected = source.hash.as_deref().unwrap(); if expected.len() != 64 @@ -1257,7 +1264,21 @@ fn validate_source(source: &SourceRecord, projects: &[RegisteredProject]) -> Res )); } Ok(hash_file(&resolved)? == expected - && !git_state(&project.root).is_some_and(|(_, clean)| clean)) + && !git_source_state(&project.root, &resolved).is_some_and(|(_, clean)| clean)) +} + +fn git_source_state(root: &Path, source: &Path) -> Option<(String, bool)> { + let repository = git2::Repository::discover(root).ok()?; + let relative = source.strip_prefix(repository.workdir()?).ok()?; + let clean = repository.status_file(relative).ok()? == git2::Status::CURRENT; + let revision = repository + .head() + .ok()? + .peel_to_commit() + .ok()? + .id() + .to_string(); + Some((revision, clean)) } fn git_revision_matches(root: &Path, revision: &str, current: &str) -> bool { @@ -1736,6 +1757,12 @@ mod tests { fn path_confinement_and_unrelated_notes_are_preserved() { let fixture = Fixture::new(); let brain = fixture.brain(); + let info = brain.info(); + assert!(info.contains("Dev Brain wiki folder (not a project)")); + assert!(info.contains(&format!( + "- Fixture: {}", + fixture.project.canonicalize().unwrap().display() + ))); fs::write(fixture.vault.join("concepts/private.md"), "# Private\n").unwrap(); assert!(!brain.allows_tool_write(&fixture.vault.join("concepts/private.md"))); assert!(brain.allows_tool_write(&brain.folder().join("concepts/new.md"))); @@ -1916,11 +1943,13 @@ mod tests { } #[test] - fn clean_full_and_short_git_revisions_become_stale_when_the_worktree_changes() { + fn source_versions_follow_that_file_instead_of_the_whole_worktree() { let fixture = Fixture::new(); + fs::write(fixture.project.join("unrelated.rs"), "unrelated\n").unwrap(); let repository = git2::Repository::init(&fixture.project).unwrap(); let mut index = repository.index().unwrap(); index.add_path(Path::new("source.rs")).unwrap(); + index.add_path(Path::new("unrelated.rs")).unwrap(); let tree_id = index.write_tree().unwrap(); index.write().unwrap(); let tree = repository.find_tree(tree_id).unwrap(); @@ -1955,6 +1984,13 @@ mod tests { .unwrap() .contains("concepts/answer.md") ); + fs::write(fixture.project.join("unrelated.rs"), "dirty elsewhere\n").unwrap(); + assert!( + brain + .search("Revision-backed knowledge", 10, true) + .unwrap() + .contains("concepts/answer.md") + ); fs::write(fixture.project.join("source.rs"), "dirty\n").unwrap(); assert!( brain @@ -1962,6 +1998,18 @@ mod tests { .unwrap() .contains("status: stale") ); + let topic = fixture.topic( + "verified", + &source_hash(&fixture), + "Hash-backed dirty knowledge.", + ); + write_topic(&fixture, &mut brain, &topic).unwrap(); + assert!( + brain + .search("Hash-backed dirty knowledge", 10, true) + .unwrap() + .contains("concepts/answer.md") + ); } #[test]