mirror of
https://github.com/samvallad33/vestige.git
synced 2026-06-08 20:25:16 +02:00
Prepare agent-neutral hardening release
This commit is contained in:
parent
9936928be9
commit
7eba0b1e97
117 changed files with 3679 additions and 513 deletions
81
.github/workflows/release.yml
vendored
81
.github/workflows/release.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
13
.github/workflows/test.yml
vendored
13
.github/workflows/test.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
64
CHANGELOG.md
64
CHANGELOG.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
4
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
57
README.md
57
README.md
|
|
@ -2,24 +2,35 @@
|
|||
|
||||
# Vestige
|
||||
|
||||
### The cognitive engine that gives AI agents a brain.
|
||||
### Local cognitive memory for MCP-compatible AI agents.
|
||||
|
||||
[](https://github.com/samvallad33/vestige)
|
||||
[](https://github.com/samvallad33/vestige/releases/latest)
|
||||
[](https://github.com/samvallad33/vestige/actions)
|
||||
[](https://github.com/samvallad33/vestige/actions)
|
||||
[](LICENSE)
|
||||
[](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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
BIN
apps/dashboard/build/_app/immutable/chunks/BTwePnbx.js.br
Normal file
BIN
apps/dashboard/build/_app/immutable/chunks/BTwePnbx.js.br
Normal file
Binary file not shown.
BIN
apps/dashboard/build/_app/immutable/chunks/BTwePnbx.js.gz
Normal file
BIN
apps/dashboard/build/_app/immutable/chunks/BTwePnbx.js.gz
Normal file
Binary file not shown.
1
apps/dashboard/build/_app/immutable/chunks/BdslOLCg.js
Normal file
1
apps/dashboard/build/_app/immutable/chunks/BdslOLCg.js
Normal file
File diff suppressed because one or more lines are too long
BIN
apps/dashboard/build/_app/immutable/chunks/BdslOLCg.js.br
Normal file
BIN
apps/dashboard/build/_app/immutable/chunks/BdslOLCg.js.br
Normal file
Binary file not shown.
BIN
apps/dashboard/build/_app/immutable/chunks/BdslOLCg.js.gz
Normal file
BIN
apps/dashboard/build/_app/immutable/chunks/BdslOLCg.js.gz
Normal file
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
BIN
apps/dashboard/build/_app/immutable/entry/app.DRELdRUq.js.br
Normal file
BIN
apps/dashboard/build/_app/immutable/entry/app.DRELdRUq.js.br
Normal file
Binary file not shown.
BIN
apps/dashboard/build/_app/immutable/entry/app.DRELdRUq.js.gz
Normal file
BIN
apps/dashboard/build/_app/immutable/entry/app.DRELdRUq.js.gz
Normal file
Binary file not shown.
|
|
@ -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};
|
||||
BIN
apps/dashboard/build/_app/immutable/entry/start.DfC8txIX.js.br
Normal file
BIN
apps/dashboard/build/_app/immutable/entry/start.DfC8txIX.js.br
Normal file
Binary file not shown.
BIN
apps/dashboard/build/_app/immutable/entry/start.DfC8txIX.js.gz
Normal file
BIN
apps/dashboard/build/_app/immutable/entry/start.DfC8txIX.js.gz
Normal file
Binary file not shown.
|
|
@ -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};
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
BIN
apps/dashboard/build/_app/immutable/nodes/0._nbDJIPC.js.br
Normal file
BIN
apps/dashboard/build/_app/immutable/nodes/0._nbDJIPC.js.br
Normal file
Binary file not shown.
Binary file not shown.
|
|
@ -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};
|
||||
BIN
apps/dashboard/build/_app/immutable/nodes/1.Bnre2dw5.js.br
Normal file
BIN
apps/dashboard/build/_app/immutable/nodes/1.Bnre2dw5.js.br
Normal file
Binary file not shown.
BIN
apps/dashboard/build/_app/immutable/nodes/1.Bnre2dw5.js.gz
Normal file
BIN
apps/dashboard/build/_app/immutable/nodes/1.Bnre2dw5.js.gz
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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
|
||||
BIN
apps/dashboard/build/_app/immutable/nodes/10.CecvzcnA.js.br
Normal file
BIN
apps/dashboard/build/_app/immutable/nodes/10.CecvzcnA.js.br
Normal file
Binary file not shown.
BIN
apps/dashboard/build/_app/immutable/nodes/10.CecvzcnA.js.gz
Normal file
BIN
apps/dashboard/build/_app/immutable/nodes/10.CecvzcnA.js.gz
Normal file
Binary file not shown.
File diff suppressed because one or more lines are too long
BIN
apps/dashboard/build/_app/immutable/nodes/11.BbfUOvv5.js.br
Normal file
BIN
apps/dashboard/build/_app/immutable/nodes/11.BbfUOvv5.js.br
Normal file
Binary file not shown.
BIN
apps/dashboard/build/_app/immutable/nodes/11.BbfUOvv5.js.gz
Normal file
BIN
apps/dashboard/build/_app/immutable/nodes/11.BbfUOvv5.js.gz
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
BIN
apps/dashboard/build/_app/immutable/nodes/20.BM_Hn1tR.js.br
Normal file
BIN
apps/dashboard/build/_app/immutable/nodes/20.BM_Hn1tR.js.br
Normal file
Binary file not shown.
BIN
apps/dashboard/build/_app/immutable/nodes/20.BM_Hn1tR.js.gz
Normal file
BIN
apps/dashboard/build/_app/immutable/nodes/20.BM_Hn1tR.js.gz
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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};
|
||||
BIN
apps/dashboard/build/_app/immutable/nodes/3.De3LPrRR.js.br
Normal file
BIN
apps/dashboard/build/_app/immutable/nodes/3.De3LPrRR.js.br
Normal file
Binary file not shown.
BIN
apps/dashboard/build/_app/immutable/nodes/3.De3LPrRR.js.gz
Normal file
BIN
apps/dashboard/build/_app/immutable/nodes/3.De3LPrRR.js.gz
Normal file
Binary file not shown.
File diff suppressed because one or more lines are too long
BIN
apps/dashboard/build/_app/immutable/nodes/6.BN-BfASZ.js.br
Normal file
BIN
apps/dashboard/build/_app/immutable/nodes/6.BN-BfASZ.js.br
Normal file
Binary file not shown.
BIN
apps/dashboard/build/_app/immutable/nodes/6.BN-BfASZ.js.gz
Normal file
BIN
apps/dashboard/build/_app/immutable/nodes/6.BN-BfASZ.js.gz
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1 +1 @@
|
|||
{"version":"1778051833240"}
|
||||
{"version":"2.1.21"}
|
||||
Binary file not shown.
Binary file not shown.
|
|
@ -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.
4
apps/dashboard/package-lock.json
generated
4
apps/dashboard/package-lock.json
generated
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@vestige/dashboard",
|
||||
"version": "2.1.2",
|
||||
"version": "2.1.21",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
81
docs/AGENT-MEMORY-PROTOCOL.md
Normal file
81
docs/AGENT-MEMORY-PROTOCOL.md
Normal 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.
|
||||
|
|
@ -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: 5–15 seconds end-to-end (single deep_reference + single Qwen call)
|
||||
- Legacy Sanhedrin verdict: 5–15 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 3–7s prompt processing, ~50–60 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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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:**
|
||||
|
|
|
|||
52
docs/FAQ.md
52
docs/FAQ.md
|
|
@ -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>
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
72
docs/integrations/codex-intelligent-memory.md
Normal file
72
docs/integrations/codex-intelligent-memory.md
Normal 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.
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue