Compare commits
5 Commits
385bd3eda2
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
08f6ea5e6c
|
|||
|
ede0f75a89
|
|||
|
2ec2aec4c0
|
|||
|
c2cb4ac433
|
|||
|
605a9170b0
|
@@ -59,6 +59,9 @@ Coyote requires the following tools to be installed on your system:
|
|||||||
* [docker](https://docs.docker.com/engine/install/)
|
* [docker](https://docs.docker.com/engine/install/)
|
||||||
* [uv](https://docs.astral.sh/uv/getting-started/installation/)
|
* [uv](https://docs.astral.sh/uv/getting-started/installation/)
|
||||||
* `curl -LsSf https://astral.sh/uv/install.sh | sh`
|
* `curl -LsSf https://astral.sh/uv/install.sh | sh`
|
||||||
|
* [iwe](https://github.com/iwe-org/iwe) (`iwec`, for the built-in `iwe` MCP server that navigates large markdown knowledgebases)
|
||||||
|
* **Homebrew:** `brew tap iwe-org/iwe && brew install iwe`
|
||||||
|
* **Cargo:** `cargo install iwec`
|
||||||
|
|
||||||
These tools are used to provide various functionalities within Coyote, such as document processing, JSON manipulation,
|
These tools are used to provide various functionalities within Coyote, such as document processing, JSON manipulation,
|
||||||
etc., and they are used within agents and tools.
|
etc., and they are used within agents and tools.
|
||||||
|
|||||||
@@ -18,6 +18,11 @@
|
|||||||
"type": "stdio",
|
"type": "stdio",
|
||||||
"command": "uvx",
|
"command": "uvx",
|
||||||
"args": ["duckduckgo-mcp-server"]
|
"args": ["duckduckgo-mcp-server"]
|
||||||
|
},
|
||||||
|
"iwe": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "iwec",
|
||||||
|
"args": ["--project", "."]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -276,6 +276,11 @@ commands:
|
|||||||
sudo install -m 0755 "$TMPDIR/usql_static" /usr/local/bin/usql
|
sudo install -m 0755 "$TMPDIR/usql_static" /usr/local/bin/usql
|
||||||
user: '1000'
|
user: '1000'
|
||||||
description: Install the usql universal SQL CLI (used by the built-in sql agent and execute_sql_code tool)
|
description: Install the usql universal SQL CLI (used by the built-in sql agent and execute_sql_code tool)
|
||||||
|
- command: |
|
||||||
|
. "$HOME/.cargo/env"
|
||||||
|
cargo install --locked iwec
|
||||||
|
user: '1000'
|
||||||
|
description: Install the IWE MCP server binary (iwec) used by the built-in iwe MCP server and iwe-knowledge-base skill
|
||||||
- command: |
|
- command: |
|
||||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
|
||||||
sh -s -- -y \
|
sh -s -- -y \
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
---
|
||||||
|
description: Navigate and curate markdown knowledge bases (plan repos, spec repos, companion docs) with IWE graph tools. Load when the workspace is or contains a markdown knowledge base and the task involves finding, reading, or reorganizing plans, specs, designs, or notes. Activates the iwe MCP server rooted at the current directory.
|
||||||
|
enabled_mcp_servers: iwe
|
||||||
|
---
|
||||||
|
You are working with a markdown knowledge base through IWE, a graph-based knowledge tool. The `iwe` MCP server is rooted at the current working directory (`--project .`), so the knowledge base is the directory Coyote was launched in. IWE derives structure from links: a link on its own line is an *inclusion link* (parent-child hierarchy); a link inside text is an *inline reference* (cross-reference, produces backlinks). The server watches the filesystem, so external edits are picked up automatically — never ask for a restart.
|
||||||
|
|
||||||
|
## When to use this (and when not)
|
||||||
|
|
||||||
|
Use IWE tools when the task involves a corpus of markdown documents: plan repositories, spec/design collections, companion docs repos, meeting notes, PKM vaults.
|
||||||
|
|
||||||
|
Do NOT use IWE tools for:
|
||||||
|
|
||||||
|
- **Agent memory** (`.coyote/memory/`, `COYOTE.md`) — use the `memory__*` tools; they own the index conventions there.
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
If unsure whether the current directory is actually a knowledge base, probe with `iwe_stats` first. Few or zero documents means this skill does not apply; unload it rather than forcing the tools.
|
||||||
|
|
||||||
|
## Orientation protocol (always start here)
|
||||||
|
|
||||||
|
Never guess document keys. Orient first:
|
||||||
|
|
||||||
|
1. `iwe_stats` — corpus size and shape. Cheap sanity check.
|
||||||
|
2. `iwe_find(query="<topic>")` — fuzzy search for entry points. Use `roots` behavior via structural selectors when you want top-level topics only.
|
||||||
|
3. `iwe_tree(key="<entry>", max_depth=2)` — see the hierarchy before reading bodies.
|
||||||
|
4. `iwe_retrieve(key="<entry>", depth=1, context=1)` — read with structure.
|
||||||
|
|
||||||
|
## Reading efficiently
|
||||||
|
|
||||||
|
`iwe_retrieve` is the workhorse. Control cost explicitly:
|
||||||
|
|
||||||
|
- `depth` — how many levels of included children to expand. Start at 1-2; increase only if needed.
|
||||||
|
- `context` — parent levels to include, so you know where a document sits. `context=1` is usually enough.
|
||||||
|
- `max_tokens` — ALWAYS set a budget (e.g. 2000-4000) on large corpora; results report truncation so you can drill further deliberately.
|
||||||
|
- `exclude` — pass keys you have already read to avoid re-retrieving known content.
|
||||||
|
- `links` / `backlinks` — include outbound/inbound references when tracing how a topic connects.
|
||||||
|
|
||||||
|
Scope searches structurally with selectors on `iwe_find`/`iwe_retrieve`/`iwe_tree`:
|
||||||
|
|
||||||
|
- `in` — only sub-documents of EVERY listed key (AND)
|
||||||
|
- `in_any` — sub-documents of at least one key (OR)
|
||||||
|
- `not_in` — exclude subtrees (e.g. archives)
|
||||||
|
|
||||||
|
Filter by frontmatter with the YAML query language: `status: draft`, `created: {$gte: "2026-01-01"}`, `tags: {$in: [urgent]}`, `reviewed: {$exists: true}`.
|
||||||
|
|
||||||
|
Use `iwe_squash(key=...)` to flatten a subtree into one linear document — good for producing a full plan readout or summary input.
|
||||||
|
|
||||||
|
## Writing and refactoring
|
||||||
|
|
||||||
|
Write tools: `iwe_create` (new doc from title + content), `iwe_update` (replace a doc's content), `iwe_delete` (remove + clean up references). Refactor tools: `iwe_rename` (key rename with automatic link updates everywhere), `iwe_extract` (split a section into its own doc, leaving an inclusion link), `iwe_inline` (merge a referenced doc back into its parent), `iwe_normalize` (reformat all docs consistently).
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- **Preview destructive operations**: `iwe_rename`, `iwe_delete`, `iwe_extract`, `iwe_inline`, and `iwe_normalize` support `dry_run` — use it first, show the user what will change, then apply.
|
||||||
|
- Never rename or delete by editing files directly; the refactor tools update every referencing document, manual edits break links.
|
||||||
|
- When adding a document, link it from an existing parent (inclusion link on its own line) so it joins the hierarchy instead of becoming an orphan.
|
||||||
|
- Match the corpus conventions: check an existing document's frontmatter fields before inventing your own schema.
|
||||||
|
- Do not run `iwe_normalize` across someone's knowledge base unprompted — it rewrites every file's formatting.
|
||||||
|
|
||||||
|
## Anti-patterns
|
||||||
|
|
||||||
|
- Retrieving with `depth=5` and no `max_tokens` "to get everything" — you will flood the context. Iterate: shallow first, drill selectively.
|
||||||
|
- Calling `iwe_find` repeatedly with rephrased queries when structural navigation (`iwe_tree`, selectors) would locate the document deterministically.
|
||||||
|
- Using IWE write tools on `.coyote/memory/` files — wrong tier; that corrupts the memory index.
|
||||||
|
- Creating documents without linking them into the hierarchy — orphans are invisible to depth-based retrieval.
|
||||||
@@ -133,6 +133,13 @@ impl MessageContent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn as_text(&self) -> Option<&str> {
|
||||||
|
match self {
|
||||||
|
MessageContent::Text(text) => Some(text),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn merge_prompt(&mut self, replace_fn: impl Fn(&str) -> String) {
|
pub fn merge_prompt(&mut self, replace_fn: impl Fn(&str) -> String) {
|
||||||
match self {
|
match self {
|
||||||
MessageContent::Text(text) => *text = replace_fn(text),
|
MessageContent::Text(text) => *text = replace_fn(text),
|
||||||
|
|||||||
@@ -118,6 +118,14 @@ pub struct MemoryFrontmatter {
|
|||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
#[serde(default, rename = "type")]
|
#[serde(default, rename = "type")]
|
||||||
pub kind: Option<String>,
|
pub kind: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub created: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub updated: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub superseded_by: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub expires: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -545,6 +553,7 @@ mod tests {
|
|||||||
name: "test".into(),
|
name: "test".into(),
|
||||||
description: Some("a test".into()),
|
description: Some("a test".into()),
|
||||||
kind: Some("user".into()),
|
kind: Some("user".into()),
|
||||||
|
..Default::default()
|
||||||
},
|
},
|
||||||
body: "Hello world\nmore text".into(),
|
body: "Hello world\nmore text".into(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ const RAGS_DIR_NAME: &str = "rags";
|
|||||||
const FUNCTIONS_DIR_NAME: &str = "functions";
|
const FUNCTIONS_DIR_NAME: &str = "functions";
|
||||||
const FUNCTIONS_BIN_DIR_NAME: &str = "bin";
|
const FUNCTIONS_BIN_DIR_NAME: &str = "bin";
|
||||||
const AGENTS_DIR_NAME: &str = "agents";
|
const AGENTS_DIR_NAME: &str = "agents";
|
||||||
|
const REPL_HISTORY_DIR_NAME: &str = "repl-history";
|
||||||
const GLOBAL_TOOLS_DIR_NAME: &str = "tools";
|
const GLOBAL_TOOLS_DIR_NAME: &str = "tools";
|
||||||
const GLOBAL_TOOLS_UTILS_DIR_NAME: &str = "utils";
|
const GLOBAL_TOOLS_UTILS_DIR_NAME: &str = "utils";
|
||||||
const BASH_PROMPT_UTILS_FILE_NAME: &str = "prompt-utils.sh";
|
const BASH_PROMPT_UTILS_FILE_NAME: &str = "prompt-utils.sh";
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ use super::{
|
|||||||
SKILLS_DIR_NAME, WORKSPACE_MEMORY_DIR_NAME,
|
SKILLS_DIR_NAME, WORKSPACE_MEMORY_DIR_NAME,
|
||||||
};
|
};
|
||||||
use crate::client::ProviderModels;
|
use crate::client::ProviderModels;
|
||||||
|
use crate::config::REPL_HISTORY_DIR_NAME;
|
||||||
|
use crate::config::session::Session;
|
||||||
use crate::utils::{get_env_name, list_file_names, normalize_env_name};
|
use crate::utils::{get_env_name, list_file_names, normalize_env_name};
|
||||||
|
|
||||||
use anyhow::{Context, Result, anyhow, bail};
|
use anyhow::{Context, Result, anyhow, bail};
|
||||||
@@ -320,6 +322,20 @@ pub fn workspace_memory_dir_for(workspace_root: &Path) -> PathBuf {
|
|||||||
.join(MEMORY_DIR_NAME)
|
.join(MEMORY_DIR_NAME)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn repl_history_dir() -> PathBuf {
|
||||||
|
cache_path().join(REPL_HISTORY_DIR_NAME)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn repl_history_file(session: &Option<Session>) -> PathBuf {
|
||||||
|
let history_key = if let Some(session) = &session {
|
||||||
|
format!("session_{}", session.name().replace('/', "_"))
|
||||||
|
} else {
|
||||||
|
"default".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
repl_history_dir().join(history_key)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn log_config() -> Result<(LevelFilter, Option<PathBuf>)> {
|
pub fn log_config() -> Result<(LevelFilter, Option<PathBuf>)> {
|
||||||
let log_level = env::var(get_env_name("log_level"))
|
let log_level = env::var(get_env_name("log_level"))
|
||||||
.ok()
|
.ok()
|
||||||
|
|||||||
+12
-2
@@ -18,10 +18,16 @@ pub(crate) const DEFAULT_MEMORY_INSTRUCTIONS: &str = indoc! {"
|
|||||||
- `memory__read(name)`: Read a specific drill file's full content.
|
- `memory__read(name)`: Read a specific drill file's full content.
|
||||||
- `memory__write(name, content, scope)`: Create or replace a drill file (scope: 'global' | 'workspace').
|
- `memory__write(name, content, scope)`: Create or replace a drill file (scope: 'global' | 'workspace').
|
||||||
The MEMORY.md index is appended automatically; do not also update the index by hand.
|
The MEMORY.md index is appended automatically; do not also update the index by hand.
|
||||||
|
Optional `superseded_by` / `expires` (YYYY-MM-DD) mark a memory as stale for later cleanup.
|
||||||
|
- `memory__rename(name, new_name, scope)`: Rename a drill file. Its index entry and every
|
||||||
|
[[wikilink]] to it are rewritten automatically.
|
||||||
|
- `memory__delete(name, scope)`: Delete a drill file and its index entry. Reports any
|
||||||
|
[[wikilinks]] left dangling in other files.
|
||||||
- `memory__edit_index(scope, content)`: Replace the entire MEMORY.md at the given scope.
|
- `memory__edit_index(scope, content)`: Replace the entire MEMORY.md at the given scope.
|
||||||
Use this to add always-on facts, reorganize, prune stale entries, or fix descriptions.
|
Use this to add always-on facts, reorganize, prune stale entries, or fix descriptions.
|
||||||
- `memory__list()`: See all known drill files and their metadata.
|
- `memory__list()`: See all known drill files and their metadata.
|
||||||
- `memory__lint()`: Health-check memory for orphans, broken links, oversized files.
|
- `memory__lint()`: Health-check memory for orphans, broken links, oversized files,
|
||||||
|
stale (superseded/expired) files, and index descriptions that drifted from the files.
|
||||||
|
|
||||||
RULES:
|
RULES:
|
||||||
- Every interaction has two outputs: your answer AND any memory updates the conversation warrants.
|
- Every interaction has two outputs: your answer AND any memory updates the conversation warrants.
|
||||||
@@ -29,7 +35,11 @@ pub(crate) const DEFAULT_MEMORY_INSTRUCTIONS: &str = indoc! {"
|
|||||||
- All MEMORY.md edits MUST go through `memory__edit_index`. NEVER use `fs_write`, `fs_patch`,
|
- All MEMORY.md edits MUST go through `memory__edit_index`. NEVER use `fs_write`, `fs_patch`,
|
||||||
or any other generic file tool on MEMORY.md — Coyote manages its location and a stray
|
or any other generic file tool on MEMORY.md — Coyote manages its location and a stray
|
||||||
MEMORY.md outside the managed path is invisible to memory.
|
MEMORY.md outside the managed path is invisible to memory.
|
||||||
- All drill files MUST go through `memory__write`. The index updates itself.
|
- All drill files MUST go through `memory__write`. The index updates itself. Renames and
|
||||||
|
deletions MUST go through `memory__rename` / `memory__delete` so links stay intact.
|
||||||
|
- When a fact becomes outdated, update it in place, delete it, or mark the old file with
|
||||||
|
`superseded_by`/`expires` so `memory__lint` flags it later. Never leave contradictory
|
||||||
|
memories side by side.
|
||||||
- Use [[wikilink]] notation in memory files to reference other memories by their `name:` slug.
|
- Use [[wikilink]] notation in memory files to reference other memories by their `name:` slug.
|
||||||
- NEVER write secrets, credentials, or API keys to memory — memory is plaintext on disk.
|
- NEVER write secrets, credentials, or API keys to memory — memory is plaintext on disk.
|
||||||
Use coyote's Vault for secrets.
|
Use coyote's Vault for secrets.
|
||||||
|
|||||||
@@ -163,6 +163,14 @@ impl Session {
|
|||||||
self.messages.is_empty() && self.compressed_messages.is_empty()
|
self.messages.is_empty() && self.compressed_messages.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn messages(&self) -> &[Message] {
|
||||||
|
&self.messages
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn compressed_messages(&self) -> &[Message] {
|
||||||
|
&self.compressed_messages
|
||||||
|
}
|
||||||
|
|
||||||
pub fn name(&self) -> &str {
|
pub fn name(&self) -> &str {
|
||||||
&self.name
|
&self.name
|
||||||
}
|
}
|
||||||
|
|||||||
+721
-46
@@ -3,6 +3,7 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::{env, fs};
|
use std::{env, fs};
|
||||||
|
|
||||||
use anyhow::{Context, Result, anyhow, bail};
|
use anyhow::{Context, Result, anyhow, bail};
|
||||||
|
use chrono::Local;
|
||||||
use indexmap::IndexMap;
|
use indexmap::IndexMap;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
@@ -97,6 +98,32 @@ pub fn memory_function_declarations() -> Vec<FunctionDeclaration> {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"superseded_by".to_string(),
|
||||||
|
JsonSchema {
|
||||||
|
type_value: Some("string".to_string()),
|
||||||
|
description: Some(
|
||||||
|
"Optional `name:` slug of the memory that replaces this one. \
|
||||||
|
`memory__lint` flags superseded files for cleanup. Omitting this \
|
||||||
|
on overwrite clears any previous value."
|
||||||
|
.into(),
|
||||||
|
),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"expires".to_string(),
|
||||||
|
JsonSchema {
|
||||||
|
type_value: Some("string".to_string()),
|
||||||
|
description: Some(
|
||||||
|
"Optional ISO date (YYYY-MM-DD) after which this memory is stale. \
|
||||||
|
`memory__lint` flags expired files. Omitting this on overwrite \
|
||||||
|
clears any previous value."
|
||||||
|
.into(),
|
||||||
|
),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
),
|
||||||
])),
|
])),
|
||||||
required: Some(vec![
|
required: Some(vec![
|
||||||
"name".to_string(),
|
"name".to_string(),
|
||||||
@@ -164,6 +191,90 @@ pub fn memory_function_declarations() -> Vec<FunctionDeclaration> {
|
|||||||
},
|
},
|
||||||
agent: false,
|
agent: false,
|
||||||
},
|
},
|
||||||
|
FunctionDeclaration {
|
||||||
|
name: format!("{MEMORY_FUNCTION_PREFIX}rename"),
|
||||||
|
description:
|
||||||
|
"Rename a memory file. Its MEMORY.md index entry and every [[wikilink]] to it in \
|
||||||
|
other memory files are rewritten automatically."
|
||||||
|
.to_string(),
|
||||||
|
parameters: JsonSchema {
|
||||||
|
type_value: Some("object".to_string()),
|
||||||
|
properties: Some(IndexMap::from([
|
||||||
|
(
|
||||||
|
"name".to_string(),
|
||||||
|
JsonSchema {
|
||||||
|
type_value: Some("string".to_string()),
|
||||||
|
description: Some("Current `name:` slug of the memory file".into()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"new_name".to_string(),
|
||||||
|
JsonSchema {
|
||||||
|
type_value: Some("string".to_string()),
|
||||||
|
description: Some(
|
||||||
|
"New kebab-case slug for the file (no extension)".into(),
|
||||||
|
),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"scope".to_string(),
|
||||||
|
JsonSchema {
|
||||||
|
type_value: Some("string".to_string()),
|
||||||
|
description: Some(
|
||||||
|
"Scope of the file: 'global' (user-level) or 'workspace' (project-level)"
|
||||||
|
.into(),
|
||||||
|
),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
])),
|
||||||
|
required: Some(vec![
|
||||||
|
"name".to_string(),
|
||||||
|
"new_name".to_string(),
|
||||||
|
"scope".to_string(),
|
||||||
|
]),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
agent: false,
|
||||||
|
},
|
||||||
|
FunctionDeclaration {
|
||||||
|
name: format!("{MEMORY_FUNCTION_PREFIX}delete"),
|
||||||
|
description:
|
||||||
|
"Delete a memory file and remove its MEMORY.md index entry. Reports any \
|
||||||
|
[[wikilinks]] in other memory files left dangling by the deletion."
|
||||||
|
.to_string(),
|
||||||
|
parameters: JsonSchema {
|
||||||
|
type_value: Some("object".to_string()),
|
||||||
|
properties: Some(IndexMap::from([
|
||||||
|
(
|
||||||
|
"name".to_string(),
|
||||||
|
JsonSchema {
|
||||||
|
type_value: Some("string".to_string()),
|
||||||
|
description: Some(
|
||||||
|
"The `name:` slug of the memory file to delete".into(),
|
||||||
|
),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"scope".to_string(),
|
||||||
|
JsonSchema {
|
||||||
|
type_value: Some("string".to_string()),
|
||||||
|
description: Some(
|
||||||
|
"Scope of the file: 'global' (user-level) or 'workspace' (project-level)"
|
||||||
|
.into(),
|
||||||
|
),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
])),
|
||||||
|
required: Some(vec!["name".to_string(), "scope".to_string()]),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
agent: false,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,47 +325,13 @@ pub fn handle_memory_tool(ctx: &mut RequestContext, cmd_name: &str, args: &Value
|
|||||||
"workspace": store.workspace.as_ref().map(workspace_label),
|
"workspace": store.workspace.as_ref().map(workspace_label),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
"write" => {
|
"write" => write_memory(&store, &cwd, args),
|
||||||
let name = arg_str(args, "name")?;
|
"rename" => rename_memory(&store, &cwd, args),
|
||||||
let description = arg_str(args, "description")?;
|
"delete" => delete_memory(&store, &cwd, args),
|
||||||
let content = arg_str(args, "content")?;
|
|
||||||
let scope = arg_str(args, "scope")?;
|
|
||||||
let kind = args.get("type").and_then(Value::as_str).map(String::from);
|
|
||||||
|
|
||||||
let target_dir = match scope.as_str() {
|
|
||||||
"global" => paths::global_memory_dir(),
|
|
||||||
"workspace" => workspace_write_dir(&store, &cwd)?,
|
|
||||||
other => bail!("unknown scope '{}': use 'global' or 'workspace'", other),
|
|
||||||
};
|
|
||||||
let file = MemoryFile {
|
|
||||||
path: target_dir.join(format!("{name}.md")),
|
|
||||||
frontmatter: MemoryFrontmatter {
|
|
||||||
name: name.clone(),
|
|
||||||
description: Some(description.clone()),
|
|
||||||
kind,
|
|
||||||
},
|
|
||||||
body: content,
|
|
||||||
};
|
|
||||||
file.save()?;
|
|
||||||
|
|
||||||
let index_path = target_dir.join("MEMORY.md");
|
|
||||||
let index_updated = ensure_index_entry(&index_path, &name, &description)?;
|
|
||||||
|
|
||||||
Ok(json!({
|
|
||||||
"status": "ok",
|
|
||||||
"path": file.path.display().to_string(),
|
|
||||||
"index_path": index_path.display().to_string(),
|
|
||||||
"index_updated": index_updated,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
"edit_index" => {
|
"edit_index" => {
|
||||||
let scope = arg_str(args, "scope")?;
|
let scope = arg_str(args, "scope")?;
|
||||||
let content = arg_str(args, "content")?;
|
let content = arg_str(args, "content")?;
|
||||||
let target_dir = match scope.as_str() {
|
let target_dir = scope_dir(&store, &cwd, &scope)?;
|
||||||
"global" => paths::global_memory_dir(),
|
|
||||||
"workspace" => workspace_write_dir(&store, &cwd)?,
|
|
||||||
other => bail!("unknown scope '{}': use 'global' or 'workspace'", other),
|
|
||||||
};
|
|
||||||
let index_path = write_memory_index(&target_dir, &content)?;
|
let index_path = write_memory_index(&target_dir, &content)?;
|
||||||
|
|
||||||
Ok(json!({
|
Ok(json!({
|
||||||
@@ -267,19 +344,229 @@ pub fn handle_memory_tool(ctx: &mut RequestContext, cmd_name: &str, args: &Value
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn write_memory(store: &MemoryStore, cwd: &Path, args: &Value) -> Result<Value> {
|
||||||
|
let name = arg_str(args, "name")?;
|
||||||
|
let description = arg_str(args, "description")?;
|
||||||
|
let content = arg_str(args, "content")?;
|
||||||
|
let scope = arg_str(args, "scope")?;
|
||||||
|
let kind = args.get("type").and_then(Value::as_str).map(String::from);
|
||||||
|
let superseded_by = args
|
||||||
|
.get("superseded_by")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(String::from);
|
||||||
|
let expires = args
|
||||||
|
.get("expires")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(String::from);
|
||||||
|
|
||||||
|
let target_dir = scope_dir(store, cwd, &scope)?;
|
||||||
|
let path = target_dir.join(format!("{name}.md"));
|
||||||
|
let previous = if path.exists() {
|
||||||
|
MemoryFile::load(&path).ok()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let today = today_string();
|
||||||
|
let created = previous
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| p.frontmatter.created.clone())
|
||||||
|
.unwrap_or_else(|| today.clone());
|
||||||
|
|
||||||
|
let file = MemoryFile {
|
||||||
|
path,
|
||||||
|
frontmatter: MemoryFrontmatter {
|
||||||
|
name: name.clone(),
|
||||||
|
description: Some(description.clone()),
|
||||||
|
kind,
|
||||||
|
created: Some(created),
|
||||||
|
updated: Some(today),
|
||||||
|
superseded_by,
|
||||||
|
expires,
|
||||||
|
},
|
||||||
|
body: content,
|
||||||
|
};
|
||||||
|
file.save()?;
|
||||||
|
|
||||||
|
let index_path = target_dir.join("MEMORY.md");
|
||||||
|
let index_updated = ensure_index_entry(&index_path, &name, &description)?;
|
||||||
|
|
||||||
|
Ok(json!({
|
||||||
|
"status": "ok",
|
||||||
|
"path": file.path.display().to_string(),
|
||||||
|
"index_path": index_path.display().to_string(),
|
||||||
|
"index_updated": index_updated,
|
||||||
|
"replaced": previous.is_some(),
|
||||||
|
"previous_description": previous.and_then(|p| p.frontmatter.description),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rename_memory(store: &MemoryStore, cwd: &Path, args: &Value) -> Result<Value> {
|
||||||
|
let name = arg_str(args, "name")?;
|
||||||
|
let new_name = arg_str(args, "new_name")?;
|
||||||
|
let scope = arg_str(args, "scope")?;
|
||||||
|
if new_name.is_empty()
|
||||||
|
|| !new_name
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
|
||||||
|
{
|
||||||
|
bail!(
|
||||||
|
"invalid new_name '{}': use a kebab-case slug (alphanumeric, hyphens, underscores)",
|
||||||
|
new_name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if name == new_name {
|
||||||
|
bail!("new_name matches the current name");
|
||||||
|
}
|
||||||
|
|
||||||
|
let target_dir = scope_dir(store, cwd, &scope)?;
|
||||||
|
let files = store.list_files()?;
|
||||||
|
let file = files
|
||||||
|
.iter()
|
||||||
|
.find(|f| f.path.starts_with(&target_dir) && f.frontmatter.name == name)
|
||||||
|
.ok_or_else(|| anyhow!("memory file '{}' not found in scope '{}'", name, scope))?
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
if target_dir.join(format!("{new_name}.md")).exists()
|
||||||
|
|| files
|
||||||
|
.iter()
|
||||||
|
.any(|f| f.path.starts_with(&target_dir) && f.frontmatter.name == new_name)
|
||||||
|
{
|
||||||
|
bail!(
|
||||||
|
"memory file '{}' already exists in scope '{}'",
|
||||||
|
new_name,
|
||||||
|
scope
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let needle = format!("[[{name}]]");
|
||||||
|
let replacement = format!("[[{new_name}]]");
|
||||||
|
|
||||||
|
let mut renamed = file.clone();
|
||||||
|
renamed.path = target_dir.join(format!("{new_name}.md"));
|
||||||
|
renamed.frontmatter.name = new_name.clone();
|
||||||
|
renamed.frontmatter.updated = Some(today_string());
|
||||||
|
renamed.body = renamed.body.replace(&needle, &replacement);
|
||||||
|
renamed.save()?;
|
||||||
|
fs::remove_file(&file.path).with_context(|| format!("remove {}", file.path.display()))?;
|
||||||
|
|
||||||
|
let mut rewritten = Vec::new();
|
||||||
|
for f in &files {
|
||||||
|
if f.path == file.path || !f.body.contains(&needle) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut updated = f.clone();
|
||||||
|
updated.body = updated.body.replace(&needle, &replacement);
|
||||||
|
updated.save()?;
|
||||||
|
rewritten.push(f.frontmatter.name.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Own-scope index: rewrite the wikilink, drop any leftover references to the
|
||||||
|
// old name, and guarantee the new name is present.
|
||||||
|
let index_path = target_dir.join("MEMORY.md");
|
||||||
|
if let Ok(existing) = fs::read_to_string(&index_path)
|
||||||
|
&& existing.contains(&needle)
|
||||||
|
{
|
||||||
|
fs::write(&index_path, existing.replace(&needle, &replacement))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
remove_index_entry(&index_path, &name)?;
|
||||||
|
let description = renamed.frontmatter.description.clone().unwrap_or_default();
|
||||||
|
ensure_index_entry(&index_path, &new_name, &description)?;
|
||||||
|
|
||||||
|
// Other indexes (other scope's MEMORY.md, lite COYOTE.md): rewrite wikilinks only.
|
||||||
|
for other_index in other_index_paths(store, &target_dir) {
|
||||||
|
if let Ok(existing) = fs::read_to_string(&other_index)
|
||||||
|
&& existing.contains(&needle)
|
||||||
|
{
|
||||||
|
fs::write(&other_index, existing.replace(&needle, &replacement))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(json!({
|
||||||
|
"status": "ok",
|
||||||
|
"old_path": file.path.display().to_string(),
|
||||||
|
"new_path": renamed.path.display().to_string(),
|
||||||
|
"rewritten_references": rewritten,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_memory(store: &MemoryStore, cwd: &Path, args: &Value) -> Result<Value> {
|
||||||
|
let name = arg_str(args, "name")?;
|
||||||
|
let scope = arg_str(args, "scope")?;
|
||||||
|
let target_dir = scope_dir(store, cwd, &scope)?;
|
||||||
|
let files = store.list_files()?;
|
||||||
|
let file = files
|
||||||
|
.iter()
|
||||||
|
.find(|f| f.path.starts_with(&target_dir) && f.frontmatter.name == name)
|
||||||
|
.ok_or_else(|| anyhow!("memory file '{}' not found in scope '{}'", name, scope))?;
|
||||||
|
let deleted_path = file.path.clone();
|
||||||
|
fs::remove_file(&deleted_path).with_context(|| format!("delete {}", deleted_path.display()))?;
|
||||||
|
|
||||||
|
let index_path = target_dir.join("MEMORY.md");
|
||||||
|
let index_updated = remove_index_entry(&index_path, &name)?;
|
||||||
|
|
||||||
|
let dangling: Vec<String> = files
|
||||||
|
.iter()
|
||||||
|
.filter(|f| f.path != deleted_path && extract_wikilinks(&f.body).iter().any(|l| l == &name))
|
||||||
|
.map(|f| f.frontmatter.name.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(json!({
|
||||||
|
"status": "ok",
|
||||||
|
"deleted_path": deleted_path.display().to_string(),
|
||||||
|
"index_updated": index_updated,
|
||||||
|
"dangling_references": dangling,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scope_dir(store: &MemoryStore, cwd: &Path, scope: &str) -> Result<PathBuf> {
|
||||||
|
match scope {
|
||||||
|
"global" => Ok(paths::global_memory_dir()),
|
||||||
|
"workspace" => workspace_write_dir(store, cwd),
|
||||||
|
other => bail!("unknown scope '{}': use 'global' or 'workspace'", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn today_string() -> String {
|
||||||
|
Local::now().format("%Y-%m-%d").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn other_index_paths(store: &MemoryStore, own_dir: &Path) -> Vec<PathBuf> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let global_index = store.global_dir.join("MEMORY.md");
|
||||||
|
if store.global_dir.as_path() != own_dir && global_index.exists() {
|
||||||
|
out.push(global_index);
|
||||||
|
}
|
||||||
|
|
||||||
|
match &store.workspace {
|
||||||
|
Some(WorkspaceMemory::Structured { dir, .. }) => {
|
||||||
|
let index = dir.join("MEMORY.md");
|
||||||
|
if dir.as_path() != own_dir && index.exists() {
|
||||||
|
out.push(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(WorkspaceMemory::Lite { file, .. }) if file.exists() => {
|
||||||
|
out.push(file.clone());
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
fn write_memory_index(target_dir: &Path, content: &str) -> Result<PathBuf> {
|
fn write_memory_index(target_dir: &Path, content: &str) -> Result<PathBuf> {
|
||||||
fs::create_dir_all(target_dir)?;
|
fs::create_dir_all(target_dir)?;
|
||||||
let index_path = target_dir.join("MEMORY.md");
|
let index_path = target_dir.join("MEMORY.md");
|
||||||
|
|
||||||
fs::write(&index_path, content)?;
|
fs::write(&index_path, content)?;
|
||||||
|
|
||||||
Ok(index_path)
|
Ok(index_path)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_index_entry(index_path: &Path, name: &str, description: &str) -> Result<bool> {
|
fn ensure_index_entry(index_path: &Path, name: &str, description: &str) -> Result<bool> {
|
||||||
let existing = fs::read_to_string(index_path).unwrap_or_default();
|
let existing = fs::read_to_string(index_path).unwrap_or_default();
|
||||||
let already_referenced =
|
if index_references(&existing, name) {
|
||||||
existing.contains(&format!("[[{name}]]")) || existing.contains(&format!("{name}.md"));
|
|
||||||
|
|
||||||
if already_referenced {
|
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,6 +584,40 @@ fn ensure_index_entry(index_path: &Path, name: &str, description: &str) -> Resul
|
|||||||
}
|
}
|
||||||
|
|
||||||
fs::write(index_path, new_content)?;
|
fs::write(index_path, new_content)?;
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn line_references(line: &str, name: &str) -> bool {
|
||||||
|
let file_name = format!("{name}.md");
|
||||||
|
line.split(|c: char| !(c.is_alphanumeric() || c == '-' || c == '_' || c == '.'))
|
||||||
|
.any(|token| token == file_name || token.trim_matches('.') == name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn index_references(index: &str, name: &str) -> bool {
|
||||||
|
index.lines().any(|line| line_references(line, name))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_index_entry(index_path: &Path, name: &str) -> Result<bool> {
|
||||||
|
let Ok(existing) = fs::read_to_string(index_path) else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
let kept: Vec<&str> = existing
|
||||||
|
.lines()
|
||||||
|
.filter(|line| !line_references(line, name))
|
||||||
|
.collect();
|
||||||
|
let mut new_content = kept.join("\n");
|
||||||
|
|
||||||
|
if existing.ends_with('\n') && !new_content.is_empty() {
|
||||||
|
new_content.push('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
if new_content == existing {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::write(index_path, new_content)?;
|
||||||
|
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,9 +671,11 @@ fn workspace_label(w: &WorkspaceMemory) -> Value {
|
|||||||
fn lint_memory(store: &MemoryStore) -> Result<Value> {
|
fn lint_memory(store: &MemoryStore) -> Result<Value> {
|
||||||
let files = store.list_files()?;
|
let files = store.list_files()?;
|
||||||
let names: HashSet<&str> = files.iter().map(|f| f.frontmatter.name.as_str()).collect();
|
let names: HashSet<&str> = files.iter().map(|f| f.frontmatter.name.as_str()).collect();
|
||||||
|
let today = today_string();
|
||||||
|
|
||||||
let mut oversized = Vec::new();
|
let mut oversized = Vec::new();
|
||||||
let mut broken_links = Vec::new();
|
let mut broken_links = Vec::new();
|
||||||
|
let mut stale = Vec::new();
|
||||||
for f in &files {
|
for f in &files {
|
||||||
if f.char_len() > PER_FILE_SOFT_CAP {
|
if f.char_len() > PER_FILE_SOFT_CAP {
|
||||||
oversized.push(json!({"name": &f.frontmatter.name, "chars": f.char_len()}));
|
oversized.push(json!({"name": &f.frontmatter.name, "chars": f.char_len()}));
|
||||||
@@ -362,16 +685,54 @@ fn lint_memory(store: &MemoryStore) -> Result<Value> {
|
|||||||
broken_links.push(json!({"from": &f.frontmatter.name, "to": link}));
|
broken_links.push(json!({"from": &f.frontmatter.name, "to": link}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(target) = &f.frontmatter.superseded_by {
|
||||||
|
stale.push(json!({
|
||||||
|
"name": &f.frontmatter.name,
|
||||||
|
"reason": "superseded",
|
||||||
|
"superseded_by": target,
|
||||||
|
"target_exists": names.contains(target.as_str()),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(expires) = &f.frontmatter.expires
|
||||||
|
&& expires.as_str() < today.as_str()
|
||||||
|
{
|
||||||
|
stale.push(json!({
|
||||||
|
"name": &f.frontmatter.name,
|
||||||
|
"reason": "expired",
|
||||||
|
"expires": expires,
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let index_content = store
|
let global_index = store.load_global_index()?.unwrap_or_default();
|
||||||
.load_global_index()?
|
let workspace_index = store
|
||||||
.or_else(|| store.load_workspace_index().ok().flatten())
|
.load_workspace_index()
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let mut orphans = Vec::new();
|
let mut orphans = Vec::new();
|
||||||
|
let mut description_drift = Vec::new();
|
||||||
|
|
||||||
for f in &files {
|
for f in &files {
|
||||||
if !index_content.contains(&f.frontmatter.name) {
|
let index = if f.path.starts_with(&store.global_dir) {
|
||||||
|
&global_index
|
||||||
|
} else {
|
||||||
|
&workspace_index
|
||||||
|
};
|
||||||
|
|
||||||
|
if !index_references(index, &f.frontmatter.name) {
|
||||||
orphans.push(f.frontmatter.name.clone());
|
orphans.push(f.frontmatter.name.clone());
|
||||||
|
} else if let (Some(index_desc), Some(file_desc)) = (
|
||||||
|
index_description(index, &f.frontmatter.name),
|
||||||
|
f.frontmatter.description.as_deref(),
|
||||||
|
) && index_desc != file_desc
|
||||||
|
{
|
||||||
|
description_drift.push(json!({
|
||||||
|
"name": &f.frontmatter.name,
|
||||||
|
"index_description": index_desc,
|
||||||
|
"file_description": file_desc,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,13 +741,26 @@ fn lint_memory(store: &MemoryStore) -> Result<Value> {
|
|||||||
"oversized": oversized,
|
"oversized": oversized,
|
||||||
"broken_wikilinks": broken_links,
|
"broken_wikilinks": broken_links,
|
||||||
"orphans": orphans,
|
"orphans": orphans,
|
||||||
|
"stale": stale,
|
||||||
|
"description_drift": description_drift,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn index_description(index: &str, name: &str) -> Option<String> {
|
||||||
|
let marker = format!("[[{name}]]");
|
||||||
|
index.lines().find_map(|line| {
|
||||||
|
let pos = line.find(&marker)?;
|
||||||
|
let rest = line[pos + marker.len()..].trim_start();
|
||||||
|
let desc = rest.strip_prefix(':')?.trim();
|
||||||
|
(!desc.is_empty()).then(|| desc.to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn extract_wikilinks(body: &str) -> Vec<String> {
|
fn extract_wikilinks(body: &str) -> Vec<String> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
let bytes = body.as_bytes();
|
let bytes = body.as_bytes();
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
|
|
||||||
while i + 1 < bytes.len() {
|
while i + 1 < bytes.len() {
|
||||||
if bytes[i] == b'['
|
if bytes[i] == b'['
|
||||||
&& bytes[i + 1] == b'['
|
&& bytes[i + 1] == b'['
|
||||||
@@ -676,4 +1050,305 @@ mod tests {
|
|||||||
|
|
||||||
let _ = fs::remove_dir_all(&root);
|
let _ = fs::remove_dir_all(&root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn line_references_requires_exact_token_match() {
|
||||||
|
assert!(line_references("- [[auth]]: description", "auth"));
|
||||||
|
assert!(line_references("- auth.md is here", "auth"));
|
||||||
|
assert!(line_references("- referenced", "referenced"));
|
||||||
|
assert!(line_references("see auth.", "auth"));
|
||||||
|
assert!(!line_references("- [[auth-flow]]: description", "auth"));
|
||||||
|
assert!(!line_references("- oauth.md legacy", "auth"));
|
||||||
|
assert!(!line_references("- preauth notes", "auth"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_index_entry_drops_only_matching_lines() {
|
||||||
|
let root = temp_root("index_remove");
|
||||||
|
let index = root.join("MEMORY.md");
|
||||||
|
fs::write(
|
||||||
|
&index,
|
||||||
|
"# Memory Index\n\n- [[keep]]: stays\n- [[gone]]: removed\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(remove_index_entry(&index, "gone").unwrap());
|
||||||
|
let content = fs::read_to_string(&index).unwrap();
|
||||||
|
assert!(content.contains("[[keep]]"));
|
||||||
|
assert!(!content.contains("[[gone]]"));
|
||||||
|
|
||||||
|
assert!(!remove_index_entry(&index, "gone").unwrap());
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lint_checks_orphans_against_own_scope_index() {
|
||||||
|
let root = temp_root("lint_scopes");
|
||||||
|
let global = root.join("global");
|
||||||
|
fs::create_dir_all(&global).unwrap();
|
||||||
|
fs::write(global.join("MEMORY.md"), "- [[global-note]]: g\n").unwrap();
|
||||||
|
fs::write(
|
||||||
|
global.join("global-note.md"),
|
||||||
|
"---\nname: global-note\n---\ng\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let workspace = root.join("ws");
|
||||||
|
let structured = workspace.join(".coyote").join("memory");
|
||||||
|
fs::create_dir_all(&structured).unwrap();
|
||||||
|
fs::write(structured.join("MEMORY.md"), "- [[ws-note]]: w\n").unwrap();
|
||||||
|
fs::write(
|
||||||
|
structured.join("ws-note.md"),
|
||||||
|
"---\nname: ws-note\n---\nw\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let store = MemoryStore {
|
||||||
|
global_dir: global,
|
||||||
|
workspace: discover_workspace_memory(&workspace),
|
||||||
|
};
|
||||||
|
|
||||||
|
let report = lint_memory(&store).unwrap();
|
||||||
|
assert!(
|
||||||
|
report["orphans"].as_array().unwrap().is_empty(),
|
||||||
|
"expected no orphans, got: {report}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lint_flags_stale_and_description_drift() {
|
||||||
|
let root = temp_root("lint_stale");
|
||||||
|
let workspace = root.join("ws");
|
||||||
|
let structured = workspace.join(".coyote").join("memory");
|
||||||
|
fs::create_dir_all(&structured).unwrap();
|
||||||
|
fs::write(
|
||||||
|
structured.join("MEMORY.md"),
|
||||||
|
"- [[old-plan]]: old\n- [[bygone]]: e\n- [[drifted]]: index says this\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(
|
||||||
|
structured.join("old-plan.md"),
|
||||||
|
"---\nname: old-plan\nsuperseded_by: new-plan\n---\nx\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(
|
||||||
|
structured.join("bygone.md"),
|
||||||
|
"---\nname: bygone\nexpires: 2000-01-01\n---\nx\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(
|
||||||
|
structured.join("drifted.md"),
|
||||||
|
"---\nname: drifted\ndescription: file says that\n---\nx\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let store = MemoryStore {
|
||||||
|
global_dir: root.join("nonexistent_global"),
|
||||||
|
workspace: discover_workspace_memory(&workspace),
|
||||||
|
};
|
||||||
|
|
||||||
|
let report = lint_memory(&store).unwrap();
|
||||||
|
let stale = report["stale"].as_array().unwrap();
|
||||||
|
let reasons: Vec<(&str, &str)> = stale
|
||||||
|
.iter()
|
||||||
|
.map(|v| (v["name"].as_str().unwrap(), v["reason"].as_str().unwrap()))
|
||||||
|
.collect();
|
||||||
|
assert!(reasons.contains(&("old-plan", "superseded")));
|
||||||
|
assert!(reasons.contains(&("bygone", "expired")));
|
||||||
|
let superseded = stale.iter().find(|v| v["name"] == "old-plan").unwrap();
|
||||||
|
assert_eq!(superseded["target_exists"], false);
|
||||||
|
|
||||||
|
let drift = report["description_drift"].as_array().unwrap();
|
||||||
|
assert_eq!(drift.len(), 1);
|
||||||
|
assert_eq!(drift[0]["name"], "drifted");
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_memory_removes_file_index_entry_and_reports_dangling() {
|
||||||
|
let root = temp_root("delete");
|
||||||
|
let workspace = root.join("ws");
|
||||||
|
let structured = workspace.join(".coyote").join("memory");
|
||||||
|
fs::create_dir_all(&structured).unwrap();
|
||||||
|
fs::write(
|
||||||
|
structured.join("MEMORY.md"),
|
||||||
|
"# Memory Index\n\n- [[doomed]]: bye\n- [[linker]]: links\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(
|
||||||
|
structured.join("doomed.md"),
|
||||||
|
"---\nname: doomed\n---\nbye\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(
|
||||||
|
structured.join("linker.md"),
|
||||||
|
"---\nname: linker\n---\nsee [[doomed]]\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let store = MemoryStore {
|
||||||
|
global_dir: root.join("g"),
|
||||||
|
workspace: discover_workspace_memory(&workspace),
|
||||||
|
};
|
||||||
|
|
||||||
|
let args = json!({"name": "doomed", "scope": "workspace"});
|
||||||
|
let result = delete_memory(&store, &workspace, &args).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result["status"], "ok");
|
||||||
|
assert_eq!(result["index_updated"], true);
|
||||||
|
assert!(!structured.join("doomed.md").exists());
|
||||||
|
let index = fs::read_to_string(structured.join("MEMORY.md")).unwrap();
|
||||||
|
assert!(!index.contains("doomed"));
|
||||||
|
assert!(index.contains("[[linker]]"));
|
||||||
|
assert_eq!(
|
||||||
|
result["dangling_references"].as_array().unwrap(),
|
||||||
|
&vec![json!("linker")]
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rename_memory_moves_file_and_rewrites_references() {
|
||||||
|
let root = temp_root("rename");
|
||||||
|
let workspace = root.join("ws");
|
||||||
|
let structured = workspace.join(".coyote").join("memory");
|
||||||
|
fs::create_dir_all(&structured).unwrap();
|
||||||
|
fs::write(
|
||||||
|
structured.join("MEMORY.md"),
|
||||||
|
"# Memory Index\n\n- [[old-name]]: the plan\n- [[linker]]: links\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(
|
||||||
|
structured.join("old-name.md"),
|
||||||
|
"---\nname: old-name\ndescription: the plan\n---\nself link [[old-name]]\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(
|
||||||
|
structured.join("linker.md"),
|
||||||
|
"---\nname: linker\n---\nsee [[old-name]] and [[old-name-extended]]\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let store = MemoryStore {
|
||||||
|
global_dir: root.join("g"),
|
||||||
|
workspace: discover_workspace_memory(&workspace),
|
||||||
|
};
|
||||||
|
|
||||||
|
let args = json!({"name": "old-name", "new_name": "new-name", "scope": "workspace"});
|
||||||
|
let result = rename_memory(&store, &workspace, &args).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result["status"], "ok");
|
||||||
|
assert!(!structured.join("old-name.md").exists());
|
||||||
|
let renamed = MemoryFile::load(&structured.join("new-name.md")).unwrap();
|
||||||
|
assert_eq!(renamed.frontmatter.name, "new-name");
|
||||||
|
assert!(renamed.body.contains("[[new-name]]"));
|
||||||
|
|
||||||
|
let linker = fs::read_to_string(structured.join("linker.md")).unwrap();
|
||||||
|
assert!(linker.contains("[[new-name]]"));
|
||||||
|
assert!(
|
||||||
|
linker.contains("[[old-name-extended]]"),
|
||||||
|
"unrelated links must be untouched: {linker}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let index = fs::read_to_string(structured.join("MEMORY.md")).unwrap();
|
||||||
|
assert!(index.contains("- [[new-name]]: the plan"));
|
||||||
|
assert!(!index.contains("[[old-name]]"));
|
||||||
|
assert!(index.contains("[[linker]]"));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result["rewritten_references"].as_array().unwrap(),
|
||||||
|
&vec![json!("linker")]
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rename_memory_rejects_collisions_and_bad_slugs() {
|
||||||
|
let root = temp_root("rename_guard");
|
||||||
|
let workspace = root.join("ws");
|
||||||
|
let structured = workspace.join(".coyote").join("memory");
|
||||||
|
fs::create_dir_all(&structured).unwrap();
|
||||||
|
fs::write(structured.join("MEMORY.md"), "- [[a]]: a\n- [[b]]: b\n").unwrap();
|
||||||
|
fs::write(structured.join("a.md"), "---\nname: a\n---\nx\n").unwrap();
|
||||||
|
fs::write(structured.join("b.md"), "---\nname: b\n---\nx\n").unwrap();
|
||||||
|
|
||||||
|
let store = MemoryStore {
|
||||||
|
global_dir: root.join("g"),
|
||||||
|
workspace: discover_workspace_memory(&workspace),
|
||||||
|
};
|
||||||
|
|
||||||
|
let collision = json!({"name": "a", "new_name": "b", "scope": "workspace"});
|
||||||
|
let err = rename_memory(&store, &workspace, &collision).unwrap_err();
|
||||||
|
assert!(err.to_string().contains("already exists"));
|
||||||
|
|
||||||
|
let bad_slug = json!({"name": "a", "new_name": "bad name!", "scope": "workspace"});
|
||||||
|
let err = rename_memory(&store, &workspace, &bad_slug).unwrap_err();
|
||||||
|
assert!(err.to_string().contains("invalid new_name"));
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_memory_stamps_timestamps_and_reports_replacement() {
|
||||||
|
let root = temp_root("write_stamps");
|
||||||
|
let workspace = root.join("ws");
|
||||||
|
let structured = workspace.join(".coyote").join("memory");
|
||||||
|
fs::create_dir_all(&structured).unwrap();
|
||||||
|
fs::write(structured.join("MEMORY.md"), "# Memory Index\n").unwrap();
|
||||||
|
|
||||||
|
let store = MemoryStore {
|
||||||
|
global_dir: root.join("g"),
|
||||||
|
workspace: discover_workspace_memory(&workspace),
|
||||||
|
};
|
||||||
|
|
||||||
|
let first = json!({
|
||||||
|
"name": "fact",
|
||||||
|
"description": "first version",
|
||||||
|
"content": "body v1",
|
||||||
|
"scope": "workspace",
|
||||||
|
"expires": "2099-01-01",
|
||||||
|
});
|
||||||
|
let before = today_string();
|
||||||
|
let result = write_memory(&store, &workspace, &first).unwrap();
|
||||||
|
let after = today_string();
|
||||||
|
assert_eq!(result["replaced"], false);
|
||||||
|
assert_eq!(result["previous_description"], Value::Null);
|
||||||
|
|
||||||
|
let saved = MemoryFile::load(&structured.join("fact.md")).unwrap();
|
||||||
|
let created = saved.frontmatter.created.clone().expect("created stamped");
|
||||||
|
assert!(
|
||||||
|
created == before || created == after,
|
||||||
|
"created '{created}' should be stamped with today's date"
|
||||||
|
);
|
||||||
|
assert_eq!(saved.frontmatter.updated, Some(created.clone()));
|
||||||
|
assert_eq!(saved.frontmatter.expires.as_deref(), Some("2099-01-01"));
|
||||||
|
assert_eq!(saved.frontmatter.superseded_by, None);
|
||||||
|
|
||||||
|
let second = json!({
|
||||||
|
"name": "fact",
|
||||||
|
"description": "second version",
|
||||||
|
"content": "body v2",
|
||||||
|
"scope": "workspace",
|
||||||
|
});
|
||||||
|
let result = write_memory(&store, &workspace, &second).unwrap();
|
||||||
|
assert_eq!(result["replaced"], true);
|
||||||
|
assert_eq!(result["previous_description"], "first version");
|
||||||
|
|
||||||
|
let saved = MemoryFile::load(&structured.join("fact.md")).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
saved.frontmatter.created,
|
||||||
|
Some(created),
|
||||||
|
"creation date must be preserved across overwrites"
|
||||||
|
);
|
||||||
|
assert!(saved.frontmatter.updated.is_some());
|
||||||
|
assert_eq!(saved.frontmatter.expires, None);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+107
-4
@@ -6,7 +6,10 @@ use self::completer::ReplCompleter;
|
|||||||
use self::highlighter::ReplHighlighter;
|
use self::highlighter::ReplHighlighter;
|
||||||
use self::prompt::ReplPrompt;
|
use self::prompt::ReplPrompt;
|
||||||
|
|
||||||
use crate::client::{call_chat_completions, call_chat_completions_streaming, init_client, oauth};
|
use crate::client::{
|
||||||
|
Message, MessageRole, call_chat_completions, call_chat_completions_streaming, init_client,
|
||||||
|
oauth,
|
||||||
|
};
|
||||||
use crate::config::{
|
use crate::config::{
|
||||||
AgentVariables, AppConfig, AssertState, Input, LastMessage, RequestContext, StateFlags,
|
AgentVariables, AppConfig, AssertState, Input, LastMessage, RequestContext, StateFlags,
|
||||||
macro_execute,
|
macro_execute,
|
||||||
@@ -29,9 +32,9 @@ use log::warn;
|
|||||||
use parking_lot::RwLock;
|
use parking_lot::RwLock;
|
||||||
use reedline::CursorConfig;
|
use reedline::CursorConfig;
|
||||||
use reedline::{
|
use reedline::{
|
||||||
ColumnarMenu, EditCommand, EditMode, Emacs, KeyCode, KeyModifiers, Keybindings, Reedline,
|
ColumnarMenu, EditCommand, EditMode, Emacs, FileBackedHistory, KeyCode, KeyModifiers,
|
||||||
ReedlineEvent, ReedlineMenu, ValidationResult, Validator, Vi, default_emacs_keybindings,
|
Keybindings, Reedline, ReedlineEvent, ReedlineMenu, ValidationResult, Validator, Vi,
|
||||||
default_vi_insert_keybindings, default_vi_normal_keybindings,
|
default_emacs_keybindings, default_vi_insert_keybindings, default_vi_normal_keybindings,
|
||||||
};
|
};
|
||||||
use reedline::{MenuBuilder, Signal};
|
use reedline::{MenuBuilder, Signal};
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
@@ -318,6 +321,58 @@ Type ".help" for additional help.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let (messages_snapshot, compressed_count) = {
|
||||||
|
let ctx = self.ctx.read();
|
||||||
|
if let Some(session) = &ctx.session {
|
||||||
|
let msgs: Vec<Message> = session
|
||||||
|
.messages()
|
||||||
|
.iter()
|
||||||
|
.filter(|m| !m.role.is_system())
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
let compressed = session.compressed_messages().len();
|
||||||
|
(msgs, compressed)
|
||||||
|
} else {
|
||||||
|
(vec![], 0)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !messages_snapshot.is_empty() || compressed_count > 0 {
|
||||||
|
let app = Arc::clone(&self.ctx.read().app.config);
|
||||||
|
if compressed_count > 0 {
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
dimmed_text(&format!(
|
||||||
|
"({compressed_count} earlier messages not shown; compressed for context)"
|
||||||
|
))
|
||||||
|
);
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
for message in &messages_snapshot {
|
||||||
|
match message.role {
|
||||||
|
MessageRole::User => {
|
||||||
|
if let Some(text) = message.content.as_text() {
|
||||||
|
println!("{}", dimmed_text("You:"));
|
||||||
|
println!("{text}");
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MessageRole::Assistant => {
|
||||||
|
if let Some(text) = message.content.as_text() {
|
||||||
|
app.print_markdown(text)?;
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!("{}", dimmed_text("─── ↑ previous conversation ↑ ───"));
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
if self.abort_signal.aborted_ctrld() {
|
if self.abort_signal.aborted_ctrld() {
|
||||||
break;
|
break;
|
||||||
@@ -393,6 +448,14 @@ Type ".help" for additional help.
|
|||||||
editor = editor.with_buffer_editor(command, temp_file);
|
editor = editor.with_buffer_editor(command, temp_file);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if app.save_shell_history {
|
||||||
|
let ctx = ctx.read();
|
||||||
|
let history_path = paths::repl_history_file(&ctx.session);
|
||||||
|
if let Ok(history) = FileBackedHistory::with_file(1000, history_path) {
|
||||||
|
editor = editor.with_history(Box::new(history));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(editor)
|
Ok(editor)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -684,6 +747,46 @@ pub async fn run_repl_command(
|
|||||||
session.set_autonaming(false);
|
session.set_autonaming(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(session) = &ctx.session {
|
||||||
|
let messages_snapshot: Vec<Message> = session
|
||||||
|
.messages()
|
||||||
|
.iter()
|
||||||
|
.filter(|m| !m.role.is_system())
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
let compressed_count = session.compressed_messages().len();
|
||||||
|
if !messages_snapshot.is_empty() || compressed_count > 0 {
|
||||||
|
if compressed_count > 0 {
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
dimmed_text(&format!(
|
||||||
|
"({compressed_count} earlier messages not shown — compressed for context)"
|
||||||
|
))
|
||||||
|
);
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
for message in &messages_snapshot {
|
||||||
|
match message.role {
|
||||||
|
MessageRole::User => {
|
||||||
|
if let Some(text) = message.content.as_text() {
|
||||||
|
println!("{}", dimmed_text("You:"));
|
||||||
|
println!("{text}");
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MessageRole::Assistant => {
|
||||||
|
if let Some(text) = message.content.as_text() {
|
||||||
|
app.print_markdown(text)?;
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!("{}", dimmed_text("─── ↑ previous conversation ↑ ───"));
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
".install" => {
|
".install" => {
|
||||||
let trimmed = args.map(str::trim).unwrap_or("");
|
let trimmed = args.map(str::trim).unwrap_or("");
|
||||||
|
|||||||
Reference in New Issue
Block a user