9 Commits

Author SHA1 Message Date
Dark-Alex-17 08f6ea5e6c feat: Added new memory functions for deleting and renaming memory files, as well as new lints for memory expiration dates and staleness of memories to improve the memory system
CI / All (macos-latest) (push) Waiting to run
CI / All (windows-latest) (push) Waiting to run
CI / All (ubuntu-latest) (push) Failing after 28s
2026-07-03 22:30:08 -06:00
Dark-Alex-17 ede0f75a89 feat: Created a new iwe skill and installed the iwe MCP server for utilizing large knowledgebases 2026-07-03 22:04:16 -06:00
Dark-Alex-17 2ec2aec4c0 style: updated the previous conversation marker a tad
CI / All (ubuntu-latest) (push) Failing after 26s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-07-02 16:49:38 -06:00
Dark-Alex-17 c2cb4ac433 feat: Session-specific, file-backed history in the REPL
CI / All (ubuntu-latest) (push) Failing after 25s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-07-02 16:44:55 -06:00
Dark-Alex-17 605a9170b0 feat: Replay session output when a user re-enters a session so all output can be seen again 2026-07-02 16:35:10 -06:00
Dark-Alex-17 385bd3eda2 fix: Overrode the default JSON content-type for MCP OAuth so its properly application/x-www-form-urlencoded
CI / All (ubuntu-latest) (push) Failing after 26s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-07-02 15:53:29 -06:00
Dark-Alex-17 6c3d96ac83 feat: Added confirmation message after MCP Oauth succeeds when invoked from --auth-mcp
CI / All (ubuntu-latest) (push) Failing after 26s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled
2026-07-02 15:22:22 -06:00
Dark-Alex-17 aa1fe7f7aa fmt: applied formatting 2026-07-02 15:22:00 -06:00
Dark-Alex-17 5e50828108 fix: typo in mcp file name 2026-07-02 15:20:57 -06:00
14 changed files with 968 additions and 58 deletions
+3
View File
@@ -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.
+5
View File
@@ -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", "."]
} }
} }
} }
+5
View File
@@ -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 \
+65
View File
@@ -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.
+7
View File
@@ -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),
+9
View File
@@ -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(),
}; };
+1
View File
@@ -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";
+16
View File
@@ -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
View File
@@ -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.
+8
View File
@@ -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
} }
+701 -26
View File
@@ -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,24 +325,63 @@ 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),
"rename" => rename_memory(&store, &cwd, args),
"delete" => delete_memory(&store, &cwd, args),
"edit_index" => {
let scope = arg_str(args, "scope")?;
let content = arg_str(args, "content")?;
let target_dir = scope_dir(&store, &cwd, &scope)?;
let index_path = write_memory_index(&target_dir, &content)?;
Ok(json!({
"status": "ok",
"path": index_path.display().to_string(),
}))
}
"lint" => lint_memory(&store),
_ => bail!("unknown memory action: {action}"),
}
}
fn write_memory(store: &MemoryStore, cwd: &Path, args: &Value) -> Result<Value> {
let name = arg_str(args, "name")?; let name = arg_str(args, "name")?;
let description = arg_str(args, "description")?; let description = arg_str(args, "description")?;
let content = arg_str(args, "content")?; let content = arg_str(args, "content")?;
let scope = arg_str(args, "scope")?; let scope = arg_str(args, "scope")?;
let kind = args.get("type").and_then(Value::as_str).map(String::from); 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 = match scope.as_str() { let target_dir = scope_dir(store, cwd, &scope)?;
"global" => paths::global_memory_dir(), let path = target_dir.join(format!("{name}.md"));
"workspace" => workspace_write_dir(&store, &cwd)?, let previous = if path.exists() {
other => bail!("unknown scope '{}': use 'global' or 'workspace'", other), 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 { let file = MemoryFile {
path: target_dir.join(format!("{name}.md")), path,
frontmatter: MemoryFrontmatter { frontmatter: MemoryFrontmatter {
name: name.clone(), name: name.clone(),
description: Some(description.clone()), description: Some(description.clone()),
kind, kind,
created: Some(created),
updated: Some(today),
superseded_by,
expires,
}, },
body: content, body: content,
}; };
@@ -245,41 +395,178 @@ pub fn handle_memory_tool(ctx: &mut RequestContext, cmd_name: &str, args: &Value
"path": file.path.display().to_string(), "path": file.path.display().to_string(),
"index_path": index_path.display().to_string(), "index_path": index_path.display().to_string(),
"index_updated": index_updated, "index_updated": index_updated,
"replaced": previous.is_some(),
"previous_description": previous.and_then(|p| p.frontmatter.description),
})) }))
} }
"edit_index" => {
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")?; let scope = arg_str(args, "scope")?;
let content = arg_str(args, "content")?; if new_name.is_empty()
let target_dir = match scope.as_str() { || !new_name
"global" => paths::global_memory_dir(), .chars()
"workspace" => workspace_write_dir(&store, &cwd)?, .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
other => bail!("unknown scope '{}': use 'global' or 'workspace'", other), {
}; bail!(
let index_path = write_memory_index(&target_dir, &content)?; "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!({ Ok(json!({
"status": "ok", "status": "ok",
"path": index_path.display().to_string(), "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),
} }
"lint" => lint_memory(&store), }
_ => bail!("unknown memory action: {action}"),
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()),
}));
} }
let index_content = store if let Some(expires) = &f.frontmatter.expires
.load_global_index()? && expires.as_str() < today.as_str()
.or_else(|| store.load_workspace_index().ok().flatten()) {
stale.push(json!({
"name": &f.frontmatter.name,
"reason": "expired",
"expires": expires,
}));
}
}
let global_index = store.load_global_index()?.unwrap_or_default();
let workspace_index = store
.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);
}
} }
+1
View File
@@ -159,6 +159,7 @@ async fn main() -> Result<()> {
let url = spec.url.as_deref().expect("validated: remote spec has url"); let url = spec.url.as_deref().expect("validated: remote spec has url");
mcp::oauth::run_mcp_oauth_flow(server_name, url, spec.oauth_client_id.as_deref()).await?; mcp::oauth::run_mcp_oauth_flow(server_name, url, spec.oauth_client_id.as_deref()).await?;
println!("Authentication saved. '{server_name}' is now available for use.");
return Ok(()); return Ok(());
} }
+5 -1
View File
@@ -1,4 +1,4 @@
use crate::client::oauth::{OAuthProvider, load_oauth_tokens, run_oauth_flow}; use crate::client::oauth::{OAuthProvider, TokenRequestFormat, load_oauth_tokens, run_oauth_flow};
use crate::config::paths; use crate::config::paths;
use anyhow::{Context, Result, anyhow}; use anyhow::{Context, Result, anyhow};
use chrono::Utc; use chrono::Utc;
@@ -63,6 +63,10 @@ impl OAuthProvider for McpOAuthProvider {
&self.scopes &self.scopes
} }
fn token_request_format(&self) -> TokenRequestFormat {
TokenRequestFormat::FormUrlEncoded
}
fn uses_localhost_redirect(&self) -> bool { fn uses_localhost_redirect(&self) -> bool {
false false
} }
+110 -9
View File
@@ -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)
} }
@@ -564,11 +627,9 @@ pub async fn run_repl_command(
.and_then(|c| c.mcp_servers.get(server_name)) .and_then(|c| c.mcp_servers.get(server_name))
.cloned(); .cloned();
match server_spec { match server_spec {
None => bail!( None => {
"MCP server '{}' not found in config. \ bail!("MCP server '{}' not found in mcp.json.", server_name)
Check your mcp_config.json.", }
server_name
),
Some(spec) if !spec.is_remote() => bail!( Some(spec) if !spec.is_remote() => bail!(
"MCP server '{}' uses stdio transport; \ "MCP server '{}' uses stdio transport; \
OAuth is only supported for http/sse servers.", OAuth is only supported for http/sse servers.",
@@ -686,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("");