Prepare agent-neutral hardening release

This commit is contained in:
Sam Valladares 2026-05-24 16:09:44 -05:00
parent 9936928be9
commit 7eba0b1e97
117 changed files with 3679 additions and 513 deletions

View file

@ -50,14 +50,80 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.tag || github.ref }}
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Validate release version
shell: bash
env:
RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }}
run: |
node <<'NODE'
const { execFileSync } = require('node:child_process');
const tag = process.env.RELEASE_TAG || '';
const expected = tag.replace(/^refs\/tags\//, '').replace(/^v/, '');
if (!expected) {
throw new Error('Release tag is empty');
}
const packageFiles = [
'package.json',
'apps/dashboard/package.json',
'packages/vestige-init/package.json',
'packages/vestige-mcp-npm/package.json'
];
for (const file of packageFiles) {
const actual = require(`./${file}`).version;
if (actual !== expected) {
throw new Error(`${file} version ${actual} does not match ${tag}`);
}
}
const metadata = JSON.parse(execFileSync('cargo', [
'metadata',
'--format-version',
'1',
'--locked',
'--no-deps'
], { encoding: 'utf8' }));
for (const name of ['vestige-core', 'vestige-mcp']) {
const pkg = metadata.packages.find((candidate) => candidate.name === name);
if (!pkg) throw new Error(`Missing Cargo package ${name}`);
if (pkg.version !== expected) {
throw new Error(`${name} version ${pkg.version} does not match ${tag}`);
}
}
NODE
- name: Build embedded dashboard
shell: bash
run: |
pnpm install --frozen-lockfile
pnpm --filter @vestige/dashboard check
pnpm --filter @vestige/dashboard test
pnpm --filter @vestige/dashboard build
if [ -n "$(git status --porcelain -- apps/dashboard/build)" ]; then
git status --short -- apps/dashboard/build
exit 1
fi
- name: Build
run: cargo build --package vestige-mcp --release --target ${{ matrix.target }} ${{ matrix.cargo_flags }}
run: cargo build --locked --package vestige-mcp --release --target ${{ matrix.target }} ${{ matrix.cargo_flags }}
- name: Package (Unix)
if: matrix.os != 'windows-latest'
@ -77,10 +143,21 @@ jobs:
cd target/${{ matrix.target }}/release
Compress-Archive -Path vestige-mcp.exe,vestige.exe,vestige-restore.exe -DestinationPath ../../../vestige-mcp-${{ matrix.target }}.zip
- name: Generate checksum
shell: bash
run: |
if command -v shasum >/dev/null 2>&1; then
shasum -a 256 vestige-mcp-${{ matrix.target }}.${{ matrix.archive }} > vestige-mcp-${{ matrix.target }}.${{ matrix.archive }}.sha256
else
sha256sum vestige-mcp-${{ matrix.target }}.${{ matrix.archive }} > vestige-mcp-${{ matrix.target }}.${{ matrix.archive }}.sha256
fi
- name: Upload to Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.event.inputs.tag || github.ref_name }}
files: vestige-mcp-${{ matrix.target }}.${{ matrix.archive }}
files: |
vestige-mcp-${{ matrix.target }}.${{ matrix.archive }}
vestige-mcp-${{ matrix.target }}.${{ matrix.archive }}.sha256
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -12,6 +12,19 @@ env:
VESTIGE_TEST_MOCK_EMBEDDINGS: "1"
jobs:
hook-tests:
name: Hook Tests
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.9"
- run: python3 -m unittest discover -s tests/hooks -p 'test_*.py'
- run: python3 -m py_compile hooks/sanhedrin-local.py tests/hooks/test_sanhedrin_claim_mode.py
- run: bash -n hooks/sanhedrin.sh scripts/install-sandwich.sh scripts/check-sandwich-prereqs.sh
unit-tests:
name: Unit Tests
runs-on: ubuntu-latest

View file

@ -7,6 +7,70 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [2.1.21] - 2026-05-24 — "Agent-Neutral Hardening"
v2.1.21 is a release-hardening pass for normal MCP usage across agents. It keeps
Claude Code Cognitive Sandwich companion files optional while making the MCP
server, package installer, release workflow, and portable sync path safer.
### Added
- **Agent-neutral memory protocol** — new `docs/AGENT-MEMORY-PROTOCOL.md` gives
any MCP-compatible client the same practical memory loop: initialize context,
search/deep-reference when needed, save durable facts with `smart_ingest`, and
promote/demote/purge with `memory`.
- **HTTP transport opt-in**`vestige-mcp` now requires `--http`,
`--http-port`, or `VESTIGE_HTTP_ENABLED=1` before starting MCP-over-HTTP.
- **Release checksums** — release assets now publish `.sha256` files beside each
archive.
### Changed
- **`vestige update` is binary-only by default** — Claude Code Cognitive
Sandwich companion files refresh only with `vestige update --sandwich-companion`
or `vestige sandwich install`.
- **MCP tool results include structured content** while keeping text content for
clients that only consume the classic MCP response shape.
- **NPM install messaging is agent-neutral** and unsupported release targets
fail fast instead of trying to download assets that do not exist.
- **Portable merge uses UPSERT instead of `INSERT OR REPLACE`** for keyed tables,
preserving related rows instead of causing delete-and-insert side effects.
### Fixed
- **Destructive delete confirmation**`memory(action="delete")` now requires
`confirm=true`, matching `purge`; the deprecated `delete_knowledge` shim no
longer bypasses confirmation.
- **Portable purge tombstone sync** — merge imports now carry
`deletion_tombstones` and apply purges without retaining deleted memory text.
Hard purge tombstones win over newer local edits during portable sync, while
tombstone merges keep the newest deletion timestamp.
- **Vector index reload staleness** — loading persisted embeddings rebuilds the
in-memory index from an empty index before adding current embeddings.
- **HTTP transport hardening** — origin, Accept, session, and protocol-version
validation now reject incompatible or cross-origin browser requests earlier.
- **Init config safety**`@vestige/init` backs up existing config files, writes
atomically, accepts JSONC-style comments/trailing commas, and no longer writes
Xcode trust-accepted flags.
- **Release tag checkout** — manual release builds now checkout the requested tag
or ref before packaging.
### Verified
- `cargo test -p vestige-mcp --lib --no-fail-fast`
- `cargo test -p vestige-mcp --bin vestige-mcp --no-fail-fast`
- `cargo test -p vestige-core portable_merge_import --no-fail-fast`
- `cargo test -p vestige-mcp --bin vestige --no-fail-fast`
- `cargo test -p vestige-e2e-tests --test mcp_protocol --no-fail-fast`
- `cargo check --workspace`
- `cargo metadata --format-version 1 --locked --no-deps`
- `pnpm --filter @vestige/dashboard check`
- `pnpm --filter @vestige/dashboard test`
- `pnpm --filter @vestige/dashboard build`
- `node --check packages/vestige-init/bin/init.js`
- `node --check packages/vestige-mcp-npm/scripts/postinstall.js`
- `node --check packages/vestige-mcp-npm/bin/vestige-restore.js`
## [2.1.2] - 2026-05-01 — "Honest Memory"
v2.1.2 focuses on operational trust: exact search stays exact, purge really removes content, contradictions are directly inspectable, and the update flow no longer depends on copied curl commands.

View file

@ -88,7 +88,7 @@ Tags: ["decision", "topic-name"]
| "Don't forget" | `smart_ingest` with tags: ["important"] |
| "I always..." / "I never..." | Save as preference |
| "I prefer..." / "I like..." | Save as preference |
| "This is important" | `smart_ingest` + `promote_memory` |
| "This is important" | `smart_ingest` + `memory(action="promote")` |
| "Remind me..." | Create `intention` with trigger |
| "Next time we..." | Create `intention` with context trigger |
| "When I'm working on X..." | Create `intention` with codebase trigger |
@ -115,7 +115,7 @@ Act on feedback immediately — don't ask permission to promote/demote.
### Proactive Health Checks
If you notice degraded recall or a user mentions memory issues:
1. Run `health_check` — check overall system status
1. Run `system_status` — check overall system status
2. If `averageRetention < 0.5` → suggest running `consolidate`
3. If `dueForReview > 50` → mention that some memories need review

4
Cargo.lock generated
View file

