feat: first cut at dev brain

This commit is contained in:
Georg Bauer
2026-07-27 21:16:10 +02:00
parent 56be87d288
commit cfc4fce8d7
14 changed files with 2119 additions and 141 deletions

169
Cargo.lock generated
View File

@@ -621,6 +621,20 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "chrono"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
"windows-link",
]
[[package]]
name = "clipboard-win"
version = "5.4.1"
@@ -1059,6 +1073,27 @@ dependencies = [
"crypto-common 0.2.2",
]
[[package]]
name = "dirs"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
dependencies = [
"dirs-sys",
]
[[package]]
name = "dirs-sys"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.61.2",
]
[[package]]
name = "dispatch"
version = "0.2.0"
@@ -1144,6 +1179,7 @@ dependencies = [
"serde_norway",
"sha2",
"time",
"turbovault-parser",
"ureq",
"url",
]
@@ -2856,6 +2892,30 @@ dependencies = [
"typenum",
]
[[package]]
name = "iana-time-zone"
version = "0.1.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"log",
"wasm-bindgen",
"windows-core 0.62.2",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]]
name = "iced"
version = "0.14.0"
@@ -3034,7 +3094,7 @@ dependencies = [
"iced_renderer",
"log",
"num-traits",
"pulldown-cmark",
"pulldown-cmark 0.12.2",
"rustc-hash 2.1.3",
"thiserror 2.0.19",
"unicode-segmentation",
@@ -3471,6 +3531,12 @@ dependencies = [
"x11",
]
[[package]]
name = "libyaml-rs"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e126dda6f34391ab7b444f9922055facc83c07a910da3eb16f1e4d9c45dc777"
[[package]]
name = "lilt"
version = "0.8.1"
@@ -4142,6 +4208,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "orbclient"
version = "0.3.55"
@@ -4501,6 +4573,19 @@ dependencies = [
"unicase",
]
[[package]]
name = "pulldown-cmark"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
dependencies = [
"bitflags 2.13.1",
"getopts",
"memchr",
"pulldown-cmark-escape",
"unicase",
]
[[package]]
name = "pulldown-cmark-escape"
version = "0.11.0"
@@ -4654,6 +4739,17 @@ dependencies = [
"bitflags 2.13.1",
]
[[package]]
name = "redox_users"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
dependencies = [
"getrandom 0.2.17",
"libredox",
"thiserror 2.0.19",
]
[[package]]
name = "regex"
version = "1.13.1"
@@ -5051,6 +5147,15 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
[[package]]
name = "shellexpand"
version = "3.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8"
dependencies = [
"dirs",
]
[[package]]
name = "shlex"
version = "2.0.1"
@@ -5583,7 +5688,20 @@ version = "1.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee"
dependencies = [
"bytes",
"pin-project-lite",
"tokio-macros",
]
[[package]]
name = "tokio-macros"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
@@ -5724,6 +5842,41 @@ dependencies = [
"core_maths",
]
[[package]]
name = "turbovault-core"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8c68b413757ee3845f5b5182c8643e16430de683af4588d005ed2f9b21f84be"
dependencies = [
"chrono",
"log",
"parking_lot",
"serde",
"serde_json",
"sha2",
"shellexpand",
"thiserror 2.0.19",
"tokio",
"uuid",
"winnow 1.0.4",
"yaml_serde",
]
[[package]]
name = "turbovault-parser"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c153db2ca08515d7bfb1b2c31479710f41d39f0c0e1ddfe75d2abc32cd9c37fa"
dependencies = [
"log",
"pulldown-cmark 0.13.4",
"regex",
"serde",
"serde_json",
"turbovault-core",
"yaml_serde",
]
[[package]]
name = "two-face"
version = "0.4.5"
@@ -5946,6 +6099,7 @@ version = "1.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
dependencies = [
"getrandom 0.4.3",
"js-sys",
"serde_core",
"wasm-bindgen",
@@ -6870,6 +7024,19 @@ dependencies = [
"linked-hash-map",
]
[[package]]
name = "yaml_serde"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08c7c1b1a6a7c8a6b2741a6c21a4f8918e51899b111cfa08d1288202656e3975"
dependencies = [
"indexmap",
"itoa",
"libyaml-rs",
"ryu",
"serde",
]
[[package]]
name = "yazi"
version = "0.2.1"

View File

@@ -26,6 +26,7 @@ serde_json = { version = "1.0.149", features = ["preserve_order", "raw_value"] }
serde_norway = "0.9.42"
sha2 = "0.11.0"
time = { version = "0.3.54", features = ["formatting", "parsing"] }
turbovault-parser = "1.6.0"
ureq = { version = "3.3.0", default-features = false, features = ["rustls"] }
url = "2.5.8"

126
PLAN.md
View File

@@ -3,129 +3,3 @@
Only unfinished implementation work belongs here. DwarfStar remains the
behavioral oracle for model execution, token processing, context accounting,
KV-cache behavior, the HTTP API, and the built-in agent loop.
## Dev Brain: a project-backed LLM Wiki
Dev Brain implements the LLM Wiki idea described by Andrej Karpathy. The
registered projects remain the authoritative, changing source material. A
user-selected Obsidian vault is the derived, human-readable body of knowledge
that the agent maintains from those projects. Its purpose is to compile durable
knowledge about architecture, behavior, decisions, invariants, workflows, and
relationships once, keep it aligned as the projects change, and make it cheap
for later agents to retrieve the relevant context.
This is not a general Markdown memory store. The vault contains coherent topic
pages with explicit provenance, links, and freshness state. Queries use the
compiled wiki when it is verified and return to project sources when knowledge
is missing or stale. The vault remains ordinary Obsidian Markdown so that the
user can inspect, navigate, edit, and version it without DS4Server.
### Configuration and ownership
- Preferences provide an enable switch and the path to one dedicated Obsidian
vault. When disabled, Dev Brain contributes neither tools nor instructions to
the agent prompt.
- A vault must be an existing directory containing `.obsidian`. DS4Server may
change only its declared wiki files and must never modify Obsidian settings,
attachments, trash, hidden files, or unrelated user notes.
- The existing configuration store persists the setting. Changing the vault or
enable state invalidates the current Dev Brain context and derived index.
### Vault contract
The vault is self-describing and contains:
- `purpose.md`, maintained with the user, defining the wiki's scope, priorities,
and recurring questions;
- `schema.md`, defining page types, naming and linking conventions, provenance,
freshness states, and the rules for compilation, querying, and validation;
- `index.md`, a concise catalog of wiki pages with one-line descriptions;
- `log.md`, an append-only record of material wiki updates and their source
revisions; and
- topic pages grouped under `projects/`, `subsystems/`, `concepts/`,
`decisions/`, `invariants/`, and `workflows/` as the content requires.
Every managed topic page has YAML frontmatter containing its page type, project
identity, `verified`, `stale`, or `needs-review` status, verification time, and
source records. A source record identifies the registered project, a
repo-relative path, an optional symbol, and the exact evidence version: the Git
revision for a clean worktree and a content hash for the files actually read.
The body uses normal Obsidian links and embeds to connect related knowledge.
Pages model useful concepts rather than mirroring every source file.
### Compilation and maintenance
Initial compilation examines high-signal project material such as manifests,
documentation, schemas, entry points, public interfaces, and tests, then writes
the smallest set of durable topic pages that answers the purpose of the wiki.
It does not attempt to summarize every file.
Refresh compares each page's recorded evidence with the current project state.
Changed source paths and hashes select the dependent pages to re-read and
revalidate. The agent may update, split, merge, or retire those pages and keeps
`index.md` and `log.md` consistent with the result. Knowledge crystallized from
a development session must still cite project evidence; unsupported conclusions
are marked `needs-review` rather than presented as fact.
Wiki changes are prepared as one candidate batch in a temporary location. The
complete candidate is validated before managed files are replaced. A failed or
ambiguous update leaves the last valid vault untouched, and publication must
not expose a partially updated wiki.
### Parsing, indexing, and querying
Use `turbovault-parser` to parse Obsidian-flavored Markdown, including YAML
frontmatter, headings, wikilinks, embeds, tags, and source positions without
treating link-like text inside code as graph edges. Keep the Markdown files as
the source of truth and build a disposable SQLite index using the database
support already present in DS4Server:
- page path, title, type, status, headings, tags, and searchable body text;
- resolved wikilink and embed edges;
- project-source-to-page provenance edges; and
- an FTS5 index for ranked full-text search.
The index is rebuilt when a vault is enabled and refreshed from file metadata
and content hashes before use. Querying starts with `index.md` or FTS, follows
relevant links and backlinks, and returns both wiki pages and their project
evidence. Stale or unverified pages are labeled and excluded from authoritative
answers until the underlying sources have been checked.
The first implementation deliberately uses no filesystem watcher, embeddings,
vector database, or separate search service. Explicit refresh plus SQLite FTS5
is sufficient until vault size or measured retrieval quality proves otherwise.
### Validation
Validation prevents the wiki from silently becoming a confident but obsolete
source:
- structural validation checks frontmatter, page types, required fields, and
the managed path boundary;
- graph validation checks that links resolve, page identities are unique, and
`index.md` matches the managed pages;
- provenance validation checks that every cited project and source exists and
that paths remain inside the registered project;
- freshness validation checks recorded revisions and hashes against the current
files; and
- semantic validation has the agent re-check affected claims against changed
code, documentation, and tests before marking a page verified.
Semantic validation cannot prove every statement correct, so uncertainty must
remain visible as `needs-review`. Agents may rely on `verified` pages, may use
`stale` pages only as leads, and must not turn unsupported wiki text into project
truth.
### Agent integration and acceptance
Dev Brain is a separate wiki maintenance and retrieval capability, not generic
filesystem access outside the active project. Its operations expose search and
read over the derived index plus validated batch publication of managed wiki
content. Detailed operating instructions live in `schema.md`; the system prompt
only announces Dev Brain when enabled and directs the agent to that contract.
The implementation is complete when tests cover vault path confinement,
Obsidian parsing and index rebuilding, detection of source-hash drift, broken
links and invalid provenance, exclusion of stale pages from authoritative
queries, all-or-nothing publication, and prompt gating when Dev Brain is
disabled.

View File

@@ -78,6 +78,55 @@ Tool calls and results appear in the transcript. Use their copy actions for the
complete, untruncated text; large outputs can also be opened from their saved
file.
## Dev Brain
Dev Brain is a project-backed wiki for durable engineering knowledge. It lets
the coding agent compile architecture, behavior, decisions, invariants,
workflows, and relationships once, then retrieve that verified context in later
chats. Registered projects remain authoritative; Dev Brain is not a general
memory store and never replaces checking changed source code.
To set it up:
1. Create or choose a dedicated Obsidian vault. The folder must already contain
**.obsidian**.
2. Open **Preferences > Dev Brain**, choose the vault, enable Dev Brain, and
save.
3. In a project chat, ask the agent to build or refresh Dev Brain. Tell it the
questions and areas that matter most so it can maintain **purpose.md** and
compile a small set of useful topic pages instead of summarizing every file.
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.
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.
- **log.md** is an append-only history of material updates.
- Topic pages live under **projects/**, **subsystems/**, **concepts/**,
**decisions/**, **invariants/**, and **workflows/** as needed.
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.
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.
## Views and sidebar
Use **View > Show Sidebar** (`⌘B`) to hide or restore the project sidebar. The

View File

@@ -94,7 +94,7 @@ pub(crate) fn run(args: impl Iterator<Item = String>) -> Result<(), String> {
.timeout_recv_body(Some(Duration::from_secs(30)))
.build()
.into();
let mut system = crate::agent::system_prompt(model, &config.generation.system_prompt);
let mut system = crate::agent::system_prompt(model, &config.generation.system_prompt, false);
system.push_str("\n\n");
system.push_str(crate::a2ui::SYSTEM_PROMPT);
let metadata = Store::default().client_metadata();

View File

@@ -302,6 +302,7 @@ pub(crate) struct Tools {
jobs: HashMap<u32, BashJob>,
next_job: u32,
browser: Browser,
dev_brain: Option<crate::dev_brain::DevBrain>,
}
impl Tools {
@@ -316,9 +317,19 @@ impl Tools {
jobs: HashMap::new(),
next_job: 1,
browser: Browser::new()?,
dev_brain: None,
})
}
pub(crate) fn enable_dev_brain(
&mut self,
config: &crate::config::DevBrainConfig,
projects: &[crate::database::Project],
) -> Result<(), String> {
self.dev_brain = Some(crate::dev_brain::DevBrain::open(config, projects)?);
Ok(())
}
fn execute(&mut self, call: &ToolCall, cancel: &AtomicBool) -> String {
let result = match call.name.as_str() {
"read" => self.read(call),
@@ -332,6 +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_search" => self.dev_brain_search(call),
"dev_brain_read" => self.dev_brain_read(call),
"dev_brain_publish" => self.dev_brain_publish(call),
name => Err(format!("unknown tool: {name}")),
};
match result {
@@ -349,6 +363,54 @@ impl Tools {
}
}
fn dev_brain_search(&mut self, call: &ToolCall) -> Result<String, String> {
let query = required_string(call, "query")?;
let limit = integer(call, "limit", 8, 1, 50);
let authoritative = boolean(call, "authoritative", true);
self.dev_brain
.as_mut()
.ok_or_else(|| "Dev Brain is disabled for this session.".to_owned())?
.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()
.ok_or_else(|| "Dev Brain is disabled for this session.".to_owned())?
.read(path, authoritative)
}
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();
self.dev_brain
.as_mut()
.ok_or_else(|| "Dev Brain is disabled for this session.".to_owned())?
.publish(files, &remove)
}
fn result_limit(&self) -> usize {
(self.context_tokens.max(4096) as usize * 2).min(512 * 1024)
}
@@ -1229,16 +1291,26 @@ pub(crate) fn parse_tool_calls(
.map(|calls| (content, calls))
}
pub(crate) fn system_prompt(model: ModelChoice, extra: &str) -> String {
pub(crate) fn system_prompt(model: ModelChoice, extra: &str, dev_brain: bool) -> String {
let schemas = if dev_brain {
format!("{TOOL_SCHEMAS}\n{}", crate::dev_brain::TOOL_SCHEMAS)
} else {
TOOL_SCHEMAS.to_owned()
};
let tools = if model == ModelChoice::Glm52 {
format!(
"You are a coding agent running in a local workspace. Use tools for local file and system work. Avoid printing large file contents or large code blocks as answers; create or edit files with tools, then summarize results briefly.\n\n# Tools\n\nYou are provided with function signatures within <tools></tools> XML tags:\n<tools>\n{TOOL_SCHEMAS}\n</tools>\n\nFor a function call, output exactly: <tool_call>function-name<arg_key>key</arg_key><arg_value>value</arg_value></tool_call>\nTool calls are not allowed inside <think></think>. Use read/search for focused context, edit with exact unique old text, and [upto] only between unique head and tail anchors. Use refresh_sec for long bash jobs and poll with bash_status or stop with bash_stop. Preserve the current system configuration unless the user explicitly asks otherwise."
"You are a coding agent running in a local workspace. Use tools for local file and system work. Avoid printing large file contents or large code blocks as answers; create or edit files with tools, then summarize results briefly.\n\n# Tools\n\nYou are provided with function signatures within <tools></tools> XML tags:\n<tools>\n{schemas}\n</tools>\n\nFor a function call, output exactly: <tool_call>function-name<arg_key>key</arg_key><arg_value>value</arg_value></tool_call>\nTool calls are not allowed inside <think></think>. Use read/search for focused context, edit with exact unique old text, and [upto] only between unique head and tail anchors. Use refresh_sec for long bash jobs and poll with bash_status or stop with bash_stop. Preserve the current system configuration unless the user explicitly asks otherwise."
)
} else {
format!(
"You are a coding agent running in a local workspace. Use tools for local file and system work. Avoid printing large file contents or large code blocks as answers; create or edit files with tools, then summarize results briefly.\n\n## Tools\n\nInvoke native DSML tools exactly as:\n<DSMLtool_calls>\n<DSMLinvoke name=\"$TOOL_NAME\">\n<DSMLparameter name=\"$PARAMETER_NAME\" string=\"true|false\">$PARAMETER_VALUE</DSMLparameter>\n</DSMLinvoke>\n</DSMLtool_calls>\n\nTool calls are not allowed inside <think></think>. String parameters use raw text and string=\"true\"; numbers and booleans use JSON text and string=\"false\". Read defaults to a bounded chunk; use more to continue and whole=true only when needed. Use write for new files or whole-file replacement. Use edit with path first and exact unique old text; old may contain one [upto] marker between unique head and tail anchors. For long bash commands pass refresh_sec, then use bash_status or bash_stop. The first web call asks permission to start visible Chrome.\n\n### Available Tool Schemas\n\n{TOOL_SCHEMAS}\n\n# Rules\n- Always use strict DSML syntax.\n- Use read/search to get anchors before editing.\n- Preserve the current system configuration unless explicitly asked otherwise."
"You are a coding agent running in a local workspace. Use tools for local file and system work. Avoid printing large file contents or large code blocks as answers; create or edit files with tools, then summarize results briefly.\n\n## Tools\n\nInvoke native DSML tools exactly as:\n<DSMLtool_calls>\n<DSMLinvoke name=\"$TOOL_NAME\">\n<DSMLparameter name=\"$PARAMETER_NAME\" string=\"true|false\">$PARAMETER_VALUE</DSMLparameter>\n</DSMLinvoke>\n</DSMLtool_calls>\n\nTool calls are not allowed inside <think></think>. String parameters use raw text and string=\"true\"; numbers and booleans use JSON text and string=\"false\". Read defaults to a bounded chunk; use more to continue and whole=true only when needed. Use write for new files or whole-file replacement. Use edit with path first and exact unique old text; old may contain one [upto] marker between unique head and tail anchors. For long bash commands pass refresh_sec, then use bash_status or bash_stop. The first web call asks permission to start visible Chrome.\n\n### Available Tool Schemas\n\n{schemas}\n\n# Rules\n- Always use strict DSML syntax.\n- Use read/search to get anchors before editing.\n- Preserve the current system configuration unless explicitly asked otherwise."
)
};
let tools = if dev_brain {
format!("{tools}\n\n{}", crate::dev_brain::PROMPT)
} else {
tools
};
if extra.trim().is_empty() {
tools
} else {
@@ -1246,10 +1318,10 @@ pub(crate) fn system_prompt(model: ModelChoice, extra: &str) -> String {
}
}
pub(crate) fn system_prompt_reminder(model: ModelChoice) -> String {
pub(crate) fn system_prompt_reminder(model: ModelChoice, dev_brain: bool) -> String {
format!(
"[System prompt reminder follows.]\n{}\n[End system prompt reminder.]",
system_prompt(model, "")
system_prompt(model, "", dev_brain)
)
}
@@ -1654,7 +1726,7 @@ mod tests {
#[test]
fn prompts_and_parsers_expose_the_reference_tool_set() {
let prompt = system_prompt(ModelChoice::DeepSeekV4Flash, "extra");
let prompt = system_prompt(ModelChoice::DeepSeekV4Flash, "extra", false);
for name in [
"google_search",
"visit_page",
@@ -1672,9 +1744,11 @@ mod tests {
}
assert!(prompt.ends_with("extra"));
assert!(
system_prompt_reminder(ModelChoice::DeepSeekV4Flash)
system_prompt_reminder(ModelChoice::DeepSeekV4Flash, false)
.contains("[System prompt reminder follows.]")
);
assert!(!prompt.contains("dev_brain_search"));
assert!(system_prompt(ModelChoice::DeepSeekV4Flash, "", true).contains("dev_brain_search"));
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));

View File

@@ -14,7 +14,7 @@ use preferences::PreferenceDraft;
#[cfg(test)]
use preferences::{parse_optional_gib, parse_streaming_cache};
use crate::config::{Config, EndpointConfig};
use crate::config::{Config, DevBrainConfig, EndpointConfig};
use crate::database::{Database, ProjectWithSessions, SessionState, StoredMessage};
#[cfg(target_os = "macos")]
use crate::engine::ChatTurn;
@@ -247,6 +247,7 @@ pub(super) enum DetailTab {
pub(super) enum PreferenceSection {
Model,
Endpoint,
DevBrain,
Generation,
Execution,
Acceleration,
@@ -255,9 +256,10 @@ pub(super) enum PreferenceSection {
}
impl PreferenceSection {
const ALL: [Self; 7] = [
const ALL: [Self; 8] = [
Self::Model,
Self::Endpoint,
Self::DevBrain,
Self::Generation,
Self::Execution,
Self::Acceleration,
@@ -269,6 +271,7 @@ impl PreferenceSection {
match self {
Self::Model => "preferences-model",
Self::Endpoint => "preferences-endpoint",
Self::DevBrain => "preferences-dev-brain",
Self::Generation => "preferences-generation",
Self::Execution => "preferences-execution",
Self::Acceleration => "preferences-acceleration",
@@ -281,6 +284,7 @@ impl PreferenceSection {
match self {
Self::Model => "Model & lifecycle",
Self::Endpoint => "Local endpoint",
Self::DevBrain => "Dev Brain",
Self::Generation => "Generation",
Self::Execution => "Execution",
Self::Acceleration => "Acceleration & memory",
@@ -338,6 +342,10 @@ pub(crate) enum Message {
PreferenceEndpointPortChanged(String),
PreferenceEndpointEnabledChanged(bool),
PreferenceEndpointCorsChanged(bool),
PreferenceDevBrainEnabledChanged(bool),
PreferenceDevBrainVaultChanged(String),
ChooseDevBrainVault,
DevBrainVaultPicked(Option<PathBuf>),
PreferenceContextChanged(String),
PreferenceMaxTokensChanged(String),
PreferenceSystemPromptAction(text_editor::Action),
@@ -1157,6 +1165,33 @@ impl App {
self.preference_draft.endpoint_cors = enabled;
self.preference_error = None;
}
Message::PreferenceDevBrainEnabledChanged(enabled) => {
self.preference_draft.dev_brain_enabled = enabled;
self.preference_error = None;
}
Message::PreferenceDevBrainVaultChanged(value) => {
self.preference_draft.dev_brain_vault_path = value;
self.preference_error = None;
}
Message::ChooseDevBrainVault => {
return Task::perform(
async {
AsyncFileDialog::new()
.set_title("Choose an Obsidian vault")
.pick_folder()
.await
.map(|folder| folder.path().to_path_buf())
},
Message::DevBrainVaultPicked,
);
}
Message::DevBrainVaultPicked(path) => {
if let Some(path) = path {
self.preference_draft.dev_brain_vault_path =
path.to_string_lossy().into_owned();
self.preference_error = None;
}
}
Message::PreferenceContextChanged(value) => {
self.preference_draft.context_tokens = value;
self.preference_error = None;
@@ -1702,6 +1737,7 @@ impl App {
self.tokens_per_second = None;
}
self.reload_projects();
self.invalidate_dev_brain_context();
self.finish_cache_change();
}
Err(error) => self.error = Some(error),

View File

@@ -382,7 +382,7 @@ fn title_context(messages: impl IntoIterator<Item = ChatTurn>) -> Vec<ChatTurn>
impl App {
fn chat_system_prompt(&self, model: ModelChoice, prompt: &str, agents: Option<&str>) -> String {
let mut prompt = crate::agent::system_prompt(model, prompt);
let mut prompt = crate::agent::system_prompt(model, prompt, self.config.dev_brain.enabled);
if self.config.a2ui_enabled {
prompt.push_str("\n\n");
prompt.push_str(crate::a2ui::SYSTEM_PROMPT);
@@ -668,7 +668,10 @@ impl App {
}
fn system_prompt_reminders(&self, model: ModelChoice) -> Vec<String> {
let mut reminders = vec![crate::agent::system_prompt_reminder(model)];
let mut reminders = vec![crate::agent::system_prompt_reminder(
model,
self.config.dev_brain.enabled,
)];
if self.config.a2ui_enabled {
reminders.push(crate::a2ui::SYSTEM_PROMPT.to_owned());
}
@@ -1163,7 +1166,15 @@ impl App {
.find(|project| project.project.id == project_id)
.map(|project| PathBuf::from(&project.project.path))
.ok_or_else(|| "The active project is unavailable.".to_owned())?;
let tools = crate::agent::Tools::new(&root, self.config.generation.context_tokens)?;
let mut tools = crate::agent::Tools::new(&root, self.config.generation.context_tokens)?;
if self.config.dev_brain.enabled {
let projects = self
.projects
.iter()
.map(|project| project.project.clone())
.collect::<Vec<_>>();
tools.enable_dev_brain(&self.config.dev_brain, &projects)?;
}
self.agent_tools = Some((session_id, Arc::new(Mutex::new(tools))));
}
let tools = Arc::clone(&self.agent_tools.as_ref().unwrap().1);

View File

@@ -10,6 +10,8 @@ pub(super) struct PreferenceDraft {
pub(super) endpoint_port: String,
pub(super) endpoint_enabled: bool,
pub(super) endpoint_cors: bool,
pub(super) dev_brain_enabled: bool,
pub(super) dev_brain_vault_path: String,
pub(super) context_tokens: String,
pub(super) max_generated_tokens: String,
pub(super) system_prompt: text_editor::Content,
@@ -60,6 +62,8 @@ impl PreferenceDraft {
endpoint_port: config.endpoint.port.to_string(),
endpoint_enabled: config.endpoint.enabled,
endpoint_cors: config.endpoint.cors,
dev_brain_enabled: config.dev_brain.enabled,
dev_brain_vault_path: config.dev_brain.vault_path.clone().unwrap_or_default(),
context_tokens: generation.context_tokens.to_string(),
max_generated_tokens: generation.max_generated_tokens.to_string(),
system_prompt: text_editor::Content::with_text(&generation.system_prompt),
@@ -368,6 +372,10 @@ impl App {
enabled: self.preference_draft.endpoint_enabled,
cors: self.preference_draft.endpoint_cors,
},
dev_brain: DevBrainConfig {
enabled: self.preference_draft.dev_brain_enabled,
vault_path: optional_text(&self.preference_draft.dev_brain_vault_path),
},
generation,
runtime,
interface: self.config.interface.clone(),
@@ -376,6 +384,18 @@ impl App {
self.preference_error = Some(error);
return;
}
if config.dev_brain.enabled {
let projects = self
.projects
.iter()
.map(|project| project.project.clone())
.collect::<Vec<_>>();
if let Err(error) = crate::dev_brain::DevBrain::open(&config.dev_brain, &projects) {
self.preference_error = Some(error);
return;
}
}
let dev_brain_changed = self.config.dev_brain != config.dev_brain;
#[cfg(target_os = "macos")]
let endpoint_changed = self.config.endpoint != config.endpoint;
#[cfg(target_os = "macos")]
@@ -415,6 +435,10 @@ impl App {
}
self.config = config;
#[cfg(target_os = "macos")]
if dev_brain_changed {
self.invalidate_dev_brain_context();
}
#[cfg(target_os = "macos")]
update_runtime_config(&self.runtime_config, &self.config);
#[cfg(target_os = "macos")]
if endpoint_changed {

View File

@@ -152,6 +152,7 @@ impl App {
self.project_name_input.clear();
self.error = None;
self.reload_projects();
self.invalidate_dev_brain_context();
self.refresh_git_state();
}
Err(error) => self.error = Some(error),
@@ -349,6 +350,21 @@ impl App {
}
}
}
pub(super) fn invalidate_dev_brain_context(&mut self) {
if !self.config.dev_brain.enabled {
return;
}
#[cfg(target_os = "macos")]
{
self.agent_tools = None;
for chat in self.background_chats.values_mut() {
chat.agent_tools = None;
chat.system_prompt_seen_at = 0;
}
}
self.system_prompt_seen_at = 0;
}
}
fn read_git_state(path: &Path) -> Option<GitState> {

View File

@@ -156,6 +156,33 @@ impl App {
]
.spacing(10),
);
let dev_brain_group = preference_group(
PreferenceSection::DevBrain,
"DEV BRAIN",
column![
hint(
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.",
),
row![
text_input(
"/path/to/Obsidian vault",
&self.preference_draft.dev_brain_vault_path,
)
.on_input(Message::PreferenceDevBrainVaultChanged)
.padding(9)
.width(Length::Fill),
action_button("Choose…").on_press(Message::ChooseDevBrainVault),
]
.spacing(8)
.align_y(Alignment::Center),
text("The folder must already contain .obsidian. DS4Server manages only its declared wiki pages and leaves settings, attachments, hidden files, and unrelated notes untouched.")
.size(12),
]
.spacing(10),
);
let generation_group = preference_group(
PreferenceSection::Generation,
"GENERATION",
@@ -552,6 +579,7 @@ impl App {
let mut fields = column![
model_group,
endpoint_group,
dev_brain_group,
generation_group,
execution_group,
acceleration_group,

View File

@@ -15,6 +15,7 @@ pub struct Config {
pub model: ModelChoice,
pub idle_timeout_minutes: i32,
pub a2ui_enabled: bool,
pub dev_brain: DevBrainConfig,
pub endpoint: EndpointConfig,
pub generation: GenerationPreferences,
pub runtime: RuntimePreferences,
@@ -27,6 +28,7 @@ impl Default for Config {
model: ModelChoice::default(),
idle_timeout_minutes: 10,
a2ui_enabled: true,
dev_brain: DevBrainConfig::default(),
endpoint: EndpointConfig::default(),
generation: GenerationPreferences::default(),
runtime: RuntimePreferences::default(),
@@ -35,6 +37,35 @@ impl Default for Config {
}
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct DevBrainConfig {
pub enabled: bool,
pub vault_path: Option<String>,
}
impl DevBrainConfig {
pub fn vault(&self) -> Result<std::path::PathBuf, String> {
if !self.enabled {
return Err("Dev Brain is disabled.".into());
}
let path = self
.vault_path
.as_deref()
.filter(|path| !path.trim().is_empty())
.ok_or_else(|| "Choose an Obsidian vault before enabling Dev Brain.".to_owned())?;
crate::dev_brain::validate_vault(Path::new(path))
}
fn validate(&self) -> Result<(), String> {
if self.enabled {
self.vault().map(|_| ())
} else {
Ok(())
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct EndpointConfig {
@@ -113,7 +144,8 @@ impl Config {
return Err("Endpoint port must be between 1 and 65535.".into());
}
self.generation.validate()?;
self.runtime.validate(self.model)
self.runtime.validate(self.model)?;
self.dev_brain.validate()
}
}

1665
src/dev_brain.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@ mod app;
mod compaction;
mod config;
mod database;
mod dev_brain;
mod engine;
mod metrics;
mod model;