mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 15:40:18 +00:00
feat(workspace): add TOOLS.md, BOOTSTRAP.md, and disk-to-DB import (#477)
* feat(workspace): add TOOLS.md, BOOTSTRAP.md, and disk-to-DB import Add two new OpenClaw-compatible workspace markdown files: - TOOLS.md: Environment-specific tool notes (SSH hosts, device names, etc.) injected into the system prompt under "## Tool Notes". Seeded as comment-only (like HEARTBEAT.md) so it's effectively empty until the user adds real content. Not write-protected — the agent can update it as it learns the environment. - BOOTSTRAP.md: First-run onboarding ritual. Injected FIRST in the system prompt when present. Guides the agent through introducing itself, learning about the user, and updating workspace files. Only seeded on truly fresh workspaces (no existing identity files) to avoid triggering the ritual on existing deployments. Agent clears it via `memory_write(target="bootstrap")` when done. Add `Workspace::import_from_directory()` for disk-to-DB import: - Scans a directory for *.md files and imports any that don't already exist in the database (never overwrites user edits) - Controlled by WORKSPACE_IMPORT_DIR env var, runs after seed_if_empty() - Enables Docker images / deployment scripts to ship customized workspace templates that override generic seeds - Backwards compatible: no-op when env var is unset Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address PR review comments - Use stable `path.extension() != Some(OsStr::new("md"))` instead of unstable `is_none_or` (nightly-only) - Use `tokio::join!` for concurrent DB reads in fresh-workspace check - Skip unreadable directory entries instead of failing the entire import - Skip unreadable files instead of failing the entire import Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
6adf95b6d1
commit
5f841554d5
+22
@@ -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 {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 = "\
|
||||
<!-- TOOLS.md — Environment-specific tool notes.
|
||||
This file does not control which tools are available; it is guidance only.
|
||||
The agent can update this file as it learns your setup.
|
||||
|
||||
Examples:
|
||||
- SSH hosts: dev-box (Ubuntu 22.04, username: alice)
|
||||
- Camera: Canon R6 mounted at /Volumes/EOS_R
|
||||
- Default shell on remote: bash, no zsh
|
||||
|
||||
Add your environment notes below (outside the comment block).
|
||||
-->";
|
||||
|
||||
/// 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<String, WorkspaceError> {
|
||||
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<usize, WorkspaceError> {
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user