Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ jobs:
steps:
- uses: actions/checkout@v4

- name: Free disk space
run: |
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc
sudo apt-get clean

- name: Install Rust
uses: dtolnay/rust-toolchain@stable

Expand All @@ -38,11 +43,8 @@ jobs:
- name: Cache cargo
uses: Swatinem/rust-cache@v2

- name: Build
run: cargo build --verbose

- name: Run tests
run: cargo test --verbose
run: cargo test

lint:
runs-on: ubuntu-latest
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ The 4-tool API is defined in `src/mcp/tools.rs`:
### Store Parameters
- `content` (required) — The content to store
- `scope` (optional, default: "project") — Where to store: "project" or "global"
- `replace_id` (optional) — ID of an existing item to replace. Atomically stores new content and deletes the old item, preserving graph lineage.

### Recall Parameters
- `query` (required) — Semantic search query
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ Go to **Settings > Tools > AI Assistant > MCP Servers**, click **+**, and add:

| Tool | Parameters | Description |
|------|------------|-------------|
| `store` | `content`, `scope?` | Save content to memory |
| `store` | `content`, `scope?`, `replace_id?` | Save content to memory |
| `recall` | `query`, `limit?` | Search by semantic similarity |
| `list` | `limit?`, `scope?` | List stored items |
| `forget` | `id` | Delete an item by ID |
Expand Down
2 changes: 1 addition & 1 deletion src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ fn sanitize_sql_string(s: &str) -> String {
/// Validate that a string looks like a valid item/project ID (UUID hex + hyphens).
/// Returns true if the string only contains safe characters for SQL interpolation.
/// Use this as an additional guard before `sanitize_sql_string` for ID fields.
fn is_valid_id(s: &str) -> bool {
pub(crate) fn is_valid_id(s: &str) -> bool {
!s.is_empty() && s.len() <= 64 && s.chars().all(|c| c.is_ascii_hexdigit() || c == '-')
}

Expand Down
137 changes: 134 additions & 3 deletions src/mcp/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use serde_json::{Value, json};

use crate::access::AccessTracker;
use crate::consolidation::{ConsolidationQueue, spawn_consolidation};
use crate::db::score_with_decay;
use crate::db::{is_valid_id, score_with_decay};
use crate::graph::GraphStore;
use crate::item::{Item, ItemFilters};
use crate::retry::{RetryConfig, with_retry};
Expand Down Expand Up @@ -43,6 +43,10 @@ pub fn get_tools() -> Vec<Tool> {
"enum": ["project", "global"],
"default": "project",
"description": "Where to store: 'project' (current project) or 'global' (all projects)"
},
"replace_id": {
"type": "string",
"description": "ID of an existing item to replace. Atomically stores new content and deletes the old item, preserving graph lineage."
}
});

Expand All @@ -67,7 +71,7 @@ pub fn get_tools() -> Vec<Tool> {
vec![
Tool {
name: "store".to_string(),
description: "Store content for later retrieval. Use for preferences, facts, reference material, docs, or any information worth remembering. Long content is automatically chunked for better search.".to_string(),
description: "Store content for later retrieval. Use for preferences, facts, reference material, docs, or any information worth remembering. Long content is automatically chunked for better search. Use replace_id to atomically replace an existing item.".to_string(),
input_schema: store_schema,
},
Tool {
Expand Down Expand Up @@ -133,6 +137,8 @@ pub struct StoreParams {
pub content: String,
#[serde(default)]
pub scope: Option<String>,
#[serde(default)]
pub replace_id: Option<String>,
/// Override creation timestamp (Unix seconds). Benchmark builds only.
#[cfg(feature = "bench")]
#[serde(default)]
Expand Down Expand Up @@ -357,12 +363,55 @@ async fn execute_store(
}
}

// Handle replace_id: delete old item, preserve graph lineage
let mut replaced = false;
if let Some(ref old_id) = params.replace_id {
if !is_valid_id(old_id) {
tracing::warn!("replace_id is not a valid ID: {}", old_id);
} else {
if let Err(e) = graph.add_supersedes_edge(&new_id, old_id) {
tracing::warn!("replace: add_supersedes_edge failed: {}", e);
}
if let Err(e) = graph.transfer_edges(old_id, &new_id) {
tracing::warn!("replace: transfer_edges failed: {}", e);
}
match db.delete_item(old_id).await {
Ok(true) => {
replaced = true;
}
Ok(false) => {
tracing::warn!("replace: old item not found: {}", old_id);
}
Err(e) => {
tracing::warn!("replace: delete_item failed: {}", e);
}
}
if let Err(e) = graph.remove_node(old_id) {
tracing::warn!("replace: remove_node failed: {}", e);
}
}
}

let message = if replaced {
format!(
"Stored in {} scope (replaced {})",
scope,
params.replace_id.as_deref().unwrap_or("")
)
} else {
format!("Stored in {} scope", scope)
};

let mut result = json!({
"success": true,
"id": new_id,
"message": format!("Stored in {} scope", scope)
"message": message
});

if replaced {
result["replaced_id"] = json!(params.replace_id);
}

if !store_result.potential_conflicts.is_empty() {
let conflicts: Vec<Value> = store_result
.potential_conflicts
Expand Down Expand Up @@ -1158,4 +1207,86 @@ mod tests {
"Should error with missing content"
);
}

#[tokio::test]
#[ignore] // requires model download
async fn test_store_replace_id() {
let (ctx, _tmp) = setup_test_context().await;

// Store an item
let store_result = execute_tool(
&ctx,
"store",
Some(json!({ "content": "Original content to replace" })),
)
.await;
assert!(store_result.is_error.is_none(), "Store should succeed");
let text = &store_result.content[0].text;
let parsed: Value = serde_json::from_str(text).unwrap();
let old_id = parsed["id"].as_str().unwrap().to_string();

// Store with replace_id
let replace_result = execute_tool(
&ctx,
"store",
Some(json!({
"content": "Updated replacement content",
"replace_id": old_id
})),
)
.await;
assert!(replace_result.is_error.is_none(), "Replace should succeed");
let text = &replace_result.content[0].text;
let parsed: Value = serde_json::from_str(text).unwrap();
assert!(
parsed["replaced_id"].is_string(),
"Should include replaced_id"
);
assert_eq!(parsed["replaced_id"].as_str().unwrap(), old_id);
assert!(
parsed["message"].as_str().unwrap().contains("replaced"),
"Message should mention replacement"
);

// Old item should be gone
let list_result = execute_tool(&ctx, "list", Some(json!({ "scope": "project" }))).await;
let text = &list_result.content[0].text;
let parsed: Value = serde_json::from_str(text).unwrap();
assert_eq!(
parsed["count"], 1,
"Should have exactly 1 item after replace"
);
}

#[tokio::test]
#[ignore] // requires model download
async fn test_store_replace_id_invalid() {
let (ctx, _tmp) = setup_test_context().await;

// Store with an invalid replace_id — should still succeed (non-fatal)
let result = execute_tool(
&ctx,
"store",
Some(json!({
"content": "Content with bad replace_id",
"replace_id": "not a valid id!@#$"
})),
)
.await;
assert!(
result.is_error.is_none(),
"Store should succeed even with invalid replace_id"
);
let text = &result.content[0].text;
let parsed: Value = serde_json::from_str(text).unwrap();
assert!(
parsed["success"].as_bool().unwrap(),
"Should report success"
);
// Should NOT have replaced_id since the ID was invalid
assert!(
parsed.get("replaced_id").is_none(),
"Should not include replaced_id for invalid ID"
);
}
}