feat: Added the ability to auto-bootstrap workspace memory when in git repos
This commit is contained in:
+218
-2
@@ -6,8 +6,8 @@ use log::warn;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::config::{
|
use crate::config::{
|
||||||
MEMORY_DIR_NAME, MEMORY_INDEX_FILE_NAME, WORKSPACE_MEMORY_DIR_NAME, WORKSPACE_MEMORY_FILE_NAME,
|
GIT_DIR_NAME, GITIGNORE_FILE_NAME, MEMORY_DIR_NAME, MEMORY_INDEX_FILE_NAME,
|
||||||
paths,
|
WORKSPACE_MEMORY_DIR_NAME, WORKSPACE_MEMORY_FILE_NAME, paths,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const DEFAULT_MEMORY_CAP_WITH_TOOLS: usize = 6_000;
|
pub const DEFAULT_MEMORY_CAP_WITH_TOOLS: usize = 6_000;
|
||||||
@@ -46,6 +46,70 @@ pub fn discover_workspace_memory(start: &Path) -> Option<WorkspaceMemory> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn find_git_root(start: &Path) -> Option<PathBuf> {
|
||||||
|
for dir in start.ancestors() {
|
||||||
|
if dir.join(GIT_DIR_NAME).exists() {
|
||||||
|
return Some(dir.to_path_buf());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bootstrap_workspace_memory(git_root: &Path) -> Result<PathBuf> {
|
||||||
|
let mem_dir = paths::workspace_memory_dir_for(git_root);
|
||||||
|
fs::create_dir_all(&mem_dir)
|
||||||
|
.with_context(|| format!("create memory dir {}", mem_dir.display()))?;
|
||||||
|
|
||||||
|
let index_path = mem_dir.join(MEMORY_INDEX_FILE_NAME);
|
||||||
|
if !index_path.exists() {
|
||||||
|
fs::write(&index_path, "# Workspace Memory Index\n\n")
|
||||||
|
.with_context(|| format!("write {}", index_path.display()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let gitignore_appended = append_gitignore_entry(git_root)?;
|
||||||
|
let suffix = if gitignore_appended {
|
||||||
|
" (appended .coyote/memory/ to .gitignore)"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
warn!(
|
||||||
|
"auto-bootstrapped workspace memory at {}{}",
|
||||||
|
mem_dir.display(),
|
||||||
|
suffix
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(mem_dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_gitignore_entry(git_root: &Path) -> Result<bool> {
|
||||||
|
let gitignore = git_root.join(GITIGNORE_FILE_NAME);
|
||||||
|
let entry = format!("{WORKSPACE_MEMORY_DIR_NAME}/{MEMORY_DIR_NAME}/");
|
||||||
|
let entry_no_slash = format!("{WORKSPACE_MEMORY_DIR_NAME}/{MEMORY_DIR_NAME}");
|
||||||
|
|
||||||
|
let existing = fs::read_to_string(&gitignore).unwrap_or_default();
|
||||||
|
let already_present = existing.lines().any(|line| {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
trimmed == entry || trimmed == entry_no_slash
|
||||||
|
});
|
||||||
|
|
||||||
|
if already_present {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_content = if existing.is_empty() {
|
||||||
|
format!("{entry}\n")
|
||||||
|
} else if existing.ends_with('\n') {
|
||||||
|
format!("{existing}{entry}\n")
|
||||||
|
} else {
|
||||||
|
format!("{existing}\n{entry}\n")
|
||||||
|
};
|
||||||
|
|
||||||
|
fs::write(&gitignore, new_content).with_context(|| format!("write {}", gitignore.display()))?;
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
|
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
|
||||||
pub struct MemoryFrontmatter {
|
pub struct MemoryFrontmatter {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -514,4 +578,156 @@ mod tests {
|
|||||||
|
|
||||||
let _ = fs::remove_dir_all(&root);
|
let _ = fs::remove_dir_all(&root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_git_root_returns_dir_containing_git_dir() {
|
||||||
|
let root = temp_root("git_root");
|
||||||
|
let repo = root.join("repo");
|
||||||
|
fs::create_dir_all(repo.join(GIT_DIR_NAME)).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(find_git_root(&repo), Some(repo.clone()));
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_git_root_walks_up_from_nested_dir() {
|
||||||
|
let root = temp_root("git_root_walk");
|
||||||
|
let repo = root.join("repo");
|
||||||
|
fs::create_dir_all(repo.join(GIT_DIR_NAME)).unwrap();
|
||||||
|
let nested = repo.join("a").join("b").join("c");
|
||||||
|
fs::create_dir_all(&nested).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(find_git_root(&nested), Some(repo));
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_git_root_treats_git_file_as_repo_marker() {
|
||||||
|
let root = temp_root("git_root_worktree");
|
||||||
|
let worktree = root.join("worktree");
|
||||||
|
fs::create_dir_all(&worktree).unwrap();
|
||||||
|
fs::write(
|
||||||
|
worktree.join(GIT_DIR_NAME),
|
||||||
|
"gitdir: /elsewhere/.git/worktrees/wt\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(find_git_root(&worktree), Some(worktree));
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_git_root_returns_none_when_no_git() {
|
||||||
|
let root = temp_root("git_root_missing");
|
||||||
|
let bare = root.join("bare");
|
||||||
|
fs::create_dir_all(&bare).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(find_git_root(&bare), None);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bootstrap_creates_structured_layout_and_index() {
|
||||||
|
let root = temp_root("bootstrap_layout");
|
||||||
|
let repo = root.join("repo");
|
||||||
|
fs::create_dir_all(repo.join(GIT_DIR_NAME)).unwrap();
|
||||||
|
|
||||||
|
let mem_dir = bootstrap_workspace_memory(&repo).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(mem_dir, paths::workspace_memory_dir_for(&repo));
|
||||||
|
assert!(mem_dir.is_dir());
|
||||||
|
let index = mem_dir.join(MEMORY_INDEX_FILE_NAME);
|
||||||
|
assert!(index.exists());
|
||||||
|
let body = fs::read_to_string(&index).unwrap();
|
||||||
|
assert!(body.starts_with("# Workspace Memory Index"));
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bootstrap_creates_gitignore_when_absent() {
|
||||||
|
let root = temp_root("bootstrap_gi_new");
|
||||||
|
let repo = root.join("repo");
|
||||||
|
fs::create_dir_all(repo.join(GIT_DIR_NAME)).unwrap();
|
||||||
|
|
||||||
|
bootstrap_workspace_memory(&repo).unwrap();
|
||||||
|
|
||||||
|
let gi = repo.join(GITIGNORE_FILE_NAME);
|
||||||
|
assert!(gi.exists());
|
||||||
|
let body = fs::read_to_string(&gi).unwrap();
|
||||||
|
assert!(body.contains(".coyote/memory/"));
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bootstrap_appends_to_existing_gitignore_without_trailing_newline() {
|
||||||
|
let root = temp_root("bootstrap_gi_append");
|
||||||
|
let repo = root.join("repo");
|
||||||
|
fs::create_dir_all(repo.join(GIT_DIR_NAME)).unwrap();
|
||||||
|
fs::write(repo.join(GITIGNORE_FILE_NAME), "target/").unwrap();
|
||||||
|
|
||||||
|
bootstrap_workspace_memory(&repo).unwrap();
|
||||||
|
|
||||||
|
let body = fs::read_to_string(repo.join(GITIGNORE_FILE_NAME)).unwrap();
|
||||||
|
assert!(body.contains("target/"));
|
||||||
|
assert!(body.contains(".coyote/memory/"));
|
||||||
|
assert!(body.ends_with('\n'));
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bootstrap_is_idempotent_on_gitignore_entry() {
|
||||||
|
let root = temp_root("bootstrap_gi_idempotent");
|
||||||
|
let repo = root.join("repo");
|
||||||
|
fs::create_dir_all(repo.join(GIT_DIR_NAME)).unwrap();
|
||||||
|
let original = "target/\n.coyote/memory/\n";
|
||||||
|
fs::write(repo.join(GITIGNORE_FILE_NAME), original).unwrap();
|
||||||
|
|
||||||
|
bootstrap_workspace_memory(&repo).unwrap();
|
||||||
|
|
||||||
|
let body = fs::read_to_string(repo.join(GITIGNORE_FILE_NAME)).unwrap();
|
||||||
|
assert_eq!(body, original, "gitignore must be untouched");
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bootstrap_treats_entry_without_trailing_slash_as_present() {
|
||||||
|
let root = temp_root("bootstrap_gi_no_slash");
|
||||||
|
let repo = root.join("repo");
|
||||||
|
fs::create_dir_all(repo.join(GIT_DIR_NAME)).unwrap();
|
||||||
|
let original = ".coyote/memory\n";
|
||||||
|
fs::write(repo.join(GITIGNORE_FILE_NAME), original).unwrap();
|
||||||
|
|
||||||
|
bootstrap_workspace_memory(&repo).unwrap();
|
||||||
|
|
||||||
|
let body = fs::read_to_string(repo.join(GITIGNORE_FILE_NAME)).unwrap();
|
||||||
|
assert_eq!(body, original);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bootstrap_does_not_clobber_existing_index() {
|
||||||
|
let root = temp_root("bootstrap_existing_index");
|
||||||
|
let repo = root.join("repo");
|
||||||
|
fs::create_dir_all(repo.join(GIT_DIR_NAME)).unwrap();
|
||||||
|
let mem_dir = paths::workspace_memory_dir_for(&repo);
|
||||||
|
fs::create_dir_all(&mem_dir).unwrap();
|
||||||
|
let preserved = "# Custom Index\n\n- [[foo]]: keep me\n";
|
||||||
|
fs::write(mem_dir.join(MEMORY_INDEX_FILE_NAME), preserved).unwrap();
|
||||||
|
|
||||||
|
bootstrap_workspace_memory(&repo).unwrap();
|
||||||
|
|
||||||
|
let body = fs::read_to_string(mem_dir.join(MEMORY_INDEX_FILE_NAME)).unwrap();
|
||||||
|
assert_eq!(body, preserved);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,6 +143,8 @@ 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_MEMORY_FILE_NAME: &str = "COYOTE.md";
|
||||||
const WORKSPACE_MEMORY_DIR_NAME: &str = ".coyote";
|
const WORKSPACE_MEMORY_DIR_NAME: &str = ".coyote";
|
||||||
|
const GIT_DIR_NAME: &str = ".git";
|
||||||
|
const GITIGNORE_FILE_NAME: &str = ".gitignore";
|
||||||
const DEFAULT_VISIBLE_TOOLS: [&str; 18] = [
|
const DEFAULT_VISIBLE_TOOLS: [&str; 18] = [
|
||||||
"execute_command.sh",
|
"execute_command.sh",
|
||||||
"execute_py_code.py",
|
"execute_py_code.py",
|
||||||
|
|||||||
+43
-9
@@ -8,7 +8,10 @@ use serde_json::{Value, json};
|
|||||||
|
|
||||||
use super::{FunctionDeclaration, JsonSchema};
|
use super::{FunctionDeclaration, JsonSchema};
|
||||||
use crate::config::RequestContext;
|
use crate::config::RequestContext;
|
||||||
use crate::config::memory::{MemoryFile, MemoryFrontmatter, MemoryStore, WorkspaceMemory};
|
use crate::config::memory::{
|
||||||
|
MemoryFile, MemoryFrontmatter, MemoryStore, WorkspaceMemory, bootstrap_workspace_memory,
|
||||||
|
find_git_root,
|
||||||
|
};
|
||||||
use crate::config::paths;
|
use crate::config::paths;
|
||||||
|
|
||||||
pub const MEMORY_FUNCTION_PREFIX: &str = "memory__";
|
pub const MEMORY_FUNCTION_PREFIX: &str = "memory__";
|
||||||
@@ -185,7 +188,7 @@ pub fn handle_memory_tool(ctx: &mut RequestContext, cmd_name: &str, args: &Value
|
|||||||
|
|
||||||
let target_dir = match scope.as_str() {
|
let target_dir = match scope.as_str() {
|
||||||
"global" => paths::global_memory_dir(),
|
"global" => paths::global_memory_dir(),
|
||||||
"workspace" => workspace_write_dir(&store)?,
|
"workspace" => workspace_write_dir(&store, &cwd)?,
|
||||||
other => bail!("unknown scope '{}': use 'global' or 'workspace'", other),
|
other => bail!("unknown scope '{}': use 'global' or 'workspace'", other),
|
||||||
};
|
};
|
||||||
let file = MemoryFile {
|
let file = MemoryFile {
|
||||||
@@ -254,13 +257,19 @@ fn find_file(store: &MemoryStore, name: &str) -> Result<Option<MemoryFile>> {
|
|||||||
.find(|f| f.frontmatter.name == name))
|
.find(|f| f.frontmatter.name == name))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn workspace_write_dir(store: &MemoryStore) -> 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(WorkspaceMemory::Structured { dir, .. }) => Ok(dir.clone()),
|
||||||
Some(WorkspaceMemory::Lite { workspace_root, .. }) => {
|
Some(WorkspaceMemory::Lite { workspace_root, .. }) => {
|
||||||
Ok(paths::workspace_memory_dir_for(workspace_root))
|
Ok(paths::workspace_memory_dir_for(workspace_root))
|
||||||
}
|
}
|
||||||
None => bail!("no workspace memory discoverable; cannot write workspace-scoped memory"),
|
None => match find_git_root(cwd) {
|
||||||
|
Some(git_root) => bootstrap_workspace_memory(&git_root),
|
||||||
|
None => bail!(
|
||||||
|
"no workspace memory discoverable and not inside a git repository for auto-bootstrap. \
|
||||||
|
If you want workspace memory, run `coyote --init-memory workspace`."
|
||||||
|
),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,7 +434,7 @@ mod tests {
|
|||||||
workspace: discover_workspace_memory(&workspace),
|
workspace: discover_workspace_memory(&workspace),
|
||||||
};
|
};
|
||||||
|
|
||||||
let dir = workspace_write_dir(&store).unwrap();
|
let dir = workspace_write_dir(&store, &workspace).unwrap();
|
||||||
assert_eq!(dir, structured);
|
assert_eq!(dir, structured);
|
||||||
|
|
||||||
let _ = fs::remove_dir_all(&root);
|
let _ = fs::remove_dir_all(&root);
|
||||||
@@ -443,14 +452,14 @@ mod tests {
|
|||||||
workspace: discover_workspace_memory(&workspace),
|
workspace: discover_workspace_memory(&workspace),
|
||||||
};
|
};
|
||||||
|
|
||||||
let dir = workspace_write_dir(&store).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"));
|
||||||
|
|
||||||
let _ = fs::remove_dir_all(&root);
|
let _ = fs::remove_dir_all(&root);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn workspace_write_dir_errors_when_no_workspace() {
|
fn workspace_write_dir_errors_when_no_workspace_and_no_git() {
|
||||||
let root = temp_root("ws_none");
|
let root = temp_root("ws_none");
|
||||||
let bare = root.join("nowhere");
|
let bare = root.join("nowhere");
|
||||||
fs::create_dir_all(&bare).unwrap();
|
fs::create_dir_all(&bare).unwrap();
|
||||||
@@ -460,8 +469,33 @@ mod tests {
|
|||||||
workspace: discover_workspace_memory(&bare),
|
workspace: discover_workspace_memory(&bare),
|
||||||
};
|
};
|
||||||
|
|
||||||
let err = workspace_write_dir(&store).unwrap_err();
|
let err = workspace_write_dir(&store, &bare).unwrap_err();
|
||||||
assert!(err.to_string().contains("no workspace memory discoverable"));
|
let msg = err.to_string();
|
||||||
|
assert!(msg.contains("no workspace memory discoverable"));
|
||||||
|
assert!(msg.contains("coyote --init-memory workspace"));
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_write_dir_auto_bootstraps_inside_git_repo() {
|
||||||
|
let root = temp_root("ws_bootstrap");
|
||||||
|
let repo = root.join("repo");
|
||||||
|
fs::create_dir_all(repo.join(".git")).unwrap();
|
||||||
|
let nested = repo.join("src").join("deep");
|
||||||
|
fs::create_dir_all(&nested).unwrap();
|
||||||
|
|
||||||
|
let store = MemoryStore {
|
||||||
|
global_dir: root.join("g"),
|
||||||
|
workspace: discover_workspace_memory(&nested),
|
||||||
|
};
|
||||||
|
assert!(store.workspace.is_none());
|
||||||
|
|
||||||
|
let dir = workspace_write_dir(&store, &nested).unwrap();
|
||||||
|
assert_eq!(dir, repo.join(".coyote").join("memory"));
|
||||||
|
assert!(dir.join("MEMORY.md").exists());
|
||||||
|
let gi = fs::read_to_string(repo.join(".gitignore")).unwrap();
|
||||||
|
assert!(gi.contains(".coyote/memory/"));
|
||||||
|
|
||||||
let _ = fs::remove_dir_all(&root);
|
let _ = fs::remove_dir_all(&root);
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -309,7 +309,7 @@ async fn run(
|
|||||||
"# 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 => (
|
||||||
std::env::current_dir()?.join("COYOTE.md"),
|
env::current_dir()?.join("COYOTE.md"),
|
||||||
"# Workspace Memory\n\n<!-- Facts about this project go here. The LLM uses this as always-on context. -->\n",
|
"# Workspace Memory\n\n<!-- Facts about this project go here. The LLM uses this as always-on context. -->\n",
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user