@ -4531,7 +4531,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "vestige-core"
version = "2.1.2"
version = "2.1.21"
dependencies = [
"candle-core",
"chrono",
@ -4567,7 +4567,7 @@ dependencies = [
[[package]]
name = "vestige-mcp"
version = "2.1.2"
version = "2.1.21"
dependencies = [
"anyhow",
"axum",

View file

@ -10,7 +10,7 @@ exclude = [
]
[workspace.package]
version = "2.1.2"
version = "2.1.21"
edition = "2024"
license = "AGPL-3.0-only"
repository = "https://github.com/samvallad33/vestige"

View file

@ -2,24 +2,35 @@
# Vestige
### The cognitive engine that gives AI agents a brain.
### Local cognitive memory for MCP-compatible AI agents.
[![GitHub stars](https://img.shields.io/github/stars/samvallad33/vestige?style=social)](https://github.com/samvallad33/vestige)
[![Release](https://img.shields.io/github/v/release/samvallad33/vestige)](https://github.com/samvallad33/vestige/releases/latest)
[![Tests](https://img.shields.io/badge/tests-1229%20passing-brightgreen)](https://github.com/samvallad33/vestige/actions)
[![Tests](https://img.shields.io/badge/tests-passing-brightgreen)](https://github.com/samvallad33/vestige/actions)
[![License](https://img.shields.io/badge/license-AGPL--3.0-blue)](LICENSE)
[![MCP Compatible](https://img.shields.io/badge/MCP-compatible-green)](https://modelcontextprotocol.io)
**Your Agent forgets everything between sessions. Vestige fixes that.**
**Your agent forgets project decisions between sessions. Vestige gives it local, inspectable memory.**
Built on 130 years of memory research — FSRS-6 spaced repetition, prediction error gating, synaptic tagging, spreading activation, memory dreaming — all running in a single Rust binary with a 3D neural visualization dashboard. 100% local. Zero cloud.
Built on proven memory and retrieval ideas — FSRS-6 spaced repetition, prediction error gating, synaptic tagging, spreading activation, and memory consolidation — all running in a single Rust binary with a local dashboard. 100% local. Zero cloud.
[Quick Start](#quick-start) | [Dashboard](#-3d-memory-dashboard) | [How It Works](#-the-cognitive-science-stack) | [Tools](#-24-mcp-tools) | [Docs](docs/)
[Quick Start](#quick-start) | [Dashboard](#-3d-memory-dashboard) | [How It Works](#-the-cognitive-science-stack) | [Tools](#-25-mcp-tools) | [Docs](docs/)
</div>
---
## What's New in v2.1.21 "Agent-Neutral Hardening"
v2.1.21 tightens Vestige for normal use across MCP-compatible agents, without
making Claude Code companion tooling part of the default path.
- **Agent-neutral default.** Stdio MCP remains the default transport; optional HTTP MCP is explicit with `--http`, `--http-port`, or `VESTIGE_HTTP_ENABLED=1`.
- **Safer destructive actions.** `memory(action="delete")` now requires `confirm=true`, matching `purge`, and the legacy `delete_knowledge` shim forwards that confirmation instead of bypassing it.
- **Portable sync repair.** Merge imports preserve purge tombstones, avoid `INSERT OR REPLACE` cascades, rebuild the vector index from a clean state, and write portable archive temp files with private Unix permissions.
- **Release/package cleanup.** Release builds check the embedded dashboard before packaging, publish checksums, and the npm installer rejects targets that do not have release assets.
- **Any-agent memory protocol.** The setup docs now include a short agent-agnostic memory protocol for Claude Code, Codex, Cursor, VS Code, Xcode, JetBrains, Windsurf, and other MCP clients.
## What's New in v2.1.2 "Honest Memory"
v2.1.2 makes Vestige easier to trust in everyday work: literal lookups stay literal, purge really removes content, contradictions are inspectable, and updates no longer require a curl reinstall flow.
@ -27,7 +38,7 @@ v2.1.2 makes Vestige easier to trust in everyday work: literal lookups stay lite
- **Concrete search mode.** Quoted strings, env vars, UUIDs, paths, and code identifiers now take a keyword/literal path that skips HyDE, semantic fusion, FSRS reweighting, competition, and spreading activation. Exact things like `OPENAI_API_KEY`, `mlx_lm.server`, and migration IDs land first.
- **Irreversible purge.** `memory(action="purge", confirm=true)` permanently removes memory content and embeddings, scrubs insight JSON references, detaches temporal-summary children, prunes graph edges, and keeps only a non-content deletion tombstone for sync/audit.
- **First-class contradiction inspection.** New `contradictions` MCP tool surfaces trust-weighted disagreements directly instead of hiding them inside `deep_reference`.
- **Simple update flow.** `vestige update` and `vestige sandwich install` refresh binaries and companion files without making users paste curl commands.
- **Simple update flow.** `vestige update` refreshes binaries. Claude Code Cognitive Sandwich companion files are opt-in with `vestige update --sandwich-companion` or `vestige sandwich install`.
- **Pro waitlist preview.** `/dashboard/waitlist` adds a local-first Solo Pro and Team Pro early-access surface. `VITE_WAITLIST_ENDPOINT` and `VITE_SUPPORT_BOT_ENDPOINT` are opt-in dashboard env vars, so no signup data is captured unless endpoints are configured.
## What's New in v2.1.1 "Portable Sync"
@ -116,10 +127,11 @@ Based on [Anderson et al. 2025](https://www.nature.com/articles/s41583-025-00929
# 1. Install
npm install -g vestige-mcp-server@latest
# 2. Connect to Claude Code
# 2. Connect to any MCP-compatible agent
# Claude Code
claude mcp add vestige vestige-mcp -s user
# Or connect to Codex
# Codex
codex mcp add vestige -- vestige-mcp
# 3. Test it
@ -137,9 +149,9 @@ codex mcp add vestige -- vestige-mcp
vestige update
```
`vestige update` updates the binaries and refreshes Cognitive Sandwich companion
files while keeping every hook layer disabled by default. Use
`vestige update --no-sandwich` if you only want the binaries.
`vestige update` updates only the Vestige binaries by default. Use
`vestige update --sandwich-companion` if you also want to refresh optional Claude
Code Cognitive Sandwich companion files.
**macOS/Linux manual binary install:**
```bash
@ -179,7 +191,7 @@ Open `%APPDATA%\Claude\claude_desktop_config.json` and point Claude Desktop at t
}
```
If Claude Desktop cannot find `vestige-mcp`, run `where vestige-mcp` in PowerShell and use the exact `.cmd` path it prints as `command`. Example: `"C:\\Users\\you\\AppData\\Roaming\\npm\\vestige-mcp.cmd"`. Reopen Claude Desktop after saving. Future binary and companion-file updates can run with `vestige update`.
If Claude Desktop cannot find `vestige-mcp`, run `where vestige-mcp` in PowerShell and use the exact `.cmd` path it prints as `command`. Example: `"C:\\Users\\you\\AppData\\Roaming\\npm\\vestige-mcp.cmd"`. Reopen Claude Desktop after saving. Future binary updates use `vestige update`; optional Claude Code companion files require `vestige update --sandwich-companion`.
**Windows source build:** 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:
@ -206,7 +218,7 @@ cargo build --release -p vestige-mcp --features metal
## Works Everywhere
Vestige speaks MCP — the universal protocol for AI tools. One brain, every IDE.
Vestige speaks MCP, so any client that can register a stdio MCP server can use it.
| IDE | Setup |
|-----|-------|
@ -379,16 +391,9 @@ This isn't a key-value store with an embedding model bolted on. Vestige implemen
## Make Your AI Use Vestige Automatically
Add this to your `CLAUDE.md`:
```markdown
## Memory
At the start of every session:
1. Search Vestige for user preferences and project context
2. Save bug fixes, decisions, and patterns without being asked
3. Create reminders when the user mentions deadlines
```
Registering the MCP server exposes tools; the agent still needs an instruction
that tells it when to call memory. Use the agent-neutral protocol, then adapt it
to your client-specific instruction file.
| You Say | AI Does |
|---------|---------|
@ -397,7 +402,7 @@ At the start of every session:
| "Remind me..." | Creates a future trigger |
| "This is important" | Saves + promotes |
[Full CLAUDE.md templates ->](docs/CLAUDE-SETUP.md)
[Agent memory protocol ->](docs/AGENT-MEMORY-PROTOCOL.md) · [Claude Code template ->](docs/CLAUDE-SETUP.md)
---
@ -406,7 +411,7 @@ At the start of every session:
| Metric | Value |
|--------|-------|
| **Language** | Rust 2024 edition (MSRV 1.91) |
| **Codebase** | 80,000+ lines, 1,292 tests (366 core + 425 mcp + 497 e2e + 4 doctests) |
| **Codebase** | 80,000+ lines with Rust core/MCP/e2e, dashboard, and hook coverage |
| **Binary size** | ~20MB |
| **Embeddings** | Nomic Embed Text v1.5 by default (768d -> 256d Matryoshka, 8192 context); Qwen3 0.6B optional |
| **Vector search** | USearch HNSW (20x faster than FAISS) |
@ -481,7 +486,7 @@ First run downloads ~130MB from Hugging Face. If behind a proxy:
export HTTPS_PROXY=your-proxy:port
```
Cache: macOS `~/Library/Caches/com.vestige.core/fastembed` | Linux `~/.cache/vestige/fastembed`
Cache: platform user cache directory first, then `./.fastembed_cache` as a fallback. Override with `FASTEMBED_CACHE_PATH`.
</details>
<details>

View file

@ -4,7 +4,8 @@
| Version | Supported |
| ------- | ------------------ |
| 2.0.x | :white_check_mark: |
| 2.1.x | :white_check_mark: |
| 2.0.x | Critical fixes only |
| 1.x | :x: |
## Reporting a Vulnerability
@ -27,13 +28,13 @@ You can expect a response within 48 hours.
Vestige is a **local MCP server** designed to run on your machine with your user permissions:
- **Trusted**: The MCP client (Claude Code/Desktop) that connects via stdio
- **Trusted**: The MCP client or local agent that connects via stdio
- **Untrusted**: Content passed through MCP tool arguments (validated before use)
### What Vestige Does NOT Do
- ❌ Make network requests (except first-run model download from Hugging Face)
- ❌ Execute shell commands
- ❌ Make network requests during normal memory use, except first-run model download from Hugging Face
- ❌ Require telemetry, hosted memory storage, or a cloud account
- ❌ Access files outside its data directory
- ❌ Send telemetry or analytics
- ❌ Phone home to any server

View file

@ -1,6 +1,6 @@
---
name: executioner
description: Optional Sanhedrin fallback verifier. Decomposes a draft into atomic claims, checks high-trust Vestige evidence, and returns a one-line pass/veto verdict.
description: Optional Sanhedrin fallback verifier. Decomposes a draft into check-worthy claims, checks high-trust durable Vestige evidence, and returns a pass/veto verdict.
tools: mcp__vestige__deep_reference, mcp__vestige__memory, mcp__vestige__search
model: claude-haiku-4-5-20251001
---
@ -11,9 +11,9 @@ You are a one-turn verifier. You do not converse. You return exactly one line.
# Job
Decompose the draft response into atomic claims, verify each claim against
high-trust Vestige memory when available, and veto only when the draft
contradicts memory or makes a sensitive user-specific assertion without
Decompose the draft response into check-worthy claims, verify each claim against
high-trust durable Vestige memory when available, and veto only when the draft
contradicts memory or makes a sensitive user-specific assertion without durable
supporting evidence.
# Claim Classes
@ -24,18 +24,22 @@ Check all relevant classes:
2. `BIOGRAPHICAL` — identity, role, location, employment, education.
3. `FINANCIAL` — costs, revenue, pricing, funding, prizes.
4. `ACHIEVEMENT` — releases, rankings, completions, scores, milestones.
5. `TEMPORAL` — dates, durations, ordering, deadlines.
5. `TIMELINE` — dates, durations, ordering, deadlines.
6. `QUANTITATIVE` — counts, percentages, metrics, measurements.
7. `ATTRIBUTION` — who said, decided, agreed, shipped, or committed.
8. `CAUSAL` — claimed causes and effects.
9. `COMPARATIVE` — better, most, fastest, more than, fewer than.
10. `EXISTENTIAL` — whether a file, feature, repo, or artifact exists.
11. `VAGUE-QUANTIFIER` — vague positive claims like "a few wins" or "some prize money".
# Decision Rules
- Veto direct contradiction with high-trust memory.
- Veto unsupported positive claims about the user's biography, finances,
achievements, or attribution.
achievements, timeline, quantitative results, attribution, or vague
positive outcomes.
- Treat staged/current-turn evidence as context only. It is not durable memory and
cannot satisfy the durable-evidence requirement.
- Do not veto purely stylistic disagreement.
- Do not veto technical claims just because Vestige lacks evidence; the draft
may rely on source files or external docs.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
import{a as r}from"../chunks/BTwePnbx.js";import{w as t}from"../chunks/BdslOLCg.js";export{t as load_css,r as start};

View file

@ -1 +0,0 @@
import{a as r}from"../chunks/BHGLDPij.js";import{w as t}from"../chunks/BskPcZf7.js";export{t as load_css,r as start};

View file

@ -1 +1 @@
import"../chunks/Bzak7iHL.js";import{i as h}from"../chunks/BUoSzNdg.js";import{p as g,f as d,t as l,a as v,d as s,r as o,e as _}from"../chunks/CpWkWWOo.js";import{s as p}from"../chunks/BlVfL1ME.js";import{a as x,f as $}from"../chunks/CHOnp4oo.js";import{p as m}from"../chunks/BskPcZf7.js";import{s as k}from"../chunks/BHGLDPij.js";const b={get error(){return m.error},get status(){return m.status}};k.updated.check;const i=b;var E=$("<h1> </h1> <p> </p>",1);function C(f,n){g(n,!1),h();var t=E(),r=d(t),c=s(r,!0);o(r);var a=_(r,2),u=s(a,!0);o(a),l(()=>{var e;p(c,i.status),p(u,(e=i.error)==null?void 0:e.message)}),x(f,t),v()}export{C as component};
import"../chunks/Bzak7iHL.js";import{i as h}from"../chunks/BUoSzNdg.js";import{p as g,f as d,t as l,a as v,d as s,r as o,e as _}from"../chunks/CpWkWWOo.js";import{s as p}from"../chunks/BlVfL1ME.js";import{a as x,f as $}from"../chunks/CHOnp4oo.js";import{p as m}from"../chunks/BdslOLCg.js";import{s as k}from"../chunks/BTwePnbx.js";const b={get error(){return m.error},get status(){return m.status}};k.updated.check;const i=b;var E=$("<h1> </h1> <p> </p>",1);function C(f,n){g(n,!1),h();var t=E(),r=d(t),c=s(r,!0);o(r);var a=_(r,2),u=s(a,!0);o(a),l(()=>{var e;p(c,i.status),p(u,(e=i.error)==null?void 0:e.message)}),x(f,t),v()}export{C as component};

View file

@ -1,4 +1,4 @@
var Bc=Object.defineProperty;var zc=(i,t,e)=>t in i?Bc(i,t,{enumerable:!0,configurable:!0,writable:!0,value:e}):i[t]=e;var kt=(i,t,e)=>zc(i,typeof t!="symbol"?t+"":t,e);import"../chunks/Bzak7iHL.js";import{o as jl,a as Zl}from"../chunks/GG5zm9kr.js";import{s as me,c as va,h as zt,g as B,p as ys,aB as kc,a as Es,d as yt,e as bt,n as Hc,r as xt,t as Ke,u as Gn,f as Kl,j as Vc}from"../chunks/CpWkWWOo.js";import{s as fe,d as $l,a as Fe}from"../chunks/BlVfL1ME.js";import{i as kn}from"../chunks/B4yTwGkE.js";import{e as _s,i as hr}from"../chunks/CGEBXrjl.js";import{a as _e,f as Se,c as Gc}from"../chunks/CHOnp4oo.js";import{s as ve,r as xa}from"../chunks/A7po6GxK.js";import{s as Us}from"../chunks/aVbAZ-t7.js";import{s as Sr}from"../chunks/Cx-f-Pzo.js";import{b as Ma}from"../chunks/sZcqyNBA.js";import{b as Jl}from"../chunks/BnXDGOmJ.js";import{s as Wc,a as Xc}from"../chunks/C6HuKgyx.js";import{b as Do}from"../chunks/BskPcZf7.js";import{b as Yc}from"../chunks/CJsMJEun.js";import{p as vs}from"../chunks/V6gjw5Ec.js";import{N as Sa}from"../chunks/DzfRjky4.js";import{i as qc}from"../chunks/BUoSzNdg.js";import{a as gi}from"../chunks/DNjM5a-l.js";import{e as jc}from"../chunks/MAY1QfFZ.js";/**
var Bc=Object.defineProperty;var zc=(i,t,e)=>t in i?Bc(i,t,{enumerable:!0,configurable:!0,writable:!0,value:e}):i[t]=e;var kt=(i,t,e)=>zc(i,typeof t!="symbol"?t+"":t,e);import"../chunks/Bzak7iHL.js";import{o as jl,a as Zl}from"../chunks/GG5zm9kr.js";import{s as me,c as va,h as zt,g as B,p as ys,aB as kc,a as Es,d as yt,e as bt,n as Hc,r as xt,t as Ke,u as Gn,f as Kl,j as Vc}from"../chunks/CpWkWWOo.js";import{s as fe,d as $l,a as Fe}from"../chunks/BlVfL1ME.js";import{i as kn}from"../chunks/B4yTwGkE.js";import{e as _s,i as hr}from"../chunks/CGEBXrjl.js";import{a as _e,f as Se,c as Gc}from"../chunks/CHOnp4oo.js";import{s as ve,r as xa}from"../chunks/A7po6GxK.js";import{s as Us}from"../chunks/aVbAZ-t7.js";import{s as Sr}from"../chunks/Cx-f-Pzo.js";import{b as Ma}from"../chunks/sZcqyNBA.js";import{b as Jl}from"../chunks/BnXDGOmJ.js";import{s as Wc,a as Xc}from"../chunks/C6HuKgyx.js";import{b as Do}from"../chunks/BdslOLCg.js";import{b as Yc}from"../chunks/CJsMJEun.js";import{p as vs}from"../chunks/V6gjw5Ec.js";import{N as Sa}from"../chunks/DzfRjky4.js";import{i as qc}from"../chunks/BUoSzNdg.js";import{a as gi}from"../chunks/DNjM5a-l.js";import{e as jc}from"../chunks/MAY1QfFZ.js";/**
* @license
* Copyright 2010-2024 Three.js Authors
* SPDX-License-Identifier: MIT

View file

@ -1 +1 @@
import"../chunks/Bzak7iHL.js";import{i as p}from"../chunks/BUoSzNdg.js";import{o as r}from"../chunks/GG5zm9kr.js";import{p as t,a}from"../chunks/CpWkWWOo.js";import{g as m}from"../chunks/BHGLDPij.js";function g(i,o){t(o,!1),r(()=>m("/graph",{replaceState:!0})),p(),a()}export{g as component};
import"../chunks/Bzak7iHL.js";import{i as p}from"../chunks/BUoSzNdg.js";import{o as r}from"../chunks/GG5zm9kr.js";import{p as t,a}from"../chunks/CpWkWWOo.js";import{g as m}from"../chunks/BTwePnbx.js";function g(i,o){t(o,!1),r(()=>m("/graph",{replaceState:!0})),p(),a()}export{g as component};

View file

@ -1 +1 @@
{"version":"1778051833240"}
{"version":"2.1.21"}

View file

@ -11,13 +11,13 @@
<link rel="icon" type="image/svg+xml" href="/dashboard/favicon.svg" />
<link rel="apple-touch-icon" href="/dashboard/favicon.svg" />
<link rel="manifest" href="/dashboard/manifest.json" />
<link href="/dashboard/_app/immutable/entry/start.gT92nAJC.js" rel="modulepreload">
<link href="/dashboard/_app/immutable/chunks/BHGLDPij.js" rel="modulepreload">
<link href="/dashboard/_app/immutable/entry/start.DfC8txIX.js" rel="modulepreload">
<link href="/dashboard/_app/immutable/chunks/BTwePnbx.js" rel="modulepreload">
<link href="/dashboard/_app/immutable/chunks/CpWkWWOo.js" rel="modulepreload">
<link href="/dashboard/_app/immutable/chunks/BeMFXnHE.js" rel="modulepreload">
<link href="/dashboard/_app/immutable/chunks/BskPcZf7.js" rel="modulepreload">
<link href="/dashboard/_app/immutable/chunks/BdslOLCg.js" rel="modulepreload">
<link href="/dashboard/_app/immutable/chunks/GG5zm9kr.js" rel="modulepreload">
<link href="/dashboard/_app/immutable/entry/app.CYIcgKkt.js" rel="modulepreload">
<link href="/dashboard/_app/immutable/entry/app.DRELdRUq.js" rel="modulepreload">
<link href="/dashboard/_app/immutable/chunks/BlVfL1ME.js" rel="modulepreload">
<link href="/dashboard/_app/immutable/chunks/CHOnp4oo.js" rel="modulepreload">
<link href="/dashboard/_app/immutable/chunks/Bzak7iHL.js" rel="modulepreload">
@ -33,7 +33,7 @@
<div style="display: contents">
<script>
{
__sveltekit_10kbxme = {
__sveltekit_1qjqcdh = {
base: "/dashboard",
assets: "/dashboard"
};
@ -41,8 +41,8 @@
const element = document.currentScript.parentElement;
Promise.all([
import("/dashboard/_app/immutable/entry/start.gT92nAJC.js"),
import("/dashboard/_app/immutable/entry/app.CYIcgKkt.js")
import("/dashboard/_app/immutable/entry/start.DfC8txIX.js"),
import("/dashboard/_app/immutable/entry/app.DRELdRUq.js")
]).then(([kit, app]) => {
kit.start(app, element);
});

Binary file not shown.

Binary file not shown.

View file

@ -1,12 +1,12 @@
{
"name": "@vestige/dashboard",
"version": "2.1.0",
"version": "2.1.21",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@vestige/dashboard",
"version": "2.1.0",
"version": "2.1.21",
"dependencies": {
"three": "^0.172.0"
},

View file

@ -1,6 +1,6 @@
{
"name": "@vestige/dashboard",
"version": "2.1.2",
"version": "2.1.21",
"private": true,
"type": "module",
"scripts": {

View file

@ -173,14 +173,14 @@ describe('buildTransferMatrix', () => {
it('aggregates transfer counts directionally', () => {
const m = buildTransferMatrix(PROJECTS, PATTERNS);
// vestige → api-gateway: Result<T,E> + proptest = 2
expect(m.vestige.api-gateway.count).toBe(2);
expect(m.vestige['api-gateway'].count).toBe(2);
// vestige → desktop-app: Result<T,E> only = 1
expect(m.vestige.desktop-app.count).toBe(1);
expect(m.vestige['desktop-app'].count).toBe(1);
// api-gateway → vestige: Axum middleware = 1
expect(m.api-gateway.vestige.count).toBe(1);
expect(m['api-gateway'].vestige.count).toBe(1);
// desktop-app → anywhere: zero (no origin in desktop-app in fixtures)
expect(m.desktop-app.vestige.count).toBe(0);
expect(m.desktop-app.api-gateway.count).toBe(0);
expect(m['desktop-app'].vestige.count).toBe(0);
expect(m['desktop-app']['api-gateway'].count).toBe(0);
});
it('treats (A, B) and (B, A) as distinct directions (asymmetry confirmed)', () => {
@ -189,7 +189,7 @@ describe('buildTransferMatrix', () => {
// bug that aggregates both directions into the same cell would pass
// the "count" test above but fail this symmetry check.
const m = buildTransferMatrix(PROJECTS, PATTERNS);
expect(m.vestige.api-gateway.count).not.toBe(m.api-gateway.vestige.count);
expect(m.vestige['api-gateway'].count).not.toBe(m['api-gateway'].vestige.count);
});
it('records self-transfer on the diagonal', () => {
@ -207,9 +207,9 @@ describe('buildTransferMatrix', () => {
transfer_count: 1,
}));
const m = buildTransferMatrix(['vestige', 'api-gateway'], manyPatterns);
expect(m.vestige.api-gateway.count).toBe(5);
expect(m.vestige.api-gateway.topNames).toHaveLength(3);
expect(m.vestige.api-gateway.topNames).toEqual(['pattern-0', 'pattern-1', 'pattern-2']);
expect(m.vestige['api-gateway'].count).toBe(5);
expect(m.vestige['api-gateway'].topNames).toHaveLength(3);
expect(m.vestige['api-gateway'].topNames).toEqual(['pattern-0', 'pattern-1', 'pattern-2']);
});
it('silently drops patterns whose origin is not in the projects axis', () => {
@ -238,7 +238,7 @@ describe('buildTransferMatrix', () => {
};
const m = buildTransferMatrix(PROJECTS, [strayDest]);
// The known destination counts; the ghost doesn't.
expect(m.vestige.api-gateway.count).toBe(1);
expect(m.vestige['api-gateway'].count).toBe(1);
expect((m.vestige as Record<string, unknown>)['ghost-project']).toBeUndefined();
});
@ -260,7 +260,7 @@ describe('buildTransferMatrix', () => {
},
];
const m = buildTransferMatrix(['vestige', 'api-gateway'], pats, 1);
expect(m.vestige.api-gateway.topNames).toEqual(['a']);
expect(m.vestige['api-gateway'].topNames).toEqual(['a']);
});
});

View file

@ -1,6 +1,8 @@
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
const appVersion = process.env.VESTIGE_DASHBOARD_VERSION ?? process.env.npm_package_version ?? 'dev';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
@ -15,6 +17,9 @@ const config = {
paths: {
base: '/dashboard'
},
version: {
name: appVersion
},
alias: {
$lib: 'src/lib',
$components: 'src/lib/components',

View file

@ -1,6 +1,6 @@
[package]
name = "vestige-core"
version = "2.1.2"
version = "2.1.21"
edition = "2024"
rust-version = "1.91"
authors = ["Vestige Team"]

View file

@ -8,7 +8,7 @@ use directories::{BaseDirs, ProjectDirs};
use lru::LruCache;
use rusqlite::types::{Type, Value, ValueRef};
use rusqlite::{Connection, OptionalExtension, params, params_from_iter};
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::io::Write;
#[cfg(all(feature = "embeddings", feature = "vector-search"))]
use std::num::NonZeroUsize;
@ -156,6 +156,16 @@ impl PortableSyncBackend for FilePortableSyncBackend {
.unwrap_or("vestige-sync.json");
let temp_path = parent.join(format!(".{}.tmp-{}", filename, Uuid::new_v4()));
#[cfg(unix)]
let mut file = {
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&temp_path)?
};
#[cfg(not(unix))]
let mut file = std::fs::File::create(&temp_path)?;
if let Err(e) = serde_json::to_writer_pretty(&mut file, archive) {
let _ = std::fs::remove_file(&temp_path);
@ -261,6 +271,11 @@ const PORTABLE_USER_DATA_TABLES: &[&str] = &[
"deletion_tombstones",
];
#[derive(Default)]
struct PortableMergeState {
locally_newer_nodes: HashSet<String>,
}
const DATA_DIR_ENV: &str = "VESTIGE_DATA_DIR";
const DATABASE_FILE: &str = "vestige.db";
@ -454,6 +469,10 @@ impl Storage {
/// Load existing embeddings into vector index
#[cfg(all(feature = "embeddings", feature = "vector-search"))]
fn load_embeddings_into_index(&self) -> Result<()> {
let mut index = self
.vector_index
.lock()
.map_err(|_| StorageError::Init("Vector index lock poisoned".to_string()))?;
let reader = self
.reader
.lock()
@ -469,10 +488,9 @@ impl Storage {
drop(stmt);
drop(reader);
let mut index = self
.vector_index
.lock()
.map_err(|_| StorageError::Init("Vector index lock poisoned".to_string()))?;
*index = VectorIndex::new().map_err(|e| {
StorageError::Init(format!("Failed to rebuild vector index before load: {}", e))
})?;
let mut load_failures = 0u32;
let mut skipped_model_mismatches = 0u32;
@ -1817,14 +1835,16 @@ impl Storage {
/// Delete a node
pub fn delete_node(&self, id: &str) -> Result<bool> {
let writer = self
let mut writer = self
.writer
.lock()
.map_err(|_| StorageError::Init("Writer lock poisoned".into()))?;
if Self::node_exists(&writer, id)? {
Self::record_sync_tombstone(&writer, "knowledge_nodes", id, "delete_node")?;
let tx = writer.transaction()?;
if Self::node_exists(&tx, id)? {
Self::record_sync_tombstone(&tx, "knowledge_nodes", id, "delete_node")?;
}
let rows = writer.execute("DELETE FROM knowledge_nodes WHERE id = ?1", params![id])?;
let rows = tx.execute("DELETE FROM knowledge_nodes WHERE id = ?1", params![id])?;
tx.commit()?;
// Clean up vector index to prevent stale search results
#[cfg(all(feature = "embeddings", feature = "vector-search"))]
@ -4740,6 +4760,16 @@ impl Storage {
.unwrap_or("vestige-portable.json");
let temp_path = parent.join(format!(".{}.tmp-{}", filename, Uuid::new_v4()));
#[cfg(unix)]
let mut file = {
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&temp_path)?
};
#[cfg(not(unix))]
let mut file = std::fs::File::create(&temp_path)?;
if let Err(e) = serde_json::to_writer_pretty(&mut file, &archive) {
let _ = std::fs::remove_file(&temp_path);
@ -4839,6 +4869,7 @@ impl Storage {
}
let tx = writer.transaction()?;
let mut merge_state = PortableMergeState::default();
for table_name in PORTABLE_TABLES {
let Some(table) = tables_by_name.get(table_name) else {
@ -4851,7 +4882,13 @@ impl Storage {
}
if mode == PortableImportMode::Merge {
Self::merge_portable_table(&tx, table_name, table, &mut report)?;
Self::merge_portable_table(
&tx,
table_name,
table,
&mut report,
&mut merge_state,
)?;
report.tables_imported += 1;
continue;
}
@ -4991,28 +5028,37 @@ impl Storage {
table_name: &str,
table: &PortableTable,
report: &mut PortableImportReport,
state: &mut PortableMergeState,
) -> Result<()> {
match table_name {
"sync_tombstones" => Self::merge_sync_tombstones(tx, table, report),
"knowledge_nodes" => Self::merge_knowledge_nodes(tx, table, report),
"knowledge_nodes" => Self::merge_knowledge_nodes(tx, table, report, state),
"memory_access_log"
| "state_transitions"
| "consolidation_history"
| "dream_history"
| "retention_snapshots" => Self::merge_append_only_table(tx, table_name, table, report),
"node_embeddings" => {
Self::merge_keyed_table(tx, table_name, table, &["node_id"], report)
Self::merge_keyed_table(tx, table_name, table, &["node_id"], report, state)
}
"fsrs_cards" | "memory_states" => {
Self::merge_keyed_table(tx, table_name, table, &["memory_id"], report)
}
"memory_connections" => {
Self::merge_keyed_table(tx, table_name, table, &["source_id", "target_id"], report)
Self::merge_keyed_table(tx, table_name, table, &["memory_id"], report, state)
}
"deletion_tombstones" => Self::merge_deletion_tombstones(tx, table, report),
"memory_connections" => Self::merge_keyed_table(
tx,
table_name,
table,
&["source_id", "target_id"],
report,
state,
),
"intentions" | "insights" | "sessions" => {
Self::merge_keyed_table(tx, table_name, table, &["id"], report)
Self::merge_keyed_table(tx, table_name, table, &["id"], report, state)
}
"fsrs_config" => {
Self::merge_keyed_table(tx, table_name, table, &["key"], report, state)
}
"fsrs_config" => Self::merge_keyed_table(tx, table_name, table, &["key"], report),
_ => {
report.tables_skipped += 1;
Ok(())
@ -5024,6 +5070,7 @@ impl Storage {
tx: &rusqlite::Transaction<'_>,
table: &PortableTable,
report: &mut PortableImportReport,
state: &mut PortableMergeState,
) -> Result<()> {
for row in &table.rows {
let Some(id) = Self::portable_text(table, row, "id") else {
@ -5055,6 +5102,7 @@ impl Storage {
incoming_updated,
) && existing > incoming
{
state.locally_newer_nodes.insert(id.to_string());
report.conflicts_kept_local += 1;
report.rows_skipped += 1;
continue;
@ -5085,15 +5133,41 @@ impl Storage {
report.rows_skipped += 1;
continue;
};
let deleted_at = Self::portable_timestamp(table, row, "deleted_at");
let incoming_deleted_at = Self::portable_timestamp(table, row, "deleted_at");
let incoming_reason = Self::portable_text(table, row, "reason").map(ToOwned::to_owned);
let affected = Self::insert_or_replace_row(tx, "sync_tombstones", table, row)?;
report.rows_imported += 1;
if affected == MergeWrite::Inserted {
report.rows_inserted += 1;
let existing_tombstone: Option<(String, Option<String>)> = tx
.query_row(
"SELECT deleted_at, reason FROM sync_tombstones WHERE table_name = ?1 AND row_id = ?2",
params![table_name, row_id],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.optional()?;
let existing_deleted_at = existing_tombstone
.as_ref()
.and_then(|(deleted_at, _)| Self::parse_rfc3339_opt(deleted_at));
let incoming_wins = match (existing_deleted_at, incoming_deleted_at) {
(Some(existing), Some(incoming)) => incoming >= existing,
(Some(_), None) => false,
(None, _) => true,
};
let (effective_deleted_at, effective_reason) = if incoming_wins {
let affected = Self::insert_or_replace_row(tx, "sync_tombstones", table, row)?;
report.rows_imported += 1;
if affected == MergeWrite::Inserted {
report.rows_inserted += 1;
} else {
report.rows_updated += 1;
}
(incoming_deleted_at, incoming_reason)
} else {
report.rows_updated += 1;
}
report.rows_skipped += 1;
(
existing_deleted_at,
existing_tombstone.and_then(|(_, reason)| reason),
)
};
if table_name == "knowledge_nodes" {
let local_updated: Option<String> = tx
@ -5105,9 +5179,11 @@ impl Storage {
.optional()?;
let should_delete = match (
local_updated.as_deref().and_then(Self::parse_rfc3339_opt),
deleted_at,
effective_deleted_at,
) {
(Some(local), Some(deleted)) => deleted >= local,
(Some(local), Some(deleted)) => {
effective_reason.as_deref() == Some("purge_node") || deleted >= local
}
(Some(_), None) => true,
(None, _) => false,
};
@ -5121,12 +5197,54 @@ impl Storage {
Ok(())
}
fn merge_deletion_tombstones(
tx: &rusqlite::Transaction<'_>,
table: &PortableTable,
report: &mut PortableImportReport,
) -> Result<()> {
for row in &table.rows {
let Some(memory_id) = Self::portable_text(table, row, "memory_id") else {
report.rows_skipped += 1;
continue;
};
let incoming_deleted_at = Self::portable_timestamp(table, row, "deleted_at");
let existing_deleted_at: Option<String> = tx
.query_row(
"SELECT deleted_at FROM deletion_tombstones WHERE memory_id = ?1",
params![memory_id],
|row| row.get(0),
)
.optional()?;
if let (Some(existing), Some(incoming)) = (
existing_deleted_at
.as_deref()
.and_then(Self::parse_rfc3339_opt),
incoming_deleted_at,
) && existing > incoming
{
report.rows_skipped += 1;
continue;
}
let affected = Self::insert_or_replace_row(tx, "deletion_tombstones", table, row)?;
report.rows_imported += 1;
if affected == MergeWrite::Inserted {
report.rows_inserted += 1;
} else {
report.rows_updated += 1;
}
}
Ok(())
}
fn merge_keyed_table(
tx: &rusqlite::Transaction<'_>,
table_name: &str,
table: &PortableTable,
key_columns: &[&str],
report: &mut PortableImportReport,
state: &PortableMergeState,
) -> Result<()> {
for row in &table.rows {
if !Self::parent_rows_exist(tx, table_name, table, row)? {
@ -5140,6 +5258,11 @@ impl Storage {
report.rows_skipped += 1;
continue;
}
if Self::row_references_locally_newer_node(table_name, table, row, state) {
report.conflicts_kept_local += 1;
report.rows_skipped += 1;
continue;
}
let affected = Self::insert_or_replace_row(tx, table_name, table, row)?;
report.rows_imported += 1;
if affected == MergeWrite::Inserted {
@ -5151,6 +5274,27 @@ impl Storage {
Ok(())
}
fn row_references_locally_newer_node(
table_name: &str,
table: &PortableTable,
row: &[PortableValue],
state: &PortableMergeState,
) -> bool {
match table_name {
"node_embeddings" => Self::portable_text(table, row, "node_id")
.is_some_and(|id| state.locally_newer_nodes.contains(id)),
"fsrs_cards" | "memory_states" => Self::portable_text(table, row, "memory_id")
.is_some_and(|id| state.locally_newer_nodes.contains(id)),
"memory_connections" => {
Self::portable_text(table, row, "source_id")
.is_some_and(|id| state.locally_newer_nodes.contains(id))
|| Self::portable_text(table, row, "target_id")
.is_some_and(|id| state.locally_newer_nodes.contains(id))
}
_ => false,
}
}
fn merge_append_only_table(
tx: &rusqlite::Transaction<'_>,
table_name: &str,
@ -5227,7 +5371,7 @@ impl Storage {
) -> Result<MergeWrite> {
let key_exists = Self::merge_row_exists(tx, table_name, table, row)?;
let values = Self::row_values_for_columns(table, row, &table.columns)?;
Self::insert_row_with_columns(tx, table_name, &table.columns, values)?;
Self::upsert_row_with_columns(tx, table_name, &table.columns, values)?;
Ok(if key_exists {
MergeWrite::Updated
} else {
@ -5235,6 +5379,66 @@ impl Storage {
})
}
fn merge_key_columns(table_name: &str) -> &'static [&'static str] {
match table_name {
"knowledge_nodes" | "intentions" | "insights" | "sessions" => &["id"],
"node_embeddings" => &["node_id"],
"fsrs_cards" | "memory_states" | "deletion_tombstones" => &["memory_id"],
"memory_connections" => &["source_id", "target_id"],
"fsrs_config" => &["key"],
"sync_tombstones" => &["table_name", "row_id"],
_ => &[],
}
}
fn upsert_row_with_columns(
tx: &rusqlite::Transaction<'_>,
table_name: &str,
columns: &[String],
values: Vec<Value>,
) -> Result<()> {
let key_columns = Self::merge_key_columns(table_name);
if key_columns.is_empty() {
return Self::insert_row_with_columns(tx, table_name, columns, values);
}
let quoted_table = Self::quote_ident(table_name);
let quoted_columns = columns
.iter()
.map(|column| Self::quote_ident(column))
.collect::<Vec<_>>()
.join(", ");
let placeholders = std::iter::repeat_n("?", columns.len())
.collect::<Vec<_>>()
.join(", ");
let conflict_target = key_columns
.iter()
.map(|column| Self::quote_ident(column))
.collect::<Vec<_>>()
.join(", ");
let update_columns = columns
.iter()
.filter(|column| !key_columns.iter().any(|key| key == &column.as_str()))
.map(|column| {
let quoted = Self::quote_ident(column);
format!("{quoted} = excluded.{quoted}")
})
.collect::<Vec<_>>();
let conflict_action = if update_columns.is_empty() {
"DO NOTHING".to_string()
} else {
format!("DO UPDATE SET {}", update_columns.join(", "))
};
let sql = format!(
"INSERT INTO {} ({}) VALUES ({}) ON CONFLICT({}) {}",
quoted_table, quoted_columns, placeholders, conflict_target, conflict_action
);
tx.execute(&sql, params_from_iter(values))?;
Ok(())
}
fn insert_row_with_columns(
tx: &rusqlite::Transaction<'_>,
table_name: &str,
@ -5264,15 +5468,7 @@ impl Storage {
table: &PortableTable,
row: &[PortableValue],
) -> Result<bool> {
let key_columns: &[&str] = match table_name {
"knowledge_nodes" | "intentions" | "insights" | "sessions" => &["id"],
"node_embeddings" => &["node_id"],
"fsrs_cards" | "memory_states" => &["memory_id"],
"memory_connections" => &["source_id", "target_id"],
"fsrs_config" => &["key"],
"sync_tombstones" => &["table_name", "row_id"],
_ => &[],
};
let key_columns = Self::merge_key_columns(table_name);
if key_columns.is_empty() {
return Ok(false);
}
@ -5561,6 +5757,13 @@ impl Storage {
.map_err(|_| StorageError::Init("Reader lock poisoned".into()))?;
// VACUUM INTO doesn't support parameterized queries; escape single quotes
reader.execute_batch(&format!("VACUUM INTO '{}'", path_str.replace('\'', "''")))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(path)?.permissions();
perms.set_mode(0o600);
std::fs::set_permissions(path, perms)?;
}
Ok(())
}
@ -6414,6 +6617,128 @@ mod tests {
assert_eq!(restored.content, "Newer local edit");
}
#[test]
fn test_portable_merge_import_keeps_children_for_newer_local_memory() {
let source_dir = tempdir().unwrap();
let target_dir = tempdir().unwrap();
let source = create_test_storage_at(&source_dir, "source.db");
let target = create_test_storage_at(&target_dir, "target.db");
let node = source
.ingest(IngestInput {
content: "Shared parent with child rows".to_string(),
node_type: "fact".to_string(),
..Default::default()
})
.unwrap();
let source_time = Utc::now().to_rfc3339();
{
let writer = source.writer.lock().unwrap();
writer
.execute(
"INSERT OR REPLACE INTO node_embeddings
(node_id, embedding, dimensions, model, created_at)
VALUES (?1, ?2, ?3, ?4, ?5)",
params![&node.id, vec![1_u8, 2, 3, 4], 4, "test-model", &source_time],
)
.unwrap();
writer
.execute(
"INSERT OR REPLACE INTO fsrs_cards
(memory_id, difficulty, stability, state, reps, lapses)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![&node.id, 3.0_f64, 2.0_f64, "review", 2_i64, 0_i64],
)
.unwrap();
writer
.execute(
"INSERT OR REPLACE INTO memory_states
(memory_id, state, last_access, access_count, state_entered_at)
VALUES (?1, ?2, ?3, ?4, ?5)",
params![&node.id, "active", &source_time, 1_i64, &source_time],
)
.unwrap();
}
let archive = source.export_portable_archive().unwrap();
target
.import_portable_archive(&archive, PortableImportMode::EmptyOnly)
.unwrap();
let local_time = (Utc::now() + Duration::hours(1)).to_rfc3339();
{
let writer = target.writer.lock().unwrap();
writer
.execute(
"UPDATE knowledge_nodes SET content = ?1, updated_at = ?2 WHERE id = ?3",
params!["Newer local parent edit", &local_time, &node.id],
)
.unwrap();
writer
.execute(
"INSERT OR REPLACE INTO node_embeddings
(node_id, embedding, dimensions, model, created_at)
VALUES (?1, ?2, ?3, ?4, ?5)",
params![&node.id, vec![9_u8, 8, 7, 6], 4, "test-model", &local_time],
)
.unwrap();
writer
.execute(
"INSERT OR REPLACE INTO fsrs_cards
(memory_id, difficulty, stability, state, reps, lapses)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![&node.id, 9.0_f64, 8.0_f64, "review", 9_i64, 1_i64],
)
.unwrap();
writer
.execute(
"INSERT OR REPLACE INTO memory_states
(memory_id, state, last_access, access_count, state_entered_at)
VALUES (?1, ?2, ?3, ?4, ?5)",
params![&node.id, "silent", &local_time, 42_i64, &local_time],
)
.unwrap();
}
let report = target
.import_portable_archive(&archive, PortableImportMode::Merge)
.unwrap();
assert!(report.conflicts_kept_local >= 4);
let restored = target.get_node(&node.id).unwrap().unwrap();
assert_eq!(restored.content, "Newer local parent edit");
let reader = target.reader.lock().unwrap();
let embedding: Vec<u8> = reader
.query_row(
"SELECT embedding FROM node_embeddings WHERE node_id = ?1",
params![&node.id],
|row| row.get(0),
)
.unwrap();
assert_eq!(embedding, vec![9_u8, 8, 7, 6]);
let difficulty: f64 = reader
.query_row(
"SELECT difficulty FROM fsrs_cards WHERE memory_id = ?1",
params![&node.id],
|row| row.get(0),
)
.unwrap();
assert_eq!(difficulty, 9.0);
let (state, access_count): (String, i64) = reader
.query_row(
"SELECT state, access_count FROM memory_states WHERE memory_id = ?1",
params![&node.id],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.unwrap();
assert_eq!(state, "silent");
assert_eq!(access_count, 42);
}
#[test]
fn test_portable_merge_import_applies_delete_tombstones() {
let source_dir = tempdir().unwrap();
@ -6444,6 +6769,92 @@ mod tests {
assert!(target.get_node(&node.id).unwrap().is_none());
}
#[test]
fn test_portable_merge_import_preserves_purge_tombstones() {
let source_dir = tempdir().unwrap();
let target_dir = tempdir().unwrap();
let source = create_test_storage_at(&source_dir, "source.db");
let target = create_test_storage_at(&target_dir, "target.db");
let node = source
.ingest(IngestInput {
content: "Memory purged on source".to_string(),
node_type: "fact".to_string(),
tags: vec!["sync".to_string()],
..Default::default()
})
.unwrap();
let archive = source.export_portable_archive().unwrap();
target
.import_portable_archive(&archive, PortableImportMode::EmptyOnly)
.unwrap();
assert!(target.get_node(&node.id).unwrap().is_some());
source
.purge_node(&node.id, Some("sync purge test"))
.unwrap();
let purge_archive = source.export_portable_archive().unwrap();
let report = target
.import_portable_archive(&purge_archive, PortableImportMode::Merge)
.unwrap();
assert!(report.rows_deleted >= 1);
assert!(target.get_node(&node.id).unwrap().is_none());
let writer = target.writer.lock().unwrap();
let tombstone_count: i64 = writer
.query_row(
"SELECT COUNT(*) FROM deletion_tombstones WHERE memory_id = ?1 AND reason = ?2",
params![node.id, "sync purge test"],
|row| row.get(0),
)
.unwrap();
assert_eq!(tombstone_count, 1);
}
#[test]
fn test_portable_merge_import_purge_wins_over_newer_local_edit() {
let source_dir = tempdir().unwrap();
let target_dir = tempdir().unwrap();
let source = create_test_storage_at(&source_dir, "source.db");
let target = create_test_storage_at(&target_dir, "target.db");
let node = source
.ingest(IngestInput {
content: "Memory that will be purged on source".to_string(),
node_type: "fact".to_string(),
tags: vec!["sync".to_string()],
..Default::default()
})
.unwrap();
let archive = source.export_portable_archive().unwrap();
target
.import_portable_archive(&archive, PortableImportMode::EmptyOnly)
.unwrap();
let newer = (Utc::now() + Duration::hours(1)).to_rfc3339();
{
let writer = target.writer.lock().unwrap();
writer
.execute(
"UPDATE knowledge_nodes SET content = ?1, updated_at = ?2 WHERE id = ?3",
params!["Newer local edit before purge arrives", newer, &node.id],
)
.unwrap();
}
source
.purge_node(&node.id, Some("hard purge wins sync conflict"))
.unwrap();
let purge_archive = source.export_portable_archive().unwrap();
let report = target
.import_portable_archive(&purge_archive, PortableImportMode::Merge)
.unwrap();
assert!(report.rows_deleted >= 1);
assert!(target.get_node(&node.id).unwrap().is_none());
}
#[test]
fn test_file_portable_sync_round_trips_between_devices() {
let sync_dir = tempdir().unwrap();

View file

@ -1,8 +1,8 @@
[package]
name = "vestige-mcp"
version = "2.1.2"
version = "2.1.21"
edition = "2024"
description = "Cognitive memory MCP server for Claude - FSRS-6, spreading activation, synaptic tagging, 3D dashboard, and 130 years of memory research"
description = "Cognitive memory MCP server for AI agents - FSRS-6, spreading activation, synaptic tagging, 3D dashboard, and 130 years of memory research"
authors = ["samvallad33"]
license = "AGPL-3.0-only"
keywords = ["mcp", "ai", "memory", "fsrs", "neuroscience", "cognitive-science", "spaced-repetition"]
@ -47,7 +47,7 @@ path = "src/bin/cli.rs"
# Only `bundled-sqlite` is always on. `embeddings` and `vector-search` are
# toggled via vestige-mcp's own feature flags below so `--no-default-features`
# actually works (previously hardcoded here, which silently defeated the flag).
vestige-core = { version = "2.1.2", path = "../vestige-core", default-features = false, features = ["bundled-sqlite"] }
vestige-core = { version = "2.1.21", path = "../vestige-core", default-features = false, features = ["bundled-sqlite"] }
# ============================================================================
# MCP Server Dependencies

View file

@ -1,115 +1,75 @@
# Vestige MCP Server
A bleeding-edge Rust MCP (Model Context Protocol) server for Vestige - providing Claude and other AI assistants with long-term memory capabilities.
Local cognitive memory for MCP-compatible AI agents.
## Features
This crate provides the `vestige-mcp` stdio MCP server plus the `vestige` CLI.
The cognitive engine lives in `vestige-core`; this crate owns protocol handling,
tool dispatch, optional dashboard serving, backups, restore, update, and
portable import/export commands.
- **FSRS-6 Algorithm**: State-of-the-art spaced repetition (21 parameters, personalized decay)
- **Dual-Strength Memory Model**: Based on Bjork & Bjork 1992 cognitive science research
- **Local Semantic Embeddings**: nomic-embed-text-v1.5 (768d) via fastembed v5 (no external API)
- **HNSW Vector Search**: USearch-based, 20x faster than FAISS
- **Hybrid Search**: BM25 + semantic with RRF fusion
- **Codebase Memory**: Remember patterns, decisions, and context
## Install
## Installation
For normal users, prefer the release package:
```bash
cd /path/to/vestige/crates/vestige-mcp
cargo build --release
npm install -g vestige-mcp-server
```
Binary will be at `target/release/vestige-mcp`
For local development:
## Claude Desktop Configuration
```bash
cargo build --release -p vestige-mcp
```
Add to your Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS):
## Register With An MCP Client
Use the command `vestige-mcp` in any stdio MCP client:
```json
{
"mcpServers": {
"vestige": {
"command": "/path/to/vestige-mcp"
"command": "vestige-mcp"
}
}
}
```
## Available Tools
Examples:
### Core Memory
| Tool | Description |
|------|-------------|
| `ingest` | Add new knowledge to memory |
| `recall` | Search and retrieve memories |
| `semantic_search` | Find conceptually similar content |
| `hybrid_search` | Combined keyword + semantic search |
| `get_knowledge` | Retrieve a specific memory by ID |
| `delete_knowledge` | Delete a memory |
| `mark_reviewed` | Review with FSRS rating (1-4) |
### Statistics & Maintenance
| Tool | Description |
|------|-------------|
| `get_stats` | Memory system statistics |
| `health_check` | System health status |
| `run_consolidation` | Apply decay, generate embeddings |
### Codebase Tools
| Tool | Description |
|------|-------------|
| `remember_pattern` | Remember code patterns |
| `remember_decision` | Remember architectural decisions |
| `get_codebase_context` | Get patterns and decisions |
## Available Resources
### Memory Resources
| URI | Description |
|-----|-------------|
| `memory://stats` | Current statistics |
| `memory://recent?n=10` | Recent memories |
| `memory://decaying` | Low retention memories |
| `memory://due` | Memories due for review |
### Codebase Resources
| URI | Description |
|-----|-------------|
| `codebase://structure` | Known codebases |
| `codebase://patterns` | Remembered patterns |
| `codebase://decisions` | Architectural decisions |
## Example Usage (with Claude)
```
User: Remember that we decided to use FSRS-6 instead of SM-2 because it's 20-30% more efficient.
Claude: [calls remember_decision]
I've recorded that architectural decision.
User: What decisions have we made about algorithms?
Claude: [calls get_codebase_context]
I found 1 decision:
- We decided to use FSRS-6 instead of SM-2 because it's 20-30% more efficient.
```bash
claude mcp add vestige vestige-mcp -s user
codex mcp add vestige -- vestige-mcp
```
## Data Storage
## Transports
- Database: `~/Library/Application Support/com.vestige.mcp/vestige-mcp.db` (macOS)
- Uses SQLite with FTS5 for full-text search
- Vector embeddings stored in separate table
- Default: JSON-RPC 2.0 over stdio.
- Optional: MCP-over-HTTP on `/mcp`, enabled only with `--http`,
`--http-port`, or `VESTIGE_HTTP_ENABLED=1`.
- Dashboard: `vestige dashboard` or `VESTIGE_DASHBOARD_ENABLED=1`.
## Protocol
HTTP and dashboard bearer tokens are generated locally; see
[`docs/CONFIGURATION.md`](../../docs/CONFIGURATION.md).
- JSON-RPC 2.0 over stdio
- MCP Protocol Version: 2024-11-05
- Logging to stderr (stdout reserved for JSON-RPC)
## Current Tool Surface
The server exposes the current unified MCP tools from
[`src/server.rs`](src/server.rs), including:
- `session_context`
- `search`, `smart_ingest`, `memory`, `codebase`, `intention`
- `deep_reference`, `cross_reference`, `contradictions`
- `dream`, `explore_connections`, `predict`
- `memory_health`, `memory_graph`, `system_status`
- `importance_score`, `find_duplicates`
- `consolidate`, `memory_timeline`, `memory_changelog`
- `backup`, `export`, `restore`, `gc`, `suppress`
See the root [`README.md`](../../README.md) and
[`docs/AGENT-MEMORY-PROTOCOL.md`](../../docs/AGENT-MEMORY-PROTOCOL.md) for
agent instructions.
## License
MIT
AGPL-3.0-only

View file

@ -2,6 +2,7 @@
//!
//! Command-line interface for managing cognitive memory system.
use std::collections::HashSet;
use std::env;
use std::fs;
use std::io::{BufWriter, Write};
@ -109,7 +110,7 @@ enum Commands {
/// Update Vestige binaries from the latest GitHub release
Update {
/// Install a specific release tag instead of latest (example: v2.1.1)
/// Install a specific release tag instead of latest (example: v2.1.21)
#[arg(long)]
version: Option<String>,
@ -121,10 +122,14 @@ enum Commands {
#[arg(long)]
dry_run: bool,
/// Skip Cognitive Sandwich companion file update and legacy hook cleanup.
/// Deprecated: companion updates are skipped by default.
#[arg(long)]
no_sandwich: bool,
/// Also refresh optional Claude Code Cognitive Sandwich companion files.
#[arg(long)]
sandwich_companion: bool,
#[command(flatten)]
sandwich: SandwichInstallOptions,
},
@ -257,8 +262,16 @@ fn main() -> anyhow::Result<()> {
install_dir,
dry_run,
no_sandwich,
sandwich_companion,
sandwich,
} => run_update(version, install_dir, dry_run, no_sandwich, sandwich),
} => run_update(
version,
install_dir,
dry_run,
no_sandwich,
sandwich_companion,
sandwich,
),
Commands::Sandwich { command } => match command {
SandwichCommands::Install { version, options } => {
run_sandwich_install(version.as_deref(), &options)
@ -405,6 +418,76 @@ fn download_file(url: &str, output: &Path, action: &str) -> anyhow::Result<()> {
)
}
fn parse_sha256(text: &str) -> anyhow::Result<String> {
let hash = text
.split_whitespace()
.next()
.ok_or_else(|| anyhow::anyhow!("checksum file is empty"))?
.to_ascii_lowercase();
if hash.len() != 64 || !hash.chars().all(|ch| ch.is_ascii_hexdigit()) {
anyhow::bail!("checksum file does not contain a valid SHA-256 hash");
}
Ok(hash)
}
fn sha256_from_command(command: &mut Command) -> anyhow::Result<Option<String>> {
match command.output() {
Ok(output) if output.status.success() => {
let text = String::from_utf8_lossy(&output.stdout);
Ok(Some(parse_sha256(&text)?))
}
Ok(_) => Ok(None),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(err) => Err(err).context("failed to run checksum command"),
}
}
fn compute_sha256(path: &Path) -> anyhow::Result<String> {
#[cfg(windows)]
{
if let Some(hash) = sha256_from_command(
Command::new("powershell")
.arg("-NoProfile")
.arg("-Command")
.arg("(Get-FileHash -Algorithm SHA256 -LiteralPath $args[0]).Hash.ToLowerInvariant()")
.arg(path),
)? {
return Ok(hash);
}
}
#[cfg(not(windows))]
{
if let Some(hash) =
sha256_from_command(Command::new("shasum").arg("-a").arg("256").arg(path))?
{
return Ok(hash);
}
if let Some(hash) = sha256_from_command(Command::new("sha256sum").arg(path))? {
return Ok(hash);
}
}
anyhow::bail!("no SHA-256 command available to verify release archive");
}
fn verify_release_checksum(archive_path: &Path, checksum_path: &Path) -> anyhow::Result<()> {
let expected = parse_sha256(&fs::read_to_string(checksum_path).with_context(|| {
format!(
"failed to read release checksum file {}",
checksum_path.display()
)
})?)?;
let actual = compute_sha256(archive_path)?;
if actual != expected {
anyhow::bail!(
"release archive checksum mismatch for {}",
archive_path.display()
);
}
Ok(())
}
fn latest_release_tag() -> anyhow::Result<String> {
let temp_dir = UpdateTempDir::create()?;
let metadata_path = temp_dir.path.join("latest-release.json");
@ -453,7 +536,7 @@ fn download_sandwich_source(version: Option<&str>, output_dir: &Path) -> anyhow:
println!("{}: {}", "Sandwich source".white().bold(), tag);
download_file(&url, &archive_path, "downloading Vestige source archive")?;
extract_archive(&archive_path, output_dir, "tar.gz")?;
extract_source_archive(&archive_path, output_dir)?;
find_sandwich_source_root(output_dir).ok_or_else(|| {
anyhow::anyhow!("Vestige source archive did not contain hooks/ and agents/ directories")
})
@ -635,7 +718,7 @@ fn write_sanhedrin_env(
) -> anyhow::Result<()> {
let env_path = hooks_dir.join("vestige-sanhedrin.env");
let contents = format!(
"VESTIGE_SANHEDRIN_ENABLED=1\nVESTIGE_SANHEDRIN_ENDPOINT={}\nVESTIGE_SANHEDRIN_MODEL={}\nVESTIGE_DASHBOARD_PORT={}\n",
"VESTIGE_SANHEDRIN_ENABLED=1\nVESTIGE_SANHEDRIN_ENDPOINT={}\nVESTIGE_SANHEDRIN_MODEL={}\nVESTIGE_DASHBOARD_PORT={}\nVESTIGE_SANHEDRIN_CLAIM_MODE=1\nVESTIGE_SANHEDRIN_OUTPUT=json\n",
quote_shell_env(endpoint),
quote_shell_env(model),
quote_shell_env(dashboard_port)
@ -794,6 +877,13 @@ fn install_sandwich_from_source(
let backup_path = claude_dir.join("settings.json.bak.pre-sandwich");
if !backup_path.exists() {
fs::copy(&settings_path, &backup_path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&backup_path)?.permissions();
perms.set_mode(0o600);
fs::set_permissions(&backup_path, perms)?;
}
}
let settings_file = fs::File::open(&settings_path)?;
@ -887,11 +977,123 @@ fn run_command(command: &mut Command, action: &str) -> anyhow::Result<()> {
Ok(())
}
fn create_private_file(path: &Path) -> std::io::Result<fs::File> {
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)
}
#[cfg(not(unix))]
{
fs::File::create(path)
}
}
fn command_output(command: &mut Command, action: &str) -> anyhow::Result<String> {
let output = command
.output()
.with_context(|| format!("failed to start {}", action))?;
if !output.status.success() {
anyhow::bail!("{} failed with status {}", action, output.status);
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
fn powershell_quote(value: &Path) -> String {
format!("'{}'", value.display().to_string().replace('\'', "''"))
}
fn normalize_archive_entry(entry: &str) -> anyhow::Result<String> {
let normalized = entry.trim().replace('\\', "/");
let normalized = normalized.strip_prefix("./").unwrap_or(&normalized);
if normalized.is_empty()
|| normalized.starts_with('/')
|| normalized.get(1..2) == Some(":")
|| normalized
.split('/')
.any(|part| part.is_empty() || part == "..")
{
anyhow::bail!("archive contains unsafe entry: {}", entry);
}
Ok(normalized.to_string())
}
fn archive_listing(archive_path: &Path, archive_ext: &str) -> anyhow::Result<String> {
let listing = match archive_ext {
"tar.gz" => command_output(
Command::new("tar").arg("-tzf").arg(archive_path),
"listing Vestige archive with tar",
)?,
"zip" => {
let script = format!(
"Add-Type -AssemblyName System.IO.Compression.FileSystem; \
$zip = [System.IO.Compression.ZipFile]::OpenRead({}); \
try {{ $zip.Entries | ForEach-Object {{ $_.FullName }} }} finally {{ $zip.Dispose() }}",
powershell_quote(archive_path)
);
command_output(
Command::new("powershell")
.arg("-NoProfile")
.arg("-Command")
.arg(script),
"listing Vestige archive with PowerShell",
)?
}
other => anyhow::bail!("unsupported release archive extension: {}", other),
};
Ok(listing)
}
fn validate_archive_safety(archive_path: &Path, archive_ext: &str) -> anyhow::Result<()> {
let listing = archive_listing(archive_path, archive_ext)?;
for entry in listing.lines().filter(|line| !line.trim().is_empty()) {
normalize_archive_entry(entry)?;
}
Ok(())
}
fn validate_archive_entries(
archive_path: &Path,
archive_ext: &str,
expected_members: &[String],
) -> anyhow::Result<()> {
let listing = archive_listing(archive_path, archive_ext)?;
let expected: HashSet<&str> = expected_members.iter().map(String::as_str).collect();
for entry in listing.lines().filter(|line| !line.trim().is_empty()) {
let normalized = normalize_archive_entry(entry)?;
if !expected.contains(normalized.as_str()) {
anyhow::bail!("release archive contains unexpected entry: {}", entry);
}
}
Ok(())
}
fn extract_source_archive(archive_path: &Path, output_dir: &Path) -> anyhow::Result<()> {
validate_archive_safety(archive_path, "tar.gz")?;
run_command(
Command::new("tar")
.arg("-xzf")
.arg(archive_path)
.arg("-C")
.arg(output_dir),
"extracting Vestige source archive with tar",
)
}
fn extract_archive(
archive_path: &Path,
output_dir: &Path,
archive_ext: &str,
expected_members: &[String],
) -> anyhow::Result<()> {
validate_archive_entries(archive_path, archive_ext, expected_members)?;
match archive_ext {
"tar.gz" => run_command(
Command::new("tar")
@ -906,9 +1108,9 @@ fn extract_archive(
.arg("-NoProfile")
.arg("-Command")
.arg(format!(
"Expand-Archive -Path '{}' -DestinationPath '{}' -Force",
archive_path.display(),
output_dir.display()
"Expand-Archive -LiteralPath {} -DestinationPath {} -Force",
powershell_quote(archive_path),
powershell_quote(output_dir)
)),
"extracting Vestige release archive with PowerShell",
),
@ -969,6 +1171,7 @@ fn run_update(
install_dir: Option<PathBuf>,
dry_run: bool,
no_sandwich: bool,
sandwich_companion: bool,
sandwich: SandwichInstallOptions,
) -> anyhow::Result<()> {
println!("{}", "=== Vestige Update ===".cyan().bold());
@ -1020,15 +1223,35 @@ fn run_update(
let temp_dir = UpdateTempDir::create()?;
let archive_path = temp_dir.path.join(&archive_name);
let checksum_path = temp_dir.path.join(format!("{}.sha256", archive_name));
println!();
println!("{}", "Downloading release archive...".cyan());
download_file(&url, &archive_path, "downloading Vestige release archive")?;
println!("{}", "Extracting release archive...".cyan());
extract_archive(&archive_path, &temp_dir.path, asset.archive_ext)?;
download_file(
&format!("{}.sha256", url),
&checksum_path,
"downloading Vestige release checksum",
)?;
verify_release_checksum(&archive_path, &checksum_path)?;
let binaries = ["vestige", "vestige-mcp", "vestige-restore"];
let mut expected_members = binaries
.iter()
.map(|binary| format!("{}{}", binary, asset.binary_suffix))
.collect::<Vec<_>>();
if asset.target == "x86_64-apple-darwin" {
expected_members.push("INSTALL-INTEL-MAC.md".to_string());
}
println!("{}", "Extracting release archive...".cyan());
extract_archive(
&archive_path,
&temp_dir.path,
asset.archive_ext,
&expected_members,
)?;
for binary in binaries {
let filename = format!("{}{}", binary, asset.binary_suffix);
let source = temp_dir.path.join(&filename);
@ -1059,18 +1282,24 @@ fn run_update(
.bold()
);
if no_sandwich {
println!(
"{}",
"Skipped Cognitive Sandwich companion update (--no-sandwich).".yellow()
);
} else {
if sandwich_companion && !no_sandwich {
println!();
println!(
"{}",
"Updating Cognitive Sandwich companion files...".cyan()
);
run_sandwich_install(version.as_deref(), &sandwich)?;
} else if no_sandwich {
println!(
"{}",
"Skipped Cognitive Sandwich companion update (--no-sandwich).".yellow()
);
} else {
println!(
"{}",
"Skipped Cognitive Sandwich companion update (default). Pass --sandwich-companion to refresh Claude Code companion files."
.yellow()
);
}
Ok(())
@ -1680,6 +1909,13 @@ fn run_backup(output: PathBuf) -> anyhow::Result<()> {
println!(" {} {}", "To:".dimmed(), output.display());
std::fs::copy(&db_path, &output)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&output)?.permissions();
perms.set_mode(0o600);
std::fs::set_permissions(&output, perms)?;
}
let file_size = std::fs::metadata(&output)?.len();
let size_display = if file_size >= 1024 * 1024 {
@ -1790,7 +2026,7 @@ fn run_export(
std::fs::create_dir_all(parent)?;
}
let file = std::fs::File::create(&output)?;
let file = create_private_file(&output)?;
let mut writer = BufWriter::new(file);
match format.as_str() {
@ -2305,7 +2541,9 @@ fn run_serve(port: u16, with_dashboard: bool, dashboard_port: u16) -> anyhow::Re
bind,
port
);
println!(" {} Auth token: {}...", ">".cyan(), &token[..8]);
if let Ok(path) = vestige_mcp::protocol::auth::token_path() {
println!(" {} Auth token file: {}", ">".cyan(), path.display());
}
println!();
println!("{}", "Press Ctrl+C to stop.".dimmed());

View file

@ -1,4 +1,4 @@
//! Vestige MCP Server v1.0 - Cognitive Memory for Claude
//! Vestige MCP Server - local cognitive memory for MCP agents.
//!
//! A bleeding-edge Rust MCP (Model Context Protocol) server that provides
//! Claude and other AI assistants with long-term memory capabilities
@ -54,6 +54,7 @@ const DATABASE_FILE: &str = "vestige.db";
struct Config {
data_dir: Option<PathBuf>,
http_port: u16,
http_enabled: bool,
dashboard_enabled: bool,
}
@ -79,6 +80,9 @@ fn parse_args_from(args: Vec<OsString>, env_data_dir: Option<PathBuf>) -> Config
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(3928);
let mut http_enabled = std::env::var("VESTIGE_HTTP_ENABLED")
.map(|v| v.eq_ignore_ascii_case("true") || v == "1")
.unwrap_or(false);
let dashboard_enabled = std::env::var("VESTIGE_DASHBOARD_ENABLED")
.map(|v| v.eq_ignore_ascii_case("true") || v == "1")
.unwrap_or(false);
@ -101,7 +105,9 @@ fn parse_args_from(args: Vec<OsString>, env_data_dir: Option<PathBuf>) -> Config
println!(
" --data-dir <PATH> Custom data directory (overrides VESTIGE_DATA_DIR)"
);
println!(" --http-port <PORT> HTTP transport port (default: 3928)");
println!(" --http Enable Streamable HTTP transport");
println!(" --no-http Disable Streamable HTTP transport");
println!(" --http-port <PORT> HTTP transport port (also enables HTTP)");
println!();
println!("ENVIRONMENT:");
println!(
@ -111,10 +117,14 @@ fn parse_args_from(args: Vec<OsString>, env_data_dir: Option<PathBuf>) -> Config
" RUST_LOG Log level filter (e.g., debug, info, warn, error)"
);
println!(
" VESTIGE_AUTH_TOKEN Override the bearer token for HTTP transport"
" VESTIGE_AUTH_TOKEN Override the bearer token for HTTP transport"
);
println!(" VESTIGE_HTTP_PORT HTTP transport port (default: 3928)");
println!(" VESTIGE_DASHBOARD_ENABLED Enable dashboard (default: disabled)");
println!(" VESTIGE_HTTP_ENABLED Enable HTTP transport (default: false)");
println!(" VESTIGE_HTTP_PORT HTTP transport port (default: 3928)");
println!(
" VESTIGE_HTTP_ALLOWED_ORIGINS Comma-separated browser origins allowed for HTTP"
);
println!(" VESTIGE_DASHBOARD_ENABLED Enable dashboard (default: disabled)");
println!(" VESTIGE_DASHBOARD_PORT Dashboard port (default: 3927)");
println!(
" VESTIGE_SYSTEM_PROMPT_MODE Inject the full composition mandate into every MCP session (minimal|full, default: minimal)"
@ -124,7 +134,7 @@ fn parse_args_from(args: Vec<OsString>, env_data_dir: Option<PathBuf>) -> Config
println!(" vestige-mcp");
println!(" vestige-mcp --data-dir /custom/path");
println!(" VESTIGE_DATA_DIR=~/.vestige vestige-mcp");
println!(" vestige-mcp --http-port 8080");
println!(" vestige-mcp --http --http-port 8080");
println!(" RUST_LOG=debug vestige-mcp");
std::process::exit(0);
}
@ -156,7 +166,14 @@ fn parse_args_from(args: Vec<OsString>, env_data_dir: Option<PathBuf>) -> Config
}
data_dir = Some(PathBuf::from(path));
}
"--http" => {
http_enabled = true;
}
"--no-http" => {
http_enabled = false;
}
"--http-port" => {
http_enabled = true;
i += 1;
if i >= args.len() {
eprintln!("error: --http-port requires a port number");
@ -173,6 +190,7 @@ fn parse_args_from(args: Vec<OsString>, env_data_dir: Option<PathBuf>) -> Config
};
}
arg if arg.starts_with("--http-port=") => {
http_enabled = true;
let val = arg.strip_prefix("--http-port=").unwrap_or("");
http_port = match val.parse() {
Ok(p) => p,
@ -195,6 +213,7 @@ fn parse_args_from(args: Vec<OsString>, env_data_dir: Option<PathBuf>) -> Config
Config {
data_dir,
http_port,
http_enabled,
dashboard_enabled,
}
}
@ -430,8 +449,8 @@ async fn main() {
info!("Dashboard disabled by VESTIGE_DASHBOARD_ENABLED=false");
}
// Start HTTP MCP transport (Streamable HTTP for Claude.ai / remote clients)
{
// Start optional HTTP MCP transport for clients that need Streamable HTTP.
if config.http_enabled {
let http_storage = Arc::clone(&storage);
let http_cognitive = Arc::clone(&cognitive);
let http_event_tx = event_tx.clone();
@ -442,7 +461,9 @@ async fn main() {
let bind =
std::env::var("VESTIGE_HTTP_BIND").unwrap_or_else(|_| "127.0.0.1".to_string());
eprintln!("Vestige HTTP transport: http://{}:{}/mcp", bind, http_port);
eprintln!("Auth token: {}...", &token[..token.len().min(8)]);
if let Ok(path) = protocol::auth::token_path() {
eprintln!("Auth token file: {}", path.display());
}
tokio::spawn(async move {
if let Err(e) = protocol::http::start_http_transport(
http_storage,
@ -464,6 +485,8 @@ async fn main() {
);
}
}
} else {
info!("HTTP MCP transport disabled; set VESTIGE_HTTP_ENABLED=1 or pass --http to enable");
}
// Load cross-encoder reranker in the background (downloads ~150MB on first run)
@ -511,6 +534,7 @@ mod tests {
);
assert_eq!(config.data_dir, Some(PathBuf::from("/tmp/vestige-env")));
assert!(!config.http_enabled);
}
#[test]
@ -523,6 +547,16 @@ mod tests {
assert_eq!(config.data_dir, Some(PathBuf::from("/tmp/vestige-cli")));
}
#[test]
fn http_is_opt_in_and_port_flag_enables_it() {
let disabled = parse_args_from(os_args(&["vestige-mcp"]), None);
assert!(!disabled.http_enabled);
let enabled = parse_args_from(os_args(&["vestige-mcp", "--http-port", "8080"]), None);
assert!(enabled.http_enabled);
assert_eq!(enabled.http_port, 8080);
}
#[test]
fn prepare_storage_path_creates_dir_and_points_to_vestige_db() {
let temp = tempfile::tempdir().unwrap();
@ -534,6 +568,17 @@ mod tests {
assert_eq!(db_path, Some(data_dir.join(DATABASE_FILE)));
}
#[test]
fn prepare_storage_path_reuses_existing_data_dir() {
let temp = tempfile::tempdir().unwrap();
let data_dir = temp.path().join("existing");
fs::create_dir_all(&data_dir).unwrap();
let db_path = prepare_storage_path(Some(data_dir.clone())).unwrap();
assert_eq!(db_path, Some(data_dir.join(DATABASE_FILE)));
}
#[test]
fn expand_tilde_expands_current_users_home_only() {
let home = BaseDirs::new().unwrap().home_dir().to_path_buf();

View file

@ -19,7 +19,7 @@ use tracing::{info, warn};
const MIN_TOKEN_LENGTH: usize = 32;
/// Return the auth token file path inside the Vestige data directory.
fn token_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
pub fn token_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
let dirs = ProjectDirs::from("com", "vestige", "core")
.ok_or("could not determine project directories")?;
Ok(dirs.data_dir().join("auth_token"))

View file

@ -12,7 +12,7 @@ use std::sync::Arc;
use std::time::{Duration, Instant};
use axum::extract::{DefaultBodyLimit, State};
use axum::http::{HeaderMap, StatusCode};
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
use axum::response::IntoResponse;
use axum::routing::{delete, post};
use axum::{Json, Router};
@ -48,6 +48,7 @@ const MAX_BODY_SIZE: usize = 256 * 1024;
struct Session {
server: McpServer,
last_active: Instant,
protocol_version: String,
}
/// Shared state cloned into every axum handler.
@ -58,6 +59,7 @@ pub struct HttpTransportState {
cognitive: Arc<Mutex<CognitiveEngine>>,
event_tx: broadcast::Sender<VestigeEvent>,
auth_token: String,
allowed_origins: Arc<Vec<String>>,
}
/// Start the HTTP MCP transport on `127.0.0.1:<port>`.
@ -76,6 +78,7 @@ pub async fn start_http_transport(
cognitive,
event_tx,
auth_token,
allowed_origins: Arc::new(allowed_origins(port)),
};
// Spawn session reaper
@ -105,6 +108,12 @@ pub async fn start_http_transport(
});
}
let cors_origins = state
.allowed_origins
.iter()
.filter_map(|origin| origin.parse().ok())
.collect::<Vec<_>>();
let app = Router::new()
.route("/mcp", post(post_mcp))
.route("/mcp", delete(delete_mcp))
@ -114,15 +123,7 @@ pub async fn start_http_transport(
.layer(ConcurrencyLimitLayer::new(CONCURRENCY_LIMIT))
.layer(
CorsLayer::new()
.allow_origin(
[
format!("http://127.0.0.1:{}", port),
format!("http://localhost:{}", port),
]
.into_iter()
.filter_map(|s| s.parse().ok())
.collect::<Vec<_>>(),
)
.allow_origin(cors_origins)
.allow_methods([
axum::http::Method::POST,
axum::http::Method::DELETE,
@ -131,6 +132,12 @@ pub async fn start_http_transport(
.allow_headers([
axum::http::header::CONTENT_TYPE,
axum::http::header::AUTHORIZATION,
axum::http::HeaderName::from_static("mcp-protocol-version"),
axum::http::HeaderName::from_static("mcp-session-id"),
])
.expose_headers([
axum::http::HeaderName::from_static("mcp-protocol-version"),
axum::http::HeaderName::from_static("mcp-session-id"),
]),
),
)
@ -187,6 +194,105 @@ fn validate_auth(headers: &HeaderMap, expected: &str) -> Result<(), (StatusCode,
Ok(())
}
fn allowed_origins(port: u16) -> Vec<String> {
if let Ok(configured) = std::env::var("VESTIGE_HTTP_ALLOWED_ORIGINS") {
let origins: Vec<String> = configured
.split(',')
.map(str::trim)
.filter(|origin| !origin.is_empty())
.map(ToOwned::to_owned)
.collect();
if !origins.is_empty() {
return origins;
}
}
vec![
format!("http://127.0.0.1:{}", port),
format!("http://localhost:{}", port),
]
}
fn validate_origin(
headers: &HeaderMap,
allowed_origins: &[String],
) -> Result<(), (StatusCode, &'static str)> {
let Some(origin) = headers.get(header::ORIGIN).and_then(|v| v.to_str().ok()) else {
return Ok(());
};
if allowed_origins.iter().any(|allowed| allowed == origin) {
Ok(())
} else {
Err((StatusCode::FORBIDDEN, "Origin not allowed"))
}
}
fn validate_accept(headers: &HeaderMap) -> Result<(), (StatusCode, &'static str)> {
let Some(accept) = headers.get(header::ACCEPT).and_then(|v| v.to_str().ok()) else {
return Err((
StatusCode::NOT_ACCEPTABLE,
"Accept must include application/json and text/event-stream",
));
};
let mut accepts_json = false;
let mut accepts_sse = false;
for mime in accept
.split(',')
.map(|part| part.trim().split(';').next().unwrap_or("").trim())
{
accepts_json |= mime == "application/json";
accepts_sse |= mime == "text/event-stream";
}
if accepts_json && accepts_sse {
Ok(())
} else {
Err((
StatusCode::NOT_ACCEPTABLE,
"Accept must include application/json and text/event-stream",
))
}
}
fn protocol_version_from_headers(headers: &HeaderMap) -> Option<&str> {
headers
.get("mcp-protocol-version")
.and_then(|v| v.to_str().ok())
}
fn validate_protocol_version(
headers: &HeaderMap,
expected: &str,
) -> Result<(), (StatusCode, &'static str)> {
let Some(version) = protocol_version_from_headers(headers) else {
return Err((
StatusCode::BAD_REQUEST,
"MCP-Protocol-Version header required",
));
};
if version == expected {
Ok(())
} else {
Err((StatusCode::BAD_REQUEST, "MCP-Protocol-Version mismatch"))
}
}
fn response_protocol_version(response: &crate::protocol::types::JsonRpcResponse) -> Option<String> {
if response.error.is_some() {
return None;
}
response
.result
.as_ref()
.and_then(|result| result.get("protocolVersion"))
.and_then(|value| value.as_str())
.map(ToOwned::to_owned)
}
/// Extract and validate the `Mcp-Session-Id` header value.
///
/// Only accepts valid UUID v4 format (8-4-4-4-12 hex) to prevent header
@ -209,6 +315,13 @@ async fn post_mcp(
headers: HeaderMap,
Json(request): Json<JsonRpcRequest>,
) -> impl IntoResponse {
if let Err((status, msg)) = validate_origin(&headers, &state.allowed_origins) {
return (status, HeaderMap::new(), msg.to_string()).into_response();
}
if let Err((status, msg)) = validate_accept(&headers) {
return (status, HeaderMap::new(), msg.to_string()).into_response();
}
// Auth check
if let Err((status, msg)) = validate_auth(&headers, &state.auth_token) {
return (status, HeaderMap::new(), msg.to_string()).into_response();
@ -235,6 +348,7 @@ async fn post_mcp(
let session = Arc::new(Mutex::new(Session {
server,
last_active: Instant::now(),
protocol_version: crate::protocol::types::MCP_VERSION.to_string(),
}));
// Handle the initialize request
@ -243,12 +357,18 @@ async fn post_mcp(
sess.server.handle_request(request).await
};
// Insert session while still holding write lock — atomic check-and-insert
sessions.insert(session_id.clone(), session);
drop(sessions);
match response {
Some(resp) => {
let Some(protocol_version) = response_protocol_version(&resp) else {
drop(sessions);
return (StatusCode::OK, HeaderMap::new(), Json(resp)).into_response();
};
{
let mut sess = session.lock().await;
sess.protocol_version = protocol_version.clone();
}
sessions.insert(session_id.clone(), session);
drop(sessions);
let mut resp_headers = HeaderMap::new();
resp_headers.insert(
"mcp-session-id",
@ -256,18 +376,14 @@ async fn post_mcp(
.parse()
.unwrap_or_else(|_| axum::http::HeaderValue::from_static("invalid")),
);
if let Ok(value) = HeaderValue::from_str(&protocol_version) {
resp_headers.insert("mcp-protocol-version", value);
}
(StatusCode::OK, resp_headers, Json(resp)).into_response()
}
None => {
// Notifications return 202
let mut resp_headers = HeaderMap::new();
resp_headers.insert(
"mcp-session-id",
session_id
.parse()
.unwrap_or_else(|_| axum::http::HeaderValue::from_static("invalid")),
);
(StatusCode::ACCEPTED, resp_headers).into_response()
drop(sessions);
(StatusCode::ACCEPTED, HeaderMap::new()).into_response()
}
}
} else {
@ -295,8 +411,14 @@ async fn post_mcp(
}
};
let session_protocol_version;
let response = {
let mut sess = session.lock().await;
if let Err((status, msg)) = validate_protocol_version(&headers, &sess.protocol_version)
{
return (status, msg).into_response();
}
session_protocol_version = sess.protocol_version.clone();
sess.last_active = Instant::now();
sess.server.handle_request(request).await
};
@ -308,6 +430,9 @@ async fn post_mcp(
.parse()
.unwrap_or_else(|_| axum::http::HeaderValue::from_static("invalid")),
);
if let Ok(value) = HeaderValue::from_str(&session_protocol_version) {
resp_headers.insert("mcp-protocol-version", value);
}
match response {
Some(resp) => (StatusCode::OK, resp_headers, Json(resp)).into_response(),
@ -321,6 +446,9 @@ async fn delete_mcp(
State(state): State<HttpTransportState>,
headers: HeaderMap,
) -> impl IntoResponse {
if let Err((status, msg)) = validate_origin(&headers, &state.allowed_origins) {
return (status, msg).into_response();
}
if let Err((status, msg)) = validate_auth(&headers, &state.auth_token) {
return (status, msg).into_response();
}
@ -336,6 +464,22 @@ async fn delete_mcp(
}
};
let session = {
let sessions = state.sessions.read().await;
sessions.get(&session_id).cloned()
};
let Some(session) = session else {
return (StatusCode::NOT_FOUND, "Session not found").into_response();
};
let protocol_version = {
let sess = session.lock().await;
sess.protocol_version.clone()
};
if let Err((status, msg)) = validate_protocol_version(&headers, &protocol_version) {
return (status, msg).into_response();
}
let mut sessions = state.sessions.write().await;
if sessions.remove(&session_id).is_some() {
info!("Session {} deleted via DELETE /mcp", &session_id[..8]);
@ -344,3 +488,79 @@ async fn delete_mcp(
(StatusCode::NOT_FOUND, "Session not found").into_response()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn origin_validation_allows_absent_and_configured_origin() {
let allowed = vec!["http://127.0.0.1:3928".to_string()];
let mut headers = HeaderMap::new();
assert!(validate_origin(&headers, &allowed).is_ok());
headers.insert(
header::ORIGIN,
HeaderValue::from_static("http://127.0.0.1:3928"),
);
assert!(validate_origin(&headers, &allowed).is_ok());
headers.insert(
header::ORIGIN,
HeaderValue::from_static("http://evil.example"),
);
assert_eq!(
validate_origin(&headers, &allowed).unwrap_err().0,
StatusCode::FORBIDDEN
);
}
#[test]
fn accept_validation_rejects_incompatible_clients() {
let mut headers = HeaderMap::new();
assert_eq!(
validate_accept(&headers).unwrap_err().0,
StatusCode::NOT_ACCEPTABLE
);
headers.insert(
header::ACCEPT,
HeaderValue::from_static("application/json, text/event-stream"),
);
assert!(validate_accept(&headers).is_ok());
headers.insert(header::ACCEPT, HeaderValue::from_static("application/json"));
assert_eq!(
validate_accept(&headers).unwrap_err().0,
StatusCode::NOT_ACCEPTABLE
);
}
#[test]
fn protocol_header_must_match_session_when_present() {
let mut headers = HeaderMap::new();
assert_eq!(
validate_protocol_version(&headers, "2025-11-25")
.unwrap_err()
.0,
StatusCode::BAD_REQUEST
);
headers.insert(
"mcp-protocol-version",
HeaderValue::from_static("2025-11-25"),
);
assert!(validate_protocol_version(&headers, "2025-11-25").is_ok());
headers.insert(
"mcp-protocol-version",
HeaderValue::from_static("2024-11-05"),
);
assert_eq!(
validate_protocol_version(&headers, "2025-11-25")
.unwrap_err()
.0,
StatusCode::BAD_REQUEST
);
}
}

View file

@ -113,6 +113,8 @@ pub struct CallToolRequest {
pub struct CallToolResult {
pub content: Vec<ToolResultContent>,
#[serde(skip_serializing_if = "Option::is_none")]
pub structured_content: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub is_error: Option<bool>,
}

View file

@ -1,10 +1,9 @@
//! stdio Transport for MCP
//!
//! Handles JSON-RPC communication over stdin/stdout.
//! v1.9.2: Async tokio I/O with heartbeat and error resilience.
//! v1.9.2: Async tokio I/O with error resilience.
use std::io;
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tracing::{debug, error, info, warn};
@ -14,9 +13,6 @@ use crate::server::McpServer;
/// Maximum consecutive I/O errors before giving up
const MAX_CONSECUTIVE_ERRORS: u32 = 5;
/// Heartbeat interval — sends a ping notification to keep the connection alive
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30);
/// stdio Transport for MCP server
pub struct StdioTransport;
@ -25,7 +21,7 @@ impl StdioTransport {
Self
}
/// Run the MCP server over stdio with heartbeat and error resilience
/// Run the MCP server over stdio with error resilience.
pub async fn run(self, mut server: McpServer) -> Result<(), io::Error> {
let stdin = tokio::io::stdin();
let stdout = tokio::io::stdout();
@ -111,25 +107,10 @@ impl StdioTransport {
break;
}
// Brief pause before retrying
tokio::time::sleep(Duration::from_millis(100)).await;
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
}
}
_ = tokio::time::sleep(HEARTBEAT_INTERVAL) => {
// Send a heartbeat ping notification to keep the connection alive
let ping = "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/ping\"}\n";
if let Err(e) = stdout.write_all(ping.as_bytes()).await {
warn!("Failed to send heartbeat ping: {}", e);
consecutive_errors += 1;
if consecutive_errors >= MAX_CONSECUTIVE_ERRORS {
error!("Too many consecutive errors, shutting down");
break;
}
} else {
let _ = stdout.flush().await;
debug!("Heartbeat ping sent");
}
}
}
}

View file

@ -114,6 +114,10 @@ impl JsonRpcError {
Self::new(ErrorCode::MethodNotFound, message)
}
pub fn invalid_request(message: &str) -> Self {
Self::new(ErrorCode::InvalidRequest, message)
}
pub fn invalid_params(message: &str) -> Self {
Self::new(ErrorCode::InvalidParams, message)
}

View file

@ -43,7 +43,7 @@ fn build_instructions() -> String {
Every retrieval MUST be composed into a recommendation, never summarized.\
\n\nCOMPOSITION MANDATE: When you receive memories from search, deep_reference, \
cross_reference, or explore_connections, your response MUST follow this shape. \
(a) Composing: [memory IDs], followed by your composition logic (your chain-of-thought \
(a) Composing: [memory IDs], followed by a brief composition rationale \
about how the memories relate, NOT a restatement of their contents). \
(b) Never-composed detected: list combinations of retrieved memories that share \
tags/topics but have never been referenced together, or write 'None.' \
@ -64,6 +64,10 @@ fn build_instructions() -> String {
}
}
fn supported_protocol_versions() -> &'static [&'static str] {
&["2024-11-05", "2025-03-26", "2025-06-18", MCP_VERSION]
}
/// MCP Server implementation
pub struct McpServer {
storage: Arc<Storage>,
@ -113,6 +117,13 @@ impl McpServer {
pub async fn handle_request(&mut self, request: JsonRpcRequest) -> Option<JsonRpcResponse> {
debug!("Handling request: {}", request.method);
if request.id.is_none() {
if request.method != "notifications/initialized" {
debug!("Dropping JSON-RPC notification '{}'", request.method);
}
return None;
}
// Check initialization for non-initialize requests
if !self.initialized
&& request.method != "initialize"
@ -130,10 +141,9 @@ impl McpServer {
let result = match request.method.as_str() {
"initialize" => self.handle_initialize(request.params).await,
"notifications/initialized" => {
// Notification, no response needed
return None;
}
"notifications/initialized" => Err(JsonRpcError::invalid_request(
"notifications/initialized must be sent without an id",
)),
"tools/list" => self.handle_tools_list().await,
"tools/call" => self.handle_tools_call(request.params).await,
"resources/list" => self.handle_resources_list().await,
@ -159,20 +169,27 @@ impl McpServer {
let request: InitializeRequest = match params {
Some(p) => serde_json::from_value(p)
.map_err(|e| JsonRpcError::invalid_params(&e.to_string()))?,
None => InitializeRequest::default(),
None => {
return Err(JsonRpcError::invalid_params(
"initialize params are required",
));
}
};
// Version negotiation: use client's version if older than server's
// Claude Desktop rejects servers with newer protocol versions
let negotiated_version = if request.protocol_version.as_str() < MCP_VERSION {
info!(
"Client requested older protocol version {}, using it",
request.protocol_version
);
request.protocol_version.clone()
} else {
MCP_VERSION.to_string()
};
let negotiated_version =
if supported_protocol_versions().contains(&request.protocol_version.as_str()) {
info!(
"Client requested supported protocol version {}, using it",
request.protocol_version
);
request.protocol_version.clone()
} else {
info!(
"Client requested unsupported protocol version {}, using {}",
request.protocol_version, MCP_VERSION
);
MCP_VERSION.to_string()
};
self.initialized = true;
info!(
@ -207,7 +224,7 @@ impl McpServer {
/// Handle tools/list request
async fn handle_tools_list(&self) -> Result<serde_json::Value, JsonRpcError> {
// v2.1.2+: 25 tools (verified by the `tools.len() == 25` assertion in the
// v2.1.21: 25 tools (verified by the `tools.len() == 25` assertion in the
// handle_tools_list test below — the `suppress` tool landed in v2.0.5).
// Deprecated tools still work via redirects in handle_tools_call.
let tools = vec![
@ -386,6 +403,13 @@ impl McpServer {
.map_err(|e| JsonRpcError::invalid_params(&e.to_string()))?,
None => return Err(JsonRpcError::invalid_params("Missing tool call parameters")),
};
if let Some(arguments) = &request.arguments
&& !arguments.is_object()
{
return Err(JsonRpcError::invalid_params(
"tools/call arguments must be an object",
));
}
// Record activity on every tool call (non-blocking)
if let Ok(mut cog) = self.cognitive.try_lock() {
@ -554,14 +578,19 @@ impl McpServer {
}
"delete_knowledge" => {
warn!(
"Tool 'delete_knowledge' is deprecated. Use 'memory' with action='delete' instead."
"Tool 'delete_knowledge' is deprecated. Use 'memory' with action='purge', confirm=true instead."
);
let unified_args = match request.arguments {
Some(ref args) => {
let id = args.get("id").cloned().unwrap_or(serde_json::Value::Null);
let confirm = args
.get("confirm")
.cloned()
.unwrap_or(serde_json::Value::Bool(false));
Some(serde_json::json!({
"action": "delete",
"id": id
"id": id,
"confirm": confirm
}))
}
None => None,
@ -845,7 +874,7 @@ impl McpServer {
"suppress" => tools::suppress::execute(&self.storage, request.arguments).await,
name => {
return Err(JsonRpcError::method_not_found_with_message(&format!(
return Err(JsonRpcError::invalid_params(&format!(
"Unknown tool: {}",
name
)));
@ -868,17 +897,20 @@ impl McpServer {
text: serde_json::to_string_pretty(&content)
.unwrap_or_else(|_| content.to_string()),
}],
structured_content: Some(content),
is_error: Some(false),
};
serde_json::to_value(call_result)
.map_err(|e| JsonRpcError::internal_error(&e.to_string()))
}
Err(e) => {
let error_content = serde_json::json!({ "error": e });
let call_result = CallToolResult {
content: vec![crate::protocol::messages::ToolResultContent {
content_type: "text".to_string(),
text: serde_json::json!({ "error": e }).to_string(),
text: error_content.to_string(),
}],
structured_content: Some(error_content),
is_error: Some(true),
};
serde_json::to_value(call_result)
@ -1043,7 +1075,15 @@ impl McpServer {
serde_json::to_value(result)
.map_err(|e| JsonRpcError::internal_error(&e.to_string()))
}
Err(e) => Err(JsonRpcError::internal_error(&e)),
Err(e) => {
if e.to_ascii_lowercase().contains("unknown")
|| e.to_ascii_lowercase().contains("not found")
{
Err(JsonRpcError::resource_not_found(uri))
} else {
Err(JsonRpcError::internal_error(&e))
}
}
}
}
@ -1171,7 +1211,21 @@ impl McpServer {
.to_string();
match action {
"delete" | "purge" => {
self.emit(VestigeEvent::MemoryDeleted { id, timestamp: now });
if result
.get("success")
.and_then(|value| value.as_bool())
.unwrap_or(false)
{
let node_id = result
.get("nodeId")
.and_then(|value| value.as_str())
.unwrap_or(&id)
.to_string();
self.emit(VestigeEvent::MemoryDeleted {
id: node_id,
timestamp: now,
});
}
}
"promote" => {
let retention = result
@ -1385,6 +1439,26 @@ mod tests {
}
}
fn make_notification(method: &str, params: Option<serde_json::Value>) -> JsonRpcRequest {
JsonRpcRequest {
jsonrpc: "2.0".to_string(),
id: None,
method: method.to_string(),
params,
}
}
fn init_params() -> serde_json::Value {
serde_json::json!({
"protocolVersion": MCP_VERSION,
"capabilities": {},
"clientInfo": {
"name": "test-client",
"version": "1.0.0"
}
})
}
// ========================================================================
// INITIALIZATION TESTS
// ========================================================================
@ -1436,13 +1510,31 @@ mod tests {
}
#[tokio::test]
async fn test_initialize_with_default_params() {
async fn test_initialize_unsupported_protocol_falls_back_to_latest() {
let (mut server, _dir) = test_server().await;
let params = serde_json::json!({
"protocolVersion": "1.0.0",
"capabilities": {},
"clientInfo": { "name": "test", "version": "1.0" }
});
let request = make_request("initialize", Some(params));
let response = server.handle_request(request).await.unwrap();
let result = response.result.unwrap();
assert_eq!(result["protocolVersion"], MCP_VERSION);
}
#[tokio::test]
async fn test_initialize_missing_params_returns_error() {
let (mut server, _dir) = test_server().await;
let request = make_request("initialize", None);
let response = server.handle_request(request).await.unwrap();
assert!(response.result.is_some());
assert!(response.error.is_none());
assert!(response.result.is_none());
assert!(response.error.is_some());
assert_eq!(response.error.unwrap().code, -32602);
assert!(!server.initialized);
}
// ========================================================================
@ -1482,17 +1574,39 @@ mod tests {
let (mut server, _dir) = test_server().await;
// First initialize
let init_request = make_request("initialize", None);
let init_request = make_request("initialize", Some(init_params()));
server.handle_request(init_request).await;
// Send initialized notification
let notification = make_request("notifications/initialized", None);
let notification = make_notification("notifications/initialized", None);
let response = server.handle_request(notification).await;
// Notifications should return None
assert!(response.is_none());
}
#[tokio::test]
async fn test_initialized_notification_with_id_returns_invalid_request() {
let (mut server, _dir) = test_server().await;
let request = make_request("notifications/initialized", None);
let response = server.handle_request(request).await.unwrap();
assert!(response.error.is_some());
assert_eq!(response.error.unwrap().code, -32600);
}
#[tokio::test]
async fn test_notification_does_not_emit_response_or_side_effect() {
let (mut server, _dir) = test_server().await;
let notification = make_notification("initialize", None);
let response = server.handle_request(notification).await;
assert!(response.is_none());
assert!(!server.initialized);
}
// ========================================================================
// TOOLS/LIST TESTS
// ========================================================================
@ -1502,7 +1616,7 @@ mod tests {
let (mut server, _dir) = test_server().await;
// Initialize first
let init_request = make_request("initialize", None);
let init_request = make_request("initialize", Some(init_params()));
server.handle_request(init_request).await;
let request = make_request("tools/list", None);
@ -1511,8 +1625,8 @@ mod tests {
let result = response.result.unwrap();
let tools = result["tools"].as_array().unwrap();
// v2.1.2: 25 tools (adds first-class contradictions surface)
assert_eq!(tools.len(), 25, "Expected exactly 25 tools in v2.1.2+");
// v2.1.21: 25 tools (includes first-class contradictions surface)
assert_eq!(tools.len(), 25, "Expected exactly 25 tools in v2.1.21");
let tool_names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
@ -1592,7 +1706,7 @@ mod tests {
async fn test_tools_have_descriptions_and_schemas() {
let (mut server, _dir) = test_server().await;
let init_request = make_request("initialize", None);
let init_request = make_request("initialize", Some(init_params()));
server.handle_request(init_request).await;
let request = make_request("tools/list", None);
@ -1622,7 +1736,7 @@ mod tests {
async fn test_resources_list_returns_all_resources() {
let (mut server, _dir) = test_server().await;
let init_request = make_request("initialize", None);
let init_request = make_request("initialize", Some(init_params()));
server.handle_request(init_request).await;
let request = make_request("resources/list", None);
@ -1651,7 +1765,7 @@ mod tests {
async fn test_resources_have_descriptions() {
let (mut server, _dir) = test_server().await;
let init_request = make_request("initialize", None);
let init_request = make_request("initialize", Some(init_params()));
server.handle_request(init_request).await;
let request = make_request("resources/list", None);
@ -1679,7 +1793,7 @@ mod tests {
let (mut server, _dir) = test_server().await;
// Initialize first
let init_request = make_request("initialize", None);
let init_request = make_request("initialize", Some(init_params()));
server.handle_request(init_request).await;
let request = make_request("unknown/method", None);
@ -1695,7 +1809,7 @@ mod tests {
async fn test_unknown_tool_returns_error() {
let (mut server, _dir) = test_server().await;
let init_request = make_request("initialize", None);
let init_request = make_request("initialize", Some(init_params()));
server.handle_request(init_request).await;
let request = make_request(
@ -1708,7 +1822,7 @@ mod tests {
let response = server.handle_request(request).await.unwrap();
assert!(response.error.is_some());
assert_eq!(response.error.unwrap().code, -32601);
assert_eq!(response.error.unwrap().code, -32602);
}
// ========================================================================
@ -1719,7 +1833,7 @@ mod tests {
async fn test_ping_returns_empty_object() {
let (mut server, _dir) = test_server().await;
let init_request = make_request("initialize", None);
let init_request = make_request("initialize", Some(init_params()));
server.handle_request(init_request).await;
let request = make_request("ping", None);
@ -1738,7 +1852,7 @@ mod tests {
async fn test_tools_call_missing_params_returns_error() {
let (mut server, _dir) = test_server().await;
let init_request = make_request("initialize", None);
let init_request = make_request("initialize", Some(init_params()));
server.handle_request(init_request).await;
let request = make_request("tools/call", None);
@ -1752,7 +1866,7 @@ mod tests {
async fn test_tools_call_invalid_params_returns_error() {
let (mut server, _dir) = test_server().await;
let init_request = make_request("initialize", None);
let init_request = make_request("initialize", Some(init_params()));
server.handle_request(init_request).await;
let request = make_request(
@ -1766,4 +1880,24 @@ mod tests {
assert!(response.error.is_some());
assert_eq!(response.error.unwrap().code, -32602);
}
#[tokio::test]
async fn test_tools_call_rejects_non_object_arguments() {
let (mut server, _dir) = test_server().await;
let init_request = make_request("initialize", Some(init_params()));
server.handle_request(init_request).await;
let request = make_request(
"tools/call",
Some(serde_json::json!({
"name": "search",
"arguments": "not-an-object"
})),
);
let response = server.handle_request(request).await.unwrap();
assert!(response.error.is_some());
assert_eq!(response.error.unwrap().code, -32602);
}
}

View file

@ -6,12 +6,31 @@
use chrono::{NaiveDate, Utc};
use serde::Deserialize;
use serde_json::Value;
use std::path::Path;
use std::sync::Arc;
use tokio::sync::Mutex;
use crate::cognitive::CognitiveEngine;
use vestige_core::{FSRSScheduler, Storage};
fn create_private_file(path: &Path) -> std::io::Result<std::fs::File> {
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)
}
#[cfg(not(unix))]
{
std::fs::File::create(path)
}
}
// ============================================================================
// SCHEMAS
// ============================================================================
@ -484,7 +503,7 @@ pub async fn execute_export(storage: &Arc<Storage>, args: Option<Value>) -> Resu
};
// Write export
let file = std::fs::File::create(&export_path)
let file = create_private_file(&export_path)
.map_err(|e| format!("Failed to create export file: {}", e))?;
let mut writer = std::io::BufWriter::new(file);

View file

@ -44,7 +44,7 @@ pub fn schema() -> Value {
"action": {
"type": "string",
"enum": ["get", "get_batch", "delete", "purge", "state", "promote", "demote", "edit"],
"description": "Action to perform: 'get' retrieves full memory node, 'get_batch' retrieves multiple memories by IDs (use 'ids' array), 'purge' permanently removes memory content and embeddings after confirm=true, 'delete' is a backwards-compatible alias for purge, 'state' returns accessibility state, 'promote' increases retrieval strength (thumbs up), 'demote' decreases retrieval strength (thumbs down), 'edit' updates content in-place (preserves FSRS state)"
"description": "Action to perform: 'get' retrieves full memory node, 'get_batch' retrieves multiple memories by IDs (use 'ids' array), 'purge' permanently removes memory content and embeddings after confirm=true, 'delete' is a backwards-compatible alias for purge and also requires confirm=true, 'state' returns accessibility state, 'promote' increases retrieval strength (thumbs up), 'demote' decreases retrieval strength (thumbs down), 'edit' updates content in-place (preserves FSRS state)"
},
"id": {
"type": "string",
@ -61,7 +61,7 @@ pub fn schema() -> Value {
},
"confirm": {
"type": "boolean",
"description": "Required for action='purge'. Purge permanently removes memory content and embeddings; only a non-content tombstone remains.",
"description": "Required for action='purge' and action='delete'. Purge/delete permanently removes memory content and embeddings; only a non-content tombstone remains.",
"default": false
},
"content": {
@ -116,7 +116,16 @@ pub async fn execute(
match args.action.as_str() {
"get" => execute_get(storage, &id).await,
"delete" => execute_purge(storage, &id, args.reason, true, "delete").await,
"delete" => {
execute_purge(
storage,
&id,
args.reason,
args.confirm.unwrap_or(false),
"delete",
)
.await
}
"purge" => {
execute_purge(
storage,
@ -604,10 +613,21 @@ mod tests {
}
#[tokio::test]
async fn test_delete_existing_memory() {
async fn test_delete_requires_confirm() {
let (storage, _dir) = test_storage().await;
let id = ingest_memory(&storage).await;
let args = serde_json::json!({ "action": "delete", "id": id });
let args = serde_json::json!({ "action": "delete", "id": id.clone() });
let result = execute(&storage, &test_cognitive(), Some(args)).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("confirm=true"));
assert!(storage.get_node(&id).unwrap().is_some());
}
#[tokio::test]
async fn test_delete_existing_memory_with_confirm() {
let (storage, _dir) = test_storage().await;
let id = ingest_memory(&storage).await;
let args = serde_json::json!({ "action": "delete", "id": id, "confirm": true });
let result = execute(&storage, &test_cognitive(), Some(args)).await;
assert!(result.is_ok());
let value = result.unwrap();
@ -628,8 +648,11 @@ mod tests {
.unwrap()
.id;
let _ = storage.delete_node(&warmup_id);
let args =
serde_json::json!({ "action": "delete", "id": "00000000-0000-0000-0000-000000000000" });
let args = serde_json::json!({
"action": "delete",
"id": "00000000-0000-0000-0000-000000000000",
"confirm": true
});
let result = execute(&storage, &test_cognitive(), Some(args)).await;
assert!(result.is_ok());
let value = result.unwrap();
@ -641,7 +664,7 @@ mod tests {
async fn test_delete_then_get_returns_not_found() {
let (storage, _dir) = test_storage().await;
let id = ingest_memory(&storage).await;
let del_args = serde_json::json!({ "action": "delete", "id": id });
let del_args = serde_json::json!({ "action": "delete", "id": id, "confirm": true });
execute(&storage, &test_cognitive(), Some(del_args))
.await
.unwrap();

View file

@ -0,0 +1,81 @@
# Agent Memory Protocol
> Minimal instructions for any MCP-compatible agent using Vestige.
Vestige is an MCP server, not a Claude-specific workflow. Register `vestige-mcp`
with your client, then give the agent a short instruction that makes memory part
of its normal reasoning loop.
## Register Vestige
Use your client's MCP server configuration format. The command is the same:
```json
{
"mcpServers": {
"vestige": {
"command": "vestige-mcp"
}
}
}
```
Examples:
```bash
claude mcp add vestige vestige-mcp -s user
codex mcp add vestige -- vestige-mcp
```
## Agent Instruction
Add this to the agent's global or project instruction file:
```text
Use Vestige as durable local memory.
At the start of a new session, call `session_context` with the current user,
project, and task context. If `session_context` is unavailable or too broad, call
`search` with a concrete query matching the current task.
When accuracy or prior decisions matter, call `deep_reference`. When memories may
conflict, call `contradictions` before answering. Compose retrieved evidence into
the answer; do not merely paste memory summaries.
Save durable preferences, project decisions, recurring corrections, stable facts,
and reusable code patterns with `smart_ingest`. Do not store secrets, credentials,
one-off logs, speculation, or transient command output.
When the user says a memory was useful, call `memory` with `action="promote"`.
When the user says a memory was wrong or unhelpful, call `memory` with
`action="demote"`. When the user explicitly asks to erase a memory permanently,
call `memory` with `action="purge"` and `confirm=true`.
```
## Practical Tool Choices
| Situation | Tool |
|-----------|------|
| Start of session | `session_context` |
| Find exact identifiers, paths, env vars, names | `search` |
| Answer from prior decisions or evolving facts | `deep_reference` |
| Inspect disagreements before answering | `contradictions` |
| Save a preference, decision, correction, or code pattern | `smart_ingest` |
| Retrieve, promote, demote, edit, or purge one memory | `memory` |
| Create a future reminder | `intention` |
| Check health or maintenance state | `system_status` |
## What Not To Store
- API keys, tokens, passwords, private keys, or session cookies.
- Raw logs or command output unless the durable lesson is extracted first.
- Guesswork the agent has not verified.
- Temporary plans that will be obsolete after the current session.
- User data the user asked not to retain.
## Portability Notes
The same protocol applies to Claude Code, Codex, Cursor, VS Code, Xcode,
JetBrains, Windsurf, and any other client that can run a stdio MCP server. Claude
Code's Cognitive Sandwich hooks are optional companion files; they are not
required for normal Vestige memory.

View file

@ -37,7 +37,7 @@ Sanhedrin, preflight, and all Vestige Claude Code hooks are optional. The defaul
3. **Claude reads the assembled context and generates a draft.**
4. **By default, no Vestige Stop hooks are installed.** If explicitly enabled, Stop hooks fire serially (any can VETO with `exit 2`, forcing a rewrite):
- `veto-detector.sh` — fast regex against `veto`-tagged Vestige memories (~50ms)
- `sanhedrin.sh``sanhedrin-local.py` — optional single-shot semantic verdict
- `sanhedrin.sh``sanhedrin-local.py` — optional Sanhedrin verifier
- `synthesis-stop-validator.sh` — regex against forbidden patterns (hedging, summary-instead-of-composition)
5. **If all enabled Stop hooks return `exit 0`, the response is delivered.**
@ -45,19 +45,44 @@ Sanhedrin, preflight, and all Vestige Claude Code hooks are optional. The defaul
## The Sanhedrin Executioner protocol
The Executioner extracts atomic claims from Claude's draft across 10 classes:
Sanhedrin has two execution modes:
- **Legacy mode** (`VESTIGE_SANHEDRIN_CLAIM_MODE=0`) keeps the original broad draft-level semantic check for technical-looking responses.
- **Claim mode** (`VESTIGE_SANHEDRIN_CLAIM_MODE=1`) extracts check-worthy claims, retrieves Vestige evidence per claim, and aggregates structured verdicts before the Stop hook allows delivery.
The claim-mode Executioner extracts atomic claims from Claude's draft across these classes:
`TECHNICAL` · `BIOGRAPHICAL` · `FINANCIAL` · `ACHIEVEMENT` · `TIMELINE` · `QUANTITATIVE` · `ATTRIBUTION` · `CAUSAL` · `COMPARATIVE` · `EXISTENTIAL` · plus v2.1.0 additions: `VAGUE-QUANTIFIER` · `UNVERIFIED-POSITIVE`
For each claim, it checks Vestige's `deep_reference` for high-trust contradicting memories. Decision rules:
For each check-worthy claim, claim mode calls Vestige's `/api/deep_reference` and judges the claim against high-trust durable evidence plus any optional staged evidence overlay. Decision rules:
| Class | Rule |
|---|---|
| TECHNICAL / EXISTENTIAL / TIMELINE | VETO if memory trust > 0.55 directly contradicts |
| BIOGRAPHICAL / FINANCIAL / ACHIEVEMENT / ATTRIBUTION | VETO if contradicted OR if factual-shaped with zero supporting evidence (fail-closed) |
| **VAGUE-QUANTIFIER** | VETO on vague achievement or financial claims without enumeration |
| TECHNICAL / EXISTENTIAL / CAUSAL / COMPARATIVE | VETO only on same-subject durable contradiction; missing memory is `NEI` |
| BIOGRAPHICAL / FINANCIAL / ACHIEVEMENT / TIMELINE / QUANTITATIVE / ATTRIBUTION / VAGUE-QUANTIFIER about the user | zero high-trust durable evidence is `REFUTED_BY_ABSENCE` and blocks |
| **VAGUE-QUANTIFIER** | VETO on vague achievement or financial claims without durable enumeration |
| **UNVERIFIED-POSITIVE** | VETO on specific named institutions/dates/employers not in evidence |
Structured verdicts:
| Verdict | Meaning |
|---|---|
| `SUPPORTED` | High-trust evidence supports or does not contradict the claim |
| `REFUTED` | High-trust durable evidence directly contradicts the same-subject claim |
| `REFUTED_BY_ABSENCE` | User-critical claim has no high-trust durable Vestige evidence |
| `NEI` | Not enough information; allow unless another claim blocks |
The bridge still prints legacy one-line `yes` / `no - ...` by default for Stop-hook compatibility. With `VESTIGE_SANHEDRIN_OUTPUT=json`, it emits structured JSON containing `decision`, `reason`, and per-claim verdicts. `sanhedrin.sh` can parse either format.
### Staged evidence overlay
`VESTIGE_SANHEDRIN_STAGE_FILE` may point to a JSON array of current-turn evidence candidates. Sanhedrin can read this staged evidence as context, but staged evidence is deliberately non-durable:
- it never calls `smart_ingest`
- it cannot promote, demote, merge, suppress, or supersede durable memories
- it does not satisfy the durable-evidence requirement for `REFUTED_BY_ABSENCE`
- durable memory writes remain a separate commit-after-pass step
False-positive guards (added v2.1.0 after dogfood):
- Subject-equality gate (memory about Vestige codebase ≠ contradiction with external tools)
- Version-discriminator rule (M3 Max ≠ M5 Max; Qwen3.5 ≠ Qwen3.6)
@ -75,10 +100,12 @@ False-positive guards (added v2.1.0 after dogfood):
vestige sandwich install
```
`vestige update` also refreshes these companion files by default after it updates
the binaries. The default command does not activate any Claude Code hook. It
removes old v2.1.0 Vestige hook wiring from `~/.claude/settings.json` while
preserving unrelated user hooks.
`vestige update` updates binaries only by default. To refresh these optional
Claude Code companion files during an update, run
`vestige update --sandwich-companion`. The companion installer does not activate
any Claude Code hook unless you pass an explicit opt-in flag. It removes old
v2.1.0 Vestige hook wiring from `~/.claude/settings.json` while preserving
unrelated user hooks.
### From a checkout
@ -122,7 +149,7 @@ vestige sandwich install \
|---|---|
| Python 3.10+ | typically preinstalled |
| `jq` | `brew install jq` |
| `vestige-mcp` | `cargo install vestige-mcp` |
| `vestige-mcp` | `npm install -g vestige-mcp-server` |
| Claude Code | https://claude.ai/code |
Optional Apple Silicon local Sanhedrin backend:
@ -158,7 +185,8 @@ cp ~/.claude/settings.json.bak.pre-sandwich ~/.claude/settings.json
## Performance notes
Optional local MLX backend on M3 Max 16-core (400 GB/s memory bandwidth):
- Sanhedrin verdict: 515 seconds end-to-end (single deep_reference + single Qwen call)
- Legacy Sanhedrin verdict: 515 seconds end-to-end (single deep_reference + single Qwen call)
- Claim mode: one `/api/deep_reference` call per extracted check-worthy claim, capped by `VESTIGE_SANHEDRIN_MAX_CLAIMS`
- mlx_lm.server token generation: ~82 tok/s
- mlx_lm.server peak resident memory: ~19.7 GB
- Cold model load: ~5 seconds
@ -176,6 +204,11 @@ On M3 Max 14-core or M2/M1 Max: closer to 37s prompt processing, ~5060 tok
| `VESTIGE_DASHBOARD_PORT` | `3927` | Vestige MCP HTTP API port used by hooks |
| `VESTIGE_SANHEDRIN_ENDPOINT` | `http://127.0.0.1:8080/v1/chat/completions` | OpenAI-compatible chat completions endpoint for Sanhedrin |
| `VESTIGE_SANHEDRIN_MODEL` | `mlx-community/Qwen3.6-35B-A3B-4bit` | Model name sent to the Sanhedrin endpoint |
| `VESTIGE_SANHEDRIN_CLAIM_MODE` | `1` when installed with `--enable-sanhedrin` | Enables per-claim retrieval and fail-closed user-critical lanes |
| `VESTIGE_SANHEDRIN_OUTPUT` | `json` when installed with `--enable-sanhedrin` | Emits structured JSON from the bridge; shell hook also accepts legacy text |
| `VESTIGE_SANHEDRIN_STAGE_FILE` | unset | Optional JSON-array staged evidence overlay, read-only and non-durable |
| `VESTIGE_SANHEDRIN_MAX_CLAIMS` | `8` | Max check-worthy claims adjudicated per draft |
| `VESTIGE_SANHEDRIN_PYTHON` | `python3` from `PATH` | Optional Python interpreter override for the Stop hook bridge |
| `MLX_ENDPOINT` / `VESTIGE_SANDWICH_MODEL` | legacy aliases | Backward-compatible names still read by the bridge |
| `VESTIGE_MEMORY_DIR` | (auto) | Override per-user Claude memory dir |

View file

@ -16,7 +16,7 @@ The embedding model is cached in platform-specific directories:
| Platform | Cache Location |
|----------|----------------|
| macOS | `~/Library/Caches/com.vestige.core/fastembed` |
| macOS | `~/Library/Caches/vestige/fastembed` |
| Linux | `~/.cache/vestige/fastembed` |
| Windows | `%LOCALAPPDATA%\vestige\cache\fastembed` |
@ -36,10 +36,12 @@ Qwen3 currently uses Hugging Face Hub's Candle loader directly, so use the stand
| `VESTIGE_DATA_DIR` | OS per-user data directory | Storage directory fallback; overridden by `--data-dir`; database lives at `<dir>/vestige.db` |
| `VESTIGE_EMBEDDING_MODEL` | `nomic-v1.5` | Embedding backend selector. Use `qwen3-0.6b` with a build that enables `qwen3-embeddings` |
| `RUST_LOG` | `info` (via tracing-subscriber) | Log verbosity + per-module filtering |
| `FASTEMBED_CACHE_PATH` | `./.fastembed_cache` | Embedding model cache location |
| `FASTEMBED_CACHE_PATH` | Platform cache directory; `./.fastembed_cache` fallback | Embedding model cache location |
| `VESTIGE_DASHBOARD_PORT` | `3927` | Dashboard HTTP + WebSocket port |
| `VESTIGE_HTTP_PORT` | `3928` | Optional MCP-over-HTTP port |
| `VESTIGE_HTTP_ENABLED` | `false` | Set `true` or `1` to enable optional MCP-over-HTTP |
| `VESTIGE_HTTP_PORT` | `3928` | Optional MCP-over-HTTP port; `--http-port` also enables HTTP |
| `VESTIGE_HTTP_BIND` | `127.0.0.1` | HTTP bind address |
| `VESTIGE_HTTP_ALLOWED_ORIGINS` | localhost origins for the HTTP port | Comma-separated browser origins allowed to call MCP-over-HTTP |
| `VESTIGE_AUTH_TOKEN` | auto-generated | Dashboard + MCP HTTP bearer auth |
| `VESTIGE_DASHBOARD_ENABLED` | `false` | Set `true` or `1` to enable the web dashboard |
| `VESTIGE_CONSOLIDATION_INTERVAL_HOURS` | `6` | FSRS-6 decay cycle cadence |
@ -175,18 +177,17 @@ See [Storage Modes](STORAGE.md) for more options.
vestige update
```
This updates `vestige`, `vestige-mcp`, `vestige-restore`, and the Cognitive
Sandwich companion files. The companion refresh keeps hooks disabled by default
and cleans up old mandatory v2.1.0 hook wiring.
This updates `vestige`, `vestige-mcp`, and `vestige-restore`. It does not mutate
Claude Code Cognitive Sandwich companion files unless you explicitly request it.
**Binaries only:**
**Also refresh optional Claude Code companion files:**
```bash
vestige update --no-sandwich
vestige update --sandwich-companion
```
**Pin to specific version:**
```bash
vestige update --version v2.1.1
vestige update --version v2.1.21
```
**Manage the optional Cognitive Sandwich layer without updating binaries:**

View file

@ -22,13 +22,13 @@
## Getting Started
<details>
<summary><b>"Can Vestige support a two-Claude household?"</b></summary>
<summary><b>"Can Vestige support multiple agents or MCP clients?"</b></summary>
**Yes!** See [Storage Modes](STORAGE.md#option-3-multi-claude-household). You can either:
- **Share memories**: Both Claudes point to the same `--data-dir`
- **Separate identities**: Each Claude gets its own data directory
**Yes.** See [Storage Modes](STORAGE.md#option-3-multi-agent-household). You can either:
- **Share memories**: Multiple agents point to the same `--data-dir`
- **Separate identities**: Each agent gets its own data directory
For two Claudes with distinct personas (e.g., "Domovoi" and "Storm") sharing the same human, use separate directories but consider a shared "household" memory for common knowledge.
For two agents with distinct roles sharing the same human, use separate directories but consider a shared "household" memory for common knowledge.
</details>
<details>
@ -38,28 +38,28 @@ For two Claudes with distinct personas (e.g., "Domovoi" and "Storm") sharing the
**For non-technical users:**
1. Have a technical friend do the 5-minute install
2. Add the CLAUDE.md instructions
3. Just talk to Claude normally—it handles the memory calls
2. Add the [agent memory protocol](AGENT-MEMORY-PROTOCOL.md) to your MCP client's instruction file
3. Just talk normally; the agent handles the memory calls
**The magic**: Once set up, you never think about it. Claude just... remembers.
**The magic**: Once set up, you never think about it. Your agent just remembers.
</details>
<details>
<summary><b>"What input do you feed it? How does it create memories?"</b></summary>
Claude creates memories via MCP tool calls. Three ways:
Your agent creates memories via MCP tool calls. Three ways:
1. **Explicit**: You say "Remember that I prefer dark mode" → Claude calls `smart_ingest`
2. **Automatic**: Claude notices something important → calls `smart_ingest` proactively
3. **Codebase**: Claude detects patterns/decisions → calls `remember_pattern` or `remember_decision`
1. **Explicit**: You say "Remember that I prefer dark mode" -> the agent calls `smart_ingest`
2. **Automatic**: The agent notices something important -> calls `smart_ingest` proactively
3. **Codebase**: The agent detects patterns/decisions -> calls `codebase(action="remember_pattern")` or `codebase(action="remember_decision")`
The CLAUDE.md instructions tell Claude when to create memories proactively.
The agent memory protocol tells the client when to create memories proactively.
</details>
<details>
<summary><b>"Can it be filled with a conversation stream in realtime?"</b></summary>
Not currently. Vestige is **tool-based**, not stream-based. Claude decides what's worth remembering, not everything gets saved.
Not currently. Vestige is **tool-based**, not stream-based. The agent decides what's worth remembering, not everything gets saved.
This is intentional—saving everything would:
- Bloat the knowledge base
@ -211,11 +211,9 @@ In Vestige's current implementation:
In Vestige's implementation:
```
importance(
memory_id="the-important-one",
event_type="user_flag", # or "emotional", "novelty", "repeated_access", "cross_reference"
hours_back=9, # Look back 9 hours (configurable)
hours_forward=2 # Capture next 2 hours too
importance_score(
content="the-important content",
context_topics=["release", "memory"]
)
```
@ -330,9 +328,9 @@ The unified `search` always uses hybrid, which gives you the best of both worlds
Three approaches:
1. **Mark as important**: `importance(memory_id="xxx", event_type="user_flag")`
1. **Mark as important**: `importance_score(content="...", event_type="user_flag")`
2. **Access regularly**: The Testing Effect strengthens memories each time you retrieve them
3. **Promote explicitly**: `promote_memory(id="xxx")` after it proves valuable
3. **Promote explicitly**: `memory(action="promote", id="xxx")` after it proves valuable
For truly critical information, consider also:
- Using specific tags like `["critical", "never-forget"]`
@ -549,13 +547,13 @@ Common issues:
| Feature | Notes App | Vestige |
|---------|-----------|---------|
| Retrieval | You search manually | Claude searches contextually |
| Retrieval | You search manually | The agent searches contextually |
| Decay | Everything stays forever | Unused knowledge fades naturally |
| Duplicates | You manage manually | Prediction Error Gating auto-merges |
| Context | Static text | Active part of AI reasoning |
| Strengthening | Manual review | Automatic via Testing Effect |
The key difference: **Vestige is part of Claude's cognitive loop.** Notes are external reference—Vestige is internal memory.
The key difference: **Vestige is part of the agent's cognitive loop.** Notes are external reference; Vestige is active working memory.
</details>
<details>
@ -619,7 +617,7 @@ Why Nomic:
- No API costs or rate limits
- Fast enough for real-time search
The model is cached at `~/.cache/huggingface/` after first run.
The model is cached in the platform user cache directory first, with `./.fastembed_cache` as a fallback. Set `FASTEMBED_CACHE_PATH` to choose a specific cache path.
</details>
<details>
@ -817,11 +815,11 @@ See [CLAUDE-SETUP.md](CLAUDE-SETUP.md) for the full template. The key elements:
**During Work**:
- Notice a pattern? `codebase(action="remember_pattern")`
- Made a decision? `codebase(action="remember_decision")` with rationale
- Something important? `importance()` to strengthen recent memories
- Something important? `importance_score(content="...")` to score it before saving or promoting
**Memory Hygiene**:
- When a memory helps: `promote_memory`
- When a memory misleads: `demote_memory`
- When a memory helps: `memory(action="promote", id="...")`
- When a memory misleads: `memory(action="demote", id="...")`
</details>
---

View file

@ -126,11 +126,9 @@ In Vestige's implementation:
In Vestige:
```
importance(
memory_id="the-important-one",
event_type="user_flag",
hours_back=9,
hours_forward=2
importance_score(
content="the-important content",
context_topics=["release", "memory"]
)
```
@ -183,7 +181,7 @@ This gives you exact keyword matching AND semantic understanding in one search.
- Runs 100% local (after first download)
- Competitive with OpenAI's ada-002
The model is cached at `~/.cache/huggingface/` after first run.
The model is cached in the platform user cache directory after first run, with `./.fastembed_cache` as a fallback. Set `FASTEMBED_CACHE_PATH` to choose a specific cache path.
---

View file

@ -1,6 +1,6 @@
# Storage Configuration
> Global, per-project, and multi-Claude setups
> Global, per-project, and multi-agent setups
---
@ -89,9 +89,9 @@ Separate memory per codebase. Good for:
- Different coding styles per project
- Team environments
**Claude Code Setup:**
**MCP Client Setup:**
Add to your project's `.claude/settings.local.json`:
Add an MCP server entry to your client or project config:
```json
{
"mcpServers": {
@ -131,11 +131,11 @@ For power users who want both global AND project memory:
}
```
### Option 3: Multi-Claude Household
### Option 3: Multi-Agent Household
For setups with multiple Claude instances (e.g., Claude Desktop + Claude Code, or two personas):
For setups with multiple MCP clients or agent personas:
**Shared Memory (Both Claudes share memories):**
**Shared Memory (all clients share memories):**
```json
{
"mcpServers": {
@ -147,27 +147,27 @@ For setups with multiple Claude instances (e.g., Claude Desktop + Claude Code, o
}
```
**Separate Identities (Each Claude has own memory):**
**Separate Identities (each agent has its own memory):**
Claude Desktop config - for "Domovoi":
Client config for "Research":
```json
{
"mcpServers": {
"vestige": {
"command": "vestige-mcp",
"args": ["--data-dir", "~/vestige-domovoi"]
"args": ["--data-dir", "~/vestige-research"]
}
}
}
```
Claude Code config - for "Storm":
Client config for "Builder":
```json
{
"mcpServers": {
"vestige": {
"command": "vestige-mcp",
"args": ["--data-dir", "~/vestige-storm"]
"args": ["--data-dir", "~/vestige-builder"]
}
}
}
@ -263,9 +263,9 @@ Internally the `Storage` type holds **separate reader and writer connections**,
| Pattern | Status | Notes |
|---------|--------|-------|
| One `vestige-mcp` + one Claude client | **Supported** | The default case. Zero contention. |
| Multiple Claude clients, separate `--data-dir` | **Supported** | Each process owns its own DB file. No shared state. |
| Multiple Claude clients, **shared** `--data-dir`, **one** `vestige-mcp` | **Supported** | Clients talk to a single MCP process that owns the DB. Recommended for multi-agent setups. |
| One `vestige-mcp` + one MCP client | **Supported** | The default case. Zero contention. |
| Multiple MCP clients, separate `--data-dir` | **Supported** | Each process owns its own DB file. No shared state. |
| Multiple MCP clients, **shared** `--data-dir`, **one** `vestige-mcp` | **Supported** | Clients talk to a single MCP process that owns the DB. Recommended for multi-agent setups. |
| CLI (`vestige` binary) reading while `vestige-mcp` runs | **Supported** | WAL makes this safe — queries see a consistent snapshot. |
| Time Machine / `rsync` backup during writes | **Supported** | WAL journal gets copied with the main file; recovery handles it. |

View file

@ -10,20 +10,20 @@ For current user-facing release information, use:
- `CHANGELOG.md`
- `docs/STORAGE.md`
- `docs/COGNITIVE_SANDWICH.md`
- `docs/AGENT-MEMORY-PROTOCOL.md`
- `docs/CLAUDE-SETUP.md`
## Current Release Shape
Vestige v2.1.2 is the "Honest Memory" release. Its public scope is:
Vestige v2.1.21 is the "Agent-Neutral Hardening" release. Its public scope is:
- concrete literal search for quoted strings, env vars, UUIDs, paths, and code
identifiers
- irreversible purge semantics with content-free deletion tombstones
- first-class contradiction inspection through the MCP `contradictions` tool
- the `vestige update` CLI flow for binary and Cognitive Sandwich updates
- dense dream connection persistence fixes
- embedding-model upgrade repair during consolidation
- an opt-in `/dashboard/waitlist` preview for Vestige Pro early access
- stdio MCP as the default agent transport, with HTTP MCP opt-in only
- binary-only `vestige update` by default
- delete and purge confirmation parity for destructive memory removal
- portable sync fixes for purge tombstones, UPSERT merge, and vector index
reloads
- safer release packaging with dashboard freshness checks and checksums
- agent-neutral memory instructions for any MCP-compatible client
The release keeps the local-first baseline intact. Heavy model hooks, local
verifier models, and preflight automation remain optional.
@ -69,23 +69,25 @@ Vestige is organized as:
- `packages/vestige-init`: installer helper
- `docs`: user and integration documentation
## v2.1.2 Implementation Notes
## v2.1.21 Implementation Notes
Concrete search is implemented in the MCP `search` tool and core SQLite
storage. Literal-looking queries use a keyword path instead of HyDE expansion,
semantic fusion, FSRS reweighting, retrieval competition, and spreading
activation.
HTTP MCP is disabled unless the user passes `--http`, passes `--http-port`, or
sets `VESTIGE_HTTP_ENABLED=1`. The stdio MCP server remains the portable default
for Claude Code, Codex, Cursor, VS Code, Xcode, JetBrains, Windsurf, and other
clients.
Purge is implemented transactionally in storage and surfaced through the MCP
`memory` tool. `memory(action="purge", confirm=true)` is the explicit hard
delete path. `delete` remains a backwards-compatible alias.
delete path. `delete` remains a backwards-compatible alias but also requires
`confirm=true`.
Contradictions are exposed as a first-class MCP tool and reuse the same trust
and topic-overlap logic used by the deeper reference pipeline.
Portable merge imports preserve both sync tombstones and non-content deletion
tombstones. Keyed table writes use UPSERT rather than `INSERT OR REPLACE` so
related rows are not accidentally cascaded away.
The waitlist preview is a dashboard route. Its capture and support endpoints
are controlled by opt-in public dashboard environment variables. If unset, the
page does not silently capture private signup data.
Claude Code Cognitive Sandwich files are optional companion files, not the
default Vestige setup path. Use `vestige update --sandwich-companion` or
`vestige sandwich install` only when that hook layer is wanted.
## 15. Autopilot Rationale

View file

@ -0,0 +1,72 @@
# Codex Intelligent Memory Protocol
Codex can connect to Vestige through MCP, but MCP registration alone only makes
the tools available. It does not make Codex automatically reason with memory.
Use this protocol when configuring a Codex workspace that should behave like it
has long-term cognitive memory.
## 1. Register Vestige MCP
```toml
[mcp_servers.vestige]
command = "/absolute/path/to/vestige-mcp"
```
Restart Codex after changing MCP configuration.
## 2. Add An `AGENTS.md` Trigger
Codex reads `AGENTS.md` files as workspace instructions. Put a file at the repo
root, or a higher workspace root, with a rule like:
```markdown
Before answering substantive prompts, consult Vestige using the current prompt
plus project and user context. Use `session_context` for broad context, `search`
for quick memory checks, and `deep_reference` for decisions, contradictions, or
accuracy-sensitive questions. Compose memories into actions; do not summarize
retrievals.
```
This is the Codex equivalent of the lightweight top-bread memory trigger.
## 3. Use A Query Router
Use the smallest call that can change the answer:
- `session_context`: start of a topic or project switch.
- `search`: identity, preference, exact memory, or quick project context.
- `deep_reference` / `cross_reference`: decision history, contradictions,
timelines, or root-cause analysis.
- `memory(get_batch)`: expand specific load-bearing memories.
- `smart_ingest`: save durable corrections, decisions, or new preferences.
## 4. Compose, Do Not Summarize
Retrieved memory is evidence, not the final answer.
Use this mental transform:
```text
memory fact -> implication -> action
```
If memory does not change the action, do not mention it. If it does, make the
changed recommendation clear.
## 5. Know The Limit
Claude Code's Cognitive Sandwich can use `UserPromptSubmit` and `Stop` hooks to
wrap every response. Codex may expose different hook events depending on version.
Do not assume Claude's hook chain is active in Codex just because Vestige MCP is
registered.
For Codex, the reliable portable layer is:
1. MCP server configured.
2. `AGENTS.md` instruction trigger.
3. Local Codex rule docs.
4. Explicit agent discipline: call Vestige before substantive answers.
If a future Codex version supports a stable pre-prompt hook, wire that hook to
inject a short Vestige reminder or context packet before the model answers.

View file

@ -89,6 +89,27 @@ args = ["--data-dir", "/Users/you/projects/my-app/.vestige"]
---
## Intelligent Memory Protocol
MCP registration makes Vestige tools available to Codex. It does not, by itself,
force Codex to call those tools before answering.
For workspaces where Codex should behave like it has persistent cognitive
memory, add an `AGENTS.md` file at the workspace or repo root:
```markdown
Before answering substantive prompts, consult Vestige using the current prompt
plus project and user context. Use `session_context` for broad context, `search`
for quick memory checks, and `deep_reference` for decisions, contradictions, or
accuracy-sensitive questions. Compose memories into actions; do not summarize
retrievals.
```
Then use the full protocol in
[`codex-intelligent-memory.md`](./codex-intelligent-memory.md).
---
## Troubleshooting
<details>

View file

@ -165,7 +165,7 @@ See [CLAUDE.md templates](../CLAUDE-SETUP.md) for a full setup.
The first time Vestige runs, it downloads the embedding model (~130MB). In Xcode's sandboxed environment, the cache location is:
```
~/Library/Caches/com.vestige.core/fastembed
~/Library/Caches/vestige/fastembed
```
If the download fails behind a corporate proxy, pre-download by running `vestige-mcp` once from your terminal.
@ -230,7 +230,7 @@ Xcode 26.3 has a feature gate (`claudeai-mcp`) that may block custom MCP servers
The first run downloads ~130MB. If Xcode's sandbox blocks the download:
1. Run `vestige-mcp` once from your terminal to cache the model
2. The cache at `~/Library/Caches/com.vestige.core/fastembed` will be available to the sandboxed instance
2. The cache at `~/Library/Caches/vestige/fastembed` will be available to the sandboxed instance
Behind a proxy:
```bash

View file

@ -318,7 +318,7 @@ SQLite is the most deployed database in the world for a reason. WAL mode gives u
### fastembed (Nomic Embed v1.5)
All embeddings run locally. The Nomic Embed v1.5 model produces 768-dimensional vectors, runs via ONNX Runtime, and is competitive with OpenAI's ada-002. The model is cached at `~/.cache/huggingface/` after first download (~130MB). No API keys. No network calls during operation. Your memories never leave your machine.
All embeddings run locally. The Nomic Embed v1.5 model produces 768-dimensional vectors, runs via ONNX Runtime, and is competitive with OpenAI's ada-002. The model is cached in the platform user cache directory after first download (~130MB), with `./.fastembed_cache` as a fallback. No API keys. No network calls during operation. Your memories never leave your machine.
### Performance

Some files were not shown because too many files have changed in this diff Show more