From 89b0b35665b091dc0f35969f01ea27febc0ffb93 Mon Sep 17 00:00:00 2001 From: Sam Valladares Date: Sat, 25 Jul 2026 21:40:28 +0800 Subject: [PATCH 1/8] feat(pro): mandatory zero-knowledge cloud sync + Pro upgrade surfaces - cloud-sync client now REQUIRES VESTIGE_CLOUD_ENCRYPTION_KEY: construction fails without it, every upload is encrypted, downloads reject plaintext - release.yml: Windows + Intel Mac binaries now build with cloud-sync - ci.yml: the paid path is now compiled and tested in CI - Second-Machine Wall: unentitled 'vestige sync --cloud' now explains Pro ($19/mo) with a subscribe link instead of a dead-end error - vestige --help / health footers, npm postinstall note, npm README Pro section - .gitignore: exclude private business docs (contact lists) from the repo Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 5 +- .github/workflows/release.yml | 4 +- .gitignore | 14 ++ crates/vestige-core/src/storage/cloud_sync.rs | 164 +++++++++++------- crates/vestige-mcp/src/bin/cli.rs | 76 +++++--- packages/vestige-mcp-npm/README.md | 23 +++ .../vestige-mcp-npm/scripts/postinstall.js | 3 + 7 files changed, 202 insertions(+), 87 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de626c8..565ac85 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,6 +59,9 @@ jobs: - name: Test run: cargo test --workspace + - name: Test paid cloud-sync path + run: cargo test --locked --package vestige-core --features cloud-sync cloud --lib + release-build: name: Release Build (${{ matrix.target }}) runs-on: ${{ matrix.os }} @@ -81,7 +84,7 @@ jobs: # runtime linking is a user concern documented in INSTALL-INTEL-MAC.md. - os: macos-14 target: x86_64-apple-darwin - cargo_flags: "--no-default-features --features ort-dynamic,vector-search" + cargo_flags: "--no-default-features --features ort-dynamic,vector-search,cloud-sync" - os: ubuntu-latest target: x86_64-unknown-linux-gnu cargo_flags: "" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c9ea405..4512ac4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,7 +38,7 @@ jobs: # generates 0 embeddings, and no model download is ever attempted. # usearch builds cleanly on MSVC because vestige-core pins it with # features=["fp16lib"] (see crates/vestige-core/Cargo.toml). - cargo_flags: "--no-default-features --features embeddings,ort-download,vector-search" + cargo_flags: "--no-default-features --features embeddings,ort-download,vector-search,cloud-sync" needs_onnxruntime: false # Intel Mac uses the ort-dynamic feature to runtime-link against a # system libonnxruntime (Homebrew), sidestepping the missing @@ -48,7 +48,7 @@ jobs: - target: x86_64-apple-darwin os: macos-latest archive: tar.gz - cargo_flags: "--no-default-features --features ort-dynamic,vector-search" + cargo_flags: "--no-default-features --features ort-dynamic,vector-search,cloud-sync" - target: aarch64-apple-darwin os: macos-latest archive: tar.gz diff --git a/.gitignore b/.gitignore index 80763c9..3421d36 100644 --- a/.gitignore +++ b/.gitignore @@ -142,3 +142,17 @@ fastembed-rs/ .claude/ .codebase-memory/ + +# Internal AI-council planning scratch + session dumps — never public +.council/ + +# Private business material: pricing strategy, sales playbooks, and outreach +# lists containing third parties' names and contact details. These sit in the +# working tree untracked; without this rule a single `git add -A` would publish +# real people's personal data to a public repo. +docs/business/ +*.session.jsonl +blackbox-proof-*/*.jsonl + +# Hermes agent-runtime conversation dumps (dated hex, tens of MB, embed system prompts) +[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]_[0-9][0-9][0-9][0-9][0-9][0-9]_[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f] diff --git a/crates/vestige-core/src/storage/cloud_sync.rs b/crates/vestige-core/src/storage/cloud_sync.rs index e30c525..2fb37e3 100644 --- a/crates/vestige-core/src/storage/cloud_sync.rs +++ b/crates/vestige-core/src/storage/cloud_sync.rs @@ -24,9 +24,9 @@ use std::cell::RefCell; use std::time::Duration; +use reqwest::StatusCode; use reqwest::blocking::Client; use reqwest::header::{AUTHORIZATION, ETAG, IF_MATCH}; -use reqwest::StatusCode; use super::portable::PortableArchive; use super::sqlite::{PortableSyncBackend, Result, StorageError}; @@ -49,10 +49,10 @@ pub struct HttpPortableSyncBackend { endpoint: String, /// Per-user sync key, presented as `Authorization: Bearer `. sync_key: String, - /// Optional zero-knowledge passphrase. When set, the archive is encrypted - /// before upload and decrypted after download — the server never sees - /// plaintext, and this passphrase is never sent to the server. - encryption_key: Option, + /// Required zero-knowledge passphrase. The archive is always encrypted + /// before upload and decrypted after download; the server never receives + /// this passphrase or a plaintext archive. + encryption_key: String, /// Blocking HTTP client (the trait is synchronous). client: Client, /// ETag captured on the most recent successful read, used as the `If-Match` @@ -62,19 +62,19 @@ pub struct HttpPortableSyncBackend { } impl HttpPortableSyncBackend { - /// Build a cloud sync backend for `endpoint` authenticated with `sync_key`, - /// with no client-side encryption (plaintext upload). + /// Plaintext cloud sync is deliberately disabled. /// /// A trailing slash on `endpoint` is trimmed so URL joining is predictable. pub fn new(endpoint: impl Into, sync_key: impl Into) -> Result { Self::new_with_encryption(endpoint, sync_key, None) } - /// Build a cloud sync backend with optional zero-knowledge encryption. + /// Build a cloud sync backend with required zero-knowledge encryption. /// - /// When `encryption_key` is `Some`, the portable archive is encrypted with - /// XChaCha20-Poly1305 (Argon2id-derived key) before upload and decrypted on - /// download. The passphrase never leaves this process. + /// The portable archive is encrypted with XChaCha20-Poly1305 + /// (Argon2id-derived key) before upload and decrypted on download. The + /// passphrase never leaves this process. Missing or blank passphrases are + /// rejected rather than silently uploading plaintext. pub fn new_with_encryption( endpoint: impl Into, sync_key: impl Into, @@ -92,7 +92,15 @@ impl HttpPortableSyncBackend { "cloud sync key is empty (set VESTIGE_CLOUD_SYNC_KEY)".to_string(), )); } - let encryption_key = encryption_key.filter(|k| !k.is_empty()); + let encryption_key = encryption_key + .filter(|k| !k.trim().is_empty()) + .ok_or_else(|| { + StorageError::Init( + "cloud sync encryption key is required; set \ + VESTIGE_CLOUD_ENCRYPTION_KEY before syncing" + .to_string(), + ) + })?; let client = Client::builder() .timeout(REQUEST_TIMEOUT) .user_agent(concat!("vestige-cloud-sync/", env!("CARGO_PKG_VERSION"))) @@ -109,7 +117,7 @@ impl HttpPortableSyncBackend { /// Whether this backend encrypts client-side (zero-knowledge). pub fn is_encrypted(&self) -> bool { - self.encryption_key.is_some() + true } /// Full blob URL for this backend. @@ -150,27 +158,18 @@ impl PortableSyncBackend for HttpPortableSyncBackend { .bytes() .map_err(|e| StorageError::Init(format!("cloud sync read body failed: {e}")))?; - // Decrypt if this is a zero-knowledge envelope. If a passphrase - // is configured but the remote is still plaintext (legacy), parse - // it directly — the next push will encrypt it (transparent upgrade). - let plaintext: std::borrow::Cow<'_, [u8]> = - if super::cloud_crypto::is_encrypted(&bytes) { - let pass = self.encryption_key.as_deref().ok_or_else(|| { - StorageError::Init( - "remote archive is encrypted but VESTIGE_CLOUD_ENCRYPTION_KEY is \ - not set on this device" - .to_string(), - ) - })?; - std::borrow::Cow::Owned(super::cloud_crypto::decrypt(pass, &bytes)?) - } else { - std::borrow::Cow::Borrowed(&bytes) - }; + if !super::cloud_crypto::is_encrypted(&bytes) { + return Err(StorageError::Init( + "refusing plaintext cloud archive: Vestige Pro requires \ + end-to-end encrypted sync" + .to_string(), + )); + } + let plaintext = super::cloud_crypto::decrypt(&self.encryption_key, &bytes)?; - let archive: PortableArchive = - serde_json::from_slice(&plaintext).map_err(|e| { - StorageError::Init(format!("failed to parse cloud sync archive: {e}")) - })?; + let archive: PortableArchive = serde_json::from_slice(&plaintext).map_err(|e| { + StorageError::Init(format!("failed to parse cloud sync archive: {e}")) + })?; Ok(Some(archive)) } StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => Err(StorageError::Init( @@ -188,21 +187,15 @@ impl PortableSyncBackend for HttpPortableSyncBackend { let plaintext = serde_json::to_vec(archive) .map_err(|e| StorageError::Init(format!("failed to serialize archive: {e}")))?; - // Zero-knowledge: encrypt before upload when a passphrase is set, so the - // server only ever stores ciphertext. Content type reflects the payload. - let (body, content_type) = match self.encryption_key.as_deref() { - Some(pass) => ( - super::cloud_crypto::encrypt(pass, &plaintext)?, - "application/octet-stream", - ), - None => (plaintext, "application/json"), - }; + // Zero-knowledge is mandatory for hosted sync: plaintext never leaves + // this process and every successful upload is an encrypted envelope. + let body = super::cloud_crypto::encrypt(&self.encryption_key, &plaintext)?; let mut req = self .client .put(self.blob_url()) .header(AUTHORIZATION, format!("Bearer {}", self.sync_key)) - .header(reqwest::header::CONTENT_TYPE, content_type) + .header(reqwest::header::CONTENT_TYPE, "application/octet-stream") .body(body); // Optimistic concurrency: only overwrite the object we pulled. If the @@ -240,13 +233,15 @@ impl PortableSyncBackend for HttpPortableSyncBackend { #[cfg(test)] mod tests { + use super::super::portable::{PORTABLE_ARCHIVE_FORMAT, PortableArchive, PortableTable}; use super::*; - use super::super::portable::{PortableArchive, PortableTable, PORTABLE_ARCHIVE_FORMAT}; use std::io::{Read, Write}; use std::net::TcpListener; use std::sync::mpsc; use std::thread; + const TEST_PASSPHRASE: &str = "correct horse battery staple"; + fn sample_archive() -> PortableArchive { PortableArchive { archive_format: PORTABLE_ARCHIVE_FORMAT.to_string(), @@ -268,6 +263,7 @@ mod tests { method: String, authorization: Option, if_match: Option, + content_type: Option, } /// Minimal one-shot HTTP mock. `responder` builds the raw HTTP response @@ -275,7 +271,7 @@ mod tests { /// URL and a receiver for the captured request. fn spawn_mock(responder: F) -> (String, mpsc::Receiver) where - F: Fn(&CapturedRequest) -> String + Send + 'static, + F: Fn(&CapturedRequest) -> Vec + Send + 'static, { let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock"); let addr = listener.local_addr().expect("addr"); @@ -293,10 +289,12 @@ mod tests { cap.authorization = Some(v.trim().to_string()); } else if let Some(v) = line.strip_prefix("if-match: ") { cap.if_match = Some(v.trim().to_string()); + } else if let Some(v) = line.strip_prefix("content-type: ") { + cap.content_type = Some(v.trim().to_string()); } } let response = responder(&cap); - let _ = stream.write_all(response.as_bytes()); + let _ = stream.write_all(&response); let _ = stream.flush(); let _ = tx.send(cap); } @@ -304,30 +302,61 @@ mod tests { (format!("http://{addr}"), rx) } - fn http_response(status: &str, extra_headers: &str, body: &str) -> String { - format!( - "HTTP/1.1 {status}\r\nContent-Length: {}\r\n{extra_headers}Connection: close\r\n\r\n{body}", + fn http_response(status: &str, extra_headers: &str, body: &str) -> Vec { + http_response_bytes(status, extra_headers, body.as_bytes()) + } + + fn http_response_bytes(status: &str, extra_headers: &str, body: &[u8]) -> Vec { + let mut response = format!( + "HTTP/1.1 {status}\r\nContent-Length: {}\r\n{extra_headers}Connection: close\r\n\r\n", body.len() ) + .into_bytes(); + response.extend_from_slice(body); + response + } + + fn encrypted_backend(endpoint: impl Into, sync_key: &str) -> HttpPortableSyncBackend { + HttpPortableSyncBackend::new_with_encryption( + endpoint, + sync_key, + Some(TEST_PASSPHRASE.to_string()), + ) + .unwrap() } #[test] - fn new_rejects_empty_endpoint_and_key() { - assert!(HttpPortableSyncBackend::new("", "key").is_err()); - assert!(HttpPortableSyncBackend::new("https://x", "").is_err()); - assert!(HttpPortableSyncBackend::new("https://x", "key").is_ok()); + fn new_rejects_plaintext_empty_endpoint_and_empty_key() { + assert!(HttpPortableSyncBackend::new("https://x", "key").is_err()); + assert!( + HttpPortableSyncBackend::new_with_encryption( + "", + "key", + Some(TEST_PASSPHRASE.to_string()) + ) + .is_err() + ); + assert!( + HttpPortableSyncBackend::new_with_encryption( + "https://x", + "", + Some(TEST_PASSPHRASE.to_string()) + ) + .is_err() + ); + assert!(encrypted_backend("https://x", "key").is_encrypted()); } #[test] fn endpoint_trailing_slash_trimmed() { - let be = HttpPortableSyncBackend::new("https://sync.example/", "k").unwrap(); + let be = encrypted_backend("https://sync.example/", "k"); assert_eq!(be.blob_url(), "https://sync.example/v1/blob"); } #[test] fn read_404_returns_none() { let (base, rx) = spawn_mock(|_| http_response("404 Not Found", "", "")); - let be = HttpPortableSyncBackend::new(base, "secret").unwrap(); + let be = encrypted_backend(base, "secret"); let got = be.read_archive().expect("read ok"); assert!(got.is_none()); let cap = rx.recv().unwrap(); @@ -338,21 +367,31 @@ mod tests { #[test] fn read_200_parses_and_captures_etag() { let archive = sample_archive(); - let body = serde_json::to_string(&archive).unwrap(); + let plaintext = serde_json::to_vec(&archive).unwrap(); + let body = super::super::cloud_crypto::encrypt(TEST_PASSPHRASE, &plaintext).unwrap(); let (base, _rx) = spawn_mock(move |_| { - http_response("200 OK", "ETag: \"v1-abc\"\r\n", &body) + http_response_bytes("200 OK", "ETag: \"v1-abc\"\r\n", &body) }); - let be = HttpPortableSyncBackend::new(base, "secret").unwrap(); + let be = encrypted_backend(base, "secret"); let got = be.read_archive().expect("read ok").expect("some archive"); assert_eq!(got.archive_format, PORTABLE_ARCHIVE_FORMAT); // ETag captured for the next If-Match write. assert_eq!(be.last_etag.borrow().as_deref(), Some("\"v1-abc\"")); } + #[test] + fn read_200_rejects_plaintext_downgrade() { + let body = serde_json::to_string(&sample_archive()).unwrap(); + let (base, _rx) = spawn_mock(move |_| http_response("200 OK", "", &body)); + let be = encrypted_backend(base, "secret"); + let err = be.read_archive().unwrap_err(); + assert!(err.to_string().contains("refusing plaintext")); + } + #[test] fn read_401_is_error() { let (base, _rx) = spawn_mock(|_| http_response("401 Unauthorized", "", "")); - let be = HttpPortableSyncBackend::new(base, "bad").unwrap(); + let be = encrypted_backend(base, "bad"); assert!(be.read_archive().is_err()); } @@ -360,19 +399,20 @@ mod tests { fn write_sends_if_match_when_etag_present() { // Seed an etag as if a prior read happened. let (base, rx) = spawn_mock(|_| http_response("200 OK", "", "")); - let be = HttpPortableSyncBackend::new(base, "secret").unwrap(); + let be = encrypted_backend(base, "secret"); *be.last_etag.borrow_mut() = Some("\"v1-abc\"".to_string()); be.write_archive(&sample_archive()).expect("write ok"); let cap = rx.recv().unwrap(); assert_eq!(cap.method, "PUT"); assert_eq!(cap.authorization.as_deref(), Some("Bearer secret")); assert_eq!(cap.if_match.as_deref(), Some("\"v1-abc\"")); + assert_eq!(cap.content_type.as_deref(), Some("application/octet-stream")); } #[test] fn write_omits_if_match_for_first_create() { let (base, rx) = spawn_mock(|_| http_response("201 Created", "", "")); - let be = HttpPortableSyncBackend::new(base, "secret").unwrap(); + let be = encrypted_backend(base, "secret"); // No prior read → no etag → no If-Match (allow create). be.write_archive(&sample_archive()).expect("write ok"); let cap = rx.recv().unwrap(); @@ -383,7 +423,7 @@ mod tests { #[test] fn write_412_is_conflict_error() { let (base, _rx) = spawn_mock(|_| http_response("412 Precondition Failed", "", "")); - let be = HttpPortableSyncBackend::new(base, "secret").unwrap(); + let be = encrypted_backend(base, "secret"); *be.last_etag.borrow_mut() = Some("\"stale\"".to_string()); let err = be.write_archive(&sample_archive()).unwrap_err(); assert!(err.to_string().contains("conflict")); diff --git a/crates/vestige-mcp/src/bin/cli.rs b/crates/vestige-mcp/src/bin/cli.rs index 15df46e..adb7a65 100644 --- a/crates/vestige-mcp/src/bin/cli.rs +++ b/crates/vestige-mcp/src/bin/cli.rs @@ -26,6 +26,9 @@ use vestige_core::{IngestInput, PortableImportMode, Storage}; #[command( long_about = "Vestige is a cognitive memory system based on 130 years of memory research.\n\nIt implements FSRS-6, spreading activation, synaptic tagging, and more." )] +#[command( + after_help = "Vestige Pro: your memory on every machine, end-to-end encrypted. $19/mo -> https://github.com/samvallad33/vestige#vestige-pro" +)] struct Cli { /// Use a specific Vestige data directory for this command. #[arg(long, global = true, value_name = "DIR")] @@ -1751,6 +1754,15 @@ fn run_health() -> anyhow::Result<()> { println!(" {} {}", icon, text); } + println!(); + println!( + "{} {}", + "Pro:".cyan().bold(), + "sync this memory across machines, end-to-end encrypted ($19/mo) — \ + https://github.com/samvallad33/vestige#vestige-pro" + .white() + ); + Ok(()) } @@ -2323,8 +2335,10 @@ fn run_sync_cloud(endpoint: Option) -> anyhow::Result<()> { .filter(|s| !s.trim().is_empty()) .ok_or_else(|| { anyhow::anyhow!( - "no cloud endpoint: pass --endpoint or set VESTIGE_CLOUD_ENDPOINT \ - (e.g. https://sync.vestige.dev)" + "Vestige Pro syncs your agent's memory across machines, end-to-end encrypted \ + ($19/month).\n Subscribe: https://github.com/samvallad33/vestige#vestige-pro\n \ + Already subscribed? Pass --endpoint or set VESTIGE_CLOUD_ENDPOINT from your \ + welcome email." ) })?; let sync_key = std::env::var("VESTIGE_CLOUD_SYNC_KEY") @@ -2332,35 +2346,37 @@ fn run_sync_cloud(endpoint: Option) -> anyhow::Result<()> { .filter(|s| !s.trim().is_empty()) .ok_or_else(|| { anyhow::anyhow!( - "no sync key: set VESTIGE_CLOUD_SYNC_KEY (issued when you subscribe to \ - Vestige Cloud)" + "no sync key set. Your local memory is free forever; syncing it across machines \ + is Vestige Pro ($19/month, zero-knowledge encrypted).\n Subscribe: \ + https://github.com/samvallad33/vestige#vestige-pro\n Already subscribed? Set \ + VESTIGE_CLOUD_SYNC_KEY from your welcome email." ) })?; - // Optional zero-knowledge encryption passphrase. Never sent to the server. + // Required zero-knowledge encryption passphrase. Never sent to the server. let encryption_key = std::env::var("VESTIGE_CLOUD_ENCRYPTION_KEY") .ok() - .filter(|s| !s.trim().is_empty()); + .filter(|s| !s.trim().is_empty()) + .ok_or_else(|| { + anyhow::anyhow!( + "no encryption key: set VESTIGE_CLOUD_ENCRYPTION_KEY to a strong passphrase. \ + Vestige Pro refuses plaintext cloud sync. Use the same passphrase on every \ + device; it cannot be recovered by Vestige." + ) + })?; println!("{}", "=== Vestige Cloud Sync ===".cyan().bold()); println!(); println!("{}: {}", "Endpoint".white().bold(), endpoint); - if encryption_key.is_some() { - println!( - "{}: {}", - "Encryption".white().bold(), - "zero-knowledge (XChaCha20-Poly1305) — your data is encrypted before upload".green() - ); - } else { - println!( - "{}: {}", - "Encryption".white().bold(), - "OFF — set VESTIGE_CLOUD_ENCRYPTION_KEY for zero-knowledge sync".yellow() - ); - } + println!( + "{}: {}", + "Encryption".white().bold(), + "zero-knowledge (XChaCha20-Poly1305) — your data is encrypted before upload".green() + ); let storage = open_storage()?; - let report = storage.sync_portable_archive_cloud(&endpoint, &sync_key, encryption_key)?; + let report = + storage.sync_portable_archive_cloud(&endpoint, &sync_key, Some(encryption_key))?; print_sync_report(&report); Ok(()) } @@ -2368,8 +2384,9 @@ fn run_sync_cloud(endpoint: Option) -> anyhow::Result<()> { #[cfg(not(feature = "cloud-sync"))] fn run_sync_cloud(_endpoint: Option) -> anyhow::Result<()> { anyhow::bail!( - "this build was compiled without the `cloud-sync` feature; rebuild with \ - --features cloud-sync to use Vestige Cloud" + "this build was compiled without the `cloud-sync` feature. Official binaries from \ + v2.3.0 include it: run `vestige update` or `npm update -g vestige-mcp-server`, \ + then retry. Building from source? Add --features cloud-sync." ) } @@ -2593,6 +2610,21 @@ fn run_ingest( let storage = open_storage()?; + // Warm up the embedding model so smart_ingest's is_ready() gate passes and + // real vector search runs (instead of silently falling back to keyword-only + // regular ingest). is_ready() is side-effect free by design, so a fresh CLI + // process must init explicitly. Non-fatal: fall back to keyword on failure. + #[cfg(feature = "embeddings")] + { + if let Err(e) = storage.init_embeddings() { + eprintln!( + " {} Embeddings unavailable: {} (ingest will use keyword-only)", + "!".yellow(), + e + ); + } + } + // Try smart_ingest (PE Gating) if available, otherwise regular ingest #[cfg(all(feature = "embeddings", feature = "vector-search"))] { diff --git a/packages/vestige-mcp-npm/README.md b/packages/vestige-mcp-npm/README.md index 7bc9e2c..ca4282e 100644 --- a/packages/vestige-mcp-npm/README.md +++ b/packages/vestige-mcp-npm/README.md @@ -22,6 +22,29 @@ This refreshes the binaries only. Optional Claude Code Cognitive Sandwich companion files are refreshed with `vestige update --sandwich-companion` or `vestige sandwich install`. +## Vestige Pro — your memory on every machine + +The entire cognitive engine is free forever, local, and never metered. Vestige +Pro is for when that memory needs to follow you: + +- **End-to-end encrypted continuity** across every machine you work on: your + memory graph plus your accountability history (Black Box traces, receipts, + memory PRs). +- **Zero-knowledge by construction**: your passphrase never leaves your machine. + The server only ever stores ciphertext. The client refuses to sync without + encryption. +- **$19/month.** No annual lock-in, no lifetime gimmicks. + +```bash +npm update -g vestige-mcp-server # get the Pro-capable client (v2.3.0+) +vestige sync --cloud # walks you through the rest +``` + +Subscribe: [github.com/samvallad33/vestige#vestige-pro](https://github.com/samvallad33/vestige#vestige-pro) + +One passphrase, chosen by you, for every device. Vestige never receives it. A +lost passphrase means the encrypted data is unrecoverable — that is the point. + ### What gets installed | Command | Description | diff --git a/packages/vestige-mcp-npm/scripts/postinstall.js b/packages/vestige-mcp-npm/scripts/postinstall.js index 76e678b..f54fcdb 100644 --- a/packages/vestige-mcp-npm/scripts/postinstall.js +++ b/packages/vestige-mcp-npm/scripts/postinstall.js @@ -262,6 +262,9 @@ async function main() { console.log(' 2. Restart your MCP client.'); console.log(' 3. Test with: "remember that my preferred editor is VS Code"'); console.log(''); + console.log('Local memory is free forever. Vestige Pro syncs it across machines,'); + console.log('end-to-end encrypted ($19/mo): https://github.com/samvallad33/vestige#vestige-pro'); + console.log(''); } catch (err) { console.error(''); From e95ab05d4a0cfe2372b181b330e160e27ececc31 Mon Sep 17 00:00:00 2001 From: Sam Valladares Date: Sat, 25 Jul 2026 21:58:03 +0800 Subject: [PATCH 2/8] fix(npm): stop rejecting INSTALL-INTEL-MAC.md in the release tarball MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The x86_64-apple-darwin release tarball packages INSTALL-INTEL-MAC.md (release.yml), but postinstall's strict archive-member check only accepted the three binaries — so npm install hard-failed on every Intel Mac with 'Unexpected archive entry'. Known-optional docs are now accepted; genuinely unknown entries are still rejected. Co-Authored-By: Claude Opus 5 --- packages/vestige-mcp-npm/scripts/postinstall.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/vestige-mcp-npm/scripts/postinstall.js b/packages/vestige-mcp-npm/scripts/postinstall.js index f54fcdb..26e4f29 100644 --- a/packages/vestige-mcp-npm/scripts/postinstall.js +++ b/packages/vestige-mcp-npm/scripts/postinstall.js @@ -60,6 +60,9 @@ const checksumPath = path.join(targetDir, `${archiveName}.sha256`); const expectedArchiveMembers = new Set( ['vestige-mcp', 'vestige', 'vestige-restore'].map((name) => (isWindows ? `${name}.exe` : name)) ); +// Docs that some release archives legitimately include alongside the binaries +// (e.g. the x86_64-apple-darwin tarball ships INSTALL-INTEL-MAC.md). +const optionalArchiveMembers = new Set(['INSTALL-INTEL-MAC.md']); function isWorkspaceCheckout() { const packageRoot = path.resolve(__dirname, '..'); @@ -183,7 +186,7 @@ function validateArchiveEntries(archivePath) { for (const entry of entries) { const normalized = normalizeArchiveEntry(entry); - if (!expectedArchiveMembers.has(normalized)) { + if (!expectedArchiveMembers.has(normalized) && !optionalArchiveMembers.has(normalized)) { throw new Error(`Unexpected archive entry: ${entry}`); } } From f97caff1861420ac166ab9d36f30783a0efdeeaf Mon Sep 17 00:00:00 2001 From: Sam Valladares Date: Sat, 25 Jul 2026 21:58:03 +0800 Subject: [PATCH 3/8] fix(core): V19 idempotency NULL/'' collision + VESTIGE_TRACE gate + trace retention - upsert_by_source lookup now matches the V19 unique index's NULL/'' bucketing (COALESCE on both sides); a legacy NULL-project row no longer collides with an empty-string envelope. Two regression tests added. - Black Box tracing gets an opt-out (VESTIGE_TRACE=0) parsed like the other levers, read once per process, plus a bounded retention sweep (VESTIGE_TRACE_RETENTION_DAYS, default 30, 0 keeps forever). Co-Authored-By: Claude Opus 5 --- crates/vestige-core/src/storage/sqlite.rs | 129 +++++++++++++++++- .../vestige-core/src/storage/trace_store.rs | 87 ++++++++++++ crates/vestige-mcp/src/server.rs | 80 +++++++++-- 3 files changed, 279 insertions(+), 17 deletions(-) diff --git a/crates/vestige-core/src/storage/sqlite.rs b/crates/vestige-core/src/storage/sqlite.rs index c48fcec..3da4c8d 100644 --- a/crates/vestige-core/src/storage/sqlite.rs +++ b/crates/vestige-core/src/storage/sqlite.rs @@ -3687,6 +3687,11 @@ impl SqliteMemoryStore { // 6. Prune old access log entries (keep 90 days) let _ = self.prune_access_log(); + // 6.5. Prune old Black Box trace events (keep 30 days by default; + // VESTIGE_TRACE_RETENTION_DAYS overrides, 0 = keep forever). Best-effort + // like the access-log sweep: a failure never blocks consolidation. + let _ = self.prune_agent_traces(); + // 7. Optimize w20 if enough usage data let w20_optimized = self.optimize_w20_if_ready().unwrap_or(None); @@ -9984,8 +9989,11 @@ impl SqliteMemoryStore { // same system (e.g. github repos octocat/repoA and octocat/repoB, or two // Redmine instances) reuse bare per-project ids ("5"), so keying on // (source_system, source_id) alone made repoB's issue #5 overwrite - // repoA's row in place. `IS NOT DISTINCT FROM` matches NULL==NULL so - // legacy rows without a project still resolve. + // repoA's row in place. The lookup MUST use the exact same + // COALESCE(source_project, '') semantics as the V19 unique index, which + // buckets NULL and '' together: a plain `IS ?3` lookup missed a legacy + // NULL-project row when the envelope carried Some(""), so the fall-through + // INSERT then hit the UNIQUE constraint on that very bucket. let source_project = env.source_project.clone(); let now = Utc::now(); @@ -9999,7 +10007,7 @@ impl SqliteMemoryStore { .query_row( "SELECT id, content_hash FROM knowledge_nodes \ WHERE source_system = ?1 AND source_id = ?2 \ - AND source_project IS ?3 LIMIT 1", + AND COALESCE(source_project, '') = COALESCE(?3, '') LIMIT 1", params![source_system, source_id, source_project], |row| Ok((row.get::<_, String>(0)?, row.get::<_, Option>(1)?)), ) @@ -10528,6 +10536,121 @@ mod tests { ); } + /// Build a `source_input` whose envelope carries an explicit project. + fn source_input_in_project( + id: &str, + content: &str, + hash: &str, + project: Option<&str>, + ) -> IngestInput { + let mut input = source_input(id, content, hash); + input.source_envelope.as_mut().unwrap().source_project = project.map(str::to_string); + input + } + + #[test] + fn upsert_by_source_scopes_key_by_project() { + // V19: two sources of the same system reuse bare per-project ids, so + // the same (system, id) under DIFFERENT projects must yield two + // distinct nodes, and re-syncing each must hit its own row (Unchanged). + let store = create_test_storage(); + + let a = store + .upsert_by_source(source_input_in_project( + "5", + "repoA issue 5", + "hA", + Some("octocat/repoA"), + )) + .unwrap(); + let b = store + .upsert_by_source(source_input_in_project( + "5", + "repoB issue 5", + "hB", + Some("octocat/repoB"), + )) + .unwrap(); + assert_eq!(a.outcome, SourceUpsertOutcome::Created); + assert_eq!(b.outcome, SourceUpsertOutcome::Created); + assert_ne!(a.node_id, b.node_id, "projects must not share a row"); + assert_eq!(node_count(&store), 2); + + // Re-sync both records with unchanged hashes → each resolves to ITS row. + let ra = store + .upsert_by_source(source_input_in_project( + "5", + "repoA issue 5", + "hA", + Some("octocat/repoA"), + )) + .unwrap(); + assert_eq!(ra.outcome, SourceUpsertOutcome::Unchanged); + assert_eq!(ra.node_id, a.node_id); + let rb = store + .upsert_by_source(source_input_in_project( + "5", + "repoB issue 5", + "hB", + Some("octocat/repoB"), + )) + .unwrap(); + assert_eq!(rb.outcome, SourceUpsertOutcome::Unchanged); + assert_eq!(rb.node_id, b.node_id); + assert_eq!(node_count(&store), 2, "resync must not duplicate"); + } + + #[test] + fn upsert_by_source_matches_legacy_null_project_row_with_empty_string() { + // Regression: the V19 unique index buckets NULL and '' together via + // COALESCE(source_project, ''), but the lookup used `source_project IS + // ?3`, which treats NULL and '' as distinct. A legacy NULL-project row + // plus an ''-project envelope for the same (system, id) made the lookup + // miss, and the fall-through INSERT then hit the UNIQUE constraint. + let store = create_test_storage(); + let created = store + .upsert_by_source(source_input("41", "legacy body", "h-legacy")) + .unwrap(); + assert_eq!(created.outcome, SourceUpsertOutcome::Created); + + // Simulate a pre-V19 legacy row: source_project stored as NULL. + { + let writer = store.writer.lock().unwrap(); + writer + .execute( + "UPDATE knowledge_nodes SET source_project = NULL WHERE id = ?1", + params![created.node_id], + ) + .unwrap(); + } + + // New connector run sends Some("") for the same (system, id). Must + // UPDATE the legacy row in place — not error on the unique index. + let res = store + .upsert_by_source(source_input_in_project( + "41", + "legacy body edited", + "h-new", + Some(""), + )) + .expect("''-project envelope must resolve the NULL-project row, not UNIQUE-fail"); + assert_eq!(res.outcome, SourceUpsertOutcome::Updated); + assert_eq!(res.node_id, created.node_id, "must reuse the legacy row"); + assert_eq!(node_count(&store), 1, "no duplicate row in the NULL bucket"); + + // And the Unchanged path resolves through the same bucket too. + let res2 = store + .upsert_by_source(source_input_in_project( + "41", + "legacy body edited", + "h-new", + Some(""), + )) + .unwrap(); + assert_eq!(res2.outcome, SourceUpsertOutcome::Unchanged); + assert_eq!(res2.node_id, created.node_id); + } + #[cfg(all(feature = "embeddings", feature = "vector-search"))] fn with_vector_search_disabled(f: impl FnOnce() -> T) -> T { let _guard = ENV_LOCK.lock().unwrap(); diff --git a/crates/vestige-core/src/storage/trace_store.rs b/crates/vestige-core/src/storage/trace_store.rs index fcac379..da9e31a 100644 --- a/crates/vestige-core/src/storage/trace_store.rs +++ b/crates/vestige-core/src/storage/trace_store.rs @@ -206,6 +206,55 @@ impl SqliteMemoryStore { }) } + /// Prune old Black Box trace events (bounded retention). + /// + /// The trace recorder appends rows on every MCP tool call, so without a + /// sweep `agent_traces` grows without bound. Called from the periodic + /// consolidation cycle (mirroring `prune_access_log`): deletes trace + /// events older than the retention window, then drops any `agent_runs` + /// roll-up left with no events (orphaned). + /// + /// Retention defaults to 30 days. `VESTIGE_TRACE_RETENTION_DAYS` overrides + /// it; `0` keeps traces forever (sweep disabled); unset, empty, negative, + /// or malformed values fall back to the default. Returns the number of + /// trace events deleted. + pub fn prune_agent_traces(&self) -> Result { + const DEFAULT_TRACE_RETENTION_DAYS: i64 = 30; + let days = std::env::var("VESTIGE_TRACE_RETENTION_DAYS") + .ok() + .and_then(|v| v.trim().parse::().ok()) + .filter(|d| *d >= 0) + .unwrap_or(DEFAULT_TRACE_RETENTION_DAYS); + self.prune_agent_traces_older_than_days(days) + } + + /// Env-independent core of [`Self::prune_agent_traces`], so tests can + /// exercise retention deterministically. `days == 0` means keep forever. + pub(crate) fn prune_agent_traces_older_than_days(&self, days: i64) -> Result { + if days == 0 { + return Ok(0); + } + let cutoff_ms = (Utc::now() - chrono::Duration::days(days)).timestamp_millis(); + let writer = self + .writer + .lock() + .map_err(|_| StorageError::Init("Writer lock poisoned".into()))?; + let deleted = writer.execute( + "DELETE FROM agent_traces WHERE at < ?1", + params![cutoff_ms], + )? as i64; + if deleted > 0 { + // Drop run roll-ups whose every event was just swept, so the Black + // Box run list never shows runs that can no longer be replayed. + writer.execute( + "DELETE FROM agent_runs + WHERE run_id NOT IN (SELECT DISTINCT run_id FROM agent_traces)", + [], + )?; + } + Ok(deleted) + } + // ======================================================================== // MEMORY RECEIPTS // ======================================================================== @@ -555,6 +604,44 @@ mod tests { assert_eq!(runs[0].run_id, run); } + #[test] + fn prune_agent_traces_sweeps_old_events_and_orphaned_runs() { + let s = store(); + let now_ms = Utc::now().timestamp_millis(); + let old_ms = now_ms - 40 * 24 * 60 * 60 * 1000; // 40 days ago + + s.append_trace_event(&MemoryTraceEvent::McpCall { + run_id: "run_old".into(), + tool: "search".into(), + args_hash: "h".into(), + at: old_ms, + }) + .unwrap(); + s.append_trace_event(&MemoryTraceEvent::McpCall { + run_id: "run_new".into(), + tool: "search".into(), + args_hash: "h".into(), + at: now_ms, + }) + .unwrap(); + + // 0 = keep forever: the sweep is disabled entirely. + assert_eq!(s.prune_agent_traces_older_than_days(0).unwrap(), 0); + assert_eq!(s.list_agent_runs(10).unwrap().len(), 2); + + // 30-day sweep: the old event goes, and its now-orphaned run roll-up + // goes with it; the fresh run is untouched. + let deleted = s.prune_agent_traces_older_than_days(30).unwrap(); + assert_eq!(deleted, 1, "exactly the 40-day-old event is deleted"); + assert!(s.get_trace("run_old").unwrap().is_empty()); + assert!( + s.get_agent_run("run_old").unwrap().is_none(), + "orphaned run roll-up must be swept with its events" + ); + assert_eq!(s.get_trace("run_new").unwrap().len(), 1); + assert!(s.get_agent_run("run_new").unwrap().is_some()); + } + #[test] fn receipt_roundtrips() { let s = store(); diff --git a/crates/vestige-mcp/src/server.rs b/crates/vestige-mcp/src/server.rs index b47d561..7445aad 100644 --- a/crates/vestige-mcp/src/server.rs +++ b/crates/vestige-mcp/src/server.rs @@ -68,6 +68,34 @@ fn supported_protocol_versions() -> &'static [&'static str] { &["2024-11-05", "2025-03-26", "2025-06-18", MCP_VERSION] } +/// Whether `VESTIGE_TRACE` enables Black Box trace recording. ON by default: +/// unset, empty, or any malformed value → true (fail-open to the documented +/// default); only an explicit `0` / `false` / `off` / `no` turns it off. +/// Parsed exactly like the sibling `VESTIGE_AUTO_CONSOLIDATE_MERGE` gate. +fn parse_trace_enabled(value: Option<&str>) -> bool { + match value { + Some(v) => { + let v = v.trim(); + !(v.eq_ignore_ascii_case("false") + || v.eq_ignore_ascii_case("off") + || v.eq_ignore_ascii_case("no") + || v == "0") + } + None => true, + } +} + +/// OPT-OUT (Black Box trace recorder): every MCP tool call writes trace rows +/// (`agent_traces`/`agent_runs`) to the user's DB. A consumer that does not +/// want per-call persistence can turn it off with `VESTIGE_TRACE=0` (or +/// false/off/no). Read ONCE per process (the recorder sits on the hot path of +/// every tool call), so flipping the env mid-process has no effect. +fn trace_enabled() -> bool { + static TRACE_ENABLED: std::sync::OnceLock = std::sync::OnceLock::new(); + *TRACE_ENABLED + .get_or_init(|| parse_trace_enabled(std::env::var("VESTIGE_TRACE").ok().as_deref())) +} + /// MCP Server implementation pub struct McpServer { storage: Arc, @@ -476,13 +504,15 @@ impl McpServer { // can attach the downstream memory events (retrieve/suppress/veto) to the // same run. let trace_run_id = crate::trace_recorder::run_id_for(&saved_args); - crate::trace_recorder::record_call( - &self.storage, - self.event_tx.as_ref(), - &trace_run_id, - &request.name, - &saved_args, - ); + if trace_enabled() { + crate::trace_recorder::record_call( + &self.storage, + self.event_tx.as_ref(), + &trace_run_id, + &request.name, + &saved_args, + ); + } let result = match request.name.as_str() { // ================================================================ @@ -1146,13 +1176,15 @@ impl McpServer { // downstream memory events (retrieve/suppress/veto/dream) under the // same run_id as the opening mcp.call, so /api/traces, /api/receipts // and the trace:// resource are actually populated. - crate::trace_recorder::record_result( - &self.storage, - self.event_tx.as_ref(), - &trace_run_id, - &request.name, - content, - ); + if trace_enabled() { + crate::trace_recorder::record_result( + &self.storage, + self.event_tx.as_ref(), + &trace_run_id, + &request.name, + content, + ); + } } let response = match result { @@ -1749,6 +1781,26 @@ mod tests { }) } + // ======================================================================== + // TRACE GATE TESTS + // ======================================================================== + + #[test] + fn test_parse_trace_enabled_defaults_on_and_honors_opt_out() { + // Default ON: unset, empty, or malformed values all fail open. + assert!(parse_trace_enabled(None)); + assert!(parse_trace_enabled(Some(""))); + assert!(parse_trace_enabled(Some("1"))); + assert!(parse_trace_enabled(Some("true"))); + assert!(parse_trace_enabled(Some("banana"))); + // Explicit opt-out only: 0/false/off/no (case-insensitive, trimmed). + assert!(!parse_trace_enabled(Some("0"))); + assert!(!parse_trace_enabled(Some("false"))); + assert!(!parse_trace_enabled(Some("FALSE"))); + assert!(!parse_trace_enabled(Some("OFF"))); + assert!(!parse_trace_enabled(Some(" no "))); + } + // ======================================================================== // INITIALIZATION TESTS // ======================================================================== From e7ae768a1c2fdc565de5d0182786c16f4490c718 Mon Sep 17 00:00:00 2001 From: Sam Valladares Date: Sat, 25 Jul 2026 21:58:03 +0800 Subject: [PATCH 4/8] fix(dashboard): WebGPU adapter-failure fallback + honest page copy - /graph now falls back to the Classic renderer when requestAdapter/ requestDevice fails (previously a dead 'MEMORY FIELD OFFLINE' panel whose link pointed back at the same page) - /duplicates: remove the no-op 'Merge all' button; copy now points at the MCP dedup tool, which actually performs reversible merges - /contradictions: stat card now reports the memories actually analyzed Co-Authored-By: Claude Opus 5 --- .../lib/components/DuplicateCluster.svelte | 27 +++++++++++-------- .../lib/components/ObservatoryCanvas.svelte | 16 ++++++++--- .../lib/observatory/ObservatoryStage.svelte | 21 +++++++++++++-- .../routes/(app)/contradictions/+page.svelte | 15 +++++++---- .../src/routes/(app)/duplicates/+page.svelte | 17 ++++-------- .../src/routes/(app)/graph/+page.svelte | 19 +++++++++++++ 6 files changed, 82 insertions(+), 33 deletions(-) diff --git a/apps/dashboard/src/lib/components/DuplicateCluster.svelte b/apps/dashboard/src/lib/components/DuplicateCluster.svelte index f414ec5..98802f2 100644 --- a/apps/dashboard/src/lib/components/DuplicateCluster.svelte +++ b/apps/dashboard/src/lib/components/DuplicateCluster.svelte @@ -2,7 +2,9 @@ DuplicateCluster — renders a single cosine-similarity cluster from the `find_duplicates` MCP tool. Shows similarity bar (color-coded by severity), stacked memory cards with type/retention/tags/date, and action controls - (Merge all → highest-retention winner, Review → expand, Dismiss → hide). + (Review → expand, Dismiss → hide). The "Merge all → winner" button renders + ONLY when the host wires `onMerge` to a real merge action — today the merge + itself lives in the MCP `dedup` tool, so the dashboard page omits it. Pure helpers live in `./duplicates-helpers.ts` and are unit-tested there. Keep this file focused on rendering + glue. @@ -160,17 +162,20 @@ {/each} - +
- + {#if onMerge} + + {/if}
{/if} diff --git a/apps/dashboard/src/lib/observatory/ObservatoryStage.svelte b/apps/dashboard/src/lib/observatory/ObservatoryStage.svelte index 0c1fda4..bbe556c 100644 --- a/apps/dashboard/src/lib/observatory/ObservatoryStage.svelte +++ b/apps/dashboard/src/lib/observatory/ObservatoryStage.svelte @@ -20,7 +20,7 @@ import RescueVerdict from '$lib/observatory/overlays/RescueVerdict.svelte'; import ObservatoryCanvas from '$lib/components/ObservatoryCanvas.svelte'; import { DEMO_MODES, type DemoMode } from '$lib/observatory/types'; - import type { ObservatoryEngine } from '$lib/observatory/engine'; + import type { EngineStatus, ObservatoryEngine } from '$lib/observatory/engine'; import { NodeRenderer } from '$lib/observatory/node-renderer'; import { BirthRenderer } from '$lib/observatory/birth-renderer'; import { RescueRenderer } from '$lib/observatory/rescue-renderer'; @@ -62,6 +62,14 @@ * back with its memory id (the host opens its inspector panel). */ onpick?: (memoryId: string) => void; + /** + * Fired when the WebGPU engine cannot run — `'gpu' in navigator` was + * true but requestAdapter() returned null, requestDevice() threw, or + * the device was lost (Linux/VM/blocklisted GPU). Hosts with a + * non-WebGPU renderer switch to it instead of stranding the user on + * the offline panel. + */ + onfallback?: (reason: string) => void; } let { @@ -74,7 +82,8 @@ onexit, embedded = false, chrome = 'full', - onpick + onpick, + onfallback }: Props = $props(); // GPU picking — screen px → NDC → NodeRenderer.pickAt (one readback/click). @@ -169,6 +178,13 @@ renderer = new NodeRenderer(e); } + // Adapter/device boot failure or a lost device — let the host swap to a + // renderer that works here (the graph page flips to the classic Three.js + // inspector) instead of leaving a dead field on screen. + function handleStatus(s: EngineStatus) { + if (s.state === 'unsupported' || s.state === 'error') onfallback?.(s.reason); + } + // Upload the memory field once the engine AND the graph are both ready. $effect(() => { if (engine && renderer && graphData && !uploaded) { @@ -299,6 +315,7 @@ {freezeFrame} onframe={handleFrame} onready={handleReady} + onstatus={handleStatus} /> diff --git a/apps/dashboard/src/routes/(app)/contradictions/+page.svelte b/apps/dashboard/src/routes/(app)/contradictions/+page.svelte index 5373045..4569c47 100644 --- a/apps/dashboard/src/routes/(app)/contradictions/+page.svelte +++ b/apps/dashboard/src/routes/(app)/contradictions/+page.svelte @@ -11,7 +11,6 @@ severityColor, severityLabel, truncate, - uniqueMemoryCount, avgTrustDelta as avgTrustDeltaFn, } from '$components/contradiction-helpers'; @@ -22,6 +21,10 @@ // System-wide count from the backend, vs. the derived stats below which // reflect only the pairs the page holds. let totalDetected = $state(0); + // How many memories the backend actually scanned — it only analyzes the + // most recent `limit` memories, so the stat copy must say so instead of + // implying a whole-corpus sweep. + let memoriesAnalyzed = $state(0); let loading = $state(true); let error = $state(null); @@ -32,10 +35,12 @@ const res = await api.contradictions(); contradictions = res.contradictions; totalDetected = res.total; + memoriesAnalyzed = res.memoriesAnalyzed; } catch (e) { error = e instanceof Error ? e.message : 'Failed to load contradictions'; contradictions = []; totalDetected = 0; + memoriesAnalyzed = 0; } finally { loading = false; } @@ -114,10 +119,10 @@ focusedPairIndex = i; } - // --- Stats. `totalDetected` is the backend's system-wide count; everything - // else is derived from the pairs the page actually holds so the numbers are + // --- Stats. `totalDetected` + `memoriesAnalyzed` come from the backend + // (which scans only the most recent `limit` memories); everything else is + // derived from the pairs the page actually holds so the numbers are // self-consistent with what the user sees. --- - const totalMemoriesInvolved = $derived(uniqueMemoryCount(contradictions)); const avgTrustDelta = $derived(avgTrustDeltaFn(contradictions)); // Map filtered index -> original index in `contradictions` so the @@ -194,7 +199,7 @@
- contradictions across {totalMemoriesInvolved.toLocaleString()} memories + contradictions among the {memoriesAnalyzed.toLocaleString()} most recent memories
diff --git a/apps/dashboard/src/routes/(app)/duplicates/+page.svelte b/apps/dashboard/src/routes/(app)/duplicates/+page.svelte index 05f6d98..b1eb331 100644 --- a/apps/dashboard/src/routes/(app)/duplicates/+page.svelte +++ b/apps/dashboard/src/routes/(app)/duplicates/+page.svelte @@ -2,8 +2,10 @@ Memory Hygiene — Duplicate Detection Dashboard exposure of the `find_duplicates` MCP tool. Threshold slider (0.70-0.95) reruns cosine-similarity clustering. Each cluster renders as a - DuplicateCluster with similarity bar, stacked memory cards, and merge / - review / dismiss actions. + DuplicateCluster with similarity bar, stacked memory cards, and review / + dismiss actions. This page is inspection-only: the actual merge lives in + the MCP `dedup` tool, so no onMerge is wired here (the merge button only + renders when a host provides a real merge action). -->