diff --git a/CHANGELOG.md b/CHANGELOG.md index fb3df2a..4d05e09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## v1.5.5 — Housekeeping (2026-04-10) + +### Added +- **`auto_link` parameter** on `create` — set to `false` to skip automatic wikilink resolution. Applies to MCP, HTTP, and CLI. Discovered links still appear as suggestions in the response. +- **`reindex_file` MCP tool + HTTP endpoint** — re-indexes a single file after external edits. Reads from disk, re-embeds chunks, rebuilds edges. Available as MCP tool, `POST /api/reindex-file`, and OpenAPI operation. + +### Changed +- **rmcp** bumped from 1.2.0 to 1.4.0 — host validation, non-Send handler support, transport fixes. Does not yet fix [#20](https://github.com/devwhodevs/engraph/issues/20) (protocol `2025-11-25` needed for Claude Desktop Cowork/Code modes — blocked upstream on [modelcontextprotocol/rust-sdk#800](https://github.com/modelcontextprotocol/rust-sdk/issues/800)). +- MCP tools: 22 → 23 +- HTTP endpoints: 23 → 24 +- OpenAPI version: 1.5.0 → 1.5.5 + ## v1.5.0 — ChatGPT Actions (2026-03-26) ### Added diff --git a/Cargo.lock b/Cargo.lock index 8d87f77..93fbc68 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -674,7 +674,7 @@ dependencies = [ [[package]] name = "engraph" -version = "1.5.4" +version = "1.5.5" dependencies = [ "anyhow", "axum", @@ -1966,9 +1966,9 @@ dependencies = [ [[package]] name = "rmcp" -version = "1.2.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba6b9d2f0efe2258b23767f1f9e0054cfbcac9c2d6f81a031214143096d7864f" +checksum = "f542f74cf247da16f19bbc87e298cd201e912314f4083e88cdd671f44f5fcb53" dependencies = [ "async-trait", "base64 0.22.1", @@ -1988,9 +1988,9 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "1.2.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab9d95d7ed26ad8306352b0d5f05b593222b272790564589790d210aa15caa9e" +checksum = "b2391e4ae47f314e70eaafb6c7bd82e495e770b935448864446302143019151f" dependencies = [ "darling 0.23.0", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index a2758ba..46b6b8b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "engraph" -version = "1.5.4" +version = "1.5.5" edition = "2024" description = "Local knowledge graph for AI agents. Hybrid search + MCP server for Obsidian vaults." license = "MIT" @@ -31,7 +31,7 @@ rayon = "1" time = { version = "0.3", features = ["parsing", "formatting", "macros"] } strsim = "0.11" ignore = "0.4" -rmcp = { version = "1.2", features = ["transport-io"] } +rmcp = { version = "1.4", features = ["transport-io"] } tokio = { version = "1", features = ["macros", "rt-multi-thread", "process", "time", "net"] } notify = "7.0" notify-debouncer-full = "0.4" diff --git a/README.md b/README.md index 9ab69b8..6723ca5 100644 --- a/README.md +++ b/README.md @@ -268,7 +268,7 @@ Returns orphan notes (no links in or out), broken wikilinks, stale notes, and ta `engraph serve --http` adds a full REST API alongside the MCP server, exposing the same capabilities over HTTP for web agents, scripts, and integrations. -**23 endpoints:** +**24 endpoints:** | Method | Endpoint | Permission | Description | |--------|----------|------------|-------------| @@ -292,6 +292,7 @@ Returns orphan notes (no links in or out), broken wikilinks, stale notes, and ta | POST | `/api/unarchive` | write | Restore archived note | | POST | `/api/update-metadata` | write | Update note metadata | | POST | `/api/delete` | write | Delete note (soft or hard) | +| POST | `/api/reindex-file` | write | Re-index a single file after external edits | | POST | `/api/migrate/preview` | write | Preview PARA migration (classify + suggest moves) | | POST | `/api/migrate/apply` | write | Apply PARA migration (move files) | | POST | `/api/migrate/undo` | write | Undo last PARA migration | @@ -542,8 +543,8 @@ engraph is not a replacement for Obsidian — it's the intelligence layer that s - LLM research orchestrator: query intent classification + query expansion + adaptive lane weights - llama.cpp inference via Rust bindings (GGUF models, Metal GPU on macOS, CUDA on Linux) - Intelligence opt-in: heuristic fallback when disabled, LLM-powered when enabled -- MCP server with 22 tools (8 read, 10 write, 1 diagnostic, 3 migrate) via stdio -- HTTP REST API with 23 endpoints, API key auth (`eg_` prefix), rate limiting, CORS — enabled via `engraph serve --http` +- MCP server with 23 tools (8 read, 10 write, 1 index, 1 diagnostic, 3 migrate) via stdio +- HTTP REST API with 24 endpoints, API key auth (`eg_` prefix), rate limiting, CORS — enabled via `engraph serve --http` - Section-level reading and editing: target specific headings with replace/prepend/append modes - Full note rewriting with automatic frontmatter preservation - Granular frontmatter mutations: set/remove fields, add/remove tags and aliases @@ -572,7 +573,9 @@ engraph is not a replacement for Obsidian — it's the intelligence layer that s - [x] ~~HTTP/REST API — complement MCP with a standard web API~~ (v1.3) - [x] ~~PARA migration — AI-assisted vault restructuring with preview/apply/undo~~ (v1.4) - [x] ~~ChatGPT Actions — OpenAPI 3.1.0 spec + plugin manifest + `--setup-chatgpt` helper~~ (v1.5) -- [ ] Multi-vault — search across multiple vaults (v1.6) +- [ ] Identity — user context at session start, enhanced onboarding (v1.6) +- [ ] Timeline — temporal knowledge graph with point-in-time queries (v1.7) +- [ ] Mining — automatic fact extraction from vault notes (v1.8) ## Configuration diff --git a/src/http.rs b/src/http.rs index 3726594..0e4e9d4 100644 --- a/src/http.rs +++ b/src/http.rs @@ -268,6 +268,7 @@ struct CreateBody { #[serde(default)] tags: Vec, folder: Option, + auto_link: Option, } #[derive(Debug, Deserialize)] @@ -326,6 +327,11 @@ struct DeleteBody { mode: Option, } +#[derive(Debug, Deserialize)] +struct ReindexFileBody { + file: String, +} + // --------------------------------------------------------------------------- // CORS // --------------------------------------------------------------------------- @@ -380,6 +386,8 @@ pub fn build_router(state: ApiState) -> Router { .route("/api/unarchive", post(handle_unarchive)) .route("/api/update-metadata", post(handle_update_metadata)) .route("/api/delete", post(handle_delete)) + // Index maintenance + .route("/api/reindex-file", post(handle_reindex_file)) // Migration endpoints .route("/api/migrate/preview", post(handle_migrate_preview)) .route("/api/migrate/apply", post(handle_migrate_apply)) @@ -712,6 +720,7 @@ async fn handle_create( tags: body.tags, folder: body.folder, created_by: "http-api".into(), + auto_link: body.auto_link, }; let result = writer::create_note( input, @@ -1011,6 +1020,52 @@ async fn handle_delete( }))) } +async fn handle_reindex_file( + State(state): State, + headers: HeaderMap, + Json(body): Json, +) -> Result { + authorize(&headers, &state, true)?; + let store = state.store.lock().await; + let mut embedder = state.embedder.lock().await; + let full_path = state.vault_path.join(&body.file); + + let content = std::fs::read_to_string(&full_path) + .map_err(|e| ApiError::internal(&format!("Cannot read file {}: {e}", body.file)))?; + + let content_hash = { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(content.as_bytes()); + format!("{:x}", hasher.finalize()) + }; + + let config = crate::config::Config::load().unwrap_or_default(); + + let result = crate::indexer::index_file( + &body.file, + &content, + &content_hash, + &store, + &mut *embedder, + &state.vault_path, + &config, + ) + .map_err(|e| ApiError::internal(&format!("{e:#}")))?; + + store + .delete_edges_for_file(result.file_id) + .map_err(|e| ApiError::internal(&format!("{e:#}")))?; + crate::indexer::build_edges_for_file(&store, result.file_id, &content) + .map_err(|e| ApiError::internal(&format!("{e:#}")))?; + + Ok(Json(serde_json::json!({ + "file": body.file, + "chunks": result.total_chunks, + "docid": result.docid, + }))) +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- diff --git a/src/main.rs b/src/main.rs index 9b66a37..15e9e8f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1280,6 +1280,7 @@ async fn main() -> Result<()> { tags, folder, created_by: "cli".into(), + auto_link: None, }; let result = engraph::writer::create_note( input, diff --git a/src/openapi.rs b/src/openapi.rs index 8d8e056..6f6620d 100644 --- a/src/openapi.rs +++ b/src/openapi.rs @@ -27,6 +27,7 @@ pub fn build_openapi_spec(server_url: &str) -> serde_json::Value { paths.insert("/api/unarchive".into(), build_unarchive()); paths.insert("/api/update-metadata".into(), build_update_metadata()); paths.insert("/api/delete".into(), build_delete()); + paths.insert("/api/reindex-file".into(), build_reindex_file()); // Migration endpoints paths.insert("/api/migrate/preview".into(), build_migrate_preview()); @@ -37,7 +38,7 @@ pub fn build_openapi_spec(server_url: &str) -> serde_json::Value { "openapi": "3.1.0", "info": { "title": "engraph", - "version": "1.5.0", + "version": "1.5.5", "description": "AI-powered semantic search and management API for Obsidian vaults." }, "servers": [{ "url": server_url }], @@ -220,7 +221,8 @@ fn build_create() -> serde_json::Value { "filename": { "type": "string", "description": "Filename without .md" }, "type_hint": { "type": "string", "description": "Type hint for placement" }, "tags": { "type": "array", "items": { "type": "string" }, "description": "Tags to apply" }, - "folder": { "type": "string", "description": "Explicit folder (skips auto-placement)" } + "folder": { "type": "string", "description": "Explicit folder (skips auto-placement)" }, + "auto_link": { "type": "boolean", "description": "Set to false to skip automatic wikilink resolution. Defaults to true." } } }}} }, @@ -428,6 +430,26 @@ fn build_delete() -> serde_json::Value { }) } +fn build_reindex_file() -> serde_json::Value { + serde_json::json!({ + "post": { + "operationId": "reindexFile", + "summary": "Re-index a single file after external edits. Re-reads, re-embeds, and updates search index.", + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { + "type": "object", + "required": ["file"], + "properties": { + "file": { "type": "string", "description": "File path relative to vault root" } + } + }}} + }, + "responses": { "200": { "description": "Re-indexed file info (chunks, docid)" } } + } + }) +} + fn build_migrate_preview() -> serde_json::Value { serde_json::json!({ "post": { diff --git a/src/serve.rs b/src/serve.rs index c105683..378dc17 100644 --- a/src/serve.rs +++ b/src/serve.rs @@ -83,6 +83,8 @@ pub struct CreateParams { pub tags: Option>, /// Explicit folder path (skips placement engine). pub folder: Option, + /// Set to false to skip automatic wikilink resolution. Defaults to true. + pub auto_link: Option, } #[derive(Debug, Deserialize, JsonSchema)] @@ -184,6 +186,12 @@ pub struct DeleteParams { pub mode: Option, } +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ReindexFileParams { + /// File path relative to vault root (e.g. "07-Daily/2026-04-10.md"). + pub file: String, +} + // --------------------------------------------------------------------------- // Server // --------------------------------------------------------------------------- @@ -198,6 +206,7 @@ pub struct EngraphServer { embedder: Arc>>, vault_path: Arc, profile: Arc>, + #[allow(dead_code)] // Required by rmcp #[tool_router] macro infrastructure tool_router: ToolRouter, /// Query expansion orchestrator (None when intelligence is disabled or failed to load). orchestrator: Option>>>, @@ -512,6 +521,7 @@ impl EngraphServer { tags: params.0.tags.unwrap_or_default(), folder: params.0.folder, created_by: "claude-code".into(), + auto_link: params.0.auto_link, }; let result = crate::writer::create_note( input, @@ -818,6 +828,64 @@ impl EngraphServer { }); to_json_result(&result) } + + #[tool( + name = "reindex_file", + description = "Re-index a single file after external edits. Reads the file from disk, re-embeds its chunks, and updates the search index. Use when a file was modified outside engraph and you need the index to reflect current content." + )] + async fn reindex_file( + &self, + params: Parameters, + ) -> Result { + let store = self.store.lock().await; + let mut embedder = self.embedder.lock().await; + let rel_path = params.0.file; + let full_path = self.vault_path.join(&rel_path); + + // Read file content from disk + let content = std::fs::read_to_string(&full_path).map_err(|e| { + McpError::new( + rmcp::model::ErrorCode::INVALID_PARAMS, + format!("Cannot read file {rel_path}: {e}"), + None::, + ) + })?; + + let content_hash = { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(content.as_bytes()); + format!("{:x}", hasher.finalize()) + }; + + let config = crate::config::Config::load().unwrap_or_default(); + + // Re-index the file (handles cleanup of old entries automatically) + let result = crate::indexer::index_file( + &rel_path, + &content, + &content_hash, + &store, + &mut *embedder, + &self.vault_path, + &config, + ) + .map_err(|e| mcp_err(&e))?; + + // Rebuild edges for the re-indexed file + store + .delete_edges_for_file(result.file_id) + .map_err(|e| mcp_err(&e))?; + crate::indexer::build_edges_for_file(&store, result.file_id, &content) + .map_err(|e| mcp_err(&e))?; + + let output = serde_json::json!({ + "file": rel_path, + "chunks": result.total_chunks, + "docid": result.docid, + }); + to_json_result(&output) + } } #[tool_handler] @@ -829,6 +897,7 @@ impl rmcp::handler::server::ServerHandler for EngraphServer { Write: create for new notes, append to add content, edit to modify a section, rewrite to replace body, \ edit_frontmatter for tags/properties, update_metadata for bulk tag/alias replacement. \ Lifecycle: move_note to relocate, archive to soft-delete, unarchive to restore, delete for permanent removal. \ + Index: reindex_file to refresh a single file's index after external edits. \ Migration: migrate_preview to classify notes into PARA folders, migrate_apply to execute the migration, migrate_undo to revert.", ) } diff --git a/src/writer.rs b/src/writer.rs index 6b35194..1698a65 100644 --- a/src/writer.rs +++ b/src/writer.rs @@ -25,6 +25,7 @@ pub struct CreateNoteInput { pub tags: Vec, pub folder: Option, pub created_by: String, + pub auto_link: Option, } #[derive(Debug, Clone)] @@ -512,17 +513,21 @@ pub fn create_note( // Step 2: Resolve tags let resolved_tags = store.resolve_tags(&input.tags)?; - // Step 3: Discover links and apply them + // Step 3: Discover links and apply them (unless auto_link is explicitly false) let people_folder = profile.and_then(|p| p.structure.folders.people.as_deref()); let discovered = links::discover_links(store, &input.content, vault_path, people_folder)?; // Split discovered links into auto-apply and suggestion-only - let (auto_apply, suggestions): (Vec<_>, Vec<_>) = + let (auto_apply, suggestions): (Vec<_>, Vec<_>) = if input.auto_link.unwrap_or(true) { discovered.into_iter().partition(|l| match &l.match_type { links::LinkMatchType::ExactName | links::LinkMatchType::Alias => true, links::LinkMatchType::FuzzyName { confidence_bp } => *confidence_bp >= 920, links::LinkMatchType::FirstName { .. } => false, - }); + }) + } else { + // auto_link disabled: all discovered links go to suggestions only + (Vec::new(), discovered) + }; let links_added: Vec = auto_apply.iter().map(|l| l.target_path.clone()).collect(); let links_suggested: Vec = suggestions diff --git a/tests/write_pipeline.rs b/tests/write_pipeline.rs index 33e88bc..7e84a78 100644 --- a/tests/write_pipeline.rs +++ b/tests/write_pipeline.rs @@ -65,6 +65,7 @@ fn test_create_note_is_immediately_searchable() { tags: vec!["engraph".into(), "search".into()], folder: Some("00-Inbox".into()), created_by: "test".into(), + auto_link: None, }; let result = create_note(input, &store, &mut embedder, vault_dir.path(), None).unwrap(); @@ -103,6 +104,7 @@ fn test_append_updates_index() { tags: vec![], folder: Some("00-Inbox".into()), created_by: "test".into(), + auto_link: None, }; let created = create_note(input, &store, &mut embedder, vault_dir.path(), None).unwrap(); @@ -136,6 +138,7 @@ fn test_conflict_detection() { tags: vec![], folder: Some("00-Inbox".into()), created_by: "test".into(), + auto_link: None, }; let created = create_note(input, &store, &mut embedder, vault_dir.path(), None).unwrap();