fix: better handling of dev brain
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
208
src/agent.rs
208
src/agent.rs
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
423
src/dev_brain.rs
423
src/dev_brain.rs
@@ -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 ¤t {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user