diff --git a/src/app.rs b/src/app.rs index eb2d4482..d0cf4b09 100644 --- a/src/app.rs +++ b/src/app.rs @@ -672,6 +672,28 @@ impl AppBuilder { } } + // Import workspace files from disk if WORKSPACE_IMPORT_DIR is set. + // This lets Docker images / deployment scripts ship customized + // workspace templates (e.g., AGENTS.md, TOOLS.md) that override + // the generic seeds. Only imports files that don't already exist + // in the database — never overwrites user edits. + if let Ok(import_dir) = std::env::var("WORKSPACE_IMPORT_DIR") { + let import_path = std::path::Path::new(&import_dir); + match ws.import_from_directory(import_path).await { + Ok(count) if count > 0 => { + tracing::info!("Imported {} workspace file(s) from {}", count, import_dir); + } + Ok(_) => {} + Err(e) => { + tracing::warn!( + "Failed to import workspace files from {}: {}", + import_dir, + e + ); + } + } + } + if embeddings.is_some() { let ws_bg = Arc::clone(ws); tokio::spawn(async move { diff --git a/src/error.rs b/src/error.rs index c1d0072d..4c746122 100644 --- a/src/error.rs +++ b/src/error.rs @@ -331,6 +331,9 @@ pub enum WorkspaceError { #[error("Heartbeat error: {reason}")] HeartbeatError { reason: String }, + + #[error("I/O error: {reason}")] + IoError { reason: String }, } /// Orchestrator errors (internal API, container management). diff --git a/src/tools/builtin/memory.rs b/src/tools/builtin/memory.rs index ea48da70..ac768402 100644 --- a/src/tools/builtin/memory.rs +++ b/src/tools/builtin/memory.rs @@ -140,7 +140,8 @@ impl Tool for MemoryWriteTool { Use for important facts, decisions, preferences, or lessons learned that should \ be remembered across sessions. Targets: 'memory' for curated long-term facts, \ 'daily_log' for timestamped session notes, 'heartbeat' for the periodic \ - checklist (HEARTBEAT.md), or provide a custom path for arbitrary file creation." + checklist (HEARTBEAT.md), 'bootstrap' to clear the first-run ritual file, \ + or provide a custom path for arbitrary file creation." } fn parameters_schema(&self) -> serde_json::Value { @@ -153,7 +154,7 @@ impl Tool for MemoryWriteTool { }, "target": { "type": "string", - "description": "Where to write: 'memory' for MEMORY.md, 'daily_log' for today's log, 'heartbeat' for HEARTBEAT.md checklist, or a path like 'projects/alpha/notes.md'", + "description": "Where to write: 'memory' for MEMORY.md, 'daily_log' for today's log, 'heartbeat' for HEARTBEAT.md checklist, 'bootstrap' to clear BOOTSTRAP.md (content is ignored; the file is always cleared), or a path like 'projects/alpha/notes.md'", "default": "daily_log" }, "append": { @@ -175,17 +176,36 @@ impl Tool for MemoryWriteTool { let content = require_str(¶ms, "content")?; + let target = params + .get("target") + .and_then(|v| v.as_str()) + .unwrap_or("daily_log"); + + // Bootstrap target: clear BOOTSTRAP.md to mark first-run ritual complete. + // Handled early because it accepts empty content (unlike other targets). + if target == "bootstrap" { + // Write empty content to effectively disable the bootstrap injection. + // system_prompt_for_context() skips empty files. + self.workspace + .write(paths::BOOTSTRAP, "") + .await + .map_err(|e| ToolError::ExecutionFailed(format!("Write failed: {}", e)))?; + + let output = serde_json::json!({ + "status": "cleared", + "path": paths::BOOTSTRAP, + "message": "BOOTSTRAP.md cleared. First-run ritual will not repeat.", + }); + + return Ok(ToolOutput::success(output, start.elapsed())); + } + if content.trim().is_empty() { return Err(ToolError::InvalidParameters( "content cannot be empty".to_string(), )); } - let target = params - .get("target") - .and_then(|v| v.as_str()) - .unwrap_or("daily_log"); - // Reject writes to identity files that are loaded into the system prompt. // An attacker could use prompt injection to trick the agent into overwriting // these, poisoning future conversations. diff --git a/src/workspace/README.md b/src/workspace/README.md index 4768acf4..2b3ee5b4 100644 --- a/src/workspace/README.md +++ b/src/workspace/README.md @@ -20,6 +20,8 @@ workspace/ ├── SOUL.md <- Core values ├── AGENTS.md <- Behavior instructions ├── USER.md <- User context +├── TOOLS.md <- Environment-specific tool notes +├── BOOTSTRAP.md <- First-run ritual (deleted after onboarding) ├── context/ <- Identity-related docs │ ├── vision.md │ └── priorities.md diff --git a/src/workspace/document.rs b/src/workspace/document.rs index 23dcd5b2..354c7175 100644 --- a/src/workspace/document.rs +++ b/src/workspace/document.rs @@ -27,6 +27,10 @@ pub mod paths { pub const DAILY_DIR: &str = "daily/"; /// Context directory (for identity-related docs). pub const CONTEXT_DIR: &str = "context/"; + /// User-editable notes for environment-specific tool guidance. + pub const TOOLS: &str = "TOOLS.md"; + /// First-run ritual file; self-deletes after onboarding completes. + pub const BOOTSTRAP: &str = "BOOTSTRAP.md"; } /// A memory document stored in the database. diff --git a/src/workspace/mod.rs b/src/workspace/mod.rs index c898a7d1..6196b3f1 100644 --- a/src/workspace/mod.rs +++ b/src/workspace/mod.rs @@ -271,6 +271,53 @@ const HEARTBEAT_SEED: &str = "\ - Clean up context/ documents that are outdated -->"; +/// Default template seeded into TOOLS.md on first access. +/// +/// TOOLS.md does not control tool availability; it is user guidance +/// for how to use external tools. The agent may update this file as it +/// learns environment-specific details (SSH hostnames, device names, etc.). +const TOOLS_SEED: &str = "\ +"; + +/// First-run ritual seeded into BOOTSTRAP.md on initial workspace setup. +/// +/// The agent reads this file at the start of every session when it exists. +/// After completing the ritual the agent must delete this file so it is +/// never repeated. It is NOT a protected file; the agent needs write access. +const BOOTSTRAP_SEED: &str = "\ +# Bootstrap + +You are starting up for the first time. Follow these steps before anything else. + +## Steps + +1. **Say hello.** Greet the user warmly and introduce yourself briefly. +2. **Get to know the user.** Ask a few questions to understand who they are, \ +what they work on, and what they want from an AI assistant. Take notes. +3. **Save what you learned.** + - Write any environment-specific tool details the user mentions to `TOOLS.md` \ +using `memory_write` with target set to the path. + - Write a summary of the conversation and key facts to `MEMORY.md` \ +using `memory_write` with target `memory`. + - Note: `USER.md`, `IDENTITY.md`, `SOUL.md`, and `AGENTS.md` are protected \ +from tool writes for security. Tell the user what you'd suggest for those files \ +so they can edit them directly. +4. **Delete this file.** When onboarding is complete, use `memory_write` with \ +target `bootstrap` to clear this file so setup never repeats. + +Keep the conversation natural. Do not read these steps aloud. +"; + /// Workspace provides database-backed memory storage for an agent. /// /// Each workspace is scoped to a user (and optionally an agent). @@ -547,6 +594,24 @@ impl Workspace { ) -> Result { let mut parts = Vec::new(); + // Bootstrap ritual: inject FIRST when present (first-run only). + // The agent must complete the ritual and then delete this file. + // + // Note: BOOTSTRAP.md is intentionally NOT write-protected so the agent + // can delete it after onboarding. This means a prompt injection attack + // could write to it, but the file is only injected on the next session + // (not the current one), limiting the blast radius. + if let Ok(doc) = self.read(paths::BOOTSTRAP).await + && !doc.content.is_empty() + { + parts.push(format!( + "## First-Run Bootstrap\n\n\ + A BOOTSTRAP.md file exists in the workspace. Read and follow it, \ + then delete it when done.\n\n{}", + doc.content + )); + } + // Load identity files in order of importance let identity_files = [ (paths::AGENTS, "## Agent Instructions"), @@ -563,6 +628,14 @@ impl Workspace { } } + // Tool notes: environment-specific guidance the agent or user has written. + // TOOLS.md does not control tool availability; it is guidance only. + if let Ok(doc) = self.read(paths::TOOLS).await + && !doc.content.is_empty() + { + parts.push(format!("## Tool Notes\n\n{}", doc.content)); + } + // Load MEMORY.md only in direct/main sessions (never group chats) if !is_group_chat && let Ok(doc) = self.read(paths::MEMORY).await @@ -693,6 +766,7 @@ impl Workspace { - `SOUL.md` - Core values and behavioral boundaries\n\ - `AGENTS.md` - Session routine and operational instructions\n\ - `USER.md` - Information about you (the user)\n\ + - `TOOLS.md` - Environment-specific tool notes\n\ - `HEARTBEAT.md` - Periodic background task checklist\n\ - `daily/` - Automatic daily session logs\n\ - `context/` - Additional context documents\n\n\ @@ -763,6 +837,7 @@ impl Workspace { You can also edit this directly to provide context upfront.", ), (paths::HEARTBEAT, HEARTBEAT_SEED), + (paths::TOOLS, TOOLS_SEED), ]; let mut count = 0; @@ -784,12 +859,119 @@ impl Workspace { } } + // BOOTSTRAP.md is only seeded on truly fresh workspaces (no identity + // files exist yet). This prevents existing users from getting a + // spurious first-run ritual after upgrading. + if self.read(paths::BOOTSTRAP).await.is_err() { + let (agents_res, soul_res, user_res) = tokio::join!( + self.read(paths::AGENTS), + self.read(paths::SOUL), + self.read(paths::USER), + ); + let is_fresh_workspace = + matches!(agents_res, Err(WorkspaceError::DocumentNotFound { .. })) + && matches!(soul_res, Err(WorkspaceError::DocumentNotFound { .. })) + && matches!(user_res, Err(WorkspaceError::DocumentNotFound { .. })); + + if is_fresh_workspace { + if let Err(e) = self.write(paths::BOOTSTRAP, BOOTSTRAP_SEED).await { + tracing::warn!("Failed to seed {}: {}", paths::BOOTSTRAP, e); + } else { + count += 1; + } + } + } + if count > 0 { tracing::info!("Seeded {} workspace files", count); } Ok(count) } + /// Import markdown files from a directory on disk into the workspace DB. + /// + /// Scans `dir` for `*.md` files (non-recursive) and writes each one into + /// the workspace **only if it doesn't already exist in the database**. + /// This allows Docker images or deployment scripts to ship customized + /// workspace templates that override the generic seeds. + /// + /// Returns the number of files imported (0 if all already existed). + pub async fn import_from_directory( + &self, + dir: &std::path::Path, + ) -> Result { + if !dir.is_dir() { + tracing::warn!( + "Workspace import directory does not exist: {}", + dir.display() + ); + return Ok(0); + } + + let entries = std::fs::read_dir(dir).map_err(|e| WorkspaceError::IoError { + reason: format!("failed to read directory {}: {}", dir.display(), e), + })?; + + let mut count = 0; + for entry in entries { + let entry = match entry { + Ok(e) => e, + Err(e) => { + tracing::warn!("Failed to read directory entry in {}: {}", dir.display(), e); + continue; + } + }; + + let path = entry.path(); + // Only import .md files + if path.extension() != Some(std::ffi::OsStr::new("md")) { + continue; + } + + let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + + // Skip if already exists in DB (never overwrite user edits) + match self.read(file_name).await { + Ok(_) => continue, + Err(WorkspaceError::DocumentNotFound { .. }) => {} + Err(e) => { + tracing::warn!("Failed to check {}: {}", file_name, e); + continue; + } + } + + let content = match std::fs::read_to_string(&path) { + Ok(c) => c, + Err(e) => { + tracing::warn!("Failed to read import file {}: {}", path.display(), e); + continue; + } + }; + + if content.trim().is_empty() { + continue; + } + + if let Err(e) = self.write(file_name, &content).await { + tracing::warn!("Failed to import {}: {}", file_name, e); + } else { + tracing::info!("Imported workspace file from disk: {}", file_name); + count += 1; + } + } + + if count > 0 { + tracing::info!( + "Imported {} workspace file(s) from {}", + count, + dir.display() + ); + } + Ok(count) + } + /// Generate embeddings for chunks that don't have them yet. /// /// This is useful for backfilling embeddings after enabling the provider.