feat: Improved workspace instructions support

This commit is contained in:
2026-07-17 14:52:35 -06:00
parent 8c885d9a77
commit a45e66c634
13 changed files with 364 additions and 81 deletions
+1
View File
@@ -38,6 +38,7 @@ Coming from [AIChat](https://github.com/sigoden/aichat)? Follow the [migration g
* [RAG](https://github.com/Dark-Alex-17/coyote/wiki/RAG): Retrieval-Augmented Generation for enhanced information retrieval and generation. * [RAG](https://github.com/Dark-Alex-17/coyote/wiki/RAG): Retrieval-Augmented Generation for enhanced information retrieval and generation.
* [Sessions](https://github.com/Dark-Alex-17/coyote/wiki/Sessions): Manage and persist conversational contexts and settings across multiple interactions. * [Sessions](https://github.com/Dark-Alex-17/coyote/wiki/Sessions): Manage and persist conversational contexts and settings across multiple interactions.
* [Memory](https://github.com/Dark-Alex-17/coyote/wiki/Memory): Persistent file-based memory that survives across sessions. Bootstrap with `coyote --init-memory [global|workspace]`. * [Memory](https://github.com/Dark-Alex-17/coyote/wiki/Memory): Persistent file-based memory that survives across sessions. Bootstrap with `coyote --init-memory [global|workspace]`.
* [Workspace Instructions](https://github.com/Dark-Alex-17/coyote/wiki/Workspace-Instructions): Human-curated project instructions (`COYOTE.md`) injected into every prompt, with `AGENTS.md`/`CLAUDE.md` fallbacks for cross-tool compatibility. Scaffold with `coyote --init-instructions`.
* [Roles](https://github.com/Dark-Alex-17/coyote/wiki/Roles): Customize model behavior for specific tasks or domains. * [Roles](https://github.com/Dark-Alex-17/coyote/wiki/Roles): Customize model behavior for specific tasks or domains.
* [Skills](https://github.com/Dark-Alex-17/coyote/wiki/Skills): Modular knowledge or capability packs the LLM can load and unload mid-conversation. Multiple skills compose; instructions stack, tools and MCPs union. * [Skills](https://github.com/Dark-Alex-17/coyote/wiki/Skills): Modular knowledge or capability packs the LLM can load and unload mid-conversation. Multiple skills compose; instructions stack, tools and MCPs union.
* [Agents](https://github.com/Dark-Alex-17/coyote/wiki/Agents): Leverage AI agents to perform complex tasks and workflows, including sub-agent spawning, teammate messaging, and user interaction tools. * [Agents](https://github.com/Dark-Alex-17/coyote/wiki/Agents): Leverage AI agents to perform complex tasks and workflows, including sub-agent spawning, teammate messaging, and user interaction tools.
+2 -2
View File
@@ -9,8 +9,8 @@ security/configuration settings. The analysis aims to ensure a thorough understa
structured and operates, enabling the creation of new files, maintaining consistency with existing practices, and the structured and operates, enabling the creation of new files, maintaining consistency with existing practices, and the
potential implementation of best practices. potential implementation of best practices.
Should the root directory contain a `COYOTE.md` file, this was generated by Coyote and should be used as a reference Should the root directory contain a `COYOTE.md` (or `AGENTS.md`/`CLAUDE.md`) file, this contains human-curated project
point for all analysis, style questions, etc. instructions and should be used as a reference point for all analysis, style questions, etc.
**Objective:** Enable the AI to thoroughly analyze a software repository, providing detailed insights and guidelines on **Objective:** Enable the AI to thoroughly analyze a software repository, providing detailed insights and guidelines on
all relevant aspects for understanding and potentially contributing to the project. all relevant aspects for understanding and potentially contributing to the project.
+2 -1
View File
@@ -10,7 +10,8 @@ Use IWE tools when the task involves a corpus of markdown documents: plan reposi
Do NOT use IWE tools for: Do NOT use IWE tools for:
- **Agent memory** (`.coyote/memory/`, `COYOTE.md`) — use the `memory__*` tools; they own the index conventions there. - **Agent memory** (`.coyote/memory/`) — use the `memory__*` tools; they own the index conventions there.
- **Workspace instructions** (`COYOTE.md`, `AGENTS.md`, `CLAUDE.md`) — human-curated and read-only; never edit them with IWE write tools.
- **Semantic/similarity search over documents** — that is RAG's job. IWE search is fuzzy title/key matching plus structural traversal, not embeddings. - **Semantic/similarity search over documents** — that is RAG's job. IWE search is fuzzy title/key matching plus structural traversal, not embeddings.
- **Source code** — IWE only understands markdown. - **Source code** — IWE only understands markdown.
+14 -2
View File
@@ -189,8 +189,8 @@ summary_context_prompt: > # The text prompt used for including the summar
# ---- Memory ---- # ---- Memory ----
# See the [Memory documentation](https://github.com/Dark-Alex-17/coyote/wiki/Memory) for more information. # See the [Memory documentation](https://github.com/Dark-Alex-17/coyote/wiki/Memory) for more information.
# Memory is opt-in by workspace presence (a `COYOTE.md` or `.coyote/memory/MEMORY.md`) # Memory is opt-in by workspace presence (`.coyote/memory/MEMORY.md`) and global
# and global presence (`<config_dir>/memory/MEMORY.md`). Set `memory: false` to disable # presence (`<config_dir>/memory/MEMORY.md`). Set `memory: false` to disable
# even when memory files exist. The cascade is: agent > session > role > app. # even when memory files exist. The cascade is: agent > session > role > app.
# Bootstrap with `coyote --init-memory [global|workspace]` to create the marker file # Bootstrap with `coyote --init-memory [global|workspace]` to create the marker file
# the LLM needs before it will write any memory. # the LLM needs before it will write any memory.
@@ -200,6 +200,18 @@ memory_cap_with_tools: null # Char cap for injected memory when function ca
memory_cap_without_tools: null # Char cap when function calling is unavailable (default: 12000). memory_cap_without_tools: null # Char cap when function calling is unavailable (default: 12000).
# Indexes plus drill file bodies are injected up to this cap. # Indexes plus drill file bodies are injected up to this cap.
# ---- Workspace Instructions ----
# Human-curated project instructions injected read-only into the system prompt, in full.
# Coyote walks up from the current directory and injects the first match from the file
# chain below (per directory, in order). Scaffold with `coyote --init-instructions`.
# Disable per-invocation with --no-workspace-instructions, or override the chain with
# repeatable --workspace-instructions-file flags.
workspace_instructions: null # null/true = inject when an instructions file exists; false = never inject
workspace_instructions_files: null # File name chain to search, in priority order.
# Default: [COYOTE.md, AGENTS.md, CLAUDE.md]
# Set to a custom list to reorder or drop fallbacks, e.g.:
# workspace_instructions_files: [COYOTE.md]
# ---- RAG ---- # ---- RAG ----
# See the [RAG Docs](https://github.com/Dark-Alex-17/coyote/wiki/RAG) for more details. # See the [RAG Docs](https://github.com/Dark-Alex-17/coyote/wiki/RAG) for more details.
rag_embedding_model: null # Specifies the embedding model used for context retrieval rag_embedding_model: null # Specifies the embedding model used for context retrieval
+9
View File
@@ -77,6 +77,12 @@ pub struct Cli {
/// Disable memory for this invocation /// Disable memory for this invocation
#[arg(long)] #[arg(long)]
pub no_memory: bool, pub no_memory: bool,
/// Disable loading workspace instructions (COYOTE.md/AGENTS.md/CLAUDE.md/etc.) for this invocation
#[arg(long)]
pub no_workspace_instructions: bool,
/// Override the workspace instructions file chain for this invocation (repeatable, priority order)
#[arg(long, value_name = "NAME")]
pub workspace_instructions_file: Vec<String>,
/// Skip permission prompts by setting AUTO_CONFIRM for all tools (dangerous!) /// Skip permission prompts by setting AUTO_CONFIRM for all tools (dangerous!)
#[arg(long)] #[arg(long)]
pub dangerously_skip_permissions: bool, pub dangerously_skip_permissions: bool,
@@ -98,6 +104,9 @@ pub struct Cli {
help_heading = "Session & Memory" help_heading = "Session & Memory"
)] )]
pub init_memory: Option<MemoryScope>, pub init_memory: Option<MemoryScope>,
/// Scaffold a COYOTE.md workspace instructions file in the current directory
#[arg(long, help_heading = "Session & Memory")]
pub init_instructions: bool,
/// Pre-load an existing skill into the session (repeatable). If a single /// Pre-load an existing skill into the session (repeatable). If a single
/// `--skill <NAME>` is given and the skill doesn't exist, opens $EDITOR /// `--skill <NAME>` is given and the skill doesn't exist, opens $EDITOR
/// with a scaffold to create it. /// with a scaffold to create it.
+9
View File
@@ -69,6 +69,9 @@ pub struct AppConfig {
pub memory_cap_with_tools: Option<usize>, pub memory_cap_with_tools: Option<usize>,
pub memory_cap_without_tools: Option<usize>, pub memory_cap_without_tools: Option<usize>,
pub workspace_instructions: Option<bool>,
pub workspace_instructions_files: Option<Vec<String>>,
pub rag_embedding_model: Option<String>, pub rag_embedding_model: Option<String>,
pub rag_reranker_model: Option<String>, pub rag_reranker_model: Option<String>,
pub rag_top_k: usize, pub rag_top_k: usize,
@@ -146,6 +149,9 @@ impl Default for AppConfig {
memory_cap_with_tools: None, memory_cap_with_tools: None,
memory_cap_without_tools: None, memory_cap_without_tools: None,
workspace_instructions: None,
workspace_instructions_files: None,
rag_embedding_model: None, rag_embedding_model: None,
rag_reranker_model: None, rag_reranker_model: None,
rag_top_k: 5, rag_top_k: 5,
@@ -224,6 +230,9 @@ impl AppConfig {
memory_cap_with_tools: config.memory_cap_with_tools, memory_cap_with_tools: config.memory_cap_with_tools,
memory_cap_without_tools: config.memory_cap_without_tools, memory_cap_without_tools: config.memory_cap_without_tools,
workspace_instructions: config.workspace_instructions,
workspace_instructions_files: config.workspace_instructions_files,
rag_embedding_model: config.rag_embedding_model, rag_embedding_model: config.rag_embedding_model,
rag_reranker_model: config.rag_reranker_model, rag_reranker_model: config.rag_reranker_model,
rag_top_k: config.rag_top_k, rag_top_k: config.rag_top_k,
+203
View File
@@ -0,0 +1,203 @@
use std::fs;
use std::path::{Path, PathBuf};
use log::warn;
pub const WORKSPACE_INSTRUCTIONS_FILE_NAME: &str = "COYOTE.md";
pub const DEFAULT_WORKSPACE_INSTRUCTIONS_FILES: [&str; 3] =
[WORKSPACE_INSTRUCTIONS_FILE_NAME, "AGENTS.md", "CLAUDE.md"];
const INSTRUCTIONS_SIZE_WARN_THRESHOLD: usize = 24_000;
#[derive(Debug, Clone)]
pub struct WorkspaceInstructions {
pub path: PathBuf,
pub content: String,
}
pub fn default_workspace_instructions_files() -> Vec<String> {
DEFAULT_WORKSPACE_INSTRUCTIONS_FILES
.iter()
.map(|s| s.to_string())
.collect()
}
pub fn discover_workspace_instructions(
start: &Path,
file_names: &[String],
) -> Option<WorkspaceInstructions> {
for dir in start.ancestors() {
for name in file_names {
let candidate = dir.join(name);
if !candidate.is_file() {
continue;
}
match fs::read_to_string(&candidate) {
Ok(content) if !content.trim().is_empty() => {
return Some(WorkspaceInstructions {
path: candidate,
content,
});
}
Ok(_) => {}
Err(e) => warn!(
"failed to read workspace instructions at {}: {e}",
candidate.display()
),
}
}
}
None
}
pub fn build_instructions_section(instructions: &WorkspaceInstructions) -> String {
let char_count = instructions.content.chars().count();
if char_count > INSTRUCTIONS_SIZE_WARN_THRESHOLD {
warn!(
"workspace instructions at {} are large ({char_count} chars); \
consider moving detail into workspace memory drill files",
instructions.path.display()
);
}
format!(
"<workspace_instructions source=\"{}\">\n{}\n</workspace_instructions>",
instructions.path.display(),
instructions.content.trim_end()
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::{env, time};
use time::SystemTime;
fn temp_root(label: &str) -> PathBuf {
let unique = SystemTime::now()
.duration_since(time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let root = env::temp_dir().join(format!("coyote-instructions-{label}-{unique}"));
fs::create_dir_all(&root).unwrap();
root
}
fn defaults() -> Vec<String> {
default_workspace_instructions_files()
}
#[test]
fn discovery_returns_none_when_no_file_exists() {
let root = temp_root("none");
assert!(discover_workspace_instructions(&root, &defaults()).is_none());
let _ = fs::remove_dir_all(&root);
}
#[test]
fn discovery_finds_coyote_md() {
let root = temp_root("coyote");
fs::write(root.join("COYOTE.md"), "coyote instructions").unwrap();
let found = discover_workspace_instructions(&root, &defaults()).unwrap();
assert_eq!(found.path, root.join("COYOTE.md"));
assert_eq!(found.content, "coyote instructions");
let _ = fs::remove_dir_all(&root);
}
#[test]
fn discovery_falls_back_to_agents_md_then_claude_md() {
let root = temp_root("fallback");
fs::write(root.join("CLAUDE.md"), "claude instructions").unwrap();
let found = discover_workspace_instructions(&root, &defaults()).unwrap();
assert_eq!(found.path, root.join("CLAUDE.md"));
fs::write(root.join("AGENTS.md"), "agents instructions").unwrap();
let found = discover_workspace_instructions(&root, &defaults()).unwrap();
assert_eq!(found.path, root.join("AGENTS.md"));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn discovery_prefers_coyote_md_over_fallbacks() {
let root = temp_root("precedence");
fs::write(root.join("COYOTE.md"), "coyote").unwrap();
fs::write(root.join("AGENTS.md"), "agents").unwrap();
fs::write(root.join("CLAUDE.md"), "claude").unwrap();
let found = discover_workspace_instructions(&root, &defaults()).unwrap();
assert_eq!(found.path, root.join("COYOTE.md"));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn discovery_walks_up_from_nested_dir() {
let root = temp_root("walk_up");
fs::write(root.join("AGENTS.md"), "root instructions").unwrap();
let nested = root.join("src").join("deep");
fs::create_dir_all(&nested).unwrap();
let found = discover_workspace_instructions(&nested, &defaults()).unwrap();
assert_eq!(found.path, root.join("AGENTS.md"));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn discovery_prefers_closer_file_over_higher_priority_name_above() {
let root = temp_root("depth_first");
fs::write(root.join("COYOTE.md"), "root coyote").unwrap();
let nested = root.join("packages").join("app");
fs::create_dir_all(&nested).unwrap();
fs::write(nested.join("CLAUDE.md"), "nested claude").unwrap();
let found = discover_workspace_instructions(&nested, &defaults()).unwrap();
assert_eq!(found.path, nested.join("CLAUDE.md"));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn discovery_skips_empty_files() {
let root = temp_root("empty");
fs::write(root.join("COYOTE.md"), " \n").unwrap();
fs::write(root.join("AGENTS.md"), "real content").unwrap();
let found = discover_workspace_instructions(&root, &defaults()).unwrap();
assert_eq!(found.path, root.join("AGENTS.md"));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn discovery_honors_custom_file_chain() {
let root = temp_root("custom");
fs::write(root.join("CLAUDE.md"), "claude").unwrap();
let only_agents = vec!["AGENTS.md".to_string()];
assert!(discover_workspace_instructions(&root, &only_agents).is_none());
let empty: Vec<String> = vec![];
assert!(discover_workspace_instructions(&root, &empty).is_none());
let _ = fs::remove_dir_all(&root);
}
#[test]
fn build_section_wraps_content_with_source_path() {
let instructions = WorkspaceInstructions {
path: PathBuf::from("/ws/COYOTE.md"),
content: "Do the thing.\n".into(),
};
let section = build_instructions_section(&instructions);
assert!(section.starts_with("<workspace_instructions source=\"/ws/COYOTE.md\">"));
assert!(section.contains("Do the thing."));
assert!(section.ends_with("</workspace_instructions>"));
}
}
+17 -37
View File
@@ -7,41 +7,27 @@ use serde::{Deserialize, Serialize};
use crate::config::{ use crate::config::{
GIT_DIR_NAME, GITIGNORE_FILE_NAME, MEMORY_DIR_NAME, MEMORY_INDEX_FILE_NAME, GIT_DIR_NAME, GITIGNORE_FILE_NAME, MEMORY_DIR_NAME, MEMORY_INDEX_FILE_NAME,
WORKSPACE_COYOTE_DIR_NAME, WORKSPACE_MEMORY_FILE_NAME, paths, WORKSPACE_COYOTE_DIR_NAME, paths,
}; };
pub const DEFAULT_MEMORY_CAP_WITH_TOOLS: usize = 6_000; pub const DEFAULT_MEMORY_CAP_WITH_TOOLS: usize = 6_000;
pub const DEFAULT_MEMORY_CAP_WITHOUT_TOOLS: usize = 12_000; pub const DEFAULT_MEMORY_CAP_WITHOUT_TOOLS: usize = 12_000;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum WorkspaceMemory { pub struct WorkspaceMemory {
Structured { pub workspace_root: PathBuf,
workspace_root: PathBuf, pub dir: PathBuf,
dir: PathBuf,
},
Lite {
workspace_root: PathBuf,
file: PathBuf,
},
} }
pub fn discover_workspace_memory(start: &Path) -> Option<WorkspaceMemory> { pub fn discover_workspace_memory(start: &Path) -> Option<WorkspaceMemory> {
for dir in start.ancestors() { for dir in start.ancestors() {
let structured = dir.join(WORKSPACE_COYOTE_DIR_NAME).join(MEMORY_DIR_NAME); let structured = dir.join(WORKSPACE_COYOTE_DIR_NAME).join(MEMORY_DIR_NAME);
if structured.join(MEMORY_INDEX_FILE_NAME).exists() { if structured.join(MEMORY_INDEX_FILE_NAME).exists() {
return Some(WorkspaceMemory::Structured { return Some(WorkspaceMemory {
workspace_root: dir.to_path_buf(), workspace_root: dir.to_path_buf(),
dir: structured, dir: structured,
}); });
} }
let lite = dir.join(WORKSPACE_MEMORY_FILE_NAME);
if lite.exists() {
return Some(WorkspaceMemory::Lite {
workspace_root: dir.to_path_buf(),
file: lite,
});
}
} }
None None
} }
@@ -82,7 +68,7 @@ pub fn bootstrap_workspace_memory(git_root: &Path) -> Result<PathBuf> {
Ok(mem_dir) Ok(mem_dir)
} }
fn append_gitignore_entry(git_root: &Path) -> Result<bool> { pub fn append_gitignore_entry(git_root: &Path) -> Result<bool> {
let gitignore = git_root.join(GITIGNORE_FILE_NAME); let gitignore = git_root.join(GITIGNORE_FILE_NAME);
let entry = format!("{WORKSPACE_COYOTE_DIR_NAME}/{MEMORY_DIR_NAME}/"); let entry = format!("{WORKSPACE_COYOTE_DIR_NAME}/{MEMORY_DIR_NAME}/");
let entry_no_slash = format!("{WORKSPACE_COYOTE_DIR_NAME}/{MEMORY_DIR_NAME}"); let entry_no_slash = format!("{WORKSPACE_COYOTE_DIR_NAME}/{MEMORY_DIR_NAME}");
@@ -212,9 +198,8 @@ impl MemoryStore {
pub fn load_workspace_index(&self) -> Result<Option<String>> { pub fn load_workspace_index(&self) -> Result<Option<String>> {
match &self.workspace { match &self.workspace {
None => Ok(None), None => Ok(None),
Some(WorkspaceMemory::Lite { file, .. }) => Ok(Some(fs::read_to_string(file)?)), Some(ws) => {
Some(WorkspaceMemory::Structured { dir, .. }) => { let index = ws.dir.join(MEMORY_INDEX_FILE_NAME);
let index = dir.join(MEMORY_INDEX_FILE_NAME);
if index.exists() { if index.exists() {
Ok(Some(fs::read_to_string(index)?)) Ok(Some(fs::read_to_string(index)?))
} else { } else {
@@ -231,8 +216,8 @@ impl MemoryStore {
collect_md_files(&self.global_dir, &mut out)?; collect_md_files(&self.global_dir, &mut out)?;
} }
if let Some(WorkspaceMemory::Structured { dir, .. }) = &self.workspace { if let Some(ws) = &self.workspace {
collect_md_files(dir, &mut out)?; collect_md_files(&ws.dir, &mut out)?;
} }
Ok(out) Ok(out)
@@ -378,18 +363,13 @@ mod tests {
} }
#[test] #[test]
fn workspace_discovery_prefers_structured_over_lite() { fn workspace_discovery_ignores_root_instructions_file() {
let root = temp_root("prefer"); let root = temp_root("no_lite");
let workspace = root.join("ws"); let workspace = root.join("ws");
let structured = workspace fs::create_dir_all(&workspace).unwrap();
.join(WORKSPACE_COYOTE_DIR_NAME) fs::write(workspace.join("COYOTE.md"), "instructions, not memory").unwrap();
.join(MEMORY_DIR_NAME);
fs::create_dir_all(&structured).unwrap();
fs::write(structured.join(MEMORY_INDEX_FILE_NAME), "s").unwrap();
fs::write(workspace.join(WORKSPACE_MEMORY_FILE_NAME), "l").unwrap();
let found = discover_workspace_memory(&workspace); assert!(discover_workspace_memory(&workspace).is_none());
assert!(matches!(found, Some(WorkspaceMemory::Structured { .. })));
let _ = fs::remove_dir_all(&root); let _ = fs::remove_dir_all(&root);
} }
@@ -582,8 +562,8 @@ mod tests {
let nested = workspace.join("src").join("deep").join("path"); let nested = workspace.join("src").join("deep").join("path");
fs::create_dir_all(&nested).unwrap(); fs::create_dir_all(&nested).unwrap();
let found = discover_workspace_memory(&nested); let found = discover_workspace_memory(&nested).expect("workspace memory should be found");
assert!(matches!(found, Some(WorkspaceMemory::Structured { .. }))); assert_eq!(found.dir, mem_dir);
let _ = fs::remove_dir_all(&root); let _ = fs::remove_dir_all(&root);
} }
+7 -1
View File
@@ -3,6 +3,7 @@ mod app_config;
mod app_state; mod app_state;
mod input; mod input;
mod install_remote; mod install_remote;
pub(crate) mod instructions;
mod macros; mod macros;
mod mcp_factory; mod mcp_factory;
pub(crate) mod memory; pub(crate) mod memory;
@@ -143,7 +144,6 @@ const MCP_FILE_NAME: &str = "mcp.json";
const HIDDEN_MCP_FILE_NAME: &str = ".mcp.json"; const HIDDEN_MCP_FILE_NAME: &str = ".mcp.json";
const MEMORY_DIR_NAME: &str = "memory"; const MEMORY_DIR_NAME: &str = "memory";
const MEMORY_INDEX_FILE_NAME: &str = "MEMORY.md"; const MEMORY_INDEX_FILE_NAME: &str = "MEMORY.md";
const WORKSPACE_MEMORY_FILE_NAME: &str = "COYOTE.md";
const WORKSPACE_COYOTE_DIR_NAME: &str = ".coyote"; const WORKSPACE_COYOTE_DIR_NAME: &str = ".coyote";
const SBX_KIT_DIR_NAME: &str = "sbx-kit"; const SBX_KIT_DIR_NAME: &str = "sbx-kit";
const SBX_KIT_HASH_FILE: &str = "kit.sha256"; const SBX_KIT_HASH_FILE: &str = "kit.sha256";
@@ -245,6 +245,9 @@ pub struct Config {
pub memory_cap_with_tools: Option<usize>, pub memory_cap_with_tools: Option<usize>,
pub memory_cap_without_tools: Option<usize>, pub memory_cap_without_tools: Option<usize>,
pub workspace_instructions: Option<bool>,
pub workspace_instructions_files: Option<Vec<String>>,
pub rag_embedding_model: Option<String>, pub rag_embedding_model: Option<String>,
pub rag_reranker_model: Option<String>, pub rag_reranker_model: Option<String>,
pub rag_top_k: usize, pub rag_top_k: usize,
@@ -320,6 +323,9 @@ impl Default for Config {
memory_cap_with_tools: None, memory_cap_with_tools: None,
memory_cap_without_tools: None, memory_cap_without_tools: None,
workspace_instructions: None,
workspace_instructions_files: None,
rag_embedding_model: None, rag_embedding_model: None,
rag_reranker_model: None, rag_reranker_model: None,
rag_top_k: 5, rag_top_k: 5,
+4
View File
@@ -356,6 +356,10 @@ pub fn workspace_memory_dir_for(workspace_root: &Path) -> PathBuf {
.join(MEMORY_DIR_NAME) .join(MEMORY_DIR_NAME)
} }
pub fn workspace_memory_index_path_for(workspace_root: &Path) -> PathBuf {
workspace_memory_dir_for(workspace_root).join(MEMORY_INDEX_FILE_NAME)
}
pub fn repl_history_dir() -> PathBuf { pub fn repl_history_dir() -> PathBuf {
cache_path().join(REPL_HISTORY_DIR_NAME) cache_path().join(REPL_HISTORY_DIR_NAME)
} }
+33
View File
@@ -32,6 +32,7 @@ use crate::utils::{
list_file_names, now, render_prompt, temp_file, list_file_names, now, render_prompt, temp_file,
}; };
use super::instructions;
use super::memory::{ use super::memory::{
DEFAULT_MEMORY_CAP_WITH_TOOLS, DEFAULT_MEMORY_CAP_WITHOUT_TOOLS, MemoryStore, WorkspaceMemory, DEFAULT_MEMORY_CAP_WITH_TOOLS, DEFAULT_MEMORY_CAP_WITHOUT_TOOLS, MemoryStore, WorkspaceMemory,
}; };
@@ -797,6 +798,20 @@ impl RequestContext {
self.session.as_ref(), self.session.as_ref(),
)?; )?;
if app.workspace_instructions.unwrap_or(true)
&& let Ok(cwd) = env::current_dir()
{
let file_names = app
.workspace_instructions_files
.clone()
.unwrap_or_else(instructions::default_workspace_instructions_files);
if let Some(found) = instructions::discover_workspace_instructions(&cwd, &file_names) {
let separator = if role.is_empty_prompt() { "" } else { "\n\n" };
role.append_to_prompt(separator);
role.append_to_prompt(&instructions::build_instructions_section(&found));
}
}
if should_inject_skill_instructions(app, &policy) { if should_inject_skill_instructions(app, &policy) {
let config = self.skill_instructions_config(); let config = self.skill_instructions_config();
@@ -1470,6 +1485,24 @@ impl RequestContext {
"memory_cap_without_tools", "memory_cap_without_tools",
super::format_option_value(&app.memory_cap_without_tools), super::format_option_value(&app.memory_cap_without_tools),
), ),
(
"workspace_instructions",
super::format_option_value(&app.workspace_instructions),
),
(
"workspace_instructions_file",
env::current_dir()
.ok()
.and_then(|cwd| {
let file_names = app
.workspace_instructions_files
.clone()
.unwrap_or_else(instructions::default_workspace_instructions_files);
instructions::discover_workspace_instructions(&cwd, &file_names)
})
.map(|i| i.path.display().to_string())
.unwrap_or_else(|| "null".into()),
),
( (
"rag_reranker_model", "rag_reranker_model",
super::format_option_value(&rag_reranker_model), super::format_option_value(&rag_reranker_model),
+19 -33
View File
@@ -474,7 +474,7 @@ fn rename_memory(store: &MemoryStore, cwd: &Path, args: &Value) -> Result<Value>
let description = renamed.frontmatter.description.clone().unwrap_or_default(); let description = renamed.frontmatter.description.clone().unwrap_or_default();
ensure_index_entry(&index_path, &new_name, &description)?; ensure_index_entry(&index_path, &new_name, &description)?;
// Other indexes (other scope's MEMORY.md, lite COYOTE.md): rewrite wikilinks only. // Other indexes (other scope's MEMORY.md): rewrite wikilinks only.
for other_index in other_index_paths(store, &target_dir) { for other_index in other_index_paths(store, &target_dir) {
if let Ok(existing) = fs::read_to_string(&other_index) if let Ok(existing) = fs::read_to_string(&other_index)
&& existing.contains(&needle) && existing.contains(&needle)
@@ -539,17 +539,11 @@ fn other_index_paths(store: &MemoryStore, own_dir: &Path) -> Vec<PathBuf> {
out.push(global_index); out.push(global_index);
} }
match &store.workspace { if let Some(ws) = &store.workspace {
Some(WorkspaceMemory::Structured { dir, .. }) => { let index = ws.dir.join("MEMORY.md");
let index = dir.join("MEMORY.md"); if ws.dir.as_path() != own_dir && index.exists() {
if dir.as_path() != own_dir && index.exists() { out.push(index);
out.push(index);
}
} }
Some(WorkspaceMemory::Lite { file, .. }) if file.exists() => {
out.push(file.clone());
}
_ => {}
} }
out out
@@ -637,10 +631,7 @@ fn find_file(store: &MemoryStore, name: &str) -> Result<Option<MemoryFile>> {
fn workspace_write_dir(store: &MemoryStore, cwd: &Path) -> Result<PathBuf> { fn workspace_write_dir(store: &MemoryStore, cwd: &Path) -> Result<PathBuf> {
match &store.workspace { match &store.workspace {
Some(WorkspaceMemory::Structured { dir, .. }) => Ok(dir.clone()), Some(ws) => Ok(ws.dir.clone()),
Some(WorkspaceMemory::Lite { workspace_root, .. }) => {
Ok(paths::workspace_memory_dir_for(workspace_root))
}
None => match find_git_root(cwd) { None => match find_git_root(cwd) {
Some(git_root) => bootstrap_workspace_memory(&git_root), Some(git_root) => bootstrap_workspace_memory(&git_root),
None => bail!( None => bail!(
@@ -652,20 +643,10 @@ fn workspace_write_dir(store: &MemoryStore, cwd: &Path) -> Result<PathBuf> {
} }
fn workspace_label(w: &WorkspaceMemory) -> Value { fn workspace_label(w: &WorkspaceMemory) -> Value {
match w { json!({
WorkspaceMemory::Structured { workspace_root, .. } => json!({ "root": w.workspace_root.display().to_string(),
"mode": "structured", "dir": w.dir.display().to_string(),
"root": workspace_root.display().to_string(), })
}),
WorkspaceMemory::Lite {
workspace_root,
file,
} => json!({
"mode": "lite",
"root": workspace_root.display().to_string(),
"file": file.display().to_string(),
}),
}
} }
fn lint_memory(store: &MemoryStore) -> Result<Value> { fn lint_memory(store: &MemoryStore) -> Result<Value> {
@@ -872,19 +853,24 @@ mod tests {
} }
#[test] #[test]
fn workspace_write_dir_promotes_lite_to_structured_subdir() { fn workspace_write_dir_treats_root_instructions_file_as_no_memory() {
let root = temp_root("ws_lite_promote"); let root = temp_root("ws_instructions_only");
let workspace = root.join("ws"); let workspace = root.join("ws");
fs::create_dir_all(&workspace).unwrap(); fs::create_dir_all(workspace.join(".git")).unwrap();
fs::write(workspace.join("COYOTE.md"), "lite").unwrap(); fs::write(workspace.join("COYOTE.md"), "instructions, not memory").unwrap();
let store = MemoryStore { let store = MemoryStore {
global_dir: root.join("g"), global_dir: root.join("g"),
workspace: discover_workspace_memory(&workspace), workspace: discover_workspace_memory(&workspace),
}; };
assert!(store.workspace.is_none(), "COYOTE.md must not be memory");
let dir = workspace_write_dir(&store, &workspace).unwrap(); let dir = workspace_write_dir(&store, &workspace).unwrap();
assert_eq!(dir, workspace.join(".coyote").join("memory")); assert_eq!(dir, workspace.join(".coyote").join("memory"));
assert!(
dir.join("MEMORY.md").exists(),
"bootstrap must create index"
);
let _ = fs::remove_dir_all(&root); let _ = fs::remove_dir_all(&root);
} }
+44 -5
View File
@@ -21,12 +21,13 @@ use crate::cli::Cli;
use crate::client::{ use crate::client::{
ModelType, call_chat_completions, call_chat_completions_streaming, list_models, oauth, ModelType, call_chat_completions, call_chat_completions_streaming, list_models, oauth,
}; };
use crate::config::paths; use crate::config::instructions::WORKSPACE_INSTRUCTIONS_FILE_NAME;
use crate::config::{ use crate::config::{
Agent, AppConfig, AppState, CODE_ROLE, Config, EXPLAIN_SHELL_ROLE, Input, MemoryScope, Agent, AppConfig, AppState, CODE_ROLE, Config, EXPLAIN_SHELL_ROLE, Input, MemoryScope,
RequestContext, SHELL_ROLE, TEMP_SESSION_NAME, WorkingMode, ensure_parent_exists, RequestContext, SHELL_ROLE, TEMP_SESSION_NAME, WorkingMode, ensure_parent_exists,
install_builtins, list_agents, load_env_file, macro_execute, sync_models, install_builtins, list_agents, load_env_file, macro_execute, sync_models,
}; };
use crate::config::{memory, paths};
use crate::function::supervisor::{GuardrailAction, check_pending_agents_guardrail}; use crate::function::supervisor::{GuardrailAction, check_pending_agents_guardrail};
use crate::mcp::McpServersConfig; use crate::mcp::McpServersConfig;
use crate::render::{prompt_theme, render_error}; use crate::render::{prompt_theme, render_error};
@@ -369,6 +370,15 @@ async fn run(
if cli.no_memory { if cli.no_memory {
update_app_config(&mut ctx, |app| app.memory = Some(false)); update_app_config(&mut ctx, |app| app.memory = Some(false));
} }
if cli.no_workspace_instructions {
update_app_config(&mut ctx, |app| app.workspace_instructions = Some(false));
}
if !cli.workspace_instructions_file.is_empty() {
let files = cli.workspace_instructions_file.clone();
update_app_config(&mut ctx, |app| {
app.workspace_instructions_files = Some(files);
});
}
if cli.empty_session { if cli.empty_session {
ctx.empty_session()?; ctx.empty_session()?;
} }
@@ -381,10 +391,14 @@ async fn run(
paths::global_memory_index_path(), paths::global_memory_index_path(),
"# Global Memory\n\n<!-- Universal facts about you go here. The LLM uses this as always-on context. -->\n<!-- Drill files (when created) are listed below. -->\n", "# Global Memory\n\n<!-- Universal facts about you go here. The LLM uses this as always-on context. -->\n<!-- Drill files (when created) are listed below. -->\n",
), ),
MemoryScope::Workspace => ( MemoryScope::Workspace => {
env::current_dir()?.join("COYOTE.md"), let cwd = env::current_dir()?;
"# Workspace Memory\n\n<!-- Facts about this project go here. The LLM uses this as always-on context. -->\n", let root = memory::find_git_root(&cwd).unwrap_or(cwd);
), (
paths::workspace_memory_index_path_for(&root),
"# Workspace Memory Index\n\n<!-- Facts about this project go here. The LLM uses this as always-on context. -->\n<!-- Drill files (when created) are listed below. -->\n",
)
}
}; };
if path.exists() { if path.exists() {
@@ -397,9 +411,34 @@ async fn run(
} }
fs::write(&path, content)?; fs::write(&path, content)?;
if scope == MemoryScope::Workspace
&& let Some(git_root) = memory::find_git_root(&path)
{
memory::append_gitignore_entry(&git_root)?;
}
println!("✓ Created memory marker at '{}'.", path.display()); println!("✓ Created memory marker at '{}'.", path.display());
return Ok(()); return Ok(());
} }
if cli.init_instructions {
let path = env::current_dir()?.join(WORKSPACE_INSTRUCTIONS_FILE_NAME);
if path.exists() {
eprintln!(
"Workspace instructions already exist at '{}'.",
path.display()
);
return Ok(());
}
fs::write(
&path,
"# Project Instructions\n\n<!-- Human-curated instructions for AI agents working in this repo. -->\n<!-- Coyote injects this file into the system prompt read-only, in full. -->\n",
)?;
println!("✓ Created workspace instructions at '{}'.", path.display());
return Ok(());
}
if cli.info { if cli.info {
let app: Arc<AppConfig> = Arc::clone(&ctx.app.config); let app: Arc<AppConfig> = Arc::clone(&ctx.app.config);
let info = ctx.info(app.as_ref())?; let info = ctx.info(app.as_ref())?;