From 89b0b35665b091dc0f35969f01ea27febc0ffb93 Mon Sep 17 00:00:00 2001 From: Sam Valladares Date: Sat, 25 Jul 2026 21:40:28 +0800 Subject: [PATCH] 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('');