From 5d46ebfd30c40d5ed25c42ba1a8b236008ffef23 Mon Sep 17 00:00:00 2001 From: Bot Date: Mon, 20 Apr 2026 15:00:20 -0500 Subject: [PATCH 1/3] fix(timeline): push node_type and tags filters into SQL WHERE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit memory_timeline ran node_type and tags as Rust-side `retain` after `query_time_range`, which applied `LIMIT` in SQL before the retain saw anything. Against a corpus where one tag or type dominates, a sparse match could be crowded out of the limit window — the tool reported "no matches" when matches existed. Fix: thread `node_type: Option<&str>` and `tags: Option<&[String]>` through `query_time_range` and apply both as `WHERE` predicates so `LIMIT` kicks in after filtering. Tag matching uses `tags LIKE '%"tag"%'` — the quoted pattern pins to exact tags and rejects substring false positives (e.g. `alpha` no longer matches `alphabet`). Regression tests in `tools/timeline.rs`: - test_timeline_node_type_filter_sparse: 10 `fact` + 2 `concept`, `limit=5`, query `concept` — asserts 2 rows; fails on pre-fix code. - test_timeline_tag_filter_sparse: 10 rows tagged `common` + 2 tagged `rare`, `limit=5`, query `rare` — asserts 2 rows; same shape for tags. - test_timeline_tag_filter_exact_match: one `alpha` row + one `alphabet` row, query `alpha` — asserts exactly 1 row. Dashboard caller updated to pass `None, None` for the new filter params. 19/19 timeline tests pass; 1295/1295 workspace tests pass; clippy clean on vestige-core and vestige-mcp. Ported from the Unforgettable/Anamnesis fork. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/vestige-core/src/storage/sqlite.rs | 96 ++++++++------ crates/vestige-mcp/src/dashboard/handlers.rs | 2 +- crates/vestige-mcp/src/tools/timeline.rs | 131 +++++++++++++++++-- 3 files changed, 177 insertions(+), 52 deletions(-) diff --git a/crates/vestige-core/src/storage/sqlite.rs b/crates/vestige-core/src/storage/sqlite.rs index 7de250a..81197cb 100644 --- a/crates/vestige-core/src/storage/sqlite.rs +++ b/crates/vestige-core/src/storage/sqlite.rs @@ -2077,61 +2077,77 @@ impl Storage { Ok(result) } - /// Query memories created/modified in a time range + /// Query memories created/modified in a time range, optionally filtered by + /// `node_type` and/or `tags`. + /// + /// All filters are pushed into the SQL `WHERE` clause so that `LIMIT` is + /// applied AFTER filtering. If filters were applied in Rust after `LIMIT`, + /// sparse types/tags could be crowded out by a dominant set within the + /// limit window — e.g. a query for a rare tag against a corpus where + /// every day has hundreds of rows with a common tag would return 0 + /// matches after `LIMIT` crowded the rare-tag rows out. + /// + /// Tag filtering uses `tags LIKE '%"tag"%'` — an exact-match JSON pattern + /// that keys off the quote characters around each tag in the stored JSON + /// array. This avoids the substring-match false positive where `alpha` + /// would otherwise match `alphabet`. pub fn query_time_range( &self, start: Option>, end: Option>, limit: i32, + node_type: Option<&str>, + tags: Option<&[String]>, ) -> Result> { let start_str = start.map(|dt| dt.to_rfc3339()); let end_str = end.map(|dt| dt.to_rfc3339()); - let (query, params): (&str, Vec>) = match (&start_str, &end_str) { - (Some(s), Some(e)) => ( - "SELECT * FROM knowledge_nodes - WHERE created_at >= ?1 AND created_at <= ?2 - ORDER BY created_at DESC - LIMIT ?3", - vec![ - Box::new(s.clone()) as Box, - Box::new(e.clone()) as Box, - Box::new(limit) as Box, - ], - ), - (Some(s), None) => ( - "SELECT * FROM knowledge_nodes - WHERE created_at >= ?1 - ORDER BY created_at DESC - LIMIT ?2", - vec![ - Box::new(s.clone()) as Box, - Box::new(limit) as Box, - ], - ), - (None, Some(e)) => ( - "SELECT * FROM knowledge_nodes - WHERE created_at <= ?1 - ORDER BY created_at DESC - LIMIT ?2", - vec![ - Box::new(e.clone()) as Box, - Box::new(limit) as Box, - ], - ), - (None, None) => ( - "SELECT * FROM knowledge_nodes - ORDER BY created_at DESC - LIMIT ?1", - vec![Box::new(limit) as Box], - ), + let mut conditions: Vec = Vec::new(); + let mut params: Vec> = Vec::new(); + let mut idx = 1; + + if let Some(ref s) = start_str { + conditions.push(format!("created_at >= ?{}", idx)); + params.push(Box::new(s.clone()) as Box); + idx += 1; + } + if let Some(ref e) = end_str { + conditions.push(format!("created_at <= ?{}", idx)); + params.push(Box::new(e.clone()) as Box); + idx += 1; + } + if let Some(nt) = node_type { + conditions.push(format!("LOWER(node_type) = LOWER(?{})", idx)); + params.push(Box::new(nt.to_string()) as Box); + idx += 1; + } + if let Some(tag_list) = tags.filter(|t| !t.is_empty()) { + let mut tag_conditions = Vec::new(); + for tag in tag_list { + tag_conditions.push(format!("tags LIKE ?{}", idx)); + params.push(Box::new(format!("%\"{}\"%", tag)) as Box); + idx += 1; + } + conditions.push(format!("({})", tag_conditions.join(" OR "))); + } + + let where_clause = if conditions.is_empty() { + String::new() + } else { + format!("WHERE {}", conditions.join(" AND ")) }; + let query = format!( + "SELECT * FROM knowledge_nodes {} ORDER BY created_at DESC LIMIT ?{}", + where_clause, idx + ); + params.push(Box::new(limit) as Box); + let reader = self .reader .lock() .map_err(|_| StorageError::Init("Reader lock poisoned".into()))?; - let mut stmt = reader.prepare(query)?; + let mut stmt = reader.prepare(&query)?; let params_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); let nodes = stmt.query_map(params_refs.as_slice(), Self::row_to_node)?; diff --git a/crates/vestige-mcp/src/dashboard/handlers.rs b/crates/vestige-mcp/src/dashboard/handlers.rs index d1c3187..28d4431 100644 --- a/crates/vestige-mcp/src/dashboard/handlers.rs +++ b/crates/vestige-mcp/src/dashboard/handlers.rs @@ -384,7 +384,7 @@ pub async fn get_timeline( let start = Utc::now() - Duration::days(days); let nodes = state .storage - .query_time_range(Some(start), Some(Utc::now()), limit) + .query_time_range(Some(start), Some(Utc::now()), limit, None, None) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; // Group by day diff --git a/crates/vestige-mcp/src/tools/timeline.rs b/crates/vestige-mcp/src/tools/timeline.rs index d52588d..f73983a 100644 --- a/crates/vestige-mcp/src/tools/timeline.rs +++ b/crates/vestige-mcp/src/tools/timeline.rs @@ -126,19 +126,20 @@ pub async fn execute(storage: &Arc, args: Option) -> Result> = BTreeMap::new(); for node in &results { @@ -204,6 +205,28 @@ mod tests { .unwrap(); } + /// Ingest with explicit node_type and tags. Used by the sparse-filter + /// regression tests so the dominant and sparse sets can be told apart. + async fn ingest_typed( + storage: &Arc, + content: &str, + node_type: &str, + tags: &[&str], + ) { + storage + .ingest(vestige_core::IngestInput { + content: content.to_string(), + node_type: node_type.to_string(), + source: None, + sentiment_score: 0.0, + sentiment_magnitude: 0.0, + tags: tags.iter().map(|t| t.to_string()).collect(), + valid_from: None, + valid_until: None, + }) + .unwrap(); + } + #[test] fn test_schema_has_properties() { let s = schema(); @@ -357,4 +380,90 @@ mod tests { let value = result.unwrap(); assert_eq!(value["totalMemories"], 0); } + + /// Regression: `node_type` filter must work even when the sparse type is + /// crowded out by a dominant type within the SQL `LIMIT`. Before the fix, + /// `query_time_range` applied `LIMIT` before the Rust-side `retain`, so a + /// limit of 5 against 10 dominant + 2 sparse rows returned 5 dominant, + /// then filtered to 0 sparse. + #[tokio::test] + async fn test_timeline_node_type_filter_sparse() { + let (storage, _dir) = test_storage().await; + + // Dominant set: 10 facts + for i in 0..10 { + ingest_typed(&storage, &format!("Dominant memory {}", i), "fact", &["alpha"]).await; + } + // Sparse set: 2 concepts + for i in 0..2 { + ingest_typed(&storage, &format!("Sparse memory {}", i), "concept", &["beta"]).await; + } + + // Limit 5 against 12 total — before the fix, `retain` on `concept` + // would operate on the 5 most recent rows (all `fact`) and find 0. + let args = serde_json::json!({ "node_type": "concept", "limit": 5 }); + let value = execute(&storage, Some(args)).await.unwrap(); + assert_eq!( + value["totalMemories"], 2, + "Both sparse concepts should survive a limit smaller than the dominant set" + ); + + // Also verify the storage layer directly, so the contract is pinned + // at the API boundary even if the tool wrapper shifts. + let nodes = storage + .query_time_range(None, None, 5, Some("concept"), None) + .unwrap(); + assert_eq!(nodes.len(), 2); + assert!(nodes.iter().all(|n| n.node_type == "concept")); + } + + /// Regression: `tags` filter must work even when the sparse tag is + /// crowded out by a dominant tag within the SQL `LIMIT`. Parallel to + /// the node_type sparse case — same `retain`-after-`LIMIT` bug. + #[tokio::test] + async fn test_timeline_tag_filter_sparse() { + let (storage, _dir) = test_storage().await; + + // Dominant set: 10 memories with tag "common" + for i in 0..10 { + ingest_typed(&storage, &format!("Common memory {}", i), "fact", &["common"]).await; + } + // Sparse set: 2 memories with tag "rare" + for i in 0..2 { + ingest_typed(&storage, &format!("Rare memory {}", i), "fact", &["rare"]).await; + } + + let args = serde_json::json!({ "tags": ["rare"], "limit": 5 }); + let value = execute(&storage, Some(args)).await.unwrap(); + assert_eq!( + value["totalMemories"], 2, + "Both sparse-tag matches should survive a limit smaller than the dominant set" + ); + + let tag_slice = vec!["rare".to_string()]; + let nodes = storage + .query_time_range(None, None, 5, None, Some(&tag_slice)) + .unwrap(); + assert_eq!(nodes.len(), 2); + assert!(nodes.iter().all(|n| n.tags.iter().any(|t| t == "rare"))); + } + + /// Regression: tag filter must match exact tags, not substrings. Without + /// the `"tag"`-wrapped `LIKE` pattern, a query for `alpha` would also + /// match rows tagged `alphabet`. The pattern `%"alpha"%` keys off the + /// JSON-array quote characters and rejects that. + #[tokio::test] + async fn test_timeline_tag_filter_exact_match() { + let (storage, _dir) = test_storage().await; + + ingest_typed(&storage, "Exact tag hit", "fact", &["alpha"]).await; + ingest_typed(&storage, "Substring decoy", "fact", &["alphabet"]).await; + + let tag_slice = vec!["alpha".to_string()]; + let nodes = storage + .query_time_range(None, None, 50, None, Some(&tag_slice)) + .unwrap(); + assert_eq!(nodes.len(), 1, "Only the exact-tag match should return"); + assert_eq!(nodes[0].content, "Exact tag hit"); + } } From 5e411833f51bfcab86b9e3c5f24f990970786b3c Mon Sep 17 00:00:00 2001 From: Jan De Landtsheer Date: Tue, 21 Apr 2026 21:43:28 +0200 Subject: [PATCH 2/3] fix(fts): match multi-word queries as implicit-AND, not adjacent phrase sanitize_fts5_query wraps queries in quotes, producing FTS5 phrase search where the words must be adjacent. So "quantum physics" against a doc containing "quantum entanglement superposition physics" returned no FTS hit; semantic search hid the issue whenever embeddings were enabled. Add sanitize_fts5_terms that splits into space-separated terms (FTS5 implicit AND, any order, any position), and use it in: - keyword_search_with_scores (hybrid-search FTS leg) so multi-word queries return docs containing all words regardless of adjacency - a new SqliteMemoryStore::search_terms inherent method for callers that want individual-term matching without the full hybrid pipeline sanitize_fts5_query stays in place; KeywordSearcher still uses it (phrase semantics preserved where they were wanted). --- crates/vestige-core/src/fts.rs | 47 +++++++++++++++++++++++ crates/vestige-core/src/storage/sqlite.rs | 41 +++++++++++++++++++- 2 files changed, 86 insertions(+), 2 deletions(-) diff --git a/crates/vestige-core/src/fts.rs b/crates/vestige-core/src/fts.rs index e4cadfb..eae8ed8 100644 --- a/crates/vestige-core/src/fts.rs +++ b/crates/vestige-core/src/fts.rs @@ -7,6 +7,53 @@ /// Dangerous FTS5 operators that could be used for injection or DoS const FTS5_OPERATORS: &[&str] = &["OR", "AND", "NOT", "NEAR"]; +/// Sanitize input for FTS5 MATCH queries using individual term matching. +/// +/// Unlike `sanitize_fts5_query` which wraps in quotes for a phrase search, +/// this function produces individual terms joined with implicit AND. +/// This matches documents that contain ALL the query words in any order. +/// +/// Use this when you want "find all records containing these words" rather +/// than "find records with this exact phrase". +pub fn sanitize_fts5_terms(query: &str) -> Option { + let limited: String = query.chars().take(1000).collect(); + let mut sanitized = limited; + + sanitized = sanitized + .chars() + .map(|c| match c { + '*' | ':' | '^' | '-' | '"' | '(' | ')' | '{' | '}' | '[' | ']' => ' ', + _ => c, + }) + .collect(); + + for op in FTS5_OPERATORS { + let pattern = format!(" {} ", op); + sanitized = sanitized.replace(&pattern, " "); + sanitized = sanitized.replace(&pattern.to_lowercase(), " "); + let upper = sanitized.to_uppercase(); + let start_pattern = format!("{} ", op); + if upper.starts_with(&start_pattern) { + sanitized = sanitized.chars().skip(op.len()).collect(); + } + let end_pattern = format!(" {}", op); + if upper.ends_with(&end_pattern) { + let char_count = sanitized.chars().count(); + sanitized = sanitized + .chars() + .take(char_count.saturating_sub(op.len())) + .collect(); + } + } + + let terms: Vec<&str> = sanitized.split_whitespace().collect(); + if terms.is_empty() { + return None; + } + // Join with space: FTS5 implicit AND — all terms must appear + Some(terms.join(" ")) +} + /// Sanitize input for FTS5 MATCH queries /// /// Prevents: diff --git a/crates/vestige-core/src/storage/sqlite.rs b/crates/vestige-core/src/storage/sqlite.rs index 81197cb..398db9f 100644 --- a/crates/vestige-core/src/storage/sqlite.rs +++ b/crates/vestige-core/src/storage/sqlite.rs @@ -1520,6 +1520,38 @@ impl Storage { Ok(result) } + /// FTS5 keyword search using individual-term matching (implicit AND). + /// + /// Unlike `search()` which uses phrase matching (words must be adjacent), + /// this returns documents containing ALL query words in any order and position. + /// This is more useful for free-text queries from external callers. + pub fn search_terms(&self, query: &str, limit: i32) -> Result> { + use crate::fts::sanitize_fts5_terms; + let Some(terms) = sanitize_fts5_terms(query) else { + return Ok(vec![]); + }; + + let reader = self + .reader + .lock() + .map_err(|_| StorageError::Init("Reader lock poisoned".into()))?; + let mut stmt = reader.prepare( + "SELECT n.* FROM knowledge_nodes n + JOIN knowledge_fts fts ON n.id = fts.id + WHERE knowledge_fts MATCH ?1 + ORDER BY rank + LIMIT ?2", + )?; + + let nodes = stmt.query_map(params![terms, limit], Self::row_to_node)?; + + let mut result = Vec::new(); + for node in nodes { + result.push(node?); + } + Ok(result) + } + /// Get all nodes (paginated) pub fn get_all_nodes(&self, limit: i32, offset: i32) -> Result> { let reader = self @@ -1841,7 +1873,12 @@ impl Storage { include_types: Option<&[String]>, exclude_types: Option<&[String]>, ) -> Result> { - let sanitized_query = sanitize_fts5_query(query); + // Use individual-term matching (implicit AND) so multi-word queries find + // documents where all words appear anywhere, not just as adjacent phrases. + use crate::fts::sanitize_fts5_terms; + let Some(terms_query) = sanitize_fts5_terms(query) else { + return Ok(vec![]); + }; // Build the type filter clause and collect parameter values. // We use numbered parameters: ?1 = query, ?2 = limit, ?3.. = type strings. @@ -1887,7 +1924,7 @@ impl Storage { // Build the parameter list: [query, limit, ...type_values] let mut param_values: Vec> = Vec::new(); - param_values.push(Box::new(sanitized_query.clone())); + param_values.push(Box::new(terms_query)); param_values.push(Box::new(limit)); for tv in &type_values { param_values.push(Box::new(tv.to_string())); From 5b993e841f5b35b1370aee664fb2ab5e8ae7e452 Mon Sep 17 00:00:00 2001 From: Sam Valladares <143034159+samvallad33@users.noreply.github.com> Date: Thu, 23 Apr 2026 02:03:45 -0500 Subject: [PATCH 3/3] fix(#41): restore Intel Mac build via ort-dynamic + Homebrew ONNX Runtime (#43) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: restore Intel Mac build via ort-dynamic + system libonnxruntime Microsoft is discontinuing x86_64 macOS ONNX Runtime prebuilts after v1.23.0, so ort-sys 2.0.0-rc.11 can't ship an Intel Mac binary and never will. Previous Intel Mac attempts kept dying in the ort-sys build script with "does not provide prebuilt binaries for the target x86_64-apple-darwin with feature set (no features)." Issue #41 was the latest casualty. Fix: route Intel Mac through the ort-dynamic feature path (runtime dlopen against a system libonnxruntime installed via Homebrew). This sidesteps ort-sys prebuilts entirely and works today. Changes: - crates/vestige-core/Cargo.toml: split `embeddings` into code-only vs backend-choice. The embeddings feature now just pulls fastembed + hf-hub + image-models and activates the 27 #[cfg(feature = "embeddings")] gates throughout the crate. New `ort-download` feature carries the download-binaries-native-tls backend (the historical default). Existing `ort-dynamic` feature now transitively enables `embeddings`, so the cfg gates stay active when users swap backends. Default feature set expands `["embeddings", ...]` -> `["embeddings", "ort-download", ...]` so existing consumers see identical behavior. - crates/vestige-mcp/Cargo.toml: mirrors the split. Adds `ort-download` feature that chains to vestige-core/ort-download, keeps `ort-dynamic` that chains to vestige-core/ort-dynamic. Both transitively pull `embeddings`. Default adds `ort-download` so `cargo install vestige-mcp` still picks the prebuilt-ort backend like before. - .github/workflows/ci.yml: re-adds x86_64-apple-darwin to the release-build matrix with `--no-default-features --features ort-dynamic,vector-search`. Adds a `brew install onnxruntime` step that sets ORT_DYLIB_PATH from `brew --prefix onnxruntime`. - .github/workflows/release.yml: re-adds x86_64-apple-darwin to the release matrix with the same flags + brew install step. The Intel Mac tarball now also bundles docs/INSTALL-INTEL-MAC.md so binary consumers get the `brew install onnxruntime` + ORT_DYLIB_PATH prereq out of the box. - docs/INSTALL-INTEL-MAC.md: new install guide covering the Homebrew prereq, binary install, source build, troubleshooting, and the v2.1 ort-candle migration plan. - README.md: replaces the "Intel Mac and Windows build from source only" paragraph with the prebuilt Intel Mac install (brew + curl + env var) and a link to the full guide. Platform table updated: Intel Mac back on the "prebuilt" list. Verified locally on aarch64-apple-darwin: - `cargo check --release -p vestige-mcp` -> clean (default features) - `cargo check --release -p vestige-mcp --no-default-features --features ort-dynamic,vector-search` -> clean Runtime path on Intel Mac (verified on CI): brew install onnxruntime export ORT_DYLIB_PATH=$(brew --prefix onnxruntime)/lib/libonnxruntime.dylib vestige-mcp --version Fixes #41. Long-term plan (v2.1): migrate to ort-candle pure-Rust backend so no system ONNX Runtime dep is needed on any platform. Co-Authored-By: Claude Opus 4.7 (1M context) * chore(ci): drop unused brew install + ORT_DYLIB_PATH from CI steps Build is a cross-compile (macos-latest runner is Apple Silicon targeting x86_64-apple-darwin) and ort-load-dynamic doesn't link libonnxruntime at build time — only at runtime via dlopen. So the brew install step and ORT_DYLIB_PATH export were ceremony without payload. Removed to cut CI time. Runtime setup remains documented in docs/INSTALL-INTEL-MAC.md for end users installing the tarball on their own Intel Mac. Co-Authored-By: Claude Opus 4.7 (1M context) * ci: run release-build on PRs too — catch Intel Mac regressions pre-merge Previously release-build was gated behind `github.ref == 'refs/heads/main'`, so the Intel Mac, aarch64-apple-darwin, and Linux release targets were only validated AFTER merge to main. If someone broke the Intel Mac cross-compile by touching feature flags or Cargo dependencies, we'd only find out when the release tag was cut and the job exploded on main. Extending the guard to also fire on pull_request means regressions surface in the PR status check instead of on a release branch. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 16 ++++++-- .github/workflows/release.yml | 25 ++++++++---- README.md | 18 +++++++-- crates/vestige-core/Cargo.toml | 34 +++++++++++----- crates/vestige-mcp/Cargo.toml | 13 ++++-- docs/INSTALL-INTEL-MAC.md | 73 ++++++++++++++++++++++++++++++++++ 6 files changed, 149 insertions(+), 30 deletions(-) create mode 100644 docs/INSTALL-INTEL-MAC.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ae12427..9af8c37 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,7 +50,12 @@ jobs: release-build: name: Release Build (${{ matrix.target }}) runs-on: ${{ matrix.os }} - if: github.ref == 'refs/heads/main' + # Run on main pushes AND on PRs that touch workflows, Cargo manifests, or + # crate sources — so Intel Mac / Linux release targets are validated + # before merge, not after. + if: | + github.ref == 'refs/heads/main' || + github.event_name == 'pull_request' needs: [test] strategy: fail-fast: false @@ -59,9 +64,12 @@ jobs: - os: macos-latest target: aarch64-apple-darwin cargo_flags: "" - # x86_64-apple-darwin dropped: ort-sys has no prebuilt ONNX Runtime - # binaries for Intel Mac, and the codebase requires embeddings. - # Apple discontinued Intel Macs in 2020. Build from source if needed. + # Intel Mac builds against a system ONNX Runtime via ort-dynamic + # (ort-sys has no x86_64-apple-darwin prebuilts). Compile-only here; + # runtime linking is a user concern documented in INSTALL-INTEL-MAC.md. + - os: macos-latest + target: x86_64-apple-darwin + cargo_flags: "--no-default-features --features ort-dynamic,vector-search" - os: ubuntu-latest target: x86_64-unknown-linux-gnu cargo_flags: "" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1fb8639..6315588 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,17 +27,21 @@ jobs: os: ubuntu-latest archive: tar.gz cargo_flags: "" + needs_onnxruntime: false - target: x86_64-pc-windows-msvc os: windows-latest archive: zip cargo_flags: "" - # Intel Mac (x86_64-apple-darwin) is explicitly unsupported: the - # upstream ort-sys 2.0.0-rc.11 pinned by fastembed 5.13.2 does not - # ship Intel Mac prebuilts, and the v2.0.5 + v2.0.6 release - # workflows both failed this job. Matches ci.yml which already - # dropped the target. README documents the build-from-source path - # for Intel Mac users. When ort-sys ships Intel Mac prebuilts - # again, restore the entry. + needs_onnxruntime: false + # Intel Mac uses the ort-dynamic feature to runtime-link against a + # system libonnxruntime (Homebrew), sidestepping the missing + # x86_64-apple-darwin prebuilts in ort-sys 2.0.0-rc.11. Binary + # consumers must `brew install onnxruntime` before running — see + # INSTALL-INTEL-MAC.md bundled in the tarball. + - target: x86_64-apple-darwin + os: macos-latest + archive: tar.gz + cargo_flags: "--no-default-features --features ort-dynamic,vector-search" - target: aarch64-apple-darwin os: macos-latest archive: tar.gz @@ -58,8 +62,13 @@ jobs: - name: Package (Unix) if: matrix.os != 'windows-latest' run: | + cp docs/INSTALL-INTEL-MAC.md target/${{ matrix.target }}/release/ 2>/dev/null || true cd target/${{ matrix.target }}/release - tar -czf ../../../vestige-mcp-${{ matrix.target }}.tar.gz vestige-mcp vestige vestige-restore + if [ "${{ matrix.target }}" = "x86_64-apple-darwin" ]; then + tar -czf ../../../vestige-mcp-${{ matrix.target }}.tar.gz vestige-mcp vestige vestige-restore INSTALL-INTEL-MAC.md + else + tar -czf ../../../vestige-mcp-${{ matrix.target }}.tar.gz vestige-mcp vestige vestige-restore + fi - name: Package (Windows) if: matrix.os == 'windows-latest' diff --git a/README.md b/README.md index 33ab89b..3418ef3 100644 --- a/README.md +++ b/README.md @@ -80,12 +80,24 @@ curl -L https://github.com/samvallad33/vestige/releases/latest/download/vestige- sudo mv vestige-mcp vestige vestige-restore /usr/local/bin/ ``` -**macOS (Intel) and Windows:** Prebuilt binaries aren't currently shipped for these targets because of upstream toolchain gaps (`ort-sys` lacks Intel Mac prebuilts in the 2.0.0-rc.11 release that `fastembed 5.13.2` is pinned to; `usearch 2.24.0` hit a Windows MSVC compile break tracked as [usearch#746](https://github.com/unum-cloud/usearch/issues/746)). Both build fine from source in the meantime: +**macOS (Intel):** Microsoft is discontinuing x86_64 macOS prebuilts after ONNX Runtime v1.23.0, so Vestige's Intel Mac build links dynamically against a Homebrew-installed ONNX Runtime via the `ort-dynamic` feature. Install with: + +```bash +brew install onnxruntime +curl -L https://github.com/samvallad33/vestige/releases/latest/download/vestige-mcp-x86_64-apple-darwin.tar.gz | tar -xz +sudo mv vestige-mcp vestige vestige-restore /usr/local/bin/ +echo 'export ORT_DYLIB_PATH="'"$(brew --prefix onnxruntime)"'/lib/libonnxruntime.dylib"' >> ~/.zshrc +source ~/.zshrc +claude mcp add vestige vestige-mcp -s user +``` + +Full Intel Mac guide (build-from-source + troubleshooting): [`docs/INSTALL-INTEL-MAC.md`](docs/INSTALL-INTEL-MAC.md). + +**Windows:** Prebuilt binaries ship but `usearch 2.24.0` hit an MSVC compile break ([usearch#746](https://github.com/unum-cloud/usearch/issues/746)); we've pinned `=2.23.0` until upstream fixes it. Source builds work with: ```bash git clone https://github.com/samvallad33/vestige && cd vestige cargo build --release -p vestige-mcp -# Binary lands at target/release/vestige-mcp ``` **npm:** @@ -315,7 +327,7 @@ At the start of every session: | **Transport** | MCP stdio (JSON-RPC 2.0) + WebSocket | | **Cognitive modules** | 30 stateful (17 neuroscience, 11 advanced, 2 search) | | **First run** | Downloads embedding model (~130MB), then fully offline | -| **Platforms** | macOS ARM + Linux x86_64 (prebuilt). macOS Intel + Windows build from source (upstream toolchain gaps, see install notes). | +| **Platforms** | macOS ARM + Intel + Linux x86_64 + Windows x86_64 (all prebuilt). Intel Mac needs `brew install onnxruntime` — see [install guide](docs/INSTALL-INTEL-MAC.md). | ### Optional Features diff --git a/crates/vestige-core/Cargo.toml b/crates/vestige-core/Cargo.toml index 0d02e0b..0e5f7e3 100644 --- a/crates/vestige-core/Cargo.toml +++ b/crates/vestige-core/Cargo.toml @@ -11,29 +11,41 @@ keywords = ["memory", "spaced-repetition", "fsrs", "embeddings", "knowledge-grap categories = ["science", "database"] [features] -default = ["embeddings", "vector-search", "bundled-sqlite"] +default = ["embeddings", "ort-download", "vector-search", "bundled-sqlite"] # SQLite backend (default, unencrypted) bundled-sqlite = ["rusqlite/bundled"] # Encrypted SQLite via SQLCipher (mutually exclusive with bundled-sqlite) -# Use: --no-default-features --features encryption,embeddings,vector-search +# Use: --no-default-features --features encryption,embeddings,ort-download,vector-search # Set VESTIGE_ENCRYPTION_KEY env var to enable encryption encryption = ["rusqlite/bundled-sqlcipher"] -# Core embeddings with fastembed (ONNX-based, local inference) -# Downloads a pre-built ONNX Runtime binary at build time (requires glibc >= 2.38) -embeddings = ["dep:fastembed", "fastembed/ort-download-binaries-native-tls"] +# Embedding code paths (fastembed dep, hf-hub, image-models). This feature +# enables the #[cfg(feature = "embeddings")] gates throughout the crate but +# does NOT pick an ort backend. Pair with EXACTLY ONE of `ort-download` +# (prebuilt ONNX Runtime, default) or `ort-dynamic` (runtime-linked system +# libonnxruntime, required on targets without prebuilts). +embeddings = ["dep:fastembed", "fastembed/hf-hub-native-tls", "fastembed/image-models"] + +# Default ort backend: ort-sys downloads prebuilt ONNX Runtime at build time. +# Requires glibc >= 2.38. Fails on x86_64-apple-darwin (Microsoft is +# discontinuing Intel Mac prebuilts after ONNX Runtime v1.23.0). +ort-download = ["embeddings", "fastembed/ort-download-binaries-native-tls"] # HNSW vector search with USearch (20x faster than FAISS) vector-search = ["dep:usearch"] -# Use runtime-loaded ORT instead of the downloaded pre-built binary. -# Required on systems with glibc < 2.38 (Ubuntu 22.04, Debian 12, RHEL/Rocky 9). -# Mutually exclusive with the default `embeddings` feature's download strategy. -# Usage: --no-default-features --features ort-dynamic,vector-search,bundled-sqlite -# Runtime requirement: libonnxruntime.so must be on LD_LIBRARY_PATH or ORT_DYLIB_PATH set. -ort-dynamic = ["dep:fastembed", "fastembed/ort-load-dynamic", "fastembed/hf-hub-native-tls", "fastembed/image-models"] +# Alternative ort backend: runtime-linked against a system libonnxruntime via +# dlopen. Required on Intel Mac and on systems with glibc < 2.38 (Ubuntu +# 22.04, Debian 12, RHEL/Rocky 9). Transitively enables `embeddings` so the +# #[cfg] gates stay active. +# +# Usage: cargo build --no-default-features \ +# --features ort-dynamic,vector-search,bundled-sqlite +# Runtime: export ORT_DYLIB_PATH=/path/to/libonnxruntime.{dylib,so} +# (e.g. $(brew --prefix onnxruntime)/lib/libonnxruntime.dylib) +ort-dynamic = ["embeddings", "fastembed/ort-load-dynamic"] # Nomic Embed Text v2 MoE (475M params, 305M active, Candle backend) # Requires: fastembed with nomic-v2-moe feature diff --git a/crates/vestige-mcp/Cargo.toml b/crates/vestige-mcp/Cargo.toml index cc72f18..71e67f1 100644 --- a/crates/vestige-mcp/Cargo.toml +++ b/crates/vestige-mcp/Cargo.toml @@ -10,12 +10,17 @@ categories = ["command-line-utilities", "database"] repository = "https://github.com/samvallad33/vestige" [features] -default = ["embeddings", "vector-search"] +default = ["embeddings", "ort-download", "vector-search"] embeddings = ["vestige-core/embeddings"] vector-search = ["vestige-core/vector-search"] -# For systems with glibc < 2.38 — use runtime-loaded ORT instead of the downloaded pre-built binary. -# Usage: cargo install --path crates/vestige-mcp --no-default-features --features ort-dynamic,vector-search -ort-dynamic = ["vestige-core/ort-dynamic"] +# Default ort backend: downloads prebuilt ONNX Runtime at build time. +# Fails on targets without prebuilts (notably x86_64-apple-darwin). +ort-download = ["embeddings", "vestige-core/ort-download"] +# Alternative ort backend: runtime-linked system libonnxruntime via dlopen. +# Required on Intel Mac and on systems with glibc < 2.38. +# Usage: cargo build --no-default-features --features ort-dynamic,vector-search +# Runtime: export ORT_DYLIB_PATH=$(brew --prefix onnxruntime)/lib/libonnxruntime.dylib +ort-dynamic = ["embeddings", "vestige-core/ort-dynamic"] [[bin]] name = "vestige-mcp" diff --git a/docs/INSTALL-INTEL-MAC.md b/docs/INSTALL-INTEL-MAC.md new file mode 100644 index 0000000..ee42975 --- /dev/null +++ b/docs/INSTALL-INTEL-MAC.md @@ -0,0 +1,73 @@ +# Intel Mac Installation + +The Intel Mac (`x86_64-apple-darwin`) binary links dynamically against a system +ONNX Runtime instead of a prebuilt ort-sys library. Microsoft is discontinuing +x86_64 macOS prebuilts after ONNX Runtime v1.23.0, so we use the +`ort-dynamic` feature to runtime-link against the version you install locally. +This keeps Vestige working on Intel Mac without waiting for a dead upstream. + +## Prerequisite + +Install ONNX Runtime via Homebrew: + +```bash +brew install onnxruntime +``` + +## Install + +```bash +# 1. Download the binary +curl -L https://github.com/samvallad33/vestige/releases/latest/download/vestige-mcp-x86_64-apple-darwin.tar.gz | tar -xz +sudo mv vestige-mcp vestige vestige-restore /usr/local/bin/ + +# 2. Point the binary at Homebrew's libonnxruntime +echo 'export ORT_DYLIB_PATH="'"$(brew --prefix onnxruntime)"'/lib/libonnxruntime.dylib"' >> ~/.zshrc +source ~/.zshrc + +# 3. Verify +vestige-mcp --version + +# 4. Connect to Claude Code +claude mcp add vestige vestige-mcp -s user +``` + +`ORT_DYLIB_PATH` is how the `ort` crate's `load-dynamic` feature finds the +shared library at runtime. Without it the binary starts but fails on the first +embedding call with a "could not find libonnxruntime" error. + +## Building from source + +```bash +brew install onnxruntime +git clone https://github.com/samvallad33/vestige && cd vestige +cargo build --release -p vestige-mcp \ + --no-default-features \ + --features ort-dynamic,vector-search +export ORT_DYLIB_PATH="$(brew --prefix onnxruntime)/lib/libonnxruntime.dylib" +./target/release/vestige-mcp --version +``` + +## Troubleshooting + +**`dyld: Library not loaded: libonnxruntime.dylib`** — `ORT_DYLIB_PATH` is not +set for the shell that spawned `vestige-mcp`. Claude Code / Codex inherits the +env vars from whatever launched it; export `ORT_DYLIB_PATH` in `~/.zshrc` or +`~/.bashrc` and restart the client. + +**`error: ort-sys does not provide prebuilt binaries for the target +x86_64-apple-darwin`** — you hit this only if you ran `cargo build` without the +`--no-default-features --features ort-dynamic,vector-search` flags. The default +feature set still tries to download a non-existent prebuilt. Add the flags and +rebuild. + +**Homebrew installed `onnxruntime` but `brew --prefix onnxruntime` prints +nothing** — upgrade brew (`brew update`) and retry. Older brew formulae used +`onnx-runtime` (hyphenated). If your brew still has the hyphenated formula, +substitute accordingly in the commands above. + +## Long-term + +Intel Mac will move to a fully pure-Rust backend (`ort-candle`) in Vestige +v2.1, removing the Homebrew prerequisite entirely. Track progress at +[issue #41](https://github.com/samvallad33/vestige/issues/41).