diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6315588..793ce48 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,14 +50,93 @@ 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 + env: + RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }} + run: | + pnpm install --frozen-lockfile + pnpm --filter @vestige/dashboard check + pnpm --filter @vestige/dashboard test + pnpm --filter @vestige/dashboard build + node <<'NODE' + const fs = require('node:fs'); + const tag = process.env.RELEASE_TAG || ''; + const expected = tag.replace(/^refs\/tags\//, '').replace(/^v/, ''); + const versionFile = 'apps/dashboard/build/_app/version.json'; + const version = JSON.parse(fs.readFileSync(versionFile, 'utf8')).version; + if (version !== expected) { + throw new Error(`${versionFile} version ${version} does not match ${tag}`); + } + for (const file of ['apps/dashboard/build/index.html', versionFile]) { + if (!fs.existsSync(file)) { + throw new Error(`Dashboard build did not produce ${file}`); + } + } + NODE + - 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 +156,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 }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7f169eb..244fe52 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 351420c..dec084c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,194 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.1.25] - 2026-06-12 — "Merge / Supersede Controls" + +v2.1.25 ships Phase 3: diff-previewed, confidence-gated, reversible, +self-explaining combine/dedupe/supersede on a never-delete (bitemporal) store. +The default is always preview/review — these tools never silently mutate memory. +The differentiator is the reversible operation log: every merge/supersede/undo is +an auditable, reversible event with provenance ("why did these combine?") — a git +reflog for your agent's memory. + +### Added + +- **Seven new MCP tools** for merge/supersede control: + - `merge_candidates` — surface likely duplicate/overlapping clusters with + confidence scores and the signals behind each (Fellegi-Sunter + match/possible/non-match). Read-only. + - `plan_merge` — produce a previewable merge PLAN (a diff of combined + content/tags/provenance) without applying it. + - `plan_supersede` — preview superseding A with B (bitemporal invalidation, + audit-preserving) without applying. + - `apply_plan` — execute a previously-generated plan id; recorded as a + reversible operation. + - `merge_undo` — reverse a prior merge/supersede operation, or list the + reversible operation log (the "memory reflog"). + - `protect` — pin a memory so it can never be auto-merged, superseded, or + garbage-collected. + - `merge_policy` — get/set the per-project Fellegi-Sunter two thresholds + (`match_threshold`, `possible_threshold`) and `auto_apply`. +- **Bitemporal "invalidate, don't delete" supersede** (Graphiti-style): a + superseded memory is kept and stays queryable for audit. It is stamped with + `valid_until = now` and a new `superseded_by` lineage pointer, instead of being + deleted or merely demoted. +- **Reversible operation log** (`merge_operations` table) — every applied + merge/supersede records an undo payload and provenance signals so any operation + can be reversed, including restoring survivor content/tags and clearing the + bitemporal invalidation. +- **Fellegi-Sunter two-threshold scoring** for dedup/merge candidates, combining + embedding cosine similarity with tag and content-token overlap. Borderline + "possible" matches are surfaced for review instead of force-merged. +- **Memory protection / pinning** — `protected` column on `knowledge_nodes`; + protected memories are excluded from auto-merge/supersede/GC paths. +- **Migration V14** adding the `merge_plans` and `merge_operations` tables, the + `protected` and `superseded_by` columns on `knowledge_nodes`, and their + indexes. Idempotent on replay. +- **Docs**: `docs/MERGE_SUPERSEDE.md` describing the design, the bitemporal + model, the two-threshold policy, the reversible operation log, and the tool + surface. + +### Notes + +- All merge/supersede operations are **opt-in and preview-first**. `apply_plan` + requires `confirm=true` for `possible`/`non_match` plans, and only applies + `match` plans without confirmation when `merge_policy.auto_apply` is enabled + (default off). This deliberately avoids the silent-merge / auto-delete / + audit-trail-loss anti-patterns reported against other memory systems. +- The merge policy persists per-project and is also overridable via + `VESTIGE_MERGE_MATCH_THRESHOLD`, `VESTIGE_MERGE_POSSIBLE_THRESHOLD`, and + `VESTIGE_MERGE_AUTO_APPLY` environment variables. + +## [2.1.23] - 2026-05-27 — "Receipt Lock Hardening" + +v2.1.23 hardens the Sanhedrin launch path so Receipt Lock is portable, +observable, and precise enough for broader use. + +### Added + +- **Model-agnostic Sanhedrin backend presets** for custom OpenAI-compatible + servers, small laptops, Ollama, MLX, vLLM, llama.cpp, hosted APIs, and + Anthropic via LiteLLM. Sanhedrin no longer guesses a large default verifier. +- **Fail-open telemetry** in `fail-open.jsonl`, plus a dashboard telemetry API + and 7-day ambient dashboard counters for vetoes, appeals, and fail-open runs. +- **Receipt schema documentation** covering receipt artifacts, appeals, command + ledgers, fail-open logs, compatibility rules, and staged-evidence trust + boundaries. +- **Opt-in CUDA feature flags** for Qwen3 embedding builds on NVIDIA hardware. + +### Changed + +- Receipt Lock strips code fences, inline code, blockquotes, quoted regions, and + scoped epistemic hedges before matching verification claims. +- Structured transcript tool-use receipts are the default evidence path; loose + JSON command scanning now requires `VESTIGE_SANHEDRIN_ALLOW_LOOSE_LEDGER=1`. +- Claim-mode sampling now prioritizes higher-severity claims instead of taking + the first eight source-order claims. +- Hosted Sanhedrin credentials now require `VESTIGE_SANHEDRIN_API_KEY` and are + only sent to the configured Sanhedrin endpoint, never to Vestige retrieval. +- `smart_ingest` batch mode now defaults to `batchMergePolicy: "force_create"` + so caller-separated items stay separate unless callers opt into smart merging. +- CUDA-enabled Qwen3 builds now try `Device::new_cuda(0)` before falling back to + Metal or CPU. + +### Fixed + +- Standalone dashboard mode now hydrates the cognitive engine for Dream and + Deep Reference instead of returning 503s. +- `--data-dir` now rejects existing non-directory paths with a clear error. +- `vestige-restore` now handles `--help` and `--version` instead of treating + them as backup file paths. +- Smart ingest merge/update responses now include `previousContent`, + `mergedFrom`, and `mergePreview` so callers can inspect mutated memories. +- Daily Sanhedrin telemetry now preserves NOTE and CAUTION buckets instead of + folding both into PASS. + +## [2.1.22] - 2026-05-25 — "Sanhedrin Receipts" + +v2.1.22 makes the optional Sanhedrin hook quieter and more accountable by +turning draft judgment into local, appealable receipts instead of opaque vetoes. + +### Added + +- **Receipt Lock** blocks unsupported verification claims such as "tests passed" + unless the current transcript contains a matching successful test, build, + lint, or typecheck command receipt. +- **Veto receipts** are written to `~/.vestige/sanhedrin/latest.json` and + `latest.html` with Claim -> Verdict -> Precedent -> Fix -> Appeal fields. +- **Dashboard Verdict Bar** surfaces the latest PASS, NOTE, CAUTION, VETO, or + APPEALED state and lets users appeal stale, wrong, or too-strict vetoes. +- **Appeal training** records feedback in `appeals.jsonl` and suppresses future + vetoes for the same claim fingerprint. + +### Changed + +- Sanhedrin claim-mode output now feeds a per-claim receipt ledger while keeping + the existing one-line Stop-hook contract for Claude Code. + +## [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. diff --git a/CLAUDE.md.template b/CLAUDE.md.template index fefd005..e007ea6 100644 --- a/CLAUDE.md.template +++ b/CLAUDE.md.template @@ -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 diff --git a/Cargo.lock b/Cargo.lock index e7f9a05..33fe576 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -392,8 +392,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6bd9895436c1ba5dc1037a19935d084b838db066ff4e15ef7dded020b7c12a4a" dependencies = [ "byteorder", + "candle-kernels", "candle-metal-kernels", "candle-ug", + "cudarc 0.19.7", "float8", "gemm 0.19.0", "half", @@ -413,6 +415,15 @@ dependencies = [ "zip", ] +[[package]] +name = "candle-kernels" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "742e2ac226b777134436e9e692f44e77c278b8a7abb1554dc10e44dc911b349f" +dependencies = [ + "cudaforge", +] + [[package]] name = "candle-metal-kernels" version = "0.10.2" @@ -453,6 +464,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca0fc3167cbc99c8ec1be618cb620aa21dca95038f118c3579a79370e3dc5f77" dependencies = [ "ug", + "ug-cuda", "ug-metal", ] @@ -771,6 +783,46 @@ dependencies = [ "typenum", ] +[[package]] +name = "cudaforge" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f7a0d45b139b5beeeb1c34188717e12241c44a0120afb498815ce7f5373c691" +dependencies = [ + "anyhow", + "fs2", + "glob", + "num_cpus", + "rayon", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.18", + "walkdir", + "which", +] + +[[package]] +name = "cudarc" +version = "0.17.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf99ab37ee7072d64d906aa2dada9a3422f1d975cdf8c8055a573bc84897ed8" +dependencies = [ + "half", + "libloading 0.8.9", +] + +[[package]] +name = "cudarc" +version = "0.19.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cea5f10a99e025c1b44ae2354c2d8326b25ddbd0baf76bde8e55cfd4018a2cc" +dependencies = [ + "float8", + "half", + "libloading 0.9.0", +] + [[package]] name = "cxx" version = "1.0.194" @@ -1034,6 +1086,12 @@ dependencies = [ "syn", ] +[[package]] +name = "env_home" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" + [[package]] name = "equator" version = "0.4.2" @@ -1254,6 +1312,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -1632,6 +1700,12 @@ dependencies = [ "url", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "h2" version = "0.4.13" @@ -3752,6 +3826,17 @@ dependencies = [ "digest", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -4327,6 +4412,19 @@ dependencies = [ "yoke 0.7.5", ] +[[package]] +name = "ug-cuda" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f0a1fa748f26166778c33b8498255ebb7c6bffb472bcc0a72839e07ebb1d9b5" +dependencies = [ + "cudarc 0.17.8", + "half", + "serde", + "thiserror 1.0.69", + "ug", +] + [[package]] name = "ug-metal" version = "0.5.0" @@ -4531,7 +4629,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vestige-core" -version = "2.1.2" +version = "2.1.25" dependencies = [ "candle-core", "chrono", @@ -4567,7 +4665,7 @@ dependencies = [ [[package]] name = "vestige-mcp" -version = "2.1.2" +version = "2.1.25" dependencies = [ "anyhow", "axum", @@ -4792,6 +4890,18 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]] +name = "which" +version = "7.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762" +dependencies = [ + "either", + "env_home", + "rustix", + "winsafe", +] + [[package]] name = "winapi" version = "0.3.9" @@ -5058,6 +5168,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index af80c4c..1c89455 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ exclude = [ ] [workspace.package] -version = "2.1.2" +version = "2.1.25" edition = "2024" license = "AGPL-3.0-only" repository = "https://github.com/samvallad33/vestige" diff --git a/README.md b/README.md index 5177e72..f747715 100644 --- a/README.md +++ b/README.md @@ -2,111 +2,36 @@ # Vestige -### The cognitive engine that gives AI agents a brain. +### Local cognitive memory for MCP-compatible AI agents. [![GitHub stars](https://img.shields.io/github/stars/samvallad33/vestige?style=social)](https://github.com/samvallad33/vestige) [![Release](https://img.shields.io/github/v/release/samvallad33/vestige)](https://github.com/samvallad33/vestige/releases/latest) -[![Tests](https://img.shields.io/badge/tests-1229%20passing-brightgreen)](https://github.com/samvallad33/vestige/actions) +[![Tests](https://img.shields.io/badge/tests-passing-brightgreen)](https://github.com/samvallad33/vestige/actions) [![License](https://img.shields.io/badge/license-AGPL--3.0-blue)](LICENSE) [![MCP Compatible](https://img.shields.io/badge/MCP-compatible-green)](https://modelcontextprotocol.io) -**Your Agent forgets everything between sessions. Vestige fixes that.** +**Your agent forgets project decisions between sessions. Vestige gives it local, inspectable memory.** -Built on 130 years of memory research — FSRS-6 spaced repetition, prediction error gating, synaptic tagging, spreading activation, memory dreaming — all running in a single Rust binary with a 3D neural visualization dashboard. 100% local. Zero cloud. +Built on proven memory and retrieval ideas — FSRS-6 spaced repetition, prediction error gating, synaptic tagging, spreading activation, and memory consolidation — all running in a single Rust binary with a local dashboard. 100% local. Zero cloud. -[Quick Start](#quick-start) | [Dashboard](#-3d-memory-dashboard) | [How It Works](#-the-cognitive-science-stack) | [Tools](#-24-mcp-tools) | [Docs](docs/) +[Quick Start](#quick-start) | [Dashboard](#-3d-memory-dashboard) | [How It Works](#-the-cognitive-science-stack) | [Tools](#-25-mcp-tools) | [Docs](docs/) --- -## What's New in v2.1.2 "Honest Memory" +## What's New in v2.1.23 "Receipt Lock Hardening" -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. +v2.1.23 turns the Sanhedrin Receipt Lock launch into something more portable, +observable, and harder to spoof. -- **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. -- **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" - -v2.1.1 focuses on the biggest post-launch ask: move memories between machines without losing cognitive state. It also adds opt-in Qwen3 embeddings for higher-recall local retrieval. - -- **Exact portable archives.** `vestige portable-export` / `vestige portable-import` preserve IDs, FSRS state, graph edges, suppression state, audit rows, and embedding blobs for Vestige-to-Vestige device transfer. -- **Sync-safe merge storage.** `vestige portable-import --merge` and `vestige sync ` merge non-empty databases, apply delete tombstones, keep newer local memories, rebuild FTS, and push through a pluggable portable-sync backend. v2.1.1 ships the file backend for Dropbox, iCloud, Syncthing, Git, and shared folders. -- **Qwen3 embeddings.** Build with `qwen3-embeddings`, set `VESTIGE_EMBEDDING_MODEL=qwen3-0.6b`, and run `vestige consolidate` to re-embed existing memories. `vestige health` reports mixed-model stores before search quality is affected. -- **Model-aware retrieval.** Vestige now avoids comparing Qwen and Nomic vectors in the same search/dedup path. - -## What's New in v2.1.0 "Cognitive Sandwich Goes Local" - -v2.1.0 adds an opt-in Claude Code hook harness around the existing Vestige MCP server. The MCP tool surface and database schema stay backward compatible, while preflight hooks can inject trusted memory context before Claude answers. The heavyweight Sanhedrin verifier is optional and can be enabled separately. - -- **Optional Sanhedrin Executioner.** The post-response verifier is off by default. Users can enable it with an OpenAI-compatible endpoint on x86/Linux/Intel Mac, or add `--with-launchd` on Apple Silicon to run the local MLX Qwen backend. -- **One-command Cognitive Sandwich installer.** `vestige sandwich install` stages hook files and agents by default, removes old Vestige hook wiring, and leaves all Claude Code hook layers plus the 19 GB model path opt-in. -- **Pulse hook backed by `/api/changelog`.** Fresh dream and connection events can be injected into the next Claude Code prompt context without blocking the prompt. -- **`VESTIGE_DATA_DIR` support.** `--data-dir` now has an env-var fallback, tilde expansion, secure directory creation, and clear precedence docs. -- **NPM release wrapper fixed.** `vestige-mcp-server@2.1.0` now downloads binaries from the matching `v2.1.0` GitHub release tag instead of an old hardcoded release. - -## What's New in v2.0.9 "Autopilot" - -Autopilot flips Vestige from passive memory library to **self-managing cognitive surface**. Same 24 MCP tools, zero schema changes — but the moment you upgrade, 14 previously dormant cognitive primitives start firing on live events without any tool call from your client. - -- **One supervised backend task subscribes to the 20-event WebSocket bus** and routes six event classes into the cognitive engine: `MemoryCreated` triggers synaptic-tagging PRP + predictive-access records, `SearchPerformed` warms the speculative-retrieval model, `MemoryPromoted` fires activation spread, `MemorySuppressed` emits the Rac1 cascade wave, high-importance `ImportanceScored` (>0.85) auto-promotes, and `Heartbeat` rate-limit-fires `find_duplicates` on large DBs. **The engine mutex is never held across `.await`, so MCP dispatch is never starved.** -- **Panic-resilient supervisors.** Both background tasks run inside an outer supervisor loop — if one handler panics on a bad memory, the supervisor respawns it in 5 s instead of losing every future event. -- **Fully backward compatible.** No new MCP tools. No schema migration. Existing v2.0.8 databases open without a single step. Opt out with `VESTIGE_AUTOPILOT_ENABLED=0` if you want the passive-library contract back. -- **3,091 LOC of orphan v1.0 tool code removed** — nine superseded modules (`checkpoint`, `codebase`, `consolidate`, `ingest`, `intentions`, `knowledge`, `recall`, plus helpers) verified zero non-test callers before deletion. Tool surface unchanged. - -## What's New in v2.0.8 "Pulse" - -v2.0.8 wires the dashboard through to the cognitive engine. Eight new surfaces expose the reasoning stack visually — every one was MCP-only before. - -- **Reasoning Theater (`/reasoning`)** — `Cmd+K` Ask palette over the 8-stage `deep_reference` pipeline (hybrid retrieval → cross-encoder rerank → spreading activation → FSRS-6 trust → temporal supersession → contradiction analysis → relation assessment → template reasoning chain). Evidence cards, confidence meter, contradiction geodesic arcs, superseded-memory lineage, evolution timeline. **Zero LLM calls, 100% local.** -- **Pulse InsightToast** — real-time toasts for `DreamCompleted`, `ConsolidationCompleted`, `ConnectionDiscovered`, promote/demote/suppress/unsuppress, `Rac1CascadeSwept`. Rate-limited, auto-dismiss, click-to-dismiss. -- **Memory Birth Ritual (Terrarium)** — new memories materialize in the 3D graph on every `MemoryCreated`: elastic scale-in, quadratic Bezier flight path, glow sprite fade-in, Newton's Cradle docking recoil. 60-frame sequence, zero-alloc math. -- **7 more dashboard surfaces** — `/duplicates`, `/dreams`, `/schedule`, `/importance`, `/activation`, `/contradictions`, `/patterns`. Left nav expanded 8 → 16 with single-key shortcuts. -- **Intel Mac (`x86_64-apple-darwin`) support restored** via the `ort-dynamic` Cargo feature + Homebrew `onnxruntime`. Microsoft deprecated x86_64 macOS prebuilts; the dynamic-link path sidesteps that permanently. **Closes #41.** -- **Contradiction-detection false positives eliminated** — four thresholds tightened so adjacent-domain memories no longer flag as conflicts. On an FSRS-6 query this collapses false contradictions 12 → 0 without regressing legitimate test cases. - -## What's New in v2.0.7 "Visible" - -Hygiene release closing two UI gaps and finishing schema cleanup. No breaking changes, no user-data migrations. - -- **`POST /api/memories/{id}/suppress` + `/unsuppress` HTTP endpoints** — dashboard can trigger Anderson 2025 SIF + Rac1 cascade without dropping to raw MCP. `suppressionCount`, `retrievalPenalty`, `reversibleUntil`, `labileWindowHours` all in response. Suppress button joins Promote / Demote / Delete on the Memories page. -- **Uptime in the sidebar footer** — the `Heartbeat` event has carried `uptime_secs` since v2.0.5 but was never rendered. Now shows as `up 3d 4h` / `up 18m` / `up 47s`. -- **`execute_export` panic fix** — unreachable match arm replaced with a clean "unsupported export format" error instead of unwinding through the MCP dispatcher. -- **`predict` surfaces `predict_degraded: true`** on lock poisoning instead of silently returning empty vecs. `memory_changelog` honors `start` / `end` bounds. `intention` check honors `include_snoozed`. -- **Migration V11** — drops dead `knowledge_edges` + `compressed_memories` tables (added speculatively in V4, never used). - -## What's New in v2.0.6 "Composer" - -v2.0.6 is a polish release that makes the existing cognitive stack finally *feel* alive in the dashboard and stays out of your way on the prompt side. - -- **Six live graph reactions, not one** — `MemorySuppressed`, `MemoryUnsuppressed`, `Rac1CascadeSwept`, `Connected`, `ConsolidationStarted`, and `ImportanceScored` now light the 3D graph in real time. v2.0.5 shipped `suppress` but the graph was silent when you called it; consolidation and importance scoring have been silent since v2.0.0. No longer. -- **Intentions page actually works** — fixes a long-standing bug where every intention rendered as "normal priority" (type/schema drift between backend and frontend) and context/time triggers surfaced as raw JSON. -- **Opt-in composition mandate** — the new MCP `instructions` string stays minimal by default. Opt in to the full Composing / Never-composed / Recommendation composition protocol with `VESTIGE_SYSTEM_PROMPT_MODE=full` when you want it, and nothing is imposed on your sessions when you don't. - -## What's New in v2.0.5 "Intentional Amnesia" - -**The first shipped AI memory system with top-down inhibitory control over retrieval.** Other systems implement passive decay — memories fade if you don't touch them. Vestige v2.0.5 also implements *active* suppression: the new **`suppress`** tool compounds a retrieval penalty on every call (up to 80%), a background Rac1 worker fades co-activated neighbors over 72 hours, and the whole thing is reversible within a 24-hour labile window. **Never deletes.** The memory is inhibited, not erased. - -Ebbinghaus 1885 models what happens to memories you don't touch. Anderson 2025 models what happens when you actively want to stop thinking about one. Every other AI memory system implements the first. Vestige is the first to ship the second. - -Based on [Anderson et al. 2025](https://www.nature.com/articles/s41583-025-00929-y) (Suppression-Induced Forgetting, *Nat Rev Neurosci*) and [Cervantes-Sandoval et al. 2020](https://pmc.ncbi.nlm.nih.gov/articles/PMC7477079/) (Rac1 synaptic cascade). - -
-Earlier releases (v2.0 "Cognitive Leap" → v2.0.4 "Deep Reference") - -- **v2.0.4 — `deep_reference` Tool** — 8-stage cognitive reasoning pipeline with FSRS-6 trust scoring, intent classification, spreading activation, contradiction analysis, and pre-built reasoning chains. Token budgets raised 10K → 100K. CORS tightened. -- **v2.0 — 3D Memory Dashboard** — SvelteKit + Three.js neural visualization with real-time WebSocket events, bloom post-processing, force-directed graph layout. -- **v2.0 — WebSocket Event Bus** — Every cognitive operation broadcasts events: memory creation, search, dreaming, consolidation, retention decay. -- **v2.0 — HyDE Query Expansion** — Template-based Hypothetical Document Embeddings for dramatically improved search quality on conceptual queries. -- **v2.0 — Nomic v2 MoE (experimental)** — fastembed 5.11 with optional Nomic Embed Text v2 MoE (475M params, 8 experts) + Metal GPU acceleration. -- **v2.0 — Command Palette** — `Cmd+K` navigation, keyboard shortcuts, responsive mobile layout, PWA installable. -- **v2.0 — FSRS Decay Visualization** — SVG retention curves with predicted decay at 1d/7d/30d. - -
+- **Model-agnostic Sanhedrin presets.** Sanhedrin no longer guesses a large default verifier. Users choose any OpenAI-compatible endpoint/model, or start from custom, small laptop, Ollama, MLX, vLLM, llama.cpp, hosted API, or LiteLLM presets. +- **Sharper Receipt Lock.** Verification claims inside code fences, quotes, blockquotes, or explicitly hedged "let me verify" language no longer trigger false vetoes, while actual "tests passed" claims still require command receipts. +- **Safer command receipts.** Transcript command evidence now prefers structured tool-use receipts; loose JSON scanning is opt-in only. +- **Visible fail-open telemetry.** Timeouts, unavailable model endpoints, and malformed verdicts are logged locally and surfaced in the dashboard's 7-day Sanhedrin stats. +- **Durable evidence boundary.** Staged evidence remains useful context, but it cannot satisfy durable support or contradiction requirements by itself. +- **Safer batch writes.** `smart_ingest` batch mode now keeps caller-separated items separate by default and returns merge previews when an existing memory is mutated. +- **Opt-in NVIDIA acceleration path.** Qwen3 embedding builds expose CUDA/cuDNN feature flags for contributors and users with CUDA-capable hosts. --- @@ -116,10 +41,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 +63,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 +105,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 +132,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 +305,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 +316,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 +325,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) | @@ -426,6 +345,53 @@ cargo build --release -p vestige-mcp --features qwen3-embeddings,metal VESTIGE_EMBEDDING_MODEL=qwen3-0.6b vestige consolidate ``` +### Building with CUDA support (NVIDIA hosts - Windows / Linux) + +The `cuda` feature routes Qwen3 embedding through NVIDIA GPUs via +`candle-core/cuda`. On a host with the CUDA toolkit installed and a supported +NVIDIA runtime, this drops Qwen3-Embedding inference from CPU-bound to GPU-bound +for batched workloads. + +```bash +# Linux / Windows + CUDA toolkit (12.x or 13.x) +cargo build --release -p vestige-mcp --features qwen3-embeddings,cuda + +# Optional cuDNN acceleration on top of CUDA +cargo build --release -p vestige-mcp --features qwen3-embeddings,cudnn + +VESTIGE_EMBEDDING_MODEL=qwen3-0.6b vestige consolidate +``` + +**Prerequisites:** + +- NVIDIA driver + CUDA toolkit (12.x or 13.x). Verify with `nvcc --version`. +- A C++ host compiler that `nvcc` can drive (Linux: `gcc`; Windows: MSVC / + `cl.exe` from a recent Visual Studio Build Tools install). + +**Windows + MSVC + CUDA 13.x build note.** Recent CCCL headers shipped with +CUDA 13.x require the modern preprocessor. Without it, the `candle-kernels` +`.cu` compile pass can fail at `cuda/include/cuda/std/__cccl/compiler.h`. Set +this env var before `cargo build` to pass `/Zc:preprocessor` through `nvcc`: + +```powershell +# PowerShell +$env:NVCC_PREPEND_FLAGS = '-Xcompiler="/Zc:preprocessor"' +cargo build --release -p vestige-mcp --features qwen3-embeddings,cuda +``` + +```cmd +:: cmd.exe +set NVCC_PREPEND_FLAGS=-Xcompiler="/Zc:preprocessor" +cargo build --release -p vestige-mcp --features qwen3-embeddings,cuda +``` + +Linux + CUDA 13.x builds with `gcc` do not need the equivalent flag. + +**Verifying GPU is actually used.** With CUDA-enabled builds, run +`VESTIGE_EMBEDDING_MODEL=qwen3-0.6b vestige consolidate` on a corpus of 1000+ +memories and watch `nvidia-smi`; embedding passes should pin a single GPU while +the run is active. + --- ## CLI @@ -481,7 +447,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`.
diff --git a/SECURITY.md b/SECURITY.md index cc46721..c130b7b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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 diff --git a/agents/executioner.md b/agents/executioner.md index c65da41..7d00615 100644 --- a/agents/executioner.md +++ b/agents/executioner.md @@ -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. diff --git a/apps/dashboard/build/_app/immutable/assets/0.Bor8S3Zo.css b/apps/dashboard/build/_app/immutable/assets/0.Bor8S3Zo.css new file mode 100644 index 0000000..f5bdd49 --- /dev/null +++ b/apps/dashboard/build/_app/immutable/assets/0.Bor8S3Zo.css @@ -0,0 +1 @@ +/*! tailwindcss v4.2.0 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:"JetBrains Mono", "Fira Code", "SF Mono", monospace;--color-amber-400:oklch(82.8% .189 84.429);--color-purple-400:oklch(71.4% .203 305.504);--color-purple-500:oklch(62.7% .265 303.9);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-md:28rem;--container-lg:32rem;--container-xl:36rem;--container-2xl:42rem;--container-4xl:56rem;--container-5xl:64rem;--container-6xl:72rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height: 1.2 ;--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--text-6xl:3.75rem;--text-6xl--line-height:1;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-wide:.025em;--tracking-wider:.05em;--tracking-widest:.1em;--leading-snug:1.375;--leading-relaxed:1.625;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--ease-out:cubic-bezier(0, 0, .2, 1);--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-ping:ping 1s cubic-bezier(0, 0, .2, 1) infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--blur-sm:8px;--blur-md:12px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-void:#050510;--color-abyss:#0a0a1a;--color-deep:#10102a;--color-surface:#161638;--color-elevated:#1e1e4a;--color-subtle:#2a2a5e;--color-muted:#4a4a7a;--color-dim:#7a7aaa;--color-text:#e0e0ff;--color-bright:#fff;--color-synapse:#6366f1;--color-synapse-glow:#818cf8;--color-dream:#a855f7;--color-dream-glow:#c084fc;--color-memory:#3b82f6;--color-recall:#10b981;--color-decay:#ef4444;--color-warning:#f59e0b;--color-node-pattern:#ec4899}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::file-selector-button{-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.pointer-events-auto{pointer-events:auto}.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-0{inset:calc(var(--spacing) * 0)}.inset-x-0{inset-inline:calc(var(--spacing) * 0)}.start{inset-inline-start:var(--spacing)}.end{inset-inline-end:var(--spacing)}.end\!{inset-inline-end:var(--spacing)!important}.top-0{top:calc(var(--spacing) * 0)}.top-0\.5{top:calc(var(--spacing) * .5)}.top-1{top:calc(var(--spacing) * 1)}.top-3{top:calc(var(--spacing) * 3)}.top-4{top:calc(var(--spacing) * 4)}.top-10{top:calc(var(--spacing) * 10)}.right-0{right:calc(var(--spacing) * 0)}.right-4{right:calc(var(--spacing) * 4)}.bottom-0{bottom:calc(var(--spacing) * 0)}.bottom-4{bottom:calc(var(--spacing) * 4)}.-left-\[29px\]{left:-29px}.left-1\/2{left:50%}.left-4{left:calc(var(--spacing) * 4)}.left-6{left:calc(var(--spacing) * 6)}.isolate{isolation:isolate}.z-10{z-index:10}.z-20{z-index:20}.z-40{z-index:40}.z-50{z-index:50}.z-\[1\]{z-index:1}.container{width:100%}@media(min-width:40rem){.container{max-width:40rem}}@media(min-width:48rem){.container{max-width:48rem}}@media(min-width:64rem){.container{max-width:64rem}}@media(min-width:80rem){.container{max-width:80rem}}@media(min-width:96rem){.container{max-width:96rem}}.mx-2{margin-inline:calc(var(--spacing) * 2)}.mx-auto{margin-inline:auto}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-\[-12px\]{margin-top:-12px}.mr-1{margin-right:calc(var(--spacing) * 1)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mb-0\.5{margin-bottom:calc(var(--spacing) * .5)}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-3{margin-left:calc(var(--spacing) * 3)}.ml-auto{margin-left:auto}.line-clamp-1{-webkit-line-clamp:1;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-2{-webkit-line-clamp:2;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-3{-webkit-line-clamp:3;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-4{-webkit-line-clamp:4;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-flex{display:inline-flex}.table{display:table}.aspect-square{aspect-ratio:1}.h-0\.5{height:calc(var(--spacing) * .5)}.h-1{height:calc(var(--spacing) * 1)}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-12{height:calc(var(--spacing) * 12)}.h-14{height:calc(var(--spacing) * 14)}.h-16{height:calc(var(--spacing) * 16)}.h-20{height:calc(var(--spacing) * 20)}.h-24{height:calc(var(--spacing) * 24)}.h-28{height:calc(var(--spacing) * 28)}.h-32{height:calc(var(--spacing) * 32)}.h-40{height:calc(var(--spacing) * 40)}.h-\[520px\]{height:520px}.h-\[560px\]{height:560px}.h-full{height:100%}.h-px{height:1px}.h-screen{height:100vh}.max-h-48{max-height:calc(var(--spacing) * 48)}.max-h-64{max-height:calc(var(--spacing) * 64)}.max-h-72{max-height:calc(var(--spacing) * 72)}.max-h-96{max-height:calc(var(--spacing) * 96)}.max-h-\[620px\]{max-height:620px}.min-h-0{min-height:calc(var(--spacing) * 0)}.min-h-40{min-height:calc(var(--spacing) * 40)}.min-h-\[240px\]{min-height:240px}.min-h-\[320px\]{min-height:320px}.min-h-\[520px\]{min-height:520px}.min-h-full{min-height:100%}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-2{width:calc(var(--spacing) * 2)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-3{width:calc(var(--spacing) * 3)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-12{width:calc(var(--spacing) * 12)}.w-14{width:calc(var(--spacing) * 14)}.w-16{width:calc(var(--spacing) * 16)}.w-20{width:calc(var(--spacing) * 20)}.w-24{width:calc(var(--spacing) * 24)}.w-32{width:calc(var(--spacing) * 32)}.w-96{width:calc(var(--spacing) * 96)}.w-\[3px\]{width:3px}.w-\[90\%\]{width:90%}.w-full{width:100%}.w-px{width:1px}.max-w-2xl{max-width:var(--container-2xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-7xl{max-width:var(--container-7xl)}.max-w-20{max-width:calc(var(--spacing) * 20)}.max-w-\[220px\]{max-width:220px}.max-w-lg{max-width:var(--container-lg)}.max-w-md{max-width:var(--container-md)}.max-w-xl{max-width:var(--container-xl)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:calc(var(--spacing) * 0)}.min-w-12{min-width:calc(var(--spacing) * 12)}.min-w-16{min-width:calc(var(--spacing) * 16)}.min-w-64{min-width:calc(var(--spacing) * 64)}.min-w-\[2rem\]{min-width:2rem}.min-w-\[3\.5rem\]{min-width:3.5rem}.flex-1{flex:1}.flex-shrink{flex-shrink:1}.flex-shrink-0{flex-shrink:0}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.border-separate{border-collapse:separate}.-translate-x-1\/2{--tw-translate-x: -50% ;translate:var(--tw-translate-x) var(--tw-translate-y)}.scale-125{--tw-scale-x:125%;--tw-scale-y:125%;--tw-scale-z:125%;scale:var(--tw-scale-x) var(--tw-scale-y)}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.animate-\[fadeSlide_0\.35s_ease-out_both\]{animation:.35s ease-out both fadeSlide}.animate-ping{animation:var(--animate-ping)}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-default{cursor:default}.cursor-pointer{cursor:pointer}.resize{resize:both}.resize-none{resize:none}.resize-y{resize:vertical}.appearance-none{-webkit-appearance:none;-moz-appearance:none;appearance:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.grid-cols-7{grid-template-columns:repeat(7,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-around{justify-content:space-around}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-0{gap:calc(var(--spacing) * 0)}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}.gap-6{gap:calc(var(--spacing) * 6)}.gap-\[2px\]{gap:2px}:where(.space-y-0\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * .5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * .5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-8>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 8) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-6{column-gap:calc(var(--spacing) * 6)}.gap-y-1{row-gap:calc(var(--spacing) * 1)}.self-center{align-self:center}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-sm{border-radius:var(--radius-sm)}.rounded-xl{border-radius:var(--radius-xl)}.rounded-t{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.\!border-decay\/20{border-color:#ef444433!important}@supports (color:color-mix(in lab,red,red)){.\!border-decay\/20{border-color:color-mix(in oklab,var(--color-decay) 20%,transparent)!important}}.\!border-decay\/30{border-color:#ef44444d!important}@supports (color:color-mix(in lab,red,red)){.\!border-decay\/30{border-color:color-mix(in oklab,var(--color-decay) 30%,transparent)!important}}.\!border-decay\/40{border-color:#ef444466!important}@supports (color:color-mix(in lab,red,red)){.\!border-decay\/40{border-color:color-mix(in oklab,var(--color-decay) 40%,transparent)!important}}.\!border-dream\/20{border-color:#a855f733!important}@supports (color:color-mix(in lab,red,red)){.\!border-dream\/20{border-color:color-mix(in oklab,var(--color-dream) 20%,transparent)!important}}.\!border-synapse\/15{border-color:#6366f126!important}@supports (color:color-mix(in lab,red,red)){.\!border-synapse\/15{border-color:color-mix(in oklab,var(--color-synapse) 15%,transparent)!important}}.\!border-synapse\/20{border-color:#6366f133!important}@supports (color:color-mix(in lab,red,red)){.\!border-synapse\/20{border-color:color-mix(in oklab,var(--color-synapse) 20%,transparent)!important}}.\!border-synapse\/25{border-color:#6366f140!important}@supports (color:color-mix(in lab,red,red)){.\!border-synapse\/25{border-color:color-mix(in oklab,var(--color-synapse) 25%,transparent)!important}}.\!border-synapse\/30{border-color:#6366f14d!important}@supports (color:color-mix(in lab,red,red)){.\!border-synapse\/30{border-color:color-mix(in oklab,var(--color-synapse) 30%,transparent)!important}}.\!border-synapse\/40{border-color:#6366f166!important}@supports (color:color-mix(in lab,red,red)){.\!border-synapse\/40{border-color:color-mix(in oklab,var(--color-synapse) 40%,transparent)!important}}.border-\[\#A33FFF\]\/40{border-color:#a33fff66}.border-decay\/20{border-color:#ef444433}@supports (color:color-mix(in lab,red,red)){.border-decay\/20{border-color:color-mix(in oklab,var(--color-decay) 20%,transparent)}}.border-dream-glow\/40{border-color:#c084fc66}@supports (color:color-mix(in lab,red,red)){.border-dream-glow\/40{border-color:color-mix(in oklab,var(--color-dream-glow) 40%,transparent)}}.border-dream\/10{border-color:#a855f71a}@supports (color:color-mix(in lab,red,red)){.border-dream\/10{border-color:color-mix(in oklab,var(--color-dream) 10%,transparent)}}.border-dream\/20{border-color:#a855f733}@supports (color:color-mix(in lab,red,red)){.border-dream\/20{border-color:color-mix(in oklab,var(--color-dream) 20%,transparent)}}.border-dream\/30{border-color:#a855f74d}@supports (color:color-mix(in lab,red,red)){.border-dream\/30{border-color:color-mix(in oklab,var(--color-dream) 30%,transparent)}}.border-dream\/40{border-color:#a855f766}@supports (color:color-mix(in lab,red,red)){.border-dream\/40{border-color:color-mix(in oklab,var(--color-dream) 40%,transparent)}}.border-dream\/50{border-color:#a855f780}@supports (color:color-mix(in lab,red,red)){.border-dream\/50{border-color:color-mix(in oklab,var(--color-dream) 50%,transparent)}}.border-recall\/30{border-color:#10b9814d}@supports (color:color-mix(in lab,red,red)){.border-recall\/30{border-color:color-mix(in oklab,var(--color-recall) 30%,transparent)}}.border-recall\/40{border-color:#10b98166}@supports (color:color-mix(in lab,red,red)){.border-recall\/40{border-color:color-mix(in oklab,var(--color-recall) 40%,transparent)}}.border-subtle\/15{border-color:#2a2a5e26}@supports (color:color-mix(in lab,red,red)){.border-subtle\/15{border-color:color-mix(in oklab,var(--color-subtle) 15%,transparent)}}.border-subtle\/20{border-color:#2a2a5e33}@supports (color:color-mix(in lab,red,red)){.border-subtle\/20{border-color:color-mix(in oklab,var(--color-subtle) 20%,transparent)}}.border-subtle\/30{border-color:#2a2a5e4d}@supports (color:color-mix(in lab,red,red)){.border-subtle\/30{border-color:color-mix(in oklab,var(--color-subtle) 30%,transparent)}}.border-synapse{border-color:var(--color-synapse)}.border-synapse\/5{border-color:#6366f10d}@supports (color:color-mix(in lab,red,red)){.border-synapse\/5{border-color:color-mix(in oklab,var(--color-synapse) 5%,transparent)}}.border-synapse\/10{border-color:#6366f11a}@supports (color:color-mix(in lab,red,red)){.border-synapse\/10{border-color:color-mix(in oklab,var(--color-synapse) 10%,transparent)}}.border-synapse\/15{border-color:#6366f126}@supports (color:color-mix(in lab,red,red)){.border-synapse\/15{border-color:color-mix(in oklab,var(--color-synapse) 15%,transparent)}}.border-synapse\/20{border-color:#6366f133}@supports (color:color-mix(in lab,red,red)){.border-synapse\/20{border-color:color-mix(in oklab,var(--color-synapse) 20%,transparent)}}.border-synapse\/30{border-color:#6366f14d}@supports (color:color-mix(in lab,red,red)){.border-synapse\/30{border-color:color-mix(in oklab,var(--color-synapse) 30%,transparent)}}.border-synapse\/40{border-color:#6366f166}@supports (color:color-mix(in lab,red,red)){.border-synapse\/40{border-color:color-mix(in oklab,var(--color-synapse) 40%,transparent)}}.border-transparent{border-color:#0000}.border-warning\/30{border-color:#f59e0b4d}@supports (color:color-mix(in lab,red,red)){.border-warning\/30{border-color:color-mix(in oklab,var(--color-warning) 30%,transparent)}}.border-warning\/40{border-color:#f59e0b66}@supports (color:color-mix(in lab,red,red)){.border-warning\/40{border-color:color-mix(in oklab,var(--color-warning) 40%,transparent)}}.border-warning\/50{border-color:#f59e0b80}@supports (color:color-mix(in lab,red,red)){.border-warning\/50{border-color:color-mix(in oklab,var(--color-warning) 50%,transparent)}}.border-white\/5{border-color:#ffffff0d}@supports (color:color-mix(in lab,red,red)){.border-white\/5{border-color:color-mix(in oklab,var(--color-white) 5%,transparent)}}.border-t-dream{border-top-color:var(--color-dream)}.border-t-synapse{border-top-color:var(--color-synapse)}.border-t-warning{border-top-color:var(--color-warning)}.bg-\[\#A33FFF\]{background-color:#a33fff}.bg-\[\#A33FFF\]\/10{background-color:#a33fff1a}.bg-amber-400{background-color:var(--color-amber-400)}.bg-black\/40{background-color:#0006}@supports (color:color-mix(in lab,red,red)){.bg-black\/40{background-color:color-mix(in oklab,var(--color-black) 40%,transparent)}}.bg-decay{background-color:var(--color-decay)}.bg-decay\/10{background-color:#ef44441a}@supports (color:color-mix(in lab,red,red)){.bg-decay\/10{background-color:color-mix(in oklab,var(--color-decay) 10%,transparent)}}.bg-decay\/20{background-color:#ef444433}@supports (color:color-mix(in lab,red,red)){.bg-decay\/20{background-color:color-mix(in oklab,var(--color-decay) 20%,transparent)}}.bg-decay\/\[0\.05\]{background-color:#ef44440d}@supports (color:color-mix(in lab,red,red)){.bg-decay\/\[0\.05\]{background-color:color-mix(in oklab,var(--color-decay) 5%,transparent)}}.bg-deep{background-color:var(--color-deep)}.bg-deep\/40{background-color:#10102a66}@supports (color:color-mix(in lab,red,red)){.bg-deep\/40{background-color:color-mix(in oklab,var(--color-deep) 40%,transparent)}}.bg-deep\/60{background-color:#10102a99}@supports (color:color-mix(in lab,red,red)){.bg-deep\/60{background-color:color-mix(in oklab,var(--color-deep) 60%,transparent)}}.bg-dream{background-color:var(--color-dream)}.bg-dream\/5{background-color:#a855f70d}@supports (color:color-mix(in lab,red,red)){.bg-dream\/5{background-color:color-mix(in oklab,var(--color-dream) 5%,transparent)}}.bg-dream\/10{background-color:#a855f71a}@supports (color:color-mix(in lab,red,red)){.bg-dream\/10{background-color:color-mix(in oklab,var(--color-dream) 10%,transparent)}}.bg-dream\/15{background-color:#a855f726}@supports (color:color-mix(in lab,red,red)){.bg-dream\/15{background-color:color-mix(in oklab,var(--color-dream) 15%,transparent)}}.bg-dream\/20{background-color:#a855f733}@supports (color:color-mix(in lab,red,red)){.bg-dream\/20{background-color:color-mix(in oklab,var(--color-dream) 20%,transparent)}}.bg-muted{background-color:var(--color-muted)}.bg-node-pattern{background-color:var(--color-node-pattern)}.bg-purple-500\/20{background-color:#ac4bff33}@supports (color:color-mix(in lab,red,red)){.bg-purple-500\/20{background-color:color-mix(in oklab,var(--color-purple-500) 20%,transparent)}}.bg-recall{background-color:var(--color-recall)}.bg-recall\/10{background-color:#10b9811a}@supports (color:color-mix(in lab,red,red)){.bg-recall\/10{background-color:color-mix(in oklab,var(--color-recall) 10%,transparent)}}.bg-recall\/15{background-color:#10b98126}@supports (color:color-mix(in lab,red,red)){.bg-recall\/15{background-color:color-mix(in oklab,var(--color-recall) 15%,transparent)}}.bg-recall\/20{background-color:#10b98133}@supports (color:color-mix(in lab,red,red)){.bg-recall\/20{background-color:color-mix(in oklab,var(--color-recall) 20%,transparent)}}.bg-synapse{background-color:var(--color-synapse)}.bg-synapse-glow{background-color:var(--color-synapse-glow)}.bg-synapse\/10{background-color:#6366f11a}@supports (color:color-mix(in lab,red,red)){.bg-synapse\/10{background-color:color-mix(in oklab,var(--color-synapse) 10%,transparent)}}.bg-synapse\/15{background-color:#6366f126}@supports (color:color-mix(in lab,red,red)){.bg-synapse\/15{background-color:color-mix(in oklab,var(--color-synapse) 15%,transparent)}}.bg-synapse\/20{background-color:#6366f133}@supports (color:color-mix(in lab,red,red)){.bg-synapse\/20{background-color:color-mix(in oklab,var(--color-synapse) 20%,transparent)}}.bg-synapse\/25{background-color:#6366f140}@supports (color:color-mix(in lab,red,red)){.bg-synapse\/25{background-color:color-mix(in oklab,var(--color-synapse) 25%,transparent)}}.bg-synapse\/70{background-color:#6366f1b3}@supports (color:color-mix(in lab,red,red)){.bg-synapse\/70{background-color:color-mix(in oklab,var(--color-synapse) 70%,transparent)}}.bg-transparent{background-color:#0000}.bg-void{background-color:var(--color-void)}.bg-void\/60{background-color:#05051099}@supports (color:color-mix(in lab,red,red)){.bg-void\/60{background-color:color-mix(in oklab,var(--color-void) 60%,transparent)}}.bg-warning{background-color:var(--color-warning)}.bg-warning\/5{background-color:#f59e0b0d}@supports (color:color-mix(in lab,red,red)){.bg-warning\/5{background-color:color-mix(in oklab,var(--color-warning) 5%,transparent)}}.bg-warning\/20{background-color:#f59e0b33}@supports (color:color-mix(in lab,red,red)){.bg-warning\/20{background-color:color-mix(in oklab,var(--color-warning) 20%,transparent)}}.bg-white\/\[0\.02\]{background-color:#ffffff05}@supports (color:color-mix(in lab,red,red)){.bg-white\/\[0\.02\]{background-color:color-mix(in oklab,var(--color-white) 2%,transparent)}}.bg-white\/\[0\.03\]{background-color:#ffffff08}@supports (color:color-mix(in lab,red,red)){.bg-white\/\[0\.03\]{background-color:color-mix(in oklab,var(--color-white) 3%,transparent)}}.bg-white\/\[0\.04\]{background-color:#ffffff0a}@supports (color:color-mix(in lab,red,red)){.bg-white\/\[0\.04\]{background-color:color-mix(in oklab,var(--color-white) 4%,transparent)}}.bg-white\/\[0\.06\]{background-color:#ffffff0f}@supports (color:color-mix(in lab,red,red)){.bg-white\/\[0\.06\]{background-color:color-mix(in oklab,var(--color-white) 6%,transparent)}}.bg-gradient-to-br{--tw-gradient-position:to bottom right in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.from-dream{--tw-gradient-from:var(--color-dream);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-synapse{--tw-gradient-to:var(--color-synapse);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.p-0{padding:calc(var(--spacing) * 0)}.p-0\.5{padding:calc(var(--spacing) * .5)}.p-1{padding:calc(var(--spacing) * 1)}.p-2{padding:calc(var(--spacing) * 2)}.p-2\.5{padding:calc(var(--spacing) * 2.5)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.p-10{padding:calc(var(--spacing) * 10)}.p-12{padding:calc(var(--spacing) * 12)}.px-1{padding-inline:calc(var(--spacing) * 1)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-8{padding-inline:calc(var(--spacing) * 8)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-5{padding-block:calc(var(--spacing) * 5)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-10{padding-block:calc(var(--spacing) * 10)}.py-12{padding-block:calc(var(--spacing) * 12)}.py-20{padding-block:calc(var(--spacing) * 20)}.pt-1{padding-top:calc(var(--spacing) * 1)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-6{padding-top:calc(var(--spacing) * 6)}.pt-8{padding-top:calc(var(--spacing) * 8)}.pt-\[10vh\]{padding-top:10vh}.pr-1{padding-right:calc(var(--spacing) * 1)}.pr-2{padding-right:calc(var(--spacing) * 2)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-16{padding-bottom:calc(var(--spacing) * 16)}.pl-6{padding-left:calc(var(--spacing) * 6)}.pl-14{padding-left:calc(var(--spacing) * 14)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-bottom{vertical-align:bottom}.align-top{vertical-align:top}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}.text-6xl{font-size:var(--text-6xl);line-height:var(--tw-leading,var(--text-6xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[8px\]{font-size:8px}.text-\[9px\]{font-size:9px}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-snug{--tw-leading:var(--leading-snug);line-height:var(--leading-snug)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[0\.12em\]{--tw-tracking:.12em;letter-spacing:.12em}.tracking-\[0\.15em\]{--tw-tracking:.15em;letter-spacing:.15em}.tracking-\[0\.18em\]{--tw-tracking:.18em;letter-spacing:.18em}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-\[\#E4C8FF\]{color:#e4c8ff}.text-amber-400{color:var(--color-amber-400)}.text-bright{color:var(--color-bright)}.text-decay{color:var(--color-decay)}.text-decay\/60{color:#ef444499}@supports (color:color-mix(in lab,red,red)){.text-decay\/60{color:color-mix(in oklab,var(--color-decay) 60%,transparent)}}.text-dim{color:var(--color-dim)}.text-dream{color:var(--color-dream)}.text-dream-glow{color:var(--color-dream-glow)}.text-dream\/40{color:#a855f766}@supports (color:color-mix(in lab,red,red)){.text-dream\/40{color:color-mix(in oklab,var(--color-dream) 40%,transparent)}}.text-dream\/80{color:#a855f7cc}@supports (color:color-mix(in lab,red,red)){.text-dream\/80{color:color-mix(in oklab,var(--color-dream) 80%,transparent)}}.text-memory{color:var(--color-memory)}.text-muted{color:var(--color-muted)}.text-muted\/50{color:#4a4a7a80}@supports (color:color-mix(in lab,red,red)){.text-muted\/50{color:color-mix(in oklab,var(--color-muted) 50%,transparent)}}.text-muted\/60{color:#4a4a7a99}@supports (color:color-mix(in lab,red,red)){.text-muted\/60{color:color-mix(in oklab,var(--color-muted) 60%,transparent)}}.text-node-pattern{color:var(--color-node-pattern)}.text-purple-400{color:var(--color-purple-400)}.text-recall{color:var(--color-recall)}.text-subtle{color:var(--color-subtle)}.text-synapse{color:var(--color-synapse)}.text-synapse-glow{color:var(--color-synapse-glow)}.text-text{color:var(--color-text)}.text-text\/80{color:#e0e0ffcc}@supports (color:color-mix(in lab,red,red)){.text-text\/80{color:color-mix(in oklab,var(--color-text) 80%,transparent)}}.text-warning{color:var(--color-warning)}.capitalize{text-transform:capitalize}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.underline-offset-4{text-underline-offset:4px}.accent-synapse{accent-color:var(--color-synapse)}.accent-synapse-glow{accent-color:var(--color-synapse-glow)}.opacity-20{opacity:.2}.opacity-30{opacity:.3}.opacity-35{opacity:.35}.opacity-40{opacity:.4}.opacity-60{opacity:.6}.opacity-75{opacity:.75}.opacity-100{opacity:1}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow\!{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a)!important;box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)!important}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_0_10px_rgba\(239\,68\,68\,0\.7\)\]{--tw-shadow:0 0 10px var(--tw-shadow-color,#ef4444b3);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_0_12px_rgba\(99\,102\,241\,0\.15\)\]{--tw-shadow:0 0 12px var(--tw-shadow-color,#6366f126);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_0_12px_rgba\(99\,102\,241\,0\.18\)\]{--tw-shadow:0 0 12px var(--tw-shadow-color,#6366f12e);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_0_12px_rgba\(163\,63\,255\,0\.15\)\]{--tw-shadow:0 0 12px var(--tw-shadow-color,#a33fff26);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_0_16px_rgba\(99\,102\,241\,0\.3\)\]{--tw-shadow:0 0 16px var(--tw-shadow-color,#6366f14d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_0_16px_rgba\(168\,85\,247\,0\.3\)\]{--tw-shadow:0 0 16px var(--tw-shadow-color,#a855f74d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring,.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-2{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-synapse\/10{--tw-shadow-color:#6366f11a}@supports (color:color-mix(in lab,red,red)){.shadow-synapse\/10{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-synapse) 10%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-synapse\/20{--tw-shadow-color:#6366f133}@supports (color:color-mix(in lab,red,red)){.shadow-synapse\/20{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-synapse) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.ring-dream-glow{--tw-ring-color:var(--color-dream-glow)}.ring-dream\/60{--tw-ring-color:#a855f799}@supports (color:color-mix(in lab,red,red)){.ring-dream\/60{--tw-ring-color:color-mix(in oklab, var(--color-dream) 60%, transparent)}}.ring-recall\/30{--tw-ring-color:#10b9814d}@supports (color:color-mix(in lab,red,red)){.ring-recall\/30{--tw-ring-color:color-mix(in oklab, var(--color-recall) 30%, transparent)}}.ring-synapse\/60{--tw-ring-color:#6366f199}@supports (color:color-mix(in lab,red,red)){.ring-synapse\/60{--tw-ring-color:color-mix(in oklab, var(--color-synapse) 60%, transparent)}}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.backdrop-blur-md{--tw-backdrop-blur:blur(var(--blur-md));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-sm{--tw-backdrop-blur:blur(var(--blur-sm));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-200{--tw-duration:.2s;transition-duration:.2s}.duration-300{--tw-duration:.3s;transition-duration:.3s}.duration-500{--tw-duration:.5s;transition-duration:.5s}.duration-700{--tw-duration:.7s;transition-duration:.7s}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.select-none{-webkit-user-select:none;user-select:none}.placeholder\:text-muted::placeholder{color:var(--color-muted)}@media(hover:hover){.hover\:z-10:hover{z-index:10}.hover\:scale-110:hover{--tw-scale-x:110%;--tw-scale-y:110%;--tw-scale-z:110%;scale:var(--tw-scale-x) var(--tw-scale-y)}.hover\:scale-\[1\.03\]:hover{scale:1.03}.hover\:\!border-synapse\/30:hover{border-color:#6366f14d!important}@supports (color:color-mix(in lab,red,red)){.hover\:\!border-synapse\/30:hover{border-color:color-mix(in oklab,var(--color-synapse) 30%,transparent)!important}}.hover\:border-synapse\/20:hover{border-color:#6366f133}@supports (color:color-mix(in lab,red,red)){.hover\:border-synapse\/20:hover{border-color:color-mix(in oklab,var(--color-synapse) 20%,transparent)}}.hover\:border-synapse\/30:hover{border-color:#6366f14d}@supports (color:color-mix(in lab,red,red)){.hover\:border-synapse\/30:hover{border-color:color-mix(in oklab,var(--color-synapse) 30%,transparent)}}.hover\:border-synapse\/50:hover{border-color:#6366f180}@supports (color:color-mix(in lab,red,red)){.hover\:border-synapse\/50:hover{border-color:color-mix(in oklab,var(--color-synapse) 50%,transparent)}}.hover\:bg-decay\/20:hover{background-color:#ef444433}@supports (color:color-mix(in lab,red,red)){.hover\:bg-decay\/20:hover{background-color:color-mix(in oklab,var(--color-decay) 20%,transparent)}}.hover\:bg-decay\/30:hover{background-color:#ef44444d}@supports (color:color-mix(in lab,red,red)){.hover\:bg-decay\/30:hover{background-color:color-mix(in oklab,var(--color-decay) 30%,transparent)}}.hover\:bg-dream\/20:hover{background-color:#a855f733}@supports (color:color-mix(in lab,red,red)){.hover\:bg-dream\/20:hover{background-color:color-mix(in oklab,var(--color-dream) 20%,transparent)}}.hover\:bg-dream\/30:hover{background-color:#a855f74d}@supports (color:color-mix(in lab,red,red)){.hover\:bg-dream\/30:hover{background-color:color-mix(in oklab,var(--color-dream) 30%,transparent)}}.hover\:bg-purple-500\/30:hover{background-color:#ac4bff4d}@supports (color:color-mix(in lab,red,red)){.hover\:bg-purple-500\/30:hover{background-color:color-mix(in oklab,var(--color-purple-500) 30%,transparent)}}.hover\:bg-recall\/30:hover{background-color:#10b9814d}@supports (color:color-mix(in lab,red,red)){.hover\:bg-recall\/30:hover{background-color:color-mix(in oklab,var(--color-recall) 30%,transparent)}}.hover\:bg-synapse\/30:hover{background-color:#6366f14d}@supports (color:color-mix(in lab,red,red)){.hover\:bg-synapse\/30:hover{background-color:color-mix(in oklab,var(--color-synapse) 30%,transparent)}}.hover\:bg-warning\/30:hover{background-color:#f59e0b4d}@supports (color:color-mix(in lab,red,red)){.hover\:bg-warning\/30:hover{background-color:color-mix(in oklab,var(--color-warning) 30%,transparent)}}.hover\:bg-white\/\[0\.02\]:hover{background-color:#ffffff05}@supports (color:color-mix(in lab,red,red)){.hover\:bg-white\/\[0\.02\]:hover{background-color:color-mix(in oklab,var(--color-white) 2%,transparent)}}.hover\:bg-white\/\[0\.03\]:hover{background-color:#ffffff08}@supports (color:color-mix(in lab,red,red)){.hover\:bg-white\/\[0\.03\]:hover{background-color:color-mix(in oklab,var(--color-white) 3%,transparent)}}.hover\:bg-white\/\[0\.04\]:hover{background-color:#ffffff0a}@supports (color:color-mix(in lab,red,red)){.hover\:bg-white\/\[0\.04\]:hover{background-color:color-mix(in oklab,var(--color-white) 4%,transparent)}}.hover\:bg-white\/\[0\.08\]:hover{background-color:#ffffff14}@supports (color:color-mix(in lab,red,red)){.hover\:bg-white\/\[0\.08\]:hover{background-color:color-mix(in oklab,var(--color-white) 8%,transparent)}}.hover\:text-bright:hover{color:var(--color-bright)}.hover\:text-dim:hover{color:var(--color-dim)}.hover\:text-synapse-glow:hover{color:var(--color-synapse-glow)}.hover\:text-text:hover{color:var(--color-text)}.hover\:underline:hover{text-decoration-line:underline}}.focus\:\!border-synapse\/40:focus{border-color:#6366f166!important}@supports (color:color-mix(in lab,red,red)){.focus\:\!border-synapse\/40:focus{border-color:color-mix(in oklab,var(--color-synapse) 40%,transparent)!important}}.focus\:border-dream\/40:focus{border-color:#a855f766}@supports (color:color-mix(in lab,red,red)){.focus\:border-dream\/40:focus{border-color:color-mix(in oklab,var(--color-dream) 40%,transparent)}}.focus\:border-synapse\/40:focus{border-color:#6366f166}@supports (color:color-mix(in lab,red,red)){.focus\:border-synapse\/40:focus{border-color:color-mix(in oklab,var(--color-synapse) 40%,transparent)}}.focus\:ring-1:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-synapse-glow:focus{--tw-ring-color:var(--color-synapse-glow)}.focus\:ring-synapse\/20:focus{--tw-ring-color:#6366f133}@supports (color:color-mix(in lab,red,red)){.focus\:ring-synapse\/20:focus{--tw-ring-color:color-mix(in oklab, var(--color-synapse) 20%, transparent)}}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-dream-glow\/60:focus-visible{--tw-ring-color:#c084fc99}@supports (color:color-mix(in lab,red,red)){.focus-visible\:ring-dream-glow\/60:focus-visible{--tw-ring-color:color-mix(in oklab, var(--color-dream-glow) 60%, transparent)}}.focus-visible\:ring-recall\/60:focus-visible{--tw-ring-color:#10b98199}@supports (color:color-mix(in lab,red,red)){.focus-visible\:ring-recall\/60:focus-visible{--tw-ring-color:color-mix(in oklab, var(--color-recall) 60%, transparent)}}.focus-visible\:ring-synapse\/60:focus-visible{--tw-ring-color:#6366f199}@supports (color:color-mix(in lab,red,red)){.focus-visible\:ring-synapse\/60:focus-visible{--tw-ring-color:color-mix(in oklab, var(--color-synapse) 60%, transparent)}}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}@media(min-width:40rem){.sm\:block{display:block}.sm\:inline-flex{display:inline-flex}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}}@media(min-width:48rem){.md\:block{display:block}.md\:flex{display:flex}.md\:hidden{display:none}.md\:inline-flex{display:inline-flex}.md\:min-w-\[340px\]{min-width:340px}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.md\:grid-cols-\[1fr_auto\]{grid-template-columns:1fr auto}.md\:grid-cols-\[280px_1fr\]{grid-template-columns:280px 1fr}.md\:flex-row{flex-direction:row}.md\:pt-\[15vh\]{padding-top:15vh}.md\:pb-0{padding-bottom:calc(var(--spacing) * 0)}}@media(min-width:64rem){.lg\:block{display:block}.lg\:inline-flex{display:inline-flex}.lg\:w-56{width:calc(var(--spacing) * 56)}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-\[1fr_280px\]{grid-template-columns:1fr 280px}.lg\:grid-cols-\[1fr_340px\]{grid-template-columns:1fr 340px}.lg\:grid-cols-\[1fr_360px\]{grid-template-columns:1fr 360px}.lg\:grid-cols-\[minmax\(0\,1fr\)_340px\]{grid-template-columns:minmax(0,1fr) 340px}}.\[\&\:\:-webkit-slider-thumb\]\:h-3::-webkit-slider-thumb{height:calc(var(--spacing) * 3)}.\[\&\:\:-webkit-slider-thumb\]\:w-3::-webkit-slider-thumb{width:calc(var(--spacing) * 3)}.\[\&\:\:-webkit-slider-thumb\]\:appearance-none::-webkit-slider-thumb{-webkit-appearance:none;-moz-appearance:none;appearance:none}.\[\&\:\:-webkit-slider-thumb\]\:rounded-full::-webkit-slider-thumb{border-radius:3.40282e38px}.\[\&\:\:-webkit-slider-thumb\]\:bg-synapse-glow::-webkit-slider-thumb{background-color:var(--color-synapse-glow)}.\[\&\:\:-webkit-slider-thumb\]\:shadow-\[0_0_8px_rgba\(129\,140\,248\,0\.4\)\]::-webkit-slider-thumb{--tw-shadow:0 0 8px var(--tw-shadow-color,#818cf866);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}}html{background:var(--color-void);color:var(--color-text);font-family:var(--font-mono)}body{min-height:100vh;margin:0;overflow:hidden}::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-track{background:0 0}::-webkit-scrollbar-thumb{background:var(--color-subtle);border-radius:3px}::-webkit-scrollbar-thumb:hover{background:var(--color-muted)}.glass{-webkit-backdrop-filter:blur(20px)saturate(180%);background:#16163873;border:1px solid #6366f114;box-shadow:inset 0 1px #ffffff08,0 4px 24px #0000004d}.glass-subtle{-webkit-backdrop-filter:blur(12px)saturate(150%);background:#10102a66;border:1px solid #6366f10f;box-shadow:inset 0 1px #ffffff05,0 2px 12px #0003}.glass-sidebar{-webkit-backdrop-filter:blur(24px)saturate(180%);background:#0a0a1a99;border-right:1px solid #6366f11a;box-shadow:inset -1px 0 #ffffff05,4px 0 24px #0000004d}.glass-panel{-webkit-backdrop-filter:blur(24px)saturate(180%);background:#0a0a1acc;border:1px solid #6366f11a;box-shadow:inset 0 1px #ffffff08,0 8px 32px #0006}.glow-synapse{box-shadow:0 0 20px #6366f14d,0 0 60px #6366f11a}.glow-dream{box-shadow:0 0 20px #a855f74d,0 0 60px #a855f71a}.glow-memory{box-shadow:0 0 20px #3b82f64d,0 0 60px #3b82f61a}@keyframes pulse-glow{0%,to{opacity:1}50%{opacity:.5}}.animate-pulse-glow{animation:2s ease-in-out infinite pulse-glow}@keyframes orb-float-1{0%,to{transform:translate(0)scale(1)}25%{transform:translate(60px,-40px)scale(1.1)}50%{transform:translate(-30px,-80px)scale(.95)}75%{transform:translate(-60px,-20px)scale(1.05)}}@keyframes orb-float-2{0%,to{transform:translate(0)scale(1)}25%{transform:translate(-50px,30px)scale(1.08)}50%{transform:translate(40px,60px)scale(.92)}75%{transform:translate(20px,-40px)scale(1.03)}}@keyframes orb-float-3{0%,to{transform:translate(0)scale(1)}25%{transform:translate(30px,50px)scale(1.05)}50%{transform:translate(-60px,20px)scale(.98)}75%{transform:translate(40px,-30px)scale(1.1)}}.ambient-orb{filter:blur(80px);pointer-events:none;z-index:0;opacity:.35;border-radius:50%;position:fixed}.ambient-orb-1{background:radial-gradient(circle,#a855f766,#0000 70%);width:400px;height:400px;animation:20s ease-in-out infinite orb-float-1;top:-10%;right:-5%}.ambient-orb-2{background:radial-gradient(circle,#6366f159,#0000 70%);width:350px;height:350px;animation:25s ease-in-out infinite orb-float-2;bottom:-15%;left:-5%}.ambient-orb-3{background:radial-gradient(circle,#f59e0b33,#0000 70%);width:300px;height:300px;animation:22s ease-in-out infinite orb-float-3;top:40%;left:40%}.nav-active-border{position:relative}.nav-active-border:before{content:"";background:linear-gradient(180deg,var(--color-synapse),var(--color-dream),var(--color-synapse));background-size:100% 200%;border-radius:1px;width:2px;animation:3s ease-in-out infinite gradient-shift;position:absolute;top:4px;bottom:4px;left:0}@keyframes gradient-shift{0%,to{background-position:0 0}50%{background-position:0 100%}}@keyframes float{0%,to{transform:translateY(0)translate(0)}25%{transform:translateY(-10px)translate(5px)}50%{transform:translateY(-5px)translate(-5px)}75%{transform:translateY(-15px)translate(3px)}}.retention-critical{color:var(--color-decay)}.retention-low{color:var(--color-warning)}.retention-good{color:var(--color-recall)}.retention-strong{color:var(--color-synapse)}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-gradient-position{syntax:"*";inherits:false}@property --tw-gradient-from{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-via{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-to{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-stops{syntax:"*";inherits:false}@property --tw-gradient-via-stops{syntax:"*";inherits:false}@property --tw-gradient-from-position{syntax:"";inherits:false;initial-value:0%}@property --tw-gradient-via-position{syntax:"";inherits:false;initial-value:50%}@property --tw-gradient-to-position{syntax:"";inherits:false;initial-value:100%}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}}@keyframes ping{75%,to{opacity:0;transform:scale(2)}}@keyframes pulse{50%{opacity:.5}}.toast-layer.svelte-pry2ep{position:fixed;z-index:60;pointer-events:none;display:flex;flex-direction:column;gap:.5rem;right:1.25rem;bottom:1.25rem;max-width:22rem;width:calc(100vw - 2.5rem)}@media(max-width:768px){.toast-layer.svelte-pry2ep{right:.75rem;left:.75rem;bottom:auto;top:5.25rem;max-width:none;width:auto;align-items:stretch}}.toast-item.svelte-pry2ep{pointer-events:auto;position:relative;display:flex;gap:.75rem;align-items:stretch;text-align:left;font:inherit;color:inherit;background:#0c0e16b8;backdrop-filter:blur(14px) saturate(160%);-webkit-backdrop-filter:blur(14px) saturate(160%);border:1px solid rgba(255,255,255,.06);border-radius:.75rem;padding:.75rem .9rem .75rem .5rem;overflow:hidden;box-shadow:0 10px 40px -12px #000c,0 0 22px -6px var(--toast-color);cursor:pointer;animation:svelte-pry2ep-toast-in .32s cubic-bezier(.16,1,.3,1);transform-origin:right center;transition:transform .15s ease,box-shadow .15s ease}.toast-item.svelte-pry2ep:hover{transform:translateY(-1px) scale(1.015);box-shadow:0 14px 48px -12px #000000d9,0 0 32px -4px var(--toast-color)}.toast-item.svelte-pry2ep:focus-visible{outline:1px solid var(--toast-color);outline-offset:2px}.toast-accent.svelte-pry2ep{width:3px;border-radius:2px;background:var(--toast-color);box-shadow:0 0 10px var(--toast-color);flex-shrink:0}.toast-body.svelte-pry2ep{display:flex;flex-direction:column;gap:.15rem;flex:1;min-width:0}.toast-head.svelte-pry2ep{display:flex;align-items:center;gap:.5rem}.toast-icon.svelte-pry2ep{color:var(--toast-color);font-size:.95rem;text-shadow:0 0 8px var(--toast-color);line-height:1;width:1rem;display:inline-flex;justify-content:center}.toast-title.svelte-pry2ep{color:#f5f5fa;font-size:.82rem;font-weight:600;letter-spacing:.01em}.toast-sub.svelte-pry2ep{color:#b0b6c4;font-size:.74rem;line-height:1.35;padding-left:1.5rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.toast-progress.svelte-pry2ep{position:absolute;left:0;bottom:0;height:2px;width:100%;background:#ffffff0a}.toast-progress-fill.svelte-pry2ep{height:100%;background:var(--toast-color);opacity:.55;transform-origin:left center;animation:svelte-pry2ep-toast-progress var(--toast-dwell) linear forwards}.toast-item.svelte-pry2ep:hover .toast-progress-fill:where(.svelte-pry2ep),.toast-item.svelte-pry2ep:focus-visible .toast-progress-fill:where(.svelte-pry2ep){animation-play-state:paused}@keyframes svelte-pry2ep-toast-in{0%{opacity:0;transform:translate(24px) scale(.98)}to{opacity:1;transform:translate(0) scale(1)}}@media(max-width:768px){.toast-item.svelte-pry2ep{transform-origin:top center;animation:svelte-pry2ep-toast-in-mobile .3s cubic-bezier(.16,1,.3,1)}}@keyframes svelte-pry2ep-toast-in-mobile{0%{opacity:0;transform:translateY(-12px) scale(.98)}to{opacity:1;transform:translateY(0) scale(1)}}@keyframes svelte-pry2ep-toast-progress{0%{transform:scaleX(1)}to{transform:scaleX(0)}}@media(prefers-reduced-motion:reduce){.toast-item.svelte-pry2ep{animation:none}.toast-progress-fill.svelte-pry2ep{animation:none;transform:scaleX(.5)}}.strip-item.svelte-1kk3799{display:inline-flex;align-items:center;gap:.4rem;padding:0 .75rem;white-space:nowrap;flex-shrink:0}.strip-divider.svelte-1kk3799{width:1px;height:14px;background:#6366f11f;flex-shrink:0}.ambient-strip.ambient-flash.svelte-1kk3799{background:linear-gradient(90deg,#ef444414,#ef444400 70%),#0006;border-bottom-color:#ef444459;transition:background .3s ease,border-color .3s ease}@keyframes svelte-1kk3799-ping-slow{0%{transform:scale(1);opacity:.8}80%,to{transform:scale(2);opacity:0}}.animate-ping-slow{animation:svelte-1kk3799-ping-slow 2.2s cubic-bezier(0,0,.2,1) infinite}@media(prefers-reduced-motion:reduce){.ambient-strip.svelte-1kk3799 .animate-ping,.ambient-strip.svelte-1kk3799 .animate-ping-slow,.ambient-strip.svelte-1kk3799 .animate-pulse{animation:none!important}}.verdict-bar.svelte-1j425e6{border-bottom:1px solid rgba(255,255,255,.06);background:#080912b8;backdrop-filter:blur(18px) saturate(160%);-webkit-backdrop-filter:blur(18px) saturate(160%);box-shadow:0 6px 24px #0000002e}.verdict-summary.svelte-1j425e6{width:100%;min-height:2.75rem;display:grid;grid-template-columns:auto auto minmax(0,1fr) auto;align-items:center;gap:.75rem;padding:.55rem 1rem;color:var(--color-text);text-align:left;font:inherit}.sr-only.svelte-1j425e6{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.label.svelte-1j425e6,.field-label.svelte-1j425e6,.appeal-row.svelte-1j425e6>span:where(.svelte-1j425e6){color:var(--color-dim);font-size:.68rem;text-transform:uppercase;letter-spacing:0}.levels.svelte-1j425e6{display:flex;align-items:center;gap:.25rem}.levels.svelte-1j425e6 span:where(.svelte-1j425e6){border:1px solid rgba(255,255,255,.08);border-radius:.35rem;padding:.18rem .38rem;color:var(--color-muted);font-size:.64rem;line-height:1}.levels.svelte-1j425e6 span.active:where(.svelte-1j425e6){color:var(--color-bright);border-color:var(--verdict-color);background:color-mix(in srgb,var(--verdict-color) 18%,transparent);box-shadow:0 0 14px color-mix(in srgb,var(--verdict-color) 28%,transparent)}.summary-text.svelte-1j425e6{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:.78rem;color:var(--color-dim)}.when.svelte-1j425e6{color:var(--color-muted);font-size:.68rem}.receipt.svelte-1j425e6{margin:0 1rem .75rem;border:1px solid rgba(255,255,255,.08);border-radius:.5rem;background:#0a0a1ac7;padding:.85rem}.receipt-grid.svelte-1j425e6{display:grid;grid-template-columns:minmax(0,1.4fr) minmax(10rem,.8fr) minmax(0,1.1fr) minmax(0,1.1fr);gap:.85rem}.receipt.svelte-1j425e6 p:where(.svelte-1j425e6),.receipt.svelte-1j425e6 li:where(.svelte-1j425e6){margin:.25rem 0 0;color:var(--color-text);font-size:.76rem;line-height:1.45;overflow-wrap:anywhere}.receipt.svelte-1j425e6 ul:where(.svelte-1j425e6){margin:.25rem 0 0;padding-left:1rem}.appeal-row.svelte-1j425e6{display:flex;align-items:center;gap:.4rem;margin-top:.85rem;flex-wrap:wrap}.appeal-row.svelte-1j425e6 button:where(.svelte-1j425e6),.appeal-row.svelte-1j425e6 p:where(.svelte-1j425e6){border:1px solid rgba(255,255,255,.1);border-radius:.4rem;padding:.35rem .6rem;color:var(--color-text);background:#ffffff0a;font-size:.72rem;margin:0}.appeal-row.svelte-1j425e6 button:where(.svelte-1j425e6):hover:not(:disabled),.verdict-summary.svelte-1j425e6:hover{background:#ffffff0d}.appeal-row.svelte-1j425e6 button:where(.svelte-1j425e6):disabled{opacity:.55;cursor:wait}.tone-pass.svelte-1j425e6,.tone-note.svelte-1j425e6{--verdict-color: #10b981}.tone-caution.svelte-1j425e6{--verdict-color: #f59e0b}.tone-veto.svelte-1j425e6{--verdict-color: #ef4444}.tone-appealed.svelte-1j425e6{--verdict-color: #818cf8}@media(max-width:900px){.verdict-summary.svelte-1j425e6{grid-template-columns:auto minmax(0,1fr) auto}.levels.svelte-1j425e6{grid-column:1 / -1;order:4;overflow-x:auto;padding-bottom:.1rem}.receipt-grid.svelte-1j425e6{grid-template-columns:1fr}}.theme-toggle.svelte-1cmi4dh{width:30px;height:30px;display:inline-flex;align-items:center;justify-content:center;padding:0;border-radius:8px;background:#6366f10f;border:1px solid rgba(99,102,241,.14);color:var(--color-text);cursor:pointer;transition:background .2s ease,border-color .2s ease,color .2s ease,transform .12s ease;-webkit-tap-highlight-color:transparent}.theme-toggle.svelte-1cmi4dh:hover{background:#6366f124;border-color:#6366f14d;color:var(--color-bright)}.theme-toggle.svelte-1cmi4dh:active{transform:scale(.94)}.theme-toggle.svelte-1cmi4dh:focus-visible{outline:1px solid var(--color-synapse);outline-offset:2px}.icon-wrap.svelte-1cmi4dh{position:relative;width:18px;height:18px;display:inline-block}.icon.svelte-1cmi4dh{position:absolute;top:0;right:0;bottom:0;left:0;width:18px;height:18px;opacity:0;transform:scale(.7) rotate(-30deg);transition:opacity .2s ease,transform .2s cubic-bezier(.16,1,.3,1);pointer-events:none}.icon.active.svelte-1cmi4dh{opacity:1;transform:scale(1) rotate(0)}.theme-toggle[data-mode=dark].svelte-1cmi4dh{color:var(--color-synapse-glow, #818cf8)}.theme-toggle[data-mode=light].svelte-1cmi4dh{color:var(--color-warning, #f59e0b)}.theme-toggle[data-mode=auto].svelte-1cmi4dh{color:var(--color-dream-glow, #c084fc)}@media(prefers-reduced-motion:reduce){.theme-toggle.svelte-1cmi4dh,.icon.svelte-1cmi4dh{transition:none}}.safe-bottom.svelte-12qhfyh{padding-bottom:env(safe-area-inset-bottom,0px)}@keyframes svelte-12qhfyh-page-in{0%{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}.animate-page-in.svelte-12qhfyh{animation:svelte-12qhfyh-page-in .2s ease-out} diff --git a/apps/dashboard/build/_app/immutable/assets/0.Bor8S3Zo.css.br b/apps/dashboard/build/_app/immutable/assets/0.Bor8S3Zo.css.br new file mode 100644 index 0000000..ace1ff4 Binary files /dev/null and b/apps/dashboard/build/_app/immutable/assets/0.Bor8S3Zo.css.br differ diff --git a/apps/dashboard/build/_app/immutable/assets/0.Bor8S3Zo.css.gz b/apps/dashboard/build/_app/immutable/assets/0.Bor8S3Zo.css.gz new file mode 100644 index 0000000..9bfd308 Binary files /dev/null and b/apps/dashboard/build/_app/immutable/assets/0.Bor8S3Zo.css.gz differ diff --git a/apps/dashboard/build/_app/immutable/assets/0.IIz8MMYb.css b/apps/dashboard/build/_app/immutable/assets/0.IIz8MMYb.css deleted file mode 100644 index 73d4560..0000000 --- a/apps/dashboard/build/_app/immutable/assets/0.IIz8MMYb.css +++ /dev/null @@ -1 +0,0 @@ -/*! tailwindcss v4.2.0 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:"JetBrains Mono", "Fira Code", "SF Mono", monospace;--color-amber-400:oklch(82.8% .189 84.429);--color-purple-400:oklch(71.4% .203 305.504);--color-purple-500:oklch(62.7% .265 303.9);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-md:28rem;--container-lg:32rem;--container-xl:36rem;--container-2xl:42rem;--container-4xl:56rem;--container-5xl:64rem;--container-6xl:72rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height: 1.2 ;--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--text-6xl:3.75rem;--text-6xl--line-height:1;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-wide:.025em;--tracking-wider:.05em;--tracking-widest:.1em;--leading-snug:1.375;--leading-relaxed:1.625;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--ease-out:cubic-bezier(0, 0, .2, 1);--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-ping:ping 1s cubic-bezier(0, 0, .2, 1) infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--blur-sm:8px;--blur-md:12px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-void:#050510;--color-abyss:#0a0a1a;--color-deep:#10102a;--color-surface:#161638;--color-elevated:#1e1e4a;--color-subtle:#2a2a5e;--color-muted:#4a4a7a;--color-dim:#7a7aaa;--color-text:#e0e0ff;--color-bright:#fff;--color-synapse:#6366f1;--color-synapse-glow:#818cf8;--color-dream:#a855f7;--color-dream-glow:#c084fc;--color-memory:#3b82f6;--color-recall:#10b981;--color-decay:#ef4444;--color-warning:#f59e0b;--color-node-pattern:#ec4899}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::file-selector-button{-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.pointer-events-auto{pointer-events:auto}.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-0{inset:calc(var(--spacing) * 0)}.inset-x-0{inset-inline:calc(var(--spacing) * 0)}.start{inset-inline-start:var(--spacing)}.end{inset-inline-end:var(--spacing)}.end\!{inset-inline-end:var(--spacing)!important}.top-0{top:calc(var(--spacing) * 0)}.top-0\.5{top:calc(var(--spacing) * .5)}.top-1{top:calc(var(--spacing) * 1)}.top-3{top:calc(var(--spacing) * 3)}.top-4{top:calc(var(--spacing) * 4)}.top-10{top:calc(var(--spacing) * 10)}.right-0{right:calc(var(--spacing) * 0)}.right-4{right:calc(var(--spacing) * 4)}.bottom-0{bottom:calc(var(--spacing) * 0)}.bottom-4{bottom:calc(var(--spacing) * 4)}.-left-\[29px\]{left:-29px}.left-1\/2{left:50%}.left-4{left:calc(var(--spacing) * 4)}.left-6{left:calc(var(--spacing) * 6)}.isolate{isolation:isolate}.z-10{z-index:10}.z-20{z-index:20}.z-40{z-index:40}.z-50{z-index:50}.z-\[1\]{z-index:1}.container{width:100%}@media(min-width:40rem){.container{max-width:40rem}}@media(min-width:48rem){.container{max-width:48rem}}@media(min-width:64rem){.container{max-width:64rem}}@media(min-width:80rem){.container{max-width:80rem}}@media(min-width:96rem){.container{max-width:96rem}}.mx-2{margin-inline:calc(var(--spacing) * 2)}.mx-auto{margin-inline:auto}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-\[-12px\]{margin-top:-12px}.mr-1{margin-right:calc(var(--spacing) * 1)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mb-0\.5{margin-bottom:calc(var(--spacing) * .5)}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-3{margin-left:calc(var(--spacing) * 3)}.ml-auto{margin-left:auto}.line-clamp-1{-webkit-line-clamp:1;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-2{-webkit-line-clamp:2;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-3{-webkit-line-clamp:3;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-4{-webkit-line-clamp:4;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-flex{display:inline-flex}.table{display:table}.aspect-square{aspect-ratio:1}.h-0\.5{height:calc(var(--spacing) * .5)}.h-1{height:calc(var(--spacing) * 1)}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-12{height:calc(var(--spacing) * 12)}.h-14{height:calc(var(--spacing) * 14)}.h-16{height:calc(var(--spacing) * 16)}.h-20{height:calc(var(--spacing) * 20)}.h-24{height:calc(var(--spacing) * 24)}.h-28{height:calc(var(--spacing) * 28)}.h-32{height:calc(var(--spacing) * 32)}.h-40{height:calc(var(--spacing) * 40)}.h-\[520px\]{height:520px}.h-\[560px\]{height:560px}.h-full{height:100%}.h-px{height:1px}.h-screen{height:100vh}.max-h-48{max-height:calc(var(--spacing) * 48)}.max-h-64{max-height:calc(var(--spacing) * 64)}.max-h-72{max-height:calc(var(--spacing) * 72)}.max-h-96{max-height:calc(var(--spacing) * 96)}.max-h-\[620px\]{max-height:620px}.min-h-0{min-height:calc(var(--spacing) * 0)}.min-h-40{min-height:calc(var(--spacing) * 40)}.min-h-\[240px\]{min-height:240px}.min-h-\[320px\]{min-height:320px}.min-h-\[520px\]{min-height:520px}.min-h-full{min-height:100%}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-2{width:calc(var(--spacing) * 2)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-3{width:calc(var(--spacing) * 3)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-12{width:calc(var(--spacing) * 12)}.w-14{width:calc(var(--spacing) * 14)}.w-16{width:calc(var(--spacing) * 16)}.w-20{width:calc(var(--spacing) * 20)}.w-24{width:calc(var(--spacing) * 24)}.w-32{width:calc(var(--spacing) * 32)}.w-96{width:calc(var(--spacing) * 96)}.w-\[3px\]{width:3px}.w-\[90\%\]{width:90%}.w-full{width:100%}.w-px{width:1px}.max-w-2xl{max-width:var(--container-2xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-7xl{max-width:var(--container-7xl)}.max-w-20{max-width:calc(var(--spacing) * 20)}.max-w-\[220px\]{max-width:220px}.max-w-lg{max-width:var(--container-lg)}.max-w-md{max-width:var(--container-md)}.max-w-xl{max-width:var(--container-xl)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:calc(var(--spacing) * 0)}.min-w-12{min-width:calc(var(--spacing) * 12)}.min-w-16{min-width:calc(var(--spacing) * 16)}.min-w-64{min-width:calc(var(--spacing) * 64)}.min-w-\[2rem\]{min-width:2rem}.min-w-\[3\.5rem\]{min-width:3.5rem}.flex-1{flex:1}.flex-shrink{flex-shrink:1}.flex-shrink-0{flex-shrink:0}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.border-separate{border-collapse:separate}.-translate-x-1\/2{--tw-translate-x: -50% ;translate:var(--tw-translate-x) var(--tw-translate-y)}.scale-125{--tw-scale-x:125%;--tw-scale-y:125%;--tw-scale-z:125%;scale:var(--tw-scale-x) var(--tw-scale-y)}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.animate-\[fadeSlide_0\.35s_ease-out_both\]{animation:.35s ease-out both fadeSlide}.animate-ping{animation:var(--animate-ping)}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-default{cursor:default}.cursor-pointer{cursor:pointer}.resize{resize:both}.resize-none{resize:none}.resize-y{resize:vertical}.appearance-none{-webkit-appearance:none;-moz-appearance:none;appearance:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.grid-cols-7{grid-template-columns:repeat(7,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-around{justify-content:space-around}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-0{gap:calc(var(--spacing) * 0)}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}.gap-6{gap:calc(var(--spacing) * 6)}.gap-\[2px\]{gap:2px}:where(.space-y-0\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * .5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * .5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-8>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 8) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-6{column-gap:calc(var(--spacing) * 6)}.gap-y-1{row-gap:calc(var(--spacing) * 1)}.self-center{align-self:center}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-sm{border-radius:var(--radius-sm)}.rounded-xl{border-radius:var(--radius-xl)}.rounded-t{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.\!border-decay\/20{border-color:#ef444433!important}@supports (color:color-mix(in lab,red,red)){.\!border-decay\/20{border-color:color-mix(in oklab,var(--color-decay) 20%,transparent)!important}}.\!border-decay\/30{border-color:#ef44444d!important}@supports (color:color-mix(in lab,red,red)){.\!border-decay\/30{border-color:color-mix(in oklab,var(--color-decay) 30%,transparent)!important}}.\!border-decay\/40{border-color:#ef444466!important}@supports (color:color-mix(in lab,red,red)){.\!border-decay\/40{border-color:color-mix(in oklab,var(--color-decay) 40%,transparent)!important}}.\!border-dream\/20{border-color:#a855f733!important}@supports (color:color-mix(in lab,red,red)){.\!border-dream\/20{border-color:color-mix(in oklab,var(--color-dream) 20%,transparent)!important}}.\!border-synapse\/15{border-color:#6366f126!important}@supports (color:color-mix(in lab,red,red)){.\!border-synapse\/15{border-color:color-mix(in oklab,var(--color-synapse) 15%,transparent)!important}}.\!border-synapse\/20{border-color:#6366f133!important}@supports (color:color-mix(in lab,red,red)){.\!border-synapse\/20{border-color:color-mix(in oklab,var(--color-synapse) 20%,transparent)!important}}.\!border-synapse\/25{border-color:#6366f140!important}@supports (color:color-mix(in lab,red,red)){.\!border-synapse\/25{border-color:color-mix(in oklab,var(--color-synapse) 25%,transparent)!important}}.\!border-synapse\/30{border-color:#6366f14d!important}@supports (color:color-mix(in lab,red,red)){.\!border-synapse\/30{border-color:color-mix(in oklab,var(--color-synapse) 30%,transparent)!important}}.\!border-synapse\/40{border-color:#6366f166!important}@supports (color:color-mix(in lab,red,red)){.\!border-synapse\/40{border-color:color-mix(in oklab,var(--color-synapse) 40%,transparent)!important}}.border-\[\#A33FFF\]\/40{border-color:#a33fff66}.border-decay\/20{border-color:#ef444433}@supports (color:color-mix(in lab,red,red)){.border-decay\/20{border-color:color-mix(in oklab,var(--color-decay) 20%,transparent)}}.border-dream-glow\/40{border-color:#c084fc66}@supports (color:color-mix(in lab,red,red)){.border-dream-glow\/40{border-color:color-mix(in oklab,var(--color-dream-glow) 40%,transparent)}}.border-dream\/10{border-color:#a855f71a}@supports (color:color-mix(in lab,red,red)){.border-dream\/10{border-color:color-mix(in oklab,var(--color-dream) 10%,transparent)}}.border-dream\/20{border-color:#a855f733}@supports (color:color-mix(in lab,red,red)){.border-dream\/20{border-color:color-mix(in oklab,var(--color-dream) 20%,transparent)}}.border-dream\/30{border-color:#a855f74d}@supports (color:color-mix(in lab,red,red)){.border-dream\/30{border-color:color-mix(in oklab,var(--color-dream) 30%,transparent)}}.border-dream\/40{border-color:#a855f766}@supports (color:color-mix(in lab,red,red)){.border-dream\/40{border-color:color-mix(in oklab,var(--color-dream) 40%,transparent)}}.border-dream\/50{border-color:#a855f780}@supports (color:color-mix(in lab,red,red)){.border-dream\/50{border-color:color-mix(in oklab,var(--color-dream) 50%,transparent)}}.border-recall\/30{border-color:#10b9814d}@supports (color:color-mix(in lab,red,red)){.border-recall\/30{border-color:color-mix(in oklab,var(--color-recall) 30%,transparent)}}.border-recall\/40{border-color:#10b98166}@supports (color:color-mix(in lab,red,red)){.border-recall\/40{border-color:color-mix(in oklab,var(--color-recall) 40%,transparent)}}.border-subtle\/15{border-color:#2a2a5e26}@supports (color:color-mix(in lab,red,red)){.border-subtle\/15{border-color:color-mix(in oklab,var(--color-subtle) 15%,transparent)}}.border-subtle\/20{border-color:#2a2a5e33}@supports (color:color-mix(in lab,red,red)){.border-subtle\/20{border-color:color-mix(in oklab,var(--color-subtle) 20%,transparent)}}.border-subtle\/30{border-color:#2a2a5e4d}@supports (color:color-mix(in lab,red,red)){.border-subtle\/30{border-color:color-mix(in oklab,var(--color-subtle) 30%,transparent)}}.border-synapse{border-color:var(--color-synapse)}.border-synapse\/5{border-color:#6366f10d}@supports (color:color-mix(in lab,red,red)){.border-synapse\/5{border-color:color-mix(in oklab,var(--color-synapse) 5%,transparent)}}.border-synapse\/10{border-color:#6366f11a}@supports (color:color-mix(in lab,red,red)){.border-synapse\/10{border-color:color-mix(in oklab,var(--color-synapse) 10%,transparent)}}.border-synapse\/15{border-color:#6366f126}@supports (color:color-mix(in lab,red,red)){.border-synapse\/15{border-color:color-mix(in oklab,var(--color-synapse) 15%,transparent)}}.border-synapse\/20{border-color:#6366f133}@supports (color:color-mix(in lab,red,red)){.border-synapse\/20{border-color:color-mix(in oklab,var(--color-synapse) 20%,transparent)}}.border-synapse\/30{border-color:#6366f14d}@supports (color:color-mix(in lab,red,red)){.border-synapse\/30{border-color:color-mix(in oklab,var(--color-synapse) 30%,transparent)}}.border-synapse\/40{border-color:#6366f166}@supports (color:color-mix(in lab,red,red)){.border-synapse\/40{border-color:color-mix(in oklab,var(--color-synapse) 40%,transparent)}}.border-transparent{border-color:#0000}.border-warning\/30{border-color:#f59e0b4d}@supports (color:color-mix(in lab,red,red)){.border-warning\/30{border-color:color-mix(in oklab,var(--color-warning) 30%,transparent)}}.border-warning\/40{border-color:#f59e0b66}@supports (color:color-mix(in lab,red,red)){.border-warning\/40{border-color:color-mix(in oklab,var(--color-warning) 40%,transparent)}}.border-warning\/50{border-color:#f59e0b80}@supports (color:color-mix(in lab,red,red)){.border-warning\/50{border-color:color-mix(in oklab,var(--color-warning) 50%,transparent)}}.border-white\/5{border-color:#ffffff0d}@supports (color:color-mix(in lab,red,red)){.border-white\/5{border-color:color-mix(in oklab,var(--color-white) 5%,transparent)}}.border-t-dream{border-top-color:var(--color-dream)}.border-t-synapse{border-top-color:var(--color-synapse)}.border-t-warning{border-top-color:var(--color-warning)}.bg-\[\#A33FFF\]{background-color:#a33fff}.bg-\[\#A33FFF\]\/10{background-color:#a33fff1a}.bg-amber-400{background-color:var(--color-amber-400)}.bg-black\/40{background-color:#0006}@supports (color:color-mix(in lab,red,red)){.bg-black\/40{background-color:color-mix(in oklab,var(--color-black) 40%,transparent)}}.bg-decay{background-color:var(--color-decay)}.bg-decay\/10{background-color:#ef44441a}@supports (color:color-mix(in lab,red,red)){.bg-decay\/10{background-color:color-mix(in oklab,var(--color-decay) 10%,transparent)}}.bg-decay\/20{background-color:#ef444433}@supports (color:color-mix(in lab,red,red)){.bg-decay\/20{background-color:color-mix(in oklab,var(--color-decay) 20%,transparent)}}.bg-decay\/\[0\.05\]{background-color:#ef44440d}@supports (color:color-mix(in lab,red,red)){.bg-decay\/\[0\.05\]{background-color:color-mix(in oklab,var(--color-decay) 5%,transparent)}}.bg-deep{background-color:var(--color-deep)}.bg-deep\/40{background-color:#10102a66}@supports (color:color-mix(in lab,red,red)){.bg-deep\/40{background-color:color-mix(in oklab,var(--color-deep) 40%,transparent)}}.bg-deep\/60{background-color:#10102a99}@supports (color:color-mix(in lab,red,red)){.bg-deep\/60{background-color:color-mix(in oklab,var(--color-deep) 60%,transparent)}}.bg-dream{background-color:var(--color-dream)}.bg-dream\/5{background-color:#a855f70d}@supports (color:color-mix(in lab,red,red)){.bg-dream\/5{background-color:color-mix(in oklab,var(--color-dream) 5%,transparent)}}.bg-dream\/10{background-color:#a855f71a}@supports (color:color-mix(in lab,red,red)){.bg-dream\/10{background-color:color-mix(in oklab,var(--color-dream) 10%,transparent)}}.bg-dream\/15{background-color:#a855f726}@supports (color:color-mix(in lab,red,red)){.bg-dream\/15{background-color:color-mix(in oklab,var(--color-dream) 15%,transparent)}}.bg-dream\/20{background-color:#a855f733}@supports (color:color-mix(in lab,red,red)){.bg-dream\/20{background-color:color-mix(in oklab,var(--color-dream) 20%,transparent)}}.bg-muted{background-color:var(--color-muted)}.bg-node-pattern{background-color:var(--color-node-pattern)}.bg-purple-500\/20{background-color:#ac4bff33}@supports (color:color-mix(in lab,red,red)){.bg-purple-500\/20{background-color:color-mix(in oklab,var(--color-purple-500) 20%,transparent)}}.bg-recall{background-color:var(--color-recall)}.bg-recall\/10{background-color:#10b9811a}@supports (color:color-mix(in lab,red,red)){.bg-recall\/10{background-color:color-mix(in oklab,var(--color-recall) 10%,transparent)}}.bg-recall\/15{background-color:#10b98126}@supports (color:color-mix(in lab,red,red)){.bg-recall\/15{background-color:color-mix(in oklab,var(--color-recall) 15%,transparent)}}.bg-recall\/20{background-color:#10b98133}@supports (color:color-mix(in lab,red,red)){.bg-recall\/20{background-color:color-mix(in oklab,var(--color-recall) 20%,transparent)}}.bg-synapse{background-color:var(--color-synapse)}.bg-synapse-glow{background-color:var(--color-synapse-glow)}.bg-synapse\/10{background-color:#6366f11a}@supports (color:color-mix(in lab,red,red)){.bg-synapse\/10{background-color:color-mix(in oklab,var(--color-synapse) 10%,transparent)}}.bg-synapse\/15{background-color:#6366f126}@supports (color:color-mix(in lab,red,red)){.bg-synapse\/15{background-color:color-mix(in oklab,var(--color-synapse) 15%,transparent)}}.bg-synapse\/20{background-color:#6366f133}@supports (color:color-mix(in lab,red,red)){.bg-synapse\/20{background-color:color-mix(in oklab,var(--color-synapse) 20%,transparent)}}.bg-synapse\/25{background-color:#6366f140}@supports (color:color-mix(in lab,red,red)){.bg-synapse\/25{background-color:color-mix(in oklab,var(--color-synapse) 25%,transparent)}}.bg-synapse\/70{background-color:#6366f1b3}@supports (color:color-mix(in lab,red,red)){.bg-synapse\/70{background-color:color-mix(in oklab,var(--color-synapse) 70%,transparent)}}.bg-transparent{background-color:#0000}.bg-void{background-color:var(--color-void)}.bg-void\/60{background-color:#05051099}@supports (color:color-mix(in lab,red,red)){.bg-void\/60{background-color:color-mix(in oklab,var(--color-void) 60%,transparent)}}.bg-warning{background-color:var(--color-warning)}.bg-warning\/5{background-color:#f59e0b0d}@supports (color:color-mix(in lab,red,red)){.bg-warning\/5{background-color:color-mix(in oklab,var(--color-warning) 5%,transparent)}}.bg-warning\/20{background-color:#f59e0b33}@supports (color:color-mix(in lab,red,red)){.bg-warning\/20{background-color:color-mix(in oklab,var(--color-warning) 20%,transparent)}}.bg-white\/\[0\.02\]{background-color:#ffffff05}@supports (color:color-mix(in lab,red,red)){.bg-white\/\[0\.02\]{background-color:color-mix(in oklab,var(--color-white) 2%,transparent)}}.bg-white\/\[0\.03\]{background-color:#ffffff08}@supports (color:color-mix(in lab,red,red)){.bg-white\/\[0\.03\]{background-color:color-mix(in oklab,var(--color-white) 3%,transparent)}}.bg-white\/\[0\.04\]{background-color:#ffffff0a}@supports (color:color-mix(in lab,red,red)){.bg-white\/\[0\.04\]{background-color:color-mix(in oklab,var(--color-white) 4%,transparent)}}.bg-white\/\[0\.06\]{background-color:#ffffff0f}@supports (color:color-mix(in lab,red,red)){.bg-white\/\[0\.06\]{background-color:color-mix(in oklab,var(--color-white) 6%,transparent)}}.bg-gradient-to-br{--tw-gradient-position:to bottom right in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.from-dream{--tw-gradient-from:var(--color-dream);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-synapse{--tw-gradient-to:var(--color-synapse);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.p-0{padding:calc(var(--spacing) * 0)}.p-0\.5{padding:calc(var(--spacing) * .5)}.p-1{padding:calc(var(--spacing) * 1)}.p-2{padding:calc(var(--spacing) * 2)}.p-2\.5{padding:calc(var(--spacing) * 2.5)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.p-10{padding:calc(var(--spacing) * 10)}.p-12{padding:calc(var(--spacing) * 12)}.px-1{padding-inline:calc(var(--spacing) * 1)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-8{padding-inline:calc(var(--spacing) * 8)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-5{padding-block:calc(var(--spacing) * 5)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-10{padding-block:calc(var(--spacing) * 10)}.py-12{padding-block:calc(var(--spacing) * 12)}.py-20{padding-block:calc(var(--spacing) * 20)}.pt-1{padding-top:calc(var(--spacing) * 1)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-6{padding-top:calc(var(--spacing) * 6)}.pt-8{padding-top:calc(var(--spacing) * 8)}.pt-\[10vh\]{padding-top:10vh}.pr-1{padding-right:calc(var(--spacing) * 1)}.pr-2{padding-right:calc(var(--spacing) * 2)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-16{padding-bottom:calc(var(--spacing) * 16)}.pl-6{padding-left:calc(var(--spacing) * 6)}.pl-14{padding-left:calc(var(--spacing) * 14)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-bottom{vertical-align:bottom}.align-top{vertical-align:top}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}.text-6xl{font-size:var(--text-6xl);line-height:var(--tw-leading,var(--text-6xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[8px\]{font-size:8px}.text-\[9px\]{font-size:9px}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-snug{--tw-leading:var(--leading-snug);line-height:var(--leading-snug)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[0\.12em\]{--tw-tracking:.12em;letter-spacing:.12em}.tracking-\[0\.15em\]{--tw-tracking:.15em;letter-spacing:.15em}.tracking-\[0\.18em\]{--tw-tracking:.18em;letter-spacing:.18em}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-\[\#E4C8FF\]{color:#e4c8ff}.text-amber-400{color:var(--color-amber-400)}.text-bright{color:var(--color-bright)}.text-decay{color:var(--color-decay)}.text-decay\/60{color:#ef444499}@supports (color:color-mix(in lab,red,red)){.text-decay\/60{color:color-mix(in oklab,var(--color-decay) 60%,transparent)}}.text-dim{color:var(--color-dim)}.text-dream{color:var(--color-dream)}.text-dream-glow{color:var(--color-dream-glow)}.text-dream\/40{color:#a855f766}@supports (color:color-mix(in lab,red,red)){.text-dream\/40{color:color-mix(in oklab,var(--color-dream) 40%,transparent)}}.text-dream\/80{color:#a855f7cc}@supports (color:color-mix(in lab,red,red)){.text-dream\/80{color:color-mix(in oklab,var(--color-dream) 80%,transparent)}}.text-memory{color:var(--color-memory)}.text-muted{color:var(--color-muted)}.text-muted\/50{color:#4a4a7a80}@supports (color:color-mix(in lab,red,red)){.text-muted\/50{color:color-mix(in oklab,var(--color-muted) 50%,transparent)}}.text-muted\/60{color:#4a4a7a99}@supports (color:color-mix(in lab,red,red)){.text-muted\/60{color:color-mix(in oklab,var(--color-muted) 60%,transparent)}}.text-node-pattern{color:var(--color-node-pattern)}.text-purple-400{color:var(--color-purple-400)}.text-recall{color:var(--color-recall)}.text-subtle{color:var(--color-subtle)}.text-synapse{color:var(--color-synapse)}.text-synapse-glow{color:var(--color-synapse-glow)}.text-text{color:var(--color-text)}.text-text\/80{color:#e0e0ffcc}@supports (color:color-mix(in lab,red,red)){.text-text\/80{color:color-mix(in oklab,var(--color-text) 80%,transparent)}}.text-warning{color:var(--color-warning)}.capitalize{text-transform:capitalize}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.underline-offset-4{text-underline-offset:4px}.accent-synapse{accent-color:var(--color-synapse)}.accent-synapse-glow{accent-color:var(--color-synapse-glow)}.opacity-20{opacity:.2}.opacity-30{opacity:.3}.opacity-35{opacity:.35}.opacity-40{opacity:.4}.opacity-60{opacity:.6}.opacity-75{opacity:.75}.opacity-100{opacity:1}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow\!{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a)!important;box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)!important}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_0_10px_rgba\(239\,68\,68\,0\.7\)\]{--tw-shadow:0 0 10px var(--tw-shadow-color,#ef4444b3);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_0_12px_rgba\(99\,102\,241\,0\.15\)\]{--tw-shadow:0 0 12px var(--tw-shadow-color,#6366f126);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_0_12px_rgba\(99\,102\,241\,0\.18\)\]{--tw-shadow:0 0 12px var(--tw-shadow-color,#6366f12e);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_0_12px_rgba\(163\,63\,255\,0\.15\)\]{--tw-shadow:0 0 12px var(--tw-shadow-color,#a33fff26);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_0_16px_rgba\(99\,102\,241\,0\.3\)\]{--tw-shadow:0 0 16px var(--tw-shadow-color,#6366f14d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[0_0_16px_rgba\(168\,85\,247\,0\.3\)\]{--tw-shadow:0 0 16px var(--tw-shadow-color,#a855f74d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring,.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-2{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-synapse\/10{--tw-shadow-color:#6366f11a}@supports (color:color-mix(in lab,red,red)){.shadow-synapse\/10{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-synapse) 10%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-synapse\/20{--tw-shadow-color:#6366f133}@supports (color:color-mix(in lab,red,red)){.shadow-synapse\/20{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-synapse) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.ring-dream-glow{--tw-ring-color:var(--color-dream-glow)}.ring-dream\/60{--tw-ring-color:#a855f799}@supports (color:color-mix(in lab,red,red)){.ring-dream\/60{--tw-ring-color:color-mix(in oklab, var(--color-dream) 60%, transparent)}}.ring-recall\/30{--tw-ring-color:#10b9814d}@supports (color:color-mix(in lab,red,red)){.ring-recall\/30{--tw-ring-color:color-mix(in oklab, var(--color-recall) 30%, transparent)}}.ring-synapse\/60{--tw-ring-color:#6366f199}@supports (color:color-mix(in lab,red,red)){.ring-synapse\/60{--tw-ring-color:color-mix(in oklab, var(--color-synapse) 60%, transparent)}}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.backdrop-blur-md{--tw-backdrop-blur:blur(var(--blur-md));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-sm{--tw-backdrop-blur:blur(var(--blur-sm));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-200{--tw-duration:.2s;transition-duration:.2s}.duration-300{--tw-duration:.3s;transition-duration:.3s}.duration-500{--tw-duration:.5s;transition-duration:.5s}.duration-700{--tw-duration:.7s;transition-duration:.7s}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.select-none{-webkit-user-select:none;user-select:none}.placeholder\:text-muted::placeholder{color:var(--color-muted)}@media(hover:hover){.hover\:z-10:hover{z-index:10}.hover\:scale-110:hover{--tw-scale-x:110%;--tw-scale-y:110%;--tw-scale-z:110%;scale:var(--tw-scale-x) var(--tw-scale-y)}.hover\:scale-\[1\.03\]:hover{scale:1.03}.hover\:\!border-synapse\/30:hover{border-color:#6366f14d!important}@supports (color:color-mix(in lab,red,red)){.hover\:\!border-synapse\/30:hover{border-color:color-mix(in oklab,var(--color-synapse) 30%,transparent)!important}}.hover\:border-synapse\/20:hover{border-color:#6366f133}@supports (color:color-mix(in lab,red,red)){.hover\:border-synapse\/20:hover{border-color:color-mix(in oklab,var(--color-synapse) 20%,transparent)}}.hover\:border-synapse\/30:hover{border-color:#6366f14d}@supports (color:color-mix(in lab,red,red)){.hover\:border-synapse\/30:hover{border-color:color-mix(in oklab,var(--color-synapse) 30%,transparent)}}.hover\:border-synapse\/50:hover{border-color:#6366f180}@supports (color:color-mix(in lab,red,red)){.hover\:border-synapse\/50:hover{border-color:color-mix(in oklab,var(--color-synapse) 50%,transparent)}}.hover\:bg-decay\/20:hover{background-color:#ef444433}@supports (color:color-mix(in lab,red,red)){.hover\:bg-decay\/20:hover{background-color:color-mix(in oklab,var(--color-decay) 20%,transparent)}}.hover\:bg-decay\/30:hover{background-color:#ef44444d}@supports (color:color-mix(in lab,red,red)){.hover\:bg-decay\/30:hover{background-color:color-mix(in oklab,var(--color-decay) 30%,transparent)}}.hover\:bg-dream\/20:hover{background-color:#a855f733}@supports (color:color-mix(in lab,red,red)){.hover\:bg-dream\/20:hover{background-color:color-mix(in oklab,var(--color-dream) 20%,transparent)}}.hover\:bg-dream\/30:hover{background-color:#a855f74d}@supports (color:color-mix(in lab,red,red)){.hover\:bg-dream\/30:hover{background-color:color-mix(in oklab,var(--color-dream) 30%,transparent)}}.hover\:bg-purple-500\/30:hover{background-color:#ac4bff4d}@supports (color:color-mix(in lab,red,red)){.hover\:bg-purple-500\/30:hover{background-color:color-mix(in oklab,var(--color-purple-500) 30%,transparent)}}.hover\:bg-recall\/30:hover{background-color:#10b9814d}@supports (color:color-mix(in lab,red,red)){.hover\:bg-recall\/30:hover{background-color:color-mix(in oklab,var(--color-recall) 30%,transparent)}}.hover\:bg-synapse\/30:hover{background-color:#6366f14d}@supports (color:color-mix(in lab,red,red)){.hover\:bg-synapse\/30:hover{background-color:color-mix(in oklab,var(--color-synapse) 30%,transparent)}}.hover\:bg-warning\/30:hover{background-color:#f59e0b4d}@supports (color:color-mix(in lab,red,red)){.hover\:bg-warning\/30:hover{background-color:color-mix(in oklab,var(--color-warning) 30%,transparent)}}.hover\:bg-white\/\[0\.02\]:hover{background-color:#ffffff05}@supports (color:color-mix(in lab,red,red)){.hover\:bg-white\/\[0\.02\]:hover{background-color:color-mix(in oklab,var(--color-white) 2%,transparent)}}.hover\:bg-white\/\[0\.03\]:hover{background-color:#ffffff08}@supports (color:color-mix(in lab,red,red)){.hover\:bg-white\/\[0\.03\]:hover{background-color:color-mix(in oklab,var(--color-white) 3%,transparent)}}.hover\:bg-white\/\[0\.04\]:hover{background-color:#ffffff0a}@supports (color:color-mix(in lab,red,red)){.hover\:bg-white\/\[0\.04\]:hover{background-color:color-mix(in oklab,var(--color-white) 4%,transparent)}}.hover\:bg-white\/\[0\.08\]:hover{background-color:#ffffff14}@supports (color:color-mix(in lab,red,red)){.hover\:bg-white\/\[0\.08\]:hover{background-color:color-mix(in oklab,var(--color-white) 8%,transparent)}}.hover\:text-bright:hover{color:var(--color-bright)}.hover\:text-dim:hover{color:var(--color-dim)}.hover\:text-synapse-glow:hover{color:var(--color-synapse-glow)}.hover\:text-text:hover{color:var(--color-text)}.hover\:underline:hover{text-decoration-line:underline}}.focus\:\!border-synapse\/40:focus{border-color:#6366f166!important}@supports (color:color-mix(in lab,red,red)){.focus\:\!border-synapse\/40:focus{border-color:color-mix(in oklab,var(--color-synapse) 40%,transparent)!important}}.focus\:border-dream\/40:focus{border-color:#a855f766}@supports (color:color-mix(in lab,red,red)){.focus\:border-dream\/40:focus{border-color:color-mix(in oklab,var(--color-dream) 40%,transparent)}}.focus\:border-synapse\/40:focus{border-color:#6366f166}@supports (color:color-mix(in lab,red,red)){.focus\:border-synapse\/40:focus{border-color:color-mix(in oklab,var(--color-synapse) 40%,transparent)}}.focus\:ring-1:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-synapse-glow:focus{--tw-ring-color:var(--color-synapse-glow)}.focus\:ring-synapse\/20:focus{--tw-ring-color:#6366f133}@supports (color:color-mix(in lab,red,red)){.focus\:ring-synapse\/20:focus{--tw-ring-color:color-mix(in oklab, var(--color-synapse) 20%, transparent)}}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-dream-glow\/60:focus-visible{--tw-ring-color:#c084fc99}@supports (color:color-mix(in lab,red,red)){.focus-visible\:ring-dream-glow\/60:focus-visible{--tw-ring-color:color-mix(in oklab, var(--color-dream-glow) 60%, transparent)}}.focus-visible\:ring-recall\/60:focus-visible{--tw-ring-color:#10b98199}@supports (color:color-mix(in lab,red,red)){.focus-visible\:ring-recall\/60:focus-visible{--tw-ring-color:color-mix(in oklab, var(--color-recall) 60%, transparent)}}.focus-visible\:ring-synapse\/60:focus-visible{--tw-ring-color:#6366f199}@supports (color:color-mix(in lab,red,red)){.focus-visible\:ring-synapse\/60:focus-visible{--tw-ring-color:color-mix(in oklab, var(--color-synapse) 60%, transparent)}}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}@media(min-width:40rem){.sm\:block{display:block}.sm\:inline-flex{display:inline-flex}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}}@media(min-width:48rem){.md\:block{display:block}.md\:flex{display:flex}.md\:hidden{display:none}.md\:inline-flex{display:inline-flex}.md\:min-w-\[340px\]{min-width:340px}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.md\:grid-cols-\[1fr_auto\]{grid-template-columns:1fr auto}.md\:grid-cols-\[280px_1fr\]{grid-template-columns:280px 1fr}.md\:flex-row{flex-direction:row}.md\:pt-\[15vh\]{padding-top:15vh}.md\:pb-0{padding-bottom:calc(var(--spacing) * 0)}}@media(min-width:64rem){.lg\:block{display:block}.lg\:w-56{width:calc(var(--spacing) * 56)}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-\[1fr_280px\]{grid-template-columns:1fr 280px}.lg\:grid-cols-\[1fr_340px\]{grid-template-columns:1fr 340px}.lg\:grid-cols-\[1fr_360px\]{grid-template-columns:1fr 360px}.lg\:grid-cols-\[minmax\(0\,1fr\)_340px\]{grid-template-columns:minmax(0,1fr) 340px}}.\[\&\:\:-webkit-slider-thumb\]\:h-3::-webkit-slider-thumb{height:calc(var(--spacing) * 3)}.\[\&\:\:-webkit-slider-thumb\]\:w-3::-webkit-slider-thumb{width:calc(var(--spacing) * 3)}.\[\&\:\:-webkit-slider-thumb\]\:appearance-none::-webkit-slider-thumb{-webkit-appearance:none;-moz-appearance:none;appearance:none}.\[\&\:\:-webkit-slider-thumb\]\:rounded-full::-webkit-slider-thumb{border-radius:3.40282e38px}.\[\&\:\:-webkit-slider-thumb\]\:bg-synapse-glow::-webkit-slider-thumb{background-color:var(--color-synapse-glow)}.\[\&\:\:-webkit-slider-thumb\]\:shadow-\[0_0_8px_rgba\(129\,140\,248\,0\.4\)\]::-webkit-slider-thumb{--tw-shadow:0 0 8px var(--tw-shadow-color,#818cf866);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}}html{background:var(--color-void);color:var(--color-text);font-family:var(--font-mono)}body{min-height:100vh;margin:0;overflow:hidden}::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-track{background:0 0}::-webkit-scrollbar-thumb{background:var(--color-subtle);border-radius:3px}::-webkit-scrollbar-thumb:hover{background:var(--color-muted)}.glass{-webkit-backdrop-filter:blur(20px)saturate(180%);background:#16163873;border:1px solid #6366f114;box-shadow:inset 0 1px #ffffff08,0 4px 24px #0000004d}.glass-subtle{-webkit-backdrop-filter:blur(12px)saturate(150%);background:#10102a66;border:1px solid #6366f10f;box-shadow:inset 0 1px #ffffff05,0 2px 12px #0003}.glass-sidebar{-webkit-backdrop-filter:blur(24px)saturate(180%);background:#0a0a1a99;border-right:1px solid #6366f11a;box-shadow:inset -1px 0 #ffffff05,4px 0 24px #0000004d}.glass-panel{-webkit-backdrop-filter:blur(24px)saturate(180%);background:#0a0a1acc;border:1px solid #6366f11a;box-shadow:inset 0 1px #ffffff08,0 8px 32px #0006}.glow-synapse{box-shadow:0 0 20px #6366f14d,0 0 60px #6366f11a}.glow-dream{box-shadow:0 0 20px #a855f74d,0 0 60px #a855f71a}.glow-memory{box-shadow:0 0 20px #3b82f64d,0 0 60px #3b82f61a}@keyframes pulse-glow{0%,to{opacity:1}50%{opacity:.5}}.animate-pulse-glow{animation:2s ease-in-out infinite pulse-glow}@keyframes orb-float-1{0%,to{transform:translate(0)scale(1)}25%{transform:translate(60px,-40px)scale(1.1)}50%{transform:translate(-30px,-80px)scale(.95)}75%{transform:translate(-60px,-20px)scale(1.05)}}@keyframes orb-float-2{0%,to{transform:translate(0)scale(1)}25%{transform:translate(-50px,30px)scale(1.08)}50%{transform:translate(40px,60px)scale(.92)}75%{transform:translate(20px,-40px)scale(1.03)}}@keyframes orb-float-3{0%,to{transform:translate(0)scale(1)}25%{transform:translate(30px,50px)scale(1.05)}50%{transform:translate(-60px,20px)scale(.98)}75%{transform:translate(40px,-30px)scale(1.1)}}.ambient-orb{filter:blur(80px);pointer-events:none;z-index:0;opacity:.35;border-radius:50%;position:fixed}.ambient-orb-1{background:radial-gradient(circle,#a855f766,#0000 70%);width:400px;height:400px;animation:20s ease-in-out infinite orb-float-1;top:-10%;right:-5%}.ambient-orb-2{background:radial-gradient(circle,#6366f159,#0000 70%);width:350px;height:350px;animation:25s ease-in-out infinite orb-float-2;bottom:-15%;left:-5%}.ambient-orb-3{background:radial-gradient(circle,#f59e0b33,#0000 70%);width:300px;height:300px;animation:22s ease-in-out infinite orb-float-3;top:40%;left:40%}.nav-active-border{position:relative}.nav-active-border:before{content:"";background:linear-gradient(180deg,var(--color-synapse),var(--color-dream),var(--color-synapse));background-size:100% 200%;border-radius:1px;width:2px;animation:3s ease-in-out infinite gradient-shift;position:absolute;top:4px;bottom:4px;left:0}@keyframes gradient-shift{0%,to{background-position:0 0}50%{background-position:0 100%}}@keyframes float{0%,to{transform:translateY(0)translate(0)}25%{transform:translateY(-10px)translate(5px)}50%{transform:translateY(-5px)translate(-5px)}75%{transform:translateY(-15px)translate(3px)}}.retention-critical{color:var(--color-decay)}.retention-low{color:var(--color-warning)}.retention-good{color:var(--color-recall)}.retention-strong{color:var(--color-synapse)}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-gradient-position{syntax:"*";inherits:false}@property --tw-gradient-from{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-via{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-to{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-stops{syntax:"*";inherits:false}@property --tw-gradient-via-stops{syntax:"*";inherits:false}@property --tw-gradient-from-position{syntax:"";inherits:false;initial-value:0%}@property --tw-gradient-via-position{syntax:"";inherits:false;initial-value:50%}@property --tw-gradient-to-position{syntax:"";inherits:false;initial-value:100%}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}}@keyframes ping{75%,to{opacity:0;transform:scale(2)}}@keyframes pulse{50%{opacity:.5}}.toast-layer.svelte-pry2ep{position:fixed;z-index:60;pointer-events:none;display:flex;flex-direction:column;gap:.5rem;right:1.25rem;bottom:1.25rem;max-width:22rem;width:calc(100vw - 2.5rem)}@media(max-width:768px){.toast-layer.svelte-pry2ep{right:.75rem;left:.75rem;bottom:auto;top:.75rem;max-width:none;width:auto;align-items:stretch}}.toast-item.svelte-pry2ep{pointer-events:auto;position:relative;display:flex;gap:.75rem;align-items:stretch;text-align:left;font:inherit;color:inherit;background:#0c0e16b8;backdrop-filter:blur(14px) saturate(160%);-webkit-backdrop-filter:blur(14px) saturate(160%);border:1px solid rgba(255,255,255,.06);border-radius:.75rem;padding:.75rem .9rem .75rem .5rem;overflow:hidden;box-shadow:0 10px 40px -12px #000c,0 0 22px -6px var(--toast-color);cursor:pointer;animation:svelte-pry2ep-toast-in .32s cubic-bezier(.16,1,.3,1);transform-origin:right center;transition:transform .15s ease,box-shadow .15s ease}.toast-item.svelte-pry2ep:hover{transform:translateY(-1px) scale(1.015);box-shadow:0 14px 48px -12px #000000d9,0 0 32px -4px var(--toast-color)}.toast-item.svelte-pry2ep:focus-visible{outline:1px solid var(--toast-color);outline-offset:2px}.toast-accent.svelte-pry2ep{width:3px;border-radius:2px;background:var(--toast-color);box-shadow:0 0 10px var(--toast-color);flex-shrink:0}.toast-body.svelte-pry2ep{display:flex;flex-direction:column;gap:.15rem;flex:1;min-width:0}.toast-head.svelte-pry2ep{display:flex;align-items:center;gap:.5rem}.toast-icon.svelte-pry2ep{color:var(--toast-color);font-size:.95rem;text-shadow:0 0 8px var(--toast-color);line-height:1;width:1rem;display:inline-flex;justify-content:center}.toast-title.svelte-pry2ep{color:#f5f5fa;font-size:.82rem;font-weight:600;letter-spacing:.01em}.toast-sub.svelte-pry2ep{color:#b0b6c4;font-size:.74rem;line-height:1.35;padding-left:1.5rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.toast-progress.svelte-pry2ep{position:absolute;left:0;bottom:0;height:2px;width:100%;background:#ffffff0a}.toast-progress-fill.svelte-pry2ep{height:100%;background:var(--toast-color);opacity:.55;transform-origin:left center;animation:svelte-pry2ep-toast-progress var(--toast-dwell) linear forwards}.toast-item.svelte-pry2ep:hover .toast-progress-fill:where(.svelte-pry2ep),.toast-item.svelte-pry2ep:focus-visible .toast-progress-fill:where(.svelte-pry2ep){animation-play-state:paused}@keyframes svelte-pry2ep-toast-in{0%{opacity:0;transform:translate(24px) scale(.98)}to{opacity:1;transform:translate(0) scale(1)}}@media(max-width:768px){.toast-item.svelte-pry2ep{transform-origin:top center;animation:svelte-pry2ep-toast-in-mobile .3s cubic-bezier(.16,1,.3,1)}}@keyframes svelte-pry2ep-toast-in-mobile{0%{opacity:0;transform:translateY(-12px) scale(.98)}to{opacity:1;transform:translateY(0) scale(1)}}@keyframes svelte-pry2ep-toast-progress{0%{transform:scaleX(1)}to{transform:scaleX(0)}}@media(prefers-reduced-motion:reduce){.toast-item.svelte-pry2ep{animation:none}.toast-progress-fill.svelte-pry2ep{animation:none;transform:scaleX(.5)}}.strip-item.svelte-1kk3799{display:inline-flex;align-items:center;gap:.4rem;padding:0 .75rem;white-space:nowrap;flex-shrink:0}.strip-divider.svelte-1kk3799{width:1px;height:14px;background:#6366f11f;flex-shrink:0}.ambient-strip.ambient-flash.svelte-1kk3799{background:linear-gradient(90deg,#ef444414,#ef444400 70%),#0006;border-bottom-color:#ef444459;transition:background .3s ease,border-color .3s ease}@keyframes svelte-1kk3799-ping-slow{0%{transform:scale(1);opacity:.8}80%,to{transform:scale(2);opacity:0}}.animate-ping-slow{animation:svelte-1kk3799-ping-slow 2.2s cubic-bezier(0,0,.2,1) infinite}@media(prefers-reduced-motion:reduce){.ambient-strip.svelte-1kk3799 .animate-ping,.ambient-strip.svelte-1kk3799 .animate-ping-slow,.ambient-strip.svelte-1kk3799 .animate-pulse{animation:none!important}}.theme-toggle.svelte-1cmi4dh{width:30px;height:30px;display:inline-flex;align-items:center;justify-content:center;padding:0;border-radius:8px;background:#6366f10f;border:1px solid rgba(99,102,241,.14);color:var(--color-text);cursor:pointer;transition:background .2s ease,border-color .2s ease,color .2s ease,transform .12s ease;-webkit-tap-highlight-color:transparent}.theme-toggle.svelte-1cmi4dh:hover{background:#6366f124;border-color:#6366f14d;color:var(--color-bright)}.theme-toggle.svelte-1cmi4dh:active{transform:scale(.94)}.theme-toggle.svelte-1cmi4dh:focus-visible{outline:1px solid var(--color-synapse);outline-offset:2px}.icon-wrap.svelte-1cmi4dh{position:relative;width:18px;height:18px;display:inline-block}.icon.svelte-1cmi4dh{position:absolute;top:0;right:0;bottom:0;left:0;width:18px;height:18px;opacity:0;transform:scale(.7) rotate(-30deg);transition:opacity .2s ease,transform .2s cubic-bezier(.16,1,.3,1);pointer-events:none}.icon.active.svelte-1cmi4dh{opacity:1;transform:scale(1) rotate(0)}.theme-toggle[data-mode=dark].svelte-1cmi4dh{color:var(--color-synapse-glow, #818cf8)}.theme-toggle[data-mode=light].svelte-1cmi4dh{color:var(--color-warning, #f59e0b)}.theme-toggle[data-mode=auto].svelte-1cmi4dh{color:var(--color-dream-glow, #c084fc)}@media(prefers-reduced-motion:reduce){.theme-toggle.svelte-1cmi4dh,.icon.svelte-1cmi4dh{transition:none}}.safe-bottom.svelte-12qhfyh{padding-bottom:env(safe-area-inset-bottom,0px)}@keyframes svelte-12qhfyh-page-in{0%{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}.animate-page-in.svelte-12qhfyh{animation:svelte-12qhfyh-page-in .2s ease-out} diff --git a/apps/dashboard/build/_app/immutable/assets/0.IIz8MMYb.css.br b/apps/dashboard/build/_app/immutable/assets/0.IIz8MMYb.css.br deleted file mode 100644 index 8739b09..0000000 Binary files a/apps/dashboard/build/_app/immutable/assets/0.IIz8MMYb.css.br and /dev/null differ diff --git a/apps/dashboard/build/_app/immutable/assets/0.IIz8MMYb.css.gz b/apps/dashboard/build/_app/immutable/assets/0.IIz8MMYb.css.gz deleted file mode 100644 index 66bc820..0000000 Binary files a/apps/dashboard/build/_app/immutable/assets/0.IIz8MMYb.css.gz and /dev/null differ diff --git a/apps/dashboard/build/_app/immutable/chunks/DNjM5a-l.js b/apps/dashboard/build/_app/immutable/chunks/B7CfdQuM.js similarity index 83% rename from apps/dashboard/build/_app/immutable/chunks/DNjM5a-l.js rename to apps/dashboard/build/_app/immutable/chunks/B7CfdQuM.js index 8ce57c7..f47a2c4 100644 --- a/apps/dashboard/build/_app/immutable/chunks/DNjM5a-l.js +++ b/apps/dashboard/build/_app/immutable/chunks/B7CfdQuM.js @@ -1 +1 @@ -const r="/api";async function t(e,o){const i=await fetch(`${r}${e}`,{headers:{"Content-Type":"application/json"},...o});if(!i.ok)throw new Error(`API ${i.status}: ${i.statusText}`);return i.json()}const n={memories:{list:e=>{const o=e?"?"+new URLSearchParams(e).toString():"";return t(`/memories${o}`)},get:e=>t(`/memories/${e}`),delete:e=>t(`/memories/${e}`,{method:"DELETE"}),promote:e=>t(`/memories/${e}/promote`,{method:"POST"}),demote:e=>t(`/memories/${e}/demote`,{method:"POST"}),suppress:(e,o)=>t(`/memories/${e}/suppress`,{method:"POST",body:o?JSON.stringify({reason:o}):void 0}),unsuppress:e=>t(`/memories/${e}/unsuppress`,{method:"POST"})},search:(e,o=20)=>t(`/search?q=${encodeURIComponent(e)}&limit=${o}`),stats:()=>t("/stats"),health:()=>t("/health"),timeline:(e=7,o=200)=>t(`/timeline?days=${e}&limit=${o}`),graph:e=>{const o=e?"?"+new URLSearchParams(Object.entries(e).filter(([,i])=>i!==void 0).map(([i,s])=>[i,String(s)])).toString():"";return t(`/graph${o}`)},dream:()=>t("/dream",{method:"POST"}),explore:(e,o="associations",i,s=10)=>t("/explore",{method:"POST",body:JSON.stringify({from_id:e,action:o,to_id:i,limit:s})}),predict:()=>t("/predict",{method:"POST"}),importance:e=>t("/importance",{method:"POST",body:JSON.stringify({content:e})}),consolidate:()=>t("/consolidate",{method:"POST"}),retentionDistribution:()=>t("/retention-distribution"),intentions:(e="active")=>t(`/intentions?status=${e}`),deepReference:(e,o=20)=>t("/deep_reference",{method:"POST",body:JSON.stringify({query:e,depth:o})})};export{n as a}; +const r="/api";async function t(e,o){const i=await fetch(`${r}${e}`,{headers:{"Content-Type":"application/json"},...o});if(!i.ok)throw new Error(`API ${i.status}: ${i.statusText}`);return i.json()}const n={memories:{list:e=>{const o=e?"?"+new URLSearchParams(e).toString():"";return t(`/memories${o}`)},get:e=>t(`/memories/${e}`),delete:e=>t(`/memories/${e}`,{method:"DELETE"}),promote:e=>t(`/memories/${e}/promote`,{method:"POST"}),demote:e=>t(`/memories/${e}/demote`,{method:"POST"}),suppress:(e,o)=>t(`/memories/${e}/suppress`,{method:"POST",body:o?JSON.stringify({reason:o}):void 0}),unsuppress:e=>t(`/memories/${e}/unsuppress`,{method:"POST"})},search:(e,o=20)=>t(`/search?q=${encodeURIComponent(e)}&limit=${o}`),stats:()=>t("/stats"),health:()=>t("/health"),timeline:(e=7,o=200)=>t(`/timeline?days=${e}&limit=${o}`),graph:e=>{const o=e?"?"+new URLSearchParams(Object.entries(e).filter(([,i])=>i!==void 0).map(([i,s])=>[i,String(s)])).toString():"";return t(`/graph${o}`)},dream:()=>t("/dream",{method:"POST"}),explore:(e,o="associations",i,s=10)=>t("/explore",{method:"POST",body:JSON.stringify({from_id:e,action:o,to_id:i,limit:s})}),predict:()=>t("/predict",{method:"POST"}),importance:e=>t("/importance",{method:"POST",body:JSON.stringify({content:e})}),consolidate:()=>t("/consolidate",{method:"POST"}),retentionDistribution:()=>t("/retention-distribution"),intentions:(e="active")=>t(`/intentions?status=${e}`),deepReference:(e,o=20)=>t("/deep_reference",{method:"POST",body:JSON.stringify({query:e,depth:o})}),sanhedrin:{latest:()=>t("/sanhedrin/latest"),telemetry:(e=7)=>t(`/sanhedrin/telemetry?days=${e}`),appeal:(e,o,i,s)=>t("/sanhedrin/appeal",{method:"POST",body:JSON.stringify({reason:e,note:o,claimId:i,receiptId:s})})}};export{n as a}; diff --git a/apps/dashboard/build/_app/immutable/chunks/B7CfdQuM.js.br b/apps/dashboard/build/_app/immutable/chunks/B7CfdQuM.js.br new file mode 100644 index 0000000..d938741 Binary files /dev/null and b/apps/dashboard/build/_app/immutable/chunks/B7CfdQuM.js.br differ diff --git a/apps/dashboard/build/_app/immutable/chunks/B7CfdQuM.js.gz b/apps/dashboard/build/_app/immutable/chunks/B7CfdQuM.js.gz new file mode 100644 index 0000000..fc0e356 Binary files /dev/null and b/apps/dashboard/build/_app/immutable/chunks/B7CfdQuM.js.gz differ diff --git a/apps/dashboard/build/_app/immutable/chunks/BHGLDPij.js.br b/apps/dashboard/build/_app/immutable/chunks/BHGLDPij.js.br deleted file mode 100644 index fcf3184..0000000 Binary files a/apps/dashboard/build/_app/immutable/chunks/BHGLDPij.js.br and /dev/null differ diff --git a/apps/dashboard/build/_app/immutable/chunks/BHGLDPij.js.gz b/apps/dashboard/build/_app/immutable/chunks/BHGLDPij.js.gz deleted file mode 100644 index 150db2f..0000000 Binary files a/apps/dashboard/build/_app/immutable/chunks/BHGLDPij.js.gz and /dev/null differ diff --git a/apps/dashboard/build/_app/immutable/chunks/BjdL4Pm2.js b/apps/dashboard/build/_app/immutable/chunks/BjdL4Pm2.js deleted file mode 100644 index 809f3b1..0000000 --- a/apps/dashboard/build/_app/immutable/chunks/BjdL4Pm2.js +++ /dev/null @@ -1 +0,0 @@ -import{w as S,g as T}from"./BeMFXnHE.js";import{e as R}from"./MAY1QfFZ.js";import{E as u}from"./DzfRjky4.js";const M=4,x=1500;function F(){const{subscribe:y,update:i}=S([]);let m=1,b=0;const d=new Map,a=new Map,l=new Map;function f(e,o){l.set(e,Date.now());const t=setTimeout(()=>{d.delete(e),l.delete(e),g(e)},o);d.set(e,t)}function w(e){const o=m++,t=Date.now(),s={id:o,createdAt:t,...e};i(n=>{const r=[s,...n];return r.length>M?r.slice(0,M):r}),f(o,e.dwellMs)}function g(e){const o=d.get(e);o&&(clearTimeout(o),d.delete(e)),a.delete(e),l.delete(e),i(t=>t.filter(s=>s.id!==e))}function C(e,o){const t=d.get(e);if(!t)return;clearTimeout(t),d.delete(e);const s=l.get(e)??Date.now(),n=Date.now()-s,r=Math.max(200,o-n);a.set(e,{remaining:r})}function D(e){const o=a.get(e);o&&(a.delete(e),f(e,o.remaining))}function N(){for(const e of d.values())clearTimeout(e);d.clear(),a.clear(),l.clear(),i(()=>[])}function _(e){const o=u[e.type]??"#818CF8",t=e.data;switch(e.type){case"DreamCompleted":{const s=Number(t.memories_replayed??0),n=Number(t.connections_found??0),r=Number(t.insights_generated??0),p=Number(t.duration_ms??0),c=[];return c.push(`Replayed ${s} ${s===1?"memory":"memories"}`),n>0&&c.push(`${n} new connection${n===1?"":"s"}`),r>0&&c.push(`${r} insight${r===1?"":"s"}`),{type:e.type,title:"Dream consolidated",body:`${c.join(" · ")} in ${(p/1e3).toFixed(1)}s`,color:o,dwellMs:7e3}}case"ConsolidationCompleted":{const s=Number(t.nodes_processed??0),n=Number(t.decay_applied??0),r=Number(t.embeddings_generated??0),p=Number(t.duration_ms??0),c=[];return n>0&&c.push(`${n} decayed`),r>0&&c.push(`${r} embedded`),{type:e.type,title:"Consolidation swept",body:`${s} node${s===1?"":"s"}${c.length?" · "+c.join(" · "):""} in ${(p/1e3).toFixed(1)}s`,color:o,dwellMs:6e3}}case"ConnectionDiscovered":{const s=Date.now();if(s-b0?`suppression #${s} · Rac1 cascade ~${n} neighbors`:`suppression #${s}`,color:o,dwellMs:5500}}case"MemoryUnsuppressed":{const s=Number(t.remaining_count??0);return{type:e.type,title:"Recovered",body:s>0?`${s} suppression${s===1?"":"s"} remain`:"fully unsuppressed",color:o,dwellMs:5e3}}case"Rac1CascadeSwept":{const s=Number(t.seeds??0),n=Number(t.neighbors_affected??0);return{type:e.type,title:"Rac1 cascade",body:`${s} seed${s===1?"":"s"} · ${n} dendritic spine${n===1?"":"s"} pruned`,color:o,dwellMs:6e3}}case"MemoryDeleted":return{type:e.type,title:"Memory deleted",body:String(t.id??"").slice(0,8),color:o,dwellMs:4e3};case"Heartbeat":case"SearchPerformed":case"RetentionDecayed":case"ActivationSpread":case"ImportanceScored":case"MemoryCreated":case"MemoryUpdated":case"DreamStarted":case"DreamProgress":case"ConsolidationStarted":case"Connected":return null;default:return null}}let h=null;return R.subscribe(e=>{if(e.length===0)return;const o=[];for(const t of e){if(t===h)break;o.push(t)}if(o.length!==0){h=e[0];for(let t=o.length-1;t>=0;t--){const s=_(o[t]);s&&w(s)}}}),{subscribe:y,dismiss:g,clear:N,pauseDwell:C,resumeDwell:D,push:w}}const $=F();function O(){[{type:"DreamCompleted",title:"Dream consolidated",body:"Replayed 127 memories · 43 new connections · 5 insights in 2.4s",color:u.DreamCompleted,dwellMs:7e3},{type:"ConnectionDiscovered",title:"Bridge discovered",body:"semantic · weight 0.87",color:u.ConnectionDiscovered,dwellMs:4500},{type:"MemorySuppressed",title:"Forgetting",body:"suppression #2 · Rac1 cascade ~8 neighbors",color:u.MemorySuppressed,dwellMs:5500},{type:"ConsolidationCompleted",title:"Consolidation swept",body:"892 nodes · 156 decayed · 48 embedded in 1.1s",color:u.ConsolidationCompleted,dwellMs:6e3}].forEach((i,m)=>{setTimeout(()=>{$.push(i)},m*800)}),T($)}export{O as f,$ as t}; diff --git a/apps/dashboard/build/_app/immutable/chunks/BjdL4Pm2.js.br b/apps/dashboard/build/_app/immutable/chunks/BjdL4Pm2.js.br deleted file mode 100644 index 30bed56..0000000 Binary files a/apps/dashboard/build/_app/immutable/chunks/BjdL4Pm2.js.br and /dev/null differ diff --git a/apps/dashboard/build/_app/immutable/chunks/BjdL4Pm2.js.gz b/apps/dashboard/build/_app/immutable/chunks/BjdL4Pm2.js.gz deleted file mode 100644 index 78fece5..0000000 Binary files a/apps/dashboard/build/_app/immutable/chunks/BjdL4Pm2.js.gz and /dev/null differ diff --git a/apps/dashboard/build/_app/immutable/chunks/BskPcZf7.js b/apps/dashboard/build/_app/immutable/chunks/BskPcZf7.js deleted file mode 100644 index 26597bd..0000000 --- a/apps/dashboard/build/_app/immutable/chunks/BskPcZf7.js +++ /dev/null @@ -1 +0,0 @@ -var x=t=>{throw TypeError(t)};var B=(t,e,n)=>e.has(t)||x("Cannot "+n);var a=(t,e,n)=>(B(t,e,"read from private field"),n?n.call(t):e.get(t)),c=(t,e,n)=>e.has(t)?x("Cannot add the same private member more than once"):e instanceof WeakSet?e.add(t):e.set(t,n);import{o as I}from"./GG5zm9kr.js";import{s as u,g as f,h as d}from"./CpWkWWOo.js";import{w as G}from"./BeMFXnHE.js";new URL("sveltekit-internal://");function ae(t,e){return t==="/"||e==="ignore"?t:e==="never"?t.endsWith("/")?t.slice(0,-1):t:e==="always"&&!t.endsWith("/")?t+"/":t}function oe(t){return t.split("%25").map(decodeURI).join("%25")}function ie(t){for(const e in t)t[e]=decodeURIComponent(t[e]);return t}function le({href:t}){return t.split("#")[0]}function W(...t){let e=5381;for(const n of t)if(typeof n=="string"){let r=n.length;for(;r;)e=e*33^n.charCodeAt(--r)}else if(ArrayBuffer.isView(n)){const r=new Uint8Array(n.buffer,n.byteOffset,n.byteLength);let s=r.length;for(;s;)e=e*33^r[--s]}else throw new TypeError("value must be a string or TypedArray");return(e>>>0).toString(36)}new TextEncoder;new TextDecoder;function X(t){const e=atob(t),n=new Uint8Array(e.length);for(let r=0;r((t instanceof Request?t.method:(e==null?void 0:e.method)||"GET")!=="GET"&&b.delete(U(t)),z(t,e));const b=new Map;function ce(t,e){const n=U(t,e),r=document.querySelector(n);if(r!=null&&r.textContent){r.remove();let{body:s,...l}=JSON.parse(r.textContent);const o=r.getAttribute("data-ttl");return o&&b.set(n,{body:s,init:l,ttl:1e3*Number(o)}),r.getAttribute("data-b64")!==null&&(s=X(s)),Promise.resolve(new Response(s,l))}return window.fetch(t,e)}function ue(t,e,n){if(b.size>0){const r=U(t,n),s=b.get(r);if(s){if(performance.now()o)}function s(o){n=!1,e.set(o)}function l(o){let i;return e.subscribe(h=>{(i===void 0||n&&h!==i)&&o(i=h)})}return{notify:r,set:s,subscribe:l}}const D={v:()=>{}};function Ae(){const{set:t,subscribe:e}=G(!1);let n;async function r(){clearTimeout(n);try{const s=await fetch(`${M}/_app/version.json`,{headers:{pragma:"no-cache","cache-control":"no-cache"}});if(!s.ok)return!1;const o=(await s.json()).version!==F;return o&&(t(!0),D.v(),clearTimeout(n)),o}catch{return!1}}return{subscribe:e,check:r}}function Q(t,e,n){return t.origin!==Y||!t.pathname.startsWith(e)?!0:n?t.pathname!==location.pathname:!1}function Re(t){}const H=new Set(["load","prerender","csr","ssr","trailingSlash","config"]);[...H];const Z=new Set([...H]);[...Z];let E,O,T;const ee=I.toString().includes("$$")||/function \w+\(\) \{\}/.test(I.toString());var _,m,w,p,v,y,k,A,P,R,V,S,j;ee?(E={data:{},form:null,error:null,params:{},route:{id:null},state:{},status:-1,url:new URL("https://example.com")},O={current:null},T={current:!1}):(E=new(P=class{constructor(){c(this,_,u({}));c(this,m,u(null));c(this,w,u(null));c(this,p,u({}));c(this,v,u({id:null}));c(this,y,u({}));c(this,k,u(-1));c(this,A,u(new URL("https://example.com")))}get data(){return f(a(this,_))}set data(e){d(a(this,_),e)}get form(){return f(a(this,m))}set form(e){d(a(this,m),e)}get error(){return f(a(this,w))}set error(e){d(a(this,w),e)}get params(){return f(a(this,p))}set params(e){d(a(this,p),e)}get route(){return f(a(this,v))}set route(e){d(a(this,v),e)}get state(){return f(a(this,y))}set state(e){d(a(this,y),e)}get status(){return f(a(this,k))}set status(e){d(a(this,k),e)}get url(){return f(a(this,A))}set url(e){d(a(this,A),e)}},_=new WeakMap,m=new WeakMap,w=new WeakMap,p=new WeakMap,v=new WeakMap,y=new WeakMap,k=new WeakMap,A=new WeakMap,P),O=new(V=class{constructor(){c(this,R,u(null))}get current(){return f(a(this,R))}set current(e){d(a(this,R),e)}},R=new WeakMap,V),T=new(j=class{constructor(){c(this,S,u(!1))}get current(){return f(a(this,S))}set current(e){d(a(this,S),e)}},S=new WeakMap,j),D.v=()=>T.current=!0);function Ue(t){Object.assign(E,t)}export{be as H,_e as N,ge as P,he as S,ye as a,J as b,Ae as c,le as d,ie as e,pe as f,ve as g,ae as h,Q as i,N as j,oe as k,fe as l,ue as m,O as n,Y as o,E as p,ce as q,me as r,we as s,de as t,ke as u,Ue as v,Re as w}; diff --git a/apps/dashboard/build/_app/immutable/chunks/BskPcZf7.js.br b/apps/dashboard/build/_app/immutable/chunks/BskPcZf7.js.br deleted file mode 100644 index c787776..0000000 Binary files a/apps/dashboard/build/_app/immutable/chunks/BskPcZf7.js.br and /dev/null differ diff --git a/apps/dashboard/build/_app/immutable/chunks/BskPcZf7.js.gz b/apps/dashboard/build/_app/immutable/chunks/BskPcZf7.js.gz deleted file mode 100644 index cd10020..0000000 Binary files a/apps/dashboard/build/_app/immutable/chunks/BskPcZf7.js.gz and /dev/null differ diff --git a/apps/dashboard/build/_app/immutable/chunks/BHGLDPij.js b/apps/dashboard/build/_app/immutable/chunks/C2TQQEIa.js similarity index 55% rename from apps/dashboard/build/_app/immutable/chunks/BHGLDPij.js rename to apps/dashboard/build/_app/immutable/chunks/C2TQQEIa.js index 2ce4954..d683bb6 100644 --- a/apps/dashboard/build/_app/immutable/chunks/BHGLDPij.js +++ b/apps/dashboard/build/_app/immutable/chunks/C2TQQEIa.js @@ -1 +1 @@ -import{$ as J,bd as ee}from"./CpWkWWOo.js";import{w as ae}from"./BeMFXnHE.js";import{c as ne,H as N,N as B,r as gt,i as _t,b as L,s as C,p as x,n as ft,f as $t,g as ut,a as X,d as it,S as Nt,P as re,e as oe,h as se,o as Dt,j as q,k as ie,l as qt,m as ce,q as le,t as Kt,u as Pt,v as fe}from"./BskPcZf7.js";class wt{constructor(a,e){this.status=a,typeof e=="string"?this.body={message:e}:e?this.body=e:this.body={message:`Error: ${a}`}}toString(){return JSON.stringify(this.body)}}class vt{constructor(a,e){this.status=a,this.location=e}}class yt extends Error{constructor(a,e,r){super(r),this.status=a,this.text=e}}const ue=/^(\[)?(\.\.\.)?(\w+)(?:=(\w+))?(\])?$/;function he(t){const a=[];return{pattern:t==="/"?/^\/$/:new RegExp(`^${pe(t).map(r=>{const n=/^\[\.\.\.(\w+)(?:=(\w+))?\]$/.exec(r);if(n)return a.push({name:n[1],matcher:n[2],optional:!1,rest:!0,chained:!0}),"(?:/([^]*))?";const o=/^\[\[(\w+)(?:=(\w+))?\]\]$/.exec(r);if(o)return a.push({name:o[1],matcher:o[2],optional:!0,rest:!1,chained:!0}),"(?:/([^/]+))?";if(!r)return;const s=r.split(/\[(.+?)\](?!\])/);return"/"+s.map((c,l)=>{if(l%2){if(c.startsWith("x+"))return ct(String.fromCharCode(parseInt(c.slice(2),16)));if(c.startsWith("u+"))return ct(String.fromCharCode(...c.slice(2).split("-").map(_=>parseInt(_,16))));const h=ue.exec(c),[,u,w,f,d]=h;return a.push({name:f,matcher:d,optional:!!u,rest:!!w,chained:w?l===1&&s[0]==="":!1}),w?"([^]*?)":u?"([^/]*)?":"([^/]+?)"}return ct(c)}).join("")}).join("")}/?$`),params:a}}function de(t){return t!==""&&!/^\([^)]+\)$/.test(t)}function pe(t){return t.slice(1).split("/").filter(de)}function me(t,a,e){const r={},n=t.slice(1),o=n.filter(i=>i!==void 0);let s=0;for(let i=0;ih).join("/"),s=0),l===void 0)if(c.rest)l="";else continue;if(!c.matcher||e[c.matcher](l)){r[c.name]=l;const h=a[i+1],u=n[i+1];h&&!h.rest&&h.optional&&u&&c.chained&&(s=0),!h&&!u&&Object.keys(r).length===o.length&&(s=0);continue}if(c.optional&&c.chained){s++;continue}return}if(!s)return r}function ct(t){return t.normalize().replace(/[[\]]/g,"\\$&").replace(/%/g,"%25").replace(/\//g,"%2[Ff]").replace(/\?/g,"%3[Ff]").replace(/#/g,"%23").replace(/[.*+?^${}()|\\]/g,"\\$&")}function ge({nodes:t,server_loads:a,dictionary:e,matchers:r}){const n=new Set(a);return Object.entries(e).map(([i,[c,l,h]])=>{const{pattern:u,params:w}=he(i),f={id:i,exec:d=>{const _=u.exec(d);if(_)return me(_,w,r)},errors:[1,...h||[]].map(d=>t[d]),layouts:[0,...l||[]].map(s),leaf:o(c)};return f.errors.length=f.layouts.length=Math.max(f.errors.length,f.layouts.length),f});function o(i){const c=i<0;return c&&(i=~i),[c,t[i]]}function s(i){return i===void 0?i:[n.has(i),t[i]]}}function Ft(t,a=JSON.parse){try{return a(sessionStorage[t])}catch{}}function It(t,a,e=JSON.stringify){const r=e(a);try{sessionStorage[t]=r}catch{}}function _e(t){return t.filter(a=>a!=null)}function bt(t){return t instanceof wt||t instanceof yt?t.status:500}function we(t){return t instanceof yt?t.text:"Internal Error"}const ve=new Set(["icon","shortcut icon","apple-touch-icon"]),I=Ft(Kt)??{},M=Ft(qt)??{},P={url:Pt({}),page:Pt({}),navigating:ae(null),updated:ne()};function Et(t){I[t]=C()}function ye(t,a){let e=t+1;for(;I[e];)delete I[e],e+=1;for(e=a+1;M[e];)delete M[e],e+=1}function V(t,a=!1){return a?location.replace(t.href):location.href=t.href,new Promise(()=>{})}async function Bt(){if("serviceWorker"in navigator){const t=await navigator.serviceWorker.getRegistration(L||"/");t&&await t.update()}}function Tt(){}let kt,ht,Q,U,dt,E;const Z=[],tt=[];let v=null;function pt(){var t;(t=v==null?void 0:v.fork)==null||t.then(a=>a==null?void 0:a.discard()),v=null}const G=new Map,Mt=new Set,be=new Set,F=new Set;let g={branch:[],error:null,url:null},Vt=!1,et=!1,Ot=!0,H=!1,K=!1,Ht=!1,St=!1,Yt,b,R,O;const at=new Set,Ct=new Map;async function Fe(t,a,e){var o,s,i,c,l;(o=globalThis.__sveltekit_10kbxme)!=null&&o.data&&globalThis.__sveltekit_10kbxme.data,document.URL!==location.href&&(location.href=location.href),E=t,await((i=(s=t.hooks).init)==null?void 0:i.call(s)),kt=ge(t),U=document.documentElement,dt=a,ht=t.nodes[0],Q=t.nodes[1],ht(),Q(),b=(c=history.state)==null?void 0:c[N],R=(l=history.state)==null?void 0:l[B],b||(b=R=Date.now(),history.replaceState({...history.state,[N]:b,[B]:R},""));const r=I[b];function n(){r&&(history.scrollRestoration="manual",scrollTo(r.x,r.y))}e?(n(),await Ce(dt,e)):(await D({type:"enter",url:gt(E.hash?Ne(new URL(location.href)):location.href),replace_state:!0}),n()),Oe()}function Ee(){Z.length=0,St=!1}function zt(t){tt.some(a=>a==null?void 0:a.snapshot)&&(M[t]=tt.map(a=>{var e;return(e=a==null?void 0:a.snapshot)==null?void 0:e.capture()}))}function Wt(t){var a;(a=M[t])==null||a.forEach((e,r)=>{var n,o;(o=(n=tt[r])==null?void 0:n.snapshot)==null||o.restore(e)})}function jt(){Et(b),It(Kt,I),zt(R),It(qt,M)}async function Gt(t,a,e,r){let n;a.invalidateAll&&pt(),await D({type:"goto",url:gt(t),keepfocus:a.keepFocus,noscroll:a.noScroll,replace_state:a.replaceState,state:a.state,redirect_count:e,nav_token:r,accept:()=>{a.invalidateAll&&(St=!0,n=[...Ct.keys()]),a.invalidate&&a.invalidate.forEach(Te)}}),a.invalidateAll&&J().then(J).then(()=>{Ct.forEach(({resource:o},s)=>{var i;n!=null&&n.includes(s)&&((i=o.refresh)==null||i.call(o))})})}async function ke(t){if(t.id!==(v==null?void 0:v.id)){pt();const a={};at.add(a),v={id:t.id,token:a,promise:Xt({...t,preload:a}).then(e=>(at.delete(a),e.type==="loaded"&&e.state.error&&pt(),e)),fork:null}}return v.promise}async function lt(t){var e;const a=(e=await ot(t,!1))==null?void 0:e.route;a&&await Promise.all([...a.layouts,a.leaf].filter(Boolean).map(r=>r[1]()))}async function Jt(t,a,e){var n;g=t.state;const r=document.querySelector("style[data-sveltekit]");if(r&&r.remove(),Object.assign(x,t.props.page),Yt=new E.root({target:a,props:{...t.props,stores:P,components:tt},hydrate:e,sync:!1}),await Promise.resolve(),Wt(R),e){const o={from:null,to:{params:g.params,route:{id:((n=g.route)==null?void 0:n.id)??null},url:new URL(location.href),scroll:I[b]??C()},willUnload:!1,type:"enter",complete:Promise.resolve()};F.forEach(s=>s(o))}et=!0}function nt({url:t,params:a,branch:e,status:r,error:n,route:o,form:s}){let i="never";if(L&&(t.pathname===L||t.pathname===L+"/"))i="always";else for(const f of e)(f==null?void 0:f.slash)!==void 0&&(i=f.slash);t.pathname=se(t.pathname,i),t.search=t.search;const c={type:"loaded",state:{url:t,params:a,branch:e,error:n,route:o},props:{constructors:_e(e).map(f=>f.node.component),page:At(x)}};s!==void 0&&(c.props.form=s);let l={},h=!x,u=0;for(let f=0;fi(new URL(s))))return!0;return!1}function xt(t,a){return(t==null?void 0:t.type)==="data"?t:(t==null?void 0:t.type)==="skip"?a??null:null}function xe(t,a){if(!t)return new Set(a.searchParams.keys());const e=new Set([...t.searchParams.keys(),...a.searchParams.keys()]);for(const r of e){const n=t.searchParams.getAll(r),o=a.searchParams.getAll(r);n.every(s=>o.includes(s))&&o.every(s=>n.includes(s))&&e.delete(r)}return e}function Le({error:t,url:a,route:e,params:r}){return{type:"loaded",state:{error:t,url:a,route:e,params:r,branch:[]},props:{page:At(x),constructors:[]}}}async function Xt({id:t,invalidating:a,url:e,params:r,route:n,preload:o}){if((v==null?void 0:v.id)===t)return at.delete(v.token),v.promise;const{errors:s,layouts:i,leaf:c}=n,l=[...i,c];s.forEach(m=>m==null?void 0:m().catch(()=>{})),l.forEach(m=>m==null?void 0:m[1]().catch(()=>{}));const h=g.url?t!==rt(g.url):!1,u=g.route?n.id!==g.route.id:!1,w=xe(g.url,e);let f=!1;const d=l.map(async(m,p)=>{var A;if(!m)return;const y=g.branch[p];return m[1]===(y==null?void 0:y.loader)&&!Re(f,u,h,w,(A=y.universal)==null?void 0:A.uses,r)?y:(f=!0,Rt({loader:m[1],url:e,params:r,route:n,parent:async()=>{var z;const T={};for(let j=0;j{});const _=[];for(let m=0;mPromise.resolve({}),server_data_node:xt(o)}),i={node:await Q(),loader:Q,universal:null,server:null,data:null};return nt({url:e,params:n,branch:[s,i],status:t,error:a,route:null})}catch(s){if(s instanceof vt)return Gt(new URL(s.location,location.href),{},0);throw s}}async function Ae(t){const a=t.href;if(G.has(a))return G.get(a);let e;try{const r=(async()=>{let n=await E.hooks.reroute({url:new URL(t),fetch:async(o,s)=>Se(o,s,t).promise})??t;if(typeof n=="string"){const o=new URL(t);E.hash?o.hash=n:o.pathname=n,n=o}return n})();G.set(a,r),e=await r}catch{G.delete(a);return}return e}async function ot(t,a){if(t&&!_t(t,L,E.hash)){const e=await Ae(t);if(!e)return;const r=Pe(e);for(const n of kt){const o=n.exec(r);if(o)return{id:rt(t),invalidating:a,route:n,params:oe(o),url:t}}}}function Pe(t){return ie(E.hash?t.hash.replace(/^#/,"").replace(/[?#].+/,""):t.pathname.slice(L.length))||"/"}function rt(t){return(E.hash?t.hash.replace(/^#/,""):t.pathname)+t.search}function Qt({url:t,type:a,intent:e,delta:r,event:n,scroll:o}){let s=!1;const i=Ut(g,e,t,a,o??null);r!==void 0&&(i.navigation.delta=r),n!==void 0&&(i.navigation.event=n);const c={...i.navigation,cancel:()=>{s=!0,i.reject(new Error("navigation cancelled"))}};return H||Mt.forEach(l=>l(c)),s?null:i}async function D({type:t,url:a,popped:e,keepfocus:r,noscroll:n,replace_state:o,state:s={},redirect_count:i=0,nav_token:c={},accept:l=Tt,block:h=Tt,event:u}){var j;const w=O;O=c;const f=await ot(a,!1),d=t==="enter"?Ut(g,f,a,t):Qt({url:a,type:t,delta:e==null?void 0:e.delta,intent:f,scroll:e==null?void 0:e.scroll,event:u});if(!d){h(),O===c&&(O=w);return}const _=b,m=R;l(),H=!0,et&&d.navigation.type!=="enter"&&P.navigating.set(ft.current=d.navigation);let p=f&&await Xt(f);if(!p){if(_t(a,L,E.hash))return await V(a,o);p=await Zt(a,{id:null},await Y(new yt(404,"Not Found",`Not found: ${a.pathname}`),{url:a,params:{},route:{id:null}}),404,o)}if(a=(f==null?void 0:f.url)||a,O!==c)return d.reject(new Error("navigation aborted")),!1;if(p.type==="redirect"){if(i<20){await D({type:t,url:new URL(p.location,a),popped:e,keepfocus:r,noscroll:n,replace_state:o,state:s,redirect_count:i+1,nav_token:c}),d.fulfil(void 0);return}p=await Lt({status:500,error:await Y(new Error("Redirect loop"),{url:a,params:{},route:{id:null}}),url:a,route:{id:null}})}else p.props.page.status>=400&&await P.updated.check()&&(await Bt(),await V(a,o));if(Ee(),Et(_),zt(m),p.props.page.url.pathname!==a.pathname&&(a.pathname=p.props.page.url.pathname),s=e?e.state:s,!e){const k=o?0:1,W={[N]:b+=k,[B]:R+=k,[Nt]:s};(o?history.replaceState:history.pushState).call(history,W,"",a),o||ye(b,R)}const y=f&&(v==null?void 0:v.id)===f.id?v.fork:null;v=null,p.props.page.state=s;let S;if(et){const k=(await Promise.all(Array.from(be,$=>$(d.navigation)))).filter($=>typeof $=="function");if(k.length>0){let $=function(){k.forEach(st=>{F.delete(st)})};k.push($),k.forEach(st=>{F.add(st)})}g=p.state,p.props.page&&(p.props.page.url=a);const W=y&&await y;W?S=W.commit():(Yt.$set(p.props),fe(p.props.page),S=(j=ee)==null?void 0:j()),Ht=!0}else await Jt(p,dt,!1);const{activeElement:A}=document;await S,await J(),await J();let T=null;if(Ot){const k=e?e.scroll:n?C():null;k?scrollTo(k.x,k.y):(T=a.hash&&document.getElementById(te(a)))?T.scrollIntoView():scrollTo(0,0)}const z=document.activeElement!==A&&document.activeElement!==document.body;!r&&!z&&$e(a,!T),Ot=!0,p.props.page&&Object.assign(x,p.props.page),H=!1,t==="popstate"&&Wt(R),d.fulfil(void 0),d.navigation.to&&(d.navigation.to.scroll=C()),F.forEach(k=>k(d.navigation)),P.navigating.set(ft.current=null)}async function Zt(t,a,e,r,n){return t.origin===Dt&&t.pathname===location.pathname&&!Vt?await Lt({status:r,error:e,url:t,route:a}):await V(t,n)}function Ie(){let t,a={element:void 0,href:void 0},e;U.addEventListener("mousemove",i=>{const c=i.target;clearTimeout(t),t=setTimeout(()=>{o(c,q.hover)},20)});function r(i){i.defaultPrevented||o(i.composedPath()[0],q.tap)}U.addEventListener("mousedown",r),U.addEventListener("touchstart",r,{passive:!0});const n=new IntersectionObserver(i=>{for(const c of i)c.isIntersecting&&(lt(new URL(c.target.href)),n.unobserve(c.target))},{threshold:0});async function o(i,c){const l=$t(i,U),h=l===a.element&&(l==null?void 0:l.href)===a.href&&c>=e;if(!l||h)return;const{url:u,external:w,download:f}=ut(l,L,E.hash);if(w||f)return;const d=X(l),_=u&&rt(g.url)===rt(u);if(!(d.reload||_))if(c<=d.preload_data){a={element:l,href:l.href},e=q.tap;const m=await ot(u,!1);if(!m)return;ke(m)}else c<=d.preload_code&&(a={element:l,href:l.href},e=c,lt(u))}function s(){n.disconnect();for(const i of U.querySelectorAll("a")){const{url:c,external:l,download:h}=ut(i,L,E.hash);if(l||h)continue;const u=X(i);u.reload||(u.preload_code===q.viewport&&n.observe(i),u.preload_code===q.eager&<(c))}}F.add(s),s()}function Y(t,a){if(t instanceof wt)return t.body;const e=bt(t),r=we(t);return E.hooks.handleError({error:t,event:a,status:e,message:r})??{message:r}}function Be(t,a={}){return t=new URL(gt(t)),t.origin!==Dt?Promise.reject(new Error("goto: invalid URL")):Gt(t,a,0)}function Te(t){if(typeof t=="function")Z.push(t);else{const{href:a}=new URL(t,location.href);Z.push(e=>e.href===a)}}function Oe(){var a;history.scrollRestoration="manual",addEventListener("beforeunload",e=>{let r=!1;if(jt(),!H){const n=Ut(g,void 0,null,"leave"),o={...n.navigation,cancel:()=>{r=!0,n.reject(new Error("navigation cancelled"))}};Mt.forEach(s=>s(o))}r?(e.preventDefault(),e.returnValue=""):history.scrollRestoration="auto"}),addEventListener("visibilitychange",()=>{document.visibilityState==="hidden"&&jt()}),(a=navigator.connection)!=null&&a.saveData||Ie(),U.addEventListener("click",async e=>{if(e.button||e.which!==1||e.metaKey||e.ctrlKey||e.shiftKey||e.altKey||e.defaultPrevented)return;const r=$t(e.composedPath()[0],U);if(!r)return;const{url:n,external:o,target:s,download:i}=ut(r,L,E.hash);if(!n)return;if(s==="_parent"||s==="_top"){if(window.parent!==window)return}else if(s&&s!=="_self")return;const c=X(r);if(!(r instanceof SVGAElement)&&n.protocol!==location.protocol&&!(n.protocol==="https:"||n.protocol==="http:")||i)return;const[h,u]=(E.hash?n.hash.replace(/^#/,""):n.href).split("#"),w=h===it(location);if(o||c.reload&&(!w||!u)){Qt({url:n,type:"link",event:e})?H=!0:e.preventDefault();return}if(u!==void 0&&w){const[,f]=g.url.href.split("#");if(f===u){if(e.preventDefault(),u===""||u==="top"&&r.ownerDocument.getElementById("top")===null)scrollTo({top:0});else{const d=r.ownerDocument.getElementById(decodeURIComponent(u));d&&(d.scrollIntoView(),d.focus())}return}if(K=!0,Et(b),t(n),!c.replace_state)return;K=!1}e.preventDefault(),await new Promise(f=>{requestAnimationFrame(()=>{setTimeout(f,0)}),setTimeout(f,100)}),await D({type:"link",url:n,keepfocus:c.keepfocus,noscroll:c.noscroll,replace_state:c.replace_state??n.href===location.href,event:e})}),U.addEventListener("submit",e=>{if(e.defaultPrevented)return;const r=HTMLFormElement.prototype.cloneNode.call(e.target),n=e.submitter;if(((n==null?void 0:n.formTarget)||r.target)==="_blank"||((n==null?void 0:n.formMethod)||r.method)!=="get")return;const i=new URL((n==null?void 0:n.hasAttribute("formaction"))&&(n==null?void 0:n.formAction)||r.action);if(_t(i,L,!1))return;const c=e.target,l=X(c);if(l.reload)return;e.preventDefault(),e.stopPropagation();const h=new FormData(c,n);i.search=new URLSearchParams(h).toString(),D({type:"form",url:i,keepfocus:l.keepfocus,noscroll:l.noscroll,replace_state:l.replace_state??i.href===location.href,event:e})}),addEventListener("popstate",async e=>{var r;if(!mt){if((r=e.state)!=null&&r[N]){const n=e.state[N];if(O={},n===b)return;const o=I[n],s=e.state[Nt]??{},i=new URL(e.state[re]??location.href),c=e.state[B],l=g.url?it(location)===it(g.url):!1;if(c===R&&(Ht||l)){s!==x.state&&(x.state=s),t(i),I[b]=C(),o&&scrollTo(o.x,o.y),b=n;return}const u=n-b;await D({type:"popstate",url:i,popped:{state:s,scroll:o,delta:u},accept:()=>{b=n,R=c},block:()=>{history.go(-u)},nav_token:O,event:e})}else if(!K){const n=new URL(location.href);t(n),E.hash&&location.reload()}}}),addEventListener("hashchange",()=>{K&&(K=!1,history.replaceState({...history.state,[N]:++b,[B]:R},"",location.href))});for(const e of document.querySelectorAll("link"))ve.has(e.rel)&&(e.href=e.href);addEventListener("pageshow",e=>{e.persisted&&P.navigating.set(ft.current=null)});function t(e){g.url=x.url=e,P.page.set(At(x)),P.page.notify()}}async function Ce(t,{status:a=200,error:e,node_ids:r,params:n,route:o,server_route:s,data:i,form:c}){Vt=!0;const l=new URL(location.href);let h;({params:n={},route:o={id:null}}=await ot(l,!1)||{}),h=kt.find(({id:f})=>f===o.id);let u,w=!0;try{const f=r.map(async(_,m)=>{const p=i[m];return p!=null&&p.uses&&(p.uses=je(p.uses)),Rt({loader:E.nodes[_],url:l,params:n,route:o,parent:async()=>{const y={};for(let S=0;S{const i=history.state;mt=!0,location.replace(new URL(`#${r}`,location.href)),history.replaceState(i,"",t),a&&scrollTo(o,s),mt=!1})}else{const o=document.body,s=o.getAttribute("tabindex");o.tabIndex=-1,o.focus({preventScroll:!0,focusVisible:!1}),s!==null?o.setAttribute("tabindex",s):o.removeAttribute("tabindex")}const n=getSelection();if(n&&n.type!=="None"){const o=[];for(let s=0;s{if(n.rangeCount===o.length){for(let s=0;s{o=u,s=w});return i.catch(()=>{}),{navigation:{from:{params:t.params,route:{id:((l=t.route)==null?void 0:l.id)??null},url:t.url,scroll:C()},to:e&&{params:(a==null?void 0:a.params)??null,route:{id:((h=a==null?void 0:a.route)==null?void 0:h.id)??null},url:e,scroll:n},willUnload:!a,type:r,complete:i},fulfil:o,reject:s}}function At(t){return{data:t.data,error:t.error,form:t.form,params:t.params,route:t.route,state:t.state,status:t.status,url:t.url}}function Ne(t){const a=new URL(t);return a.hash=decodeURIComponent(t.hash),a}function te(t){let a;if(E.hash){const[,,e]=t.hash.split("#",3);a=e??""}else a=t.hash.slice(1);return decodeURIComponent(a)}export{Fe as a,Be as g,P as s}; +import{$ as J,bd as ee}from"./CpWkWWOo.js";import{w as ae}from"./BeMFXnHE.js";import{c as ne,H as N,N as B,r as mt,i as _t,b as L,s as C,p as x,n as ft,f as $t,g as ut,a as X,d as it,S as Nt,P as re,e as oe,h as se,o as Dt,j as q,k as ie,l as qt,m as le,q as ce,t as Kt,u as Pt,v as fe}from"./D8UfWY0j.js";class wt{constructor(a,e){this.status=a,typeof e=="string"?this.body={message:e}:e?this.body=e:this.body={message:`Error: ${a}`}}toString(){return JSON.stringify(this.body)}}class vt{constructor(a,e){this.status=a,this.location=e}}class yt extends Error{constructor(a,e,r){super(r),this.status=a,this.text=e}}const ue=/^(\[)?(\.\.\.)?(\w+)(?:=(\w+))?(\])?$/;function he(t){const a=[];return{pattern:t==="/"?/^\/$/:new RegExp(`^${pe(t).map(r=>{const n=/^\[\.\.\.(\w+)(?:=(\w+))?\]$/.exec(r);if(n)return a.push({name:n[1],matcher:n[2],optional:!1,rest:!0,chained:!0}),"(?:/([^]*))?";const o=/^\[\[(\w+)(?:=(\w+))?\]\]$/.exec(r);if(o)return a.push({name:o[1],matcher:o[2],optional:!0,rest:!1,chained:!0}),"(?:/([^/]+))?";if(!r)return;const s=r.split(/\[(.+?)\](?!\])/);return"/"+s.map((l,c)=>{if(c%2){if(l.startsWith("x+"))return lt(String.fromCharCode(parseInt(l.slice(2),16)));if(l.startsWith("u+"))return lt(String.fromCharCode(...l.slice(2).split("-").map(_=>parseInt(_,16))));const h=ue.exec(l),[,u,w,f,d]=h;return a.push({name:f,matcher:d,optional:!!u,rest:!!w,chained:w?c===1&&s[0]==="":!1}),w?"([^]*?)":u?"([^/]*)?":"([^/]+?)"}return lt(l)}).join("")}).join("")}/?$`),params:a}}function de(t){return t!==""&&!/^\([^)]+\)$/.test(t)}function pe(t){return t.slice(1).split("/").filter(de)}function ge(t,a,e){const r={},n=t.slice(1),o=n.filter(i=>i!==void 0);let s=0;for(let i=0;ih).join("/"),s=0),c===void 0)if(l.rest)c="";else continue;if(!l.matcher||e[l.matcher](c)){r[l.name]=c;const h=a[i+1],u=n[i+1];h&&!h.rest&&h.optional&&u&&l.chained&&(s=0),!h&&!u&&Object.keys(r).length===o.length&&(s=0);continue}if(l.optional&&l.chained){s++;continue}return}if(!s)return r}function lt(t){return t.normalize().replace(/[[\]]/g,"\\$&").replace(/%/g,"%25").replace(/\//g,"%2[Ff]").replace(/\?/g,"%3[Ff]").replace(/#/g,"%23").replace(/[.*+?^${}()|\\]/g,"\\$&")}function me({nodes:t,server_loads:a,dictionary:e,matchers:r}){const n=new Set(a);return Object.entries(e).map(([i,[l,c,h]])=>{const{pattern:u,params:w}=he(i),f={id:i,exec:d=>{const _=u.exec(d);if(_)return ge(_,w,r)},errors:[1,...h||[]].map(d=>t[d]),layouts:[0,...c||[]].map(s),leaf:o(l)};return f.errors.length=f.layouts.length=Math.max(f.errors.length,f.layouts.length),f});function o(i){const l=i<0;return l&&(i=~i),[l,t[i]]}function s(i){return i===void 0?i:[n.has(i),t[i]]}}function Ft(t,a=JSON.parse){try{return a(sessionStorage[t])}catch{}}function It(t,a,e=JSON.stringify){const r=e(a);try{sessionStorage[t]=r}catch{}}function _e(t){return t.filter(a=>a!=null)}function Et(t){return t instanceof wt||t instanceof yt?t.status:500}function we(t){return t instanceof yt?t.text:"Internal Error"}const ve=new Set(["icon","shortcut icon","apple-touch-icon"]),I=Ft(Kt)??{},M=Ft(qt)??{},P={url:Pt({}),page:Pt({}),navigating:ae(null),updated:ne()};function bt(t){I[t]=C()}function ye(t,a){let e=t+1;for(;I[e];)delete I[e],e+=1;for(e=a+1;M[e];)delete M[e],e+=1}function V(t,a=!1){return a?location.replace(t.href):location.href=t.href,new Promise(()=>{})}async function Bt(){if("serviceWorker"in navigator){const t=await navigator.serviceWorker.getRegistration(L||"/");t&&await t.update()}}function Tt(){}let kt,ht,Q,U,dt,b;const Z=[],tt=[];let v=null;function pt(){var t;(t=v==null?void 0:v.fork)==null||t.then(a=>a==null?void 0:a.discard()),v=null}const G=new Map,Mt=new Set,Ee=new Set,F=new Set;let m={branch:[],error:null,url:null},Vt=!1,et=!1,Ot=!0,H=!1,K=!1,Ht=!1,St=!1,Yt,E,R,O;const at=new Set,Ct=new Map;async function Fe(t,a,e){var o,s,i,l,c;(o=globalThis.__sveltekit_1rrld2f)!=null&&o.data&&globalThis.__sveltekit_1rrld2f.data,document.URL!==location.href&&(location.href=location.href),b=t,await((i=(s=t.hooks).init)==null?void 0:i.call(s)),kt=me(t),U=document.documentElement,dt=a,ht=t.nodes[0],Q=t.nodes[1],ht(),Q(),E=(l=history.state)==null?void 0:l[N],R=(c=history.state)==null?void 0:c[B],E||(E=R=Date.now(),history.replaceState({...history.state,[N]:E,[B]:R},""));const r=I[E];function n(){r&&(history.scrollRestoration="manual",scrollTo(r.x,r.y))}e?(n(),await Ce(dt,e)):(await D({type:"enter",url:mt(b.hash?Ne(new URL(location.href)):location.href),replace_state:!0}),n()),Oe()}function be(){Z.length=0,St=!1}function zt(t){tt.some(a=>a==null?void 0:a.snapshot)&&(M[t]=tt.map(a=>{var e;return(e=a==null?void 0:a.snapshot)==null?void 0:e.capture()}))}function Wt(t){var a;(a=M[t])==null||a.forEach((e,r)=>{var n,o;(o=(n=tt[r])==null?void 0:n.snapshot)==null||o.restore(e)})}function jt(){bt(E),It(Kt,I),zt(R),It(qt,M)}async function Gt(t,a,e,r){let n;a.invalidateAll&&pt(),await D({type:"goto",url:mt(t),keepfocus:a.keepFocus,noscroll:a.noScroll,replace_state:a.replaceState,state:a.state,redirect_count:e,nav_token:r,accept:()=>{a.invalidateAll&&(St=!0,n=[...Ct.keys()]),a.invalidate&&a.invalidate.forEach(Te)}}),a.invalidateAll&&J().then(J).then(()=>{Ct.forEach(({resource:o},s)=>{var i;n!=null&&n.includes(s)&&((i=o.refresh)==null||i.call(o))})})}async function ke(t){if(t.id!==(v==null?void 0:v.id)){pt();const a={};at.add(a),v={id:t.id,token:a,promise:Xt({...t,preload:a}).then(e=>(at.delete(a),e.type==="loaded"&&e.state.error&&pt(),e)),fork:null}}return v.promise}async function ct(t){var e;const a=(e=await ot(t,!1))==null?void 0:e.route;a&&await Promise.all([...a.layouts,a.leaf].filter(Boolean).map(r=>r[1]()))}async function Jt(t,a,e){var n;m=t.state;const r=document.querySelector("style[data-sveltekit]");if(r&&r.remove(),Object.assign(x,t.props.page),Yt=new b.root({target:a,props:{...t.props,stores:P,components:tt},hydrate:e,sync:!1}),await Promise.resolve(),Wt(R),e){const o={from:null,to:{params:m.params,route:{id:((n=m.route)==null?void 0:n.id)??null},url:new URL(location.href),scroll:I[E]??C()},willUnload:!1,type:"enter",complete:Promise.resolve()};F.forEach(s=>s(o))}et=!0}function nt({url:t,params:a,branch:e,status:r,error:n,route:o,form:s}){let i="never";if(L&&(t.pathname===L||t.pathname===L+"/"))i="always";else for(const f of e)(f==null?void 0:f.slash)!==void 0&&(i=f.slash);t.pathname=se(t.pathname,i),t.search=t.search;const l={type:"loaded",state:{url:t,params:a,branch:e,error:n,route:o},props:{constructors:_e(e).map(f=>f.node.component),page:At(x)}};s!==void 0&&(l.props.form=s);let c={},h=!x,u=0;for(let f=0;fi(new URL(s))))return!0;return!1}function xt(t,a){return(t==null?void 0:t.type)==="data"?t:(t==null?void 0:t.type)==="skip"?a??null:null}function xe(t,a){if(!t)return new Set(a.searchParams.keys());const e=new Set([...t.searchParams.keys(),...a.searchParams.keys()]);for(const r of e){const n=t.searchParams.getAll(r),o=a.searchParams.getAll(r);n.every(s=>o.includes(s))&&o.every(s=>n.includes(s))&&e.delete(r)}return e}function Le({error:t,url:a,route:e,params:r}){return{type:"loaded",state:{error:t,url:a,route:e,params:r,branch:[]},props:{page:At(x),constructors:[]}}}async function Xt({id:t,invalidating:a,url:e,params:r,route:n,preload:o}){if((v==null?void 0:v.id)===t)return at.delete(v.token),v.promise;const{errors:s,layouts:i,leaf:l}=n,c=[...i,l];s.forEach(g=>g==null?void 0:g().catch(()=>{})),c.forEach(g=>g==null?void 0:g[1]().catch(()=>{}));const h=m.url?t!==rt(m.url):!1,u=m.route?n.id!==m.route.id:!1,w=xe(m.url,e);let f=!1;const d=c.map(async(g,p)=>{var A;if(!g)return;const y=m.branch[p];return g[1]===(y==null?void 0:y.loader)&&!Re(f,u,h,w,(A=y.universal)==null?void 0:A.uses,r)?y:(f=!0,Rt({loader:g[1],url:e,params:r,route:n,parent:async()=>{var z;const T={};for(let j=0;j{});const _=[];for(let g=0;gPromise.resolve({}),server_data_node:xt(o)}),i={node:await Q(),loader:Q,universal:null,server:null,data:null};return nt({url:e,params:n,branch:[s,i],status:t,error:a,route:null})}catch(s){if(s instanceof vt)return Gt(new URL(s.location,location.href),{},0);throw s}}async function Ae(t){const a=t.href;if(G.has(a))return G.get(a);let e;try{const r=(async()=>{let n=await b.hooks.reroute({url:new URL(t),fetch:async(o,s)=>Se(o,s,t).promise})??t;if(typeof n=="string"){const o=new URL(t);b.hash?o.hash=n:o.pathname=n,n=o}return n})();G.set(a,r),e=await r}catch{G.delete(a);return}return e}async function ot(t,a){if(t&&!_t(t,L,b.hash)){const e=await Ae(t);if(!e)return;const r=Pe(e);for(const n of kt){const o=n.exec(r);if(o)return{id:rt(t),invalidating:a,route:n,params:oe(o),url:t}}}}function Pe(t){return ie(b.hash?t.hash.replace(/^#/,"").replace(/[?#].+/,""):t.pathname.slice(L.length))||"/"}function rt(t){return(b.hash?t.hash.replace(/^#/,""):t.pathname)+t.search}function Qt({url:t,type:a,intent:e,delta:r,event:n,scroll:o}){let s=!1;const i=Ut(m,e,t,a,o??null);r!==void 0&&(i.navigation.delta=r),n!==void 0&&(i.navigation.event=n);const l={...i.navigation,cancel:()=>{s=!0,i.reject(new Error("navigation cancelled"))}};return H||Mt.forEach(c=>c(l)),s?null:i}async function D({type:t,url:a,popped:e,keepfocus:r,noscroll:n,replace_state:o,state:s={},redirect_count:i=0,nav_token:l={},accept:c=Tt,block:h=Tt,event:u}){var j;const w=O;O=l;const f=await ot(a,!1),d=t==="enter"?Ut(m,f,a,t):Qt({url:a,type:t,delta:e==null?void 0:e.delta,intent:f,scroll:e==null?void 0:e.scroll,event:u});if(!d){h(),O===l&&(O=w);return}const _=E,g=R;c(),H=!0,et&&d.navigation.type!=="enter"&&P.navigating.set(ft.current=d.navigation);let p=f&&await Xt(f);if(!p){if(_t(a,L,b.hash))return await V(a,o);p=await Zt(a,{id:null},await Y(new yt(404,"Not Found",`Not found: ${a.pathname}`),{url:a,params:{},route:{id:null}}),404,o)}if(a=(f==null?void 0:f.url)||a,O!==l)return d.reject(new Error("navigation aborted")),!1;if(p.type==="redirect"){if(i<20){await D({type:t,url:new URL(p.location,a),popped:e,keepfocus:r,noscroll:n,replace_state:o,state:s,redirect_count:i+1,nav_token:l}),d.fulfil(void 0);return}p=await Lt({status:500,error:await Y(new Error("Redirect loop"),{url:a,params:{},route:{id:null}}),url:a,route:{id:null}})}else p.props.page.status>=400&&await P.updated.check()&&(await Bt(),await V(a,o));if(be(),bt(_),zt(g),p.props.page.url.pathname!==a.pathname&&(a.pathname=p.props.page.url.pathname),s=e?e.state:s,!e){const k=o?0:1,W={[N]:E+=k,[B]:R+=k,[Nt]:s};(o?history.replaceState:history.pushState).call(history,W,"",a),o||ye(E,R)}const y=f&&(v==null?void 0:v.id)===f.id?v.fork:null;v=null,p.props.page.state=s;let S;if(et){const k=(await Promise.all(Array.from(Ee,$=>$(d.navigation)))).filter($=>typeof $=="function");if(k.length>0){let $=function(){k.forEach(st=>{F.delete(st)})};k.push($),k.forEach(st=>{F.add(st)})}m=p.state,p.props.page&&(p.props.page.url=a);const W=y&&await y;W?S=W.commit():(Yt.$set(p.props),fe(p.props.page),S=(j=ee)==null?void 0:j()),Ht=!0}else await Jt(p,dt,!1);const{activeElement:A}=document;await S,await J(),await J();let T=null;if(Ot){const k=e?e.scroll:n?C():null;k?scrollTo(k.x,k.y):(T=a.hash&&document.getElementById(te(a)))?T.scrollIntoView():scrollTo(0,0)}const z=document.activeElement!==A&&document.activeElement!==document.body;!r&&!z&&$e(a,!T),Ot=!0,p.props.page&&Object.assign(x,p.props.page),H=!1,t==="popstate"&&Wt(R),d.fulfil(void 0),d.navigation.to&&(d.navigation.to.scroll=C()),F.forEach(k=>k(d.navigation)),P.navigating.set(ft.current=null)}async function Zt(t,a,e,r,n){return t.origin===Dt&&t.pathname===location.pathname&&!Vt?await Lt({status:r,error:e,url:t,route:a}):await V(t,n)}function Ie(){let t,a={element:void 0,href:void 0},e;U.addEventListener("mousemove",i=>{const l=i.target;clearTimeout(t),t=setTimeout(()=>{o(l,q.hover)},20)});function r(i){i.defaultPrevented||o(i.composedPath()[0],q.tap)}U.addEventListener("mousedown",r),U.addEventListener("touchstart",r,{passive:!0});const n=new IntersectionObserver(i=>{for(const l of i)l.isIntersecting&&(ct(new URL(l.target.href)),n.unobserve(l.target))},{threshold:0});async function o(i,l){const c=$t(i,U),h=c===a.element&&(c==null?void 0:c.href)===a.href&&l>=e;if(!c||h)return;const{url:u,external:w,download:f}=ut(c,L,b.hash);if(w||f)return;const d=X(c),_=u&&rt(m.url)===rt(u);if(!(d.reload||_))if(l<=d.preload_data){a={element:c,href:c.href},e=q.tap;const g=await ot(u,!1);if(!g)return;ke(g)}else l<=d.preload_code&&(a={element:c,href:c.href},e=l,ct(u))}function s(){n.disconnect();for(const i of U.querySelectorAll("a")){const{url:l,external:c,download:h}=ut(i,L,b.hash);if(c||h)continue;const u=X(i);u.reload||(u.preload_code===q.viewport&&n.observe(i),u.preload_code===q.eager&&ct(l))}}F.add(s),s()}function Y(t,a){if(t instanceof wt)return t.body;const e=Et(t),r=we(t);return b.hooks.handleError({error:t,event:a,status:e,message:r})??{message:r}}function Be(t,a={}){return t=new URL(mt(t)),t.origin!==Dt?Promise.reject(new Error("goto: invalid URL")):Gt(t,a,0)}function Te(t){if(typeof t=="function")Z.push(t);else{const{href:a}=new URL(t,location.href);Z.push(e=>e.href===a)}}function Oe(){var a;history.scrollRestoration="manual",addEventListener("beforeunload",e=>{let r=!1;if(jt(),!H){const n=Ut(m,void 0,null,"leave"),o={...n.navigation,cancel:()=>{r=!0,n.reject(new Error("navigation cancelled"))}};Mt.forEach(s=>s(o))}r?(e.preventDefault(),e.returnValue=""):history.scrollRestoration="auto"}),addEventListener("visibilitychange",()=>{document.visibilityState==="hidden"&&jt()}),(a=navigator.connection)!=null&&a.saveData||Ie(),U.addEventListener("click",async e=>{if(e.button||e.which!==1||e.metaKey||e.ctrlKey||e.shiftKey||e.altKey||e.defaultPrevented)return;const r=$t(e.composedPath()[0],U);if(!r)return;const{url:n,external:o,target:s,download:i}=ut(r,L,b.hash);if(!n)return;if(s==="_parent"||s==="_top"){if(window.parent!==window)return}else if(s&&s!=="_self")return;const l=X(r);if(!(r instanceof SVGAElement)&&n.protocol!==location.protocol&&!(n.protocol==="https:"||n.protocol==="http:")||i)return;const[h,u]=(b.hash?n.hash.replace(/^#/,""):n.href).split("#"),w=h===it(location);if(o||l.reload&&(!w||!u)){Qt({url:n,type:"link",event:e})?H=!0:e.preventDefault();return}if(u!==void 0&&w){const[,f]=m.url.href.split("#");if(f===u){if(e.preventDefault(),u===""||u==="top"&&r.ownerDocument.getElementById("top")===null)scrollTo({top:0});else{const d=r.ownerDocument.getElementById(decodeURIComponent(u));d&&(d.scrollIntoView(),d.focus())}return}if(K=!0,bt(E),t(n),!l.replace_state)return;K=!1}e.preventDefault(),await new Promise(f=>{requestAnimationFrame(()=>{setTimeout(f,0)}),setTimeout(f,100)}),await D({type:"link",url:n,keepfocus:l.keepfocus,noscroll:l.noscroll,replace_state:l.replace_state??n.href===location.href,event:e})}),U.addEventListener("submit",e=>{if(e.defaultPrevented)return;const r=HTMLFormElement.prototype.cloneNode.call(e.target),n=e.submitter;if(((n==null?void 0:n.formTarget)||r.target)==="_blank"||((n==null?void 0:n.formMethod)||r.method)!=="get")return;const i=new URL((n==null?void 0:n.hasAttribute("formaction"))&&(n==null?void 0:n.formAction)||r.action);if(_t(i,L,!1))return;const l=e.target,c=X(l);if(c.reload)return;e.preventDefault(),e.stopPropagation();const h=new FormData(l,n);i.search=new URLSearchParams(h).toString(),D({type:"form",url:i,keepfocus:c.keepfocus,noscroll:c.noscroll,replace_state:c.replace_state??i.href===location.href,event:e})}),addEventListener("popstate",async e=>{var r;if(!gt){if((r=e.state)!=null&&r[N]){const n=e.state[N];if(O={},n===E)return;const o=I[n],s=e.state[Nt]??{},i=new URL(e.state[re]??location.href),l=e.state[B],c=m.url?it(location)===it(m.url):!1;if(l===R&&(Ht||c)){s!==x.state&&(x.state=s),t(i),I[E]=C(),o&&scrollTo(o.x,o.y),E=n;return}const u=n-E;await D({type:"popstate",url:i,popped:{state:s,scroll:o,delta:u},accept:()=>{E=n,R=l},block:()=>{history.go(-u)},nav_token:O,event:e})}else if(!K){const n=new URL(location.href);t(n),b.hash&&location.reload()}}}),addEventListener("hashchange",()=>{K&&(K=!1,history.replaceState({...history.state,[N]:++E,[B]:R},"",location.href))});for(const e of document.querySelectorAll("link"))ve.has(e.rel)&&(e.href=e.href);addEventListener("pageshow",e=>{e.persisted&&P.navigating.set(ft.current=null)});function t(e){m.url=x.url=e,P.page.set(At(x)),P.page.notify()}}async function Ce(t,{status:a=200,error:e,node_ids:r,params:n,route:o,server_route:s,data:i,form:l}){Vt=!0;const c=new URL(location.href);let h;({params:n={},route:o={id:null}}=await ot(c,!1)||{}),h=kt.find(({id:f})=>f===o.id);let u,w=!0;try{const f=r.map(async(_,g)=>{const p=i[g];return p!=null&&p.uses&&(p.uses=je(p.uses)),Rt({loader:b.nodes[_],url:c,params:n,route:o,parent:async()=>{const y={};for(let S=0;S{const i=history.state;gt=!0,location.replace(new URL(`#${r}`,location.href)),history.replaceState(i,"",t),a&&scrollTo(o,s),gt=!1})}else{const o=document.body,s=o.getAttribute("tabindex");o.tabIndex=-1,o.focus({preventScroll:!0,focusVisible:!1}),s!==null?o.setAttribute("tabindex",s):o.removeAttribute("tabindex")}const n=getSelection();if(n&&n.type!=="None"){const o=[];for(let s=0;s{if(n.rangeCount===o.length){for(let s=0;s{o=u,s=w});return i.catch(()=>{}),{navigation:{from:{params:t.params,route:{id:((c=t.route)==null?void 0:c.id)??null},url:t.url,scroll:C()},to:e&&{params:(a==null?void 0:a.params)??null,route:{id:((h=a==null?void 0:a.route)==null?void 0:h.id)??null},url:e,scroll:n},willUnload:!a,type:r,complete:i},fulfil:o,reject:s}}function At(t){return{data:t.data,error:t.error,form:t.form,params:t.params,route:t.route,state:t.state,status:t.status,url:t.url}}function Ne(t){const a=new URL(t);return a.hash=decodeURIComponent(t.hash),a}function te(t){let a;if(b.hash){const[,,e]=t.hash.split("#",3);a=e??""}else a=t.hash.slice(1);return decodeURIComponent(a)}export{Fe as a,Be as g,P as s}; diff --git a/apps/dashboard/build/_app/immutable/chunks/C2TQQEIa.js.br b/apps/dashboard/build/_app/immutable/chunks/C2TQQEIa.js.br new file mode 100644 index 0000000..dfd5c89 Binary files /dev/null and b/apps/dashboard/build/_app/immutable/chunks/C2TQQEIa.js.br differ diff --git a/apps/dashboard/build/_app/immutable/chunks/C2TQQEIa.js.gz b/apps/dashboard/build/_app/immutable/chunks/C2TQQEIa.js.gz new file mode 100644 index 0000000..99be8f2 Binary files /dev/null and b/apps/dashboard/build/_app/immutable/chunks/C2TQQEIa.js.gz differ diff --git a/apps/dashboard/build/_app/immutable/chunks/DzfRjky4.js b/apps/dashboard/build/_app/immutable/chunks/CcUbQ_Wl.js similarity index 52% rename from apps/dashboard/build/_app/immutable/chunks/DzfRjky4.js rename to apps/dashboard/build/_app/immutable/chunks/CcUbQ_Wl.js index 0f1e075..b9a927a 100644 --- a/apps/dashboard/build/_app/immutable/chunks/DzfRjky4.js +++ b/apps/dashboard/build/_app/immutable/chunks/CcUbQ_Wl.js @@ -1 +1 @@ -const e={fact:"#00A8FF",concept:"#9D00FF",event:"#FFB800",person:"#00FFD1",place:"#00D4FF",note:"#8B95A5",pattern:"#FF3CAC",decision:"#FF4757"},F={MemoryCreated:"#00FFD1",MemoryUpdated:"#00A8FF",MemoryDeleted:"#FF4757",MemoryPromoted:"#00FF88",MemoryDemoted:"#FF6B35",MemorySuppressed:"#A33FFF",MemoryUnsuppressed:"#14E8C6",Rac1CascadeSwept:"#6E3FFF",SearchPerformed:"#818CF8",DeepReferenceCompleted:"#C4B5FD",DreamStarted:"#9D00FF",DreamProgress:"#B44AFF",DreamCompleted:"#C084FC",ConsolidationStarted:"#FFB800",ConsolidationCompleted:"#FF9500",RetentionDecayed:"#FF4757",ConnectionDiscovered:"#00D4FF",ActivationSpread:"#14E8C6",ImportanceScored:"#FF3CAC",Heartbeat:"#8B95A5"};export{F as E,e as N}; +const e={fact:"#00A8FF",concept:"#9D00FF",event:"#FFB800",person:"#00FFD1",place:"#00D4FF",note:"#8B95A5",pattern:"#FF3CAC",decision:"#FF4757"},F={MemoryCreated:"#00FFD1",MemoryUpdated:"#00A8FF",MemoryDeleted:"#FF4757",MemoryPromoted:"#00FF88",MemoryDemoted:"#FF6B35",MemorySuppressed:"#A33FFF",MemoryUnsuppressed:"#14E8C6",Rac1CascadeSwept:"#6E3FFF",SearchPerformed:"#818CF8",DeepReferenceCompleted:"#C4B5FD",HookVerdictRecorded:"#F59E0B",DreamStarted:"#9D00FF",DreamProgress:"#B44AFF",DreamCompleted:"#C084FC",ConsolidationStarted:"#FFB800",ConsolidationCompleted:"#FF9500",RetentionDecayed:"#FF4757",ConnectionDiscovered:"#00D4FF",ActivationSpread:"#14E8C6",ImportanceScored:"#FF3CAC",Heartbeat:"#8B95A5"};export{F as E,e as N}; diff --git a/apps/dashboard/build/_app/immutable/chunks/CcUbQ_Wl.js.br b/apps/dashboard/build/_app/immutable/chunks/CcUbQ_Wl.js.br new file mode 100644 index 0000000..e5e0cfe Binary files /dev/null and b/apps/dashboard/build/_app/immutable/chunks/CcUbQ_Wl.js.br differ diff --git a/apps/dashboard/build/_app/immutable/chunks/CcUbQ_Wl.js.gz b/apps/dashboard/build/_app/immutable/chunks/CcUbQ_Wl.js.gz new file mode 100644 index 0000000..3353949 Binary files /dev/null and b/apps/dashboard/build/_app/immutable/chunks/CcUbQ_Wl.js.gz differ diff --git a/apps/dashboard/build/_app/immutable/chunks/D4ymNiig.js b/apps/dashboard/build/_app/immutable/chunks/D4ymNiig.js new file mode 100644 index 0000000..99ccabc --- /dev/null +++ b/apps/dashboard/build/_app/immutable/chunks/D4ymNiig.js @@ -0,0 +1 @@ +import{w as N,g as T}from"./BeMFXnHE.js";import{e as E}from"./MAY1QfFZ.js";import{E as p}from"./CcUbQ_Wl.js";const y=4,R=5500,F=1500;function x(){const{subscribe:b,update:u}=N([]);let m=1,f=0;const c=new Map,a=new Map,l=new Map;function w(e,s){l.set(e,Date.now());const t=setTimeout(()=>{c.delete(e),l.delete(e),h(e)},s);c.set(e,t)}function g(e){const s=m++,t=Date.now(),o={id:s,createdAt:t,...e};u(n=>{const r=[o,...n];if(r.length>y){for(const i of r.slice(y)){const d=c.get(i.id);d&&clearTimeout(d),c.delete(i.id),a.delete(i.id),l.delete(i.id)}return r.slice(0,y)}return r}),w(s,e.dwellMs)}function h(e){const s=c.get(e);s&&(clearTimeout(s),c.delete(e)),a.delete(e),l.delete(e),u(t=>t.filter(o=>o.id!==e))}function D(e,s){const t=c.get(e);if(!t)return;clearTimeout(t),c.delete(e);const o=l.get(e)??Date.now(),n=Date.now()-o,r=Math.max(200,s-n);a.set(e,{remaining:r})}function C(e){const s=a.get(e);s&&(a.delete(e),w(e,s.remaining))}function S(){for(const e of c.values())clearTimeout(e);c.clear(),a.clear(),l.clear(),u(()=>[])}function _(e){const s=p[e.type]??"#818CF8",t=e.data;switch(e.type){case"DreamCompleted":{const o=Number(t.memories_replayed??0),n=Number(t.connections_found??0),r=Number(t.insights_generated??0),i=Number(t.duration_ms??0),d=[];return d.push(`Replayed ${o} ${o===1?"memory":"memories"}`),n>0&&d.push(`${n} new connection${n===1?"":"s"}`),r>0&&d.push(`${r} insight${r===1?"":"s"}`),{type:e.type,title:"Dream consolidated",body:`${d.join(" · ")} in ${(i/1e3).toFixed(1)}s`,color:s,dwellMs:7e3}}case"ConsolidationCompleted":{const o=Number(t.nodes_processed??0),n=Number(t.decay_applied??0),r=Number(t.embeddings_generated??0),i=Number(t.duration_ms??0),d=[];return n>0&&d.push(`${n} decayed`),r>0&&d.push(`${r} embedded`),{type:e.type,title:"Consolidation swept",body:`${o} node${o===1?"":"s"}${d.length?" · "+d.join(" · "):""} in ${(i/1e3).toFixed(1)}s`,color:s,dwellMs:6e3}}case"ConnectionDiscovered":{const o=Date.now();if(o-f0?`suppression #${o} · Rac1 cascade ~${n} neighbors`:`suppression #${o}`,color:s,dwellMs:5500}}case"MemoryUnsuppressed":{const o=Number(t.remaining_count??0);return{type:e.type,title:"Recovered",body:o>0?`${o} suppression${o===1?"":"s"} remain`:"fully unsuppressed",color:s,dwellMs:5e3}}case"Rac1CascadeSwept":{const o=Number(t.seeds??0),n=Number(t.neighbors_affected??0);return{type:e.type,title:"Rac1 cascade",body:`${o} seed${o===1?"":"s"} · ${n} dendritic spine${n===1?"":"s"} pruned`,color:s,dwellMs:6e3}}case"MemoryDeleted":return{type:e.type,title:"Memory deleted",body:String(t.id??"").slice(0,8),color:s,dwellMs:4e3};case"HookVerdictRecorded":{const o=String(t.verdict??"NOTE"),n=String(t.reason??"Sanhedrin receipt updated");return{type:e.type,title:`Sanhedrin ${o}`,body:n,color:s,dwellMs:o==="VETO"?8e3:R}}case"Heartbeat":case"SearchPerformed":case"RetentionDecayed":case"ActivationSpread":case"ImportanceScored":case"MemoryCreated":case"MemoryUpdated":case"DreamStarted":case"DreamProgress":case"ConsolidationStarted":case"Connected":return null;default:return null}}let M=null;return E.subscribe(e=>{if(e.length===0)return;const s=[];for(const t of e){if(t===M)break;s.push(t)}if(s.length!==0){M=e[0];for(let t=s.length-1;t>=0;t--){const o=_(s[t]);o&&g(o)}}}),{subscribe:b,dismiss:h,clear:S,pauseDwell:D,resumeDwell:C,push:g}}const $=x();function I(){[{type:"DreamCompleted",title:"Dream consolidated",body:"Replayed 127 memories · 43 new connections · 5 insights in 2.4s",color:p.DreamCompleted,dwellMs:7e3},{type:"ConnectionDiscovered",title:"Bridge discovered",body:"semantic · weight 0.87",color:p.ConnectionDiscovered,dwellMs:4500},{type:"MemorySuppressed",title:"Forgetting",body:"suppression #2 · Rac1 cascade ~8 neighbors",color:p.MemorySuppressed,dwellMs:5500},{type:"ConsolidationCompleted",title:"Consolidation swept",body:"892 nodes · 156 decayed · 48 embedded in 1.1s",color:p.ConsolidationCompleted,dwellMs:6e3}].forEach((u,m)=>{setTimeout(()=>{$.push(u)},m*800)}),T($)}export{I as f,$ as t}; diff --git a/apps/dashboard/build/_app/immutable/chunks/D4ymNiig.js.br b/apps/dashboard/build/_app/immutable/chunks/D4ymNiig.js.br new file mode 100644 index 0000000..fffecbf Binary files /dev/null and b/apps/dashboard/build/_app/immutable/chunks/D4ymNiig.js.br differ diff --git a/apps/dashboard/build/_app/immutable/chunks/D4ymNiig.js.gz b/apps/dashboard/build/_app/immutable/chunks/D4ymNiig.js.gz new file mode 100644 index 0000000..896a3b1 Binary files /dev/null and b/apps/dashboard/build/_app/immutable/chunks/D4ymNiig.js.gz differ diff --git a/apps/dashboard/build/_app/immutable/chunks/D8UfWY0j.js b/apps/dashboard/build/_app/immutable/chunks/D8UfWY0j.js new file mode 100644 index 0000000..8fe3a65 --- /dev/null +++ b/apps/dashboard/build/_app/immutable/chunks/D8UfWY0j.js @@ -0,0 +1 @@ +var x=t=>{throw TypeError(t)};var B=(t,e,n)=>e.has(t)||x("Cannot "+n);var a=(t,e,n)=>(B(t,e,"read from private field"),n?n.call(t):e.get(t)),c=(t,e,n)=>e.has(t)?x("Cannot add the same private member more than once"):e instanceof WeakSet?e.add(t):e.set(t,n);import{o as I}from"./GG5zm9kr.js";import{s as u,g as f,h as d}from"./CpWkWWOo.js";import{w as G}from"./BeMFXnHE.js";new URL("sveltekit-internal://");function ae(t,e){return t==="/"||e==="ignore"?t:e==="never"?t.endsWith("/")?t.slice(0,-1):t:e==="always"&&!t.endsWith("/")?t+"/":t}function oe(t){return t.split("%25").map(decodeURI).join("%25")}function ie(t){for(const e in t)t[e]=decodeURIComponent(t[e]);return t}function le({href:t}){return t.split("#")[0]}function W(...t){let e=5381;for(const n of t)if(typeof n=="string"){let r=n.length;for(;r;)e=e*33^n.charCodeAt(--r)}else if(ArrayBuffer.isView(n)){const r=new Uint8Array(n.buffer,n.byteOffset,n.byteLength);let s=r.length;for(;s;)e=e*33^r[--s]}else throw new TypeError("value must be a string or TypedArray");return(e>>>0).toString(36)}new TextEncoder;new TextDecoder;function X(t){const e=atob(t),n=new Uint8Array(e.length);for(let r=0;r((t instanceof Request?t.method:(e==null?void 0:e.method)||"GET")!=="GET"&&b.delete(U(t)),z(t,e));const b=new Map;function ce(t,e){const n=U(t,e),r=document.querySelector(n);if(r!=null&&r.textContent){r.remove();let{body:s,...l}=JSON.parse(r.textContent);const o=r.getAttribute("data-ttl");return o&&b.set(n,{body:s,init:l,ttl:1e3*Number(o)}),r.getAttribute("data-b64")!==null&&(s=X(s)),Promise.resolve(new Response(s,l))}return window.fetch(t,e)}function ue(t,e,n){if(b.size>0){const r=U(t,n),s=b.get(r);if(s){if(performance.now()o)}function s(o){n=!1,e.set(o)}function l(o){let i;return e.subscribe(h=>{(i===void 0||n&&h!==i)&&o(i=h)})}return{notify:r,set:s,subscribe:l}}const D={v:()=>{}};function Re(){const{set:t,subscribe:e}=G(!1);let n;async function r(){clearTimeout(n);try{const s=await fetch(`${M}/_app/version.json`,{headers:{pragma:"no-cache","cache-control":"no-cache"}});if(!s.ok)return!1;const o=(await s.json()).version!==F;return o&&(t(!0),D.v(),clearTimeout(n)),o}catch{return!1}}return{subscribe:e,check:r}}function Q(t,e,n){return t.origin!==Y||!t.pathname.startsWith(e)?!0:n?t.pathname!==location.pathname:!1}function Se(t){}const H=new Set(["load","prerender","csr","ssr","trailingSlash","config"]);[...H];const Z=new Set([...H]);[...Z];let E,O,T;const ee=I.toString().includes("$$")||/function \w+\(\) \{\}/.test(I.toString());var _,w,m,p,v,y,A,R,P,S,V,k,j;ee?(E={data:{},form:null,error:null,params:{},route:{id:null},state:{},status:-1,url:new URL("https://example.com")},O={current:null},T={current:!1}):(E=new(P=class{constructor(){c(this,_,u({}));c(this,w,u(null));c(this,m,u(null));c(this,p,u({}));c(this,v,u({id:null}));c(this,y,u({}));c(this,A,u(-1));c(this,R,u(new URL("https://example.com")))}get data(){return f(a(this,_))}set data(e){d(a(this,_),e)}get form(){return f(a(this,w))}set form(e){d(a(this,w),e)}get error(){return f(a(this,m))}set error(e){d(a(this,m),e)}get params(){return f(a(this,p))}set params(e){d(a(this,p),e)}get route(){return f(a(this,v))}set route(e){d(a(this,v),e)}get state(){return f(a(this,y))}set state(e){d(a(this,y),e)}get status(){return f(a(this,A))}set status(e){d(a(this,A),e)}get url(){return f(a(this,R))}set url(e){d(a(this,R),e)}},_=new WeakMap,w=new WeakMap,m=new WeakMap,p=new WeakMap,v=new WeakMap,y=new WeakMap,A=new WeakMap,R=new WeakMap,P),O=new(V=class{constructor(){c(this,S,u(null))}get current(){return f(a(this,S))}set current(e){d(a(this,S),e)}},S=new WeakMap,V),T=new(j=class{constructor(){c(this,k,u(!1))}get current(){return f(a(this,k))}set current(e){d(a(this,k),e)}},k=new WeakMap,j),D.v=()=>T.current=!0);function Ue(t){Object.assign(E,t)}export{be as H,_e as N,ge as P,he as S,ye as a,J as b,Re as c,le as d,ie as e,pe as f,ve as g,ae as h,Q as i,N as j,oe as k,fe as l,ue as m,O as n,Y as o,E as p,ce as q,we as r,me as s,de as t,Ae as u,Ue as v,Se as w}; diff --git a/apps/dashboard/build/_app/immutable/chunks/D8UfWY0j.js.br b/apps/dashboard/build/_app/immutable/chunks/D8UfWY0j.js.br new file mode 100644 index 0000000..8a34f57 Binary files /dev/null and b/apps/dashboard/build/_app/immutable/chunks/D8UfWY0j.js.br differ diff --git a/apps/dashboard/build/_app/immutable/chunks/D8UfWY0j.js.gz b/apps/dashboard/build/_app/immutable/chunks/D8UfWY0j.js.gz new file mode 100644 index 0000000..62beff4 Binary files /dev/null and b/apps/dashboard/build/_app/immutable/chunks/D8UfWY0j.js.gz differ diff --git a/apps/dashboard/build/_app/immutable/chunks/DNjM5a-l.js.br b/apps/dashboard/build/_app/immutable/chunks/DNjM5a-l.js.br deleted file mode 100644 index 03be48c..0000000 Binary files a/apps/dashboard/build/_app/immutable/chunks/DNjM5a-l.js.br and /dev/null differ diff --git a/apps/dashboard/build/_app/immutable/chunks/DNjM5a-l.js.gz b/apps/dashboard/build/_app/immutable/chunks/DNjM5a-l.js.gz deleted file mode 100644 index 44aee17..0000000 Binary files a/apps/dashboard/build/_app/immutable/chunks/DNjM5a-l.js.gz and /dev/null differ diff --git a/apps/dashboard/build/_app/immutable/chunks/DzfRjky4.js.br b/apps/dashboard/build/_app/immutable/chunks/DzfRjky4.js.br deleted file mode 100644 index e2697c3..0000000 Binary files a/apps/dashboard/build/_app/immutable/chunks/DzfRjky4.js.br and /dev/null differ diff --git a/apps/dashboard/build/_app/immutable/chunks/DzfRjky4.js.gz b/apps/dashboard/build/_app/immutable/chunks/DzfRjky4.js.gz deleted file mode 100644 index ec4983f..0000000 Binary files a/apps/dashboard/build/_app/immutable/chunks/DzfRjky4.js.gz and /dev/null differ diff --git a/apps/dashboard/build/_app/immutable/entry/app.CYIcgKkt.js b/apps/dashboard/build/_app/immutable/entry/app.Bv9rD2TH.js similarity index 77% rename from apps/dashboard/build/_app/immutable/entry/app.CYIcgKkt.js rename to apps/dashboard/build/_app/immutable/entry/app.Bv9rD2TH.js index e396d4a..da09974 100644 --- a/apps/dashboard/build/_app/immutable/entry/app.CYIcgKkt.js +++ b/apps/dashboard/build/_app/immutable/entry/app.Bv9rD2TH.js @@ -1,2 +1,2 @@ -const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["../nodes/0.COz2esg5.js","../chunks/Bzak7iHL.js","../chunks/GG5zm9kr.js","../chunks/CpWkWWOo.js","../chunks/BlVfL1ME.js","../chunks/CHOnp4oo.js","../chunks/B4yTwGkE.js","../chunks/DdEqwvdI.js","../chunks/CGEBXrjl.js","../chunks/CJCPY1OL.js","../chunks/A7po6GxK.js","../chunks/aVbAZ-t7.js","../chunks/BKuqSeVd.js","../chunks/sZcqyNBA.js","../chunks/CJsMJEun.js","../chunks/C6HuKgyx.js","../chunks/BeMFXnHE.js","../chunks/BHGLDPij.js","../chunks/BskPcZf7.js","../chunks/MAY1QfFZ.js","../chunks/BUoSzNdg.js","../chunks/Cx-f-Pzo.js","../chunks/BjdL4Pm2.js","../chunks/DzfRjky4.js","../chunks/DNjM5a-l.js","../assets/0.IIz8MMYb.css","../nodes/1.DJo7hfwf.js","../nodes/2.D-vKwnTC.js","../nodes/3.Caati8mq.js","../nodes/4.DJCab_le.js","../chunks/V6gjw5Ec.js","../nodes/5.C0AYWqwr.js","../chunks/BnXDGOmJ.js","../assets/5.DQ_AfUnN.css","../nodes/6.DTUGCA1p.js","../chunks/C4h_mRt2.js","../assets/6.BSSBWVKL.css","../nodes/7.jHtvjgRi.js","../assets/7.CCrNEDd3.css","../nodes/8.CgPowUzz.js","../nodes/9.BWaJ-VBd.js","../assets/9.BBx09UGv.css","../nodes/10.Btb56kL1.js","../nodes/11.WP3QAgOF.js","../nodes/12.DaxyVsV4.js","../nodes/13.D52bbIQQ.js","../assets/13.Bjd0S47S.css","../nodes/14.DUh3SXOF.js","../nodes/15.C7Fk4d1G.js","../assets/15.ChjqzJHo.css","../nodes/16.DeYkCVEo.js","../assets/16.BnHgRQtR.css","../nodes/17.CLL0vjL4.js","../nodes/18.CXHHR36X.js","../nodes/19.D4UHDxxJ.js","../nodes/20.BwEdZXUF.js","../assets/20.DKhUrxcR.css"])))=>i.map(i=>d[i]); -var Q=r=>{throw TypeError(r)};var X=(r,t,e)=>t.has(r)||Q("Cannot "+e);var l=(r,t,e)=>(X(r,t,"read from private field"),e?e.call(r):t.get(r)),H=(r,t,e)=>t.has(r)?Q("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(r):t.set(r,e),W=(r,t,e,n)=>(X(r,t,"write to private field"),n?n.call(r,e):t.set(r,e),e);import{N as Z,ab as ut,b as _t,E as ct,ac as lt,ae as dt,T as ft,R as $,ax as vt,U as ht,h as U,L as pt,g as h,bc as Et,G as gt,I as Pt,p as Rt,aA as yt,aB as Ot,$ as At,f as L,e as Tt,a as bt,s as z,d as Lt,r as It,u as x,t as Dt}from"../chunks/CpWkWWOo.js";import{h as Vt,m as wt,u as kt,s as xt}from"../chunks/BlVfL1ME.js";import"../chunks/Bzak7iHL.js";import{o as St}from"../chunks/GG5zm9kr.js";import{i as B}from"../chunks/B4yTwGkE.js";import{a as g,c as V,f as et,t as jt}from"../chunks/CHOnp4oo.js";import{B as Ct}from"../chunks/DdEqwvdI.js";import{b as S}from"../chunks/CJsMJEun.js";import{p as N}from"../chunks/V6gjw5Ec.js";function j(r,t,e){var n;Z&&(n=ht,ut());var i=new Ct(r);_t(()=>{var c=t()??null;if(Z){var s=lt(n),a=s===vt,m=c!==null;if(a!==m){var R=dt();ft(R),i.anchor=R,$(!1),i.ensure(c,c&&(u=>e(u,c))),$(!0);return}}i.ensure(c,c&&(u=>e(u,c)))},ct)}function Bt(r){return class extends Nt{constructor(t){super({component:r,...t})}}}var P,d;class Nt{constructor(t){H(this,P);H(this,d);var c;var e=new Map,n=(s,a)=>{var m=Pt(a,!1,!1);return e.set(s,m),m};const i=new Proxy({...t.props||{},$$events:{}},{get(s,a){return h(e.get(a)??n(a,Reflect.get(s,a)))},has(s,a){return a===pt?!0:(h(e.get(a)??n(a,Reflect.get(s,a))),Reflect.has(s,a))},set(s,a,m){return U(e.get(a)??n(a,m),m),Reflect.set(s,a,m)}});W(this,d,(t.hydrate?Vt:wt)(t.component,{target:t.target,anchor:t.anchor,props:i,context:t.context,intro:t.intro??!1,recover:t.recover,transformError:t.transformError})),(!((c=t==null?void 0:t.props)!=null&&c.$$host)||t.sync===!1)&&Et(),W(this,P,i.$$events);for(const s of Object.keys(l(this,d)))s==="$set"||s==="$destroy"||s==="$on"||gt(this,s,{get(){return l(this,d)[s]},set(a){l(this,d)[s]=a},enumerable:!0});l(this,d).$set=s=>{Object.assign(i,s)},l(this,d).$destroy=()=>{kt(l(this,d))}}$set(t){l(this,d).$set(t)}$on(t,e){l(this,P)[t]=l(this,P)[t]||[];const n=(...i)=>e.call(this,...i);return l(this,P)[t].push(n),()=>{l(this,P)[t]=l(this,P)[t].filter(i=>i!==n)}}$destroy(){l(this,d).$destroy()}}P=new WeakMap,d=new WeakMap;const Ut="modulepreload",qt=function(r,t){return new URL(r,t).href},tt={},o=function(t,e,n){let i=Promise.resolve();if(e&&e.length>0){let s=function(u){return Promise.all(u.map(p=>Promise.resolve(p).then(y=>({status:"fulfilled",value:y}),y=>({status:"rejected",reason:y}))))};const a=document.getElementsByTagName("link"),m=document.querySelector("meta[property=csp-nonce]"),R=(m==null?void 0:m.nonce)||(m==null?void 0:m.getAttribute("nonce"));i=s(e.map(u=>{if(u=qt(u,n),u in tt)return;tt[u]=!0;const p=u.endsWith(".css"),y=p?'[rel="stylesheet"]':"";if(!!n)for(let O=a.length-1;O>=0;O--){const _=a[O];if(_.href===u&&(!p||_.rel==="stylesheet"))return}else if(document.querySelector(`link[href="${u}"]${y}`))return;const E=document.createElement("link");if(E.rel=p?"stylesheet":Ut,p||(E.as="script"),E.crossOrigin="",E.href=u,R&&E.setAttribute("nonce",R),document.head.appendChild(E),p)return new Promise((O,_)=>{E.addEventListener("load",O),E.addEventListener("error",()=>_(new Error(`Unable to preload CSS for ${u}`)))})}))}function c(s){const a=new Event("vite:preloadError",{cancelable:!0});if(a.payload=s,window.dispatchEvent(a),!a.defaultPrevented)throw s}return i.then(s=>{for(const a of s||[])a.status==="rejected"&&c(a.reason);return t().catch(c)})},ae={};var Ft=et('
'),Gt=et(" ",1);function Yt(r,t){Rt(t,!0);let e=N(t,"components",23,()=>[]),n=N(t,"data_0",3,null),i=N(t,"data_1",3,null),c=N(t,"data_2",3,null);yt(()=>t.stores.page.set(t.page)),Ot(()=>{t.stores,t.page,t.constructors,e(),t.form,n(),i(),c(),t.stores.page.notify()});let s=z(!1),a=z(!1),m=z(null);St(()=>{const _=t.stores.page.subscribe(()=>{h(s)&&(U(a,!0),At().then(()=>{U(m,document.title||"untitled page",!0)}))});return U(s,!0),_});const R=x(()=>t.constructors[2]);var u=Gt(),p=L(u);{var y=_=>{const A=x(()=>t.constructors[0]);var T=V(),w=L(T);j(w,()=>h(A),(b,I)=>{S(I(b,{get data(){return n()},get form(){return t.form},get params(){return t.page.params},children:(f,Wt)=>{var K=V(),at=L(K);{var st=D=>{const q=x(()=>t.constructors[1]);var k=V(),F=L(k);j(F,()=>h(q),(G,Y)=>{S(Y(G,{get data(){return i()},get form(){return t.form},get params(){return t.page.params},children:(v,zt)=>{var M=V(),nt=L(M);j(nt,()=>h(R),(it,mt)=>{S(mt(it,{get data(){return c()},get form(){return t.form},get params(){return t.page.params}}),C=>e()[2]=C,()=>{var C;return(C=e())==null?void 0:C[2]})}),g(v,M)},$$slots:{default:!0}}),v=>e()[1]=v,()=>{var v;return(v=e())==null?void 0:v[1]})}),g(D,k)},ot=D=>{const q=x(()=>t.constructors[1]);var k=V(),F=L(k);j(F,()=>h(q),(G,Y)=>{S(Y(G,{get data(){return i()},get form(){return t.form},get params(){return t.page.params}}),v=>e()[1]=v,()=>{var v;return(v=e())==null?void 0:v[1]})}),g(D,k)};B(at,D=>{t.constructors[2]?D(st):D(ot,!1)})}g(f,K)},$$slots:{default:!0}}),f=>e()[0]=f,()=>{var f;return(f=e())==null?void 0:f[0]})}),g(_,T)},J=_=>{const A=x(()=>t.constructors[0]);var T=V(),w=L(T);j(w,()=>h(A),(b,I)=>{S(I(b,{get data(){return n()},get form(){return t.form},get params(){return t.page.params}}),f=>e()[0]=f,()=>{var f;return(f=e())==null?void 0:f[0]})}),g(_,T)};B(p,_=>{t.constructors[1]?_(y):_(J,!1)})}var E=Tt(p,2);{var O=_=>{var A=Ft(),T=Lt(A);{var w=b=>{var I=jt();Dt(()=>xt(I,h(m))),g(b,I)};B(T,b=>{h(a)&&b(w)})}It(A),g(_,A)};B(E,_=>{h(s)&&_(O)})}g(r,u),bt()}const se=Bt(Yt),oe=[()=>o(()=>import("../nodes/0.COz2esg5.js"),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25]),import.meta.url),()=>o(()=>import("../nodes/1.DJo7hfwf.js"),__vite__mapDeps([26,1,20,3,4,5,18,2,16,17]),import.meta.url),()=>o(()=>import("../nodes/2.D-vKwnTC.js"),__vite__mapDeps([27,1,3,5,9,7]),import.meta.url),()=>o(()=>import("../nodes/3.Caati8mq.js"),__vite__mapDeps([28,1,20,3,2,17,16,18]),import.meta.url),()=>o(()=>import("../nodes/4.DJCab_le.js"),__vite__mapDeps([29,1,2,3,4,5,6,7,10,13,24,19,16,8,30,15,23]),import.meta.url),()=>o(()=>import("../nodes/5.C0AYWqwr.js"),__vite__mapDeps([31,1,3,4,5,6,7,8,11,12,21,32,10,30,15,16,33]),import.meta.url),()=>o(()=>import("../nodes/6.DTUGCA1p.js"),__vite__mapDeps([34,1,3,4,5,6,7,8,35,10,11,12,24,21,30,15,16,18,2,36]),import.meta.url),()=>o(()=>import("../nodes/7.jHtvjgRi.js"),__vite__mapDeps([37,1,2,3,4,5,6,7,8,10,13,11,12,21,23,38]),import.meta.url),()=>o(()=>import("../nodes/8.CgPowUzz.js"),__vite__mapDeps([39,1,3,4,5,6,7,8,10,11,12,21,13,24]),import.meta.url),()=>o(()=>import("../nodes/9.BWaJ-VBd.js"),__vite__mapDeps([40,1,20,3,4,5,6,7,8,21,12,15,16,19,23,10,11,30,41]),import.meta.url),()=>o(()=>import("../nodes/10.Btb56kL1.js"),__vite__mapDeps([42,1,2,3,4,5,6,7,8,10,11,12,21,13,32,15,16,18,14,30,23,20,24,19]),import.meta.url),()=>o(()=>import("../nodes/11.WP3QAgOF.js"),__vite__mapDeps([43,1,2,3,4,5,6,7,8,21,12,13,17,16,18,24,23,10,30,15]),import.meta.url),()=>o(()=>import("../nodes/12.DaxyVsV4.js"),__vite__mapDeps([44,1,2,3,4,5,6,7,8,11,12,24]),import.meta.url),()=>o(()=>import("../nodes/13.D52bbIQQ.js"),__vite__mapDeps([45,1,2,3,4,5,6,7,8,10,11,12,21,13,32,24,23,46]),import.meta.url),()=>o(()=>import("../nodes/14.DUh3SXOF.js"),__vite__mapDeps([47,1,2,3,4,5,6,7,8,10,11,12,21]),import.meta.url),()=>o(()=>import("../nodes/15.C7Fk4d1G.js"),__vite__mapDeps([48,1,2,3,4,5,6,7,8,35,10,21,12,13,14,24,11,30,15,16,23,49]),import.meta.url),()=>o(()=>import("../nodes/16.DeYkCVEo.js"),__vite__mapDeps([50,1,2,3,4,5,6,7,8,11,12,24,10,21,30,15,16,23,51]),import.meta.url),()=>o(()=>import("../nodes/17.CLL0vjL4.js"),__vite__mapDeps([52,1,2,3,4,5,6,7,8,11,12,21,15,16,24,19,22,23]),import.meta.url),()=>o(()=>import("../nodes/18.CXHHR36X.js"),__vite__mapDeps([53,1,2,3,4,5,6,7,8,21,12,24]),import.meta.url),()=>o(()=>import("../nodes/19.D4UHDxxJ.js"),__vite__mapDeps([54,1,2,3,4,5,6,7,8,21,12,32,24,23]),import.meta.url),()=>o(()=>import("../nodes/20.BwEdZXUF.js"),__vite__mapDeps([55,1,2,3,4,5,6,7,8,35,10,11,12,21,13,32,14,18,16,56]),import.meta.url)],ne=[],ie={"/":[3],"/(app)/activation":[4,[2]],"/(app)/contradictions":[5,[2]],"/(app)/dreams":[6,[2]],"/(app)/duplicates":[7,[2]],"/(app)/explore":[8,[2]],"/(app)/feed":[9,[2]],"/(app)/graph":[10,[2]],"/(app)/importance":[11,[2]],"/(app)/intentions":[12,[2]],"/(app)/memories":[13,[2]],"/(app)/patterns":[14,[2]],"/(app)/reasoning":[15,[2]],"/(app)/schedule":[16,[2]],"/(app)/settings":[17,[2]],"/(app)/stats":[18,[2]],"/(app)/timeline":[19,[2]],"/waitlist":[20]},rt={handleError:(({error:r})=>{console.error(r)}),reroute:(()=>{}),transport:{}},Ht=Object.fromEntries(Object.entries(rt.transport).map(([r,t])=>[r,t.decode])),me=Object.fromEntries(Object.entries(rt.transport).map(([r,t])=>[r,t.encode])),ue=!1,_e=(r,t)=>Ht[r](t);export{_e as decode,Ht as decoders,ie as dictionary,me as encoders,ue as hash,rt as hooks,ae as matchers,oe as nodes,se as root,ne as server_loads}; +const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["../nodes/0.j0CpgSFp.js","../chunks/Bzak7iHL.js","../chunks/GG5zm9kr.js","../chunks/CpWkWWOo.js","../chunks/BlVfL1ME.js","../chunks/CHOnp4oo.js","../chunks/B4yTwGkE.js","../chunks/DdEqwvdI.js","../chunks/CGEBXrjl.js","../chunks/CJCPY1OL.js","../chunks/A7po6GxK.js","../chunks/aVbAZ-t7.js","../chunks/BKuqSeVd.js","../chunks/sZcqyNBA.js","../chunks/CJsMJEun.js","../chunks/C6HuKgyx.js","../chunks/BeMFXnHE.js","../chunks/C2TQQEIa.js","../chunks/D8UfWY0j.js","../chunks/MAY1QfFZ.js","../chunks/BUoSzNdg.js","../chunks/Cx-f-Pzo.js","../chunks/D4ymNiig.js","../chunks/CcUbQ_Wl.js","../chunks/B7CfdQuM.js","../assets/0.Bor8S3Zo.css","../nodes/1.DEUqmURt.js","../nodes/2.D-vKwnTC.js","../nodes/3.Bu_uPddU.js","../nodes/4.DYVet_v-.js","../chunks/V6gjw5Ec.js","../nodes/5.C0AYWqwr.js","../chunks/BnXDGOmJ.js","../assets/5.DQ_AfUnN.css","../nodes/6.54m-BxV_.js","../chunks/C4h_mRt2.js","../assets/6.BSSBWVKL.css","../nodes/7.2YrTacps.js","../assets/7.CCrNEDd3.css","../nodes/8.DGKslLJe.js","../nodes/9.Vu2AXN40.js","../assets/9.BBx09UGv.css","../nodes/10.CACwABbv.js","../nodes/11.B_W3XFQr.js","../nodes/12.DxkSrFsy.js","../nodes/13.CD5qzYsO.js","../assets/13.Bjd0S47S.css","../nodes/14.DUh3SXOF.js","../nodes/15.CyCv1LGV.js","../assets/15.ChjqzJHo.css","../nodes/16.Cth-SSqa.js","../assets/16.BnHgRQtR.css","../nodes/17.k6k7874Y.js","../nodes/18.C60Wuzj2.js","../nodes/19.BIUSI5ln.js","../nodes/20.DebghJca.js","../assets/20.DKhUrxcR.css"])))=>i.map(i=>d[i]); +var Q=r=>{throw TypeError(r)};var X=(r,t,e)=>t.has(r)||Q("Cannot "+e);var l=(r,t,e)=>(X(r,t,"read from private field"),e?e.call(r):t.get(r)),H=(r,t,e)=>t.has(r)?Q("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(r):t.set(r,e),W=(r,t,e,n)=>(X(r,t,"write to private field"),n?n.call(r,e):t.set(r,e),e);import{N as Z,ab as ut,b as _t,E as ct,ac as lt,ae as dt,T as ft,R as $,ax as vt,U as ht,h as U,L as pt,g as h,bc as Et,G as gt,I as Pt,p as Rt,aA as yt,aB as Ot,$ as At,f as L,e as Tt,a as bt,s as z,d as Lt,r as It,u as x,t as Dt}from"../chunks/CpWkWWOo.js";import{h as Vt,m as wt,u as kt,s as xt}from"../chunks/BlVfL1ME.js";import"../chunks/Bzak7iHL.js";import{o as St}from"../chunks/GG5zm9kr.js";import{i as B}from"../chunks/B4yTwGkE.js";import{a as g,c as V,f as et,t as jt}from"../chunks/CHOnp4oo.js";import{B as Ct}from"../chunks/DdEqwvdI.js";import{b as S}from"../chunks/CJsMJEun.js";import{p as N}from"../chunks/V6gjw5Ec.js";function j(r,t,e){var n;Z&&(n=ht,ut());var i=new Ct(r);_t(()=>{var c=t()??null;if(Z){var s=lt(n),a=s===vt,m=c!==null;if(a!==m){var R=dt();ft(R),i.anchor=R,$(!1),i.ensure(c,c&&(u=>e(u,c))),$(!0);return}}i.ensure(c,c&&(u=>e(u,c)))},ct)}function Bt(r){return class extends Nt{constructor(t){super({component:r,...t})}}}var P,d;class Nt{constructor(t){H(this,P);H(this,d);var c;var e=new Map,n=(s,a)=>{var m=Pt(a,!1,!1);return e.set(s,m),m};const i=new Proxy({...t.props||{},$$events:{}},{get(s,a){return h(e.get(a)??n(a,Reflect.get(s,a)))},has(s,a){return a===pt?!0:(h(e.get(a)??n(a,Reflect.get(s,a))),Reflect.has(s,a))},set(s,a,m){return U(e.get(a)??n(a,m),m),Reflect.set(s,a,m)}});W(this,d,(t.hydrate?Vt:wt)(t.component,{target:t.target,anchor:t.anchor,props:i,context:t.context,intro:t.intro??!1,recover:t.recover,transformError:t.transformError})),(!((c=t==null?void 0:t.props)!=null&&c.$$host)||t.sync===!1)&&Et(),W(this,P,i.$$events);for(const s of Object.keys(l(this,d)))s==="$set"||s==="$destroy"||s==="$on"||gt(this,s,{get(){return l(this,d)[s]},set(a){l(this,d)[s]=a},enumerable:!0});l(this,d).$set=s=>{Object.assign(i,s)},l(this,d).$destroy=()=>{kt(l(this,d))}}$set(t){l(this,d).$set(t)}$on(t,e){l(this,P)[t]=l(this,P)[t]||[];const n=(...i)=>e.call(this,...i);return l(this,P)[t].push(n),()=>{l(this,P)[t]=l(this,P)[t].filter(i=>i!==n)}}$destroy(){l(this,d).$destroy()}}P=new WeakMap,d=new WeakMap;const Ut="modulepreload",qt=function(r,t){return new URL(r,t).href},tt={},o=function(t,e,n){let i=Promise.resolve();if(e&&e.length>0){let s=function(u){return Promise.all(u.map(p=>Promise.resolve(p).then(y=>({status:"fulfilled",value:y}),y=>({status:"rejected",reason:y}))))};const a=document.getElementsByTagName("link"),m=document.querySelector("meta[property=csp-nonce]"),R=(m==null?void 0:m.nonce)||(m==null?void 0:m.getAttribute("nonce"));i=s(e.map(u=>{if(u=qt(u,n),u in tt)return;tt[u]=!0;const p=u.endsWith(".css"),y=p?'[rel="stylesheet"]':"";if(!!n)for(let O=a.length-1;O>=0;O--){const _=a[O];if(_.href===u&&(!p||_.rel==="stylesheet"))return}else if(document.querySelector(`link[href="${u}"]${y}`))return;const E=document.createElement("link");if(E.rel=p?"stylesheet":Ut,p||(E.as="script"),E.crossOrigin="",E.href=u,R&&E.setAttribute("nonce",R),document.head.appendChild(E),p)return new Promise((O,_)=>{E.addEventListener("load",O),E.addEventListener("error",()=>_(new Error(`Unable to preload CSS for ${u}`)))})}))}function c(s){const a=new Event("vite:preloadError",{cancelable:!0});if(a.payload=s,window.dispatchEvent(a),!a.defaultPrevented)throw s}return i.then(s=>{for(const a of s||[])a.status==="rejected"&&c(a.reason);return t().catch(c)})},ae={};var Ft=et('
'),Gt=et(" ",1);function Yt(r,t){Rt(t,!0);let e=N(t,"components",23,()=>[]),n=N(t,"data_0",3,null),i=N(t,"data_1",3,null),c=N(t,"data_2",3,null);yt(()=>t.stores.page.set(t.page)),Ot(()=>{t.stores,t.page,t.constructors,e(),t.form,n(),i(),c(),t.stores.page.notify()});let s=z(!1),a=z(!1),m=z(null);St(()=>{const _=t.stores.page.subscribe(()=>{h(s)&&(U(a,!0),At().then(()=>{U(m,document.title||"untitled page",!0)}))});return U(s,!0),_});const R=x(()=>t.constructors[2]);var u=Gt(),p=L(u);{var y=_=>{const A=x(()=>t.constructors[0]);var T=V(),w=L(T);j(w,()=>h(A),(b,I)=>{S(I(b,{get data(){return n()},get form(){return t.form},get params(){return t.page.params},children:(f,Wt)=>{var K=V(),at=L(K);{var st=D=>{const q=x(()=>t.constructors[1]);var k=V(),F=L(k);j(F,()=>h(q),(G,Y)=>{S(Y(G,{get data(){return i()},get form(){return t.form},get params(){return t.page.params},children:(v,zt)=>{var M=V(),nt=L(M);j(nt,()=>h(R),(it,mt)=>{S(mt(it,{get data(){return c()},get form(){return t.form},get params(){return t.page.params}}),C=>e()[2]=C,()=>{var C;return(C=e())==null?void 0:C[2]})}),g(v,M)},$$slots:{default:!0}}),v=>e()[1]=v,()=>{var v;return(v=e())==null?void 0:v[1]})}),g(D,k)},ot=D=>{const q=x(()=>t.constructors[1]);var k=V(),F=L(k);j(F,()=>h(q),(G,Y)=>{S(Y(G,{get data(){return i()},get form(){return t.form},get params(){return t.page.params}}),v=>e()[1]=v,()=>{var v;return(v=e())==null?void 0:v[1]})}),g(D,k)};B(at,D=>{t.constructors[2]?D(st):D(ot,!1)})}g(f,K)},$$slots:{default:!0}}),f=>e()[0]=f,()=>{var f;return(f=e())==null?void 0:f[0]})}),g(_,T)},J=_=>{const A=x(()=>t.constructors[0]);var T=V(),w=L(T);j(w,()=>h(A),(b,I)=>{S(I(b,{get data(){return n()},get form(){return t.form},get params(){return t.page.params}}),f=>e()[0]=f,()=>{var f;return(f=e())==null?void 0:f[0]})}),g(_,T)};B(p,_=>{t.constructors[1]?_(y):_(J,!1)})}var E=Tt(p,2);{var O=_=>{var A=Ft(),T=Lt(A);{var w=b=>{var I=jt();Dt(()=>xt(I,h(m))),g(b,I)};B(T,b=>{h(a)&&b(w)})}It(A),g(_,A)};B(E,_=>{h(s)&&_(O)})}g(r,u),bt()}const se=Bt(Yt),oe=[()=>o(()=>import("../nodes/0.j0CpgSFp.js"),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25]),import.meta.url),()=>o(()=>import("../nodes/1.DEUqmURt.js"),__vite__mapDeps([26,1,20,3,4,5,18,2,16,17]),import.meta.url),()=>o(()=>import("../nodes/2.D-vKwnTC.js"),__vite__mapDeps([27,1,3,5,9,7]),import.meta.url),()=>o(()=>import("../nodes/3.Bu_uPddU.js"),__vite__mapDeps([28,1,20,3,2,17,16,18]),import.meta.url),()=>o(()=>import("../nodes/4.DYVet_v-.js"),__vite__mapDeps([29,1,2,3,4,5,6,7,10,13,24,19,16,8,30,15,23]),import.meta.url),()=>o(()=>import("../nodes/5.C0AYWqwr.js"),__vite__mapDeps([31,1,3,4,5,6,7,8,11,12,21,32,10,30,15,16,33]),import.meta.url),()=>o(()=>import("../nodes/6.54m-BxV_.js"),__vite__mapDeps([34,1,3,4,5,6,7,8,35,10,11,12,24,21,30,15,16,18,2,36]),import.meta.url),()=>o(()=>import("../nodes/7.2YrTacps.js"),__vite__mapDeps([37,1,2,3,4,5,6,7,8,10,13,11,12,21,23,38]),import.meta.url),()=>o(()=>import("../nodes/8.DGKslLJe.js"),__vite__mapDeps([39,1,3,4,5,6,7,8,10,11,12,21,13,24]),import.meta.url),()=>o(()=>import("../nodes/9.Vu2AXN40.js"),__vite__mapDeps([40,1,20,3,4,5,6,7,8,21,12,15,16,19,23,10,11,30,41]),import.meta.url),()=>o(()=>import("../nodes/10.CACwABbv.js"),__vite__mapDeps([42,1,2,3,4,5,6,7,8,10,11,12,21,13,32,15,16,18,14,30,23,20,24,19]),import.meta.url),()=>o(()=>import("../nodes/11.B_W3XFQr.js"),__vite__mapDeps([43,1,2,3,4,5,6,7,8,21,12,13,17,16,18,24,23,10,30,15]),import.meta.url),()=>o(()=>import("../nodes/12.DxkSrFsy.js"),__vite__mapDeps([44,1,2,3,4,5,6,7,8,11,12,24]),import.meta.url),()=>o(()=>import("../nodes/13.CD5qzYsO.js"),__vite__mapDeps([45,1,2,3,4,5,6,7,8,10,11,12,21,13,32,24,23,46]),import.meta.url),()=>o(()=>import("../nodes/14.DUh3SXOF.js"),__vite__mapDeps([47,1,2,3,4,5,6,7,8,10,11,12,21]),import.meta.url),()=>o(()=>import("../nodes/15.CyCv1LGV.js"),__vite__mapDeps([48,1,2,3,4,5,6,7,8,35,10,21,12,13,14,24,11,30,15,16,23,49]),import.meta.url),()=>o(()=>import("../nodes/16.Cth-SSqa.js"),__vite__mapDeps([50,1,2,3,4,5,6,7,8,11,12,24,10,21,30,15,16,23,51]),import.meta.url),()=>o(()=>import("../nodes/17.k6k7874Y.js"),__vite__mapDeps([52,1,2,3,4,5,6,7,8,11,12,21,15,16,24,19,22,23]),import.meta.url),()=>o(()=>import("../nodes/18.C60Wuzj2.js"),__vite__mapDeps([53,1,2,3,4,5,6,7,8,21,12,24]),import.meta.url),()=>o(()=>import("../nodes/19.BIUSI5ln.js"),__vite__mapDeps([54,1,2,3,4,5,6,7,8,21,12,32,24,23]),import.meta.url),()=>o(()=>import("../nodes/20.DebghJca.js"),__vite__mapDeps([55,1,2,3,4,5,6,7,8,35,10,11,12,21,13,32,14,18,16,56]),import.meta.url)],ne=[],ie={"/":[3],"/(app)/activation":[4,[2]],"/(app)/contradictions":[5,[2]],"/(app)/dreams":[6,[2]],"/(app)/duplicates":[7,[2]],"/(app)/explore":[8,[2]],"/(app)/feed":[9,[2]],"/(app)/graph":[10,[2]],"/(app)/importance":[11,[2]],"/(app)/intentions":[12,[2]],"/(app)/memories":[13,[2]],"/(app)/patterns":[14,[2]],"/(app)/reasoning":[15,[2]],"/(app)/schedule":[16,[2]],"/(app)/settings":[17,[2]],"/(app)/stats":[18,[2]],"/(app)/timeline":[19,[2]],"/waitlist":[20]},rt={handleError:(({error:r})=>{console.error(r)}),reroute:(()=>{}),transport:{}},Ht=Object.fromEntries(Object.entries(rt.transport).map(([r,t])=>[r,t.decode])),me=Object.fromEntries(Object.entries(rt.transport).map(([r,t])=>[r,t.encode])),ue=!1,_e=(r,t)=>Ht[r](t);export{_e as decode,Ht as decoders,ie as dictionary,me as encoders,ue as hash,rt as hooks,ae as matchers,oe as nodes,se as root,ne as server_loads}; diff --git a/apps/dashboard/build/_app/immutable/entry/app.Bv9rD2TH.js.br b/apps/dashboard/build/_app/immutable/entry/app.Bv9rD2TH.js.br new file mode 100644 index 0000000..3a1c082 Binary files /dev/null and b/apps/dashboard/build/_app/immutable/entry/app.Bv9rD2TH.js.br differ diff --git a/apps/dashboard/build/_app/immutable/entry/app.Bv9rD2TH.js.gz b/apps/dashboard/build/_app/immutable/entry/app.Bv9rD2TH.js.gz new file mode 100644 index 0000000..f6e5443 Binary files /dev/null and b/apps/dashboard/build/_app/immutable/entry/app.Bv9rD2TH.js.gz differ diff --git a/apps/dashboard/build/_app/immutable/entry/app.CYIcgKkt.js.br b/apps/dashboard/build/_app/immutable/entry/app.CYIcgKkt.js.br deleted file mode 100644 index 6e9072d..0000000 Binary files a/apps/dashboard/build/_app/immutable/entry/app.CYIcgKkt.js.br and /dev/null differ diff --git a/apps/dashboard/build/_app/immutable/entry/app.CYIcgKkt.js.gz b/apps/dashboard/build/_app/immutable/entry/app.CYIcgKkt.js.gz deleted file mode 100644 index 8b88be8..0000000 Binary files a/apps/dashboard/build/_app/immutable/entry/app.CYIcgKkt.js.gz and /dev/null differ diff --git a/apps/dashboard/build/_app/immutable/entry/start.HXOjGRUF.js b/apps/dashboard/build/_app/immutable/entry/start.HXOjGRUF.js new file mode 100644 index 0000000..a48a6e4 --- /dev/null +++ b/apps/dashboard/build/_app/immutable/entry/start.HXOjGRUF.js @@ -0,0 +1 @@ +import{a as r}from"../chunks/C2TQQEIa.js";import{w as t}from"../chunks/D8UfWY0j.js";export{t as load_css,r as start}; diff --git a/apps/dashboard/build/_app/immutable/entry/start.HXOjGRUF.js.br b/apps/dashboard/build/_app/immutable/entry/start.HXOjGRUF.js.br new file mode 100644 index 0000000..92478f2 Binary files /dev/null and b/apps/dashboard/build/_app/immutable/entry/start.HXOjGRUF.js.br differ diff --git a/apps/dashboard/build/_app/immutable/entry/start.HXOjGRUF.js.gz b/apps/dashboard/build/_app/immutable/entry/start.HXOjGRUF.js.gz new file mode 100644 index 0000000..9258abb Binary files /dev/null and b/apps/dashboard/build/_app/immutable/entry/start.HXOjGRUF.js.gz differ diff --git a/apps/dashboard/build/_app/immutable/entry/start.gT92nAJC.js b/apps/dashboard/build/_app/immutable/entry/start.gT92nAJC.js deleted file mode 100644 index f01b681..0000000 --- a/apps/dashboard/build/_app/immutable/entry/start.gT92nAJC.js +++ /dev/null @@ -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}; diff --git a/apps/dashboard/build/_app/immutable/entry/start.gT92nAJC.js.br b/apps/dashboard/build/_app/immutable/entry/start.gT92nAJC.js.br deleted file mode 100644 index 68a435b..0000000 Binary files a/apps/dashboard/build/_app/immutable/entry/start.gT92nAJC.js.br and /dev/null differ diff --git a/apps/dashboard/build/_app/immutable/entry/start.gT92nAJC.js.gz b/apps/dashboard/build/_app/immutable/entry/start.gT92nAJC.js.gz deleted file mode 100644 index 0b2d810..0000000 Binary files a/apps/dashboard/build/_app/immutable/entry/start.gT92nAJC.js.gz and /dev/null differ diff --git a/apps/dashboard/build/_app/immutable/nodes/0.COz2esg5.js b/apps/dashboard/build/_app/immutable/nodes/0.COz2esg5.js deleted file mode 100644 index f9dcc17..0000000 --- a/apps/dashboard/build/_app/immutable/nodes/0.COz2esg5.js +++ /dev/null @@ -1,86 +0,0 @@ -import"../chunks/Bzak7iHL.js";import{o as st}from"../chunks/GG5zm9kr.js";import{f as ce,e as o,d as s,r as t,t as N,p as We,n as se,g as e,a as Oe,s as ye,c as mt,h as A,u as j}from"../chunks/CpWkWWOo.js";import{s as _,d as Ye,a as re,e as Le}from"../chunks/BlVfL1ME.js";import{i as B}from"../chunks/B4yTwGkE.js";import{e as De,i as Ne}from"../chunks/CGEBXrjl.js";import{c as rt,a as u,f as m}from"../chunks/CHOnp4oo.js";import{s as Xe}from"../chunks/CJCPY1OL.js";import{s as be,r as ft}from"../chunks/A7po6GxK.js";import{s as G}from"../chunks/aVbAZ-t7.js";import{b as ht}from"../chunks/sZcqyNBA.js";import{b as gt}from"../chunks/CJsMJEun.js";import{a as H,s as Te}from"../chunks/C6HuKgyx.js";import{s as bt,g as Ze}from"../chunks/BHGLDPij.js";import{b as U}from"../chunks/BskPcZf7.js";import{s as nt,m as it,a as ot,e as xt,w as Je,u as kt,i as _t,f as yt}from"../chunks/MAY1QfFZ.js";import{i as wt}from"../chunks/BUoSzNdg.js";import{s as lt}from"../chunks/Cx-f-Pzo.js";import{t as ge}from"../chunks/BjdL4Pm2.js";import{a as Ue}from"../chunks/DNjM5a-l.js";import{d as $t,w as dt,g as ct}from"../chunks/BeMFXnHE.js";const Mt=()=>{const a=bt;return{page:{subscribe:a.page.subscribe},navigating:{subscribe:a.navigating.subscribe},updated:a.updated}},Ct={subscribe(a){return Mt().page.subscribe(a)}};var At=m('
');function Dt(a){const r=()=>H(nt,"$suppressedCount",i),[i,c]=Te();var f=rt(),M=ce(f);{var y=D=>{var x=At(),h=o(s(x),2),v=s(h);t(h),t(x),N(()=>_(v,`Actively forgetting ${r()??""} ${r()===1?"memory":"memories"}`)),u(D,x)};B(M,D=>{r()>0&&D(y)})}u(a,f),c()}var Tt=m(''),Et=m('
');function Ft(a,r){We(r,!1);const i=()=>H(ge,"$toasts",c),[c,f]=Te(),M={DreamCompleted:"✦",ConsolidationCompleted:"◉",ConnectionDiscovered:"⟷",MemoryPromoted:"↑",MemoryDemoted:"↓",MemorySuppressed:"◬",MemoryUnsuppressed:"◉",Rac1CascadeSwept:"✺",MemoryDeleted:"✕"};function y(v){return M[v]??"◆"}function D(v){ge.dismiss(v.id)}function x(v,l){(v.key==="Enter"||v.key===" ")&&(v.preventDefault(),ge.dismiss(l.id))}wt();var h=Et();De(h,5,i,v=>v.id,(v,l)=>{var b=Tt(),F=o(s(b),2),T=s(F),K=s(T),Q=s(K,!0);t(K);var V=o(K,2),X=s(V,!0);t(V),t(T);var Z=o(T,2),ne=s(Z,!0);t(Z),t(F),se(2),t(b),N(J=>{be(b,"aria-label",`${e(l).title??""}: ${e(l).body??""}. Click to dismiss.`),lt(b,`--toast-color: ${e(l).color??""}; --toast-dwell: ${e(l).dwellMs??""}ms;`),_(Q,J),_(X,e(l).title),_(ne,e(l).body)},[()=>y(e(l).type)]),re("click",b,()=>D(e(l))),re("keydown",b,J=>x(J,e(l))),Le("mouseenter",b,()=>ge.pauseDwell(e(l).id,e(l).dwellMs)),Le("mouseleave",b,()=>ge.resumeDwell(e(l).id)),Le("focus",b,()=>ge.pauseDwell(e(l).id,e(l).dwellMs)),Le("blur",b,()=>ge.resumeDwell(e(l).id)),u(v,b)}),t(h),u(a,h),Oe(),f()}Ye(["click","keydown"]);function we(a){const r=a.data;if(!r||typeof r!="object")return null;const i=r.timestamp??r.at??r.occurred_at;if(i==null)return null;if(typeof i=="number")return Number.isFinite(i)?i>1e12?i:i*1e3:null;if(typeof i!="string")return null;const c=Date.parse(i);return Number.isFinite(c)?c:null}const ze=10,vt=3e4,St=ze*vt;function It(a,r){const i=r-St,c=new Array(ze).fill(0);for(const M of a){if(M.type==="Heartbeat")continue;const y=we(M);if(y===null||yr)continue;const D=Math.min(ze-1,Math.floor((y-i)/vt));c[D]+=1}const f=Math.max(1,...c);return c.map(M=>({count:M,ratio:M/f}))}function Lt(a,r){const i=r-864e5;for(const c of a){if(c.type!=="DreamCompleted")continue;return(we(c)??r)>=i?c:null}return null}function Nt(a){if(!a||!a.data)return null;const r=a.data,i=typeof r.insights_generated=="number"?r.insights_generated:typeof r.insightsGenerated=="number"?r.insightsGenerated:null;return i!==null&&Number.isFinite(i)?i:null}function Rt(a,r){let i=null,c=null;for(const D of a)if(!i&&D.type==="DreamStarted"&&(i=D),!c&&D.type==="DreamCompleted"&&(c=D),i&&c)break;if(!i)return!1;const f=we(i)??r,M=r-300*1e3;return f=c}return!1}var Vt=m(' at risk',1),Bt=m('0 at risk',1),Gt=m(' at risk',1),Ht=m(' intentions',1),qt=m('— intentions'),zt=m('· insights',1),Pt=m(' Last dream: ',1),Wt=m('No recent dream'),Ot=m('
'),Yt=m('
DREAMING...
',1),Qt=m(''),Xt=m('
memories · avg retention
');function Zt(a,r){We(r,!0);const i=()=>H(ot,"$avgRetention",M),c=()=>H(xt,"$eventFeed",M),f=()=>H(it,"$memoryCount",M),[M,y]=Te(),D=j(()=>Math.round((i()??0)*100)),x=j(()=>(i()??0)>=.5);let h=ye(null);async function v(){try{const n=await Ue.retentionDistribution();if(Array.isArray(n.endangered)&&n.endangered.length>0){A(h,n.endangered.length,!0);return}const d=n.distribution??[];let $=0;for(const S of d){const W=/^(\d+)/.exec(S.range);if(!W)continue;const O=Number.parseInt(W[1],10);Number.isFinite(O)&&O<30&&($+=S.count??0)}A(h,$,!0)}catch{A(h,null)}}let l=ye(null);async function b(){var n;try{const d=await Ue.intentions("active");A(l,d.total??((n=d.intentions)==null?void 0:n.length)??0,!0)}catch{A(l,null)}}let F=ye(mt(Date.now()));const T=j(()=>{const n=c(),d=Lt(n,e(F)),$=d?we(d)??e(F):null,S=$!==null?e(F)-$:null;return{isDreaming:Rt(n,e(F)),recent:d,recentMsAgo:S,insights:Nt(d)}}),K=j(()=>It(c(),e(F))),Q=j(()=>Kt(c(),e(F)));st(()=>{v(),b();const n=setInterval(()=>{A(F,Date.now(),!0)},1e3),d=setInterval(()=>{v(),b()},6e4);return()=>{clearInterval(n),clearInterval(d)}});var V=Xt();let X;var Z=s(V),ne=s(Z),J=s(ne);let Ee;var Re=o(J,2);let Fe;t(ne);var Me=o(ne,2),w=s(Me,!0);t(Me);var k=o(Me,6);let p;var z=s(k);t(k),se(2),t(Z);var L=o(Z,4),I=s(L);{var ie=n=>{var d=Vt(),$=ce(d),S=s($,!0);t($),se(2),N(()=>_(S,e(h))),u(n,d)},Ce=n=>{var d=Bt();se(2),u(n,d)},xe=n=>{var d=Gt();se(2),u(n,d)};B(I,n=>{e(h)!==null&&e(h)>0?n(ie):e(h)===0?n(Ce,1):n(xe,!1)})}t(L);var g=o(L,4),q=s(g);{var P=n=>{var d=Ht(),$=ce(d);let S;var W=o($,2);let O;var Y=s(W,!0);t(W),se(2),N(()=>{S=G($,1,"inline-flex h-2 w-2 rounded-full svelte-1kk3799",null,S,{"bg-node-pattern":e(l)>5,"animate-ping-slow":e(l)>5,"bg-muted":e(l)<=5}),O=G(W,1,"tabular-nums svelte-1kk3799",null,O,{"text-node-pattern":e(l)>5,"text-text":e(l)>0&&e(l)<=5,"text-muted":e(l)===0}),_(Y,e(l))}),u(n,d)},ve=n=>{var d=qt();u(n,d)};B(q,n=>{e(l)!==null?n(P):n(ve,!1)})}t(g);var oe=o(g,4),pe=s(oe);{var ue=n=>{var d=Pt(),$=o(ce(d),4),S=s($,!0);t($);var W=o($,2);{var O=Y=>{var Ae=zt(),Ie=o(ce(Ae),2),Be=s(Ie,!0);t(Ie),se(2),N(()=>_(Be,e(T).insights)),u(Y,Ae)};B(W,Y=>{e(T).insights!==null&&Y(O)})}N(Y=>_(S,Y),[()=>jt(e(T).recentMsAgo)]),u(n,d)},le=n=>{var d=Wt();u(n,d)};B(pe,n=>{e(T).recent&&e(T).recentMsAgo!==null?n(ue):n(le,!1)})}t(oe);var me=o(oe,4),ke=o(s(me),2);De(ke,21,()=>e(K),Ne,(n,d)=>{var $=Ot();N(S=>lt($,`height: ${S??""}%; opacity: ${e(d).count===0?.18:.5+e(d).ratio*.5};`),[()=>Math.max(10,e(d).ratio*100)]),u(n,$)}),t(ke),t(me);var Se=o(me,2);{var je=n=>{var d=Yt();se(2),u(n,d)};B(Se,n=>{e(T).isDreaming&&n(je)})}var Ke=o(Se,4);{var Ve=n=>{var d=Qt();u(n,d)};B(Ke,n=>{e(Q)&&n(Ve)})}t(V),N(()=>{X=G(V,1,"ambient-strip relative flex h-9 w-full items-center gap-0 overflow-hidden border-b border-synapse/15 bg-black/40 px-3 text-[11px] text-dim backdrop-blur-md svelte-1kk3799",null,X,{"ambient-flash":e(Q)}),Ee=G(J,1,"absolute inline-flex h-full w-full animate-ping rounded-full opacity-75 svelte-1kk3799",null,Ee,{"bg-recall":e(x),"bg-warning":!e(x)}),Fe=G(Re,1,"relative inline-flex h-2 w-2 rounded-full svelte-1kk3799",null,Fe,{"bg-recall":e(x),"bg-warning":!e(x)}),_(w,f()),p=G(k,1,"svelte-1kk3799",null,p,{"text-recall":e(x),"text-warning":!e(x)}),_(z,`${e(D)??""}%`)}),u(a,V),Oe(),y()}const pt="vestige.theme",et="vestige-theme-light",$e=dt("dark"),Pe=dt(!0),tt=$t([$e,Pe],([a,r])=>a==="auto"?r?"dark":"light":a);function Jt(a){return a==="dark"||a==="light"||a==="auto"}function Ut(a){if(Jt(a)){$e.set(a);try{localStorage.setItem(pt,a)}catch{}}}function qe(){const a=ct($e);Ut(a==="dark"?"light":a==="light"?"auto":"dark")}function ea(){if(document.getElementById(et))return;const a=document.createElement("style");a.id=et,a.textContent=` -/* Vestige light-mode overrides — injected by theme.ts. - * Activated by [data-theme='light'] on . - * Tokens mirror the real names used in app.css so the cascade stays clean. */ -[data-theme='light'] { - /* Core surface palette (slate scale) */ - --color-void: #f8fafc; /* slate-50 — page background */ - --color-abyss: #f1f5f9; /* slate-100 */ - --color-deep: #e2e8f0; /* slate-200 */ - --color-surface: #f1f5f9; /* slate-100 */ - --color-elevated: #e2e8f0; /* slate-200 */ - --color-subtle: #cbd5e1; /* slate-300 */ - --color-muted: #94a3b8; /* slate-400 */ - --color-dim: #475569; /* slate-600 */ - --color-text: #0f172a; /* slate-900 */ - --color-bright: #020617; /* slate-950 */ -} - -/* Baseline body/html wiring — app.css sets these against the dark - * tokens; we just let the variables do the work. Reassert for clarity. */ -[data-theme='light'] html, -html[data-theme='light'] { - background: var(--color-void); - color: var(--color-text); -} - -/* Glass surfaces — recompose on a light canvas. The original alphas - * are tuned for dark; invert-and-tint for light so panels still read - * as elevated instead of vanishing. */ -[data-theme='light'] .glass { - background: rgba(255, 255, 255, 0.65); - border: 1px solid rgba(99, 102, 241, 0.12); - box-shadow: - inset 0 1px 0 0 rgba(255, 255, 255, 0.6), - 0 4px 24px rgba(15, 23, 42, 0.08); -} -[data-theme='light'] .glass-subtle { - background: rgba(255, 255, 255, 0.55); - border: 1px solid rgba(99, 102, 241, 0.1); - box-shadow: - inset 0 1px 0 0 rgba(255, 255, 255, 0.5), - 0 2px 12px rgba(15, 23, 42, 0.06); -} -[data-theme='light'] .glass-sidebar { - background: rgba(248, 250, 252, 0.82); - border-right: 1px solid rgba(99, 102, 241, 0.14); - box-shadow: - inset -1px 0 0 0 rgba(255, 255, 255, 0.4), - 4px 0 24px rgba(15, 23, 42, 0.08); -} -[data-theme='light'] .glass-panel { - background: rgba(255, 255, 255, 0.75); - border: 1px solid rgba(99, 102, 241, 0.14); - box-shadow: - inset 0 1px 0 0 rgba(255, 255, 255, 0.5), - 0 8px 32px rgba(15, 23, 42, 0.1); -} - -/* Halve glow intensity — neon accents stay recognizable without - * washing out on slate-50. */ -[data-theme='light'] .glow-synapse { - box-shadow: 0 0 10px rgba(99, 102, 241, 0.15), 0 0 30px rgba(99, 102, 241, 0.05); -} -[data-theme='light'] .glow-dream { - box-shadow: 0 0 10px rgba(168, 85, 247, 0.15), 0 0 30px rgba(168, 85, 247, 0.05); -} -[data-theme='light'] .glow-memory { - box-shadow: 0 0 10px rgba(59, 130, 246, 0.15), 0 0 30px rgba(59, 130, 246, 0.05); -} - -/* Ambient orbs are gorgeous on black and blinding on white. Tame them. */ -[data-theme='light'] .ambient-orb { - opacity: 0.18; - filter: blur(100px); -} - -/* Scrollbar recolor for the lighter surface. */ -[data-theme='light'] ::-webkit-scrollbar-thumb { - background: #cbd5e1; -} -[data-theme='light'] ::-webkit-scrollbar-thumb:hover { - background: #94a3b8; -} -`,document.head.appendChild(a)}function at(a){document.documentElement.dataset.theme=a}let ee=null,de=null,te=null,ae=null;function ta(){ee&&de&&ee.removeEventListener("change",de),ae==null||ae(),te==null||te(),ee=null,de=null,ae=null,te=null,ea();let a="dark";try{const r=localStorage.getItem(pt);(r==="dark"||r==="light"||r==="auto")&&(a=r)}catch{}return $e.set(a),ee=window.matchMedia("(prefers-color-scheme: dark)"),Pe.set(ee.matches),de=r=>Pe.set(r.matches),ee.addEventListener("change",de),at(ct(tt)),ae=tt.subscribe(at),te=$e.subscribe(()=>{}),()=>{ee&&de&&ee.removeEventListener("change",de),ee=null,de=null,ae==null||ae(),te==null||te(),ae=null,te=null}}var aa=m('');function sa(a){const r=()=>H($e,"$theme",i),[i,c]=Te(),f={dark:"Dark",light:"Light",auto:"Auto (system)"},M={dark:"light",light:"auto",auto:"dark"};let y=j(r),D=j(()=>M[e(y)]),x=j(()=>`Toggle theme: ${f[e(y)]} (click for ${f[e(D)]})`);var h=aa(),v=s(h),l=s(v);let b;var F=o(l,2);let T;var K=o(F,2);let Q;t(v),t(h),N(()=>{be(h,"aria-label",e(x)),be(h,"title",e(x)),be(h,"data-mode",e(y)),b=G(l,0,"icon svelte-1cmi4dh",null,b,{active:e(y)==="dark"}),T=G(F,0,"icon svelte-1cmi4dh",null,T,{active:e(y)==="light"}),Q=G(K,0,"icon svelte-1cmi4dh",null,Q,{active:e(y)==="auto"})}),re("click",h,function(...V){qe==null||qe.apply(this,V)}),u(a,h),c()}Ye(["click"]);var ra=m(' '),na=m('
'),ia=m(''),oa=m(' '),la=m('
',1),da=m(''),ca=m('
No matches
'),va=m('
esc
'),pa=m(" ",1);function La(a,r){We(r,!0);const i=()=>H(Ct,"$page",x),c=()=>H(_t,"$isConnected",x),f=()=>H(it,"$memoryCount",x),M=()=>H(ot,"$avgRetention",x),y=()=>H(kt,"$uptimeSeconds",x),D=()=>H(nt,"$suppressedCount",x),[x,h]=Te();let v=ye(!1),l=ye(""),b=ye(void 0),F=j(()=>i().url.pathname.startsWith(U)?i().url.pathname.slice(U.length)||"/":i().url.pathname),T=j(()=>e(F)==="/waitlist"||e(F).startsWith("/waitlist/"));st(()=>{e(T)||Je.connect();const w=ta();function k(p){if(e(T))return;if((p.metaKey||p.ctrlKey)&&p.key==="k"){p.preventDefault(),A(v,!e(v)),A(l,""),e(v)&&requestAnimationFrame(()=>{var I;return(I=e(b))==null?void 0:I.focus()});return}if(p.key==="Escape"&&e(v)){A(v,!1);return}if(p.target instanceof HTMLInputElement||p.target instanceof HTMLTextAreaElement)return;if(p.key==="/"){p.preventDefault();const I=document.querySelector('input[type="text"]');I==null||I.focus();return}const L={g:"/graph",m:"/memories",t:"/timeline",f:"/feed",e:"/explore",i:"/intentions",s:"/stats",r:"/reasoning",a:"/activation",d:"/dreams",c:"/schedule",p:"/importance",u:"/duplicates",x:"/contradictions",n:"/patterns"}[p.key.toLowerCase()];L&&!p.metaKey&&!p.ctrlKey&&!p.altKey&&(p.preventDefault(),Ze(`${U}${L}`))}return window.addEventListener("keydown",k),()=>{Je.disconnect(),window.removeEventListener("keydown",k),w()}});const K=[{href:"/graph",label:"Graph",icon:"◎",shortcut:"G"},{href:"/reasoning",label:"Reasoning",icon:"✦",shortcut:"R"},{href:"/memories",label:"Memories",icon:"◈",shortcut:"M"},{href:"/timeline",label:"Timeline",icon:"◷",shortcut:"T"},{href:"/feed",label:"Feed",icon:"◉",shortcut:"F"},{href:"/explore",label:"Explore",icon:"◬",shortcut:"E"},{href:"/activation",label:"Activation",icon:"◈",shortcut:"A"},{href:"/dreams",label:"Dreams",icon:"✧",shortcut:"D"},{href:"/schedule",label:"Schedule",icon:"◷",shortcut:"C"},{href:"/importance",label:"Importance",icon:"◎",shortcut:"P"},{href:"/duplicates",label:"Duplicates",icon:"◉",shortcut:"U"},{href:"/contradictions",label:"Contradictions",icon:"⚠",shortcut:"X"},{href:"/patterns",label:"Patterns",icon:"▦",shortcut:"N"},{href:"/intentions",label:"Intentions",icon:"◇",shortcut:"I"},{href:"/stats",label:"Stats",icon:"◫",shortcut:"S"},{href:"/settings",label:"Settings",icon:"⚙",shortcut:","}],Q=K.slice(0,5);function V(w,k){const p=k.startsWith(U)?k.slice(U.length)||"/":k;return w==="/graph"?p==="/"||p==="/graph":p.startsWith(w)}let X=j(()=>e(l)?K.filter(w=>w.label.toLowerCase().includes(e(l).toLowerCase())):K);function Z(w){A(v,!1),A(l,""),Ze(`${U}${w}`)}var ne=pa(),J=ce(ne);{var Ee=w=>{var k=rt(),p=ce(k);Xe(p,()=>r.children),u(w,k)},Re=w=>{var k=la(),p=o(ce(k),6),z=s(p),L=s(z),I=o(L,2);De(I,21,()=>K,Ne,(C,E)=>{const fe=j(()=>V(e(E).href,i().url.pathname));var R=ra(),he=s(R),Ge=s(he,!0);t(he);var _e=o(he,2),He=s(_e,!0);t(_e);var Qe=o(_e,2),ut=s(Qe,!0);t(Qe),t(R),N(()=>{be(R,"href",`${U??""}${e(E).href??""}`),G(R,1,`flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-200 text-sm - ${e(fe)?"bg-synapse/15 text-synapse-glow border border-synapse/30 shadow-[0_0_12px_rgba(99,102,241,0.15)] nav-active-border":"text-dim hover:text-text hover:bg-white/[0.03] border border-transparent"}`),_(Ge,e(E).icon),_(He,e(E).label),_(ut,e(E).shortcut)}),u(C,R)}),t(I);var ie=o(I,2),Ce=s(ie);t(ie);var xe=o(ie,2),g=s(xe),q=s(g),P=o(q,2),ve=s(P,!0);t(P);var oe=o(P,2),pe=s(oe);sa(pe),t(oe),t(g);var ue=o(g,2),le=s(ue),me=s(le);t(le);var ke=o(le,2),Se=s(ke);t(ke);var je=o(ke,2);{var Ke=C=>{var E=na(),fe=s(E);t(E),N(R=>_(fe,`up ${R??""}`),[()=>yt(y())]),u(C,E)};B(je,C=>{y()>0&&C(Ke)})}t(ue);var Ve=o(ue,2);{var n=C=>{var E=ia(),fe=s(E);Dt(fe),t(E),u(C,E)};B(Ve,C=>{D()>0&&C(n)})}t(xe),t(z);var d=o(z,2),$=s(d);Zt($,{});var S=o($,2),W=s(S);Xe(W,()=>r.children),t(S),t(d);var O=o(d,2),Y=s(O),Ae=s(Y);De(Ae,17,()=>Q,Ne,(C,E)=>{const fe=j(()=>V(e(E).href,i().url.pathname));var R=oa(),he=s(R),Ge=s(he,!0);t(he);var _e=o(he,2),He=s(_e,!0);t(_e),t(R),N(()=>{be(R,"href",`${U??""}${e(E).href??""}`),G(R,1,`flex flex-col items-center gap-0.5 px-3 py-2 rounded-lg transition-all min-w-[3.5rem] - ${e(fe)?"text-synapse-glow":"text-muted"}`),_(Ge,e(E).icon),_(He,e(E).label)}),u(C,R)});var Ie=o(Ae,2);t(Y),t(O),t(p);var Be=o(p,2);Ft(Be,{}),N(C=>{be(L,"href",`${U??""}/graph`),G(q,1,`w-2 h-2 rounded-full ${c()?"bg-recall animate-pulse-glow":"bg-decay"}`),_(ve,c()?"Connected":"Offline"),_(me,`${f()??""} memories`),_(Se,`${C??""}% retention`)},[()=>(M()*100).toFixed(0)]),re("click",Ce,()=>{A(v,!0),A(l,""),requestAnimationFrame(()=>{var C;return(C=e(b))==null?void 0:C.focus()})}),re("click",Ie,()=>{A(v,!0),A(l,""),requestAnimationFrame(()=>{var C;return(C=e(b))==null?void 0:C.focus()})}),u(w,k)};B(J,w=>{e(T)?w(Ee):w(Re,!1)})}var Fe=o(J,2);{var Me=w=>{var k=va(),p=s(k),z=s(p),L=o(s(z),2);ft(L),gt(L,g=>A(b,g),()=>e(b)),se(2),t(z);var I=o(z,2),ie=s(I);De(ie,17,()=>e(X),Ne,(g,q)=>{var P=da(),ve=s(P),oe=s(ve,!0);t(ve);var pe=o(ve,2),ue=s(pe,!0);t(pe);var le=o(pe,2),me=s(le,!0);t(le),t(P),N(()=>{_(oe,e(q).icon),_(ue,e(q).label),_(me,e(q).shortcut)}),re("click",P,()=>Z(e(q).href)),u(g,P)});var Ce=o(ie,2);{var xe=g=>{var q=ca();u(g,q)};B(Ce,g=>{e(X).length===0&&g(xe)})}t(I),t(p),t(k),re("keydown",k,g=>{g.key==="Escape"&&A(v,!1)}),re("click",k,g=>{g.target===g.currentTarget&&A(v,!1)}),re("keydown",L,g=>{g.key==="Enter"&&e(X).length>0&&Z(e(X)[0].href)}),ht(L,()=>e(l),g=>A(l,g)),u(w,k)};B(Fe,w=>{e(v)&&!e(T)&&w(Me)})}u(a,ne),Oe(),h()}Ye(["click","keydown"]);export{La as component}; diff --git a/apps/dashboard/build/_app/immutable/nodes/0.COz2esg5.js.br b/apps/dashboard/build/_app/immutable/nodes/0.COz2esg5.js.br deleted file mode 100644 index 830faa7..0000000 Binary files a/apps/dashboard/build/_app/immutable/nodes/0.COz2esg5.js.br and /dev/null differ diff --git a/apps/dashboard/build/_app/immutable/nodes/0.COz2esg5.js.gz b/apps/dashboard/build/_app/immutable/nodes/0.COz2esg5.js.gz deleted file mode 100644 index aa16a20..0000000 Binary files a/apps/dashboard/build/_app/immutable/nodes/0.COz2esg5.js.gz and /dev/null differ diff --git a/apps/dashboard/build/_app/immutable/nodes/0.j0CpgSFp.js b/apps/dashboard/build/_app/immutable/nodes/0.j0CpgSFp.js new file mode 100644 index 0000000..c8a2beb --- /dev/null +++ b/apps/dashboard/build/_app/immutable/nodes/0.j0CpgSFp.js @@ -0,0 +1,86 @@ +import"../chunks/Bzak7iHL.js";import{o as Xe}from"../chunks/GG5zm9kr.js";import{f as le,e as s,d as a,r as t,t as I,p as We,n as fe,g as e,a as qe,s as ne,c as ht,h,u as q}from"../chunks/CpWkWWOo.js";import{s as m,d as ze,a as re,e as Ge}from"../chunks/BlVfL1ME.js";import{i as G}from"../chunks/B4yTwGkE.js";import{e as Ie,i as Ve}from"../chunks/CGEBXrjl.js";import{c as Ze,a as v,f as g,t as et}from"../chunks/CHOnp4oo.js";import{s as tt}from"../chunks/CJCPY1OL.js";import{s as we,r as gt}from"../chunks/A7po6GxK.js";import{s as te}from"../chunks/aVbAZ-t7.js";import{b as bt}from"../chunks/sZcqyNBA.js";import{b as xt}from"../chunks/CJsMJEun.js";import{a as oe,s as He}from"../chunks/C6HuKgyx.js";import{s as kt,g as at}from"../chunks/C2TQQEIa.js";import{b as xe}from"../chunks/D8UfWY0j.js";import{s as lt,m as ot,a as dt,e as _t,w as st,u as yt,i as wt,f as $t}from"../chunks/MAY1QfFZ.js";import{i as At}from"../chunks/BUoSzNdg.js";import{s as ct}from"../chunks/Cx-f-Pzo.js";import{t as De}from"../chunks/D4ymNiig.js";import{a as Ke}from"../chunks/B7CfdQuM.js";import{d as Ct,w as vt,g as pt}from"../chunks/BeMFXnHE.js";const Mt=()=>{const n=kt;return{page:{subscribe:n.page.subscribe},navigating:{subscribe:n.navigating.subscribe},updated:n.updated}},Tt={subscribe(n){return Mt().page.subscribe(n)}};var Et=g('
');function St(n){const l=()=>oe(lt,"$suppressedCount",o),[o,i]=He();var x=Ze(),T=le(x);{var y=N=>{var C=Et(),$=s(a(C),2),p=a($);t($),t(C),I(()=>m(p,`Actively forgetting ${l()??""} ${l()===1?"memory":"memories"}`)),v(N,C)};G(T,N=>{l()>0&&N(y)})}v(n,x),i()}var jt=g(''),Dt=g('
');function It(n,l){We(l,!1);const o=()=>oe(De,"$toasts",i),[i,x]=He(),T={DreamCompleted:"✦",ConsolidationCompleted:"◉",ConnectionDiscovered:"⟷",MemoryPromoted:"↑",MemoryDemoted:"↓",MemorySuppressed:"◬",MemoryUnsuppressed:"◉",Rac1CascadeSwept:"✺",MemoryDeleted:"✕",HookVerdictRecorded:"⚑"};function y(p){return T[p]??"◆"}function N(p){De.dismiss(p.id)}function C(p,c){(p.key==="Enter"||p.key===" ")&&(p.preventDefault(),De.dismiss(c.id))}At();var $=Dt();Ie($,5,o,p=>p.id,(p,c)=>{var E=jt(),L=s(a(E),2),V=a(L),Z=a(V),z=a(Z,!0);t(Z);var ae=s(Z,2),de=a(ae,!0);t(ae),t(V);var u=s(V,2),f=a(u,!0);t(u),t(L),fe(2),t(E),I(M=>{we(E,"aria-label",`${e(c).title??""}: ${e(c).body??""}. Click to dismiss.`),ct(E,`--toast-color: ${e(c).color??""}; --toast-dwell: ${e(c).dwellMs??""}ms;`),m(z,M),m(de,e(c).title),m(f,e(c).body)},[()=>y(e(c).type)]),re("click",E,()=>N(e(c))),re("keydown",E,M=>C(M,e(c))),Ge("mouseenter",E,()=>De.pauseDwell(e(c).id,e(c).dwellMs)),Ge("mouseleave",E,()=>De.resumeDwell(e(c).id)),Ge("focus",E,()=>De.pauseDwell(e(c).id,e(c).dwellMs)),Ge("blur",E,()=>De.resumeDwell(e(c).id)),v(p,E)}),t($),v(n,$),qe(),x()}ze(["click","keydown"]);function Be(n){const l=n.data;if(!l||typeof l!="object")return null;const o=l.timestamp??l.at??l.occurred_at;if(o==null)return null;if(typeof o=="number")return Number.isFinite(o)?o>1e12?o:o*1e3:null;if(typeof o!="string")return null;const i=Date.parse(o);return Number.isFinite(i)?i:null}const Qe=10,ut=3e4,Ft=Qe*ut;function Nt(n,l){const o=l-Ft,i=new Array(Qe).fill(0);for(const T of n){if(T.type==="Heartbeat")continue;const y=Be(T);if(y===null||yl)continue;const N=Math.min(Qe-1,Math.floor((y-o)/ut));i[N]+=1}const x=Math.max(1,...i);return i.map(T=>({count:T,ratio:T/x}))}function Lt(n,l){const o=l-864e5;for(const i of n){if(i.type!=="DreamCompleted")continue;return(Be(i)??l)>=o?i:null}return null}function Vt(n){if(!n||!n.data)return null;const l=n.data,o=typeof l.insights_generated=="number"?l.insights_generated:typeof l.insightsGenerated=="number"?l.insightsGenerated:null;return o!==null&&Number.isFinite(o)?o:null}function Bt(n,l){let o=null,i=null;for(const N of n)if(!o&&N.type==="DreamStarted"&&(o=N),!i&&N.type==="DreamCompleted"&&(i=N),o&&i)break;if(!o)return!1;const x=Be(o)??l,T=l-300*1e3;return x=i}return!1}var Pt=g(' at risk',1),Kt=g('0 at risk',1),Ht=g(' at risk',1),Gt=g(' intentions',1),Wt=g('— intentions'),qt=g('· insights',1),zt=g(' Last dream: ',1),Yt=g('No recent dream'),Qt=g('
'),Ut=g('telemetry unavailable'),Xt=g('· fail-open',1),Zt=g(' vetoes · appeals ',1),Jt=g('
DREAMING...
',1),ea=g(''),ta=g('
memories · avg retention
');function aa(n,l){We(l,!0);const o=()=>oe(dt,"$avgRetention",T),i=()=>oe(_t,"$eventFeed",T),x=()=>oe(ot,"$memoryCount",T),[T,y]=He(),N=q(()=>Math.round((o()??0)*100)),C=q(()=>(o()??0)>=.5);let $=ne(null);async function p(){try{const r=await Ke.retentionDistribution();if(Array.isArray(r.endangered)&&r.endangered.length>0){h($,r.endangered.length,!0);return}const d=r.distribution??[];let A=0;for(const R of d){const b=/^(\d+)/.exec(R.range);if(!b)continue;const w=Number.parseInt(b[1],10);Number.isFinite(w)&&w<30&&(A+=R.count??0)}h($,A,!0)}catch{h($,null)}}let c=ne(null);async function E(){var r;try{const d=await Ke.intentions("active");h(c,d.total??((r=d.intentions)==null?void 0:r.length)??0,!0)}catch{h(c,null)}}let L=ne(ht(Date.now()));const V=q(()=>{const r=i(),d=Lt(r,e(L)),A=d?Be(d)??e(L):null,R=A!==null?e(L)-A:null;return{isDreaming:Bt(r,e(L)),recent:d,recentMsAgo:R,insights:Vt(d)}}),Z=q(()=>Nt(i(),e(L)));let z=ne(null),ae=ne(!1);async function de(){try{h(z,await Ke.sanhedrin.telemetry(7),!0),h(ae,!1)}catch{h(z,null),h(ae,!0)}}const u=q(()=>Ot(i(),e(L)));Xe(()=>{p(),E(),de();const r=setInterval(()=>{h(L,Date.now(),!0)},1e3),d=setInterval(()=>{p(),E(),de()},6e4);return()=>{clearInterval(r),clearInterval(d)}});var f=ta();let M;var he=a(f),ge=a(he),Fe=a(ge);let $e;var j=s(Fe,2);let D;t(ge);var k=s(ge,2),se=a(k,!0);t(k);var Y=s(k,6);let P;var pe=a(Y);t(Y),fe(2),t(he);var S=s(he,4),F=a(S);{var _=r=>{var d=Pt(),A=le(d),R=a(A,!0);t(A),fe(2),I(()=>m(R,e($))),v(r,d)},K=r=>{var d=Kt();fe(2),v(r,d)},X=r=>{var d=Ht();fe(2),v(r,d)};G(F,r=>{e($)!==null&&e($)>0?r(_):e($)===0?r(K,1):r(X,!1)})}t(S);var ce=s(S,4),ue=a(ce);{var me=r=>{var d=Gt(),A=le(d);let R;var b=s(A,2);let w;var O=a(b,!0);t(b),fe(2),I(()=>{R=te(A,1,"inline-flex h-2 w-2 rounded-full svelte-1kk3799",null,R,{"bg-node-pattern":e(c)>5,"animate-ping-slow":e(c)>5,"bg-muted":e(c)<=5}),w=te(b,1,"tabular-nums svelte-1kk3799",null,w,{"text-node-pattern":e(c)>5,"text-text":e(c)>0&&e(c)<=5,"text-muted":e(c)===0}),m(O,e(c))}),v(r,d)},be=r=>{var d=Wt();v(r,d)};G(ue,r=>{e(c)!==null?r(me):r(be,!1)})}t(ce);var J=s(ce,4),Ae=a(J);{var Ce=r=>{var d=zt(),A=s(le(d),4),R=a(A,!0);t(A);var b=s(A,2);{var w=O=>{var H=qt(),W=s(le(H),2),ee=a(W,!0);t(W),fe(2),I(()=>m(ee,e(V).insights)),v(O,H)};G(b,O=>{e(V).insights!==null&&O(w)})}I(O=>m(R,O),[()=>Rt(e(V).recentMsAgo)]),v(r,d)},Ne=r=>{var d=Yt();v(r,d)};G(Ae,r=>{e(V).recent&&e(V).recentMsAgo!==null?r(Ce):r(Ne,!1)})}t(J);var Te=s(J,4),Ee=s(a(Te),2);Ie(Ee,21,()=>e(Z),Ve,(r,d)=>{var A=Qt();I(R=>ct(A,`height: ${R??""}%; opacity: ${e(d).count===0?.18:.5+e(d).ratio*.5};`),[()=>Math.max(10,e(d).ratio*100)]),v(r,A)}),t(Ee),t(Te);var Se=s(Te,4),Oe=s(a(Se),2);{var je=r=>{var d=Ut();v(r,d)},Le=r=>{var d=Zt(),A=le(d),R=a(A,!0);t(A);var b=s(A,6),w=a(b,!0);t(b);var O=s(b,4);{var H=W=>{var ee=Xt(),ie=s(le(ee),2),Pe=a(ie,!0);t(ie),fe(2),I(()=>m(Pe,e(z).failOpen)),v(W,ee)};G(O,W=>{var ee;(ee=e(z))!=null&&ee.failOpen&&W(H)})}I(()=>{var W,ee,ie;m(R,((ee=(W=e(z))==null?void 0:W.byVerdict)==null?void 0:ee.VETO)??"—"),m(w,((ie=e(z))==null?void 0:ie.appeals)??"—")}),v(r,d)};G(Oe,r=>{e(ae)?r(je):r(Le,!1)})}t(Se);var B=s(Se,2);{var Q=r=>{var d=Jt();fe(2),v(r,d)};G(B,r=>{e(V).isDreaming&&r(Q)})}var U=s(B,4);{var ve=r=>{var d=ea();v(r,d)};G(U,r=>{e(u)&&r(ve)})}t(f),I(()=>{M=te(f,1,"ambient-strip relative flex h-9 w-full items-center gap-0 overflow-hidden border-b border-synapse/15 bg-black/40 px-3 text-[11px] text-dim backdrop-blur-md svelte-1kk3799",null,M,{"ambient-flash":e(u)}),$e=te(Fe,1,"absolute inline-flex h-full w-full animate-ping rounded-full opacity-75 svelte-1kk3799",null,$e,{"bg-recall":e(C),"bg-warning":!e(C)}),D=te(j,1,"relative inline-flex h-2 w-2 rounded-full svelte-1kk3799",null,D,{"bg-recall":e(C),"bg-warning":!e(C)}),m(se,x()),P=te(Y,1,"svelte-1kk3799",null,P,{"text-recall":e(C),"text-warning":!e(C)}),m(pe,`${e(N)??""}%`)}),v(n,f),qe(),y()}var sa=g(" "),ra=g('
  • '),na=g(' ',1),ia=g('

    Appeal recorded.

    '),la=g('

    No appealable veto in this receipt.

    '),oa=g('
    Claim

    Verdict

    Precedent
      Fix

      Appeal
      '),da=g('
      ');function ca(n,l){We(l,!0);const o=["PASS","NOTE","CAUTION","VETO","APPEALED"];let i=ne(null),x=ne(""),T=ne(!1),y=ne(null),N=ne(null),C=q(()=>{var u;return((u=e(i))==null?void 0:u.verdictBar)??(e(x)?"CAUTION":"NOTE")}),$=q(()=>{var u,f;return((u=e(i))==null?void 0:u.claims.find(M=>M.decision==="veto"))??((f=e(i))==null?void 0:f.claims.find(M=>M.decision==="appealed"))??null}),p=q(()=>{var u;return e($)??((u=e(i))==null?void 0:u.claims[0])??null}),c=q(()=>!!e(i)||!!e(x));Xe(()=>{E();const u=window.setInterval(E,4e3);return()=>window.clearInterval(u)});async function E(){var u;try{const f=await Ke.sanhedrin.latest();h(i,f.receipt,!0),h(x,""),((u=f.receipt)==null?void 0:u.verdictBar)==="VETO"&&f.receipt.id!==e(N)&&(h(T,!0),h(N,f.receipt.id,!0))}catch(f){h(x,f instanceof Error?f.message:String(f),!0)}}async function L(u){var f;if(!(!e($)||((f=e(i))==null?void 0:f.verdictBar)!=="VETO")){h(y,u,!0);try{const M=await Ke.sanhedrin.appeal(u,void 0,e($).id,e(i).id);h(i,M.receipt,!0),h(T,!0),h(x,"")}catch(M){h(x,M instanceof Error?M.message:String(M),!0)}finally{h(y,null)}}}function V(u){if(!u)return"";const f=new Date(u);return Number.isNaN(f.getTime())?"":f.toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"})}function Z(u){var f;return(f=u==null?void 0:u.precedent)!=null&&f.length?u.precedent.map(M=>M.summary??M.command??"Precedent recorded.").slice(0,3):["No precedent attached."]}var z=Ze(),ae=le(z);{var de=u=>{var f=da(),M=a(f),he=s(a(M),2);Ie(he,21,()=>o,Ve,(S,F)=>{var _=sa();let K;var X=a(_,!0);t(_),I(()=>{we(_,"aria-current",e(F)===e(C)?"true":void 0),K=te(_,1,"svelte-1j425e6",null,K,{active:e(F)===e(C)}),m(X,e(F))}),v(S,_)}),t(he);var ge=s(he,2),Fe=a(ge);t(ge);var $e=s(ge,2),j=a($e);{var D=S=>{var F=et();I(()=>m(F,e(x))),v(S,F)},k=S=>{var F=et();I(()=>m(F,e(i).summary)),v(S,F)};G(j,S=>{e(x)?S(D):e(i)&&S(k,1)})}t($e);var se=s($e,2),Y=a(se,!0);t(se),t(M);var P=s(M,2);{var pe=S=>{var F=oa(),_=a(F),K=a(_),X=s(a(K),2),ce=a(X,!0);t(X),t(K);var ue=s(K,2),me=s(a(ue),2),be=a(me);t(me),t(ue);var J=s(ue,2),Ae=s(a(J),2);Ie(Ae,21,()=>Z(e(p)),Ve,(B,Q)=>{var U=ra(),ve=a(U,!0);t(U),I(()=>m(ve,e(Q))),v(B,U)}),t(Ae),t(J);var Ce=s(J,2),Ne=s(a(Ce),2),Te=a(Ne,!0);t(Ne),t(Ce),t(_);var Ee=s(_,2),Se=s(a(Ee),2);{var Oe=B=>{var Q=na(),U=le(Q),ve=a(U,!0);t(U);var r=s(U,2),d=a(r,!0);t(r);var A=s(r,2),R=a(A,!0);t(A),I((b,w,O)=>{U.disabled=b,m(ve,e(y)==="stale"?"Saving":"Stale"),r.disabled=w,m(d,e(y)==="wrong"?"Saving":"Wrong"),A.disabled=O,m(R,e(y)==="too_strict"?"Saving":"Too strict")},[()=>!!e(y),()=>!!e(y),()=>!!e(y)]),re("click",U,()=>L("stale")),re("click",r,()=>L("wrong")),re("click",A,()=>L("too_strict")),v(B,Q)},je=B=>{var Q=ia();v(B,Q)},Le=B=>{var Q=la();v(B,Q)};G(Se,B=>{e($)&&e(i).verdictBar==="VETO"?B(Oe):e(i).verdictBar==="APPEALED"?B(je,1):B(Le,!1)})}t(Ee),t(F),I(()=>{var B,Q,U,ve;m(ce,((B=e(p))==null?void 0:B.text)??e(i).draftPreview),m(be,`${((Q=e(p))==null?void 0:Q.decision)??e(i).overall??""} · ${((U=e(p))==null?void 0:U.evidence_state)??e(C)??""}`),m(Te,((ve=e(p))==null?void 0:ve.fix)||"No change required.")}),v(S,F)};G(P,S=>{e(T)&&e(i)&&S(pe)})}t(f),I((S,F)=>{te(f,1,S,"svelte-1j425e6"),we(M,"aria-expanded",e(T)),m(Fe,`Current verdict: ${e(C)??""}`),m(Y,F)},[()=>`verdict-bar tone-${e(C).toLowerCase()}`,()=>{var S;return V((S=e(i))==null?void 0:S.createdAt)}]),re("click",M,()=>h(T,!e(T))),v(u,f)};G(ae,u=>{e(c)&&u(de)})}v(n,z),qe()}ze(["click"]);const mt="vestige.theme",rt="vestige-theme-light",Re=vt("dark"),Ue=vt(!0),nt=Ct([Re,Ue],([n,l])=>n==="auto"?l?"dark":"light":n);function va(n){return n==="dark"||n==="light"||n==="auto"}function pa(n){if(va(n)){Re.set(n);try{localStorage.setItem(mt,n)}catch{}}}function Ye(){const n=pt(Re);pa(n==="dark"?"light":n==="light"?"auto":"dark")}function ua(){if(document.getElementById(rt))return;const n=document.createElement("style");n.id=rt,n.textContent=` +/* Vestige light-mode overrides — injected by theme.ts. + * Activated by [data-theme='light'] on . + * Tokens mirror the real names used in app.css so the cascade stays clean. */ +[data-theme='light'] { + /* Core surface palette (slate scale) */ + --color-void: #f8fafc; /* slate-50 — page background */ + --color-abyss: #f1f5f9; /* slate-100 */ + --color-deep: #e2e8f0; /* slate-200 */ + --color-surface: #f1f5f9; /* slate-100 */ + --color-elevated: #e2e8f0; /* slate-200 */ + --color-subtle: #cbd5e1; /* slate-300 */ + --color-muted: #94a3b8; /* slate-400 */ + --color-dim: #475569; /* slate-600 */ + --color-text: #0f172a; /* slate-900 */ + --color-bright: #020617; /* slate-950 */ +} + +/* Baseline body/html wiring — app.css sets these against the dark + * tokens; we just let the variables do the work. Reassert for clarity. */ +[data-theme='light'] html, +html[data-theme='light'] { + background: var(--color-void); + color: var(--color-text); +} + +/* Glass surfaces — recompose on a light canvas. The original alphas + * are tuned for dark; invert-and-tint for light so panels still read + * as elevated instead of vanishing. */ +[data-theme='light'] .glass { + background: rgba(255, 255, 255, 0.65); + border: 1px solid rgba(99, 102, 241, 0.12); + box-shadow: + inset 0 1px 0 0 rgba(255, 255, 255, 0.6), + 0 4px 24px rgba(15, 23, 42, 0.08); +} +[data-theme='light'] .glass-subtle { + background: rgba(255, 255, 255, 0.55); + border: 1px solid rgba(99, 102, 241, 0.1); + box-shadow: + inset 0 1px 0 0 rgba(255, 255, 255, 0.5), + 0 2px 12px rgba(15, 23, 42, 0.06); +} +[data-theme='light'] .glass-sidebar { + background: rgba(248, 250, 252, 0.82); + border-right: 1px solid rgba(99, 102, 241, 0.14); + box-shadow: + inset -1px 0 0 0 rgba(255, 255, 255, 0.4), + 4px 0 24px rgba(15, 23, 42, 0.08); +} +[data-theme='light'] .glass-panel { + background: rgba(255, 255, 255, 0.75); + border: 1px solid rgba(99, 102, 241, 0.14); + box-shadow: + inset 0 1px 0 0 rgba(255, 255, 255, 0.5), + 0 8px 32px rgba(15, 23, 42, 0.1); +} + +/* Halve glow intensity — neon accents stay recognizable without + * washing out on slate-50. */ +[data-theme='light'] .glow-synapse { + box-shadow: 0 0 10px rgba(99, 102, 241, 0.15), 0 0 30px rgba(99, 102, 241, 0.05); +} +[data-theme='light'] .glow-dream { + box-shadow: 0 0 10px rgba(168, 85, 247, 0.15), 0 0 30px rgba(168, 85, 247, 0.05); +} +[data-theme='light'] .glow-memory { + box-shadow: 0 0 10px rgba(59, 130, 246, 0.15), 0 0 30px rgba(59, 130, 246, 0.05); +} + +/* Ambient orbs are gorgeous on black and blinding on white. Tame them. */ +[data-theme='light'] .ambient-orb { + opacity: 0.18; + filter: blur(100px); +} + +/* Scrollbar recolor for the lighter surface. */ +[data-theme='light'] ::-webkit-scrollbar-thumb { + background: #cbd5e1; +} +[data-theme='light'] ::-webkit-scrollbar-thumb:hover { + background: #94a3b8; +} +`,document.head.appendChild(n)}function it(n){document.documentElement.dataset.theme=n}let ke=null,Me=null,_e=null,ye=null;function ma(){ke&&Me&&ke.removeEventListener("change",Me),ye==null||ye(),_e==null||_e(),ke=null,Me=null,ye=null,_e=null,ua();let n="dark";try{const l=localStorage.getItem(mt);(l==="dark"||l==="light"||l==="auto")&&(n=l)}catch{}return Re.set(n),ke=window.matchMedia("(prefers-color-scheme: dark)"),Ue.set(ke.matches),Me=l=>Ue.set(l.matches),ke.addEventListener("change",Me),it(pt(nt)),ye=nt.subscribe(it),_e=Re.subscribe(()=>{}),()=>{ke&&Me&&ke.removeEventListener("change",Me),ke=null,Me=null,ye==null||ye(),_e==null||_e(),ye=null,_e=null}}var fa=g('');function ha(n){const l=()=>oe(Re,"$theme",o),[o,i]=He(),x={dark:"Dark",light:"Light",auto:"Auto (system)"},T={dark:"light",light:"auto",auto:"dark"};let y=q(l),N=q(()=>T[e(y)]),C=q(()=>`Toggle theme: ${x[e(y)]} (click for ${x[e(N)]})`);var $=fa(),p=a($),c=a(p);let E;var L=s(c,2);let V;var Z=s(L,2);let z;t(p),t($),I(()=>{we($,"aria-label",e(C)),we($,"title",e(C)),we($,"data-mode",e(y)),E=te(c,0,"icon svelte-1cmi4dh",null,E,{active:e(y)==="dark"}),V=te(L,0,"icon svelte-1cmi4dh",null,V,{active:e(y)==="light"}),z=te(Z,0,"icon svelte-1cmi4dh",null,z,{active:e(y)==="auto"})}),re("click",$,function(...ae){Ye==null||Ye.apply(this,ae)}),v(n,$),i()}ze(["click"]);var ga=g(' '),ba=g('
      '),xa=g(''),ka=g(' '),_a=g('
      ',1),ya=g(''),wa=g('
      No matches
      '),$a=g('
      esc
      '),Aa=g(" ",1);function za(n,l){We(l,!0);const o=()=>oe(Tt,"$page",C),i=()=>oe(wt,"$isConnected",C),x=()=>oe(ot,"$memoryCount",C),T=()=>oe(dt,"$avgRetention",C),y=()=>oe(yt,"$uptimeSeconds",C),N=()=>oe(lt,"$suppressedCount",C),[C,$]=He();let p=ne(!1),c=ne(""),E=ne(void 0),L=q(()=>o().url.pathname.startsWith(xe)?o().url.pathname.slice(xe.length)||"/":o().url.pathname),V=q(()=>e(L)==="/waitlist"||e(L).startsWith("/waitlist/"));Xe(()=>{e(V)||st.connect();const j=ma();function D(k){if(e(V))return;if((k.metaKey||k.ctrlKey)&&k.key==="k"){k.preventDefault(),h(p,!e(p)),h(c,""),e(p)&&requestAnimationFrame(()=>{var P;return(P=e(E))==null?void 0:P.focus()});return}if(k.key==="Escape"&&e(p)){h(p,!1);return}if(k.target instanceof HTMLInputElement||k.target instanceof HTMLTextAreaElement)return;if(k.key==="/"){k.preventDefault();const P=document.querySelector('input[type="text"]');P==null||P.focus();return}const Y={g:"/graph",m:"/memories",t:"/timeline",f:"/feed",e:"/explore",i:"/intentions",s:"/stats",r:"/reasoning",a:"/activation",d:"/dreams",c:"/schedule",p:"/importance",u:"/duplicates",x:"/contradictions",n:"/patterns"}[k.key.toLowerCase()];Y&&!k.metaKey&&!k.ctrlKey&&!k.altKey&&(k.preventDefault(),at(`${xe}${Y}`))}return window.addEventListener("keydown",D),()=>{st.disconnect(),window.removeEventListener("keydown",D),j()}});const Z=[{href:"/graph",label:"Graph",icon:"◎",shortcut:"G"},{href:"/reasoning",label:"Reasoning",icon:"✦",shortcut:"R"},{href:"/memories",label:"Memories",icon:"◈",shortcut:"M"},{href:"/timeline",label:"Timeline",icon:"◷",shortcut:"T"},{href:"/feed",label:"Feed",icon:"◉",shortcut:"F"},{href:"/explore",label:"Explore",icon:"◬",shortcut:"E"},{href:"/activation",label:"Activation",icon:"◈",shortcut:"A"},{href:"/dreams",label:"Dreams",icon:"✧",shortcut:"D"},{href:"/schedule",label:"Schedule",icon:"◷",shortcut:"C"},{href:"/importance",label:"Importance",icon:"◎",shortcut:"P"},{href:"/duplicates",label:"Duplicates",icon:"◉",shortcut:"U"},{href:"/contradictions",label:"Contradictions",icon:"⚠",shortcut:"X"},{href:"/patterns",label:"Patterns",icon:"▦",shortcut:"N"},{href:"/intentions",label:"Intentions",icon:"◇",shortcut:"I"},{href:"/stats",label:"Stats",icon:"◫",shortcut:"S"},{href:"/settings",label:"Settings",icon:"⚙",shortcut:","}],z=Z.slice(0,5);function ae(j,D){const k=D.startsWith(xe)?D.slice(xe.length)||"/":D;return j==="/graph"?k==="/"||k==="/graph":k.startsWith(j)}let de=q(()=>e(c)?Z.filter(j=>j.label.toLowerCase().includes(e(c).toLowerCase())):Z);function u(j){h(p,!1),h(c,""),at(`${xe}${j}`)}var f=Aa(),M=le(f);{var he=j=>{var D=Ze(),k=le(D);tt(k,()=>l.children),v(j,D)},ge=j=>{var D=_a(),k=s(le(D),6),se=a(k),Y=a(se),P=s(Y,2);Ie(P,21,()=>Z,Ve,(b,w)=>{const O=q(()=>ae(e(w).href,o().url.pathname));var H=ga(),W=a(H),ee=a(W,!0);t(W);var ie=s(W,2),Pe=a(ie,!0);t(ie);var Je=s(ie,2),ft=a(Je,!0);t(Je),t(H),I(()=>{we(H,"href",`${xe??""}${e(w).href??""}`),te(H,1,`flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-200 text-sm + ${e(O)?"bg-synapse/15 text-synapse-glow border border-synapse/30 shadow-[0_0_12px_rgba(99,102,241,0.15)] nav-active-border":"text-dim hover:text-text hover:bg-white/[0.03] border border-transparent"}`),m(ee,e(w).icon),m(Pe,e(w).label),m(ft,e(w).shortcut)}),v(b,H)}),t(P);var pe=s(P,2),S=a(pe);t(pe);var F=s(pe,2),_=a(F),K=a(_),X=s(K,2),ce=a(X,!0);t(X);var ue=s(X,2),me=a(ue);ha(me),t(ue),t(_);var be=s(_,2),J=a(be),Ae=a(J);t(J);var Ce=s(J,2),Ne=a(Ce);t(Ce);var Te=s(Ce,2);{var Ee=b=>{var w=ba(),O=a(w);t(w),I(H=>m(O,`up ${H??""}`),[()=>$t(y())]),v(b,w)};G(Te,b=>{y()>0&&b(Ee)})}t(be);var Se=s(be,2);{var Oe=b=>{var w=xa(),O=a(w);St(O),t(w),v(b,w)};G(Se,b=>{N()>0&&b(Oe)})}t(F),t(se);var je=s(se,2),Le=a(je);aa(Le,{});var B=s(Le,2);ca(B,{});var Q=s(B,2),U=a(Q);tt(U,()=>l.children),t(Q),t(je);var ve=s(je,2),r=a(ve),d=a(r);Ie(d,17,()=>z,Ve,(b,w)=>{const O=q(()=>ae(e(w).href,o().url.pathname));var H=ka(),W=a(H),ee=a(W,!0);t(W);var ie=s(W,2),Pe=a(ie,!0);t(ie),t(H),I(()=>{we(H,"href",`${xe??""}${e(w).href??""}`),te(H,1,`flex flex-col items-center gap-0.5 px-3 py-2 rounded-lg transition-all min-w-[3.5rem] + ${e(O)?"text-synapse-glow":"text-muted"}`),m(ee,e(w).icon),m(Pe,e(w).label)}),v(b,H)});var A=s(d,2);t(r),t(ve),t(k);var R=s(k,2);It(R,{}),I(b=>{we(Y,"href",`${xe??""}/graph`),te(K,1,`w-2 h-2 rounded-full ${i()?"bg-recall animate-pulse-glow":"bg-decay"}`),m(ce,i()?"Connected":"Offline"),m(Ae,`${x()??""} memories`),m(Ne,`${b??""}% retention`)},[()=>(T()*100).toFixed(0)]),re("click",S,()=>{h(p,!0),h(c,""),requestAnimationFrame(()=>{var b;return(b=e(E))==null?void 0:b.focus()})}),re("click",A,()=>{h(p,!0),h(c,""),requestAnimationFrame(()=>{var b;return(b=e(E))==null?void 0:b.focus()})}),v(j,D)};G(M,j=>{e(V)?j(he):j(ge,!1)})}var Fe=s(M,2);{var $e=j=>{var D=$a(),k=a(D),se=a(k),Y=s(a(se),2);gt(Y),xt(Y,_=>h(E,_),()=>e(E)),fe(2),t(se);var P=s(se,2),pe=a(P);Ie(pe,17,()=>e(de),Ve,(_,K)=>{var X=ya(),ce=a(X),ue=a(ce,!0);t(ce);var me=s(ce,2),be=a(me,!0);t(me);var J=s(me,2),Ae=a(J,!0);t(J),t(X),I(()=>{m(ue,e(K).icon),m(be,e(K).label),m(Ae,e(K).shortcut)}),re("click",X,()=>u(e(K).href)),v(_,X)});var S=s(pe,2);{var F=_=>{var K=wa();v(_,K)};G(S,_=>{e(de).length===0&&_(F)})}t(P),t(k),t(D),re("keydown",D,_=>{_.key==="Escape"&&h(p,!1)}),re("click",D,_=>{_.target===_.currentTarget&&h(p,!1)}),re("keydown",Y,_=>{_.key==="Enter"&&e(de).length>0&&u(e(de)[0].href)}),bt(Y,()=>e(c),_=>h(c,_)),v(j,D)};G(Fe,j=>{e(p)&&!e(V)&&j($e)})}v(n,f),qe(),$()}ze(["click","keydown"]);export{za as component}; diff --git a/apps/dashboard/build/_app/immutable/nodes/0.j0CpgSFp.js.br b/apps/dashboard/build/_app/immutable/nodes/0.j0CpgSFp.js.br new file mode 100644 index 0000000..17966bc Binary files /dev/null and b/apps/dashboard/build/_app/immutable/nodes/0.j0CpgSFp.js.br differ diff --git a/apps/dashboard/build/_app/immutable/nodes/0.j0CpgSFp.js.gz b/apps/dashboard/build/_app/immutable/nodes/0.j0CpgSFp.js.gz new file mode 100644 index 0000000..6d56e43 Binary files /dev/null and b/apps/dashboard/build/_app/immutable/nodes/0.j0CpgSFp.js.gz differ diff --git a/apps/dashboard/build/_app/immutable/nodes/1.DJo7hfwf.js b/apps/dashboard/build/_app/immutable/nodes/1.DEUqmURt.js similarity index 80% rename from apps/dashboard/build/_app/immutable/nodes/1.DJo7hfwf.js rename to apps/dashboard/build/_app/immutable/nodes/1.DEUqmURt.js index 748af3e..9bb5c91 100644 --- a/apps/dashboard/build/_app/immutable/nodes/1.DJo7hfwf.js +++ b/apps/dashboard/build/_app/immutable/nodes/1.DEUqmURt.js @@ -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=$("

      ",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/D8UfWY0j.js";import{s as k}from"../chunks/C2TQQEIa.js";const b={get error(){return m.error},get status(){return m.status}};k.updated.check;const i=b;var E=$("

      ",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}; diff --git a/apps/dashboard/build/_app/immutable/nodes/1.DEUqmURt.js.br b/apps/dashboard/build/_app/immutable/nodes/1.DEUqmURt.js.br new file mode 100644 index 0000000..894d776 Binary files /dev/null and b/apps/dashboard/build/_app/immutable/nodes/1.DEUqmURt.js.br differ diff --git a/apps/dashboard/build/_app/immutable/nodes/1.DEUqmURt.js.gz b/apps/dashboard/build/_app/immutable/nodes/1.DEUqmURt.js.gz new file mode 100644 index 0000000..f103943 Binary files /dev/null and b/apps/dashboard/build/_app/immutable/nodes/1.DEUqmURt.js.gz differ diff --git a/apps/dashboard/build/_app/immutable/nodes/1.DJo7hfwf.js.br b/apps/dashboard/build/_app/immutable/nodes/1.DJo7hfwf.js.br deleted file mode 100644 index fc2f8d4..0000000 Binary files a/apps/dashboard/build/_app/immutable/nodes/1.DJo7hfwf.js.br and /dev/null differ diff --git a/apps/dashboard/build/_app/immutable/nodes/1.DJo7hfwf.js.gz b/apps/dashboard/build/_app/immutable/nodes/1.DJo7hfwf.js.gz deleted file mode 100644 index 91c8b82..0000000 Binary files a/apps/dashboard/build/_app/immutable/nodes/1.DJo7hfwf.js.gz and /dev/null differ diff --git a/apps/dashboard/build/_app/immutable/nodes/10.Btb56kL1.js.br b/apps/dashboard/build/_app/immutable/nodes/10.Btb56kL1.js.br deleted file mode 100644 index 0115d29..0000000 Binary files a/apps/dashboard/build/_app/immutable/nodes/10.Btb56kL1.js.br and /dev/null differ diff --git a/apps/dashboard/build/_app/immutable/nodes/10.Btb56kL1.js b/apps/dashboard/build/_app/immutable/nodes/10.CACwABbv.js similarity index 99% rename from apps/dashboard/build/_app/immutable/nodes/10.Btb56kL1.js rename to apps/dashboard/build/_app/immutable/nodes/10.CACwABbv.js index cb871ff..f942a69 100644 --- a/apps/dashboard/build/_app/immutable/nodes/10.Btb56kL1.js +++ b/apps/dashboard/build/_app/immutable/nodes/10.CACwABbv.js @@ -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/D8UfWY0j.js";import{b as Yc}from"../chunks/CJsMJEun.js";import{p as vs}from"../chunks/V6gjw5Ec.js";import{N as Sa}from"../chunks/CcUbQ_Wl.js";import{i as qc}from"../chunks/BUoSzNdg.js";import{a as gi}from"../chunks/B7CfdQuM.js";import{e as jc}from"../chunks/MAY1QfFZ.js";/** * @license * Copyright 2010-2024 Three.js Authors * SPDX-License-Identifier: MIT diff --git a/apps/dashboard/build/_app/immutable/nodes/10.CACwABbv.js.br b/apps/dashboard/build/_app/immutable/nodes/10.CACwABbv.js.br new file mode 100644 index 0000000..ddad2eb Binary files /dev/null and b/apps/dashboard/build/_app/immutable/nodes/10.CACwABbv.js.br differ diff --git a/apps/dashboard/build/_app/immutable/nodes/10.Btb56kL1.js.gz b/apps/dashboard/build/_app/immutable/nodes/10.CACwABbv.js.gz similarity index 82% rename from apps/dashboard/build/_app/immutable/nodes/10.Btb56kL1.js.gz rename to apps/dashboard/build/_app/immutable/nodes/10.CACwABbv.js.gz index b707b91..dd073be 100644 Binary files a/apps/dashboard/build/_app/immutable/nodes/10.Btb56kL1.js.gz and b/apps/dashboard/build/_app/immutable/nodes/10.CACwABbv.js.gz differ diff --git a/apps/dashboard/build/_app/immutable/nodes/11.WP3QAgOF.js b/apps/dashboard/build/_app/immutable/nodes/11.B_W3XFQr.js similarity index 98% rename from apps/dashboard/build/_app/immutable/nodes/11.WP3QAgOF.js rename to apps/dashboard/build/_app/immutable/nodes/11.B_W3XFQr.js index 1b2c42e..c90d9cc 100644 --- a/apps/dashboard/build/_app/immutable/nodes/11.WP3QAgOF.js +++ b/apps/dashboard/build/_app/immutable/nodes/11.B_W3XFQr.js @@ -1,4 +1,4 @@ -import"../chunks/Bzak7iHL.js";import{o as Pt}from"../chunks/GG5zm9kr.js";import{N as Kt,ab as qt,aP as Ht,b as Wt,p as Rt,h as F,d as i,t as S,g as t,e as o,r as n,a as Tt,u as y,s as q,f as U,c as Ft,C as Xt,i as Zt,n as Gt}from"../chunks/CpWkWWOo.js";import{s as _,d as Ut,a as kt}from"../chunks/BlVfL1ME.js";import{i as R}from"../chunks/B4yTwGkE.js";import{B as Vt}from"../chunks/DdEqwvdI.js";import{e as V,i as rt}from"../chunks/CGEBXrjl.js";import{a as x,c as Nt,b as yt,f as h}from"../chunks/CHOnp4oo.js";import{s as Yt}from"../chunks/Cx-f-Pzo.js";import{b as Jt}from"../chunks/sZcqyNBA.js";import{g as Qt}from"../chunks/BHGLDPij.js";import{b as te}from"../chunks/BskPcZf7.js";import{a as Ct}from"../chunks/DNjM5a-l.js";import{N as ee}from"../chunks/DzfRjky4.js";import{s as p}from"../chunks/A7po6GxK.js";import{p as ae}from"../chunks/V6gjw5Ec.js";const re=Symbol("NaN");function se(a,C,m){Kt&&qt();var v=new Vt(a),T=!Ht();Wt(()=>{var w=C();w!==w&&(w=re),T&&w!==null&&typeof w=="object"&&(w={}),v.ensure(w,m)})}function Mt(a){return a==null||!Number.isFinite(a)||a<0?0:a>1?1:a}function ne(a){return{novelty:Mt(a==null?void 0:a.novelty),arousal:Mt(a==null?void 0:a.arousal),reward:Mt(a==null?void 0:a.reward),attention:Mt(a==null?void 0:a.attention)}}const It={sm:80,md:180,lg:320};function $t(a){return a&&(a==="sm"||a==="md"||a==="lg")?It[a]:It.md}const gt=[{key:"novelty",angle:-Math.PI/2},{key:"arousal",angle:0},{key:"reward",angle:Math.PI/2},{key:"attention",angle:Math.PI}];function oe(a){const C=$t(a);let m;switch(a){case"lg":m=44;break;case"sm":m=4;break;default:m=28}return Math.max(0,C/2-m)}var ie=yt(''),le=yt(''),de=yt(''),ce=yt(' ',1),ve=yt('');function Et(a,C){Rt(C,!0);let m=ae(C,"size",3,"md"),v=y(()=>$t(m())),T=y(()=>m()!=="sm"),w=y(()=>oe(m())),Y=y(()=>t(v)/2),J=y(()=>t(v)/2);const St={novelty:"Novelty",arousal:"Arousal",reward:"Reward",attention:"Attention"};let N=y(()=>ne({novelty:C.novelty,arousal:C.arousal,reward:C.reward,attention:C.attention}));function H(d,l){const r=d*t(w);return[t(Y)+Math.cos(l)*r,t(J)+Math.sin(l)*r]}const st=[.25,.5,.75,1];function nt(d){return gt.map(({angle:r})=>H(d,r)).map((r,c)=>`${c===0?"M":"L"}${r[0].toFixed(2)},${r[1].toFixed(2)}`).join(" ")+" Z"}let $=q(0);Pt(()=>{const l=performance.now();let r=0;const c=b=>{const g=Math.min(1,(b-l)/600);F($,1-Math.pow(1-g,3)),g<1&&(r=requestAnimationFrame(c))};return r=requestAnimationFrame(c),()=>cancelAnimationFrame(r)});let ot=y(()=>{const d=t($);return gt.map(({key:r,angle:c})=>H(t(N)[r]*d,c)).map((r,c)=>`${c===0?"M":"L"}${r[0].toFixed(2)},${r[1].toFixed(2)}`).join(" ")+" Z"});function _t(d){const l=t(w)+(m()==="lg"?18:12),r=t(Y)+Math.cos(d)*l,c=t(J)+Math.sin(d)*l;let b="middle";return Math.abs(Math.cos(d))>.5&&(b=Math.cos(d)>0?"start":"end"),{x:r,y:c,anchor:b}}var P=ve(),it=i(P);V(it,17,()=>st,rt,(d,l)=>{var r=ie();S(c=>{p(r,"d",c),p(r,"stroke-opacity",t(l)===1?.45:.18),p(r,"stroke-width",t(l)===1?1:.75)},[()=>nt(t(l))]),x(d,r)});var ht=o(it);V(ht,17,()=>gt,rt,(d,l)=>{const r=y(()=>{const[b,g]=H(1,t(l).angle);return{x:b,y:g}});var c=le();S(()=>{p(c,"x1",t(Y)),p(c,"y1",t(J)),p(c,"x2",t(r).x),p(c,"y2",t(r).y)}),x(d,c)});var W=o(ht),Q=o(W);{var wt=d=>{var l=Nt(),r=U(l);V(r,17,()=>gt,rt,(c,b)=>{const g=y(()=>{const[I,dt]=H(t(N)[t(b).key]*t($),t(b).angle);return{px:I,py:dt}});var j=de();S(()=>{p(j,"cx",t(g).px),p(j,"cy",t(g).py),p(j,"r",m()==="lg"?3:2.25)}),x(c,j)}),x(d,l)};R(Q,d=>{m()!=="sm"&&d(wt)})}var lt=o(Q);{var tt=d=>{var l=Nt(),r=U(l);V(r,17,()=>gt,rt,(c,b)=>{const g=y(()=>_t(t(b).angle));var j=ce(),I=U(j),dt=i(I);n(I);var L=o(I),ct=i(L,!0);n(L),S(At=>{p(I,"x",t(g).x),p(I,"y",t(g).y),p(I,"text-anchor",t(g).anchor),p(I,"font-size",m()==="lg"?12:10),_(dt,`${At??""}%`),p(L,"x",t(g).x),p(L,"y",t(g).y+(m()==="lg"?14:11)),p(L,"text-anchor",t(g).anchor),p(L,"font-size",m()==="lg"?10:8.5),_(ct,St[t(b).key])},[()=>(t(N)[t(b).key]*100).toFixed(0)]),x(c,j)}),x(d,l)};R(lt,d=>{t(T)&&d(tt)})}n(P),S((d,l,r,c)=>{p(P,"width",t(v)),p(P,"height",t(v)),p(P,"viewBox",`0 0 ${t(v)??""} ${t(v)??""}`),p(P,"aria-label",`Importance radar: novelty ${d??""}%, arousal ${l??""}%, reward ${r??""}%, attention ${c??""}%`),p(W,"d",t(ot)),p(W,"stroke-width",m()==="sm"?1:1.5)},[()=>(t(N).novelty*100).toFixed(0),()=>(t(N).arousal*100).toFixed(0),()=>(t(N).reward*100).toFixed(0),()=>(t(N).attention*100).toFixed(0)]),x(a,P),Tt()}var pe=h(' '),xe=h('Driven by ',1),me=h('
      ✓ Save

      '),ue=h('Weakest channel: ',1),fe=h('
      ⨯ Skip

      '),ge=h('
      Composite
      %
      ',1),ye=h(`

      Type some content above to score its importance.

      Composite = 0.25·novelty + 0.30·arousal + 0.25·reward + 0.20·attention. +import"../chunks/Bzak7iHL.js";import{o as Pt}from"../chunks/GG5zm9kr.js";import{N as Kt,ab as qt,aP as Ht,b as Wt,p as Rt,h as F,d as i,t as S,g as t,e as o,r as n,a as Tt,u as y,s as q,f as U,c as Ft,C as Xt,i as Zt,n as Gt}from"../chunks/CpWkWWOo.js";import{s as _,d as Ut,a as kt}from"../chunks/BlVfL1ME.js";import{i as R}from"../chunks/B4yTwGkE.js";import{B as Vt}from"../chunks/DdEqwvdI.js";import{e as V,i as rt}from"../chunks/CGEBXrjl.js";import{a as x,c as Nt,b as yt,f as h}from"../chunks/CHOnp4oo.js";import{s as Yt}from"../chunks/Cx-f-Pzo.js";import{b as Jt}from"../chunks/sZcqyNBA.js";import{g as Qt}from"../chunks/C2TQQEIa.js";import{b as te}from"../chunks/D8UfWY0j.js";import{a as Ct}from"../chunks/B7CfdQuM.js";import{N as ee}from"../chunks/CcUbQ_Wl.js";import{s as p}from"../chunks/A7po6GxK.js";import{p as ae}from"../chunks/V6gjw5Ec.js";const re=Symbol("NaN");function se(a,C,m){Kt&&qt();var v=new Vt(a),T=!Ht();Wt(()=>{var w=C();w!==w&&(w=re),T&&w!==null&&typeof w=="object"&&(w={}),v.ensure(w,m)})}function Mt(a){return a==null||!Number.isFinite(a)||a<0?0:a>1?1:a}function ne(a){return{novelty:Mt(a==null?void 0:a.novelty),arousal:Mt(a==null?void 0:a.arousal),reward:Mt(a==null?void 0:a.reward),attention:Mt(a==null?void 0:a.attention)}}const It={sm:80,md:180,lg:320};function $t(a){return a&&(a==="sm"||a==="md"||a==="lg")?It[a]:It.md}const gt=[{key:"novelty",angle:-Math.PI/2},{key:"arousal",angle:0},{key:"reward",angle:Math.PI/2},{key:"attention",angle:Math.PI}];function oe(a){const C=$t(a);let m;switch(a){case"lg":m=44;break;case"sm":m=4;break;default:m=28}return Math.max(0,C/2-m)}var ie=yt(''),le=yt(''),de=yt(''),ce=yt(' ',1),ve=yt('');function Et(a,C){Rt(C,!0);let m=ae(C,"size",3,"md"),v=y(()=>$t(m())),T=y(()=>m()!=="sm"),w=y(()=>oe(m())),Y=y(()=>t(v)/2),J=y(()=>t(v)/2);const St={novelty:"Novelty",arousal:"Arousal",reward:"Reward",attention:"Attention"};let N=y(()=>ne({novelty:C.novelty,arousal:C.arousal,reward:C.reward,attention:C.attention}));function H(d,l){const r=d*t(w);return[t(Y)+Math.cos(l)*r,t(J)+Math.sin(l)*r]}const st=[.25,.5,.75,1];function nt(d){return gt.map(({angle:r})=>H(d,r)).map((r,c)=>`${c===0?"M":"L"}${r[0].toFixed(2)},${r[1].toFixed(2)}`).join(" ")+" Z"}let $=q(0);Pt(()=>{const l=performance.now();let r=0;const c=b=>{const g=Math.min(1,(b-l)/600);F($,1-Math.pow(1-g,3)),g<1&&(r=requestAnimationFrame(c))};return r=requestAnimationFrame(c),()=>cancelAnimationFrame(r)});let ot=y(()=>{const d=t($);return gt.map(({key:r,angle:c})=>H(t(N)[r]*d,c)).map((r,c)=>`${c===0?"M":"L"}${r[0].toFixed(2)},${r[1].toFixed(2)}`).join(" ")+" Z"});function _t(d){const l=t(w)+(m()==="lg"?18:12),r=t(Y)+Math.cos(d)*l,c=t(J)+Math.sin(d)*l;let b="middle";return Math.abs(Math.cos(d))>.5&&(b=Math.cos(d)>0?"start":"end"),{x:r,y:c,anchor:b}}var P=ve(),it=i(P);V(it,17,()=>st,rt,(d,l)=>{var r=ie();S(c=>{p(r,"d",c),p(r,"stroke-opacity",t(l)===1?.45:.18),p(r,"stroke-width",t(l)===1?1:.75)},[()=>nt(t(l))]),x(d,r)});var ht=o(it);V(ht,17,()=>gt,rt,(d,l)=>{const r=y(()=>{const[b,g]=H(1,t(l).angle);return{x:b,y:g}});var c=le();S(()=>{p(c,"x1",t(Y)),p(c,"y1",t(J)),p(c,"x2",t(r).x),p(c,"y2",t(r).y)}),x(d,c)});var W=o(ht),Q=o(W);{var wt=d=>{var l=Nt(),r=U(l);V(r,17,()=>gt,rt,(c,b)=>{const g=y(()=>{const[I,dt]=H(t(N)[t(b).key]*t($),t(b).angle);return{px:I,py:dt}});var j=de();S(()=>{p(j,"cx",t(g).px),p(j,"cy",t(g).py),p(j,"r",m()==="lg"?3:2.25)}),x(c,j)}),x(d,l)};R(Q,d=>{m()!=="sm"&&d(wt)})}var lt=o(Q);{var tt=d=>{var l=Nt(),r=U(l);V(r,17,()=>gt,rt,(c,b)=>{const g=y(()=>_t(t(b).angle));var j=ce(),I=U(j),dt=i(I);n(I);var L=o(I),ct=i(L,!0);n(L),S(At=>{p(I,"x",t(g).x),p(I,"y",t(g).y),p(I,"text-anchor",t(g).anchor),p(I,"font-size",m()==="lg"?12:10),_(dt,`${At??""}%`),p(L,"x",t(g).x),p(L,"y",t(g).y+(m()==="lg"?14:11)),p(L,"text-anchor",t(g).anchor),p(L,"font-size",m()==="lg"?10:8.5),_(ct,St[t(b).key])},[()=>(t(N)[t(b).key]*100).toFixed(0)]),x(c,j)}),x(d,l)};R(lt,d=>{t(T)&&d(tt)})}n(P),S((d,l,r,c)=>{p(P,"width",t(v)),p(P,"height",t(v)),p(P,"viewBox",`0 0 ${t(v)??""} ${t(v)??""}`),p(P,"aria-label",`Importance radar: novelty ${d??""}%, arousal ${l??""}%, reward ${r??""}%, attention ${c??""}%`),p(W,"d",t(ot)),p(W,"stroke-width",m()==="sm"?1:1.5)},[()=>(t(N).novelty*100).toFixed(0),()=>(t(N).arousal*100).toFixed(0),()=>(t(N).reward*100).toFixed(0),()=>(t(N).attention*100).toFixed(0)]),x(a,P),Tt()}var pe=h(' '),xe=h('Driven by ',1),me=h('

      ✓ Save

      '),ue=h('Weakest channel: ',1),fe=h('
      ⨯ Skip

      '),ge=h('
      Composite
      %
      ',1),ye=h(`

      Type some content above to score its importance.

      Composite = 0.25·novelty + 0.30·arousal + 0.25·reward + 0.20·attention. Threshold for save: 60%.

      `),_e=h('
      '),he=h('
      '),we=h('

      No memories yet.

      '),be=h('· ',1),ke=h(' '),Me=h('
      '),Se=h(``),Ae=h('
      '),Ce=h(`

      Importance Radar

      4-channel importance model: Novelty · Arousal · Reward · Attention

      Test Importance

      Paste any content below. Vestige scores it across 4 channels and decides whether it is worth saving.

      Why Pro exists

      Two plans. One promise: agent memory you can depend on.

      Always-on answers

      The support bot handles the first wave.

      This is the first support layer: instant onboarding answers before anyone has to write an email. It can run locally from the FAQ now and call a hosted support endpoint later.

      Onboarding bot

      May to June

      The plan is simple.

      1. May Get Vestige into every MCP, Claude Code, Cursor, local AI, Rust, and self-hosted channel that cares about agent memory.
      2. June Invite the first Solo Pro and Team Pro users into sync, backups, shared memory, PostgreSQL-backed deployments, and bot-assisted support.
      3. After Use paid feedback to turn Vestige from a beloved local tool into durable agent-memory infrastructure.
      `);function Ft(Be,Ee){ct(Ee,!0);let q,G=p(""),j=p(""),I=p("solo"),J=p("sync"),x=p(""),H=p(""),y=p("idle"),_=p(""),S=p(""),T=p(!1),C=p(mt([{role:"bot",content:"Ask me about installing Vestige, whether heavy models are required, Solo vs Team Pro, sync, pricing, or what happens after you join the June list."}]));const Fe=[{value:"Local",label:"SQLite memory, no hosted memory service"},{value:"MCP",label:"Claude Code, Cursor, Cline, Codex, Goose"},{value:"June",label:"Pro sync, backup, team memory early access"}],De=[{name:"Solo Pro",accent:"#22c55e",copy:"Multi-device sync, encrypted backups, managed updates, and a cleaner memory dashboard for developers living inside AI coding agents."},{name:"Team Pro",accent:"#06b6d4",copy:"Shared project memory, admin review, audit trails, PostgreSQL-backed deployments, and async support for engineering teams."}],Oe=["Private by default","Sync without lock-in","Team memory controls","Bot-assisted support"],ze=[{label:"Install",prompt:"How do I install Vestige and connect it to Claude Code?"},{label:"No 20GB?",prompt:"Do I need the Sanhedrin model or 20GB of RAM?"},{label:"Solo vs Team",prompt:"Should I choose Solo Pro or Team Pro?"},{label:"Sync",prompt:"How will Pro sync and backups work?"},{label:"Pricing",prompt:"How much will Vestige Pro cost?"},{label:"Human help",prompt:"When does a human get involved?"}];it(()=>{const t=q.getContext("2d");if(!t)return;const e=t;let r=0,i=0,m=0;const d=Array.from({length:62},(b,w)=>({x:Math.random(),y:Math.random(),vx:(Math.random()-.5)*16e-5,vy:(Math.random()-.5)*16e-5,phase:Math.random()*Math.PI*2,kind:w%5}));function M(){const b=Math.min(window.devicePixelRatio||1,2);i=window.innerWidth,m=window.innerHeight,q.width=Math.floor(i*b),q.height=Math.floor(m*b),q.style.width=`${i}px`,q.style.height=`${m}px`,e.setTransform(b,0,0,b,0,0)}function Me(b){e.clearRect(0,0,i,m);const w=e.createLinearGradient(0,0,i,m);w.addColorStop(0,"#07100f"),w.addColorStop(.45,"#0b1221"),w.addColorStop(1,"#15100a"),e.fillStyle=w,e.fillRect(0,0,i,m),e.strokeStyle="rgba(148, 163, 184, 0.08)",e.lineWidth=1;for(let n=0;n.96)&&(n.vx*=-1),(n.y<.06||n.y>.94)&&(n.vy*=-1);for(let n=0;n{cancelAnimationFrame(r),window.removeEventListener("resize",M)}});function Ne(){const t=["## Vestige Pro waitlist","",`Plan: ${a(I)}`,`Priority: ${a(J)}`,a(x).trim()?`Use case: ${a(x).trim()}`:"Use case:","","Please do not include private email addresses in this public issue."].join(` `);return`https://github.com/samvallad33/vestige/issues/new?title=${encodeURIComponent("Vestige Pro waitlist")}&body=${encodeURIComponent(t)}`}async function Ye(t){if(t.preventDefault(),c(y,"submitting"),c(_,""),a(H).trim()){c(y,"success"),c(_,"You are on the list.");return}if(!a(j).includes("@")){c(y,"error"),c(_,"Enter an email so the early-access invite can reach you.");return}a(G).trim(),a(j).trim(),a(I),a(J),a(x).trim(),new Date().toISOString();{c(y,"success"),c(_,"Email capture is ready for an endpoint. Opening the GitHub waitlist fallback with your email omitted."),window.open(Ne(),"_blank","noopener,noreferrer");return}}function Ke(t){const e=t.toLowerCase();return/(install|setup|onboard|claude|cursor|cline|codex|connect)/.test(e)?["Start with the open-source install:","1. `npm install -g vestige-mcp-server@latest`","2. Claude Code: `claude mcp add vestige vestige-mcp -s user`","3. Codex: `codex mcp add vestige -- vestige-mcp`","Then test it by asking your agent to remember a preference, opening a fresh session, and asking for that preference back."].join(` diff --git a/apps/dashboard/build/_app/immutable/nodes/20.DebghJca.js.br b/apps/dashboard/build/_app/immutable/nodes/20.DebghJca.js.br new file mode 100644 index 0000000..cc2dc08 Binary files /dev/null and b/apps/dashboard/build/_app/immutable/nodes/20.DebghJca.js.br differ diff --git a/apps/dashboard/build/_app/immutable/nodes/20.DebghJca.js.gz b/apps/dashboard/build/_app/immutable/nodes/20.DebghJca.js.gz new file mode 100644 index 0000000..6c1b8e8 Binary files /dev/null and b/apps/dashboard/build/_app/immutable/nodes/20.DebghJca.js.gz differ diff --git a/apps/dashboard/build/_app/immutable/nodes/3.Caati8mq.js b/apps/dashboard/build/_app/immutable/nodes/3.Bu_uPddU.js similarity index 56% rename from apps/dashboard/build/_app/immutable/nodes/3.Caati8mq.js rename to apps/dashboard/build/_app/immutable/nodes/3.Bu_uPddU.js index 1814225..3cca1e6 100644 --- a/apps/dashboard/build/_app/immutable/nodes/3.Caati8mq.js +++ b/apps/dashboard/build/_app/immutable/nodes/3.Bu_uPddU.js @@ -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/C2TQQEIa.js";function g(i,o){t(o,!1),r(()=>m("/graph",{replaceState:!0})),p(),a()}export{g as component}; diff --git a/apps/dashboard/build/_app/immutable/nodes/3.Bu_uPddU.js.br b/apps/dashboard/build/_app/immutable/nodes/3.Bu_uPddU.js.br new file mode 100644 index 0000000..ca7f12c Binary files /dev/null and b/apps/dashboard/build/_app/immutable/nodes/3.Bu_uPddU.js.br differ diff --git a/apps/dashboard/build/_app/immutable/nodes/3.Bu_uPddU.js.gz b/apps/dashboard/build/_app/immutable/nodes/3.Bu_uPddU.js.gz new file mode 100644 index 0000000..09e598a Binary files /dev/null and b/apps/dashboard/build/_app/immutable/nodes/3.Bu_uPddU.js.gz differ diff --git a/apps/dashboard/build/_app/immutable/nodes/3.Caati8mq.js.br b/apps/dashboard/build/_app/immutable/nodes/3.Caati8mq.js.br deleted file mode 100644 index 1830c35..0000000 Binary files a/apps/dashboard/build/_app/immutable/nodes/3.Caati8mq.js.br and /dev/null differ diff --git a/apps/dashboard/build/_app/immutable/nodes/3.Caati8mq.js.gz b/apps/dashboard/build/_app/immutable/nodes/3.Caati8mq.js.gz deleted file mode 100644 index 9de279d..0000000 Binary files a/apps/dashboard/build/_app/immutable/nodes/3.Caati8mq.js.gz and /dev/null differ diff --git a/apps/dashboard/build/_app/immutable/nodes/4.DJCab_le.js.br b/apps/dashboard/build/_app/immutable/nodes/4.DJCab_le.js.br deleted file mode 100644 index 788a8c4..0000000 Binary files a/apps/dashboard/build/_app/immutable/nodes/4.DJCab_le.js.br and /dev/null differ diff --git a/apps/dashboard/build/_app/immutable/nodes/4.DJCab_le.js.gz b/apps/dashboard/build/_app/immutable/nodes/4.DJCab_le.js.gz deleted file mode 100644 index baf0e00..0000000 Binary files a/apps/dashboard/build/_app/immutable/nodes/4.DJCab_le.js.gz and /dev/null differ diff --git a/apps/dashboard/build/_app/immutable/nodes/4.DJCab_le.js b/apps/dashboard/build/_app/immutable/nodes/4.DYVet_v-.js similarity index 98% rename from apps/dashboard/build/_app/immutable/nodes/4.DJCab_le.js rename to apps/dashboard/build/_app/immutable/nodes/4.DYVet_v-.js index 7d60bca..f4d960a 100644 --- a/apps/dashboard/build/_app/immutable/nodes/4.DJCab_le.js +++ b/apps/dashboard/build/_app/immutable/nodes/4.DYVet_v-.js @@ -1,4 +1,4 @@ -import"../chunks/Bzak7iHL.js";import{o as we,a as Ne}from"../chunks/GG5zm9kr.js";import{p as Me,s as A,c as le,aB as ve,e as y,d as p,t as L,g as e,f as Ee,u as de,r as v,a as Se,h as d,n as me}from"../chunks/CpWkWWOo.js";import{s as K,d as Ie,a as xe}from"../chunks/BlVfL1ME.js";import{i as ue}from"../chunks/B4yTwGkE.js";import{a as E,c as Fe,b as oe,f as X}from"../chunks/CHOnp4oo.js";import{s as o,r as ge}from"../chunks/A7po6GxK.js";import{b as Ge,a as Pe}from"../chunks/sZcqyNBA.js";import{a as he}from"../chunks/DNjM5a-l.js";import{e as ke}from"../chunks/MAY1QfFZ.js";import{e as fe,i as ye}from"../chunks/CGEBXrjl.js";import{p as W}from"../chunks/V6gjw5Ec.js";import{N as Ce}from"../chunks/DzfRjky4.js";const Re=.93,Te=.05,be="#8B95A5",Le="#818cf8",Oe=140,pe=8,De=4,Ke=12;function je(s){return!Number.isFinite(s)||s<=0?0:s*Re}function Ue(s){return Number.isFinite(s)?s>=Te:!1}function Ae(s){return!Number.isFinite(s)||stypeof b=="string");M.length!==0&&u.push({source_id:N.source_id,target_ids:M})}return u.reverse()}var Qe=oe(''),We=oe(''),Xe=oe(' '),Je=oe(''),Ze=oe('');function $e(s,f){Me(f,!0);let u=W(f,"width",3,900),x=W(f,"height",3,560),N=W(f,"source",3,null),M=W(f,"neighbours",19,()=>[]),b=W(f,"liveBurstKey",3,0),S=W(f,"liveBurst",3,null);const F=22,J=14;let B=A(le([])),G=A(le([])),T=A(le([])),U=0,O=null,ne=null,Z=0;function $(n,t,c,_){U+=1;const i=U,l=b()>0&&e(B).length>0?40:0,m=c+(Math.random()-.5)*l,g=_+(Math.random()-.5)*l;d(T,[...e(T),{burstId:i,x:m,y:g,radius:F,opacity:.75},{burstId:i,x:m,y:g,radius:F,opacity:.5}],!0);const V={id:`${n.id}::${i}`,label:n.label,nodeType:"source",x:m,y:g,activation:1,isSource:!0,sourceBurstId:i},H=[],k=[],C=U*.37%(Math.PI*2),re=qe(m,g,t.length,C);t.forEach((D,ie)=>{const se=re[ie];se&&(H.push({id:`${D.id}::${i}`,label:D.label,nodeType:D.nodeType,x:se.x,y:se.y,activation:Ye(ie,t.length),isSource:!1,sourceBurstId:i}),k.push({burstId:i,sourceNodeId:V.id,targetNodeId:`${D.id}::${i}`,drawProgress:0,staggerDelay:ze(ie),framesElapsed:0}))}),d(B,[...e(B),V,...H],!0),d(G,[...e(G),...k],!0)}function q(){let n=[];for(const i of e(B)){const l=je(i.activation);Ue(l)&&n.push({...i,activation:l})}d(B,n,!0);const t=new Set(n.map(i=>i.id));let c=[];for(const i of e(G)){if(!t.has(i.sourceNodeId)||!t.has(i.targetNodeId))continue;const l=i.framesElapsed+1;let m=i.drawProgress;l>=i.staggerDelay&&(m=Math.min(1,m+1/15)),c.push({...i,framesElapsed:l,drawProgress:m})}d(G,c,!0);let _=[];for(const i of e(T)){const l=i.radius+6,m=i.opacity*.96;m<.02||l>Math.max(u(),x())||_.push({...i,radius:l,opacity:m})}d(T,_,!0),O=requestAnimationFrame(q)}function Y(){d(B,[],!0),d(G,[],!0),d(T,[],!0)}ve(()=>{if(!N())return;const n=N().id;n!==ne&&(ne=n,Y(),$(N(),M(),u()/2,x()/2))}),ve(()=>{if(!S()||b()===0||b()===Z)return;Z=b();const n=(Math.random()-.5)*120,t=(Math.random()-.5)*120;$(S().source,S().neighbours,u()/2+n,x()/2+t)}),we(()=>{O=requestAnimationFrame(q)}),Ne(()=>{O!==null&&cancelAnimationFrame(O)});function ce(n,t){return Ve(n,t)}function ee(n){const t=e(B).find(l=>l.id===n.sourceNodeId),c=e(B).find(l=>l.id===n.targetNodeId);if(!t||!c)return null;const _=t.x+(c.x-t.x)*n.drawProgress,i=t.y+(c.y-t.y)*n.drawProgress;return{x1:t.x,y1:t.y,x2:_,y2:i}}var P=Ze(),te=y(p(P));fe(te,17,()=>e(T),ye,(n,t)=>{var c=Qe();L(()=>{o(c,"cx",e(t).x),o(c,"cy",e(t).y),o(c,"r",e(t).radius),o(c,"opacity",e(t).opacity)}),E(n,c)});var j=y(te);fe(j,17,()=>e(G),ye,(n,t)=>{const c=de(()=>ee(e(t)));var _=Fe(),i=Ee(_);{var l=m=>{var g=We();L(()=>{o(g,"x1",e(c).x1),o(g,"y1",e(c).y1),o(g,"x2",e(c).x2),o(g,"y2",e(c).y2),o(g,"opacity",.35*e(t).drawProgress)}),E(m,g)};ue(i,m=>{e(c)&&m(l)})}E(n,_)});var z=y(j);fe(z,17,()=>e(B),n=>n.id,(n,t)=>{const c=de(()=>ce(e(t).nodeType,e(t).isSource)),_=de(()=>e(t).isSource?F*(.7+.3*e(t).activation):J*(.5+.8*e(t).activation));var i=Je(),l=p(i),m=y(l),g=y(m),V=y(g);{var H=k=>{var C=Xe(),re=p(C,!0);v(C),L(D=>{o(C,"x",e(t).x),o(C,"y",e(t).y+e(_)+18),o(C,"opacity",.9*e(t).activation),K(re,D)},[()=>e(t).label.length>40?e(t).label.slice(0,40)+"…":e(t).label]),E(k,C)};ue(V,k=>{e(t).isSource&&e(t).label&&k(H)})}v(i),L(k=>{o(i,"opacity",k),o(l,"cx",e(t).x),o(l,"cy",e(t).y),o(l,"r",e(_)*1.9),o(l,"fill",e(c)),o(l,"opacity",.18*e(t).activation),o(m,"cx",e(t).x),o(m,"cy",e(t).y),o(m,"r",e(_)),o(m,"fill",e(c)),o(g,"cx",e(t).x-e(_)*.3),o(g,"cy",e(t).y-e(_)*.3),o(g,"r",e(_)*.35),o(g,"opacity",.35*e(t).activation)},[()=>Math.min(1,e(t).activation*1.25)]),E(n,i)}),v(P),L(()=>{o(P,"width",u()),o(P,"height",x()),o(P,"viewBox",`0 0 ${u()??""} ${x()??""}`)}),E(s,P),Se()}var et=X('

      Computing activation...

      '),tt=X('

      Activation failed

      '),rt=X(`

      No matching memory

      Nothing in the graph matches . Try a broader +import"../chunks/Bzak7iHL.js";import{o as we,a as Ne}from"../chunks/GG5zm9kr.js";import{p as Me,s as A,c as le,aB as ve,e as y,d as p,t as L,g as e,f as Ee,u as de,r as v,a as Se,h as d,n as me}from"../chunks/CpWkWWOo.js";import{s as K,d as Ie,a as xe}from"../chunks/BlVfL1ME.js";import{i as ue}from"../chunks/B4yTwGkE.js";import{a as E,c as Fe,b as oe,f as X}from"../chunks/CHOnp4oo.js";import{s as o,r as ge}from"../chunks/A7po6GxK.js";import{b as Ge,a as Pe}from"../chunks/sZcqyNBA.js";import{a as he}from"../chunks/B7CfdQuM.js";import{e as ke}from"../chunks/MAY1QfFZ.js";import{e as fe,i as ye}from"../chunks/CGEBXrjl.js";import{p as W}from"../chunks/V6gjw5Ec.js";import{N as Ce}from"../chunks/CcUbQ_Wl.js";const Re=.93,Te=.05,be="#8B95A5",Le="#818cf8",Oe=140,pe=8,De=4,Ke=12;function je(s){return!Number.isFinite(s)||s<=0?0:s*Re}function Ue(s){return Number.isFinite(s)?s>=Te:!1}function Ae(s){return!Number.isFinite(s)||stypeof b=="string");M.length!==0&&u.push({source_id:N.source_id,target_ids:M})}return u.reverse()}var Qe=oe(''),We=oe(''),Xe=oe(' '),Je=oe(''),Ze=oe('');function $e(s,f){Me(f,!0);let u=W(f,"width",3,900),x=W(f,"height",3,560),N=W(f,"source",3,null),M=W(f,"neighbours",19,()=>[]),b=W(f,"liveBurstKey",3,0),S=W(f,"liveBurst",3,null);const F=22,J=14;let B=A(le([])),G=A(le([])),T=A(le([])),U=0,O=null,ne=null,Z=0;function $(n,t,c,_){U+=1;const i=U,l=b()>0&&e(B).length>0?40:0,m=c+(Math.random()-.5)*l,g=_+(Math.random()-.5)*l;d(T,[...e(T),{burstId:i,x:m,y:g,radius:F,opacity:.75},{burstId:i,x:m,y:g,radius:F,opacity:.5}],!0);const V={id:`${n.id}::${i}`,label:n.label,nodeType:"source",x:m,y:g,activation:1,isSource:!0,sourceBurstId:i},H=[],k=[],C=U*.37%(Math.PI*2),re=qe(m,g,t.length,C);t.forEach((D,ie)=>{const se=re[ie];se&&(H.push({id:`${D.id}::${i}`,label:D.label,nodeType:D.nodeType,x:se.x,y:se.y,activation:Ye(ie,t.length),isSource:!1,sourceBurstId:i}),k.push({burstId:i,sourceNodeId:V.id,targetNodeId:`${D.id}::${i}`,drawProgress:0,staggerDelay:ze(ie),framesElapsed:0}))}),d(B,[...e(B),V,...H],!0),d(G,[...e(G),...k],!0)}function q(){let n=[];for(const i of e(B)){const l=je(i.activation);Ue(l)&&n.push({...i,activation:l})}d(B,n,!0);const t=new Set(n.map(i=>i.id));let c=[];for(const i of e(G)){if(!t.has(i.sourceNodeId)||!t.has(i.targetNodeId))continue;const l=i.framesElapsed+1;let m=i.drawProgress;l>=i.staggerDelay&&(m=Math.min(1,m+1/15)),c.push({...i,framesElapsed:l,drawProgress:m})}d(G,c,!0);let _=[];for(const i of e(T)){const l=i.radius+6,m=i.opacity*.96;m<.02||l>Math.max(u(),x())||_.push({...i,radius:l,opacity:m})}d(T,_,!0),O=requestAnimationFrame(q)}function Y(){d(B,[],!0),d(G,[],!0),d(T,[],!0)}ve(()=>{if(!N())return;const n=N().id;n!==ne&&(ne=n,Y(),$(N(),M(),u()/2,x()/2))}),ve(()=>{if(!S()||b()===0||b()===Z)return;Z=b();const n=(Math.random()-.5)*120,t=(Math.random()-.5)*120;$(S().source,S().neighbours,u()/2+n,x()/2+t)}),we(()=>{O=requestAnimationFrame(q)}),Ne(()=>{O!==null&&cancelAnimationFrame(O)});function ce(n,t){return Ve(n,t)}function ee(n){const t=e(B).find(l=>l.id===n.sourceNodeId),c=e(B).find(l=>l.id===n.targetNodeId);if(!t||!c)return null;const _=t.x+(c.x-t.x)*n.drawProgress,i=t.y+(c.y-t.y)*n.drawProgress;return{x1:t.x,y1:t.y,x2:_,y2:i}}var P=Ze(),te=y(p(P));fe(te,17,()=>e(T),ye,(n,t)=>{var c=Qe();L(()=>{o(c,"cx",e(t).x),o(c,"cy",e(t).y),o(c,"r",e(t).radius),o(c,"opacity",e(t).opacity)}),E(n,c)});var j=y(te);fe(j,17,()=>e(G),ye,(n,t)=>{const c=de(()=>ee(e(t)));var _=Fe(),i=Ee(_);{var l=m=>{var g=We();L(()=>{o(g,"x1",e(c).x1),o(g,"y1",e(c).y1),o(g,"x2",e(c).x2),o(g,"y2",e(c).y2),o(g,"opacity",.35*e(t).drawProgress)}),E(m,g)};ue(i,m=>{e(c)&&m(l)})}E(n,_)});var z=y(j);fe(z,17,()=>e(B),n=>n.id,(n,t)=>{const c=de(()=>ce(e(t).nodeType,e(t).isSource)),_=de(()=>e(t).isSource?F*(.7+.3*e(t).activation):J*(.5+.8*e(t).activation));var i=Je(),l=p(i),m=y(l),g=y(m),V=y(g);{var H=k=>{var C=Xe(),re=p(C,!0);v(C),L(D=>{o(C,"x",e(t).x),o(C,"y",e(t).y+e(_)+18),o(C,"opacity",.9*e(t).activation),K(re,D)},[()=>e(t).label.length>40?e(t).label.slice(0,40)+"…":e(t).label]),E(k,C)};ue(V,k=>{e(t).isSource&&e(t).label&&k(H)})}v(i),L(k=>{o(i,"opacity",k),o(l,"cx",e(t).x),o(l,"cy",e(t).y),o(l,"r",e(_)*1.9),o(l,"fill",e(c)),o(l,"opacity",.18*e(t).activation),o(m,"cx",e(t).x),o(m,"cy",e(t).y),o(m,"r",e(_)),o(m,"fill",e(c)),o(g,"cx",e(t).x-e(_)*.3),o(g,"cy",e(t).y-e(_)*.3),o(g,"r",e(_)*.35),o(g,"opacity",.35*e(t).activation)},[()=>Math.min(1,e(t).activation*1.25)]),E(n,i)}),v(P),L(()=>{o(P,"width",u()),o(P,"height",x()),o(P,"viewBox",`0 0 ${u()??""} ${x()??""}`)}),E(s,P),Se()}var et=X('

      Computing activation...

      '),tt=X('

      Activation failed

      '),rt=X(`

      No matching memory

      Nothing in the graph matches . Try a broader query or switch on live mode to watch the engine fire its own bursts.

      `),it=X(`

      Waiting for activation

      Seed a burst with the search bar above, or enable live mode to overlay bursts from the cognitive engine as they happen.

      `),st=X('
      Seed

      '),at=X(`

      Spreading Activation

      Collins & Loftus 1975 — activation spreads from a seed memory to diff --git a/apps/dashboard/build/_app/immutable/nodes/4.DYVet_v-.js.br b/apps/dashboard/build/_app/immutable/nodes/4.DYVet_v-.js.br new file mode 100644 index 0000000..48a22ef Binary files /dev/null and b/apps/dashboard/build/_app/immutable/nodes/4.DYVet_v-.js.br differ diff --git a/apps/dashboard/build/_app/immutable/nodes/4.DYVet_v-.js.gz b/apps/dashboard/build/_app/immutable/nodes/4.DYVet_v-.js.gz new file mode 100644 index 0000000..7edd3c0 Binary files /dev/null and b/apps/dashboard/build/_app/immutable/nodes/4.DYVet_v-.js.gz differ diff --git a/apps/dashboard/build/_app/immutable/nodes/6.DTUGCA1p.js b/apps/dashboard/build/_app/immutable/nodes/6.54m-BxV_.js similarity index 99% rename from apps/dashboard/build/_app/immutable/nodes/6.DTUGCA1p.js rename to apps/dashboard/build/_app/immutable/nodes/6.54m-BxV_.js index 5f3fc6c..77162f8 100644 --- a/apps/dashboard/build/_app/immutable/nodes/6.DTUGCA1p.js +++ b/apps/dashboard/build/_app/immutable/nodes/6.54m-BxV_.js @@ -1,4 +1,4 @@ -import"../chunks/Bzak7iHL.js";import{p as qe,d as a,r as t,e as i,g as e,f as he,u as b,t as R,a as Se,n as X,h as de,s as be,W as Ge,aG as Ae}from"../chunks/CpWkWWOo.js";import{s as x,d as Ye,a as pe}from"../chunks/BlVfL1ME.js";import{c as Fe,a as m,f as _,b as Ve,t as Ie}from"../chunks/CHOnp4oo.js";import{i as j}from"../chunks/B4yTwGkE.js";import{e as ge}from"../chunks/CGEBXrjl.js";import{h as We}from"../chunks/C4h_mRt2.js";import{s as A,r as Be,a as Xe}from"../chunks/A7po6GxK.js";import{s as _e}from"../chunks/aVbAZ-t7.js";import{a as ze}from"../chunks/DNjM5a-l.js";import{s as ae}from"../chunks/Cx-f-Pzo.js";import{p as Ue}from"../chunks/V6gjw5Ec.js";import{b as Je}from"../chunks/BskPcZf7.js";const Te=5,je=["Replay","Cross-reference","Strengthen","Prune","Transfer"];function Oe(v){if(!Number.isFinite(v))return 1;const n=Math.floor(v);return n<1?1:n>Te?Te:n}const Ke=.3,Qe=.7;function Ze(v){const n=ye(v);return n>Qe?"high":n1?1:v}function $e(v){return v==null||!Number.isFinite(v)||v<0?"0ms":v<1e3?`${Math.round(v)}ms`:`${(v/1e3).toFixed(2)}s`}function et(v){const n=ye(v);return`${Math.round(n*100)}%`}function tt(v,n=""){return`${n}/memories/${v}`}function st(v,n=2){return!v||v.length===0?[]:v.slice(0,Math.max(0,n))}function at(v,n=2){return v?Math.max(0,v.length-n):0}function rt(v){return v?v.length>8?v.slice(0,8):v:""}var nt=_('

      Episodic hippocampus
      Semantic cortex
      ',1),it=Ve(''),vt=_('
      '),lt=_(''),ot=_('Replaying memories'),ct=_('New connections found: '),dt=_('Strengthened: '),ut=_('Compressed: '),ft=_('Connections persisted: Insights: ',1),mt=_('
      ');function pt(v,n){qe(n,!0);const N=[{num:1,name:"Replay",color:"#818cf8",desc:"Hippocampal replay: tagged memories surface for consolidation."},{num:2,name:"Cross-reference",color:"#a855f7",desc:"Semantic proximity check — new edges discovered across memories."},{num:3,name:"Strengthen",color:"#c084fc",desc:"Co-activated memories strengthen; FSRS stability grows."},{num:4,name:"Prune",color:"#ef4444",desc:"Low-retention redundant memories compressed or released."},{num:5,name:"Transfer",color:"#10b981",desc:"Episodic → semantic consolidation (hippocampus → cortex)."}];let l=b(()=>Oe(n.stage)),f=b(()=>N[e(l)-1]),q=b(()=>{if(!n.dreamResult)return 8;const s=n.dreamResult.memoriesReplayed??8;return Math.max(6,Math.min(12,s))}),re=b(()=>{var r;if(!n.dreamResult)return 5;const s=((r=n.dreamResult.stats)==null?void 0:r.newConnectionsFound)??5;return Math.max(3,Math.min(e(q),s))}),z=b(()=>{var r;if(!n.dreamResult)return Math.ceil(e(q)*.5);const s=((r=n.dreamResult.stats)==null?void 0:r.memoriesStrengthened)??Math.ceil(e(q)*.5);return Math.max(1,Math.min(e(q),s))}),ne=b(()=>{var r;if(!n.dreamResult)return Math.ceil(e(q)*.25);const s=((r=n.dreamResult.stats)==null?void 0:r.memoriesCompressed)??Math.ceil(e(q)*.25);return Math.max(1,Math.min(Math.floor(e(q)/2),s))});function C(s,r=0){const d=Math.sin((s+1)*9301+49297+r*233)*233280;return d-Math.floor(d)}let U=b(()=>{const s=[],r=Math.ceil(Math.sqrt(e(q))),d=Math.ceil(e(q)/r);for(let c=0;c{const s=[],r=e(U).length;for(let d=0;d{var r=nt();X(4),m(s,r)};j(le,s=>{e(l)===5&&s(oe)})}var ee=i(le,2);ge(ee,23,()=>e(L),(s,r)=>s.a+"-"+s.b+"-"+r,(s,r,d)=>{const c=b(()=>e(U)[e(r).a]),p=b(()=>e(U)[e(r).b]);var u=Fe(),k=he(u);{var w=g=>{const M=b(()=>I(e(c))),h=b(()=>F(e(c))),V=b(()=>I(e(p))),ke=b(()=>F(e(p)));var E=it();R(()=>{A(E,"x1",e(M)),A(E,"y1",e(h)),A(E,"x2",e(V)),A(E,"y2",e(ke)),A(E,"stroke",e(f).color),A(E,"stroke-width",e(l)===2?.25:e(l)===3?.35:.2),A(E,"stroke-opacity",e(l)<2?0:e(l)===4?.25:e(l)===5?.15:.6),A(E,"stroke-dasharray",e(l)===2?"1.2 0.8":"none"),ae(E,`--edge-delay: ${e(d)*80}ms`)}),m(g,E)};j(k,g=>{e(c)&&e(p)&&g(w)})}m(s,u)}),t(ee);var S=i(ee,2);ge(S,17,()=>e(U),s=>s.id,(s,r)=>{var d=vt();let c;R((p,u,k,w,g)=>{c=_e(d,1,"memory-card svelte-1cq1ntk",null,c,{"is-pulsing":e(l)===3&&e(r).strengthened,"is-pruning":e(l)===4&&e(r).pruned,"is-transferring":e(l)===5,"semantic-side":e(l)===5&&e(r).transferIsSemantic}),ae(d,` +import"../chunks/Bzak7iHL.js";import{p as qe,d as a,r as t,e as i,g as e,f as he,u as b,t as R,a as Se,n as X,h as de,s as be,W as Ge,aG as Ae}from"../chunks/CpWkWWOo.js";import{s as x,d as Ye,a as pe}from"../chunks/BlVfL1ME.js";import{c as Fe,a as m,f as _,b as Ve,t as Ie}from"../chunks/CHOnp4oo.js";import{i as j}from"../chunks/B4yTwGkE.js";import{e as ge}from"../chunks/CGEBXrjl.js";import{h as We}from"../chunks/C4h_mRt2.js";import{s as A,r as Be,a as Xe}from"../chunks/A7po6GxK.js";import{s as _e}from"../chunks/aVbAZ-t7.js";import{a as ze}from"../chunks/B7CfdQuM.js";import{s as ae}from"../chunks/Cx-f-Pzo.js";import{p as Ue}from"../chunks/V6gjw5Ec.js";import{b as Je}from"../chunks/D8UfWY0j.js";const Te=5,je=["Replay","Cross-reference","Strengthen","Prune","Transfer"];function Oe(v){if(!Number.isFinite(v))return 1;const n=Math.floor(v);return n<1?1:n>Te?Te:n}const Ke=.3,Qe=.7;function Ze(v){const n=ye(v);return n>Qe?"high":n1?1:v}function $e(v){return v==null||!Number.isFinite(v)||v<0?"0ms":v<1e3?`${Math.round(v)}ms`:`${(v/1e3).toFixed(2)}s`}function et(v){const n=ye(v);return`${Math.round(n*100)}%`}function tt(v,n=""){return`${n}/memories/${v}`}function st(v,n=2){return!v||v.length===0?[]:v.slice(0,Math.max(0,n))}function at(v,n=2){return v?Math.max(0,v.length-n):0}function rt(v){return v?v.length>8?v.slice(0,8):v:""}var nt=_('
      Episodic hippocampus
      Semantic cortex
      ',1),it=Ve(''),vt=_('
      '),lt=_(''),ot=_('Replaying memories'),ct=_('New connections found: '),dt=_('Strengthened: '),ut=_('Compressed: '),ft=_('Connections persisted: Insights: ',1),mt=_('
      ');function pt(v,n){qe(n,!0);const N=[{num:1,name:"Replay",color:"#818cf8",desc:"Hippocampal replay: tagged memories surface for consolidation."},{num:2,name:"Cross-reference",color:"#a855f7",desc:"Semantic proximity check — new edges discovered across memories."},{num:3,name:"Strengthen",color:"#c084fc",desc:"Co-activated memories strengthen; FSRS stability grows."},{num:4,name:"Prune",color:"#ef4444",desc:"Low-retention redundant memories compressed or released."},{num:5,name:"Transfer",color:"#10b981",desc:"Episodic → semantic consolidation (hippocampus → cortex)."}];let l=b(()=>Oe(n.stage)),f=b(()=>N[e(l)-1]),q=b(()=>{if(!n.dreamResult)return 8;const s=n.dreamResult.memoriesReplayed??8;return Math.max(6,Math.min(12,s))}),re=b(()=>{var r;if(!n.dreamResult)return 5;const s=((r=n.dreamResult.stats)==null?void 0:r.newConnectionsFound)??5;return Math.max(3,Math.min(e(q),s))}),z=b(()=>{var r;if(!n.dreamResult)return Math.ceil(e(q)*.5);const s=((r=n.dreamResult.stats)==null?void 0:r.memoriesStrengthened)??Math.ceil(e(q)*.5);return Math.max(1,Math.min(e(q),s))}),ne=b(()=>{var r;if(!n.dreamResult)return Math.ceil(e(q)*.25);const s=((r=n.dreamResult.stats)==null?void 0:r.memoriesCompressed)??Math.ceil(e(q)*.25);return Math.max(1,Math.min(Math.floor(e(q)/2),s))});function C(s,r=0){const d=Math.sin((s+1)*9301+49297+r*233)*233280;return d-Math.floor(d)}let U=b(()=>{const s=[],r=Math.ceil(Math.sqrt(e(q))),d=Math.ceil(e(q)/r);for(let c=0;c{const s=[],r=e(U).length;for(let d=0;d{var r=nt();X(4),m(s,r)};j(le,s=>{e(l)===5&&s(oe)})}var ee=i(le,2);ge(ee,23,()=>e(L),(s,r)=>s.a+"-"+s.b+"-"+r,(s,r,d)=>{const c=b(()=>e(U)[e(r).a]),p=b(()=>e(U)[e(r).b]);var u=Fe(),k=he(u);{var w=g=>{const M=b(()=>I(e(c))),h=b(()=>F(e(c))),V=b(()=>I(e(p))),ke=b(()=>F(e(p)));var E=it();R(()=>{A(E,"x1",e(M)),A(E,"y1",e(h)),A(E,"x2",e(V)),A(E,"y2",e(ke)),A(E,"stroke",e(f).color),A(E,"stroke-width",e(l)===2?.25:e(l)===3?.35:.2),A(E,"stroke-opacity",e(l)<2?0:e(l)===4?.25:e(l)===5?.15:.6),A(E,"stroke-dasharray",e(l)===2?"1.2 0.8":"none"),ae(E,`--edge-delay: ${e(d)*80}ms`)}),m(g,E)};j(k,g=>{e(c)&&e(p)&&g(w)})}m(s,u)}),t(ee);var S=i(ee,2);ge(S,17,()=>e(U),s=>s.id,(s,r)=>{var d=vt();let c;R((p,u,k,w,g)=>{c=_e(d,1,"memory-card svelte-1cq1ntk",null,c,{"is-pulsing":e(l)===3&&e(r).strengthened,"is-pruning":e(l)===4&&e(r).pruned,"is-transferring":e(l)===5,"semantic-side":e(l)===5&&e(r).transferIsSemantic}),ae(d,` left: ${p??""}%; top: ${u??""}%; opacity: ${k??""}; diff --git a/apps/dashboard/build/_app/immutable/nodes/6.54m-BxV_.js.br b/apps/dashboard/build/_app/immutable/nodes/6.54m-BxV_.js.br new file mode 100644 index 0000000..0dc56f0 Binary files /dev/null and b/apps/dashboard/build/_app/immutable/nodes/6.54m-BxV_.js.br differ diff --git a/apps/dashboard/build/_app/immutable/nodes/6.54m-BxV_.js.gz b/apps/dashboard/build/_app/immutable/nodes/6.54m-BxV_.js.gz new file mode 100644 index 0000000..9a8e1e6 Binary files /dev/null and b/apps/dashboard/build/_app/immutable/nodes/6.54m-BxV_.js.gz differ diff --git a/apps/dashboard/build/_app/immutable/nodes/6.DTUGCA1p.js.br b/apps/dashboard/build/_app/immutable/nodes/6.DTUGCA1p.js.br deleted file mode 100644 index 103008a..0000000 Binary files a/apps/dashboard/build/_app/immutable/nodes/6.DTUGCA1p.js.br and /dev/null differ diff --git a/apps/dashboard/build/_app/immutable/nodes/6.DTUGCA1p.js.gz b/apps/dashboard/build/_app/immutable/nodes/6.DTUGCA1p.js.gz deleted file mode 100644 index 50ce680..0000000 Binary files a/apps/dashboard/build/_app/immutable/nodes/6.DTUGCA1p.js.gz and /dev/null differ diff --git a/apps/dashboard/build/_app/immutable/nodes/7.jHtvjgRi.js b/apps/dashboard/build/_app/immutable/nodes/7.2YrTacps.js similarity index 99% rename from apps/dashboard/build/_app/immutable/nodes/7.jHtvjgRi.js rename to apps/dashboard/build/_app/immutable/nodes/7.2YrTacps.js index 65bc749..bf4106d 100644 --- a/apps/dashboard/build/_app/immutable/nodes/7.jHtvjgRi.js +++ b/apps/dashboard/build/_app/immutable/nodes/7.2YrTacps.js @@ -1,4 +1,4 @@ -import"../chunks/Bzak7iHL.js";import{o as Ce,a as Re}from"../chunks/GG5zm9kr.js";import{p as _e,f as we,g as e,a as ke,u as I,d as r,r as n,e as o,t as S,h as w,s as K,c as xe,n as he}from"../chunks/CpWkWWOo.js";import{d as Te,s as g,a as z}from"../chunks/BlVfL1ME.js";import{i as G}from"../chunks/B4yTwGkE.js";import{e as oe,i as Ae}from"../chunks/CGEBXrjl.js";import{c as Le,a as v,f as m}from"../chunks/CHOnp4oo.js";import{s as se,r as Fe}from"../chunks/A7po6GxK.js";import{b as Ne}from"../chunks/sZcqyNBA.js";import{s as pe}from"../chunks/aVbAZ-t7.js";import{s as re}from"../chunks/Cx-f-Pzo.js";import{N as Ze}from"../chunks/DzfRjky4.js";function De(s){return s>=.92?"near-identical":s>=.8?"strong":"weak"}function ge(s){const i=De(s);return i==="near-identical"?"var(--color-decay)":i==="strong"?"var(--color-warning)":"#fde047"}function Ee(s){const i=De(s);return i==="near-identical"?"Near-identical":i==="strong"?"Strong match":"Weak match"}function Pe(s){return s>.7?"#10b981":s>.4?"#f59e0b":"#ef4444"}function Be(s){if(!s||s.length===0)return null;let i=s[0],d=Number.isFinite(i.retention)?i.retention:-1/0;for(let u=1;ud&&(i=b,d=_)}return i}function Ie(s,i){return s.filter(d=>d.similarity>=i)}function be(s){return s.map(i=>i.id).slice().sort().join("|")}function Oe(s,i=80){if(!s)return"";const d=s.trim().replace(/\s+/g," ");return d.length<=i?d:d.slice(0,i)+"…"}function ye(s){if(!s||typeof s!="string")return"";const i=new Date(s);return Number.isNaN(i.getTime())?"":i.toLocaleDateString(void 0,{year:"numeric",month:"short",day:"numeric"})}function He(s,i=4){return Array.isArray(s)?s.slice(0,i):[]}var Ue=m('WINNER'),We=m(' '),je=m('
      '),Ke=m('

      '),ze=m('
      ');function Ge(s,i){_e(i,!0);let d=K(!1);const u=I(()=>Be(i.memories)),b=I(()=>e(u)?i.memories.filter(x=>x.id!==e(u).id).map(x=>x.id):[]);function _(){i.onMerge&&e(u)&&i.onMerge(e(u).id,e(b))}var A=Le(),V=we(A);{var le=x=>{var X=ze(),O=r(X),Y=r(O),h=r(Y),C=r(h),ee=r(C);n(C);var H=o(C,2),de=r(H,!0);n(H);var U=o(H,2),q=r(U);n(U),n(h);var R=o(h,2),W=r(R);n(R),n(Y);var j=o(Y,2),ce=r(j);n(j),n(O);var L=o(O,2);oe(L,21,()=>i.memories,k=>k.id,(k,c)=>{var D=Ke(),N=r(D),Z=o(N,2),t=r(Z),a=r(t),l=r(a,!0);n(a);var p=o(a,2);{var E=y=>{var T=Ue();v(y,T)};G(p,y=>{e(c).id===e(u).id&&y(E)})}var M=o(p,2);oe(M,17,()=>He(e(c).tags,4),Ae,(y,T)=>{var B=We(),me=r(B,!0);n(B),S(()=>g(me,e(T))),v(y,B)}),n(t);var f=o(t,2),P=r(f,!0);n(f);var ae=o(f,2);{var Q=y=>{var T=je(),B=r(T,!0);n(T),S(me=>g(B,me),[()=>ye(e(c).createdAt)]),v(y,T)},ve=I(()=>ye(e(c).createdAt));G(ae,y=>{e(ve)&&y(Q)})}n(Z);var ne=o(Z,2),$=r(ne),Me=r($);n($);var fe=o($,2),Se=r(fe);n(fe),n(ne),n(D),S((y,T,B)=>{pe(D,1,`group flex items-start gap-3 rounded-xl border border-synapse/5 bg-white/[0.02] p-3 transition-all duration-200 hover:border-synapse/20 hover:bg-white/[0.04] ${e(c).id===e(u).id?"ring-1 ring-recall/30":""}`),re(N,`background: ${(Ze[e(c).nodeType]||"#8B95A5")??""}`),se(N,"title",e(c).nodeType),g(l,e(c).nodeType),pe(f,1,`text-sm text-text leading-relaxed ${e(d)?"whitespace-pre-wrap":""}`),g(P,y),re(Me,`width: ${e(c).retention*100}%; background: ${T??""}`),g(Se,`${B??""}%`)},[()=>e(d)?e(c).content:Oe(e(c).content),()=>Pe(e(c).retention),()=>(e(c).retention*100).toFixed(0)]),v(k,D)}),n(L);var te=o(L,2),J=r(te),F=o(J,2),ue=r(F,!0);n(F);var ie=o(F,2);n(te),n(X),S((k,c,D,N,Z,t,a,l)=>{re(C,`color: ${k??""}`),g(ee,`${c??""}%`),g(de,D),g(q,`· ${i.memories.length??""} memories`),se(R,"aria-valuenow",N),re(W,`width: ${Z??""}%; background: ${t??""}; box-shadow: 0 0 12px ${a??""}66`),pe(j,1,`flex-shrink-0 rounded-full border px-3 py-1 text-xs font-medium ${i.suggestedAction==="merge"?"border-recall/40 bg-recall/10 text-recall":"border-dream-glow/40 bg-dream/10 text-dream-glow"}`),g(ce,`Suggested: ${i.suggestedAction==="merge"?"Merge":"Review"}`),se(J,"title",`Merge all into highest-retention memory (${l??""}%)`),se(F,"aria-expanded",e(d)),g(ue,e(d)?"Collapse":"Review")},[()=>ge(i.similarity),()=>(i.similarity*100).toFixed(1),()=>Ee(i.similarity),()=>Math.round(i.similarity*100),()=>(i.similarity*100).toFixed(1),()=>ge(i.similarity),()=>ge(i.similarity),()=>(e(u).retention*100).toFixed(0)]),z("click",J,_),z("click",F,()=>w(d,!e(d))),z("click",ie,function(...k){var c;(c=i.onDismiss)==null||c.apply(this,k)}),v(x,X)};G(V,x=>{i.memories.length>0&&e(u)&&x(le)})}v(s,A),ke()}Te(["click"]);var Ve=m(' Detecting…',1),Xe=m(' Error',1),Ye=m(' ',1),qe=m(`
      Couldn't detect duplicates
      `),Je=m('
      '),Qe=m('
      '),$e=m('
      ·
      No duplicates found above threshold.
      Memory is clean.
      '),et=m('
      '),tt=m('
      '),it=m('
      '),at=m(`

      Memory Hygiene — Duplicate Detection

      Cosine-similarity clustering over embeddings. Merges reinforce the winner's FSRS state; +import"../chunks/Bzak7iHL.js";import{o as Ce,a as Re}from"../chunks/GG5zm9kr.js";import{p as _e,f as we,g as e,a as ke,u as I,d as r,r as n,e as o,t as S,h as w,s as K,c as xe,n as he}from"../chunks/CpWkWWOo.js";import{d as Te,s as g,a as z}from"../chunks/BlVfL1ME.js";import{i as G}from"../chunks/B4yTwGkE.js";import{e as oe,i as Ae}from"../chunks/CGEBXrjl.js";import{c as Le,a as v,f as m}from"../chunks/CHOnp4oo.js";import{s as se,r as Fe}from"../chunks/A7po6GxK.js";import{b as Ne}from"../chunks/sZcqyNBA.js";import{s as pe}from"../chunks/aVbAZ-t7.js";import{s as re}from"../chunks/Cx-f-Pzo.js";import{N as Ze}from"../chunks/CcUbQ_Wl.js";function De(s){return s>=.92?"near-identical":s>=.8?"strong":"weak"}function ge(s){const i=De(s);return i==="near-identical"?"var(--color-decay)":i==="strong"?"var(--color-warning)":"#fde047"}function Ee(s){const i=De(s);return i==="near-identical"?"Near-identical":i==="strong"?"Strong match":"Weak match"}function Pe(s){return s>.7?"#10b981":s>.4?"#f59e0b":"#ef4444"}function Be(s){if(!s||s.length===0)return null;let i=s[0],d=Number.isFinite(i.retention)?i.retention:-1/0;for(let u=1;ud&&(i=b,d=_)}return i}function Ie(s,i){return s.filter(d=>d.similarity>=i)}function be(s){return s.map(i=>i.id).slice().sort().join("|")}function Oe(s,i=80){if(!s)return"";const d=s.trim().replace(/\s+/g," ");return d.length<=i?d:d.slice(0,i)+"…"}function ye(s){if(!s||typeof s!="string")return"";const i=new Date(s);return Number.isNaN(i.getTime())?"":i.toLocaleDateString(void 0,{year:"numeric",month:"short",day:"numeric"})}function He(s,i=4){return Array.isArray(s)?s.slice(0,i):[]}var Ue=m('WINNER'),We=m(' '),je=m('

      '),Ke=m('

      '),ze=m('
      ');function Ge(s,i){_e(i,!0);let d=K(!1);const u=I(()=>Be(i.memories)),b=I(()=>e(u)?i.memories.filter(x=>x.id!==e(u).id).map(x=>x.id):[]);function _(){i.onMerge&&e(u)&&i.onMerge(e(u).id,e(b))}var A=Le(),V=we(A);{var le=x=>{var X=ze(),O=r(X),Y=r(O),h=r(Y),C=r(h),ee=r(C);n(C);var H=o(C,2),de=r(H,!0);n(H);var U=o(H,2),q=r(U);n(U),n(h);var R=o(h,2),W=r(R);n(R),n(Y);var j=o(Y,2),ce=r(j);n(j),n(O);var L=o(O,2);oe(L,21,()=>i.memories,k=>k.id,(k,c)=>{var D=Ke(),N=r(D),Z=o(N,2),t=r(Z),a=r(t),l=r(a,!0);n(a);var p=o(a,2);{var E=y=>{var T=Ue();v(y,T)};G(p,y=>{e(c).id===e(u).id&&y(E)})}var M=o(p,2);oe(M,17,()=>He(e(c).tags,4),Ae,(y,T)=>{var B=We(),me=r(B,!0);n(B),S(()=>g(me,e(T))),v(y,B)}),n(t);var f=o(t,2),P=r(f,!0);n(f);var ae=o(f,2);{var Q=y=>{var T=je(),B=r(T,!0);n(T),S(me=>g(B,me),[()=>ye(e(c).createdAt)]),v(y,T)},ve=I(()=>ye(e(c).createdAt));G(ae,y=>{e(ve)&&y(Q)})}n(Z);var ne=o(Z,2),$=r(ne),Me=r($);n($);var fe=o($,2),Se=r(fe);n(fe),n(ne),n(D),S((y,T,B)=>{pe(D,1,`group flex items-start gap-3 rounded-xl border border-synapse/5 bg-white/[0.02] p-3 transition-all duration-200 hover:border-synapse/20 hover:bg-white/[0.04] ${e(c).id===e(u).id?"ring-1 ring-recall/30":""}`),re(N,`background: ${(Ze[e(c).nodeType]||"#8B95A5")??""}`),se(N,"title",e(c).nodeType),g(l,e(c).nodeType),pe(f,1,`text-sm text-text leading-relaxed ${e(d)?"whitespace-pre-wrap":""}`),g(P,y),re(Me,`width: ${e(c).retention*100}%; background: ${T??""}`),g(Se,`${B??""}%`)},[()=>e(d)?e(c).content:Oe(e(c).content),()=>Pe(e(c).retention),()=>(e(c).retention*100).toFixed(0)]),v(k,D)}),n(L);var te=o(L,2),J=r(te),F=o(J,2),ue=r(F,!0);n(F);var ie=o(F,2);n(te),n(X),S((k,c,D,N,Z,t,a,l)=>{re(C,`color: ${k??""}`),g(ee,`${c??""}%`),g(de,D),g(q,`· ${i.memories.length??""} memories`),se(R,"aria-valuenow",N),re(W,`width: ${Z??""}%; background: ${t??""}; box-shadow: 0 0 12px ${a??""}66`),pe(j,1,`flex-shrink-0 rounded-full border px-3 py-1 text-xs font-medium ${i.suggestedAction==="merge"?"border-recall/40 bg-recall/10 text-recall":"border-dream-glow/40 bg-dream/10 text-dream-glow"}`),g(ce,`Suggested: ${i.suggestedAction==="merge"?"Merge":"Review"}`),se(J,"title",`Merge all into highest-retention memory (${l??""}%)`),se(F,"aria-expanded",e(d)),g(ue,e(d)?"Collapse":"Review")},[()=>ge(i.similarity),()=>(i.similarity*100).toFixed(1),()=>Ee(i.similarity),()=>Math.round(i.similarity*100),()=>(i.similarity*100).toFixed(1),()=>ge(i.similarity),()=>ge(i.similarity),()=>(e(u).retention*100).toFixed(0)]),z("click",J,_),z("click",F,()=>w(d,!e(d))),z("click",ie,function(...k){var c;(c=i.onDismiss)==null||c.apply(this,k)}),v(x,X)};G(V,x=>{i.memories.length>0&&e(u)&&x(le)})}v(s,A),ke()}Te(["click"]);var Ve=m(' Detecting…',1),Xe=m(' Error',1),Ye=m(' ',1),qe=m(`
      Couldn't detect duplicates
      `),Je=m('
      '),Qe=m('
      '),$e=m('
      ·
      No duplicates found above threshold.
      Memory is clean.
      '),et=m('
      '),tt=m('
      '),it=m('
      '),at=m(`

      Memory Hygiene — Duplicate Detection

      Cosine-similarity clustering over embeddings. Merges reinforce the winner's FSRS state; losers inherit into the merged node. Dismissed clusters are hidden for this session only.

      `);function ft(s,i){_e(i,!0);let d=K(.8),u=K(xe([])),b=K(xe(new Set)),_=K(!0),A=K(null),V;async function le(t){return await new Promise(l=>setTimeout(l,450)),{clusters:Ie([{similarity:.96,suggestedAction:"merge",memories:[{id:"m-001",content:"BUG FIX: Harmony parser dropped `final` channel tokens when tool call followed. Root cause: 5-layer fallback missed the final channel marker when channel switched mid-stream. Solution: added final-channel detector before tool-call pop. Files: src/parser/harmony.rs",nodeType:"fact",tags:["bug-fix","benchmark-suite","parser"],retention:.91,createdAt:"2026-04-12T14:22:00Z"},{id:"m-002",content:"Fixed Harmony parser final-channel bug — 5-layer fallback was missing the final channel marker when a tool call followed. Added detector before tool pop.",nodeType:"fact",tags:["bug-fix","benchmark-suite"],retention:.64,createdAt:"2026-04-13T09:15:00Z"},{id:"m-003",content:"Harmony parser: final channel dropped on tool-call. Patched the fallback stack.",nodeType:"note",tags:["parser"],retention:.38,createdAt:"2026-04-14T11:02:00Z"}]},{similarity:.88,suggestedAction:"merge",memories:[{id:"m-004",content:"DECISION: Use vLLM prefix caching at 0.35 gpu_memory_utilization for benchmark suite submissions. Alternatives considered: sglang (slower cold start), TensorRT-LLM (deployment friction).",nodeType:"decision",tags:["vllm","benchmark-suite","inference"],retention:.84,createdAt:"2026-04-05T18:44:00Z"},{id:"m-005",content:"Chose vLLM with prefix caching (0.35 mem util) over sglang and TensorRT-LLM for benchmark suite inference.",nodeType:"decision",tags:["vllm","benchmark-suite"],retention:.72,createdAt:"2026-04-06T10:30:00Z"}]},{similarity:.83,suggestedAction:"review",memories:[{id:"m-006",content:"Release process prefers one change per benchmark submission — stacking changes destroyed signal in a prior run.",nodeType:"pattern",tags:["methodology","benchmark-suite"],retention:.88,createdAt:"2026-04-04T22:10:00Z"},{id:"m-007",content:"One-variable-at-a-time rule: never stack multiple changes per submission. Paper 2603.27844 proves +/-2 points is noise.",nodeType:"pattern",tags:["kaggle","methodology"],retention:.67,createdAt:"2026-04-08T16:20:00Z"},{id:"m-008",content:"Lesson: stacking many changes in one benchmark run hid the causal signal. Always isolate variables.",nodeType:"note",tags:["methodology"],retention:.42,createdAt:"2026-04-15T08:55:00Z"}]},{similarity:.78,suggestedAction:"review",memories:[{id:"m-009",content:"Dimensional Illusion performance: 7-minute flow poi set, LED config Parthenos overcook preset, tempo 128 BPM.",nodeType:"event",tags:["dimensional-illusion","poi","performance"],retention:.76,createdAt:"2026-03-28T19:45:00Z"},{id:"m-010",content:"Dimensional Illusion set: 7 min, Parthenos LED overcook, 128 BPM.",nodeType:"event",tags:["dimensional-illusion","poi"],retention:.51,createdAt:"2026-04-02T12:12:00Z"}]},{similarity:.76,suggestedAction:"review",memories:[{id:"m-011",content:"Vestige v2.0.7 shipped active forgetting via Anderson 2025 top-down inhibition + Davis Rac1 cascade. Suppress compounds, reversible 24h.",nodeType:"fact",tags:["vestige","release","active-forgetting"],retention:.93,createdAt:"2026-04-17T03:22:00Z"},{id:"m-012",content:"Active Forgetting feature: compounds on each suppress, 24h reversible labile window, violet implosion animation in graph view.",nodeType:"concept",tags:["vestige","active-forgetting"],retention:.81,createdAt:"2026-04-18T09:07:00Z"}]}],t)}}async function x(){w(_,!0),w(A,null);try{const t=await le(e(d));w(u,t.clusters,!0);const a=new Set(e(u).map(p=>be(p.memories))),l=new Set;for(const p of e(b))a.has(p)&&l.add(p);w(b,l,!0)}catch(t){w(A,t instanceof Error?t.message:"Failed to detect duplicates",!0),w(u,[],!0)}finally{w(_,!1)}}function X(){clearTimeout(V),V=setTimeout(x,250)}function O(t){const a=new Set(e(b));a.add(t),w(b,a,!0)}function Y(t,a,l){console.log("Merge cluster",t,{winnerId:a,loserIds:l}),O(t)}const h=I(()=>e(u).map(t=>({c:t,key:be(t.memories)})).filter(({key:t})=>!e(b).has(t))),C=I(()=>e(h).reduce((t,{c:a})=>t+a.memories.length,0)),ee=50,H=I(()=>e(h).length>ee),de=I(()=>e(H)?e(h).slice(0,ee):e(h));Ce(()=>x()),Re(()=>clearTimeout(V));var U=at(),q=o(r(U),2),R=r(q),W=o(r(R),2);Fe(W);var j=o(W,2),ce=r(j);n(j),n(R);var L=o(R,2),te=r(L);{var J=t=>{var a=Ve();he(2),v(t,a)},F=t=>{var a=Xe();he(2),v(t,a)},ue=t=>{var a=Ye(),l=o(we(a),2),p=r(l);n(l),S(()=>g(p,`${e(h).length??""} ${e(h).length===1?"cluster":"clusters"}, ${e(C)??""} potential duplicate${e(C)===1?"":"s"}`)),v(t,a)};G(te,t=>{e(_)?t(J):e(A)?t(F,1):t(ue,!1)})}n(L);var ie=o(L,2);n(q);var k=o(q,2);{var c=t=>{var a=qe(),l=o(r(a),2),p=r(l,!0);n(l);var E=o(l,2);n(a),S(()=>g(p,e(A))),z("click",E,x),v(t,a)},D=t=>{var a=Qe();oe(a,20,()=>Array(3),Ae,(l,p)=>{var E=Je();v(l,E)}),n(a),v(t,a)},N=t=>{var a=$e();v(t,a)},Z=t=>{var a=it(),l=r(a);{var p=M=>{var f=et(),P=r(f);n(f),S(()=>g(P,`Showing first 50 of ${e(h).length??""} clusters. Raise the diff --git a/apps/dashboard/build/_app/immutable/nodes/7.2YrTacps.js.br b/apps/dashboard/build/_app/immutable/nodes/7.2YrTacps.js.br new file mode 100644 index 0000000..30a09c9 Binary files /dev/null and b/apps/dashboard/build/_app/immutable/nodes/7.2YrTacps.js.br differ diff --git a/apps/dashboard/build/_app/immutable/nodes/7.2YrTacps.js.gz b/apps/dashboard/build/_app/immutable/nodes/7.2YrTacps.js.gz new file mode 100644 index 0000000..41058ca Binary files /dev/null and b/apps/dashboard/build/_app/immutable/nodes/7.2YrTacps.js.gz differ diff --git a/apps/dashboard/build/_app/immutable/nodes/7.jHtvjgRi.js.br b/apps/dashboard/build/_app/immutable/nodes/7.jHtvjgRi.js.br deleted file mode 100644 index 3b795a7..0000000 Binary files a/apps/dashboard/build/_app/immutable/nodes/7.jHtvjgRi.js.br and /dev/null differ diff --git a/apps/dashboard/build/_app/immutable/nodes/7.jHtvjgRi.js.gz b/apps/dashboard/build/_app/immutable/nodes/7.jHtvjgRi.js.gz deleted file mode 100644 index 8943186..0000000 Binary files a/apps/dashboard/build/_app/immutable/nodes/7.jHtvjgRi.js.gz and /dev/null differ diff --git a/apps/dashboard/build/_app/immutable/nodes/8.CgPowUzz.js.br b/apps/dashboard/build/_app/immutable/nodes/8.CgPowUzz.js.br deleted file mode 100644 index 3fdec98..0000000 Binary files a/apps/dashboard/build/_app/immutable/nodes/8.CgPowUzz.js.br and /dev/null differ diff --git a/apps/dashboard/build/_app/immutable/nodes/8.CgPowUzz.js.gz b/apps/dashboard/build/_app/immutable/nodes/8.CgPowUzz.js.gz deleted file mode 100644 index a7c32e9..0000000 Binary files a/apps/dashboard/build/_app/immutable/nodes/8.CgPowUzz.js.gz and /dev/null differ diff --git a/apps/dashboard/build/_app/immutable/nodes/8.CgPowUzz.js b/apps/dashboard/build/_app/immutable/nodes/8.DGKslLJe.js similarity index 99% rename from apps/dashboard/build/_app/immutable/nodes/8.CgPowUzz.js rename to apps/dashboard/build/_app/immutable/nodes/8.DGKslLJe.js index 6e3406e..d57b207 100644 --- a/apps/dashboard/build/_app/immutable/nodes/8.CgPowUzz.js +++ b/apps/dashboard/build/_app/immutable/nodes/8.DGKslLJe.js @@ -1,4 +1,4 @@ -import"../chunks/Bzak7iHL.js";import{p as ze,s as I,c as Ae,g as e,a as Pe,d as r,e as a,h as b,r as t,i as Qe,t as y,f as ge,u as se,j as qe}from"../chunks/CpWkWWOo.js";import{d as Be,a as q,s as o}from"../chunks/BlVfL1ME.js";import{a as c,f as m,c as De}from"../chunks/CHOnp4oo.js";import{i as k}from"../chunks/B4yTwGkE.js";import{e as ie,i as ne}from"../chunks/CGEBXrjl.js";import{r as ye}from"../chunks/A7po6GxK.js";import{s as oe}from"../chunks/aVbAZ-t7.js";import{s as Ke}from"../chunks/Cx-f-Pzo.js";import{b as de}from"../chunks/sZcqyNBA.js";import{a as X}from"../chunks/DNjM5a-l.js";var Re=m(''),Ue=m('
      Source

      '),Ve=m('
      Target

      '),Ge=m(`
      Target Memory
      '),Ue=m('
      Source

      '),Ve=m('
      Target

      '),Ge=m(`
      Target Memory
      `,1),He=m('

      '),Je=m(' '),Le=m(" "),We=m(" "),Xe=m(" "),Ye=m(' '),Ze=m('

      '),et=m('

      '),tt=m('

      No connections found for this query.

      '),rt=m('
      '),at=m('
      '),st=m('
      '),it=m(`

      Explore Connections

      Source Memory

      Importance Scorer

      4-channel neuroscience scoring: novelty, arousal, reward, attention

      `);function ft(he,we){ze(we,!0);let V=I(""),G=I(""),F=I(null),C=I(null),B=I(Ae([])),$=I("associations"),O=I(!1),H=I(""),D=I(null);const le={associations:{icon:"◎",desc:"Spreading activation — find related memories via graph traversal"},chains:{icon:"⟿",desc:"Build reasoning path from source to target memory"},bridges:{icon:"⬡",desc:"Find connecting memories between two concepts"}};async function ve(){if(e(V).trim()){b(O,!0);try{const s=await X.search(e(V),1);s.results.length>0&&(b(F,s.results[0],!0),await Y())}catch{}finally{b(O,!1)}}}async function pe(){if(e(G).trim()){b(O,!0);try{const s=await X.search(e(G),1);s.results.length>0&&(b(C,s.results[0],!0),e(F)&&await Y())}catch{}finally{b(O,!1)}}}async function Y(){if(e(F)){b(O,!0);try{const s=(e($)==="chains"||e($)==="bridges")&&e(C)?e(C).id:void 0,i=await X.explore(e(F).id,e($),s);b(B,i.results||i.nodes||i.chain||i.bridges||[],!0)}catch{b(B,[],!0)}finally{b(O,!1)}}}async function ke(){e(H).trim()&&b(D,await X.importance(e(H)),!0)}function Se(s){b($,s,!0),e(F)&&Y()}var Z=it(),ee=a(r(Z),2);ie(ee,20,()=>["associations","chains","bridges"],ne,(s,i)=>{var d=Re(),_=r(d),h=r(_,!0);t(_);var f=a(_,2),p=r(f,!0);t(f);var n=a(f,2),g=r(n,!0);t(n),t(d),y(w=>{oe(d,1,`flex flex-col items-center gap-1 p-3 rounded-xl text-sm transition diff --git a/apps/dashboard/build/_app/immutable/nodes/8.DGKslLJe.js.br b/apps/dashboard/build/_app/immutable/nodes/8.DGKslLJe.js.br new file mode 100644 index 0000000..94fe0cd Binary files /dev/null and b/apps/dashboard/build/_app/immutable/nodes/8.DGKslLJe.js.br differ diff --git a/apps/dashboard/build/_app/immutable/nodes/8.DGKslLJe.js.gz b/apps/dashboard/build/_app/immutable/nodes/8.DGKslLJe.js.gz new file mode 100644 index 0000000..57e055d Binary files /dev/null and b/apps/dashboard/build/_app/immutable/nodes/8.DGKslLJe.js.gz differ diff --git a/apps/dashboard/build/_app/immutable/nodes/9.BWaJ-VBd.js.br b/apps/dashboard/build/_app/immutable/nodes/9.BWaJ-VBd.js.br deleted file mode 100644 index 36cc5aa..0000000 Binary files a/apps/dashboard/build/_app/immutable/nodes/9.BWaJ-VBd.js.br and /dev/null differ diff --git a/apps/dashboard/build/_app/immutable/nodes/9.BWaJ-VBd.js.gz b/apps/dashboard/build/_app/immutable/nodes/9.BWaJ-VBd.js.gz deleted file mode 100644 index 4766f92..0000000 Binary files a/apps/dashboard/build/_app/immutable/nodes/9.BWaJ-VBd.js.gz and /dev/null differ diff --git a/apps/dashboard/build/_app/immutable/nodes/9.BWaJ-VBd.js b/apps/dashboard/build/_app/immutable/nodes/9.Vu2AXN40.js similarity index 99% rename from apps/dashboard/build/_app/immutable/nodes/9.BWaJ-VBd.js rename to apps/dashboard/build/_app/immutable/nodes/9.Vu2AXN40.js index 4378a5e..d3ddfe5 100644 --- a/apps/dashboard/build/_app/immutable/nodes/9.BWaJ-VBd.js +++ b/apps/dashboard/build/_app/immutable/nodes/9.Vu2AXN40.js @@ -1,4 +1,4 @@ -import"../chunks/Bzak7iHL.js";import{i as oe}from"../chunks/BUoSzNdg.js";import{p as ee,aB as ie,g as e,h as A,d as o,e as d,r as s,f as ne,t as $,a as te,s as K,u as X,A as Z}from"../chunks/CpWkWWOo.js";import{s as x,d as de,a as ce}from"../chunks/BlVfL1ME.js";import{a as l,f as m}from"../chunks/CHOnp4oo.js";import{i as w}from"../chunks/B4yTwGkE.js";import{e as re,i as ae}from"../chunks/CGEBXrjl.js";import{s as C}from"../chunks/Cx-f-Pzo.js";import{s as le,a as me}from"../chunks/C6HuKgyx.js";import{w as ve,e as ue}from"../chunks/MAY1QfFZ.js";import{E as O}from"../chunks/DzfRjky4.js";import{s as pe}from"../chunks/A7po6GxK.js";import{s as fe}from"../chunks/aVbAZ-t7.js";import{p as Q}from"../chunks/V6gjw5Ec.js";var xe=m(' '),_e=m('
      '),ge=m('
      ',1),he=m('
      '),ye=m('
      '),$e=m('
      Cognitive Search Pipeline
      ');function be(q,F){ee(F,!0);let S=Q(F,"resultCount",3,0),j=Q(F,"durationMs",3,0),I=Q(F,"active",3,!1);const p=[{name:"Overfetch",icon:"◎",color:"#818CF8",desc:"Pull 3x results from hybrid search"},{name:"Rerank",icon:"⟿",color:"#00A8FF",desc:"Re-score by relevance quality"},{name:"Temporal",icon:"◷",color:"#00D4FF",desc:"Recent memories get recency bonus"},{name:"Access",icon:"◇",color:"#00FFD1",desc:"FSRS-6 retention threshold filter"},{name:"Context",icon:"◬",color:"#FFB800",desc:"Encoding specificity matching"},{name:"Compete",icon:"⬡",color:"#FF3CAC",desc:"Retrieval-induced forgetting"},{name:"Activate",icon:"◈",color:"#9D00FF",desc:"Spreading activation cascade"}];let _=K(-1),g=K(!1),u=K(!1);ie(()=>{I()&&!e(g)&&M()});function M(){A(g,!0),A(_,-1),A(u,!1);const t=Math.max(1500,(j()||50)*2),a=t/(p.length+1);p.forEach((i,v)=>{setTimeout(()=>{A(_,v,!0)},a*(v+1))}),setTimeout(()=>{A(u,!0),A(g,!1)},t)}var D=$e(),b=o(D),L=d(o(b),2);{var V=t=>{var a=xe(),i=o(a);s(a),$(()=>x(i,`${S()??""} results in ${j()??""}ms`)),l(t,a)};w(L,t=>{e(u)&&t(V)})}s(b);var P=d(b,2);re(P,21,()=>p,ae,(t,a,i)=>{const v=X(()=>i<=e(_)),E=X(()=>i===e(_)&&e(g));var k=ge(),h=ne(k),y=o(h),J=o(y,!0);s(y);var R=d(y,2),T=o(R,!0);s(R),s(h);var U=d(h,2);{var W=B=>{var c=_e();$(()=>C(c,`background: ${i{i{fe(y,1,`w-8 h-8 rounded-full flex items-center justify-center text-xs transition-all duration-300 +import"../chunks/Bzak7iHL.js";import{i as oe}from"../chunks/BUoSzNdg.js";import{p as ee,aB as ie,g as e,h as A,d as o,e as d,r as s,f as ne,t as $,a as te,s as K,u as X,A as Z}from"../chunks/CpWkWWOo.js";import{s as x,d as de,a as ce}from"../chunks/BlVfL1ME.js";import{a as l,f as m}from"../chunks/CHOnp4oo.js";import{i as w}from"../chunks/B4yTwGkE.js";import{e as re,i as ae}from"../chunks/CGEBXrjl.js";import{s as C}from"../chunks/Cx-f-Pzo.js";import{s as le,a as me}from"../chunks/C6HuKgyx.js";import{w as ve,e as ue}from"../chunks/MAY1QfFZ.js";import{E as O}from"../chunks/CcUbQ_Wl.js";import{s as pe}from"../chunks/A7po6GxK.js";import{s as fe}from"../chunks/aVbAZ-t7.js";import{p as Q}from"../chunks/V6gjw5Ec.js";var xe=m(' '),_e=m('
      '),ge=m('
      ',1),he=m('
      '),ye=m('
      '),$e=m('
      Cognitive Search Pipeline
      ');function be(q,F){ee(F,!0);let S=Q(F,"resultCount",3,0),j=Q(F,"durationMs",3,0),I=Q(F,"active",3,!1);const p=[{name:"Overfetch",icon:"◎",color:"#818CF8",desc:"Pull 3x results from hybrid search"},{name:"Rerank",icon:"⟿",color:"#00A8FF",desc:"Re-score by relevance quality"},{name:"Temporal",icon:"◷",color:"#00D4FF",desc:"Recent memories get recency bonus"},{name:"Access",icon:"◇",color:"#00FFD1",desc:"FSRS-6 retention threshold filter"},{name:"Context",icon:"◬",color:"#FFB800",desc:"Encoding specificity matching"},{name:"Compete",icon:"⬡",color:"#FF3CAC",desc:"Retrieval-induced forgetting"},{name:"Activate",icon:"◈",color:"#9D00FF",desc:"Spreading activation cascade"}];let _=K(-1),g=K(!1),u=K(!1);ie(()=>{I()&&!e(g)&&M()});function M(){A(g,!0),A(_,-1),A(u,!1);const t=Math.max(1500,(j()||50)*2),a=t/(p.length+1);p.forEach((i,v)=>{setTimeout(()=>{A(_,v,!0)},a*(v+1))}),setTimeout(()=>{A(u,!0),A(g,!1)},t)}var D=$e(),b=o(D),L=d(o(b),2);{var V=t=>{var a=xe(),i=o(a);s(a),$(()=>x(i,`${S()??""} results in ${j()??""}ms`)),l(t,a)};w(L,t=>{e(u)&&t(V)})}s(b);var P=d(b,2);re(P,21,()=>p,ae,(t,a,i)=>{const v=X(()=>i<=e(_)),E=X(()=>i===e(_)&&e(g));var k=ge(),h=ne(k),y=o(h),J=o(y,!0);s(y);var R=d(y,2),T=o(R,!0);s(R),s(h);var U=d(h,2);{var W=B=>{var c=_e();$(()=>C(c,`background: ${i{i{fe(y,1,`w-8 h-8 rounded-full flex items-center justify-center text-xs transition-all duration-300 ${e(E)?"scale-125":""}`),C(y,`background: ${e(v)?e(a).color+"25":"rgba(255,255,255,0.03)"}; border: 1.5px solid ${(e(v)?e(a).color:"rgba(255,255,255,0.06)")??""}; color: ${(e(v)?e(a).color:"#4a4a7a")??""}; diff --git a/apps/dashboard/build/_app/immutable/nodes/9.Vu2AXN40.js.br b/apps/dashboard/build/_app/immutable/nodes/9.Vu2AXN40.js.br new file mode 100644 index 0000000..90505a6 Binary files /dev/null and b/apps/dashboard/build/_app/immutable/nodes/9.Vu2AXN40.js.br differ diff --git a/apps/dashboard/build/_app/immutable/nodes/9.Vu2AXN40.js.gz b/apps/dashboard/build/_app/immutable/nodes/9.Vu2AXN40.js.gz new file mode 100644 index 0000000..433c6c1 Binary files /dev/null and b/apps/dashboard/build/_app/immutable/nodes/9.Vu2AXN40.js.gz differ diff --git a/apps/dashboard/build/_app/version.json b/apps/dashboard/build/_app/version.json index 2d5883d..d87c2dc 100644 --- a/apps/dashboard/build/_app/version.json +++ b/apps/dashboard/build/_app/version.json @@ -1 +1 @@ -{"version":"1778051833240"} \ No newline at end of file +{"version":"2.1.23"} \ No newline at end of file diff --git a/apps/dashboard/build/_app/version.json.br b/apps/dashboard/build/_app/version.json.br index 28b7338..94cb2d3 100644 Binary files a/apps/dashboard/build/_app/version.json.br and b/apps/dashboard/build/_app/version.json.br differ diff --git a/apps/dashboard/build/_app/version.json.gz b/apps/dashboard/build/_app/version.json.gz index 03b8889..f6da573 100644 Binary files a/apps/dashboard/build/_app/version.json.gz and b/apps/dashboard/build/_app/version.json.gz differ diff --git a/apps/dashboard/build/index.html b/apps/dashboard/build/index.html index 31b0831..4d5765f 100644 --- a/apps/dashboard/build/index.html +++ b/apps/dashboard/build/index.html @@ -11,13 +11,13 @@ - - + + - + - + @@ -33,7 +33,7 @@
      + +{#if visible} +
      + + + {#if expanded && receipt} +
      +
      +
      +
      Claim
      +

      {displayClaim?.text ?? receipt.draftPreview}

      +
      +
      +
      Verdict
      +

      {displayClaim?.decision ?? receipt.overall} · {displayClaim?.evidence_state ?? verdict}

      +
      +
      +
      Precedent
      +
        + {#each precedentText(displayClaim) as item} +
      • {item}
      • + {/each} +
      +
      +
      +
      Fix
      +

      {displayClaim?.fix || 'No change required.'}

      +
      +
      + +
      + Appeal + {#if appealClaim && receipt.verdictBar === 'VETO'} + + + + {:else if receipt.verdictBar === 'APPEALED'} +

      Appeal recorded.

      + {:else} +

      No appealable veto in this receipt.

      + {/if} +
      +
      + {/if} +
      +{/if} + + diff --git a/apps/dashboard/src/lib/components/__tests__/PatternTransferHeatmap.test.ts b/apps/dashboard/src/lib/components/__tests__/PatternTransferHeatmap.test.ts index 0fcf856..8f7fa32 100644 --- a/apps/dashboard/src/lib/components/__tests__/PatternTransferHeatmap.test.ts +++ b/apps/dashboard/src/lib/components/__tests__/PatternTransferHeatmap.test.ts @@ -173,14 +173,14 @@ describe('buildTransferMatrix', () => { it('aggregates transfer counts directionally', () => { const m = buildTransferMatrix(PROJECTS, PATTERNS); // vestige → api-gateway: Result + proptest = 2 - expect(m.vestige.api-gateway.count).toBe(2); + expect(m.vestige['api-gateway'].count).toBe(2); // vestige → desktop-app: Result 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)['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']); }); }); diff --git a/apps/dashboard/src/lib/stores/api.ts b/apps/dashboard/src/lib/stores/api.ts index f4b77e0..7bc22dd 100644 --- a/apps/dashboard/src/lib/stores/api.ts +++ b/apps/dashboard/src/lib/stores/api.ts @@ -12,7 +12,11 @@ import type { ConsolidationResult, IntentionItem, SuppressResult, - UnsuppressResult + UnsuppressResult, + SanhedrinAppealReason, + SanhedrinAppealResponse, + SanhedrinLatestResponse, + SanhedrinTelemetryResponse } from '$types'; const BASE = '/api'; @@ -119,5 +123,15 @@ export const api = { fetcher>('/deep_reference', { method: 'POST', body: JSON.stringify({ query, depth }) - }) + }), + + sanhedrin: { + latest: () => fetcher('/sanhedrin/latest'), + telemetry: (days = 7) => fetcher(`/sanhedrin/telemetry?days=${days}`), + appeal: (reason: SanhedrinAppealReason, note?: string, claimId?: string, receiptId?: string) => + fetcher('/sanhedrin/appeal', { + method: 'POST', + body: JSON.stringify({ reason, note, claimId, receiptId }) + }) + } }; diff --git a/apps/dashboard/src/lib/stores/toast.ts b/apps/dashboard/src/lib/stores/toast.ts index 6daef38..6bc195c 100644 --- a/apps/dashboard/src/lib/stores/toast.ts +++ b/apps/dashboard/src/lib/stores/toast.ts @@ -61,6 +61,13 @@ function createToastStore() { update(list => { const next = [entry, ...list]; if (next.length > MAX_VISIBLE) { + for (const dropped of next.slice(MAX_VISIBLE)) { + const handle = dwellTimers.get(dropped.id); + if (handle) clearTimeout(handle); + dwellTimers.delete(dropped.id); + dwellPaused.delete(dropped.id); + dwellStart.delete(dropped.id); + } return next.slice(0, MAX_VISIBLE); } return next; @@ -229,6 +236,18 @@ function createToastStore() { }; } + case 'HookVerdictRecorded': { + const verdict = String(d.verdict ?? 'NOTE'); + const reason = String(d.reason ?? 'Sanhedrin receipt updated'); + return { + type: event.type, + title: `Sanhedrin ${verdict}`, + body: reason, + color, + dwellMs: verdict === 'VETO' ? 8000 : DEFAULT_DWELL_MS, + }; + } + // Noise — never toast case 'Heartbeat': case 'SearchPerformed': diff --git a/apps/dashboard/src/lib/types/index.ts b/apps/dashboard/src/lib/types/index.ts index a76b092..4c47a16 100644 --- a/apps/dashboard/src/lib/types/index.ts +++ b/apps/dashboard/src/lib/types/index.ts @@ -167,6 +167,7 @@ export type VestigeEventType = | 'ActivationSpread' | 'ImportanceScored' | 'DeepReferenceCompleted' + | 'HookVerdictRecorded' | 'Heartbeat'; export interface VestigeEvent { @@ -202,6 +203,89 @@ export interface UnsuppressResult { stability: number; } +export type VerdictLevel = 'PASS' | 'NOTE' | 'CAUTION' | 'VETO' | 'APPEALED'; +export type SanhedrinAppealReason = 'stale' | 'wrong' | 'too_strict'; + +export interface SanhedrinAppealState { + status: 'open' | 'appealed'; + actions?: SanhedrinAppealReason[]; + lastReason?: SanhedrinAppealReason; + note?: string; +} + +export interface SanhedrinPrecedent { + type?: string; + summary?: string; + command?: string; + exitCode?: number | null; + evidence?: string; +} + +export interface SanhedrinClaim { + id: string; + text: string; + fingerprint: string; + class: string; + subject: string; + risk: string; + evidence_state: string; + decision: string; + precedent: SanhedrinPrecedent[]; + fix: string; + appeal: SanhedrinAppealState; +} + +export interface SanhedrinReceipt { + schema: string; + id: string; + draftId: string; + createdAt: string; + overall: string; + verdictBar: VerdictLevel; + summary: string; + draftPreview: string; + claims: SanhedrinClaim[]; + receipts: Array>; + source?: Record; +} + +export interface SanhedrinLatestResponse { + receipt: SanhedrinReceipt | null; + stateDir: string; + receiptPath?: string; + htmlPath?: string; + schemaWarning?: string | null; +} + +export interface SanhedrinAppealResponse { + appeal: Record; + receipt: SanhedrinReceipt; +} + +export interface SanhedrinDailyTelemetry { + date: string; + total: number; + pass: number; + note: number; + caution: number; + veto: number; + appealed: number; + failOpen: number; +} + +export interface SanhedrinTelemetryResponse { + days: number; + stateDir: string; + totalRuns: number; + byVerdict: Partial>; + byClass: Record; + appeals: number; + failOpen: number; + truncated?: boolean; + lastRunAt?: string | null; + daily: SanhedrinDailyTelemetry[]; +} + // Intentions (prospective memory) export interface IntentionItem { id: string; @@ -238,6 +322,7 @@ export const EVENT_TYPE_COLORS: Record = { Rac1CascadeSwept: '#6E3FFF', SearchPerformed: '#818CF8', DeepReferenceCompleted: '#C4B5FD', + HookVerdictRecorded: '#F59E0B', DreamStarted: '#9D00FF', DreamProgress: '#B44AFF', DreamCompleted: '#C084FC', diff --git a/apps/dashboard/src/routes/+layout.svelte b/apps/dashboard/src/routes/+layout.svelte index c00c098..ca19491 100644 --- a/apps/dashboard/src/routes/+layout.svelte +++ b/apps/dashboard/src/routes/+layout.svelte @@ -16,6 +16,7 @@ import ForgettingIndicator from '$lib/components/ForgettingIndicator.svelte'; import InsightToast from '$lib/components/InsightToast.svelte'; import AmbientAwarenessStrip from '$lib/components/AmbientAwarenessStrip.svelte'; + import VerdictBar from '$lib/components/VerdictBar.svelte'; import ThemeToggle from '$lib/components/ThemeToggle.svelte'; import { initTheme } from '$stores/theme'; @@ -199,6 +200,7 @@
      +
      {@render children()}
      diff --git a/apps/dashboard/svelte.config.js b/apps/dashboard/svelte.config.js index 4298fa7..731bd4a 100644 --- a/apps/dashboard/svelte.config.js +++ b/apps/dashboard/svelte.config.js @@ -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', diff --git a/crates/vestige-core/Cargo.toml b/crates/vestige-core/Cargo.toml index 42a32b6..c66c369 100644 --- a/crates/vestige-core/Cargo.toml +++ b/crates/vestige-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vestige-core" -version = "2.1.2" +version = "2.1.25" edition = "2024" rust-version = "1.91" authors = ["Vestige Team"] @@ -60,6 +60,17 @@ qwen3-reranker = ["qwen3-embeddings"] # Metal GPU acceleration on Apple Silicon (significantly faster inference) metal = ["fastembed/metal"] +# CUDA GPU acceleration on NVIDIA hardware (Windows / Linux, x86_64 + aarch64). +# Propagates to `candle-core/cuda`, which pulls in `cudarc` and `candle-kernels` +# for a per-build nvcc compile pass. Pair with `qwen3-embeddings` so the Candle +# backend is present in the build graph. +# +# Build: cargo build --release -p vestige-mcp --features qwen3-embeddings,cuda +cuda = ["qwen3-embeddings", "candle-core/cuda"] + +# cuDNN on top of CUDA — additional fused kernels and faster inference paths. +cudnn = ["cuda", "candle-core/cudnn"] + [dependencies] # Serialization diff --git a/crates/vestige-core/src/advanced/merge_supersede.rs b/crates/vestige-core/src/advanced/merge_supersede.rs new file mode 100644 index 0000000..c10485a --- /dev/null +++ b/crates/vestige-core/src/advanced/merge_supersede.rs @@ -0,0 +1,447 @@ +//! # Merge / Supersede Controls (Phase 3) +//! +//! Diff-previewed, confidence-gated, reversible, self-explaining combine / +//! dedupe / supersede operations on a never-delete (bitemporal) store. +//! +//! This module holds the **pure** logic: candidate scoring, two-threshold +//! classification, and the plan / operation data model. The actual persistence +//! (writing plans, applying them, recording the reversible operation log, and +//! bitemporally invalidating superseded nodes) lives in +//! [`crate::storage`]. Keeping the math here makes it unit-testable without a +//! database. +//! +//! ## Design north star +//! +//! Every combine/dedupe/supersede operation is: +//! +//! - **diff-previewed** — `plan_merge` / `plan_supersede` produce a [`MergePlan`] +//! you can inspect before anything mutates, +//! - **confidence-gated** — a Fellegi-Sunter two-threshold score classifies each +//! candidate as match / possible-match / non-match, +//! - **reversible** — every applied plan records a [`MergeOperation`] with an +//! undo payload (the "git reflog for your agent's memory"), +//! - **self-explaining** — each candidate carries the [`MatchSignals`] that +//! explain *why* the memories combined, +//! - **opt-in, never silent** — the default is preview/review, never auto-mutate, +//! - **audit-preserving** — superseding stamps `valid_until` and keeps the old +//! node queryable (Graphiti-style "invalidate, don't delete"). +//! +//! ## Why Fellegi-Sunter +//! +//! Pure hashing under-merges (misses paraphrases); aggressive LLM merging +//! over-merges and destroys the audit trail. Fellegi-Sunter record linkage uses +//! **two** thresholds to carve the score space into three zones, so the +//! borderline "possible match" cases are surfaced for review instead of being +//! force-decided. We reuse the embedding cosine similarity already in the store +//! plus cheap lexical signals (tag overlap, token Jaccard) as the match weight. + +use serde::{Deserialize, Serialize}; + +// ============================================================================ +// CONSTANTS — the two Fellegi-Sunter thresholds +// ============================================================================ + +/// Above this combined score → automatic-eligible "match". +pub const DEFAULT_MATCH_THRESHOLD: f32 = 0.86; + +/// Between the two thresholds → "possible match", surfaced for review. +/// Below this → "non-match" (never offered). +pub const DEFAULT_POSSIBLE_THRESHOLD: f32 = 0.72; + +/// Weight of embedding cosine similarity in the combined score. +const W_EMBEDDING: f32 = 0.70; +/// Weight of tag overlap (Jaccard) in the combined score. +const W_TAGS: f32 = 0.15; +/// Weight of content token overlap (Jaccard) in the combined score. +const W_TOKENS: f32 = 0.15; + +// ============================================================================ +// CLASSIFICATION +// ============================================================================ + +/// Fellegi-Sunter three-way classification of a candidate pair/cluster. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MatchClass { + /// Score ≥ match threshold — strong duplicate, auto-merge eligible. + Match, + /// Between thresholds — surfaced for human/agent review, never auto-applied. + Possible, + /// Below the possible threshold — not offered as a candidate. + NonMatch, +} + +impl MatchClass { + /// String label used in tool output and the `classification` column. + pub fn as_str(&self) -> &'static str { + match self { + MatchClass::Match => "match", + MatchClass::Possible => "possible", + MatchClass::NonMatch => "non_match", + } + } +} + +/// Per-merge-policy thresholds. Wired to `vestige.toml` when present, else the +/// defaults above. `auto_apply` gates whether `Match`-class candidates may be +/// applied without an explicit preview step (default: false — never silent). +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct MergePolicy { + /// Score ≥ this → `Match`. + pub match_threshold: f32, + /// Score in `[possible_threshold, match_threshold)` → `Possible`. + pub possible_threshold: f32, + /// If true, `Match`-class candidates may be auto-applied. Default false: + /// the product promise is review/preview, not silent mutation. + pub auto_apply: bool, +} + +impl Default for MergePolicy { + fn default() -> Self { + Self { + match_threshold: DEFAULT_MATCH_THRESHOLD, + possible_threshold: DEFAULT_POSSIBLE_THRESHOLD, + auto_apply: false, + } + } +} + +impl MergePolicy { + /// Build a policy, clamping thresholds into `[0,1]` and ensuring + /// `possible_threshold <= match_threshold`. + pub fn new(match_threshold: f32, possible_threshold: f32, auto_apply: bool) -> Self { + let match_threshold = match_threshold.clamp(0.0, 1.0); + let possible_threshold = possible_threshold.clamp(0.0, match_threshold); + Self { + match_threshold, + possible_threshold, + auto_apply, + } + } + + /// Classify a combined match score. + pub fn classify(&self, score: f32) -> MatchClass { + if score >= self.match_threshold { + MatchClass::Match + } else if score >= self.possible_threshold { + MatchClass::Possible + } else { + MatchClass::NonMatch + } + } +} + +// ============================================================================ +// SIGNALS — the self-explaining "why did these combine?" +// ============================================================================ + +/// The individual signals behind a candidate's score. Surfaced verbatim so a +/// user can see *why* two memories were judged duplicates. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MatchSignals { + /// Cosine similarity of the two embeddings (0–1). + pub embedding_similarity: f32, + /// Jaccard overlap of the two tag sets (0–1). + pub tag_overlap: f32, + /// Jaccard overlap of content tokens (0–1). + pub token_overlap: f32, + /// Combined weighted score that was classified. + pub combined_score: f32, +} + +/// Compute the combined match score and its signal breakdown for a pair. +pub fn score_pair( + embedding_similarity: f32, + a_tags: &[String], + b_tags: &[String], + a_content: &str, + b_content: &str, +) -> MatchSignals { + let tag_overlap = jaccard(&tag_set(a_tags), &tag_set(b_tags)); + let token_overlap = jaccard(&token_set(a_content), &token_set(b_content)); + let combined_score = (W_EMBEDDING * embedding_similarity.clamp(0.0, 1.0) + + W_TAGS * tag_overlap + + W_TOKENS * token_overlap) + .clamp(0.0, 1.0); + MatchSignals { + embedding_similarity: embedding_similarity.clamp(0.0, 1.0), + tag_overlap, + token_overlap, + combined_score, + } +} + +fn tag_set(tags: &[String]) -> std::collections::HashSet { + tags.iter().map(|t| t.to_lowercase()).collect() +} + +fn token_set(content: &str) -> std::collections::HashSet { + content + .split(|c: char| !c.is_alphanumeric()) + .filter(|t| t.len() > 2) + .map(|t| t.to_lowercase()) + .collect() +} + +fn jaccard(a: &std::collections::HashSet, b: &std::collections::HashSet) -> f32 { + if a.is_empty() && b.is_empty() { + return 0.0; + } + let inter = a.intersection(b).count() as f32; + let union = a.union(b).count() as f32; + if union == 0.0 { 0.0 } else { inter / union } +} + +// ============================================================================ +// CANDIDATE +// ============================================================================ + +/// A surfaced merge candidate: a cluster of likely-duplicate memories with the +/// signals and classification that justify offering it. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MergeCandidate { + /// Node ids in the cluster. The first is the suggested survivor (highest + /// retention). + pub member_ids: Vec, + /// Short content previews, parallel to `member_ids`. + pub previews: Vec, + /// Suggested survivor id (kept after a merge). + pub survivor_id: String, + /// Combined match score for the cluster (min pairwise within the cluster — + /// the weakest link, so a cluster is only as confident as its loosest pair). + pub confidence: f32, + /// Three-way classification under the active policy. + pub classification: MatchClass, + /// Signals for the survivor↔closest-member pair (the explanation). + pub signals: MatchSignals, + /// True if any member is protected (pinned) — blocks auto-merge. + pub has_protected_member: bool, +} + +// ============================================================================ +// PLAN — the previewable diff +// ============================================================================ + +/// What kind of plan this is. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PlanKind { + /// Combine N memories into one survivor. + Merge, + /// Invalidate A in favour of B (bitemporal, audit-preserving). + Supersede, +} + +impl PlanKind { + pub fn as_str(&self) -> &'static str { + match self { + PlanKind::Merge => "merge", + PlanKind::Supersede => "supersede", + } + } +} + +/// A previewable plan: exactly what *would* change, without changing anything. +/// Persisted to `merge_plans`; consumed by `apply_plan` via its `id`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MergePlan { + /// Plan id (UUID). + pub id: String, + /// merge | supersede. + pub kind: PlanKind, + /// Node kept after the operation. + pub survivor_id: String, + /// All node ids involved. + pub member_ids: Vec, + /// Resulting content of the survivor after applying. + pub result_content: String, + /// Resulting tag set of the survivor after applying. + pub result_tags: Vec, + /// Resulting provenance / source string after applying. + pub result_source: Option, + /// For supersede: ids that get bitemporally invalidated (their + /// `valid_until` stamped, kept queryable). For merge: the absorbed ids. + pub invalidated_ids: Vec, + /// Match confidence (0–1) for the plan. + pub confidence: f32, + /// Three-way classification. + pub classification: MatchClass, + /// Signals explaining the plan. + pub signals: MatchSignals, + /// Human-readable explanation of what this plan does. + pub explanation: String, +} + +// ============================================================================ +// OPERATION LOG — the reversible "memory reflog" +// ============================================================================ + +/// A recorded, reversible operation. One row in `merge_operations`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MergeOperation { + /// Operation id (UUID). + pub id: String, + /// Plan id this came from (if any). + pub plan_id: Option, + /// merge | supersede | undo. + pub op_type: String, + /// applied | reverted. + pub status: String, + /// When recorded (RFC3339). + pub created_at: String, + /// When reverted (RFC3339), if reverted. + pub reverted_at: Option, + /// For undo ops: the op id being reversed. + pub reverts_op_id: Option, + /// Survivor node id. + pub survivor_id: Option, + /// Node ids touched by the op. + pub affected_ids: Vec, + /// Match confidence. + pub confidence: Option, + /// Human-readable reason. + pub reason: Option, +} + +// ============================================================================ +// MERGE COMPOSITION — pure helpers used by the storage apply path +// ============================================================================ + +/// Compose merged content from an ordered list of (id, content) members. +/// Survivor content leads; each absorbed member is appended with provenance so +/// nothing is silently dropped (anti-pattern: Mem0 #4896 double-store / +/// contradiction loss). +pub fn compose_merged_content(members: &[(String, String)]) -> String { + if members.is_empty() { + return String::new(); + } + let mut out = members[0].1.trim().to_string(); + for (id, content) in &members[1..] { + let c = content.trim(); + if c.is_empty() || out.contains(c) { + continue; + } + out.push_str("\n\n[merged from "); + out.push_str(id); + out.push_str("]\n"); + out.push_str(c); + } + out +} + +/// Union the tag sets of all members, preserving first-seen order. +pub fn compose_merged_tags(member_tags: &[Vec]) -> Vec { + let mut seen = std::collections::HashSet::new(); + let mut out = Vec::new(); + for tags in member_tags { + for t in tags { + if seen.insert(t.to_lowercase()) { + out.push(t.clone()); + } + } + } + out +} + +// ============================================================================ +// TESTS +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classify_three_zones() { + let policy = MergePolicy::default(); + assert_eq!(policy.classify(0.95), MatchClass::Match); + assert_eq!(policy.classify(0.80), MatchClass::Possible); + assert_eq!(policy.classify(0.50), MatchClass::NonMatch); + // boundaries are inclusive at the lower edge of each higher zone + assert_eq!(policy.classify(DEFAULT_MATCH_THRESHOLD), MatchClass::Match); + assert_eq!( + policy.classify(DEFAULT_POSSIBLE_THRESHOLD), + MatchClass::Possible + ); + } + + #[test] + fn policy_clamps_and_orders() { + // possible above match gets clamped down to match + let p = MergePolicy::new(0.8, 0.95, true); + assert!(p.possible_threshold <= p.match_threshold); + // out-of-range clamps to [0,1] + let p2 = MergePolicy::new(2.0, -1.0, false); + assert_eq!(p2.match_threshold, 1.0); + assert_eq!(p2.possible_threshold, 0.0); + } + + #[test] + fn score_pair_combines_signals() { + let s = score_pair( + 1.0, + &["rust".into(), "async".into()], + &["rust".into(), "async".into()], + "use tokio for async rust", + "use tokio for async rust", + ); + assert!((s.embedding_similarity - 1.0).abs() < 1e-6); + assert!((s.tag_overlap - 1.0).abs() < 1e-6); + assert!(s.token_overlap > 0.9); + assert!(s.combined_score > 0.95); + } + + #[test] + fn score_pair_disjoint_is_low() { + let s = score_pair( + 0.1, + &["a".into()], + &["b".into()], + "completely different topic alpha", + "totally unrelated subject beta", + ); + assert!(s.combined_score < 0.3); + assert_eq!(MergePolicy::default().classify(s.combined_score), MatchClass::NonMatch); + } + + #[test] + fn jaccard_basics() { + let a: std::collections::HashSet = ["x".into(), "y".into()].into_iter().collect(); + let b: std::collections::HashSet = ["y".into(), "z".into()].into_iter().collect(); + assert!((jaccard(&a, &b) - (1.0 / 3.0)).abs() < 1e-6); + let empty: std::collections::HashSet = Default::default(); + assert_eq!(jaccard(&empty, &empty), 0.0); + } + + #[test] + fn compose_merged_content_dedups_and_attributes() { + let members = vec![ + ("a".into(), "Keep this.".into()), + ("b".into(), "Extra detail.".into()), + ("c".into(), "Keep this.".into()), // duplicate of survivor → skipped + ]; + let merged = compose_merged_content(&members); + assert!(merged.starts_with("Keep this.")); + assert!(merged.contains("[merged from b]")); + assert!(merged.contains("Extra detail.")); + // duplicate content not appended twice + assert_eq!(merged.matches("Keep this.").count(), 1); + } + + #[test] + fn compose_merged_tags_unions_in_order() { + let tags = vec![ + vec!["rust".into(), "async".into()], + vec!["async".into(), "tokio".into()], + ]; + let merged = compose_merged_tags(&tags); + assert_eq!(merged, vec!["rust", "async", "tokio"]); + } + + #[test] + fn match_class_labels() { + assert_eq!(MatchClass::Match.as_str(), "match"); + assert_eq!(MatchClass::Possible.as_str(), "possible"); + assert_eq!(MatchClass::NonMatch.as_str(), "non_match"); + } +} diff --git a/crates/vestige-core/src/advanced/mod.rs b/crates/vestige-core/src/advanced/mod.rs index fdbdfe4..0ed9280 100644 --- a/crates/vestige-core/src/advanced/mod.rs +++ b/crates/vestige-core/src/advanced/mod.rs @@ -23,6 +23,7 @@ pub mod cross_project; pub mod dreams; pub mod importance; pub mod intent; +pub mod merge_supersede; pub mod prediction_error; pub mod reconsolidation; pub mod speculative; @@ -61,6 +62,11 @@ pub use dreams::{ }; pub use importance::{ImportanceDecayConfig, ImportanceScore, ImportanceTracker, UsageEvent}; pub use intent::{ActionType, DetectedIntent, IntentDetector, MaintenanceType, UserAction}; +pub use merge_supersede::{ + DEFAULT_MATCH_THRESHOLD, DEFAULT_POSSIBLE_THRESHOLD, MatchClass, MatchSignals, MergeCandidate, + MergeOperation, MergePlan, MergePolicy, PlanKind, compose_merged_content, compose_merged_tags, + score_pair, +}; pub use prediction_error::{ CandidateMemory, CreateReason, EvaluationIntent, GateDecision, GateStats, MergeStrategy, PredictionErrorConfig, PredictionErrorGate, SimilarityResult, SupersedeReason, UpdateType, diff --git a/crates/vestige-core/src/embeddings/local.rs b/crates/vestige-core/src/embeddings/local.rs index a6ec555..d1ce798 100644 --- a/crates/vestige-core/src/embeddings/local.rs +++ b/crates/vestige-core/src/embeddings/local.rs @@ -209,6 +209,12 @@ fn get_backend() -> Result, Emb #[cfg(feature = "qwen3-embeddings")] fn qwen3_device() -> Device { + #[cfg(feature = "cuda")] + { + if let Ok(device) = Device::new_cuda(0) { + return device; + } + } #[cfg(feature = "metal")] { if let Ok(device) = Device::new_metal(0) { diff --git a/crates/vestige-core/src/lib.rs b/crates/vestige-core/src/lib.rs index 640ba4a..4c50413 100644 --- a/crates/vestige-core/src/lib.rs +++ b/crates/vestige-core/src/lib.rs @@ -127,9 +127,11 @@ pub use memory::{ MemorySystem, NodeType, RecallInput, + SchemaIntrospection, SearchMode, SearchResult, SimilarityResult, + TableIntrospection, TemporalRange, }; @@ -223,8 +225,16 @@ pub use advanced::{ MemoryPath, MemoryReplay, MemorySnapshot, + // Merge / Supersede controls (Phase 3) + MatchClass, + MatchSignals, + MergeCandidate, + MergeOperation, + MergePlan, + MergePolicy, MergeStrategy, Modification, + PlanKind, Pattern, PatternType, PredictedMemory, diff --git a/crates/vestige-core/src/memory/mod.rs b/crates/vestige-core/src/memory/mod.rs index e8c3f32..8cd618e 100644 --- a/crates/vestige-core/src/memory/mod.rs +++ b/crates/vestige-core/src/memory/mod.rs @@ -276,6 +276,59 @@ impl Default for MemoryStats { } } +// ============================================================================ +// SCHEMA INTROSPECTION (v2.1.24+: surfaces DB shape to MCP consumers) +// ============================================================================ + +/// A single SQLite table's introspected shape: name, row count, column list. +/// +/// Returned as part of `SchemaIntrospection` from `Storage::schema_introspection()`. +/// Consumers needing more depth (e.g. per-column NULL counts) should request +/// targeted methods rather than expecting this struct to grow unboundedly — +/// the row + column shape covered here is the 80% case for audit / migration +/// guard scripts. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TableIntrospection { + /// SQLite table name. + pub name: String, + /// Row count. + pub rows: i64, + /// Column names in declaration order. + pub columns: Vec, +} + +/// Result of `Storage::schema_introspection()`. Snapshots the schema version, +/// migration timestamp, and a row/column view of every user-data table. +/// +/// Motivation: external consumers (audit scripts, migration guards, downstream +/// upgrade scripts) currently must read SQLite directly to learn the schema +/// version and table shape, which couples them to internal layout. This struct +/// gives them a first-class MCP-callable surface. The list of tables walked is +/// intentionally the same canonical set used elsewhere in storage (the user- +/// data tables) so the surface stays stable across migrations. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct SchemaIntrospection { + /// Current schema version (highest applied migration; matches the + /// `schema_version` table's MAX(version)). + pub schema_version: u32, + /// When the current schema version was applied (RFC3339), if known. + pub schema_version_applied_at: Option>, + /// Per-table introspection rows. + pub tables: Vec, + /// Total number of nodes whose `embeddings.embedding` is NULL (i.e., have + /// no embedding row). Convenience field for embedding-coverage audits; + /// equivalent to (knowledge_nodes.rows − rows in `embeddings` joined to + /// knowledge_nodes), so consumers don't have to compute it themselves. + pub embedding_null_count: i64, + /// Active embedding model name (mirrors `MemoryStats.active_embedding_model`). + /// Useful when an audit script wants schema_version + active model in one call. + pub active_embedding_model: Option, + /// Embedding dimensions for the active model, if known. + pub active_embedding_dimensions: Option, +} + // ============================================================================ // CONSOLIDATION RESULT // ============================================================================ diff --git a/crates/vestige-core/src/storage/migrations.rs b/crates/vestige-core/src/storage/migrations.rs index 2c66a2d..3be941c 100644 --- a/crates/vestige-core/src/storage/migrations.rs +++ b/crates/vestige-core/src/storage/migrations.rs @@ -69,6 +69,11 @@ pub const MIGRATIONS: &[Migration] = &[ description: "v2.1.2 Honest Memory: non-content purge tombstones", up: MIGRATION_V13_UP, }, + Migration { + version: 14, + description: "v2.1.25 Merge/Supersede: reversible operation log, merge plans, bitemporal lineage, protected pins", + up: MIGRATION_V14_UP, + }, ]; /// A database migration @@ -735,6 +740,79 @@ ON deletion_tombstones(deleted_at); UPDATE schema_version SET version = 13, applied_at = datetime('now'); "#; +/// V14: Merge / Supersede controls (Phase 3). +/// +/// Adds the four pieces the merge/supersede feature needs on a never-delete +/// (bitemporal) store: +/// +/// 1. `merge_plans` — previewable, not-yet-applied plans. `plan_merge` and +/// `plan_supersede` write a plan row containing a JSON diff; `apply_plan` +/// consumes it by id. Plans are append-only; status moves +/// pending -> applied / cancelled. +/// 2. `merge_operations` — the reversible operation log (the "memory reflog"). +/// Every applied merge/supersede records one row with a JSON `undo_payload` +/// capturing exactly what changed, so `merge_undo` can reverse it. The +/// `signals` column records WHY the memories combined (provenance), which is +/// the self-explaining differentiator. +/// 3. `knowledge_nodes.protected` — pin flag. A protected memory can never be +/// auto-merged, superseded, or forgotten. +/// 4. `knowledge_nodes.superseded_by` — bitemporal lineage pointer. Superseding +/// A with B does NOT delete A: it stamps A.valid_until = B.valid_from and +/// sets A.superseded_by = B.id, leaving A fully queryable for audit +/// (Graphiti-style invalidate-don't-delete). +// The two `protected` / `superseded_by` ADD COLUMNs (and their indexes) are +// applied separately in `apply_migrations` BEFORE this batch runs, guarded +// against "duplicate column" on replay, since SQLite has no +// `ADD COLUMN IF NOT EXISTS`. The rest of V14 is idempotent (CREATE ... IF NOT +// EXISTS). +const MIGRATION_V14_UP: &str = r#" +CREATE INDEX IF NOT EXISTS idx_nodes_protected ON knowledge_nodes(protected); +CREATE INDEX IF NOT EXISTS idx_nodes_superseded_by ON knowledge_nodes(superseded_by); + +-- Previewable plans (a diff) produced by plan_merge / plan_supersede. +-- `kind` is 'merge' | 'supersede'. `payload` is the full JSON plan/diff. +CREATE TABLE IF NOT EXISTS merge_plans ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', -- pending | applied | cancelled + created_at TEXT NOT NULL, + applied_at TEXT, + survivor_id TEXT, -- node kept after the op + member_ids TEXT NOT NULL DEFAULT '[]', -- JSON array of all involved node ids + confidence REAL, -- Fellegi-Sunter match score (0-1) + classification TEXT, -- match | possible | non_match + payload TEXT NOT NULL -- full JSON plan/diff +); + +CREATE INDEX IF NOT EXISTS idx_merge_plans_status ON merge_plans(status); +CREATE INDEX IF NOT EXISTS idx_merge_plans_created_at ON merge_plans(created_at); + +-- Reversible operation log — the "git reflog for your agent's memory". +-- One row per applied merge/supersede; `undo_payload` carries everything +-- needed to reverse it, `signals` records why the memories combined. +CREATE TABLE IF NOT EXISTS merge_operations ( + id TEXT PRIMARY KEY, + plan_id TEXT, -- merge_plans.id this came from + op_type TEXT NOT NULL, -- merge | supersede | undo + status TEXT NOT NULL DEFAULT 'applied', -- applied | reverted + created_at TEXT NOT NULL, + reverted_at TEXT, + reverts_op_id TEXT, -- set when op_type = 'undo' + survivor_id TEXT, -- node kept + affected_ids TEXT NOT NULL DEFAULT '[]', -- JSON array of node ids touched + confidence REAL, + signals TEXT, -- JSON: why they combined (provenance) + reason TEXT, -- human-readable explanation + undo_payload TEXT NOT NULL -- JSON snapshot to reverse the op +); + +CREATE INDEX IF NOT EXISTS idx_merge_operations_status ON merge_operations(status); +CREATE INDEX IF NOT EXISTS idx_merge_operations_created_at ON merge_operations(created_at); +CREATE INDEX IF NOT EXISTS idx_merge_operations_survivor ON merge_operations(survivor_id); + +UPDATE schema_version SET version = 14, applied_at = datetime('now'); +"#; + /// Get current schema version from database pub fn get_current_version(conn: &rusqlite::Connection) -> rusqlite::Result { conn.query_row( @@ -745,6 +823,19 @@ pub fn get_current_version(conn: &rusqlite::Connection) -> rusqlite::Result .or(Ok(0)) } +/// Run an `ALTER TABLE ... ADD COLUMN` statement, treating a "duplicate column +/// name" failure as success so migration replay stays idempotent (SQLite has no +/// `ADD COLUMN IF NOT EXISTS`). +fn add_column_if_missing(conn: &rusqlite::Connection, sql: &str) -> rusqlite::Result<()> { + match conn.execute(sql, []) { + Ok(_) => Ok(()), + Err(rusqlite::Error::SqliteFailure(_, Some(msg))) if msg.contains("duplicate column name") => { + Ok(()) + } + Err(e) => Err(e), + } +} + /// Apply pending migrations pub fn apply_migrations(conn: &rusqlite::Connection) -> rusqlite::Result { let current_version = get_current_version(conn)?; @@ -758,6 +849,21 @@ pub fn apply_migrations(conn: &rusqlite::Connection) -> rusqlite::Result { migration.description ); + // V14: add the two bitemporal/protect columns BEFORE the batch (the + // batch's indexes reference them). SQLite lacks + // `ADD COLUMN IF NOT EXISTS`, so swallow the "duplicate column" + // error to stay idempotent on replay. + if migration.version == 14 { + add_column_if_missing( + conn, + "ALTER TABLE knowledge_nodes ADD COLUMN protected INTEGER NOT NULL DEFAULT 0", + )?; + add_column_if_missing( + conn, + "ALTER TABLE knowledge_nodes ADD COLUMN superseded_by TEXT", + )?; + } + // Use execute_batch to handle multi-statement SQL including triggers conn.execute_batch(migration.up)?; @@ -784,17 +890,17 @@ mod tests { /// version after `apply_migrations` runs all migrations end-to-end, and /// neither of the dead tables V11 drops must exist afterwards. #[test] - fn test_apply_migrations_advances_to_v13_and_drops_dead_tables() { + fn test_apply_migrations_advances_to_v14_and_drops_dead_tables() { let conn = rusqlite::Connection::open_in_memory().expect("open in-memory"); // Pre-requisite: schema_version must be bootstrapped by V1. apply_migrations(&conn).expect("apply_migrations succeeds"); - // 1. schema_version advanced to V13 + // 1. schema_version advanced to V14 let version = get_current_version(&conn).expect("read schema_version"); assert_eq!( - version, 13, - "schema_version must be 13 after all migrations" + version, 14, + "schema_version must be 14 after all migrations" ); // 2. knowledge_edges is gone (V11 drops it) @@ -848,6 +954,37 @@ mod tests { deletion_tombstone_rows, 1, "deletion_tombstones table must be created by V13" ); + + // 6. merge_plans + merge_operations exist (V14 creates them) + for table in ["merge_plans", "merge_operations"] { + let rows: i64 = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1", + [table], + |row| row.get(0), + ) + .expect("query sqlite_master"); + assert_eq!(rows, 1, "{table} table must be created by V14"); + } + + // 7. knowledge_nodes gains `protected` + `superseded_by` (V14) + let node_cols: Vec = { + let mut stmt = conn + .prepare("PRAGMA table_info(knowledge_nodes)") + .expect("prepare table_info"); + stmt.query_map([], |row| row.get::<_, String>(1)) + .expect("query table_info") + .filter_map(|r| r.ok()) + .collect() + }; + assert!( + node_cols.iter().any(|c| c == "protected"), + "knowledge_nodes must have `protected` column after V14" + ); + assert!( + node_cols.iter().any(|c| c == "superseded_by"), + "knowledge_nodes must have `superseded_by` column after V14" + ); } /// V11 must be idempotent on replay — if the tables were already dropped @@ -869,6 +1006,6 @@ mod tests { apply_migrations(&conn).expect("V11 replay must be idempotent"); let version = get_current_version(&conn).expect("read schema_version"); - assert_eq!(version, 13, "schema_version back at 13 after replay"); + assert_eq!(version, 14, "schema_version back at 14 after replay"); } } diff --git a/crates/vestige-core/src/storage/sqlite.rs b/crates/vestige-core/src/storage/sqlite.rs index c89a31c..dcd32ad 100644 --- a/crates/vestige-core/src/storage/sqlite.rs +++ b/crates/vestige-core/src/storage/sqlite.rs @@ -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; @@ -86,6 +86,15 @@ pub struct SmartIngestResult { pub prediction_error: Option, /// Human-readable explanation of the decision pub reason: String, + /// Previous content when smart ingest mutated an existing memory. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub previous_content: Option, + /// Existing memory id that received merged or appended content. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub merged_from: Option, + /// Full updated content after a merge/append/context write. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub merge_preview: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -156,6 +165,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 +280,11 @@ const PORTABLE_USER_DATA_TABLES: &[&str] = &[ "deletion_tombstones", ]; +#[derive(Default)] +struct PortableMergeState { + locally_newer_nodes: HashSet, +} + const DATA_DIR_ENV: &str = "VESTIGE_DATA_DIR"; const DATABASE_FILE: &str = "vestige.db"; @@ -454,6 +478,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 +497,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; @@ -619,6 +646,20 @@ impl Storage { /// This solves the "bad vs good similar memory" problem. #[cfg(all(feature = "embeddings", feature = "vector-search"))] pub fn smart_ingest(&self, input: IngestInput) -> Result { + self.smart_ingest_excluding(input, &[]) + } + + /// Smart ingest with caller-provided candidate exclusions. + /// + /// Batch callers use this to keep two new items from the same caller-curated + /// batch from merging into each other while still allowing smart updates + /// against memories that existed before the batch began. + #[cfg(all(feature = "embeddings", feature = "vector-search"))] + pub fn smart_ingest_excluding( + &self, + input: IngestInput, + excluded_node_ids: &[String], + ) -> Result { use crate::advanced::prediction_error::{ CandidateMemory, GateDecision, PredictionErrorGate, UpdateType, }; @@ -634,6 +675,9 @@ impl Storage { similarity: None, prediction_error: Some(1.0), reason: "Embeddings not available, falling back to regular ingest".to_string(), + previous_content: None, + merged_from: None, + merge_preview: None, }); } @@ -648,6 +692,9 @@ impl Storage { // Build candidate memories let mut candidates: Vec = Vec::new(); for (node_id, _similarity) in similar.iter() { + if excluded_node_ids.iter().any(|id| id == node_id) { + continue; + } if let Some(node) = self.get_node(node_id)? { // Get embedding for this node if let Some(emb) = self.get_node_embedding(node_id)? { @@ -697,6 +744,9 @@ impl Storage { reason, related_memory_ids ) }, + previous_content: None, + merged_from: None, + merge_preview: None, }) } GateDecision::Update { @@ -720,6 +770,9 @@ impl Storage { prediction_error: Some(prediction_error), reason: "Content nearly identical - reinforced existing memory" .to_string(), + previous_content: None, + merged_from: None, + merge_preview: None, }) } UpdateType::Merge | UpdateType::Append => { @@ -727,10 +780,11 @@ impl Storage { let existing = self .get_node(&target_id)? .ok_or_else(|| StorageError::NotFound(target_id.clone()))?; + let previous_content = existing.content.clone(); let merged_content = format!( "{}\n\n[Updated {}]\n{}", - existing.content, + previous_content, chrono::Utc::now().format("%Y-%m-%d"), input.content ); @@ -749,10 +803,18 @@ impl Storage { similarity: Some(similarity), prediction_error: Some(prediction_error), reason: "Merged with existing similar memory".to_string(), + previous_content: Some(previous_content), + merged_from: Some(target_id), + merge_preview: Some(merged_content), }) } UpdateType::Replace => { // Replace content entirely + let existing = self + .get_node(&target_id)? + .ok_or_else(|| StorageError::NotFound(target_id.clone()))?; + let previous_content = existing.content; + self.update_node_content(&target_id, &input.content)?; let node = self .get_node(&target_id)? @@ -765,6 +827,9 @@ impl Storage { similarity: Some(similarity), prediction_error: Some(prediction_error), reason: "Replaced existing memory with new content".to_string(), + previous_content: Some(previous_content), + merged_from: Some(target_id), + merge_preview: Some(input.content), }) } UpdateType::AddContext => { @@ -772,9 +837,10 @@ impl Storage { let existing = self .get_node(&target_id)? .ok_or_else(|| StorageError::NotFound(target_id.clone()))?; + let previous_content = existing.content.clone(); let merged_content = - format!("{}\n\n---\nContext: {}", existing.content, input.content); + format!("{}\n\n---\nContext: {}", previous_content, input.content); self.update_node_content(&target_id, &merged_content)?; let node = self @@ -788,6 +854,9 @@ impl Storage { similarity: Some(similarity), prediction_error: Some(prediction_error), reason: "Added new content as context to existing memory".to_string(), + previous_content: Some(previous_content), + merged_from: Some(target_id), + merge_preview: Some(merged_content), }) } } @@ -811,6 +880,9 @@ impl Storage { similarity: Some(similarity), prediction_error: Some(prediction_error), reason: format!("New memory supersedes old: {:?}", supersede_reason), + previous_content: None, + merged_from: None, + merge_preview: None, }) } GateDecision::Merge { @@ -832,6 +904,9 @@ impl Storage { memory_ids.len(), strategy ), + previous_content: None, + merged_from: None, + merge_preview: None, }) } } @@ -1815,16 +1890,115 @@ impl Storage { }) } + /// Introspect the live SQLite schema: schema version + per-table row/column + /// shape + embedding-coverage convenience fields. + /// + /// This is the v2.1.24+ replacement for the direct-SQLite reads that + /// audit scripts and migration guards previously had to perform. The set + /// of tables walked matches `PORTABLE_USER_DATA_TABLES` — the same + /// canonical set used by portable export / import — so the surface stays + /// stable across migrations rather than chasing arbitrary + /// `sqlite_master` rows. + /// + /// Cost: O(N_tables) `COUNT(*)` queries + one PRAGMA per table. Negligible + /// at the table cardinalities Vestige carries (~15 tables, all indexed). + /// Safe to call on every MCP `system_status` invocation when the flag is + /// set; callers wanting to limit cost should leave the flag off (default). + pub fn schema_introspection(&self) -> Result { + let reader = self + .reader + .lock() + .map_err(|_| StorageError::Init("Reader lock poisoned".into()))?; + + let schema_version = Self::current_schema_version(&reader)?; + + // schema_version has the row (version PK + applied_at TEXT). Read the + // applied_at for the current version row; tolerate failure (legacy + // databases may have skipped the applied_at fill on early upgrades). + let applied_at_str: Option = reader + .query_row( + "SELECT applied_at FROM schema_version WHERE version = ?1", + params![schema_version as i64], + |row| row.get(0), + ) + .optional()?; + let schema_version_applied_at = applied_at_str.and_then(|s| { + // The migration scripts use `datetime('now')` which yields + // SQLite's "YYYY-MM-DD HH:MM:SS" UTC form (NOT RFC3339). + // Try the SQLite form first, fall back to RFC3339 for any + // future migrations that switch. + chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%d %H:%M:%S") + .map(|naive| naive.and_utc()) + .or_else(|_| { + DateTime::parse_from_rfc3339(&s) + .map(|dt| dt.with_timezone(&Utc)) + }) + .ok() + }); + + let mut tables = Vec::with_capacity(PORTABLE_USER_DATA_TABLES.len()); + for table_name in PORTABLE_USER_DATA_TABLES { + if Self::table_exists(&reader, table_name)? { + let rows = Self::table_row_count(&reader, table_name)?; + let columns = Self::table_columns(&reader, table_name)?; + tables.push(crate::TableIntrospection { + name: (*table_name).to_string(), + rows, + columns, + }); + } + } + + // Convenience: embedding-coverage NULL count. Defined as the number + // of knowledge_nodes with NO matching row in node_embeddings. This is + // distinct from `nodes_with_embeddings` in MemoryStats (which uses + // the `has_embedding` column flag); we compute the join-based truth + // here so audit scripts can detect drift between the flag and the + // actual embeddings table. + let embedding_null_count: i64 = reader + .query_row( + "SELECT COUNT(*) FROM knowledge_nodes kn + WHERE NOT EXISTS ( + SELECT 1 FROM node_embeddings ne WHERE ne.node_id = kn.id + )", + [], + |row| row.get(0), + ) + .unwrap_or(0); + + #[cfg(feature = "embeddings")] + let active_embedding_model = Some(self.embedding_service.model_name().to_string()); + #[cfg(not(feature = "embeddings"))] + let active_embedding_model: Option = None; + + #[cfg(feature = "embeddings")] + let active_embedding_dimensions: Option = + Some(self.embedding_service.dimensions() as u32); + #[cfg(not(feature = "embeddings"))] + let active_embedding_dimensions: Option = None; + + Ok(crate::SchemaIntrospection { + schema_version, + schema_version_applied_at, + tables, + embedding_null_count, + active_embedding_model, + active_embedding_dimensions, + }) + } + /// Delete a node pub fn delete_node(&self, id: &str) -> Result { - 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 +4914,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 +5023,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 +5036,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 +5182,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 +5224,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 +5256,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 +5287,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)> = 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 = tx @@ -5105,9 +5333,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 +5351,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 = 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 +5412,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 +5428,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 +5525,7 @@ impl Storage { ) -> Result { 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 +5533,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, + ) -> 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::>() + .join(", "); + let placeholders = std::iter::repeat_n("?", columns.len()) + .collect::>() + .join(", "); + let conflict_target = key_columns + .iter() + .map(|column| Self::quote_ident(column)) + .collect::>() + .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::>(); + + 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 +5622,7 @@ impl Storage { table: &PortableTable, row: &[PortableValue], ) -> Result { - 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 +5911,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(()) } @@ -5922,6 +6279,988 @@ impl Storage { } Ok(result) } + + // ======================================================================== + // Merge / Supersede controls (Phase 3 — v2.1.25) + // + // Diff-previewed, confidence-gated, reversible, self-explaining + // combine/dedupe/supersede on a never-delete (bitemporal) store. + // Pure scoring/plan/op types live in `advanced::merge_supersede`. + // ======================================================================== + + /// Mark a memory protected (pinned) or unprotected. A protected memory can + /// never be auto-merged, superseded, or garbage-collected. + pub fn set_protected(&self, id: &str, protected: bool) -> Result<()> { + let writer = self + .writer + .lock() + .map_err(|_| StorageError::Init("Writer lock poisoned".into()))?; + let affected = writer.execute( + "UPDATE knowledge_nodes SET protected = ?1 WHERE id = ?2", + params![if protected { 1 } else { 0 }, id], + )?; + if affected == 0 { + return Err(StorageError::NotFound(id.to_string())); + } + Ok(()) + } + + /// Is this memory protected (pinned)? + pub fn is_protected(&self, id: &str) -> Result { + let reader = self + .reader + .lock() + .map_err(|_| StorageError::Init("Reader lock poisoned".into()))?; + let v: Option = reader + .query_row( + "SELECT protected FROM knowledge_nodes WHERE id = ?1", + params![id], + |row| row.get(0), + ) + .optional()?; + match v { + Some(p) => Ok(p != 0), + None => Err(StorageError::NotFound(id.to_string())), + } + } + + /// Read the per-project merge policy (two Fellegi-Sunter thresholds + + /// auto_apply). Persisted in `fsrs_config` so it survives restarts without a + /// new table; falls back to defaults (env-overridable) when unset. + pub fn get_merge_policy(&self) -> Result { + let reader = self + .reader + .lock() + .map_err(|_| StorageError::Init("Reader lock poisoned".into()))?; + let read_key = |key: &str| -> Option { + reader + .query_row( + "SELECT value FROM fsrs_config WHERE key = ?1", + params![key], + |row| row.get::<_, f64>(0), + ) + .optional() + .ok() + .flatten() + }; + let default = crate::advanced::MergePolicy::default(); + let env_f32 = |name: &str, fallback: f32| -> f32 { + std::env::var(name) + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(fallback) + }; + let match_threshold = read_key("merge_match_threshold") + .map(|v| v as f32) + .unwrap_or_else(|| env_f32("VESTIGE_MERGE_MATCH_THRESHOLD", default.match_threshold)); + let possible_threshold = read_key("merge_possible_threshold") + .map(|v| v as f32) + .unwrap_or_else(|| { + env_f32("VESTIGE_MERGE_POSSIBLE_THRESHOLD", default.possible_threshold) + }); + let auto_apply = match read_key("merge_auto_apply") { + Some(v) => v != 0.0, + None => std::env::var("VESTIGE_MERGE_AUTO_APPLY") + .ok() + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(default.auto_apply), + }; + Ok(crate::advanced::MergePolicy::new( + match_threshold, + possible_threshold, + auto_apply, + )) + } + + /// Persist the per-project merge policy into `fsrs_config`. + pub fn set_merge_policy(&self, policy: crate::advanced::MergePolicy) -> Result<()> { + let writer = self + .writer + .lock() + .map_err(|_| StorageError::Init("Writer lock poisoned".into()))?; + let now = Utc::now().to_rfc3339(); + let put = |key: &str, value: f64| -> Result<()> { + writer.execute( + "INSERT OR REPLACE INTO fsrs_config (key, value, updated_at) VALUES (?1, ?2, ?3)", + params![key, value, now], + )?; + Ok(()) + }; + put("merge_match_threshold", policy.match_threshold as f64)?; + put("merge_possible_threshold", policy.possible_threshold as f64)?; + put( + "merge_auto_apply", + if policy.auto_apply { 1.0 } else { 0.0 }, + )?; + Ok(()) + } + + /// Surface likely duplicate/overlapping memory clusters with confidence + /// scores and the signals behind each (Fellegi-Sunter classified). + /// + /// Only clusters whose weakest pair scores at or above the policy's + /// `possible_threshold` are returned. Protected members are flagged so the + /// caller never auto-merges a pin. + #[cfg(all(feature = "embeddings", feature = "vector-search"))] + pub fn merge_candidates( + &self, + policy: crate::advanced::MergePolicy, + limit: usize, + tag_filter: &[String], + ) -> Result> { + use crate::advanced::{MatchClass, MergeCandidate, score_pair}; + + let all_embeddings = self.get_all_embeddings()?; + if all_embeddings.is_empty() { + return Ok(vec![]); + } + + // Load nodes for metadata. Exclude already-superseded nodes — they are + // historical and must not be re-offered for merge. + let mut node_map: std::collections::HashMap = + std::collections::HashMap::new(); + let superseded: std::collections::HashSet = self.superseded_node_ids()?; + let protected: std::collections::HashSet = self.protected_node_ids()?; + + let mut offset = 0; + loop { + let batch = self.get_all_nodes(500, offset)?; + let n = batch.len(); + for node in batch { + node_map.insert(node.id.clone(), node); + } + if n < 500 { + break; + } + offset += 500; + } + + // Candidate embeddings, filtered by tag and excluding superseded. + let items: Vec<(String, Vec)> = all_embeddings + .into_iter() + .filter(|(id, _)| !superseded.contains(id)) + .filter(|(id, _)| { + if tag_filter.is_empty() { + return true; + } + node_map + .get(id) + .map(|n| tag_filter.iter().any(|t| n.tags.contains(t))) + .unwrap_or(false) + }) + .collect(); + + let n = items.len(); + if n > 2000 { + return Err(StorageError::Init(format!( + "Too many memories to scan ({n} with embeddings). Filter by tags to reduce scope." + ))); + } + + // Union-find clustering over pairs above the possible threshold. + let mut parent: Vec = (0..n).collect(); + fn find(parent: &mut [usize], x: usize) -> usize { + let mut root = x; + while parent[root] != root { + root = parent[root]; + } + let mut cur = x; + while parent[cur] != root { + let next = parent[cur]; + parent[cur] = root; + cur = next; + } + root + } + + // Best pair score per resulting cluster member, for the explanation. + let mut pair_score: std::collections::HashMap<(usize, usize), crate::advanced::MatchSignals> = + std::collections::HashMap::new(); + + for i in 0..n { + for j in (i + 1)..n { + let sim = crate::cosine_similarity(&items[i].1, &items[j].1); + let (a_node, b_node) = (node_map.get(&items[i].0), node_map.get(&items[j].0)); + let signals = score_pair( + sim, + a_node.map(|n| n.tags.as_slice()).unwrap_or(&[]), + b_node.map(|n| n.tags.as_slice()).unwrap_or(&[]), + a_node.map(|n| n.content.as_str()).unwrap_or(""), + b_node.map(|n| n.content.as_str()).unwrap_or(""), + ); + if signals.combined_score >= policy.possible_threshold { + let ri = find(&mut parent, i); + let rj = find(&mut parent, j); + if ri != rj { + parent[ri] = rj; + } + pair_score.insert((i, j), signals); + } + } + } + + // Group indices by root. + let mut clusters: std::collections::HashMap> = + std::collections::HashMap::new(); + for i in 0..n { + let r = find(&mut parent, i); + clusters.entry(r).or_default().push(i); + } + + let mut out: Vec = Vec::new(); + for members in clusters.into_values() { + if members.len() < 2 { + continue; + } + // Cluster confidence = weakest recorded pair (the loosest link). + let mut min_score = 1.0f32; + let mut best_signals: Option = None; + for a in 0..members.len() { + for b in (a + 1)..members.len() { + let key = (members[a].min(members[b]), members[a].max(members[b])); + if let Some(sig) = pair_score.get(&key) { + if sig.combined_score < min_score { + min_score = sig.combined_score; + } + if best_signals + .as_ref() + .map(|s| sig.combined_score > s.combined_score) + .unwrap_or(true) + { + best_signals = Some(sig.clone()); + } + } + } + } + let signals = match best_signals { + Some(s) => s, + None => continue, + }; + + // Survivor = highest retention member. + let mut member_ids: Vec = + members.iter().map(|&idx| items[idx].0.clone()).collect(); + member_ids.sort_by(|a, b| { + let ra = node_map.get(a).map(|n| n.retention_strength).unwrap_or(0.0); + let rb = node_map.get(b).map(|n| n.retention_strength).unwrap_or(0.0); + rb.partial_cmp(&ra).unwrap_or(std::cmp::Ordering::Equal) + }); + let survivor_id = member_ids[0].clone(); + let has_protected_member = member_ids.iter().any(|id| protected.contains(id)); + let previews: Vec = member_ids + .iter() + .map(|id| { + node_map + .get(id) + .map(|n| preview(&n.content, 120)) + .unwrap_or_default() + }) + .collect(); + + let classification = match policy.classify(min_score) { + MatchClass::NonMatch => continue, + c => c, + }; + + out.push(MergeCandidate { + member_ids, + previews, + survivor_id, + confidence: min_score, + classification, + signals, + has_protected_member, + }); + } + + out.sort_by(|a, b| { + b.confidence + .partial_cmp(&a.confidence) + .unwrap_or(std::cmp::Ordering::Equal) + }); + out.truncate(limit); + Ok(out) + } + + /// IDs of nodes that have been bitemporally superseded (kept, but invalid). + pub fn superseded_node_ids(&self) -> Result> { + let reader = self + .reader + .lock() + .map_err(|_| StorageError::Init("Reader lock poisoned".into()))?; + let mut stmt = + reader.prepare("SELECT id FROM knowledge_nodes WHERE superseded_by IS NOT NULL")?; + let rows = stmt.query_map([], |row| row.get::<_, String>(0))?; + let mut set = std::collections::HashSet::new(); + for r in rows { + set.insert(r?); + } + Ok(set) + } + + /// IDs of protected (pinned) nodes. + pub fn protected_node_ids(&self) -> Result> { + let reader = self + .reader + .lock() + .map_err(|_| StorageError::Init("Reader lock poisoned".into()))?; + let mut stmt = reader.prepare("SELECT id FROM knowledge_nodes WHERE protected = 1")?; + let rows = stmt.query_map([], |row| row.get::<_, String>(0))?; + let mut set = std::collections::HashSet::new(); + for r in rows { + set.insert(r?); + } + Ok(set) + } + + /// Build a previewable MERGE plan (a diff) WITHOUT applying it. + /// + /// The survivor is the first id (or highest retention if unspecified). The + /// plan is persisted to `merge_plans` with status `pending` and returned for + /// inspection. Nothing about the nodes changes until `apply_plan`. + #[cfg(all(feature = "embeddings", feature = "vector-search"))] + pub fn plan_merge( + &self, + member_ids: &[String], + survivor_id: Option<&str>, + policy: crate::advanced::MergePolicy, + ) -> Result { + use crate::advanced::{ + MatchClass, PlanKind, compose_merged_content, compose_merged_tags, score_pair, + }; + + if member_ids.len() < 2 { + return Err(StorageError::Init( + "plan_merge needs at least 2 member ids".into(), + )); + } + + let mut nodes: Vec = Vec::new(); + for id in member_ids { + let node = self + .get_node(id)? + .ok_or_else(|| StorageError::NotFound(id.clone()))?; + nodes.push(node); + } + + // Protected nodes can never be absorbed. They may only be the survivor. + let survivor = match survivor_id { + Some(s) => s.to_string(), + None => { + // highest retention + nodes + .iter() + .max_by(|a, b| { + a.retention_strength + .partial_cmp(&b.retention_strength) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .map(|n| n.id.clone()) + .unwrap_or_else(|| member_ids[0].clone()) + } + }; + for node in &nodes { + if node.id != survivor && self.is_protected(&node.id)? { + return Err(StorageError::Init(format!( + "Memory {} is protected and cannot be merged away. Unprotect it first or make it the survivor.", + node.id + ))); + } + } + + // Order: survivor first, then others. + nodes.sort_by_key(|n| if n.id == survivor { 0 } else { 1 }); + + let members: Vec<(String, String)> = nodes + .iter() + .map(|n| (n.id.clone(), n.content.clone())) + .collect(); + let result_content = compose_merged_content(&members); + let result_tags = compose_merged_tags( + &nodes.iter().map(|n| n.tags.clone()).collect::>(), + ); + let result_source = nodes.iter().find(|n| n.id == survivor).and_then(|n| n.source.clone()); + let invalidated_ids: Vec = nodes + .iter() + .filter(|n| n.id != survivor) + .map(|n| n.id.clone()) + .collect(); + + // Confidence = weakest pair survivor↔absorbed. + let survivor_node = nodes.iter().find(|n| n.id == survivor).unwrap(); + let mut min_score = 1.0f32; + let mut best_signals = score_pair( + 1.0, + &survivor_node.tags, + &survivor_node.tags, + &survivor_node.content, + &survivor_node.content, + ); + for node in nodes.iter().filter(|n| n.id != survivor) { + let sim = self.pair_similarity(&survivor, &node.id)?; + let sig = score_pair( + sim, + &survivor_node.tags, + &node.tags, + &survivor_node.content, + &node.content, + ); + if sig.combined_score < min_score { + min_score = sig.combined_score; + best_signals = sig; + } + } + let classification = policy.classify(min_score); + + let plan = crate::advanced::MergePlan { + id: uuid::Uuid::new_v4().to_string(), + kind: PlanKind::Merge, + survivor_id: survivor.clone(), + member_ids: member_ids.to_vec(), + result_content, + result_tags, + result_source, + invalidated_ids, + confidence: min_score, + classification, + signals: best_signals, + explanation: format!( + "Merge {} memories into {survivor} ({}). {} memory(ies) will be bitemporally invalidated (kept for audit, marked superseded_by={survivor}).", + member_ids.len(), + match classification { + MatchClass::Match => "strong duplicate", + MatchClass::Possible => "possible duplicate — review advised", + MatchClass::NonMatch => "weak match — review strongly advised", + }, + member_ids.len() - 1 + ), + }; + + self.persist_plan(&plan)?; + Ok(plan) + } + + /// Build a previewable SUPERSEDE plan: invalidate `old_id` in favour of + /// `new_id` (bitemporal, audit-preserving) WITHOUT applying it. + #[cfg(all(feature = "embeddings", feature = "vector-search"))] + pub fn plan_supersede( + &self, + old_id: &str, + new_id: &str, + policy: crate::advanced::MergePolicy, + ) -> Result { + use crate::advanced::{PlanKind, score_pair}; + + let old = self + .get_node(old_id)? + .ok_or_else(|| StorageError::NotFound(old_id.to_string()))?; + let new = self + .get_node(new_id)? + .ok_or_else(|| StorageError::NotFound(new_id.to_string()))?; + + if self.is_protected(old_id)? { + return Err(StorageError::Init(format!( + "Memory {old_id} is protected and cannot be superseded. Unprotect it first." + ))); + } + + let sim = self.pair_similarity(old_id, new_id)?; + let signals = score_pair(sim, &old.tags, &new.tags, &old.content, &new.content); + let classification = policy.classify(signals.combined_score); + + let plan = crate::advanced::MergePlan { + id: uuid::Uuid::new_v4().to_string(), + kind: PlanKind::Supersede, + survivor_id: new_id.to_string(), + member_ids: vec![old_id.to_string(), new_id.to_string()], + result_content: new.content.clone(), + result_tags: new.tags.clone(), + result_source: new.source.clone(), + invalidated_ids: vec![old_id.to_string()], + confidence: signals.combined_score, + classification, + signals, + explanation: format!( + "Supersede {old_id} with {new_id}. {old_id} is kept and remains queryable for audit, but stamped valid_until=now and superseded_by={new_id} (invalidate, don't delete)." + ), + }; + + self.persist_plan(&plan)?; + Ok(plan) + } + + /// Cosine similarity between two nodes' stored embeddings (0 if missing). + #[cfg(all(feature = "embeddings", feature = "vector-search"))] + fn pair_similarity(&self, a: &str, b: &str) -> Result { + let ea = self.get_node_embedding(a)?; + let eb = self.get_node_embedding(b)?; + match (ea, eb) { + (Some(ea), Some(eb)) => Ok(crate::cosine_similarity(&ea, &eb)), + _ => Ok(0.0), + } + } + + /// Persist a plan row (status pending). Idempotent on plan id. + fn persist_plan(&self, plan: &crate::advanced::MergePlan) -> Result<()> { + let writer = self + .writer + .lock() + .map_err(|_| StorageError::Init("Writer lock poisoned".into()))?; + let payload = serde_json::to_string(plan) + .map_err(|e| StorageError::Init(format!("plan serialize failed: {e}")))?; + let member_ids = serde_json::to_string(&plan.member_ids).unwrap_or_else(|_| "[]".into()); + writer.execute( + "INSERT OR REPLACE INTO merge_plans + (id, kind, status, created_at, applied_at, survivor_id, member_ids, confidence, classification, payload) + VALUES (?1, ?2, 'pending', ?3, NULL, ?4, ?5, ?6, ?7, ?8)", + params![ + plan.id, + plan.kind.as_str(), + Utc::now().to_rfc3339(), + plan.survivor_id, + member_ids, + plan.confidence as f64, + plan.classification.as_str(), + payload, + ], + )?; + Ok(()) + } + + /// Fetch a stored plan by id. + pub fn get_plan(&self, plan_id: &str) -> Result> { + let reader = self + .reader + .lock() + .map_err(|_| StorageError::Init("Reader lock poisoned".into()))?; + let row: Option<(String, String)> = reader + .query_row( + "SELECT status, payload FROM merge_plans WHERE id = ?1", + params![plan_id], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ) + .optional()?; + match row { + Some((_status, payload)) => { + let plan: crate::advanced::MergePlan = serde_json::from_str(&payload) + .map_err(|e| StorageError::Init(format!("plan deserialize failed: {e}")))?; + Ok(Some(plan)) + } + None => Ok(None), + } + } + + /// Plan status string (pending | applied | cancelled), if the plan exists. + pub fn plan_status(&self, plan_id: &str) -> Result> { + let reader = self + .reader + .lock() + .map_err(|_| StorageError::Init("Reader lock poisoned".into()))?; + let status: Option = reader + .query_row( + "SELECT status FROM merge_plans WHERE id = ?1", + params![plan_id], + |row| row.get(0), + ) + .optional()?; + Ok(status) + } + + /// Execute a previously-generated plan by id. Everything it does is recorded + /// as a reversible [`MergeOperation`] in `merge_operations`. Returns the + /// recorded operation id. + /// + /// - **merge**: survivor content/tags are rewritten to the merged result; + /// each absorbed node is bitemporally invalidated (valid_until=now, + /// superseded_by=survivor) and kept queryable. + /// - **supersede**: old node is bitemporally invalidated in favour of new. + /// + /// `auto_apply` must be true in the policy to apply a `Match` plan without an + /// explicit `confirm`; non-`Match` plans always require `confirm=true`. + #[cfg(all(feature = "embeddings", feature = "vector-search"))] + pub fn apply_plan( + &self, + plan_id: &str, + confirm: bool, + ) -> Result { + use crate::advanced::{MatchClass, PlanKind}; + + let plan = self + .get_plan(plan_id)? + .ok_or_else(|| StorageError::NotFound(format!("plan {plan_id}")))?; + + match self.plan_status(plan_id)?.as_deref() { + Some("applied") => { + return Err(StorageError::Init(format!( + "plan {plan_id} was already applied" + ))); + } + Some("cancelled") => { + return Err(StorageError::Init(format!("plan {plan_id} was cancelled"))); + } + _ => {} + } + + // Confirmation gate: only auto-applyable Match plans may skip confirm. + let needs_confirm = !(plan.classification == MatchClass::Match); + if needs_confirm && !confirm { + return Err(StorageError::Init(format!( + "plan {plan_id} is classified '{}' (confidence {:.3}) and requires confirm=true to apply", + plan.classification.as_str(), + plan.confidence + ))); + } + + let now = Utc::now(); + let op_id = uuid::Uuid::new_v4().to_string(); + + // Snapshot everything we need to undo, BEFORE mutating. + let mut undo = serde_json::Map::new(); + undo.insert("plan_id".into(), serde_json::json!(plan_id)); + undo.insert("kind".into(), serde_json::json!(plan.kind.as_str())); + undo.insert("survivor_id".into(), serde_json::json!(plan.survivor_id)); + + match plan.kind { + PlanKind::Merge => { + let survivor = self + .get_node(&plan.survivor_id)? + .ok_or_else(|| StorageError::NotFound(plan.survivor_id.clone()))?; + undo.insert( + "survivor_prev_content".into(), + serde_json::json!(survivor.content), + ); + undo.insert( + "survivor_prev_tags".into(), + serde_json::json!(survivor.tags), + ); + + // Capture prior valid_until / superseded_by of each absorbed node. + let mut absorbed = Vec::new(); + for id in &plan.invalidated_ids { + let (vu, sb) = self.read_bitemporal(id)?; + absorbed.push(serde_json::json!({ + "id": id, + "prev_valid_until": vu, + "prev_superseded_by": sb, + })); + } + undo.insert("absorbed".into(), serde_json::json!(absorbed)); + + // Apply: rewrite survivor, invalidate absorbed. + self.rewrite_survivor( + &plan.survivor_id, + &plan.result_content, + &plan.result_tags, + )?; + for id in &plan.invalidated_ids { + self.invalidate_node(id, &plan.survivor_id, now)?; + } + } + PlanKind::Supersede => { + let old_id = &plan.member_ids[0]; + let (vu, sb) = self.read_bitemporal(old_id)?; + undo.insert( + "absorbed".into(), + serde_json::json!([{ + "id": old_id, + "prev_valid_until": vu, + "prev_superseded_by": sb, + }]), + ); + self.invalidate_node(old_id, &plan.survivor_id, now)?; + } + } + + // Record the reversible operation. + let affected: Vec = { + let mut v = vec![plan.survivor_id.clone()]; + v.extend(plan.invalidated_ids.clone()); + v + }; + let signals = serde_json::to_string(&plan.signals).unwrap_or_else(|_| "{}".into()); + { + let writer = self + .writer + .lock() + .map_err(|_| StorageError::Init("Writer lock poisoned".into()))?; + writer.execute( + "INSERT INTO merge_operations + (id, plan_id, op_type, status, created_at, reverted_at, reverts_op_id, + survivor_id, affected_ids, confidence, signals, reason, undo_payload) + VALUES (?1, ?2, ?3, 'applied', ?4, NULL, NULL, ?5, ?6, ?7, ?8, ?9, ?10)", + params![ + op_id, + plan_id, + plan.kind.as_str(), + now.to_rfc3339(), + plan.survivor_id, + serde_json::to_string(&affected).unwrap_or_else(|_| "[]".into()), + plan.confidence as f64, + signals, + plan.explanation, + serde_json::Value::Object(undo).to_string(), + ], + )?; + writer.execute( + "UPDATE merge_plans SET status = 'applied', applied_at = ?1 WHERE id = ?2", + params![now.to_rfc3339(), plan_id], + )?; + } + + self.read_operation(&op_id)? + .ok_or_else(|| StorageError::Init("operation vanished after insert".into())) + } + + /// Reverse a prior merge/supersede operation by id (the "memory reflog"). + /// Restores survivor content/tags and clears the bitemporal invalidation on + /// every node the operation touched, then records a compensating `undo` op. + #[cfg(all(feature = "embeddings", feature = "vector-search"))] + pub fn merge_undo(&self, op_id: &str) -> Result { + let op = self + .read_operation(op_id)? + .ok_or_else(|| StorageError::NotFound(format!("operation {op_id}")))?; + if op.status == "reverted" { + return Err(StorageError::Init(format!( + "operation {op_id} was already reverted" + ))); + } + if op.op_type == "undo" { + return Err(StorageError::Init( + "cannot undo an undo operation".into(), + )); + } + + let undo: serde_json::Value = { + let reader = self + .reader + .lock() + .map_err(|_| StorageError::Init("Reader lock poisoned".into()))?; + let payload: String = reader.query_row( + "SELECT undo_payload FROM merge_operations WHERE id = ?1", + params![op_id], + |row| row.get(0), + )?; + serde_json::from_str(&payload) + .map_err(|e| StorageError::Init(format!("undo payload parse failed: {e}")))? + }; + + let kind = undo.get("kind").and_then(|v| v.as_str()).unwrap_or(""); + let survivor_id = undo + .get("survivor_id") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + + // Restore survivor content/tags if this was a merge. + if kind == "merge" + && let (Some(content), Some(tags)) = ( + undo.get("survivor_prev_content").and_then(|v| v.as_str()), + undo.get("survivor_prev_tags").and_then(|v| v.as_array()), + ) + { + let tags: Vec = tags + .iter() + .filter_map(|t| t.as_str().map(|s| s.to_string())) + .collect(); + self.rewrite_survivor(&survivor_id, content, &tags)?; + } + + // Clear invalidation on every absorbed node, restoring prior values. + if let Some(absorbed) = undo.get("absorbed").and_then(|v| v.as_array()) { + for entry in absorbed { + let id = entry.get("id").and_then(|v| v.as_str()).unwrap_or_default(); + if id.is_empty() { + continue; + } + let prev_vu = entry.get("prev_valid_until").and_then(|v| v.as_str()); + let prev_sb = entry.get("prev_superseded_by").and_then(|v| v.as_str()); + self.restore_bitemporal(id, prev_vu, prev_sb)?; + } + } + + let now = Utc::now(); + let new_op_id = uuid::Uuid::new_v4().to_string(); + { + let writer = self + .writer + .lock() + .map_err(|_| StorageError::Init("Writer lock poisoned".into()))?; + // Mark original reverted. + writer.execute( + "UPDATE merge_operations SET status = 'reverted', reverted_at = ?1 WHERE id = ?2", + params![now.to_rfc3339(), op_id], + )?; + // Re-open the plan so it could be re-applied if desired. + if let Some(plan_id) = op.plan_id.as_deref() { + writer.execute( + "UPDATE merge_plans SET status = 'pending', applied_at = NULL WHERE id = ?1", + params![plan_id], + )?; + } + // Record compensating undo op. + writer.execute( + "INSERT INTO merge_operations + (id, plan_id, op_type, status, created_at, reverted_at, reverts_op_id, + survivor_id, affected_ids, confidence, signals, reason, undo_payload) + VALUES (?1, ?2, 'undo', 'applied', ?3, NULL, ?4, ?5, ?6, NULL, NULL, ?7, '{}')", + params![ + new_op_id, + op.plan_id, + now.to_rfc3339(), + op_id, + survivor_id, + serde_json::to_string(&op.affected_ids).unwrap_or_else(|_| "[]".into()), + format!("Reverted {} operation {op_id}", op.op_type), + ], + )?; + } + + self.read_operation(&new_op_id)? + .ok_or_else(|| StorageError::Init("undo operation vanished after insert".into())) + } + + /// List recent merge/supersede operations (the reflog), newest first. + pub fn list_merge_operations( + &self, + limit: usize, + ) -> Result> { + let reader = self + .reader + .lock() + .map_err(|_| StorageError::Init("Reader lock poisoned".into()))?; + let mut stmt = reader.prepare( + "SELECT id, plan_id, op_type, status, created_at, reverted_at, reverts_op_id, + survivor_id, affected_ids, confidence, reason + FROM merge_operations ORDER BY created_at DESC LIMIT ?1", + )?; + let rows = stmt.query_map(params![limit as i64], Self::row_to_operation)?; + let mut out = Vec::new(); + for r in rows { + out.push(r?); + } + Ok(out) + } + + /// Read a single operation by id. + fn read_operation(&self, op_id: &str) -> Result> { + let reader = self + .reader + .lock() + .map_err(|_| StorageError::Init("Reader lock poisoned".into()))?; + let op = reader + .query_row( + "SELECT id, plan_id, op_type, status, created_at, reverted_at, reverts_op_id, + survivor_id, affected_ids, confidence, reason + FROM merge_operations WHERE id = ?1", + params![op_id], + Self::row_to_operation, + ) + .optional()?; + Ok(op) + } + + fn row_to_operation( + row: &rusqlite::Row, + ) -> rusqlite::Result { + let affected: String = row.get("affected_ids")?; + let affected_ids: Vec = serde_json::from_str(&affected).unwrap_or_default(); + Ok(crate::advanced::MergeOperation { + id: row.get("id")?, + plan_id: row.get("plan_id").ok().flatten(), + op_type: row.get("op_type")?, + status: row.get("status")?, + created_at: row.get("created_at")?, + reverted_at: row.get("reverted_at").ok().flatten(), + reverts_op_id: row.get("reverts_op_id").ok().flatten(), + survivor_id: row.get("survivor_id").ok().flatten(), + affected_ids, + confidence: row.get::<_, Option>("confidence").ok().flatten().map(|v| v as f32), + reason: row.get("reason").ok().flatten(), + }) + } + + /// Read (valid_until, superseded_by) for a node. + fn read_bitemporal(&self, id: &str) -> Result<(Option, Option)> { + let reader = self + .reader + .lock() + .map_err(|_| StorageError::Init("Reader lock poisoned".into()))?; + let res = reader + .query_row( + "SELECT valid_until, superseded_by FROM knowledge_nodes WHERE id = ?1", + params![id], + |row| { + Ok(( + row.get::<_, Option>(0)?, + row.get::<_, Option>(1)?, + )) + }, + ) + .optional()?; + res.ok_or_else(|| StorageError::NotFound(id.to_string())) + } + + /// Bitemporally invalidate a node: stamp valid_until=now and superseded_by, + /// keeping the row fully queryable (Graphiti-style invalidate, don't delete). + fn invalidate_node(&self, id: &str, superseded_by: &str, now: DateTime) -> Result<()> { + let writer = self + .writer + .lock() + .map_err(|_| StorageError::Init("Writer lock poisoned".into()))?; + writer.execute( + "UPDATE knowledge_nodes + SET valid_until = ?1, superseded_by = ?2, updated_at = ?1 + WHERE id = ?3", + params![now.to_rfc3339(), superseded_by, id], + )?; + Ok(()) + } + + /// Restore a node's bitemporal columns (used by undo). + fn restore_bitemporal( + &self, + id: &str, + valid_until: Option<&str>, + superseded_by: Option<&str>, + ) -> Result<()> { + let writer = self + .writer + .lock() + .map_err(|_| StorageError::Init("Writer lock poisoned".into()))?; + writer.execute( + "UPDATE knowledge_nodes + SET valid_until = ?1, superseded_by = ?2, updated_at = ?3 + WHERE id = ?4", + params![valid_until, superseded_by, Utc::now().to_rfc3339(), id], + )?; + Ok(()) + } + + /// Rewrite a survivor's content and tags (used by merge apply + undo). + /// Content rewrite regenerates the embedding via `update_node_content`. + fn rewrite_survivor(&self, id: &str, content: &str, tags: &[String]) -> Result<()> { + self.update_node_content(id, content)?; + let tags_json = serde_json::to_string(tags).unwrap_or_else(|_| "[]".into()); + let writer = self + .writer + .lock() + .map_err(|_| StorageError::Init("Writer lock poisoned".into()))?; + writer.execute( + "UPDATE knowledge_nodes SET tags = ?1, updated_at = ?2 WHERE id = ?3", + params![tags_json, Utc::now().to_rfc3339(), id], + )?; + Ok(()) + } +} + +/// Truncate `content` to `max` chars on a char boundary, collapsing newlines. +fn preview(content: &str, max: usize) -> String { + let c = content.replace('\n', " "); + if c.len() > max { + format!("{}...", &c[..c.floor_char_boundary(max)]) + } else { + c + } } // ============================================================================ @@ -5931,6 +7270,7 @@ impl Storage { #[cfg(test)] mod tests { use super::*; + use crate::advanced::{MatchClass, MergePolicy}; use tempfile::tempdir; fn create_test_storage() -> Storage { @@ -6414,6 +7754,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 = 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 +7906,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(); @@ -6805,4 +8353,300 @@ mod tests { .unwrap(); assert_eq!(has_content_column, 0); } + + // ======================================================================== + // Merge / Supersede controls (Phase 3 — v2.1.25) + // + // These exercise the full lifecycle without the live embedding model by + // seeding the `node_embeddings` table directly with the ACTIVE model name, + // so `get_all_embeddings` / `get_node_embedding` accept them. + // ======================================================================== + + /// Ingest a node and seed it with a controllable embedding under the active + /// model so similarity is deterministic in tests. + #[cfg(all(feature = "embeddings", feature = "vector-search"))] + fn seed_node(storage: &Storage, content: &str, tags: &[&str], vector: Vec) -> String { + let node = storage + .ingest(IngestInput { + content: content.to_string(), + node_type: "fact".to_string(), + tags: tags.iter().map(|t| t.to_string()).collect(), + ..Default::default() + }) + .unwrap(); + let bytes = Embedding::new(vector).to_bytes(); + let active = storage.embedding_service.model_name().to_string(); + let writer = storage.writer.lock().unwrap(); + writer + .execute( + "INSERT OR REPLACE INTO node_embeddings + (node_id, embedding, dimensions, model, created_at) + VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params![ + &node.id, + &bytes, + EMBEDDING_DIMENSIONS as i32, + active, + Utc::now().to_rfc3339() + ], + ) + .unwrap(); + writer + .execute( + "UPDATE knowledge_nodes SET has_embedding = 1 WHERE id = ?1", + rusqlite::params![&node.id], + ) + .unwrap(); + node.id + } + + /// A near-unit vector pointing mostly along `axis`, so two nodes sharing an + /// axis are highly similar and nodes on different axes are not. + #[cfg(all(feature = "embeddings", feature = "vector-search"))] + fn axis_vector(axis: usize, jitter: f32) -> Vec { + let mut v = vec![0.0f32; EMBEDDING_DIMENSIONS]; + v[axis % EMBEDDING_DIMENSIONS] = 1.0; + v[(axis + 1) % EMBEDDING_DIMENSIONS] = jitter; + v + } + + #[cfg(all(feature = "embeddings", feature = "vector-search"))] + #[test] + fn test_merge_candidates_threshold_classification() { + let storage = create_test_storage(); + // Two near-identical (same axis) — should be offered as a candidate. + let a = seed_node( + &storage, + "Use tokio runtime for async Rust services", + &["rust", "async"], + axis_vector(3, 0.02), + ); + let b = seed_node( + &storage, + "Use the tokio runtime for async Rust services", + &["rust", "async"], + axis_vector(3, 0.01), + ); + // One unrelated (different axis) — must not join the cluster. + let _c = seed_node( + &storage, + "Prefer postgres for relational data", + &["db"], + axis_vector(200, 0.0), + ); + + let policy = MergePolicy::default(); + let candidates = storage.merge_candidates(policy, 20, &[]).unwrap(); + assert_eq!(candidates.len(), 1, "exactly one duplicate cluster"); + let cluster = &candidates[0]; + assert_eq!(cluster.member_ids.len(), 2); + assert!(cluster.member_ids.contains(&a)); + assert!(cluster.member_ids.contains(&b)); + assert!( + cluster.confidence >= policy.possible_threshold, + "confidence above possible threshold" + ); + assert!(!cluster.has_protected_member); + } + + #[cfg(all(feature = "embeddings", feature = "vector-search"))] + #[test] + fn test_plan_merge_is_preview_only_no_mutation() { + let storage = create_test_storage(); + let a = seed_node(&storage, "Fact A about caching", &["perf"], axis_vector(5, 0.02)); + let b = seed_node( + &storage, + "Fact A about caching, expanded", + &["perf", "cache"], + axis_vector(5, 0.01), + ); + + let plan = storage + .plan_merge(&[a.clone(), b.clone()], None, MergePolicy::default()) + .unwrap(); + + // Plan diff is populated... + assert!(plan.result_content.contains("Fact A about caching")); + assert!(plan.result_tags.contains(&"cache".to_string())); + assert_eq!(plan.invalidated_ids.len(), 1); + + // ...but NOTHING changed: both nodes still valid, content untouched. + let na = storage.get_node(&a).unwrap().unwrap(); + let nb = storage.get_node(&b).unwrap().unwrap(); + assert_eq!(na.content, "Fact A about caching"); + assert_eq!(nb.content, "Fact A about caching, expanded"); + let (vu_a, sb_a) = storage.read_bitemporal(&a).unwrap(); + let (vu_b, sb_b) = storage.read_bitemporal(&b).unwrap(); + assert!(vu_a.is_none() && sb_a.is_none()); + assert!(vu_b.is_none() && sb_b.is_none()); + + // Plan persisted as pending. + assert_eq!(storage.plan_status(&plan.id).unwrap().as_deref(), Some("pending")); + } + + #[cfg(all(feature = "embeddings", feature = "vector-search"))] + #[test] + fn test_apply_then_undo_merge_is_reversible() { + let storage = create_test_storage(); + let survivor = seed_node(&storage, "Keep this canonical note", &["x"], axis_vector(7, 0.02)); + let absorbed = seed_node( + &storage, + "Extra detail to fold in", + &["x", "y"], + axis_vector(7, 0.01), + ); + + let plan = storage + .plan_merge( + &[survivor.clone(), absorbed.clone()], + Some(&survivor), + MergePolicy::default(), + ) + .unwrap(); + let op = storage.apply_plan(&plan.id, true).unwrap(); + assert_eq!(op.op_type, "merge"); + + // After apply: survivor content merged, absorbed bitemporally invalidated + // but STILL QUERYABLE (never deleted). + let surv = storage.get_node(&survivor).unwrap().unwrap(); + assert!(surv.content.contains("Keep this canonical note")); + assert!(surv.content.contains("Extra detail to fold in")); + assert!(surv.tags.contains(&"y".to_string())); + + let (vu, sb) = storage.read_bitemporal(&absorbed).unwrap(); + assert!(vu.is_some(), "absorbed node stamped valid_until"); + assert_eq!(sb.as_deref(), Some(survivor.as_str())); + // Old node is still fully retrievable for audit. + assert!( + storage.get_node(&absorbed).unwrap().is_some(), + "superseded node remains queryable" + ); + assert!(storage.superseded_node_ids().unwrap().contains(&absorbed)); + + // Undo restores everything. + let undo = storage.merge_undo(&op.id).unwrap(); + assert_eq!(undo.op_type, "undo"); + let surv_after = storage.get_node(&survivor).unwrap().unwrap(); + assert_eq!(surv_after.content, "Keep this canonical note"); + let (vu2, sb2) = storage.read_bitemporal(&absorbed).unwrap(); + assert!(vu2.is_none() && sb2.is_none(), "invalidation cleared on undo"); + assert!(!storage.superseded_node_ids().unwrap().contains(&absorbed)); + + // The original op is now marked reverted; double-undo is rejected. + assert!(storage.merge_undo(&op.id).is_err()); + } + + #[cfg(all(feature = "embeddings", feature = "vector-search"))] + #[test] + fn test_supersede_invalidates_old_but_keeps_it_queryable() { + let storage = create_test_storage(); + let old = seed_node(&storage, "LR should be 1e-4", &["ml"], axis_vector(9, 0.02)); + let new = seed_node( + &storage, + "Correction: LR should be 3e-4", + &["ml"], + axis_vector(9, 0.01), + ); + + let plan = storage + .plan_supersede(&old, &new, MergePolicy::default()) + .unwrap(); + // Preview did not mutate. + let (vu0, _) = storage.read_bitemporal(&old).unwrap(); + assert!(vu0.is_none()); + + let op = storage.apply_plan(&plan.id, true).unwrap(); + assert_eq!(op.op_type, "supersede"); + + let (vu, sb) = storage.read_bitemporal(&old).unwrap(); + assert!(vu.is_some(), "old stamped valid_until"); + assert_eq!(sb.as_deref(), Some(new.as_str())); + // New node untouched and valid. + let (vu_new, sb_new) = storage.read_bitemporal(&new).unwrap(); + assert!(vu_new.is_none() && sb_new.is_none()); + // Old still queryable for audit (invalidate, don't delete). + let old_node = storage.get_node(&old).unwrap().unwrap(); + assert_eq!(old_node.content, "LR should be 1e-4"); + + // And reversible. + storage.merge_undo(&op.id).unwrap(); + let (vu_r, sb_r) = storage.read_bitemporal(&old).unwrap(); + assert!(vu_r.is_none() && sb_r.is_none()); + } + + #[cfg(all(feature = "embeddings", feature = "vector-search"))] + #[test] + fn test_protect_blocks_merge_away() { + let storage = create_test_storage(); + let pinned = seed_node(&storage, "Load-bearing fact", &["pin"], axis_vector(11, 0.02)); + let other = seed_node( + &storage, + "Load-bearing fact restated", + &["pin"], + axis_vector(11, 0.01), + ); + storage.set_protected(&pinned, true).unwrap(); + assert!(storage.is_protected(&pinned).unwrap()); + + // Protected node may not be merged AWAY (survivor=other). + let err = storage.plan_merge(&[other.clone(), pinned.clone()], Some(&other), MergePolicy::default()); + assert!(err.is_err(), "merging a protected node away must fail"); + + // But it CAN be the survivor. + let ok = storage.plan_merge( + &[pinned.clone(), other.clone()], + Some(&pinned), + MergePolicy::default(), + ); + assert!(ok.is_ok(), "protected node can be the survivor"); + + // Supersede of a protected node is also blocked. + assert!( + storage.plan_supersede(&pinned, &other, MergePolicy::default()).is_err(), + "superseding a protected node must fail" + ); + + // merge_candidates flags the protected member. + let cands = storage.merge_candidates(MergePolicy::default(), 20, &[]).unwrap(); + assert!(cands.iter().all(|c| c.has_protected_member)); + } + + #[cfg(all(feature = "embeddings", feature = "vector-search"))] + #[test] + fn test_apply_requires_confirm_for_low_confidence() { + let storage = create_test_storage(); + // Tighten thresholds so a moderate pair lands in 'possible' (needs confirm). + let strict = MergePolicy::new(0.99, 0.5, false); + storage.set_merge_policy(strict).unwrap(); + + let a = seed_node(&storage, "Topic alpha note", &["t"], axis_vector(13, 0.30)); + let b = seed_node(&storage, "Topic alpha aside", &["t"], axis_vector(13, 0.60)); + let plan = storage.plan_merge(&[a, b], None, storage.get_merge_policy().unwrap()).unwrap(); + assert_ne!(plan.classification, MatchClass::Match); + + // Without confirm => rejected. + assert!(storage.apply_plan(&plan.id, false).is_err()); + // With confirm => applied. + assert!(storage.apply_plan(&plan.id, true).is_ok()); + // Re-applying an applied plan => rejected. + assert!(storage.apply_plan(&plan.id, true).is_err()); + } + + #[cfg(all(feature = "embeddings", feature = "vector-search"))] + #[test] + fn test_merge_policy_roundtrip_persists() { + let storage = create_test_storage(); + let p = MergePolicy::new(0.9, 0.6, true); + storage.set_merge_policy(p).unwrap(); + let got = storage.get_merge_policy().unwrap(); + assert!((got.match_threshold - 0.9).abs() < 1e-6); + assert!((got.possible_threshold - 0.6).abs() < 1e-6); + assert!(got.auto_apply); + } + + #[test] + fn test_set_protected_unknown_node_errors() { + let storage = create_test_storage(); + assert!(storage.set_protected("does-not-exist", true).is_err()); + } } diff --git a/crates/vestige-mcp/Cargo.toml b/crates/vestige-mcp/Cargo.toml index 221dd1d..f265ec5 100644 --- a/crates/vestige-mcp/Cargo.toml +++ b/crates/vestige-mcp/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "vestige-mcp" -version = "2.1.2" +version = "2.1.25" 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"] @@ -24,6 +24,10 @@ ort-dynamic = ["embeddings", "vestige-core/ort-dynamic"] qwen3-embeddings = ["embeddings", "vestige-core/qwen3-embeddings"] qwen3-reranker = ["qwen3-embeddings"] metal = ["embeddings", "vestige-core/metal"] +# CUDA GPU acceleration on NVIDIA hardware (Windows / Linux, x86_64 + aarch64). +# Pairs with `qwen3-embeddings`; see vestige-core Cargo.toml. +cuda = ["qwen3-embeddings", "vestige-core/cuda"] +cudnn = ["cuda", "vestige-core/cudnn"] [[bin]] name = "vestige-mcp" @@ -47,7 +51,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.25", path = "../vestige-core", default-features = false, features = ["bundled-sqlite"] } # ============================================================================ # MCP Server Dependencies diff --git a/crates/vestige-mcp/README.md b/crates/vestige-mcp/README.md index 10bdfa3..92f53d8 100644 --- a/crates/vestige-mcp/README.md +++ b/crates/vestige-mcp/README.md @@ -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 diff --git a/crates/vestige-mcp/src/autopilot.rs b/crates/vestige-mcp/src/autopilot.rs index 4b4c260..2db04a8 100644 --- a/crates/vestige-mcp/src/autopilot.rs +++ b/crates/vestige-mcp/src/autopilot.rs @@ -435,6 +435,7 @@ async fn handle_event( | VestigeEvent::MemoryUnsuppressed { .. } | VestigeEvent::Rac1CascadeSwept { .. } | VestigeEvent::DeepReferenceCompleted { .. } + | VestigeEvent::HookVerdictRecorded { .. } | VestigeEvent::DreamStarted { .. } | VestigeEvent::DreamProgress { .. } | VestigeEvent::DreamCompleted { .. } diff --git a/crates/vestige-mcp/src/bin/cli.rs b/crates/vestige-mcp/src/bin/cli.rs index 9b34cd3..3daa8e3 100644 --- a/crates/vestige-mcp/src/bin/cli.rs +++ b/crates/vestige-mcp/src/bin/cli.rs @@ -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, @@ -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 { + 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> { + 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 { + #[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 { 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) @@ -740,6 +823,13 @@ fn install_sandwich_from_source( 0o755, options, )?; + let (json_copied, json_skipped) = copy_companion_files( + &source_root.join("hooks"), + &hooks_dir, + &["json"], + 0o644, + options, + )?; let (agents_copied, agents_skipped) = copy_companion_files( &source_root.join("agents"), &agents_dir, @@ -751,8 +841,8 @@ fn install_sandwich_from_source( println!( "{}: {} installed, {} skipped", "Hooks".white().bold(), - hooks_copied, - hooks_skipped + hooks_copied + json_copied, + hooks_skipped + json_skipped ); println!( "{}: {} installed, {} skipped", @@ -766,22 +856,37 @@ fn install_sandwich_from_source( } let dashboard_port = env::var("VESTIGE_DASHBOARD_PORT").unwrap_or_else(|_| "3927".to_string()); - let endpoint = options + let mut endpoint = options .sanhedrin_endpoint .clone() .or_else(|| env::var("VESTIGE_SANHEDRIN_ENDPOINT").ok()) .or_else(|| env::var("MLX_ENDPOINT").ok()) - .unwrap_or_else(|| "http://127.0.0.1:8080/v1/chat/completions".to_string()) + .unwrap_or_default() .trim_end_matches('/') .to_string(); - let model = options + let mut model = options .sanhedrin_model .clone() .or_else(|| env::var("VESTIGE_SANHEDRIN_MODEL").ok()) .or_else(|| env::var("VESTIGE_SANDWICH_MODEL").ok()) - .unwrap_or_else(|| "mlx-community/Qwen3.6-35B-A3B-4bit".to_string()); + .unwrap_or_default(); + if with_launchd { + if endpoint.is_empty() { + endpoint = "http://127.0.0.1:8080/v1/chat/completions".to_string(); + } + if model.is_empty() { + model = "mlx-community/Qwen3.6-35B-A3B-4bit".to_string(); + } + } if enable_sanhedrin { + if endpoint.is_empty() || model.is_empty() { + println!( + "{}", + "Sanhedrin enabled without a verifier model; it will fail open until VESTIGE_SANHEDRIN_ENDPOINT and VESTIGE_SANHEDRIN_MODEL are set." + .yellow() + ); + } write_sanhedrin_env(&hooks_dir, &endpoint, &model, &dashboard_port)?; } if with_launchd { @@ -794,6 +899,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 +999,123 @@ fn run_command(command: &mut Command, action: &str) -> anyhow::Result<()> { Ok(()) } +fn create_private_file(path: &Path) -> std::io::Result { + #[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 { + 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 { + 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 { + 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 +1130,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 +1193,7 @@ fn run_update( install_dir: Option, dry_run: bool, no_sandwich: bool, + sandwich_companion: bool, sandwich: SandwichInstallOptions, ) -> anyhow::Result<()> { println!("{}", "=== Vestige Update ===".cyan().bold()); @@ -1020,15 +1245,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::>(); + 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 +1304,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 +1931,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 +2048,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() { @@ -2206,6 +2464,8 @@ fn run_ingest( /// Run the dashboard web server fn run_dashboard(port: u16, open_browser: bool) -> anyhow::Result<()> { + use vestige_mcp::cognitive::CognitiveEngine; + println!("{}", "=== Vestige Dashboard ===".cyan().bold()); println!(); println!( @@ -2231,7 +2491,14 @@ fn run_dashboard(port: u16, open_browser: bool) -> anyhow::Result<()> { let rt = tokio::runtime::Runtime::new()?; rt.block_on(async move { - vestige_mcp::dashboard::start_dashboard(storage, None, port, open_browser) + // Initialize cognitive engine for dream and other cognitive features + let cognitive = Arc::new(tokio::sync::Mutex::new(CognitiveEngine::new())); + { + let mut cog = cognitive.lock().await; + cog.hydrate(&storage); // Load persisted connections + } + + vestige_mcp::dashboard::start_dashboard(storage, Some(cognitive), port, open_browser) .await .map_err(|e| anyhow::anyhow!("Dashboard error: {}", e)) }) @@ -2305,7 +2572,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()); diff --git a/crates/vestige-mcp/src/bin/restore.rs b/crates/vestige-mcp/src/bin/restore.rs index 6e24800..332c329 100644 --- a/crates/vestige-mcp/src/bin/restore.rs +++ b/crates/vestige-mcp/src/bin/restore.rs @@ -26,7 +26,20 @@ fn main() -> anyhow::Result<()> { // Parse args let args: Vec = std::env::args().collect(); if args.len() < 2 { - eprintln!("Usage: vestige-restore "); + print_usage_stderr(); + std::process::exit(1); + } + if matches!(args[1].as_str(), "-h" | "--help") { + print_usage_stdout(); + return Ok(()); + } + if matches!(args[1].as_str(), "-V" | "--version") { + println!("vestige-restore {}", env!("CARGO_PKG_VERSION")); + return Ok(()); + } + if args.len() > 2 { + eprintln!("Unexpected extra argument: {}", args[2]); + print_usage_stderr(); std::process::exit(1); } @@ -91,6 +104,18 @@ fn main() -> anyhow::Result<()> { Ok(()) } +fn print_usage_stdout() { + println!("{}", usage()); +} + +fn print_usage_stderr() { + eprintln!("{}", usage()); +} + +fn usage() -> &'static str { + "Vestige Restore\n\nUSAGE:\n vestige-restore \n\nOPTIONS:\n -h, --help Print help information\n -V, --version Print version information" +} + /// Truncate a string for display (UTF-8 safe) fn truncate(s: &str, max_chars: usize) -> String { let s = s.replace('\n', " "); diff --git a/crates/vestige-mcp/src/dashboard/events.rs b/crates/vestige-mcp/src/dashboard/events.rs index a6807e2..8edb238 100644 --- a/crates/vestige-mcp/src/dashboard/events.rs +++ b/crates/vestige-mcp/src/dashboard/events.rs @@ -85,6 +85,16 @@ pub enum VestigeEvent { timestamp: DateTime, }, + // -- Hook verdicts -- + HookVerdictRecorded { + hook: String, + verdict: String, + phase: String, + reason: String, + receipt_id: Option, + timestamp: DateTime, + }, + // -- Dream -- DreamStarted { memory_count: usize, diff --git a/crates/vestige-mcp/src/dashboard/handlers.rs b/crates/vestige-mcp/src/dashboard/handlers.rs index 78e3b87..39f80ff 100644 --- a/crates/vestige-mcp/src/dashboard/handlers.rs +++ b/crates/vestige-mcp/src/dashboard/handlers.rs @@ -3,6 +3,10 @@ //! v2.0: Adds cognitive operation endpoints (dream, explore, predict, importance, consolidation) use std::cmp::Reverse; +use std::collections::{BTreeMap, HashSet}; +use std::fs::{self, OpenOptions}; +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path as FsPath, PathBuf}; use axum::extract::{Path, Query, State}; use axum::http::StatusCode; @@ -342,6 +346,561 @@ pub async fn unsuppress_memory( }))) } +#[derive(Debug, Deserialize)] +pub struct SanhedrinAppealRequest { + pub reason: String, + pub note: Option, + #[serde(rename = "receiptId")] + pub receipt_id: Option, + #[serde(rename = "claimId")] + pub claim_id: Option, +} + +#[derive(Debug, Deserialize)] +pub struct SanhedrinTelemetryParams { + pub days: Option, +} + +/// Return the latest Sanhedrin receipt written by the Stop-hook bridge. +pub async fn get_sanhedrin_latest() -> Result, StatusCode> { + let state_dir = sanhedrin_state_dir(); + let latest_path = state_dir.join("latest.json"); + if !latest_path.exists() { + return Ok(Json(serde_json::json!({ + "receipt": null, + "stateDir": state_dir, + }))); + } + + let raw = fs::read_to_string(&latest_path).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let receipt: Value = + serde_json::from_str(&raw).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let schema_warning = sanhedrin_schema_warning(&receipt); + + Ok(Json(serde_json::json!({ + "receipt": receipt, + "stateDir": state_dir, + "receiptPath": latest_path, + "htmlPath": state_dir.join("latest.html"), + "schemaWarning": schema_warning, + }))) +} + +/// Return rolling Sanhedrin receipts, appeals, and fail-open counters. +pub async fn get_sanhedrin_telemetry( + Query(params): Query, +) -> Result, StatusCode> { + let state_dir = sanhedrin_state_dir(); + let days = params.days.unwrap_or(7).clamp(1, 90); + let telemetry = tokio::task::spawn_blocking(move || build_sanhedrin_telemetry(state_dir, days)) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)??; + Ok(Json(telemetry)) +} + +fn build_sanhedrin_telemetry(state_dir: PathBuf, days: i64) -> Result { + let cutoff = Utc::now() - Duration::days(days); + let receipts_dir = state_dir.join("receipts"); + let mut by_verdict = serde_json::Map::new(); + for verdict in ["PASS", "NOTE", "CAUTION", "VETO", "APPEALED"] { + by_verdict.insert(verdict.to_string(), Value::from(0)); + } + let mut by_class: BTreeMap = BTreeMap::new(); + let mut daily: BTreeMap> = BTreeMap::new(); + let mut total_runs = 0i64; + let mut last_run_at: Option> = None; + let mut truncated = false; + + if let Ok(entries) = bounded_receipt_entries(&receipts_dir, cutoff) { + for path in entries { + let Ok(raw) = fs::read_to_string(&path) else { + continue; + }; + let Ok(receipt) = serde_json::from_str::(&raw) else { + continue; + }; + let Some(created_at) = parse_sanhedrin_timestamp(&receipt["createdAt"]) else { + continue; + }; + if created_at < cutoff { + continue; + } + total_runs += 1; + if last_run_at.map(|last| created_at > last).unwrap_or(true) { + last_run_at = Some(created_at); + } + let verdict = receipt + .get("verdictBar") + .and_then(Value::as_str) + .unwrap_or("NOTE") + .to_ascii_uppercase(); + increment_json_counter(&mut by_verdict, &verdict); + let day = created_at.date_naive().to_string(); + let bucket = daily.entry(day).or_insert_with(|| { + let mut map = serde_json::Map::new(); + map.insert("date".to_string(), Value::String(String::new())); + map.insert("total".to_string(), Value::from(0)); + map.insert("pass".to_string(), Value::from(0)); + map.insert("note".to_string(), Value::from(0)); + map.insert("caution".to_string(), Value::from(0)); + map.insert("veto".to_string(), Value::from(0)); + map.insert("appealed".to_string(), Value::from(0)); + map.insert("failOpen".to_string(), Value::from(0)); + map + }); + increment_json_counter(bucket, "total"); + match verdict.as_str() { + "PASS" => increment_json_counter(bucket, "pass"), + "NOTE" => increment_json_counter(bucket, "note"), + "CAUTION" => increment_json_counter(bucket, "caution"), + "VETO" => increment_json_counter(bucket, "veto"), + "APPEALED" => increment_json_counter(bucket, "appealed"), + _ => increment_json_counter(bucket, "note"), + } + + if let Some(claims) = receipt.get("claims").and_then(Value::as_array) { + for claim in claims { + let class = known_sanhedrin_class( + claim + .get("class") + .and_then(Value::as_str) + .unwrap_or("UNKNOWN"), + ); + *by_class.entry(class).or_insert(0) += 1; + } + } + } + truncated = total_runs >= 5_000; + } + + let appeals = count_jsonl_since(&state_dir.join("appeals.jsonl"), cutoff, false); + let fail_open = count_jsonl_since(&state_dir.join("fail-open.jsonl"), cutoff, true); + for (date, bucket) in daily.iter_mut() { + bucket.insert("date".to_string(), Value::String(date.clone())); + } + + Ok(serde_json::json!({ + "days": days, + "stateDir": state_dir, + "totalRuns": total_runs, + "byVerdict": by_verdict, + "byClass": by_class, + "appeals": appeals, + "failOpen": fail_open, + "truncated": truncated, + "lastRunAt": last_run_at.map(|dt| dt.to_rfc3339()), + "daily": daily.into_values().map(Value::Object).collect::>(), + })) +} + +/// Record feedback that a Sanhedrin veto was stale, wrong, or too strict. +/// +/// This intentionally does not promote, demote, suppress, edit, or delete any +/// memory. The hook reads this ledger and suppresses future same-fingerprint +/// vetoes, which keeps appeal training scoped to Sanhedrin behavior. +pub async fn appeal_sanhedrin( + State(state): State, + Json(req): Json, +) -> Result, StatusCode> { + let reason = req.reason.trim().to_ascii_lowercase(); + if !matches!(reason.as_str(), "stale" | "wrong" | "too_strict") { + return Err(StatusCode::BAD_REQUEST); + } + + let state_dir = sanhedrin_state_dir(); + let latest_path = state_dir.join("latest.json"); + let raw = match fs::read_to_string(&latest_path) { + Ok(raw) => raw, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + return Err(StatusCode::NOT_FOUND); + } + Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR), + }; + let mut receipt: Value = serde_json::from_str(&raw).map_err(|_| StatusCode::BAD_REQUEST)?; + let original_receipt = receipt.clone(); + let note = req.note.unwrap_or_default(); + let receipt_id = receipt + .get("id") + .and_then(Value::as_str) + .map(ToOwned::to_owned); + let receipt_id_ref = receipt_id.as_deref().ok_or(StatusCode::BAD_REQUEST)?; + let _ = sanitize_receipt_id(receipt_id_ref)?; + let expected_receipt_id = req.receipt_id.as_deref().ok_or(StatusCode::BAD_REQUEST)?; + if expected_receipt_id != receipt_id_ref { + return Err(StatusCode::CONFLICT); + } + if receipt + .get("verdictBar") + .and_then(Value::as_str) + .map(|v| v != "VETO") + .unwrap_or(true) + { + return Err(StatusCode::CONFLICT); + } + let claim = mark_sanhedrin_claim(&mut receipt, &reason, ¬e, req.claim_id.as_deref())?; + + let appeal = serde_json::json!({ + "timestamp": Utc::now().to_rfc3339(), + "receiptId": receipt_id.as_deref(), + "claimId": claim.get("id").and_then(Value::as_str), + "claimFingerprint": claim.get("fingerprint").and_then(Value::as_str), + "claim": claim.get("text").and_then(Value::as_str), + "reason": &reason, + "note": ¬e, + "status": "active", + }); + + set_json_field(&mut receipt, "overall", "appealed"); + set_json_field(&mut receipt, "verdictBar", "APPEALED"); + set_json_field(&mut receipt, "summary", &format!("Appealed as {}.", reason)); + save_sanhedrin_receipt(&state_dir, &receipt)?; + if let Err(err) = append_sanhedrin_appeal(&state_dir, &appeal) { + let _ = save_sanhedrin_receipt(&state_dir, &original_receipt); + return Err(err); + } + + state.emit(VestigeEvent::HookVerdictRecorded { + hook: "sanhedrin".to_string(), + verdict: "APPEALED".to_string(), + phase: "appeal".to_string(), + reason: reason.clone(), + receipt_id: receipt_id.clone(), + timestamp: Utc::now(), + }); + + Ok(Json(serde_json::json!({ + "appeal": appeal, + "receipt": receipt, + }))) +} + +fn sanhedrin_state_dir() -> PathBuf { + std::env::var_os("VESTIGE_SANHEDRIN_STATE_DIR") + .map(PathBuf::from) + .or_else(|| { + std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".vestige/sanhedrin")) + }) + .unwrap_or_else(|| PathBuf::from(".vestige/sanhedrin")) +} + +fn sanhedrin_schema_warning(receipt: &Value) -> Option { + let schema = receipt.get("schema").and_then(Value::as_str).unwrap_or(""); + if schema.is_empty() || schema == "vestige.sanhedrin.receipt.v1" { + None + } else { + Some(format!( + "Unsupported Sanhedrin receipt schema '{}'; dashboard expects vestige.sanhedrin.receipt.v1", + schema + )) + } +} + +fn parse_sanhedrin_timestamp(value: &Value) -> Option> { + let raw = value.as_str()?; + DateTime::parse_from_rfc3339(raw) + .map(|dt| dt.with_timezone(&Utc)) + .ok() +} + +fn bounded_receipt_entries( + receipts_dir: &FsPath, + cutoff: DateTime, +) -> Result, StatusCode> { + const MAX_RECEIPTS: usize = 5_000; + const MAX_RECEIPT_BYTES: u64 = 256 * 1024; + + let mut entries: Vec<(Option>, PathBuf)> = Vec::new(); + let Ok(read_dir) = fs::read_dir(receipts_dir) else { + return Ok(Vec::new()); + }; + + for entry in read_dir.flatten() { + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("json") { + continue; + } + let Ok(metadata) = fs::symlink_metadata(&path) else { + continue; + }; + if metadata.file_type().is_symlink() + || !metadata.is_file() + || metadata.len() > MAX_RECEIPT_BYTES + { + continue; + } + let modified = metadata.modified().ok().map(DateTime::::from); + if modified.map(|mtime| mtime < cutoff).unwrap_or(false) { + continue; + } + entries.push((modified, path)); + } + + entries.sort_by(|(left_time, _), (right_time, _)| right_time.cmp(left_time)); + Ok(entries + .into_iter() + .take(MAX_RECEIPTS) + .map(|(_, path)| path) + .collect()) +} + +fn increment_json_counter(map: &mut serde_json::Map, key: &str) { + let current = map.get(key).and_then(Value::as_i64).unwrap_or(0); + map.insert(key.to_string(), Value::from(current + 1)); +} + +fn count_jsonl_since(path: &FsPath, cutoff: DateTime, distinct_run: bool) -> i64 { + const MAX_LEDGER_BYTES: u64 = 2 * 1024 * 1024; + let Ok(metadata) = fs::symlink_metadata(path) else { + return 0; + }; + if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.len() > MAX_LEDGER_BYTES + { + return 0; + } + let Ok(file) = fs::File::open(path) else { + return 0; + }; + let mut seen_runs = HashSet::new(); + let mut count = 0i64; + for line in BufReader::new(file).lines().map_while(Result::ok) { + let Ok(item) = serde_json::from_str::(&line) else { + continue; + }; + let Some(timestamp) = parse_sanhedrin_timestamp(&item["timestamp"]) else { + continue; + }; + if timestamp < cutoff { + continue; + } + if distinct_run + && let Some(run_id) = item.get("runId").and_then(Value::as_str) + && !seen_runs.insert(run_id.to_string()) + { + continue; + } + count += 1; + } + count +} + +fn known_sanhedrin_class(class: &str) -> String { + match class { + "receipt_lock" + | "TECHNICAL" + | "BIOGRAPHICAL" + | "FINANCIAL" + | "ACHIEVEMENT" + | "TIMELINE" + | "QUANTITATIVE" + | "ATTRIBUTION" + | "CAUSAL" + | "COMPARATIVE" + | "EXISTENTIAL" + | "VAGUE-QUANTIFIER" + | "UNVERIFIED-POSITIVE" => class.to_string(), + _ => "OTHER".to_string(), + } +} + +fn ensure_sanhedrin_dirs(state_dir: &FsPath) -> Result<(), StatusCode> { + fs::create_dir_all(state_dir.join("receipts")).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) +} + +fn mark_sanhedrin_claim( + receipt: &mut Value, + reason: &str, + note: &str, + claim_id: Option<&str>, +) -> Result { + let claim_id = claim_id.ok_or(StatusCode::BAD_REQUEST)?; + let claims = receipt + .get_mut("claims") + .and_then(Value::as_array_mut) + .ok_or(StatusCode::BAD_REQUEST)?; + + if claims.is_empty() { + return Err(StatusCode::BAD_REQUEST); + } + + let selected = claims + .iter() + .position(|claim| claim.get("id").and_then(Value::as_str) == Some(claim_id)) + .ok_or(StatusCode::NOT_FOUND)?; + + if claims + .get(selected) + .and_then(|claim| claim.get("decision")) + .and_then(Value::as_str) + != Some("veto") + { + return Err(StatusCode::CONFLICT); + } + + let claim = claims + .get_mut(selected) + .and_then(Value::as_object_mut) + .ok_or(StatusCode::BAD_REQUEST)?; + + claim.insert( + "decision".to_string(), + Value::String("appealed".to_string()), + ); + claim.insert( + "evidence_state".to_string(), + Value::String("appealed".to_string()), + ); + claim.insert( + "appeal".to_string(), + serde_json::json!({ + "status": "appealed", + "lastReason": reason, + "note": note, + "actions": ["stale", "wrong", "too_strict"], + }), + ); + + Ok(Value::Object(claim.clone())) +} + +fn set_json_field(receipt: &mut Value, key: &str, value: &str) { + if let Some(obj) = receipt.as_object_mut() { + obj.insert(key.to_string(), Value::String(value.to_string())); + } +} + +fn save_sanhedrin_receipt(state_dir: &FsPath, receipt: &Value) -> Result<(), StatusCode> { + ensure_sanhedrin_dirs(state_dir)?; + let rendered = render_sanhedrin_receipt_html(receipt); + let pretty = serde_json::to_string_pretty(receipt).map_err(|_| StatusCode::BAD_REQUEST)?; + let safe_id = receipt + .get("id") + .and_then(Value::as_str) + .map(sanitize_receipt_id) + .transpose()?; + + if let Some(safe_id) = safe_id { + write_atomic( + &state_dir.join("receipts").join(format!("{}.json", safe_id)), + pretty.as_bytes(), + )?; + write_atomic( + &state_dir.join("receipts").join(format!("{}.html", safe_id)), + rendered.as_bytes(), + )?; + } + + write_atomic(&state_dir.join("latest.json"), pretty.as_bytes())?; + write_atomic(&state_dir.join("latest.html"), rendered.as_bytes())?; + Ok(()) +} + +fn append_sanhedrin_appeal(state_dir: &FsPath, appeal: &Value) -> Result<(), StatusCode> { + ensure_sanhedrin_dirs(state_dir)?; + let mut appeals = OpenOptions::new() + .create(true) + .append(true) + .open(state_dir.join("appeals.jsonl")) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + writeln!(appeals, "{}", appeal).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) +} + +fn sanitize_receipt_id(id: &str) -> Result<&str, StatusCode> { + if !id.is_empty() + && id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-')) + { + Ok(id) + } else { + Err(StatusCode::BAD_REQUEST) + } +} + +fn write_atomic(path: &FsPath, bytes: &[u8]) -> Result<(), StatusCode> { + let parent = path.parent().ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; + fs::create_dir_all(parent).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let tmp = path.with_extension(format!( + "{}.tmp", + Utc::now().timestamp_nanos_opt().unwrap_or_default() + )); + fs::write(&tmp, bytes).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + fs::rename(&tmp, path).map_err(|_| { + let _ = fs::remove_file(&tmp); + StatusCode::INTERNAL_SERVER_ERROR + }) +} + +fn render_sanhedrin_receipt_html(receipt: &Value) -> String { + let verdict = escape_html( + receipt + .get("verdictBar") + .and_then(Value::as_str) + .unwrap_or("PASS"), + ); + let summary = escape_html(receipt.get("summary").and_then(Value::as_str).unwrap_or("")); + let mut claims_html = String::new(); + + if let Some(claims) = receipt.get("claims").and_then(Value::as_array) { + for claim in claims { + let text = escape_html(claim.get("text").and_then(Value::as_str).unwrap_or("")); + let decision = escape_html(claim.get("decision").and_then(Value::as_str).unwrap_or("")); + let evidence_state = escape_html( + claim + .get("evidence_state") + .and_then(Value::as_str) + .unwrap_or(""), + ); + let fix = escape_html( + claim + .get("fix") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .unwrap_or("No change required."), + ); + let mut precedents = String::new(); + if let Some(items) = claim.get("precedent").and_then(Value::as_array) { + for item in items { + let summary = item + .get("summary") + .and_then(Value::as_str) + .unwrap_or("Precedent recorded."); + precedents.push_str(&format!("
    • {}
    • ", escape_html(summary))); + } + } + claims_html.push_str(&format!( + "
      {} / {}

      {}

      Fix: {}

      Appeal: stale | wrong | too_strict

        {}
      ", + decision, evidence_state, text, fix, precedents + )); + } + } + + format!( + r#" +Vestige Veto Receipt + +
      Verdict{}
      +

      Veto Receipt

      {}

      {} +"#, + verdict, summary, claims_html + ) +} + +fn escape_html(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + /// Get system stats pub async fn get_stats(State(state): State) -> Result, StatusCode> { let stats = state diff --git a/crates/vestige-mcp/src/dashboard/mod.rs b/crates/vestige-mcp/src/dashboard/mod.rs index 6bf9bc1..e6336cb 100644 --- a/crates/vestige-mcp/src/dashboard/mod.rs +++ b/crates/vestige-mcp/src/dashboard/mod.rs @@ -176,6 +176,13 @@ fn build_router_inner(state: AppState, port: u16) -> (Router, AppState) { // Wraps crate::tools::cross_reference::execute. Emits // DeepReferenceCompleted so Graph3D can glide, pulse, and arc. .route("/api/deep_reference", post(handlers::deep_reference_query)) + // Sanhedrin receipts: latest local hook verdict + appeal training. + .route("/api/sanhedrin/latest", get(handlers::get_sanhedrin_latest)) + .route( + "/api/sanhedrin/telemetry", + get(handlers::get_sanhedrin_telemetry), + ) + .route("/api/sanhedrin/appeal", post(handlers::appeal_sanhedrin)) .layer( ServiceBuilder::new() .concurrency_limit(50) diff --git a/crates/vestige-mcp/src/main.rs b/crates/vestige-mcp/src/main.rs index 27d37c7..52524ba 100644 --- a/crates/vestige-mcp/src/main.rs +++ b/crates/vestige-mcp/src/main.rs @@ -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, http_port: u16, + http_enabled: bool, dashboard_enabled: bool, } @@ -79,6 +80,9 @@ fn parse_args_from(args: Vec, env_data_dir: Option) -> 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, env_data_dir: Option) -> Config println!( " --data-dir Custom data directory (overrides VESTIGE_DATA_DIR)" ); - println!(" --http-port HTTP transport port (default: 3928)"); + println!(" --http Enable Streamable HTTP transport"); + println!(" --no-http Disable Streamable HTTP transport"); + println!(" --http-port HTTP transport port (also enables HTTP)"); println!(); println!("ENVIRONMENT:"); println!( @@ -111,10 +117,14 @@ fn parse_args_from(args: Vec, env_data_dir: Option) -> 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, env_data_dir: Option) -> 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, env_data_dir: Option) -> 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, env_data_dir: Option) -> 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, env_data_dir: Option) -> Config Config { data_dir, http_port, + http_enabled, dashboard_enabled, } } @@ -224,7 +243,22 @@ fn prepare_storage_path(data_dir: Option) -> io::Result }; let data_dir = expand_tilde(data_dir); - fs::create_dir_all(&data_dir)?; + + // Check if path exists and is a file (not a directory) + if data_dir.exists() && !data_dir.is_dir() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "Data directory path exists but is not a directory: {}", + data_dir.display() + ), + )); + } + + // Only create if it doesn't exist (avoids "File exists" error on existing directories) + if !data_dir.exists() { + fs::create_dir_all(&data_dir)?; + } #[cfg(unix)] { @@ -430,8 +464,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 +476,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 +500,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 +549,7 @@ mod tests { ); assert_eq!(config.data_dir, Some(PathBuf::from("/tmp/vestige-env"))); + assert!(!config.http_enabled); } #[test] @@ -523,6 +562,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 +583,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(); diff --git a/crates/vestige-mcp/src/protocol/auth.rs b/crates/vestige-mcp/src/protocol/auth.rs index dce821b..955336b 100644 --- a/crates/vestige-mcp/src/protocol/auth.rs +++ b/crates/vestige-mcp/src/protocol/auth.rs @@ -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> { +pub fn token_path() -> Result> { let dirs = ProjectDirs::from("com", "vestige", "core") .ok_or("could not determine project directories")?; Ok(dirs.data_dir().join("auth_token")) diff --git a/crates/vestige-mcp/src/protocol/http.rs b/crates/vestige-mcp/src/protocol/http.rs index e90c313..8bb788c 100644 --- a/crates/vestige-mcp/src/protocol/http.rs +++ b/crates/vestige-mcp/src/protocol/http.rs @@ -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>, event_tx: broadcast::Sender, auth_token: String, + allowed_origins: Arc>, } /// Start the HTTP MCP transport on `127.0.0.1:`. @@ -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::>(); + 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::>(), - ) + .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 { + if let Ok(configured) = std::env::var("VESTIGE_HTTP_ALLOWED_ORIGINS") { + let origins: Vec = 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 { + 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, ) -> 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, 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 + ); + } +} diff --git a/crates/vestige-mcp/src/protocol/messages.rs b/crates/vestige-mcp/src/protocol/messages.rs index b4d6fe1..8f7e459 100644 --- a/crates/vestige-mcp/src/protocol/messages.rs +++ b/crates/vestige-mcp/src/protocol/messages.rs @@ -82,13 +82,25 @@ pub struct ServerCapabilities { // ============================================================================ /// Tool description for tools/list -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ToolDescription { pub name: String, #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, pub input_schema: Value, + /// Per-tool `_meta` annotations from the MCP wire spec. + /// + /// Notable keys recognized by Claude Code (v2.1.91+): + /// - `anthropic/maxResultSizeChars` (integer, up to 500_000): + /// per-tool override of the 50K default `CallToolResult` truncation + /// ceiling. Pinned on the Tool definition; applies to every invocation. + /// + /// Free-form `serde_json::Value` (typically an object) so additional + /// vendor-specific `_meta` keys can be added without further schema + /// changes. + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, } /// Result of tools/list @@ -113,6 +125,8 @@ pub struct CallToolRequest { pub struct CallToolResult { pub content: Vec, #[serde(skip_serializing_if = "Option::is_none")] + pub structured_content: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub is_error: Option, } diff --git a/crates/vestige-mcp/src/protocol/stdio.rs b/crates/vestige-mcp/src/protocol/stdio.rs index 3d36b3e..12d4706 100644 --- a/crates/vestige-mcp/src/protocol/stdio.rs +++ b/crates/vestige-mcp/src/protocol/stdio.rs @@ -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"); - } - } } } diff --git a/crates/vestige-mcp/src/protocol/types.rs b/crates/vestige-mcp/src/protocol/types.rs index 57f5a10..fa489dc 100644 --- a/crates/vestige-mcp/src/protocol/types.rs +++ b/crates/vestige-mcp/src/protocol/types.rs @@ -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) } diff --git a/crates/vestige-mcp/src/server.rs b/crates/vestige-mcp/src/server.rs index 6f3fd65..890739b 100644 --- a/crates/vestige-mcp/src/server.rs +++ b/crates/vestige-mcp/src/server.rs @@ -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, @@ -113,6 +117,13 @@ impl McpServer { pub async fn handle_request(&mut self, request: JsonRpcRequest) -> Option { 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,10 +224,10 @@ impl McpServer { /// Handle tools/list request async fn handle_tools_list(&self) -> Result { - // 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![ + let mut tools = vec![ // ================================================================ // UNIFIED TOOLS (v1.1+) // ================================================================ @@ -218,21 +235,25 @@ impl McpServer { name: "search".to_string(), description: Some("Unified search tool. Uses hybrid search (keyword + semantic + convex combination fusion) internally. Auto-strengthens memories on access (Testing Effect).".to_string()), input_schema: tools::search_unified::schema(), + ..Default::default() }, ToolDescription { name: "memory".to_string(), description: Some("Unified memory management tool. Actions: 'get' (retrieve full node), 'purge' (irreversibly remove content/embeddings with confirm=true), 'delete' (legacy alias for purge), 'state' (get accessibility state), 'promote' (thumbs up — increases retrieval strength), 'demote' (thumbs down — decreases retrieval strength, does NOT delete), 'edit' (update content in-place, preserves FSRS state).".to_string()), input_schema: tools::memory_unified::schema(), + ..Default::default() }, ToolDescription { name: "codebase".to_string(), description: Some("Unified codebase tool. Actions: 'remember_pattern' (store code pattern), 'remember_decision' (store architectural decision), 'get_context' (retrieve patterns and decisions).".to_string()), input_schema: tools::codebase_unified::schema(), + ..Default::default() }, ToolDescription { name: "intention".to_string(), description: Some("Unified intention management tool. Actions: 'set' (create), 'check' (find triggered), 'update' (complete/snooze/cancel), 'list' (show intentions).".to_string()), input_schema: tools::intention_unified::schema(), + ..Default::default() }, // ================================================================ // CORE MEMORY (v1.7: smart_ingest absorbs ingest + checkpoint) @@ -241,6 +262,7 @@ impl McpServer { name: "smart_ingest".to_string(), description: Some("INTELLIGENT memory ingestion with Prediction Error Gating. Single mode: provide 'content' to auto-decide CREATE/UPDATE/SUPERSEDE. Batch mode: provide 'items' array (max 20) for session-end saves — each item runs the full cognitive pipeline (importance scoring, intent detection, synaptic tagging).".to_string()), input_schema: tools::smart_ingest::schema(), + ..Default::default() }, // ================================================================ // TEMPORAL TOOLS (v1.2+) @@ -249,11 +271,13 @@ impl McpServer { name: "memory_timeline".to_string(), description: Some("Browse memories chronologically. Returns memories in a time range, grouped by day. Defaults to last 7 days.".to_string()), input_schema: tools::timeline::schema(), + ..Default::default() }, ToolDescription { name: "memory_changelog".to_string(), description: Some("View audit trail of memory changes. Per-memory: state transitions. System-wide: consolidations + recent state changes.".to_string()), input_schema: tools::changelog::schema(), + ..Default::default() }, // ================================================================ // MAINTENANCE TOOLS (v1.7: system_status replaces health_check + stats) @@ -262,26 +286,31 @@ impl McpServer { name: "system_status".to_string(), description: Some("Combined system health and statistics. Returns status (healthy/degraded/critical/empty), full stats, FSRS preview, cognitive module health, state distribution, warnings, and recommendations.".to_string()), input_schema: tools::maintenance::system_status_schema(), + ..Default::default() }, ToolDescription { name: "consolidate".to_string(), description: Some("Run FSRS-6 memory consolidation cycle. Applies decay, generates embeddings, and performs maintenance. Use when memories seem stale.".to_string()), input_schema: tools::maintenance::consolidate_schema(), + ..Default::default() }, ToolDescription { name: "backup".to_string(), description: Some("Create a SQLite database backup. Returns the backup file path.".to_string()), input_schema: tools::maintenance::backup_schema(), + ..Default::default() }, ToolDescription { name: "export".to_string(), description: Some("Export memories as JSON or JSONL. Supports tag and date filters.".to_string()), input_schema: tools::maintenance::export_schema(), + ..Default::default() }, ToolDescription { name: "gc".to_string(), description: Some("Garbage collect stale memories below retention threshold. Defaults to dry_run=true for safety.".to_string()), input_schema: tools::maintenance::gc_schema(), + ..Default::default() }, // ================================================================ // AUTO-SAVE & DEDUP TOOLS (v1.3+) @@ -290,11 +319,59 @@ impl McpServer { name: "importance_score".to_string(), description: Some("Score content importance using 4-channel neuroscience model (novelty/arousal/reward/attention). Returns composite score, channel breakdown, encoding boost, and explanations.".to_string()), input_schema: tools::importance::schema(), + ..Default::default() }, ToolDescription { name: "find_duplicates".to_string(), description: Some("Find duplicate and near-duplicate memory clusters using cosine similarity on embeddings. Returns clusters with suggested actions (merge/review). Use to clean up redundant memories.".to_string()), input_schema: tools::dedup::schema(), + ..Default::default() + }, + // ================================================================ + // MERGE / SUPERSEDE CONTROLS (v2.1.25 — Phase 3) + // Diff-previewed, confidence-gated, reversible, never silent. + // ================================================================ + ToolDescription { + name: "merge_candidates".to_string(), + description: Some("Surface likely duplicate/overlapping memory clusters with confidence scores and the signals behind each (Fellegi-Sunter match/possible/non-match). Read-only — nothing is changed.".to_string()), + input_schema: tools::merge::merge_candidates_schema(), + ..Default::default() + }, + ToolDescription { + name: "plan_merge".to_string(), + description: Some("Produce a previewable MERGE plan (a diff: combined content/tags/provenance) for 2+ memories WITHOUT applying it. Returns a plan_id for apply_plan. Protected members block the merge.".to_string()), + input_schema: tools::merge::plan_merge_schema(), + ..Default::default() + }, + ToolDescription { + name: "plan_supersede".to_string(), + description: Some("Preview superseding memory A with B — bitemporal invalidation (stamps valid_until, keeps A queryable for audit) WITHOUT applying. Returns a plan_id for apply_plan.".to_string()), + input_schema: tools::merge::plan_supersede_schema(), + ..Default::default() + }, + ToolDescription { + name: "apply_plan".to_string(), + description: Some("Execute a previously-generated merge/supersede plan by id. Recorded as a reversible operation. Old memories are invalidated (never deleted). 'possible'/'non_match' plans require confirm=true.".to_string()), + input_schema: tools::merge::apply_plan_schema(), + ..Default::default() + }, + ToolDescription { + name: "merge_undo".to_string(), + description: Some("Reverse a prior merge/supersede operation (the 'git reflog for your agent's memory'). With no operation_id, lists the reversible operation log so you can pick one.".to_string()), + input_schema: tools::merge::merge_undo_schema(), + ..Default::default() + }, + ToolDescription { + name: "protect".to_string(), + description: Some("Pin a memory so it can never be auto-merged, superseded, or garbage-collected. Pass protected=false to unpin.".to_string()), + input_schema: tools::merge::protect_schema(), + ..Default::default() + }, + ToolDescription { + name: "merge_policy".to_string(), + description: Some("Get or set the per-project merge policy: the two Fellegi-Sunter thresholds (match_threshold, possible_threshold) and auto_apply. No args returns the current policy.".to_string()), + input_schema: tools::merge::merge_policy_schema(), + ..Default::default() }, // ================================================================ // COGNITIVE TOOLS (v1.5+) @@ -303,16 +380,19 @@ impl McpServer { name: "dream".to_string(), description: Some("Trigger memory dreaming — replays recent memories to discover hidden connections, synthesize insights, and strengthen important patterns. Returns insights, connections, and dream stats.".to_string()), input_schema: tools::dream::schema(), + ..Default::default() }, ToolDescription { name: "explore_connections".to_string(), description: Some("Graph exploration tool for memory connections. Actions: 'chain' (build reasoning path between memories), 'associations' (find related memories via spreading activation + hippocampal index), 'bridges' (find connecting memories between two nodes).".to_string()), input_schema: tools::explore::schema(), + ..Default::default() }, ToolDescription { name: "predict".to_string(), description: Some("Proactive memory prediction — predicts what memories you'll need next based on context, recent activity, and learned patterns. Returns predictions, suggestions, and speculative retrievals.".to_string()), input_schema: tools::predict::schema(), + ..Default::default() }, // ================================================================ // RESTORE TOOL (v1.5+) @@ -321,6 +401,7 @@ impl McpServer { name: "restore".to_string(), description: Some("Restore memories from a JSON backup file. Supports MCP wrapper format, RecallResult format, and direct memory array format.".to_string()), input_schema: tools::restore::schema(), + ..Default::default() }, // ================================================================ // CONTEXT PACKETS (v1.8+) @@ -329,6 +410,7 @@ impl McpServer { name: "session_context".to_string(), description: Some("One-call session initialization. Combines search, intentions, status, predictions, and codebase context into a single token-budgeted response. Replaces 5 separate calls at session start.".to_string()), input_schema: tools::session_context::schema(), + ..Default::default() }, // ================================================================ // AUTONOMIC TOOLS (v1.9+) @@ -337,11 +419,13 @@ impl McpServer { name: "memory_health".to_string(), description: Some("Retention dashboard. Returns avg retention, retention distribution (buckets: 0-20%, 20-40%, etc.), trend (improving/declining/stable), and recommendation. Lightweight alternative to full system_status focused on memory quality.".to_string()), input_schema: tools::health::schema(), + ..Default::default() }, ToolDescription { name: "memory_graph".to_string(), description: Some("Subgraph export for visualization. Input: center_id or query, depth (1-3), max_nodes. Returns nodes with force-directed layout positions and edges with weights. Powers memory graph visualization.".to_string()), input_schema: tools::graph::schema(), + ..Default::default() }, // ================================================================ // DEEP REFERENCE (v2.0.4+) — replaces cross_reference @@ -350,16 +434,19 @@ impl McpServer { name: "deep_reference".to_string(), description: Some("Deep cognitive reasoning across memories. Combines FSRS-6 trust scoring, spreading activation, temporal supersession, dream insights, and contradiction analysis to build a complete understanding of a topic. Returns trust-scored evidence, fact evolution timeline, and a recommended answer. Use this when accuracy matters.".to_string()), input_schema: tools::cross_reference::schema(), + ..Default::default() }, ToolDescription { name: "cross_reference".to_string(), description: Some("Alias for deep_reference. Connect the dots across memories with cognitive reasoning.".to_string()), input_schema: tools::cross_reference::schema(), + ..Default::default() }, ToolDescription { name: "contradictions".to_string(), description: Some("Inspect memory disagreements directly. Scans a topic or recent memories for trust-weighted contradiction pairs using the same local logic as deep_reference.".to_string()), input_schema: tools::contradictions::schema(), + ..Default::default() }, // ================================================================ // ACTIVE FORGETTING (v2.0.5) — top-down suppression @@ -369,9 +456,47 @@ impl McpServer { name: "suppress".to_string(), description: Some("Actively suppress a memory via top-down inhibitory control (Anderson 2025 SIF + Davis Rac1). Distinct from delete: the memory persists but is inhibited from retrieval and actively decays. Each call compounds. A background Rac1 worker cascades decay to co-activated neighbors. Reversible within 24 hours via reverse=true.".to_string()), input_schema: tools::suppress::schema(), + ..Default::default() }, ]; + // Per-tool result-size annotation `_meta["anthropic/maxResultSizeChars"]`. + // + // Claude Code v2.1.91+ honors this annotation to override its 50K default + // `CallToolResult` truncation. Without it, large Vestige payloads + // (`search` with `detail_level="full"` at `limit=20` has been observed + // at ~135K chars; `memory_timeline` at `limit=30` at ~84K chars) are + // silently truncated and spilled to disk, forcing the parent agent to + // chunk-read them. + // + // Per-tool caps below are sized at ~2× observed peak with growth + // headroom; max permitted by Anthropic is 500_000. Only the four + // empirically-measured high-payload tools carry the annotation today; + // the remaining 21 tools deliberately do NOT (cargo-cult prevention — + // annotating a small-payload tool dilutes the signal). + // + // Other tools that COULD plausibly grow into the annotated set with + // future workload (`deep_reference`, `cross_reference`, `memory_graph`, + // `explore_connections`, `session_context`) are left unannotated until + // empirical measurement shows truncation under realistic use. + for tool in tools.iter_mut() { + let max_chars: Option = match tool.name.as_str() { + "search" => Some(300_000), + "memory_timeline" => Some(200_000), + "memory" => Some(100_000), + "codebase" => Some(100_000), + _ => None, + }; + if let Some(n) = max_chars { + let mut meta = serde_json::Map::new(); + meta.insert( + "anthropic/maxResultSizeChars".to_string(), + serde_json::Value::from(n), + ); + tool.meta = Some(serde_json::Value::Object(meta)); + } + } + let result = ListToolsResult { tools }; serde_json::to_value(result).map_err(|e| JsonRpcError::internal_error(&e.to_string())) } @@ -386,6 +511,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 +686,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, @@ -796,6 +933,14 @@ impl McpServer { } "find_duplicates" => tools::dedup::execute(&self.storage, request.arguments).await, + // ================================================================ + // MERGE / SUPERSEDE CONTROLS (v2.1.25 — Phase 3) + // ================================================================ + "merge_candidates" | "plan_merge" | "plan_supersede" | "apply_plan" | "merge_undo" + | "protect" | "merge_policy" => { + tools::merge::execute(&self.storage, request.name.as_str(), request.arguments).await + } + // ================================================================ // COGNITIVE TOOLS (v1.5+) // ================================================================ @@ -845,7 +990,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 +1013,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 +1191,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)) + } + } } } @@ -1170,8 +1326,21 @@ impl McpServer { .unwrap_or("") .to_string(); match action { - "delete" | "purge" => { - self.emit(VestigeEvent::MemoryDeleted { id, timestamp: now }); + "delete" | "purge" + 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 +1554,26 @@ mod tests { } } + fn make_notification(method: &str, params: Option) -> 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 +1625,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 +1689,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 +1731,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 +1740,10 @@ 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.25: 32 tools (25 from v2.1.21 + 7 Phase 3 merge/supersede tools: + // merge_candidates, plan_merge, plan_supersede, apply_plan, merge_undo, + // protect, merge_policy) + assert_eq!(tools.len(), 32, "Expected exactly 32 tools in v2.1.25"); let tool_names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect(); @@ -1566,6 +1797,15 @@ mod tests { assert!(tool_names.contains(&"importance_score")); assert!(tool_names.contains(&"find_duplicates")); + // Merge / Supersede controls (v2.1.25 — Phase 3) + assert!(tool_names.contains(&"merge_candidates")); + assert!(tool_names.contains(&"plan_merge")); + assert!(tool_names.contains(&"plan_supersede")); + assert!(tool_names.contains(&"apply_plan")); + assert!(tool_names.contains(&"merge_undo")); + assert!(tool_names.contains(&"protect")); + assert!(tool_names.contains(&"merge_policy")); + // Cognitive tools (v1.5) assert!(tool_names.contains(&"dream")); assert!(tool_names.contains(&"explore_connections")); @@ -1592,7 +1832,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 +1862,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 +1891,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 +1919,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 +1935,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 +1948,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 +1959,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 +1978,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 +1992,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 +2006,162 @@ 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); + } + + // ======================================================================== + // Per-tool result-size annotation tests + // (`_meta["anthropic/maxResultSizeChars"]`, CC v2.1.91+) + // + // The annotation lives on the Tool definition in `tools/list`, so CC reads + // it once when the MCP session opens and applies the override to every + // invocation of that tool. These tests pin the wire-form so a future + // refactor of `ToolDescription` cannot silently drop the annotation. + // ======================================================================== + + /// Expected per-tool caps. Returns `Some(cap)` for tools the discipline + /// annotates, `None` for tools that MUST NOT carry the annotation + /// (cargo-cult prevention). + fn expected_max_result_size(name: &str) -> Option { + match name { + "search" => Some(300_000), + "memory_timeline" => Some(200_000), + "memory" => Some(100_000), + "codebase" => Some(100_000), + _ => None, + } + } + + #[tokio::test] + async fn test_high_payload_tools_have_max_result_size_annotation() { + 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/list", None); + let response = server.handle_request(request).await.unwrap(); + let result = response.result.unwrap(); + let tools = result["tools"].as_array().unwrap(); + + for name in ["search", "memory_timeline", "memory", "codebase"] { + let tool = tools + .iter() + .find(|t| t["name"].as_str() == Some(name)) + .unwrap_or_else(|| panic!("Tool '{}' missing from tools/list", name)); + + let expected = expected_max_result_size(name).unwrap(); + let meta = tool.get("_meta").unwrap_or_else(|| { + panic!("Tool '{}' is missing the `_meta` field on the wire", name) + }); + let actual = meta + .get("anthropic/maxResultSizeChars") + .and_then(|v| v.as_u64()) + .unwrap_or_else(|| { + panic!( + "Tool '{}' _meta lacks integer 'anthropic/maxResultSizeChars'", + name + ) + }); + assert_eq!( + actual, expected, + "Tool '{}' cap drift: expected {} got {}", + name, expected, actual + ); + assert!( + actual <= 500_000, + "Tool '{}' cap {} exceeds Anthropic 500K ceiling", + name, + actual + ); + } + } + + #[tokio::test] + async fn test_other_tools_do_not_carry_max_result_size_annotation() { + // Cargo-cult prevention. Dynamically derived from tools/list so this + // test is robust to new tools being added: any tool that is NOT in + // the discipline-prescribed set MUST NOT carry the annotation. + // Adding the annotation to a small-payload tool dilutes the signal + // and trains future maintainers that the value is arbitrary. + 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/list", None); + let response = server.handle_request(request).await.unwrap(); + let result = response.result.unwrap(); + let tools = result["tools"].as_array().unwrap(); + + for tool in tools { + let name = tool["name"].as_str().unwrap(); + if expected_max_result_size(name).is_some() { + continue; // covered by the annotated-tools test + } + + // Either the `_meta` key is absent OR it is an object without the + // anthropic key — both are acceptable. The forbidden case is the + // anthropic key present on this tool. + let has_max_size = tool + .get("_meta") + .and_then(|m| m.get("anthropic/maxResultSizeChars")) + .is_some(); + assert!( + !has_max_size, + "Tool '{}' should NOT carry maxResultSizeChars annotation \ + (not in the discipline-prescribed set: search, memory_timeline, \ + memory, codebase). If this tool's realistic max-payload now \ + routinely exceeds 50K, update expected_max_result_size() + the \ + annotation loop in handle_tools_list together.", + name + ); + } + } + + #[tokio::test] + async fn test_meta_wire_shape_uses_underscore_meta_field() { + // Anthropic's MCP spec is explicit: the field on the wire is `_meta`, + // NOT `meta`. The Rust struct uses `meta: Option` with + // `#[serde(rename = "_meta")]` — assert the rename actually fired. + 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/list", None); + let response = server.handle_request(request).await.unwrap(); + let result = response.result.unwrap(); + let tools = result["tools"].as_array().unwrap(); + + let search_tool = tools + .iter() + .find(|t| t["name"].as_str() == Some("search")) + .expect("'search' tool present"); + + // Wire-form: `_meta` must exist; `meta` (un-renamed) must NOT exist. + assert!( + search_tool.get("_meta").is_some(), + "search tool missing `_meta` key (serde rename to _meta did not apply)" + ); + assert!( + search_tool.get("meta").is_none(), + "search tool has un-renamed `meta` key (regression — serde rename broke)" + ); + } } diff --git a/crates/vestige-mcp/src/tools/maintenance.rs b/crates/vestige-mcp/src/tools/maintenance.rs index 9081716..9dfb50e 100644 --- a/crates/vestige-mcp/src/tools/maintenance.rs +++ b/crates/vestige-mcp/src/tools/maintenance.rs @@ -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 { + #[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 // ============================================================================ @@ -86,10 +105,24 @@ pub fn gc_schema() -> Value { pub fn system_status_schema() -> Value { serde_json::json!({ "type": "object", - "properties": {} + "properties": { + "schema_introspection": { + "type": "boolean", + "description": "When true, extends the response with a 'schema' block carrying the SQLite schema version, per-table row counts + column lists, and embedding-coverage convenience fields. Default: false (response shape unchanged). Use this for audit / migration-guard / downstream-upgrade scripts that otherwise have to read SQLite directly.", + "default": false + } + } }) } +/// Arguments for the system_status tool. All optional. +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SystemStatusArgs { + #[serde(alias = "schema_introspection")] + schema_introspection: Option, +} + // ============================================================================ // EXECUTE FUNCTIONS // ============================================================================ @@ -98,11 +131,24 @@ pub fn system_status_schema() -> Value { /// /// Returns system health status, full statistics, FSRS preview, /// cognitive module health, state distribution, and actionable recommendations. +/// +/// v2.1.24+: when `schema_introspection: true` is passed, the response +/// additionally carries a `schema` block with the live SQLite schema version, +/// per-table row counts + column lists, and embedding-coverage convenience +/// fields. Default off; response shape unchanged when omitted. pub async fn execute_system_status( storage: &Arc, cognitive: &Arc>, - _args: Option, + args: Option, ) -> Result { + // Parse arguments (all optional, including the args envelope itself). + let parsed: SystemStatusArgs = match args { + Some(v) => serde_json::from_value(v) + .map_err(|e| format!("Invalid arguments: {}", e))?, + None => SystemStatusArgs::default(), + }; + let include_schema = parsed.schema_introspection.unwrap_or(false); + let stats = storage.get_stats().map_err(|e| e.to_string())?; // === Health assessment === @@ -240,7 +286,7 @@ pub async fn execute_system_status( }; let last_backup = storage.last_backup_timestamp(); - Ok(serde_json::json!({ + let mut response = serde_json::json!({ "tool": "system_status", // Health "status": status, @@ -280,7 +326,34 @@ pub async fn execute_system_status( "lastBackupTimestamp": last_backup.map(|dt| dt.to_rfc3339()), "lastConsolidationTimestamp": last_consolidation.map(|dt| dt.to_rfc3339()), }, - })) + }); + + // v2.1.24+: optional schema introspection block. Default off; response + // shape unchanged when omitted. + if include_schema { + let intro = storage.schema_introspection().map_err(|e| e.to_string())?; + let tables_json: Vec = intro + .tables + .iter() + .map(|t| { + serde_json::json!({ + "name": t.name, + "rows": t.rows, + "columns": t.columns, + }) + }) + .collect(); + response["schema"] = serde_json::json!({ + "schemaVersion": intro.schema_version, + "schemaVersionAppliedAt": intro.schema_version_applied_at.map(|dt| dt.to_rfc3339()), + "tables": tables_json, + "embeddingNullCount": intro.embedding_null_count, + "activeEmbeddingModel": intro.active_embedding_model, + "activeEmbeddingDimensions": intro.active_embedding_dimensions, + }); + } + + Ok(response) } /// Consolidate tool @@ -484,7 +557,7 @@ pub async fn execute_export(storage: &Arc, args: Option) -> 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); @@ -773,6 +846,163 @@ mod tests { assert!(triggers["lastDreamTimestamp"].is_null()); } + // ======================================================================== + // SCHEMA INTROSPECTION TESTS (PR2) + // ======================================================================== + + #[test] + fn test_system_status_schema_has_schema_introspection_flag() { + let schema = system_status_schema(); + let props = &schema["properties"]; + let flag = &props["schema_introspection"]; + assert!(flag.is_object(), "schema_introspection property must exist"); + assert_eq!(flag["type"], "boolean"); + assert_eq!(flag["default"], false); + // Top-level required must NOT include this — flag is opt-in. + let required = schema.get("required"); + if let Some(req) = required { + let req_arr = req.as_array().unwrap(); + assert!(!req_arr.contains(&serde_json::json!("schema_introspection"))); + } + } + + #[tokio::test] + async fn test_system_status_without_schema_flag_omits_schema_block() { + // Backwards-compat: when the flag is not set (or false), the response + // shape is unchanged — no `schema` key. + let (storage, _dir) = test_storage().await; + let result = execute_system_status(&storage, &test_cognitive(), None).await; + assert!(result.is_ok()); + let value = result.unwrap(); + assert!( + value.get("schema").is_none(), + "schema block must NOT be present when flag is unset, got {:?}", + value.get("schema") + ); + + // Explicit false → still no schema block. + let result = execute_system_status( + &storage, + &test_cognitive(), + Some(serde_json::json!({ "schema_introspection": false })), + ) + .await; + assert!(result.is_ok()); + let value = result.unwrap(); + assert!(value.get("schema").is_none()); + } + + #[tokio::test] + async fn test_system_status_with_schema_flag_emits_schema_block() { + let (storage, _dir) = test_storage().await; + storage + .ingest(vestige_core::IngestInput { + content: "Schema introspection seed memory".to_string(), + node_type: "fact".to_string(), + source: None, + sentiment_score: 0.0, + sentiment_magnitude: 0.0, + tags: vec!["schema-test".to_string()], + valid_from: None, + valid_until: None, + }) + .unwrap(); + + let result = execute_system_status( + &storage, + &test_cognitive(), + Some(serde_json::json!({ "schema_introspection": true })), + ) + .await; + assert!(result.is_ok(), "{:?}", result); + let value = result.unwrap(); + + // Shape assertions. + let schema_block = value + .get("schema") + .expect("schema block must be present when flag is true"); + assert!(schema_block.is_object()); + assert!( + schema_block["schemaVersion"].is_number(), + "schemaVersion must be a number, got {:?}", + schema_block["schemaVersion"] + ); + // Schema version should be >= 13 (V13 is the highest landed migration + // at the time this PR was authored). + let v = schema_block["schemaVersion"].as_u64().unwrap(); + assert!(v >= 13, "expected schema_version >= 13, got {}", v); + + // tables should be a non-empty array of {name, rows, columns}. + let tables = schema_block["tables"].as_array().unwrap(); + assert!(!tables.is_empty(), "expected at least one table"); + let kn = tables + .iter() + .find(|t| t["name"] == "knowledge_nodes") + .expect("knowledge_nodes table must be present"); + assert_eq!(kn["rows"], 1, "ingested exactly one memory"); + let cols = kn["columns"].as_array().unwrap(); + assert!(!cols.is_empty(), "knowledge_nodes must have columns"); + // The id column is universally present. + let col_names: Vec<&str> = cols.iter().filter_map(|c| c.as_str()).collect(); + assert!( + col_names.contains(&"id"), + "knowledge_nodes.id must be in columns list: {:?}", + col_names + ); + + // Convenience fields. + assert!(schema_block["embeddingNullCount"].is_number()); + // activeEmbeddingModel may be null if the `embeddings` feature is + // not enabled in the test build; just check the key exists. + assert!(schema_block.get("activeEmbeddingModel").is_some()); + assert!(schema_block.get("activeEmbeddingDimensions").is_some()); + } + + #[tokio::test] + async fn test_system_status_camelcase_alias() { + // Accept both `schema_introspection` (snake) and `schemaIntrospection` + // (camel) per the #[serde(rename_all = "camelCase")] + alias attr. + let (storage, _dir) = test_storage().await; + let result = execute_system_status( + &storage, + &test_cognitive(), + Some(serde_json::json!({ "schemaIntrospection": true })), + ) + .await; + assert!(result.is_ok(), "{:?}", result); + let value = result.unwrap(); + assert!( + value.get("schema").is_some(), + "camelCase form must also trigger schema block" + ); + } + + #[test] + fn test_storage_schema_introspection_method() { + // Direct test on the Storage method, independent of the MCP layer. + let dir = TempDir::new().unwrap(); + let storage = Storage::new(Some(dir.path().join("test.db"))).unwrap(); + let intro = storage + .schema_introspection() + .expect("schema_introspection must succeed on a fresh DB"); + + // Schema version pulled from the schema_version table. + assert!( + intro.schema_version >= 13, + "fresh DB should be at schema_version >= 13, got {}", + intro.schema_version + ); + // At least one walked table should exist. + assert!( + !intro.tables.is_empty(), + "expected at least one user-data table" + ); + // Empty DB → no embeddings → embedding_null_count == 0 (no rows to + // count). Once we ingest, it should be > 0 (no embeddings generated + // in tests by default). + assert_eq!(intro.embedding_null_count, 0); + } + #[tokio::test] async fn test_portable_export_writes_archive_to_storage_exports_dir() { let (storage, _dir) = test_storage().await; diff --git a/crates/vestige-mcp/src/tools/memory_unified.rs b/crates/vestige-mcp/src/tools/memory_unified.rs index e524ac4..8a9ddcf 100644 --- a/crates/vestige-mcp/src/tools/memory_unified.rs +++ b/crates/vestige-mcp/src/tools/memory_unified.rs @@ -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(); diff --git a/crates/vestige-mcp/src/tools/merge.rs b/crates/vestige-mcp/src/tools/merge.rs new file mode 100644 index 0000000..f836f5f --- /dev/null +++ b/crates/vestige-mcp/src/tools/merge.rs @@ -0,0 +1,530 @@ +//! Merge / Supersede control tools (Phase 3 — v2.1.25) +//! +//! Diff-previewed, confidence-gated, reversible, self-explaining +//! combine/dedupe/supersede on a never-delete (bitemporal) store. The default +//! is always preview/review — these tools never silently mutate memory. +//! +//! Tool surface (each registered as its own MCP tool name, all routed here): +//! +//! - `merge_candidates` — surface likely duplicate clusters with confidence + +//! the signals behind each (Fellegi-Sunter match / possible / non-match). +//! - `plan_merge` — previewable merge PLAN (a diff) without applying it. +//! - `plan_supersede` — preview superseding A with B (bitemporal invalidation, +//! audit-preserving) without applying. +//! - `apply_plan` — execute a previously-generated plan id; recorded as a +//! reversible operation. +//! - `merge_undo` — reverse a prior merge/supersede operation (the reflog). +//! - `protect` — pin a memory so it can never be auto-merged/superseded/forgotten. +//! - `merge_policy` — get/set the two confidence thresholds + auto_apply. +//! +//! The actual logic lives in `vestige_core` (`storage::Storage` + +//! `advanced::merge_supersede`); this layer only validates arguments and shapes +//! JSON. + +use serde_json::{Value, json}; +use std::sync::Arc; +use vestige_core::Storage; + +// ============================================================================ +// SCHEMAS +// ============================================================================ + +/// `merge_candidates` input schema. +pub fn merge_candidates_schema() -> Value { + json!({ + "type": "object", + "properties": { + "limit": { + "type": "integer", + "description": "Max candidate clusters to return (default 20).", + "default": 20, "minimum": 1, "maximum": 100 + }, + "tags": { + "type": "array", + "items": { "type": "string" }, + "description": "Optional: only consider memories with these tags (ANY match)." + } + } + }) +} + +/// `plan_merge` input schema. +pub fn plan_merge_schema() -> Value { + json!({ + "type": "object", + "properties": { + "member_ids": { + "type": "array", + "items": { "type": "string" }, + "description": "IDs of the memories to merge (>= 2). The survivor is kept; the rest are bitemporally invalidated (kept for audit)." + }, + "survivor_id": { + "type": "string", + "description": "Optional: which member to keep. Defaults to the highest-retention member." + } + }, + "required": ["member_ids"] + }) +} + +/// `plan_supersede` input schema. +pub fn plan_supersede_schema() -> Value { + json!({ + "type": "object", + "properties": { + "old_id": { "type": "string", "description": "Memory being superseded (kept, marked invalid)." }, + "new_id": { "type": "string", "description": "Memory that supersedes the old one." } + }, + "required": ["old_id", "new_id"] + }) +} + +/// `apply_plan` input schema. +pub fn apply_plan_schema() -> Value { + json!({ + "type": "object", + "properties": { + "plan_id": { "type": "string", "description": "ID of a plan produced by plan_merge / plan_supersede." }, + "confirm": { + "type": "boolean", + "description": "Required true for 'possible'/'non_match' plans. 'match' plans apply only if the policy has auto_apply=true, else confirm is required too.", + "default": false + } + }, + "required": ["plan_id"] + }) +} + +/// `merge_undo` input schema. +pub fn merge_undo_schema() -> Value { + json!({ + "type": "object", + "properties": { + "operation_id": { + "type": "string", + "description": "ID of the merge/supersede operation to reverse. Omit to list recent operations (the reflog)." + } + } + }) +} + +/// `protect` input schema. +pub fn protect_schema() -> Value { + json!({ + "type": "object", + "properties": { + "id": { "type": "string", "description": "Memory id to protect/unprotect." }, + "protected": { + "type": "boolean", + "description": "true to pin (block auto-merge/supersede/forget), false to unpin. Default true.", + "default": true + } + }, + "required": ["id"] + }) +} + +/// `merge_policy` input schema. +pub fn merge_policy_schema() -> Value { + json!({ + "type": "object", + "properties": { + "match_threshold": { + "type": "number", + "description": "Score >= this => 'match' (auto-merge eligible). 0-1.", + "minimum": 0.0, "maximum": 1.0 + }, + "possible_threshold": { + "type": "number", + "description": "Score in [possible, match) => 'possible' (review). Below => not offered. 0-1.", + "minimum": 0.0, "maximum": 1.0 + }, + "auto_apply": { + "type": "boolean", + "description": "Allow 'match'-class plans to apply without confirm. Default false (review-first)." + } + } + }) +} + +// ============================================================================ +// DISPATCH +// ============================================================================ + +/// Route a merge/supersede tool call by tool name. +pub async fn execute(storage: &Arc, tool: &str, args: Option) -> Result { + match tool { + "merge_candidates" => merge_candidates(storage, args), + "plan_merge" => plan_merge(storage, args), + "plan_supersede" => plan_supersede(storage, args), + "apply_plan" => apply_plan(storage, args), + "merge_undo" => merge_undo(storage, args), + "protect" => protect(storage, args), + "merge_policy" => merge_policy(storage, args), + other => Err(format!("unknown merge tool: {other}")), + } +} + +fn obj(args: &Option) -> serde_json::Map { + args.as_ref() + .and_then(|v| v.as_object().cloned()) + .unwrap_or_default() +} + +// ============================================================================ +// merge_candidates +// ============================================================================ + +fn merge_candidates(storage: &Arc, args: Option) -> Result { + #[cfg(all(feature = "embeddings", feature = "vector-search"))] + { + let a = obj(&args); + let limit = a.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize; + let tags: Vec = a + .get("tags") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|t| t.as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(); + + let policy = storage.get_merge_policy().map_err(|e| e.to_string())?; + let candidates = storage + .merge_candidates(policy, limit, &tags) + .map_err(|e| e.to_string())?; + + let out: Vec = candidates + .iter() + .map(|c| { + json!({ + "memberIds": c.member_ids, + "previews": c.previews, + "survivorId": c.survivor_id, + "confidence": format!("{:.3}", c.confidence), + "classification": c.classification.as_str(), + "hasProtectedMember": c.has_protected_member, + "signals": { + "embeddingSimilarity": format!("{:.3}", c.signals.embedding_similarity), + "tagOverlap": format!("{:.3}", c.signals.tag_overlap), + "tokenOverlap": format!("{:.3}", c.signals.token_overlap), + "combinedScore": format!("{:.3}", c.signals.combined_score) + }, + "nextStep": if c.has_protected_member { + "A member is protected — unprotect it or pick it as survivor before plan_merge." + } else { + "Call plan_merge with these memberIds to preview the combined result." + } + }) + }) + .collect(); + + let policy = storage.get_merge_policy().map_err(|e| e.to_string())?; + Ok(json!({ + "candidates": out, + "totalCandidates": out.len(), + "policy": { + "matchThreshold": policy.match_threshold, + "possibleThreshold": policy.possible_threshold, + "autoApply": policy.auto_apply + }, + "note": "Nothing was changed. These are review candidates only." + })) + } + #[cfg(not(all(feature = "embeddings", feature = "vector-search")))] + { + let _ = (storage, args); + Ok(json!({ "error": "Embeddings feature not enabled.", "candidates": [] })) + } +} + +// ============================================================================ +// plan_merge +// ============================================================================ + +fn plan_merge(storage: &Arc, args: Option) -> Result { + #[cfg(all(feature = "embeddings", feature = "vector-search"))] + { + let a = obj(&args); + let member_ids: Vec = a + .get("member_ids") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|t| t.as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(); + if member_ids.len() < 2 { + return Err("member_ids must contain at least 2 ids".into()); + } + let survivor = a.get("survivor_id").and_then(|v| v.as_str()); + let policy = storage.get_merge_policy().map_err(|e| e.to_string())?; + let plan = storage + .plan_merge(&member_ids, survivor, policy) + .map_err(|e| e.to_string())?; + Ok(plan_to_json(&plan, &policy)) + } + #[cfg(not(all(feature = "embeddings", feature = "vector-search")))] + { + let _ = (storage, args); + Err("Embeddings feature not enabled.".into()) + } +} + +// ============================================================================ +// plan_supersede +// ============================================================================ + +fn plan_supersede(storage: &Arc, args: Option) -> Result { + #[cfg(all(feature = "embeddings", feature = "vector-search"))] + { + let a = obj(&args); + let old_id = a + .get("old_id") + .and_then(|v| v.as_str()) + .ok_or("old_id is required")?; + let new_id = a + .get("new_id") + .and_then(|v| v.as_str()) + .ok_or("new_id is required")?; + let policy = storage.get_merge_policy().map_err(|e| e.to_string())?; + let plan = storage + .plan_supersede(old_id, new_id, policy) + .map_err(|e| e.to_string())?; + Ok(plan_to_json(&plan, &policy)) + } + #[cfg(not(all(feature = "embeddings", feature = "vector-search")))] + { + let _ = (storage, args); + Err("Embeddings feature not enabled.".into()) + } +} + +#[cfg(all(feature = "embeddings", feature = "vector-search"))] +fn plan_to_json(plan: &vestige_core::MergePlan, policy: &vestige_core::MergePolicy) -> Value { + let requires_confirm = plan.classification != vestige_core::MatchClass::Match || !policy.auto_apply; + json!({ + "planId": plan.id, + "kind": plan.kind.as_str(), + "survivorId": plan.survivor_id, + "memberIds": plan.member_ids, + "diff": { + "resultContent": plan.result_content, + "resultTags": plan.result_tags, + "resultSource": plan.result_source, + "invalidatedIds": plan.invalidated_ids + }, + "confidence": format!("{:.3}", plan.confidence), + "classification": plan.classification.as_str(), + "signals": { + "embeddingSimilarity": format!("{:.3}", plan.signals.embedding_similarity), + "tagOverlap": format!("{:.3}", plan.signals.tag_overlap), + "tokenOverlap": format!("{:.3}", plan.signals.token_overlap), + "combinedScore": format!("{:.3}", plan.signals.combined_score) + }, + "explanation": plan.explanation, + "requiresConfirm": requires_confirm, + "nextStep": format!( + "Review the diff. To execute: apply_plan with plan_id='{}'{}.", + plan.id, + if requires_confirm { " and confirm=true" } else { "" } + ), + "note": "Nothing was changed. This is a preview plan — apply_plan applies it; merge_undo reverses it." + }) +} + +// ============================================================================ +// apply_plan +// ============================================================================ + +fn apply_plan(storage: &Arc, args: Option) -> Result { + #[cfg(all(feature = "embeddings", feature = "vector-search"))] + { + let a = obj(&args); + let plan_id = a + .get("plan_id") + .and_then(|v| v.as_str()) + .ok_or("plan_id is required")?; + let confirm = a.get("confirm").and_then(|v| v.as_bool()).unwrap_or(false); + let op = storage + .apply_plan(plan_id, confirm) + .map_err(|e| e.to_string())?; + Ok(json!({ + "operationId": op.id, + "opType": op.op_type, + "status": op.status, + "survivorId": op.survivor_id, + "affectedIds": op.affected_ids, + "reason": op.reason, + "appliedAt": op.created_at, + "reversible": true, + "nextStep": format!("To reverse this, call merge_undo with operation_id='{}'.", op.id), + "note": "Old memories were bitemporally invalidated (valid_until stamped), NOT deleted. They remain queryable for audit." + })) + } + #[cfg(not(all(feature = "embeddings", feature = "vector-search")))] + { + let _ = (storage, args); + Err("Embeddings feature not enabled.".into()) + } +} + +// ============================================================================ +// merge_undo (also lists the reflog when no id given) +// ============================================================================ + +fn merge_undo(storage: &Arc, args: Option) -> Result { + #[cfg(all(feature = "embeddings", feature = "vector-search"))] + { + let a = obj(&args); + match a.get("operation_id").and_then(|v| v.as_str()) { + Some(op_id) => { + let op = storage.merge_undo(op_id).map_err(|e| e.to_string())?; + Ok(json!({ + "undoOperationId": op.id, + "revertedOperationId": op.reverts_op_id, + "status": "reverted", + "affectedIds": op.affected_ids, + "reason": op.reason, + "note": "The original operation was reversed: survivor content/tags restored and invalidation cleared. The plan is re-openable." + })) + } + None => { + // No id => return the reflog so the caller can pick one. + let ops = storage.list_merge_operations(20).map_err(|e| e.to_string())?; + let log: Vec = ops + .iter() + .map(|op| { + json!({ + "operationId": op.id, + "opType": op.op_type, + "status": op.status, + "survivorId": op.survivor_id, + "affectedIds": op.affected_ids, + "confidence": op.confidence.map(|c| format!("{:.3}", c)), + "reason": op.reason, + "createdAt": op.created_at, + "revertedAt": op.reverted_at + }) + }) + .collect(); + Ok(json!({ + "operations": log, + "totalOperations": log.len(), + "note": "This is the reversible operation log (the memory reflog). Pass operation_id to reverse one." + })) + } + } + } + #[cfg(not(all(feature = "embeddings", feature = "vector-search")))] + { + let _ = (storage, args); + Err("Embeddings feature not enabled.".into()) + } +} + +// ============================================================================ +// protect +// ============================================================================ + +fn protect(storage: &Arc, args: Option) -> Result { + let a = obj(&args); + let id = a + .get("id") + .and_then(|v| v.as_str()) + .ok_or("id is required")?; + let protected = a.get("protected").and_then(|v| v.as_bool()).unwrap_or(true); + storage + .set_protected(id, protected) + .map_err(|e| e.to_string())?; + Ok(json!({ + "id": id, + "protected": protected, + "note": if protected { + "Memory pinned. It can never be auto-merged, superseded, or garbage-collected until unprotected." + } else { + "Memory unprotected. It is now eligible for merge/supersede/forget again." + } + })) +} + +// ============================================================================ +// merge_policy (get when no args, set otherwise) +// ============================================================================ + +fn merge_policy(storage: &Arc, args: Option) -> Result { + let a = obj(&args); + let current = storage.get_merge_policy().map_err(|e| e.to_string())?; + + let has_update = a.contains_key("match_threshold") + || a.contains_key("possible_threshold") + || a.contains_key("auto_apply"); + + if has_update { + let match_t = a + .get("match_threshold") + .and_then(|v| v.as_f64()) + .map(|v| v as f32) + .unwrap_or(current.match_threshold); + let possible_t = a + .get("possible_threshold") + .and_then(|v| v.as_f64()) + .map(|v| v as f32) + .unwrap_or(current.possible_threshold); + let auto = a + .get("auto_apply") + .and_then(|v| v.as_bool()) + .unwrap_or(current.auto_apply); + let policy = vestige_core::MergePolicy::new(match_t, possible_t, auto); + storage.set_merge_policy(policy).map_err(|e| e.to_string())?; + Ok(json!({ + "updated": true, + "matchThreshold": policy.match_threshold, + "possibleThreshold": policy.possible_threshold, + "autoApply": policy.auto_apply, + "note": "Policy saved. Fellegi-Sunter: score>=match => auto-merge eligible; [possible,match) => review; below => not offered." + })) + } else { + Ok(json!({ + "matchThreshold": current.match_threshold, + "possibleThreshold": current.possible_threshold, + "autoApply": current.auto_apply, + "note": "Two-threshold merge policy. Pass match_threshold / possible_threshold / auto_apply to change it." + })) + } +} + +// ============================================================================ +// TESTS — see tests/merge_supersede_test.rs for full integration coverage. +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn schemas_are_objects() { + for s in [ + merge_candidates_schema(), + plan_merge_schema(), + plan_supersede_schema(), + apply_plan_schema(), + merge_undo_schema(), + protect_schema(), + merge_policy_schema(), + ] { + assert_eq!(s["type"], "object"); + } + } + + #[test] + fn plan_merge_requires_two_ids() { + assert!(plan_merge_schema()["required"] + .as_array() + .unwrap() + .iter() + .any(|v| v == "member_ids")); + } +} diff --git a/crates/vestige-mcp/src/tools/mod.rs b/crates/vestige-mcp/src/tools/mod.rs index 260869e..a2c3e24 100644 --- a/crates/vestige-mcp/src/tools/mod.rs +++ b/crates/vestige-mcp/src/tools/mod.rs @@ -24,6 +24,9 @@ pub mod maintenance; pub mod dedup; pub mod importance; +// v2.1.25: Merge / Supersede controls (Phase 3) +pub mod merge; + // v1.5: Cognitive tools pub mod dream; pub mod explore; diff --git a/crates/vestige-mcp/src/tools/search_unified.rs b/crates/vestige-mcp/src/tools/search_unified.rs index 9faf961..3aea11f 100644 --- a/crates/vestige-mcp/src/tools/search_unified.rs +++ b/crates/vestige-mcp/src/tools/search_unified.rs @@ -92,6 +92,10 @@ pub fn schema() -> Value { "type": "boolean", "description": "Force literal/concrete search. Skips semantic expansion, FSRS reweighting, spreading activation, and cognitive side effects. Auto-enabled for quoted strings, env vars, UUIDs, paths, and code identifiers.", "default": false + }, + "tag_prefix": { + "type": "string", + "description": "Optional tag-prefix filter. When set, only results carrying at least one tag whose value starts with this prefix are returned (case-sensitive). Example: tag_prefix=\"meeting:\" matches memories tagged 'meeting:standup', 'meeting:1-on-1', etc. Applied as a post-filter; combine with a larger 'limit' if you expect heavy thinning." } }, "required": ["query"] @@ -120,6 +124,8 @@ struct SearchArgs { #[serde(alias = "retrieval_mode")] retrieval_mode: Option, concrete: Option, + #[serde(alias = "tag_prefix")] + tag_prefix: Option, } /// Execute unified search with 7-stage cognitive pipeline. @@ -183,19 +189,43 @@ pub async fn execute( .concrete .unwrap_or_else(|| is_literal_query(&args.query)); if concrete { + // When a tag_prefix is requested, fetch a larger pool so the + // post-filter has enough headroom to still return ~limit results + // after thinning. Cap at the same upper bound the underlying SQL + // path uses elsewhere (100). + let concrete_fetch_limit = if args.tag_prefix.is_some() { + (limit * 3).min(100) + } else { + limit + }; let results = storage .concrete_search_filtered( &args.query, - limit, + concrete_fetch_limit, args.include_types.as_deref(), args.exclude_types.as_deref(), ) .map_err(|e| e.to_string())?; - let ids: Vec<&str> = results.iter().map(|r| r.node.id.as_str()).collect(); + // Apply tag_prefix post-filter BEFORE strengthen-on-access so + // results the caller did not actually receive do not get a + // testing-effect boost. + let filtered_results: Vec<&vestige_core::SearchResult> = match args.tag_prefix.as_deref() { + Some(prefix) => results + .iter() + .filter(|r| tags_match_prefix(&r.node.tags, prefix)) + .take(limit as usize) + .collect(), + None => results.iter().collect(), + }; + + let ids: Vec<&str> = filtered_results + .iter() + .map(|r| r.node.id.as_str()) + .collect(); let _ = storage.strengthen_batch_on_access(&ids); - let mut formatted: Vec = results + let mut formatted: Vec = filtered_results .iter() .filter(|r| r.node.retention_strength >= min_retention) .map(|r| format_search_result(r, detail_level)) @@ -297,7 +327,11 @@ pub async fn execute( "exhaustive" => 5, // Deep overfetch for maximum recall _ => 3, // Balanced default }; - let overfetch_limit = (limit * overfetch_multiplier).min(100); // Cap at 100 to avoid excessive DB load + // When a tag_prefix filter is requested, double the overfetch (capped at + // the same 100 ceiling) so the post-filter has enough headroom to still + // return ~limit results after thinning. + let tag_prefix_multiplier = if args.tag_prefix.is_some() { 2 } else { 1 }; + let overfetch_limit = (limit * overfetch_multiplier * tag_prefix_multiplier).min(100); // Cap at 100 to avoid excessive DB load let results = storage .hybrid_search_filtered( @@ -326,10 +360,26 @@ pub async fn execute( }) .collect(); + // Apply tag_prefix post-filter BEFORE the reranker so the (expensive) + // cross-encoder does not waste cycles on memories the caller will not + // receive. The Stage 0 keyword-priority merge below also respects the + // filter when applied, since merged items must have survived this step + // OR be re-introduced from keyword_priority_results (which we re-filter). + if let Some(prefix) = args.tag_prefix.as_deref() { + filtered_results.retain(|r| tags_match_prefix(&r.node.tags, prefix)); + } + // ==================================================================== // Dedup: merge Stage 0 keyword-priority results into Stage 1 results // ==================================================================== for kp in &keyword_priority_results { + // Respect tag_prefix here too — Stage 0 ran without it and can + // re-introduce filtered-out memories on the "new result" branch. + if let Some(prefix) = args.tag_prefix.as_deref() + && !tags_match_prefix(&kp.node.tags, prefix) + { + continue; + } if let Some(existing) = filtered_results .iter_mut() .find(|r| r.node.id == kp.node.id) @@ -781,6 +831,18 @@ fn is_literal_query(query: &str) -> bool { .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_') } +/// Returns `true` when the given tag list contains at least one tag whose +/// string value starts with `prefix`. Empty prefix matches every result with +/// at least one tag (and never matches a tagless result). +/// +/// Case-sensitive by design: the existing tag-match semantics in +/// `memory_timeline` / `export` / `gc` are exact-match (case-sensitive), so +/// keeping this consistent avoids surprise. Operators wanting case-insensitive +/// prefix-search should normalize tags at ingest time. +fn tags_match_prefix(tags: &[String], prefix: &str) -> bool { + tags.iter().any(|t| t.starts_with(prefix)) +} + /// Format a search result based on the requested detail level. fn format_search_result(r: &vestige_core::SearchResult, detail_level: &str) -> Value { match detail_level { @@ -1531,4 +1593,210 @@ mod tests { assert_eq!(tb["minimum"], 100); assert_eq!(tb["maximum"], 100000); } + + // ======================================================================== + // TAG_PREFIX TESTS (PR1) + // ======================================================================== + + #[test] + fn test_tags_match_prefix_unit() { + let with_meeting = vec!["meeting:standup".to_string(), "team".to_string()]; + let without_meeting = vec!["adhoc".to_string(), "team".to_string()]; + let tagless: Vec = vec![]; + + assert!(tags_match_prefix(&with_meeting, "meeting:")); + assert!(!tags_match_prefix(&without_meeting, "meeting:")); + // Empty prefix matches when any tag exists; never matches a tagless + // memory. This preserves the "tag_prefix is a filter, not a default + // wildcard" semantics — a tagless memory has no tag-prefix to satisfy. + assert!(tags_match_prefix(&with_meeting, "")); + assert!(!tags_match_prefix(&tagless, "")); + // Case-sensitive (consistent with existing exact-tag matching). + assert!(!tags_match_prefix(&with_meeting, "Meeting:")); + // Prefix must match from the start, not anywhere in the tag value. + assert!(!tags_match_prefix(&with_meeting, "standup")); + } + + #[test] + fn test_schema_has_tag_prefix() { + let schema_value = schema(); + let tp = &schema_value["properties"]["tag_prefix"]; + assert!(tp.is_object(), "tag_prefix property must be present"); + assert_eq!(tp["type"], "string"); + // tag_prefix is NOT required. + let required = schema_value["required"].as_array().unwrap(); + assert!(!required.contains(&serde_json::json!("tag_prefix"))); + } + + /// Helper that ingests a memory with specific tags. The base + /// `ingest_test_content` helper passes `tags: vec![]`, which is fine + /// for legacy tests but not for tag_prefix coverage. + async fn ingest_with_tags( + storage: &Arc, + content: &str, + tags: Vec<&str>, + ) -> String { + let input = IngestInput { + content: content.to_string(), + node_type: "fact".to_string(), + source: None, + sentiment_score: 0.0, + sentiment_magnitude: 0.0, + tags: tags.into_iter().map(String::from).collect(), + valid_from: None, + valid_until: None, + }; + let node = storage.ingest(input).unwrap(); + node.id + } + + #[tokio::test] + async fn test_search_tag_prefix_filters_results() { + let (storage, _dir) = test_storage().await; + // Three memories matching the query semantically, only two carry + // the meeting:* tag-class. + ingest_with_tags( + &storage, + "Standup discussion about Q3 roadmap blockers", + vec!["meeting:standup", "roadmap"], + ) + .await; + ingest_with_tags( + &storage, + "1-on-1 sync on roadmap clarity and ownership", + vec!["meeting:1-on-1", "roadmap"], + ) + .await; + ingest_with_tags( + &storage, + "Solo note: roadmap dependency graph audit", + vec!["adhoc", "roadmap"], + ) + .await; + + let args = serde_json::json!({ + "query": "roadmap", + "tag_prefix": "meeting:", + "min_similarity": 0.0 + }); + let result = execute(&storage, &test_cognitive(), Some(args)).await; + assert!(result.is_ok(), "{:?}", result); + let value = result.unwrap(); + let results = value["results"].as_array().unwrap(); + // Both meeting:* memories should land; the adhoc one should not. + for r in results { + let tags = r["tags"].as_array().expect("tags must be present"); + let has_meeting = tags + .iter() + .any(|t| t.as_str().is_some_and(|s| s.starts_with("meeting:"))); + assert!(has_meeting, "result lacks meeting:* tag: {}", r); + } + // We expect 2 matches given the corpus above. The exact count + // depends on the cognitive pipeline's competition/suppression + // dynamics, so assert a lower bound. + assert!( + results.len() >= 1, + "tag_prefix should leave at least one meeting:* result, got {}", + results.len() + ); + } + + #[tokio::test] + async fn test_search_tag_prefix_excludes_tagless_memories() { + let (storage, _dir) = test_storage().await; + ingest_with_tags( + &storage, + "Notebook entry about consolidation cycles", + vec![], // tagless + ) + .await; + ingest_with_tags( + &storage, + "Project note about consolidation cycles", + vec!["project:vestige"], + ) + .await; + + let args = serde_json::json!({ + "query": "consolidation", + "tag_prefix": "project:", + "min_similarity": 0.0 + }); + let result = execute(&storage, &test_cognitive(), Some(args)).await; + assert!(result.is_ok()); + let value = result.unwrap(); + let results = value["results"].as_array().unwrap(); + for r in results { + let tags = r["tags"].as_array().expect("tags must be present"); + let has_project = tags + .iter() + .any(|t| t.as_str().is_some_and(|s| s.starts_with("project:"))); + assert!(has_project, "tagless or non-project result leaked: {}", r); + } + } + + #[tokio::test] + async fn test_search_without_tag_prefix_unchanged() { + // Backwards-compat: same corpus, same query, no tag_prefix → all + // results pass through regardless of tag composition. This is the + // load-bearing test for additive-only behavior. + let (storage, _dir) = test_storage().await; + ingest_with_tags(&storage, "Notebook entry about audit cycles", vec![]).await; + ingest_with_tags( + &storage, + "Project note about audit cycles", + vec!["project:audit"], + ) + .await; + + let args = serde_json::json!({ + "query": "audit", + "min_similarity": 0.0 + }); + let result = execute(&storage, &test_cognitive(), Some(args)).await; + assert!(result.is_ok()); + let value = result.unwrap(); + let results = value["results"].as_array().unwrap(); + // Both should be retrievable since no tag_prefix is set. + assert!( + results.len() >= 1, + "expected at least one result with no tag_prefix" + ); + } + + #[tokio::test] + async fn test_search_tag_prefix_concrete_path() { + // Concrete-search path (literal query) must also honor tag_prefix. + let (storage, _dir) = test_storage().await; + ingest_with_tags( + &storage, + "OPENAI_API_KEY rotation playbook for meetings", + vec!["meeting:ops"], + ) + .await; + ingest_with_tags( + &storage, + "OPENAI_API_KEY rotation playbook for solo audits", + vec!["adhoc"], + ) + .await; + + let args = serde_json::json!({ + "query": "OPENAI_API_KEY", + "concrete": true, + "tag_prefix": "meeting:" + }); + let result = execute(&storage, &test_cognitive(), Some(args)).await; + assert!(result.is_ok(), "{:?}", result); + let value = result.unwrap(); + assert_eq!(value["method"], "concrete"); + let results = value["results"].as_array().unwrap(); + for r in results { + let tags = r["tags"].as_array().expect("tags must be present"); + let has_meeting = tags + .iter() + .any(|t| t.as_str().is_some_and(|s| s.starts_with("meeting:"))); + assert!(has_meeting, "concrete result lacks meeting:* tag: {}", r); + } + } } diff --git a/crates/vestige-mcp/src/tools/smart_ingest.rs b/crates/vestige-mcp/src/tools/smart_ingest.rs index f5c60cd..c0b447d 100644 --- a/crates/vestige-mcp/src/tools/smart_ingest.rs +++ b/crates/vestige-mcp/src/tools/smart_ingest.rs @@ -57,9 +57,15 @@ pub fn schema() -> Value { "description": "Force creation of a new memory even if similar content exists", "default": false }, + "batchMergePolicy": { + "type": "string", + "enum": ["force_create", "smart"], + "description": "Batch mode only. Defaults to 'force_create' so caller-separated items stay separate. Use 'smart' to allow Prediction Error Gating against existing memories.", + "default": "force_create" + }, "items": { "type": "array", - "description": "Batch mode: array of items to save (max 20). Each runs through full cognitive pipeline with Prediction Error Gating. Use at session end or before context compaction.", + "description": "Batch mode: array of items to save (max 20). Defaults to force-creating each caller-separated item; set batchMergePolicy='smart' to allow Prediction Error Gating against existing memories. Use at session end or before context compaction.", "maxItems": 20, "items": { "type": "object", @@ -104,6 +110,7 @@ struct SmartIngestArgs { tags: Option>, source: Option, force_create: Option, + batch_merge_policy: Option, items: Option>, } @@ -131,8 +138,26 @@ pub async fn execute( // Detect mode: batch (items present) vs single (content present) if let Some(items) = args.items { - let global_force = args.force_create.unwrap_or(false); - return execute_batch(storage, cognitive, items, global_force).await; + let batch_merge_policy = args + .batch_merge_policy + .unwrap_or_else(|| "force_create".to_string()); + let default_force_create = match batch_merge_policy.as_str() { + "force_create" => true, + "smart" => false, + other => { + return Err(format!( + "Invalid batchMergePolicy '{}'. Must be 'force_create' or 'smart'.", + other + )); + } + }; + let global_force = match args.force_create { + Some(true) => true, + Some(false) if batch_merge_policy == "smart" => false, + Some(false) => default_force_create, + None => default_force_create, + }; + return execute_batch(storage, cognitive, items, global_force, &batch_merge_policy).await; } // Single mode: content is required @@ -252,6 +277,9 @@ pub async fn execute( "similarity": result.similarity, "predictionError": result.prediction_error, "supersededId": result.superseded_id, + "previousContent": result.previous_content, + "mergedFrom": result.merged_from, + "mergePreview": result.merge_preview, "importanceScore": importance_composite, "reason": result.reason, "explanation": match result.decision.as_str() { @@ -305,6 +333,7 @@ async fn execute_batch( cognitive: &Arc>, items: Vec, global_force_create: bool, + batch_merge_policy: &str, ) -> Result { if items.is_empty() { return Err("Items array cannot be empty".to_string()); @@ -321,6 +350,7 @@ async fn execute_batch( let updated = 0u32; let mut skipped = 0u32; let mut errors = 0u32; + let mut batch_created_node_ids: Vec = Vec::new(); for (i, item) in items.into_iter().enumerate() { // Skip empty content @@ -400,6 +430,7 @@ async fn execute_batch( let node_type = node.node_type.clone(); created += 1; + batch_created_node_ids.push(node_id.clone()); run_post_ingest( cognitive, &node_id, @@ -431,15 +462,18 @@ async fn execute_batch( #[cfg(all(feature = "embeddings", feature = "vector-search"))] { - match storage.smart_ingest(input) { + match storage.smart_ingest_excluding(input, &batch_created_node_ids) { Ok(result) => { let node_id = result.node.id.clone(); let node_content = result.node.content.clone(); let node_type = result.node.node_type.clone(); match result.decision.as_str() { - "create" | "supersede" | "replace" => created += 1, - "update" | "reinforce" | "merge" | "add_context" => updated += 1, + "create" | "supersede" | "merge" => { + created += 1; + batch_created_node_ids.push(node_id.clone()); + } + "update" | "reinforce" | "replace" | "add_context" => updated += 1, _ => created += 1, } @@ -458,6 +492,11 @@ async fn execute_batch( "decision": result.decision, "nodeId": node_id, "similarity": result.similarity, + "predictionError": result.prediction_error, + "supersededId": result.superseded_id, + "previousContent": result.previous_content, + "mergedFrom": result.merged_from, + "mergePreview": result.merge_preview, "importanceScore": importance_composite, "reason": result.reason })); @@ -482,6 +521,7 @@ async fn execute_batch( let node_type = node.node_type.clone(); created += 1; + batch_created_node_ids.push(node_id.clone()); run_post_ingest( cognitive, &node_id, @@ -514,6 +554,7 @@ async fn execute_batch( Ok(serde_json::json!({ "success": errors == 0, "mode": "batch", + "batchMergePolicy": batch_merge_policy, "summary": { "total": results.len(), "created": created, @@ -637,6 +678,7 @@ mod tests { assert_eq!(schema_value["type"], "object"); assert!(schema_value["properties"]["content"].is_object()); assert!(schema_value["properties"]["forceCreate"].is_object()); + assert!(schema_value["properties"]["batchMergePolicy"].is_object()); assert!(schema_value["properties"]["items"].is_object()); // v1.7: no top-level required — content for single mode, items for batch mode assert!(schema_value.get("required").is_none() || schema_value["required"].is_null()); @@ -807,9 +849,53 @@ mod tests { assert!(result.is_ok()); let value = result.unwrap(); assert_eq!(value["mode"], "batch"); + assert_eq!(value["batchMergePolicy"], "force_create"); assert_eq!(value["summary"]["total"], 2); } + #[tokio::test] + async fn test_batch_defaults_to_force_create_for_caller_separated_items() { + let (storage, _dir) = test_storage().await; + let result = execute( + &storage, + &test_cognitive(), + Some(serde_json::json!({ + "forceCreate": false, + "items": [ + { "content": "Jira tickets should not auto-assign sprint fields." }, + { "content": "Sprint planning summaries should not append Jira status labels." } + ] + })), + ) + .await; + + let value = result.unwrap(); + assert_eq!(value["batchMergePolicy"], "force_create"); + assert_eq!(value["summary"]["created"], 2); + assert_eq!(value["summary"]["updated"], 0); + for item in value["results"].as_array().unwrap() { + assert_eq!(item["decision"], "create"); + assert!(item["reason"].as_str().unwrap().contains("Forced creation")); + } + } + + #[tokio::test] + async fn test_batch_rejects_invalid_merge_policy() { + let (storage, _dir) = test_storage().await; + let result = execute( + &storage, + &test_cognitive(), + Some(serde_json::json!({ + "batchMergePolicy": "merge_everything", + "items": [{ "content": "Invalid policy should fail." }] + })), + ) + .await; + + assert!(result.is_err()); + assert!(result.unwrap_err().contains("batchMergePolicy")); + } + #[tokio::test] async fn test_batch_skips_empty_content() { let (storage, _dir) = test_storage().await; diff --git a/docs/AGENT-MEMORY-PROTOCOL.md b/docs/AGENT-MEMORY-PROTOCOL.md new file mode 100644 index 0000000..367ca4b --- /dev/null +++ b/docs/AGENT-MEMORY-PROTOCOL.md @@ -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. diff --git a/docs/COGNITIVE_SANDWICH.md b/docs/COGNITIVE_SANDWICH.md index 4ba4d1d..4ae9703 100644 --- a/docs/COGNITIVE_SANDWICH.md +++ b/docs/COGNITIVE_SANDWICH.md @@ -21,7 +21,7 @@ The default Cognitive Sandwich installer only stages files and removes old v2.1. └────────────────────────────────────────────────┘ ``` -Sanhedrin, preflight, and all Vestige Claude Code hooks are optional. The default installer wires none of them; it does not call Claude, start MLX, require a 19 GB model download, or require 20+ GB of RAM. Users who want preflight context can opt in with `--enable-preflight`. Users who want the post-response verifier can opt in with `--enable-sanhedrin` and point it at any OpenAI-compatible `/v1/chat/completions` endpoint. On Apple Silicon, an additional `--with-launchd` flag can auto-start the local MLX Qwen backend. +Sanhedrin, preflight, and all Vestige Claude Code hooks are optional. The default installer wires none of them; it does not call Claude, start MLX, require a 19 GB model download, or require 20+ GB of RAM. Users who want preflight context can opt in with `--enable-preflight`. Users who want the post-response verifier can opt in with `--enable-sanhedrin` and point it at any OpenAI-compatible `/v1/chat/completions` endpoint and model name. Sanhedrin is model-agnostic: if no verifier model is configured, it fails open and records guidance instead of guessing a large model. On Apple Silicon, an additional `--with-launchd` flag can auto-start the local MLX Qwen backend. --- @@ -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 `SUPPORTED`, `REFUTED`, or `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 @@ -103,7 +130,8 @@ scripts/check-sandwich-prereqs.sh --preflight Sanhedrin is a separate opt-in layer. ```bash -# Wire the Sanhedrin Stop hook, using the default OpenAI-compatible endpoint. +# Wire the Sanhedrin Stop hook without choosing a model yet. +# It will fail open until endpoint/model are configured. vestige sandwich install --enable-sanhedrin # Apple Silicon only, and only if the machine has enough memory: @@ -116,13 +144,23 @@ vestige sandwich install \ --sanhedrin-model=qwen2.5:14b ``` +Backend presets live at `hooks/sanhedrin-presets.json` and cover custom +OpenAI-compatible servers, small local laptops, balanced local Ollama, MLX, +vLLM, llama.cpp, hosted OpenAI-compatible APIs, and Anthropic via LiteLLM. +Presets are recipes, not requirements. The hook itself only needs an +OpenAI-compatible `/v1/chat/completions` endpoint and a model name chosen by the +user. Backend-specific payload extensions are enabled only by +`VESTIGE_SANHEDRIN_BACKEND=mlx` or `vllm`. For hosted APIs, use +`VESTIGE_SANHEDRIN_API_KEY`; Sanhedrin intentionally does not forward a generic +`OPENAI_API_KEY` to arbitrary configured endpoints. + ### Prerequisites | Tool | 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 +196,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 @@ -174,8 +213,14 @@ On M3 Max 14-core or M2/M1 Max: closer to 3–7s prompt processing, ~50–60 tok | `VESTIGE_SANHEDRIN_ENABLED` | `0` | Set to `1` to enable the optional Sanhedrin Stop hook | | `VESTIGE_SWARM_ENABLED` | `1` | Set to `0` to disable preflight lateral-thinker swarm | | `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_ENDPOINT` | unset | OpenAI-compatible chat completions endpoint for Sanhedrin | +| `VESTIGE_SANHEDRIN_MODEL` | unset | Model name sent to the Sanhedrin endpoint; choose any compatible model | +| `VESTIGE_SANHEDRIN_BACKEND` | unset | Optional backend hint (`ollama`, `llama.cpp`, `mlx`, `vllm`, `openai`, `litellm`) | +| `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 | diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 8f5a36d..64b9c06 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -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 `/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 | @@ -139,6 +141,20 @@ Add to `%APPDATA%\Claude\claude_desktop_config.json`: } ``` +### Opencode TUI/Desktop + +You can put it at [various different](https://opencode.ai/docs/config/#locations) locations. I recommend `opencode.json` in the project folder. + +```json +{ + "mcpServers": { + "vestige": { + "command": "vestige-mcp" + } + } +} +``` + --- ## Custom Data Directory @@ -175,18 +191,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:** diff --git a/docs/FAQ.md b/docs/FAQ.md index 4c3047e..93005b8 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -22,13 +22,13 @@ ## Getting Started
      -"Can Vestige support a two-Claude household?" +"Can Vestige support multiple agents or MCP clients?" -**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.
      @@ -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.
      "What input do you feed it? How does it create memories?" -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.
      "Can it be filled with a conversation stream in realtime?" -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.
      @@ -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.
      @@ -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="...")`
      --- diff --git a/docs/MERGE_SUPERSEDE.md b/docs/MERGE_SUPERSEDE.md new file mode 100644 index 0000000..a35fb06 --- /dev/null +++ b/docs/MERGE_SUPERSEDE.md @@ -0,0 +1,152 @@ +# Merge / Supersede Controls (Phase 3) + +> Diff-previewed, confidence-gated, reversible, self-explaining +> combine/dedupe/supersede on a never-delete (bitemporal) store. + +Memory systems accumulate duplicates, near-duplicates, and outdated facts. The +naive fixes are all bad: dumb hashing under-merges (misses paraphrases), +aggressive LLM merging over-merges and destroys the audit trail, and +auto-deleting on contradiction silently loses information. Vestige's Phase 3 +takes the opposite stance: + +- **Opt-in, never silent.** The default is preview/review. Nothing mutates your + memory unless you explicitly apply a plan. +- **Diff-previewed.** `plan_merge` / `plan_supersede` show exactly what *would* + change before anything does. +- **Confidence-gated.** A Fellegi-Sunter two-threshold score classifies each + candidate as `match` / `possible` / `non_match`. +- **Reversible.** Every applied operation is recorded with an undo payload — a + *git reflog for your agent's memory*. +- **Self-explaining.** Each candidate carries the signals that explain *why* two + memories were judged duplicates. +- **Audit-preserving.** Superseding does not delete: it stamps `valid_until` and + keeps the old memory queryable (Graphiti-style "invalidate, don't delete"). + +## The bitemporal model: invalidate, don't delete + +Superseding memory A with memory B does **not** erase A. Instead: + +- `A.valid_until` is stamped with the supersede time. +- `A.superseded_by` is set to `B.id` (a lineage pointer). +- A remains fully queryable for audit. Searches and timelines can still surface + it; it is simply marked as no longer the current truth. + +This reuses the existing `valid_from` / `valid_until` columns on +`knowledge_nodes` (migration V2) plus a new `superseded_by` column (migration +V14). Merges work the same way: the survivor absorbs the others' content, and +each absorbed node is bitemporally invalidated rather than deleted. + +## Fellegi-Sunter two-threshold scoring + +Candidate scoring combines three signals into a weighted score in `[0, 1]`: + +| Signal | Weight | Source | +| ----------------------- | -----: | ------------------------------------------ | +| Embedding cosine sim | 0.70 | stored embeddings (`node_embeddings`) | +| Tag overlap (Jaccard) | 0.15 | `knowledge_nodes.tags` | +| Content token overlap | 0.15 | Jaccard over content tokens (len > 2) | + +The combined score is then classified against **two** thresholds: + +``` +score >= match_threshold => "match" (auto-merge eligible) +possible_threshold <= score => "possible" (surfaced for review) +score < possible_threshold => "non_match" (never offered) +``` + +Defaults: `match_threshold = 0.86`, `possible_threshold = 0.72`. The two-band +design means borderline cases are surfaced for review instead of being +force-decided in either direction. + +A cluster's confidence is the **weakest** pairwise score within it (the loosest +link), so a cluster is only as confident as its least-similar member. + +## The reversible operation log (the "memory reflog") + +Every applied merge/supersede writes one row to `merge_operations`: + +- `op_type` — `merge` | `supersede` | `undo` +- `status` — `applied` | `reverted` +- `survivor_id`, `affected_ids` — what was touched +- `confidence`, `signals` — the score and *why* the memories combined +- `reason` — a human-readable explanation +- `undo_payload` — a JSON snapshot capturing everything needed to reverse it + +`merge_undo` consumes the undo payload to restore the survivor's prior +content/tags and clear the bitemporal invalidation on every affected node, then +records a compensating `undo` operation. Calling `merge_undo` with no +`operation_id` returns the operation log so you can pick one. + +## Memory protection (pinning) + +`protect` sets the `protected` flag on a memory. A protected memory: + +- is never offered for auto-merge (it is flagged in `merge_candidates`), +- cannot be merged *away* (it may only be the survivor of a merge), +- cannot be superseded, +- is excluded from garbage collection. + +Pass `protected: false` to unpin. + +## Tool surface + +| Tool | Mutates? | Purpose | +| ------------------ | :------: | ------------------------------------------------------------------------- | +| `merge_candidates` | No | Surface likely duplicate clusters with confidence + signals. | +| `plan_merge` | No | Preview a merge of 2+ memories (a diff). Returns a `plan_id`. | +| `plan_supersede` | No | Preview superseding A with B (bitemporal). Returns a `plan_id`. | +| `apply_plan` | **Yes** | Execute a plan by id; recorded as a reversible operation. | +| `merge_undo` | **Yes** | Reverse an operation, or list the operation log when given no id. | +| `protect` | **Yes** | Pin / unpin a memory so it can never be auto-merged/superseded/forgotten. | +| `merge_policy` | **Yes** | Get/set the two thresholds + `auto_apply`. | + +### Typical flow + +```text +1. merge_candidates -> review clusters + confidence + signals +2. plan_merge { member_ids: [...] } -> inspect the diff, get plan_id +3. apply_plan { plan_id, confirm } -> apply; get operation_id (reversible) +4. merge_undo { operation_id } -> reverse if it was wrong +``` + +`apply_plan` requires `confirm: true` for `possible` / `non_match` plans. A +`match` plan applies without `confirm` only when the policy has +`auto_apply: true` (default `false`). + +## Configuration + +The merge policy persists per project (stored in `fsrs_config`). It can also be +overridden via environment variables: + +| Variable | Meaning | +| ----------------------------------- | ------------------------------------ | +| `VESTIGE_MERGE_MATCH_THRESHOLD` | Score ≥ this ⇒ `match`. | +| `VESTIGE_MERGE_POSSIBLE_THRESHOLD` | Score ≥ this ⇒ at least `possible`. | +| `VESTIGE_MERGE_AUTO_APPLY` | `1`/`true` to allow auto-apply. | + +A persisted policy (set via `merge_policy`) takes precedence over the +environment, which takes precedence over the built-in defaults. When +`vestige.toml` configuration lands, the policy will read from there as well. + +## Schema (migration V14) + +- `knowledge_nodes.protected INTEGER NOT NULL DEFAULT 0` +- `knowledge_nodes.superseded_by TEXT` +- `merge_plans(id, kind, status, created_at, applied_at, survivor_id, + member_ids, confidence, classification, payload)` +- `merge_operations(id, plan_id, op_type, status, created_at, reverted_at, + reverts_op_id, survivor_id, affected_ids, confidence, signals, reason, + undo_payload)` + +The two `ALTER TABLE ... ADD COLUMN` statements are applied with duplicate-column +guards so the migration is idempotent on replay; the rest of V14 uses +`CREATE ... IF NOT EXISTS`. + +## Anti-patterns this design avoids + +- **Silently double-storing contradictions.** Merge composition attributes and + de-duplicates content instead of blindly concatenating or dropping it. +- **Auto-deleting on contradiction.** Supersede invalidates bitemporally; the + old memory is retained and queryable. +- **Trading away the audit trail for auto-merge convenience.** Every operation is + logged and reversible, with provenance for why memories combined. diff --git a/docs/SANHEDRIN_RECEIPTS.md b/docs/SANHEDRIN_RECEIPTS.md new file mode 100644 index 0000000..ac0bd4d --- /dev/null +++ b/docs/SANHEDRIN_RECEIPTS.md @@ -0,0 +1,96 @@ +# Sanhedrin Receipt Schema + +Sanhedrin writes local, inspectable receipts so a Stop-hook veto is appealable +instead of opaque. The current schema is `vestige.sanhedrin.receipt.v1`. + +## Locations + +- Latest JSON: `~/.vestige/sanhedrin/latest.json` +- Latest HTML: `~/.vestige/sanhedrin/latest.html` +- Receipt archive: `~/.vestige/sanhedrin/receipts/.json` +- Command receipt ledger: `~/.vestige/sanhedrin/command-receipts.jsonl` +- Appeals: `~/.vestige/sanhedrin/appeals.jsonl` +- Fail-open events: `~/.vestige/sanhedrin/fail-open.jsonl` + +## v1 JSON Shape + +```json +{ + "schema": "vestige.sanhedrin.receipt.v1", + "id": "receipt_", + "draftId": "draft_", + "createdAt": "2026-05-25T18:00:00+00:00", + "overall": "pass|pass_with_warnings|veto|appealed", + "verdictBar": "PASS|NOTE|CAUTION|VETO|APPEALED", + "summary": "Human-readable result", + "draftPreview": "First 1000 chars of the assistant draft", + "claims": [ + { + "id": "c001", + "text": "All tests passed.", + "fingerprint": "16-char sha256 prefix", + "class": "receipt_lock|TECHNICAL|ACHIEVEMENT|...", + "subject": "Sam|draft|command receipt", + "risk": "normal|hard", + "evidence_state": "supported|missing_receipt|contradicted|appealed|...", + "decision": "pass|pass_unverified|veto|appealed", + "precedent": [ + { + "type": "command|receipt_lock|vestige|appeal", + "summary": "Why this claim passed or failed", + "command": "cargo test --workspace", + "exitCode": 0 + } + ], + "fix": "Suggested rewrite", + "appeal": { + "status": "open|appealed", + "actions": ["stale", "wrong", "too_strict"] + } + } + ], + "receipts": [ + { + "source": "transcript|codex-transcript", + "command": "cargo test --workspace", + "exitCode": 0, + "success": true, + "timestamp": "2026-05-25T18:00:00+00:00" + } + ], + "source": { + "stateDir": "~/.vestige/sanhedrin", + "transcript": "/path/to/session.jsonl" + } +} +``` + +## Compatibility Rules + +- Readers should accept `vestige.sanhedrin.receipt.v1` without warning. +- Readers should keep rendering unknown schemas defensively, but surface a + warning instead of silently treating them as v1. +- New schema versions must keep `id`, `createdAt`, `verdictBar`, `summary`, and + `claims` stable or provide a dashboard migration. + +## Staged Evidence Boundary + +`VESTIGE_SANHEDRIN_STAGE_FILE` is a non-durable overlay for current-turn context. +It may help the executioner understand a draft, but code enforces that staged +evidence cannot satisfy durable evidence requirements for `SUPPORTED`, +`REFUTED`, or `REFUTED_BY_ABSENCE`. Durable support must come from Vestige memory +or command receipts. + +## Receipt Lock Compatibility Flags + +`VESTIGE_SANHEDRIN_ALLOW_COMMAND_LEDGER=1` lets Receipt Lock read +`command-receipts.jsonl` when no live transcript path is available. + +`VESTIGE_SANHEDRIN_ALLOW_LOOSE_LEDGER=1` re-enables the legacy fallback that +regex-scans transcript JSON blobs for `command` or `cmd` fields. Keep this off +unless you are migrating old transcripts; structured tool-use receipts are safer +because loose scanning can mistake quoted text for a real command execution. + +Hosted Sanhedrin backends should use `VESTIGE_SANHEDRIN_API_KEY` in +`~/.claude/hooks/vestige-sanhedrin.env`. The installer keeps that file at mode +`0600`; do not store shared or unrelated API keys there. diff --git a/docs/SCIENCE.md b/docs/SCIENCE.md index 795708d..fad2ca0 100644 --- a/docs/SCIENCE.md +++ b/docs/SCIENCE.md @@ -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. --- diff --git a/docs/STORAGE.md b/docs/STORAGE.md index 0e57428..1a82687 100644 --- a/docs/STORAGE.md +++ b/docs/STORAGE.md @@ -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. | diff --git a/docs/VESTIGE_STATE_AND_PLAN.md b/docs/VESTIGE_STATE_AND_PLAN.md index fd67a7f..3e3a001 100644 --- a/docs/VESTIGE_STATE_AND_PLAN.md +++ b/docs/VESTIGE_STATE_AND_PLAN.md @@ -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 diff --git a/docs/integrations/codex-intelligent-memory.md b/docs/integrations/codex-intelligent-memory.md new file mode 100644 index 0000000..b4ab0a5 --- /dev/null +++ b/docs/integrations/codex-intelligent-memory.md @@ -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. diff --git a/docs/integrations/codex.md b/docs/integrations/codex.md index 71f21bd..7d9b17b 100644 --- a/docs/integrations/codex.md +++ b/docs/integrations/codex.md @@ -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
      diff --git a/docs/integrations/xcode.md b/docs/integrations/xcode.md index 0e6396f..3856a5e 100644 --- a/docs/integrations/xcode.md +++ b/docs/integrations/xcode.md @@ -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 diff --git a/docs/launch/blog-post.md b/docs/launch/blog-post.md index 558ef24..886bb5a 100644 --- a/docs/launch/blog-post.md +++ b/docs/launch/blog-post.md @@ -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 diff --git a/glama.json b/glama.json new file mode 100644 index 0000000..ecca9c4 --- /dev/null +++ b/glama.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://glama.ai/mcp/schemas/server.json", + "maintainers": [ + "samvallad33" + ] +} diff --git a/hooks/sanhedrin-local.py b/hooks/sanhedrin-local.py index f6b3d99..f9b68c3 100755 --- a/hooks/sanhedrin-local.py +++ b/hooks/sanhedrin-local.py @@ -15,12 +15,25 @@ # the Cognitive Sandwich on infra errors). The wrapping sanhedrin.sh maps # "yes" to exit 0, so this preserves existing fail-open semantics. +from __future__ import annotations + import json import os import re import sys +import unicodedata import urllib.error +import urllib.parse import urllib.request +from dataclasses import asdict, dataclass, field, replace +from pathlib import Path +from typing import Any + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +try: + import sanhedrin_core +except Exception: + sanhedrin_core = None def env_int(name: str, default: int) -> int: @@ -38,7 +51,7 @@ VESTIGE_BASE_URL = ( SANHEDRIN_ENDPOINT = ( os.environ.get("VESTIGE_SANHEDRIN_ENDPOINT") or os.environ.get("MLX_ENDPOINT") - or "http://127.0.0.1:8080/v1/chat/completions" + or "" ) VESTIGE_ENDPOINT = ( os.environ.get("VESTIGE_DEEP_REFERENCE_ENDPOINT") @@ -50,17 +63,27 @@ VESTIGE_HEALTH = ( MODEL = ( os.environ.get("VESTIGE_SANHEDRIN_MODEL") or os.environ.get("VESTIGE_SANDWICH_MODEL") - or "mlx-community/Qwen3.6-35B-A3B-4bit" + or "" ) SANHEDRIN_TIMEOUT = env_int("VESTIGE_SANHEDRIN_TIMEOUT", env_int("MLX_TIMEOUT", 45)) VESTIGE_TIMEOUT = env_int("VESTIGE_TIMEOUT", 5) -THINK_RE = re.compile(r".*?", re.DOTALL | re.IGNORECASE) +SANHEDRIN_BACKEND = (os.environ.get("VESTIGE_SANHEDRIN_BACKEND") or "").strip().lower() +THINK_RE = re.compile( + r"<(?:think|thinking|reasoning)>.*?", + re.DOTALL | re.IGNORECASE, +) def post_json(url: str, body: dict, timeout: int): + if not url: + return None data = json.dumps(body).encode("utf-8") + headers = {"Content-Type": "application/json"} + api_key = os.environ.get("VESTIGE_SANHEDRIN_API_KEY") + if api_key and same_endpoint_origin(url, SANHEDRIN_ENDPOINT): + headers["Authorization"] = f"Bearer {api_key}" req = urllib.request.Request( - url, data=data, headers={"Content-Type": "application/json"} + url, data=data, headers=headers ) try: with urllib.request.urlopen(req, timeout=timeout) as r: @@ -69,8 +92,267 @@ def post_json(url: str, body: dict, timeout: int): return None +def same_endpoint_origin(url: str, endpoint: str) -> bool: + try: + target = urllib.parse.urlsplit(url) + expected = urllib.parse.urlsplit(endpoint) + except ValueError: + return False + return ( + target.scheme == expected.scheme + and target.netloc == expected.netloc + and target.path == expected.path + ) + + +def use_backend_extensions() -> bool: + if SANHEDRIN_BACKEND in {"mlx", "vllm"}: + return True + if SANHEDRIN_BACKEND in {"openai", "ollama", "llama.cpp", "llamacpp", "litellm"}: + return False + return MODEL.startswith("mlx-community/") + + +def sanhedrin_model_configured() -> bool: + return bool(SANHEDRIN_ENDPOINT and MODEL) + + +def sanhedrin_body( + messages: list[dict[str, str]], + max_tokens: int, + stop: list[str] | None = None, +) -> dict[str, Any]: + body: dict[str, Any] = { + "model": MODEL, + "messages": messages, + "max_tokens": max_tokens, + "temperature": 0.0, + "top_p": 1.0, + "stream": False, + } + if stop: + body["stop"] = stop + if use_backend_extensions(): + body["top_k"] = 1 + body["seed"] = 42 + body["chat_template_kwargs"] = {"enable_thinking": False} + return body + + TRUST_FLOOR = 0.55 # filter out low-trust memories that drive false-positive vetoes +CLAIM_MODE_ENV = "VESTIGE_SANHEDRIN_CLAIM_MODE" +OUTPUT_ENV = "VESTIGE_SANHEDRIN_OUTPUT" +STAGE_FILE_ENV = "VESTIGE_SANHEDRIN_STAGE_FILE" +MAX_CLAIMS = env_int("VESTIGE_SANHEDRIN_MAX_CLAIMS", 8) +MAX_CLAIM_CHARS = env_int("VESTIGE_SANHEDRIN_MAX_CLAIM_CHARS", 500) +MAX_EVIDENCE_CHARS = env_int("VESTIGE_SANHEDRIN_MAX_EVIDENCE_CHARS", 420) + +CLAIM_CLASSES = { + "TECHNICAL", + "BIOGRAPHICAL", + "FINANCIAL", + "ACHIEVEMENT", + "TIMELINE", + "QUANTITATIVE", + "ATTRIBUTION", + "CAUSAL", + "COMPARATIVE", + "EXISTENTIAL", + "VAGUE-QUANTIFIER", + "UNVERIFIED-POSITIVE", +} +CRITICAL_ABSENCE_CLASSES = { + "BIOGRAPHICAL", + "FINANCIAL", + "ACHIEVEMENT", + "TIMELINE", + "QUANTITATIVE", + "ATTRIBUTION", + "VAGUE-QUANTIFIER", +} +STRUCTURED_VERDICTS = {"SUPPORTED", "REFUTED", "REFUTED_BY_ABSENCE", "NEI"} +SEVERITY_ORDER = { + "BIOGRAPHICAL": 0, + "FINANCIAL": 1, + "ACHIEVEMENT": 2, + "ATTRIBUTION": 3, + "TIMELINE": 4, + "QUANTITATIVE": 5, + "VAGUE-QUANTIFIER": 6, + "UNVERIFIED-POSITIVE": 7, + "TECHNICAL": 8, + "EXISTENTIAL": 9, + "CAUSAL": 10, + "COMPARATIVE": 11, +} +USER_TERMS_RE = re.compile( + r"\b(sam|sam's|the user|user's|you|your|yours|yourself)\b", re.IGNORECASE +) +HYPOTHETICAL_PREFIX_RE = re.compile( + r"^\s*(if|suppose|imagine|hypothetically|assume|what if)\b", + re.IGNORECASE, +) +SUBJECT_MODAL_PREFIX_RE = re.compile( + r"^\s*(sam|sam's|the user|user's|you|your)\b\s+(would|could)\b", + re.IGNORECASE, +) +TRAILING_MODAL_COMMENT_RE = re.compile( + r"\s*,?\s+(which|that)\s+(would|could)\b.*$", + re.IGNORECASE, +) +CURRENT_TURN_PREFIXES = [ + re.compile(r"^\s*(per your request|as requested)\s*,?\s*", re.IGNORECASE), + re.compile( + r"^\s*(you|sam|the user)\s+(asked for|requested)\s+maximum subagents\b[^,.;]*(?:,?\s*(and|so)\s*)?", + re.IGNORECASE, + ), + re.compile( + r"^\s*(you|sam|the user)\s+(asked|told|requested|wanted)\s+" + r"(?:(me|us|codex|claude)\s+)?(to|for)\s+", + re.IGNORECASE, + ), + re.compile( + r"^\s*(your|sam's|the user's)\s+request\s+(was|is)\s+(to|for)\s+", + re.IGNORECASE, + ), +] +FIRST_PERSON_DISCOURSE_RE = re.compile( + r"^\s*(i|we)\s+(reviewed|audited|checked|inspected|looked at|verified|" + r"confirmed|found|updated|changed|implemented|fixed|patched|added|removed|" + r"wired|ran|left)\b", + re.IGNORECASE, +) +DISCOURSE_ACTION_PREFIX_RE = re.compile( + r"^\s*(audit|review|check|inspect|look at|verify|confirm|implement|fix|" + r"patch|add|remove|wire|run|use|go all in)\b", + re.IGNORECASE, +) +EMBEDDED_USER_CLAIM_RE = re.compile(r"\b(sam|sam's|the user|user's)\b", re.IGNORECASE) +TECHNICAL_RE = re.compile( + r"(/\w|[\w.-]+\.(py|rs|ts|tsx|js|jsx|json|md|toml|yaml|yml|sh)\b|" + r"\b(api|endpoint|env|flag|model|server|hook|script|function|class|repo|" + r"crate|mcp|http|json|sqlite|rust|python|typescript|command|config)\b|" + r"\b[A-Z][A-Z0-9_]{2,}\b)", + re.IGNORECASE, +) +BIOGRAPHICAL_RE = re.compile( + r"\b(born|lives?|located|based in|works? at|employed|employer|school|" + r"university|college|graduated|degree|founder|ceo|cto|student|job|role)\b", + re.IGNORECASE, +) +FINANCIAL_RE = re.compile( + r"(\$[\d,.]+|\b(revenue|funding|raised|earned|paid|payout|prize money|" + r"salary|net worth|valuation|stock|shares?|portfolio|profit|loss)\b)", + re.IGNORECASE, +) +ACHIEVEMENT_RE = re.compile( + r"\b(won|winner|ranked|placed|scored|score|completed|finished|launched|" + r"released|shipped|milestone|award|prize|accepted|published|graduated)\b", + re.IGNORECASE, +) +TIMELINE_RE = re.compile( + r"\b(\d{4}-\d{2}-\d{2}|\d{1,2}/\d{1,2}/\d{2,4}|" + r"jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|" + r"jul(?:y)?|aug(?:ust)?|sep(?:tember)?|oct(?:ober)?|nov(?:ember)?|" + r"dec(?:ember)?|today|yesterday|tomorrow|last week|next week|" + r"\d+\s+(days?|weeks?|months?|years?)\b)", + re.IGNORECASE, +) +QUANTITATIVE_RE = re.compile( + r"(\b\d+(?:\.\d+)?\s*(%|percent|x|times|stars?|users?|customers?|" + r"submissions?|points?|gb|mb|ms|s|seconds?|minutes?|hours?)?\b|" + r"\b(one|two|three|four|five|six|seven|eight|nine|ten|dozens?|hundreds?|" + r"thousands?|many|several|few|most)\b)", + re.IGNORECASE, +) +TOKEN_RE = re.compile(r"\$?\b[a-z0-9][a-z0-9.-]*\b", re.IGNORECASE) +STOP_CLAIM_TOKENS = { + "about", + "after", + "also", + "because", + "been", + "before", + "claim", + "from", + "have", + "into", + "more", + "sam", + "that", + "their", + "there", + "this", + "user", + "with", + "your", +} +ATTRIBUTION_RE = re.compile( + r"\b(said|told|asked|agreed|decided|approved|rejected|committed|authored|" + r"wrote|built|implemented|requested|wanted|prefers?)\b", + re.IGNORECASE, +) +VAGUE_QUANTIFIER_RE = re.compile( + r"\b(a few|some|several|many|most|multiple)\b.*\b(wins?|won|prizes?|" + r"money|customers?|deals?|submissions?|placements?)\b", + re.IGNORECASE, +) + + +@dataclass(frozen=True) +class Claim: + text: str + claim_class: str + source_index: int + sam_critical: bool + + +@dataclass(frozen=True) +class EvidenceItem: + id: str + preview: str + trust: float + role: str = "evidence" + date: str = "" + durable: bool = True + source: str = "vestige" + + +@dataclass +class ClaimVerdict: + claim: Claim + status: str + reason: str = "" + evidence_ids: list[str] = field(default_factory=list) + durable_evidence_count: int = 0 + high_trust_evidence_count: int = 0 + + +def env_flag(name: str) -> bool: + return (os.environ.get(name) or "").strip().lower() in {"1", "true", "yes", "on"} + + +def truncate_chars(text: str, max_chars: int, suffix: str = "...") -> str: + """Truncate by Python characters, never UTF-8 bytes, and avoid dangling marks.""" + if max_chars <= 0: + return "" + if len(text) <= max_chars: + return text + if max_chars <= len(suffix): + return text[:max_chars] + cut = text[: max_chars - len(suffix)].rstrip() + while cut and unicodedata.combining(cut[-1]): + cut = cut[:-1] + return f"{cut}{suffix}" + + +def safe_float(value: Any, default: float = 0.0) -> float: + try: + return float(value) + except (TypeError, ValueError): + return default + def fetch_evidence(draft: str) -> tuple[str, int]: """Single deep_reference call — returns (formatted evidence, count of high-trust memories). @@ -81,11 +363,15 @@ def fetch_evidence(draft: str) -> tuple[str, int]: with urllib.request.urlopen(VESTIGE_HEALTH, timeout=VESTIGE_TIMEOUT) as r: r.read() except Exception: + if sanhedrin_core is not None: + sanhedrin_core.record_fail_open("vestige_health_unavailable", VESTIGE_HEALTH) return "", 0 query = draft[:1500] resp = post_json(VESTIGE_ENDPOINT, {"query": query, "depth": 12}, VESTIGE_TIMEOUT) if not isinstance(resp, dict): + if sanhedrin_core is not None: + sanhedrin_core.record_fail_open("deep_reference_unavailable", VESTIGE_ENDPOINT) return "", 0 parts = [] @@ -135,6 +421,329 @@ def fetch_evidence(draft: str) -> tuple[str, int]: return header + "\n".join(parts), high_trust_count +def split_candidate_claims(draft: str) -> list[str]: + """Return sentence-ish draft fragments that can be classified as claims.""" + without_fences = re.sub(r"```.*?```", " ", draft[:16_384], flags=re.DOTALL) + without_fences = re.sub(r"`[^`\n]+`", " ", without_fences) + without_fences = re.sub(r'(^|[\s([{])"[^"\n]+"(?=([\s.,;:!?)}\]]|$))', r"\1 ", without_fences) + fragments: list[str] = [] + for line in without_fences.splitlines(): + if line.lstrip().startswith(">"): + continue + line = re.sub(r"^\s*[-*+]\s+", "", line).strip() + line = re.sub(r"^\s*\d+[.)]\s+", "", line).strip() + if not line: + continue + parts = re.split(r"(?<=[.!?])\s+(?=[A-Z0-9`\"'])", line) + fragments.extend(part.strip(" \t-") for part in parts if part.strip(" \t-")) + if len(fragments) >= 512: + return fragments[:512] + if not fragments: + compact = " ".join(without_fences.split()) + fragments = [ + part.strip() + for part in re.split(r"(?<=[.!?])\s+(?=[A-Z0-9`\"'])", compact) + if part.strip() + ] + return fragments[:512] + + +def normalize_asserted_fragment(text: str) -> str | None: + text = " ".join(text.split()).strip() + if not text: + return None + if re.search(r"\b(need|still need|let me|will|should)\s+to\s+verify\b", text, re.I): + return None + if re.fullmatch( + r"(the user|user|sam|you)\s+(said|told|asked|wrote|noted|mentioned)\s*" + r"(earlier|before|previously)?\.?", + text, + re.I, + ): + return None + text = TRAILING_MODAL_COMMENT_RE.sub("", text).strip(" ,;:-") + if HYPOTHETICAL_PREFIX_RE.search(text) or SUBJECT_MODAL_PREFIX_RE.search(text): + return None + + for prefix in CURRENT_TURN_PREFIXES: + stripped = prefix.sub("", text, count=1).strip(" ,;:-") + if stripped == text: + continue + embedded = EMBEDDED_USER_CLAIM_RE.search(stripped) + if embedded and embedded.start() > 0 and DISCOURSE_ACTION_PREFIX_RE.search(stripped): + stripped = stripped[embedded.start() :].strip(" ,;:-") + elif DISCOURSE_ACTION_PREFIX_RE.search(stripped) or FIRST_PERSON_DISCOURSE_RE.search(stripped): + return None + text = stripped + break + + if FIRST_PERSON_DISCOURSE_RE.search(text): + embedded = EMBEDDED_USER_CLAIM_RE.search(text) + if embedded and embedded.start() > 0: + text = text[embedded.start() :].strip(" ,;:-") + else: + return None + + text = TRAILING_MODAL_COMMENT_RE.sub("", text).strip(" ,;:-") + if not text or HYPOTHETICAL_PREFIX_RE.search(text) or SUBJECT_MODAL_PREFIX_RE.search(text): + return None + return text + + +def classify_claim(text: str) -> str | None: + """Classify a factual-shaped claim with conservative, testable heuristics.""" + if VAGUE_QUANTIFIER_RE.search(text): + return "VAGUE-QUANTIFIER" + if BIOGRAPHICAL_RE.search(text): + return "BIOGRAPHICAL" + if FINANCIAL_RE.search(text): + return "FINANCIAL" + if ACHIEVEMENT_RE.search(text): + return "ACHIEVEMENT" + if ATTRIBUTION_RE.search(text): + return "ATTRIBUTION" + if TECHNICAL_RE.search(text): + return "TECHNICAL" + if TIMELINE_RE.search(text): + return "TIMELINE" + if QUANTITATIVE_RE.search(text): + return "QUANTITATIVE" + if re.search(r"\b(exists?|there is|there are|contains?|includes?)\b", text, re.I): + return "EXISTENTIAL" + if re.search(r"\b(because|caused|causes|therefore|so that|as a result)\b", text, re.I): + return "CAUSAL" + if re.search(r"\b(better|best|faster|fastest|more than|less than|fewer than)\b", text, re.I): + return "COMPARATIVE" + return None + + +def is_sam_critical_claim(text: str, claim_class: str) -> bool: + if claim_class not in CRITICAL_ABSENCE_CLASSES: + return False + return bool(USER_TERMS_RE.search(text)) + + +def extract_check_worthy_claims( + draft: str, + max_claims: int = MAX_CLAIMS, + max_claim_chars: int = MAX_CLAIM_CHARS, +) -> list[Claim]: + claims: list[Claim] = [] + seen: set[str] = set() + for idx, fragment in enumerate(split_candidate_claims(draft)): + text = normalize_asserted_fragment(fragment) + if not text: + continue + claim_class = classify_claim(text) + if not claim_class: + continue + text = truncate_chars(text, max_claim_chars) + key = text.lower() + if key in seen: + continue + seen.add(key) + claims.append( + Claim( + text=text, + claim_class=claim_class, + source_index=idx, + sam_critical=is_sam_critical_claim(text, claim_class), + ) + ) + return sorted( + claims, + key=lambda claim: (SEVERITY_ORDER.get(claim.claim_class, 99), claim.source_index), + )[:max_claims] + + +def normalize_evidence_item(raw: Any, source: str = "vestige") -> EvidenceItem | None: + if isinstance(raw, str): + preview = raw.strip() + if not preview: + return None + return EvidenceItem( + id="stage", + preview=truncate_chars(preview, MAX_EVIDENCE_CHARS), + trust=1.0, + role="staged", + durable=False, + source="stage", + ) + if not isinstance(raw, dict): + return None + + preview = ( + raw.get("preview") + or raw.get("answer_preview") + or raw.get("content") + or raw.get("text") + or raw.get("claim") + or "" + ) + preview = str(preview).strip() + if not preview: + return None + trust = safe_float(raw.get("trust", raw.get("trust_score", 1.0 if source == "stage" else 0.0))) + item_id = str(raw.get("memory_id") or raw.get("id") or source or "evidence") + role = str(raw.get("role") or ("staged" if source == "stage" else "evidence")) + date = str(raw.get("date") or raw.get("created_at") or "")[:32] + return EvidenceItem( + id=item_id, + preview=truncate_chars(preview, MAX_EVIDENCE_CHARS), + trust=trust, + role=role, + date=date, + durable=(source != "stage"), + source=source, + ) + + +def evidence_from_deep_reference(resp: dict[str, Any]) -> list[EvidenceItem]: + items: list[EvidenceItem] = [] + rec = resp.get("recommended") or {} + rec_item = normalize_evidence_item(rec, "vestige") + if rec_item: + items.append(rec_item) + for raw in resp.get("evidence") or []: + item = normalize_evidence_item(raw, "vestige") + if item: + items.append(item) + for raw in resp.get("superseded") or []: + item = normalize_evidence_item(raw, "vestige") + if item: + items.append(item) + return dedupe_evidence(items) + + +def dedupe_evidence(items: list[EvidenceItem]) -> list[EvidenceItem]: + deduped: list[EvidenceItem] = [] + seen: set[tuple[str, str]] = set() + for item in items: + key = (item.source, item.id) + if key in seen: + continue + seen.add(key) + deduped.append(item) + return deduped + + +def load_staged_evidence(path: str | None) -> list[EvidenceItem]: + """Read optional JSON-array staged evidence. It is non-durable by design.""" + if not path: + return [] + try: + with open(path, "r", encoding="utf-8") as f: + raw = json.load(f) + except (OSError, json.JSONDecodeError): + return [] + if not isinstance(raw, list): + return [] + items: list[EvidenceItem] = [] + for idx, raw_item in enumerate(raw): + item = normalize_evidence_item(raw_item, "stage") + if item is None: + continue + if item.id == "stage": + item = replace(item, id=f"stage:{idx}") + items.append(item) + return items + + +def claim_query(claim: Claim) -> str: + return ( + f"Class: {claim.claim_class}\n" + f"Claim: {claim.text}" + ) + + +def fetch_claim_evidence(claim: Claim) -> tuple[list[EvidenceItem], bool]: + resp = post_json(VESTIGE_ENDPOINT, {"query": claim_query(claim), "depth": 12}, VESTIGE_TIMEOUT) + if not isinstance(resp, dict): + return [], False + if resp.get("error") or resp.get("errors"): + return [], False + if str(resp.get("status") or "").strip().lower() in { + "error", + "failed", + "failure", + "unavailable", + "timeout", + }: + return [], False + if not any( + key in resp + for key in ("confidence", "evidence", "recommended", "reasoning", "query", "status") + ): + return [], False + return evidence_from_deep_reference(resp), True + + +def high_trust(items: list[EvidenceItem]) -> list[EvidenceItem]: + return [item for item in items if item.trust >= TRUST_FLOOR] + + +def durable_high_trust(items: list[EvidenceItem]) -> list[EvidenceItem]: + return [item for item in items if item.durable and item.trust >= TRUST_FLOOR] + + +def salient_claim_tokens(text: str) -> set[str]: + tokens = {token.lower().strip(".") for token in TOKEN_RE.findall(text)} + return { + token + for token in tokens + if len(token) >= 4 and token not in STOP_CLAIM_TOKENS + } + + +def evidence_relevant_to_claim(claim: Claim, evidence: EvidenceItem) -> bool: + claim_numbers = set(re.findall(r"\$?\d+(?:[,.]\d+)*(?:\.\d+)?", claim.text)) + if claim_numbers and any(num in evidence.preview for num in claim_numbers): + return True + claim_tokens = salient_claim_tokens(claim.text) + if not claim_tokens: + return True + preview_tokens = salient_claim_tokens(evidence.preview) + overlap = claim_tokens & preview_tokens + threshold = 1 if claim.claim_class == "TECHNICAL" else 2 + return len(overlap) >= threshold + + +def relevant_durable_high_trust(claim: Claim, items: list[EvidenceItem]) -> list[EvidenceItem]: + return [ + item + for item in durable_high_trust(items) + if evidence_relevant_to_claim(claim, item) + ] + + +def format_claim_evidence(items: list[EvidenceItem], claim: Claim | None = None) -> str: + if not items: + return "(no relevant evidence retrieved)" + lines = [] + durable_count = ( + len(relevant_durable_high_trust(claim, items)) + if claim is not None + else len(durable_high_trust(items)) + ) + high_count = len(high_trust(items)) + lines.append( + f"HIGH-TRUST EVIDENCE: {high_count} | DURABLE HIGH-TRUST EVIDENCE: {durable_count}" + ) + stage_count = len([item for item in items if not item.durable]) + if stage_count: + lines.append( + "STAGED EVIDENCE PRESENT: non-durable overlay; do not count it as durable memory." + ) + for item in high_trust(items)[:8]: + durable = "durable" if item.durable else "staged" + short_id = item.id[:12] + lines.append( + f"[{short_id}] {durable} role={item.role} trust={item.trust:.2f} date={item.date}\n" + f"{item.preview}" + ) + return "\n\n".join(lines) + + SYSTEM_PROMPT = """You are the Sanhedrin Executioner. You judge whether a DRAFT contradicts Vestige memory evidence about the user. ONE LINE OF OUTPUT. VALID CLASS TAGS (closed set — pick exactly one): @@ -229,16 +838,34 @@ MULTI-CLAIM SEVERITY ORDERING: if multiple claims are vetoable, choose ACHIEVEME When in doubt on TECHNICAL/TIMELINE: PASS. When in doubt on a user-about ACHIEVEMENT/FINANCIAL/BIOGRAPHICAL claim with specific named entities not in evidence: VETO with UNVERIFIED-POSITIVE.""" -VALID_CLASSES = { - "TECHNICAL", "ACHIEVEMENT", "FINANCIAL", "BIOGRAPHICAL", - "TIMELINE", "ATTRIBUTION", "VAGUE-QUANTIFIER", "UNVERIFIED-POSITIVE", +CLAIM_SYSTEM_PROMPT = """You are the Sanhedrin Executioner in claim mode. Judge ONE extracted claim against the provided Vestige evidence. + +Return exactly one JSON object, no markdown: +{ + "status": "SUPPORTED|REFUTED|REFUTED_BY_ABSENCE|NEI", + "class": "", + "reason": "", + "evidence_ids": [""] } + +Rules: +- SUPPORTED: high-trust evidence directly supports the claim. +- REFUTED: high-trust evidence directly contradicts the same-subject claim. +- REFUTED_BY_ABSENCE: use only when instructions say absence-fail-closed applies. +- NEI: not enough information, stale/noisy evidence, wrong subject, or inference required. +- Do not infer contradiction across different subjects, versions, projects, or architecture layers. +- Staged evidence is context only and is not durable Vestige memory. +- Reasons must not use implies, suggests, must mean, would mean, indicates, therefore, or this means. +""" + + +VALID_CLASSES = CLAIM_CLASSES INFERENCE_VERBS = ( "implies", "implying", "suggests", "must mean", "would mean", "indicates that", "therefore the", "this means", ) VERDICT_RE = re.compile( - r"^no - \[Sanhedrin Veto\] ([A-Z][A-Z\-]*): (.{1,180})$" + r"^no - \[Sanhedrin Veto\] \[?([A-Z][A-Z\-]*)\]?: (.{1,180})$" ) @@ -274,29 +901,29 @@ def validate_verdict(verdict: str) -> str: def judge(draft: str, evidence: str) -> str: + if not sanhedrin_model_configured(): + if sanhedrin_core is not None: + sanhedrin_core.record_fail_open( + "model_not_configured", + "Set VESTIGE_SANHEDRIN_ENDPOINT and VESTIGE_SANHEDRIN_MODEL, or choose a preset.", + ) + return "" user_msg = ( f"VESTIGE EVIDENCE (recommended + top trust-scored memories):\n" f"{evidence if evidence else '(no relevant evidence retrieved)'}\n\n" f"---\nDRAFT TO JUDGE:\n{draft}" ) - body = { - "model": MODEL, - "messages": [ + body = sanhedrin_body( + [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_msg}, ], - "max_tokens": 2500, - "temperature": 0.0, - "top_p": 1.0, - "top_k": 1, - "seed": 42, - "stream": False, - "chat_template_kwargs": {"enable_thinking": False}, - "stop": [ + 2500, + [ "\n\nWait,", "\n\nActually,", "\n\nLet me", "\n\nHmm,", "\n\nOn second thought", "\n\nOh wait", ], - } + ) resp = post_json(SANHEDRIN_ENDPOINT, body, SANHEDRIN_TIMEOUT) if not isinstance(resp, dict): return "" @@ -322,18 +949,418 @@ def judge(draft: str, evidence: str) -> str: return "" +def absence_verdict(claim: Claim) -> ClaimVerdict: + reason = ( + f"{claim.claim_class} claim about Sam has zero high-trust durable Vestige evidence." + ) + return ClaimVerdict( + claim=claim, + status="REFUTED_BY_ABSENCE", + reason=truncate_chars(reason, 140), + ) + + +def nei_verdict( + claim: Claim, + reason: str, + evidence: list[EvidenceItem] | None = None, +) -> ClaimVerdict: + evidence = evidence or [] + return ClaimVerdict( + claim=claim, + status="NEI", + reason=truncate_chars(reason, 140), + evidence_ids=[item.id for item in high_trust(evidence)[:3]], + durable_evidence_count=len(relevant_durable_high_trust(claim, evidence)), + high_trust_evidence_count=len(high_trust(evidence)), + ) + + +def supported_verdict(claim: Claim, evidence: list[EvidenceItem]) -> ClaimVerdict: + return ClaimVerdict( + claim=claim, + status="SUPPORTED", + reason="High-trust evidence supports or does not contradict the claim.", + evidence_ids=[item.id for item in high_trust(evidence)[:3]], + durable_evidence_count=len(relevant_durable_high_trust(claim, evidence)), + high_trust_evidence_count=len(high_trust(evidence)), + ) + + +def parse_json_object(raw: str) -> dict[str, Any] | None: + cleaned = THINK_RE.sub("", raw).strip() + cleaned = re.sub(r"^```(?:json)?\s*", "", cleaned, flags=re.IGNORECASE).strip() + cleaned = re.sub(r"\s*```$", "", cleaned).strip() + try: + obj = json.loads(cleaned) + return obj if isinstance(obj, dict) else None + except json.JSONDecodeError: + pass + start = cleaned.find("{") + end = cleaned.rfind("}") + if start >= 0 and end > start: + try: + obj = json.loads(cleaned[start : end + 1]) + return obj if isinstance(obj, dict) else None + except json.JSONDecodeError: + return None + return None + + +def verdict_from_legacy_line(claim: Claim, raw: str, evidence: list[EvidenceItem]) -> ClaimVerdict | None: + line = validate_verdict(raw) + if line == "yes": + return supported_verdict(claim, evidence) + m = VERDICT_RE.match(line) + if not m: + return None + reason = m.group(2) + if not relevant_durable_high_trust(claim, evidence): + return nei_verdict(claim, "Durable evidence required for refuted verdict.", evidence) + return ClaimVerdict( + claim=claim, + status="REFUTED", + reason=truncate_chars(reason, 140), + evidence_ids=[item.id for item in high_trust(evidence)[:3]], + durable_evidence_count=len(relevant_durable_high_trust(claim, evidence)), + high_trust_evidence_count=len(high_trust(evidence)), + ) + + +def validate_structured_verdict( + claim: Claim, + data: dict[str, Any], + evidence: list[EvidenceItem], +) -> ClaimVerdict: + status = str(data.get("status") or "").strip().upper() + if status not in STRUCTURED_VERDICTS: + status = "NEI" + claim_class = str(data.get("class") or claim.claim_class).strip().upper() + if claim_class not in CLAIM_CLASSES: + claim_class = claim.claim_class + reason = truncate_chars(str(data.get("reason") or "").strip(), 140) + if any(verb in reason.lower() for verb in INFERENCE_VERBS): + return nei_verdict(claim, "Inference-chain verdict downgraded to NEI.", evidence) + if status == "REFUTED_BY_ABSENCE": + if not (claim.sam_critical and claim.claim_class in CRITICAL_ABSENCE_CLASSES): + return nei_verdict(claim, "Absence veto does not apply to this claim.", evidence) + if relevant_durable_high_trust(claim, evidence): + return nei_verdict(claim, "Durable evidence exists; absence veto does not apply.", evidence) + if status == "REFUTED" and not relevant_durable_high_trust(claim, evidence): + return nei_verdict(claim, "Durable evidence required for refuted verdict.", evidence) + if status == "SUPPORTED" and high_trust(evidence) and not durable_high_trust(evidence): + return nei_verdict(claim, "Durable evidence required for supported verdict.", evidence) + evidence_ids_raw = data.get("evidence_ids") or [] + evidence_ids = [ + str(eid) for eid in evidence_ids_raw[:5] + ] if isinstance(evidence_ids_raw, list) else [] + if not reason: + if status == "SUPPORTED": + reason = "High-trust evidence supports or does not contradict the claim." + elif status == "NEI": + reason = "Not enough high-trust evidence to decide." + elif status == "REFUTED_BY_ABSENCE": + reason = absence_verdict(claim).reason + else: + reason = "High-trust evidence refutes the claim." + return ClaimVerdict( + claim=Claim( + text=claim.text, + claim_class=claim_class, + source_index=claim.source_index, + sam_critical=claim.sam_critical, + ), + status=status, + reason=reason, + evidence_ids=evidence_ids, + durable_evidence_count=len(relevant_durable_high_trust(claim, evidence)), + high_trust_evidence_count=len(high_trust(evidence)), + ) + + +def judge_claim_with_model(claim: Claim, evidence: list[EvidenceItem]) -> ClaimVerdict: + if not sanhedrin_model_configured(): + if sanhedrin_core is not None: + sanhedrin_core.record_fail_open( + "model_not_configured", + "Set VESTIGE_SANHEDRIN_ENDPOINT and VESTIGE_SANHEDRIN_MODEL, or choose a preset.", + ) + return nei_verdict(claim, "Sanhedrin model not configured; fail-open for this claim.", evidence) + user_msg = ( + f"CLAIM CLASS: {claim.claim_class}\n" + f"SAM-CRITICAL: {'yes' if claim.sam_critical else 'no'}\n" + f"ABSENCE-FAIL-CLOSED APPLIES: " + f"{'yes' if claim.sam_critical and claim.claim_class in CRITICAL_ABSENCE_CLASSES else 'no'}\n" + f"DURABLE HIGH-TRUST EVIDENCE COUNT: {len(relevant_durable_high_trust(claim, evidence))}\n\n" + f"CLAIM:\n{claim.text}\n\n" + f"EVIDENCE:\n{format_claim_evidence(evidence, claim)}" + ) + body = sanhedrin_body( + [ + {"role": "system", "content": CLAIM_SYSTEM_PROMPT}, + {"role": "user", "content": user_msg}, + ], + 700, + ) + resp = post_json(SANHEDRIN_ENDPOINT, body, SANHEDRIN_TIMEOUT) + if not isinstance(resp, dict): + if sanhedrin_core is not None: + sanhedrin_core.record_fail_open("model_unavailable", f"endpoint={SANHEDRIN_ENDPOINT}") + return nei_verdict(claim, "Sanhedrin model unavailable; fail-open for this claim.", evidence) + try: + msg = resp["choices"][0]["message"] + raw = msg.get("content") or msg.get("reasoning") or "" + except (KeyError, IndexError, TypeError): + if sanhedrin_core is not None: + sanhedrin_core.record_fail_open("malformed_model_response", f"endpoint={SANHEDRIN_ENDPOINT}") + return nei_verdict(claim, "Malformed Sanhedrin model response.", evidence) + data = parse_json_object(raw) + if data is not None: + return validate_structured_verdict(claim, data, evidence) + legacy = verdict_from_legacy_line(claim, raw, evidence) + if legacy is not None: + return legacy + if sanhedrin_core is not None: + sanhedrin_core.record_fail_open("unstructured_model_response", raw[:500]) + return nei_verdict(claim, "Sanhedrin model did not return structured JSON.", evidence) + + +def judge_claim(claim: Claim, evidence: list[EvidenceItem]) -> ClaimVerdict: + if not sanhedrin_model_configured(): + if sanhedrin_core is not None: + sanhedrin_core.record_fail_open( + "model_not_configured", + "Set VESTIGE_SANHEDRIN_ENDPOINT and VESTIGE_SANHEDRIN_MODEL, or choose a preset.", + ) + return nei_verdict(claim, "Sanhedrin model not configured; fail-open for this claim.", evidence) + durable_count = len(relevant_durable_high_trust(claim, evidence)) + high_count = len(high_trust(evidence)) + if claim.sam_critical and claim.claim_class in CRITICAL_ABSENCE_CLASSES and durable_count == 0: + verdict = absence_verdict(claim) + verdict.high_trust_evidence_count = high_count + return verdict + if high_count == 0: + return nei_verdict(claim, "No high-trust evidence retrieved for this claim.", evidence) + return judge_claim_with_model(claim, evidence) + + +def render_legacy_from_verdicts(verdicts: list[ClaimVerdict]) -> str: + vetoes = [v for v in verdicts if v.status in {"REFUTED", "REFUTED_BY_ABSENCE"}] + if not vetoes: + return "yes" + vetoes.sort( + key=lambda v: ( + SEVERITY_ORDER.get(v.claim.claim_class, 99), + v.claim.source_index, + ) + ) + chosen = vetoes[0] + reason = truncate_chars(chosen.reason or chosen.claim.text, 140) + return f"no - [Sanhedrin Veto] [{chosen.claim.claim_class}]: {reason}" + + +def recompute_legacy_from_result(result: dict[str, Any]) -> str: + vetoes = [] + for raw in result.get("verdicts", []): + claim = raw.get("claim", {}) if isinstance(raw, dict) else {} + status = str(raw.get("status", "")) + if status not in {"REFUTED", "REFUTED_BY_ABSENCE"}: + continue + vetoes.append( + ( + SEVERITY_ORDER.get(str(claim.get("claim_class", "")), 99), + int(claim.get("source_index", 0) or 0), + str(claim.get("claim_class", "TECHNICAL")), + truncate_chars(str(raw.get("reason") or claim.get("text") or ""), 140), + ) + ) + if not vetoes: + return "yes" + _, _, claim_class, reason = sorted(vetoes)[0] + return f"no - [Sanhedrin Veto] [{claim_class}]: {reason}" + + +def apply_appeals_to_claim_mode_result(result: dict[str, Any]) -> dict[str, Any]: + if sanhedrin_core is None: + return result + appeals = sanhedrin_core.load_appeals() + changed = False + for raw in result.get("verdicts", []): + if not isinstance(raw, dict) or raw.get("status") not in {"REFUTED", "REFUTED_BY_ABSENCE"}: + continue + claim = raw.get("claim", {}) if isinstance(raw.get("claim"), dict) else {} + text = str(claim.get("text") or "") + if sanhedrin_core.is_appealed({"fingerprint": sanhedrin_core.claim_fingerprint(text)}, appeals): + raw["status"] = "APPEALED" + raw["reason"] = "Prior appeal suppresses this Sanhedrin veto." + changed = True + + if changed: + legacy = recompute_legacy_from_result(result) + result["legacy_verdict"] = legacy + result["decision"] = "yes" if legacy == "yes" else "no" + result["verdict"] = result["decision"] + result["passed"] = legacy == "yes" + result["reason"] = "" if result["passed"] else legacy.split(" - ", 1)[-1] + return result + + +def save_claim_mode_receipt( + draft: str, + result: dict[str, Any], + manifest: dict[str, Any] | None = None, +) -> None: + if sanhedrin_core is None: + return + manifest = manifest or sanhedrin_core.new_manifest(draft) + claims = [] + for idx, raw in enumerate(result.get("verdicts", []), start=1): + if not isinstance(raw, dict): + continue + claim = raw.get("claim", {}) if isinstance(raw.get("claim"), dict) else {} + text = str(claim.get("text") or "") + claim_class = str(claim.get("claim_class") or "TECHNICAL") + status = str(raw.get("status") or "NEI") + evidence_ids = raw.get("evidence_ids") if isinstance(raw.get("evidence_ids"), list) else [] + if status == "SUPPORTED": + decision = "pass" + evidence_state = "supported" + fix = "No change required." + elif status == "APPEALED": + decision = "appealed" + evidence_state = "appealed" + fix = "Prior appeal suppresses this veto fingerprint." + elif status == "REFUTED_BY_ABSENCE": + decision = "veto" + evidence_state = "missing_precedent" + fix = "Remove the unsupported user-specific claim or cite durable Vestige evidence first." + elif status == "REFUTED": + decision = "veto" + evidence_state = "contradicted" + fix = "Remove or qualify the contradicted claim using the cited Vestige precedent." + else: + decision = "pass_unverified" + evidence_state = "not_enough_information" + fix = "No blocking change required." + claims.append( + { + "id": f"c{idx:03d}", + "text": text, + "fingerprint": sanhedrin_core.claim_fingerprint(text), + "class": claim_class, + "subject": "Sam" if bool(claim.get("sam_critical")) else "draft", + "risk": "hard" if bool(claim.get("sam_critical")) else "normal", + "evidence_state": evidence_state, + "decision": decision, + "precedent": [ + { + "type": "vestige", + "summary": str(raw.get("reason") or status), + "evidence": ", ".join(str(eid) for eid in evidence_ids[:5]), + "durableCount": raw.get("durable_evidence_count"), + "highTrustCount": raw.get("high_trust_evidence_count"), + } + ], + "fix": fix, + "appeal": { + "status": "appealed" if decision == "appealed" else "open", + "actions": ["stale", "wrong", "too_strict"], + }, + } + ) + + manifest["claims"] = claims + manifest["overall"] = "pass" if result.get("passed") else "veto" + if any(claim["decision"] == "appealed" for claim in claims): + manifest["overall"] = "pass_with_warnings" if result.get("passed") else manifest["overall"] + manifest["verdictBar"] = "APPEALED" + manifest["summary"] = "Prior appeal suppressed a Sanhedrin veto." + elif result.get("passed"): + manifest["verdictBar"] = "PASS" if not claims else "NOTE" + manifest["summary"] = "Sanhedrin found no blocking claim issues." + else: + manifest["verdictBar"] = "VETO" + manifest["summary"] = str(result.get("reason") or "Sanhedrin blocked a claim.") + sanhedrin_core.save_manifest(manifest) + + +def save_legacy_receipt(manifest: dict[str, Any] | None, verdict: str, evidence: str = "") -> str: + if sanhedrin_core is None or manifest is None: + return verdict + updated = sanhedrin_core.apply_model_verdict(manifest, verdict, evidence) + sanhedrin_core.save_manifest(manifest) + return updated + + +def claim_mode_result(draft: str) -> dict[str, Any]: + claims = extract_check_worthy_claims(draft) + staged = load_staged_evidence(os.environ.get(STAGE_FILE_ENV)) + verdicts: list[ClaimVerdict] = [] + for claim in claims: + evidence, ok = fetch_claim_evidence(claim) + if not ok: + if sanhedrin_core is not None: + sanhedrin_core.record_fail_open("retrieval_unavailable", claim.text) + verdicts.append( + nei_verdict( + claim, + "Vestige retrieval unavailable; fail-open for this claim.", + staged, + ) + ) + continue + combined = dedupe_evidence(evidence + staged) + verdicts.append(judge_claim(claim, combined)) + legacy_verdict = render_legacy_from_verdicts(verdicts) + decision = "yes" if legacy_verdict == "yes" else "no" + json_reason = "" if decision == "yes" else legacy_verdict.split(" - ", 1)[-1] + return { + "mode": "claim", + "decision": decision, + "verdict": decision, + "reason": json_reason, + "passed": legacy_verdict == "yes", + "legacy_verdict": legacy_verdict, + "claims_extracted": len(claims), + "staged_evidence_count": len(staged), + "verdicts": [asdict(v) for v in verdicts], + } + + +def print_claim_mode_result(result: dict[str, Any]) -> None: + if (os.environ.get(OUTPUT_ENV) or "").strip().lower() == "json": + print(json.dumps(result, ensure_ascii=False, separators=(",", ":"))) + else: + print(result.get("legacy_verdict") or "yes") + + def main() -> None: draft = sys.stdin.read().strip() if not draft: print("yes") return + manifest = sanhedrin_core.new_manifest(draft) if sanhedrin_core is not None else None + if sanhedrin_core is not None and manifest is not None: + receipt_veto = sanhedrin_core.apply_receipt_lock(manifest) + if receipt_veto: + sanhedrin_core.save_manifest(manifest) + print(f"no - [Sanhedrin Veto] [TECHNICAL]: {receipt_veto}") + return + + if env_flag(CLAIM_MODE_ENV): + result = apply_appeals_to_claim_mode_result(claim_mode_result(draft)) + save_claim_mode_receipt(draft, result, manifest) + print_claim_mode_result(result) + return + evidence, high_trust_count = fetch_evidence(draft) # Auto-pass if no high-trust evidence — model can't legitimately veto # without something concrete to cite. Eliminates the common false-positive # mode where the model invents a contradiction from low-trust noise. if high_trust_count == 0: + save_legacy_receipt(manifest, "yes", evidence) print("yes") return @@ -341,9 +1368,13 @@ def main() -> None: if not verdict: # Fail-open: server unreachable, malformed response, etc. + if sanhedrin_core is not None: + sanhedrin_core.record_fail_open("legacy_model_unavailable", f"endpoint={SANHEDRIN_ENDPOINT}") + save_legacy_receipt(manifest, "yes", evidence) print("yes") return + verdict = save_legacy_receipt(manifest, verdict, evidence) print(verdict) diff --git a/hooks/sanhedrin-presets.json b/hooks/sanhedrin-presets.json new file mode 100644 index 0000000..7b446ad --- /dev/null +++ b/hooks/sanhedrin-presets.json @@ -0,0 +1,103 @@ +{ + "schema": "vestige.sanhedrin.presets.v2", + "defaultPreset": null, + "description": "Model-agnostic Sanhedrin backend recipes. Presets are suggestions only; users may set any OpenAI-compatible endpoint and model name.", + "presets": { + "custom-openai-compatible": { + "label": "Custom OpenAI-compatible endpoint", + "tier": "custom", + "bestFor": "Any model/server the user already trusts", + "requiresUserModel": true, + "endpointPlaceholder": "http://127.0.0.1:8000/v1/chat/completions", + "modelPlaceholder": "your-model-name", + "env": { + "VESTIGE_SANHEDRIN_ENDPOINT": "", + "VESTIGE_SANHEDRIN_MODEL": "", + "VESTIGE_SANHEDRIN_BACKEND": "openai-compatible", + "VESTIGE_SANHEDRIN_TIMEOUT": "45" + } + }, + "small-laptop-ollama": { + "label": "Small laptop Ollama", + "tier": "small-local", + "bestFor": "8-16 GB RAM laptops that need a lightweight offline verifier", + "setup": "Install Ollama, then pull any small instruct model you trust, for example: ollama pull llama3.2:3b or ollama pull qwen2.5:7b", + "tradeoffs": ["fast and accessible", "weaker contradiction judgment than larger models"], + "env": { + "VESTIGE_SANHEDRIN_ENDPOINT": "http://127.0.0.1:11434/v1/chat/completions", + "VESTIGE_SANHEDRIN_MODEL": "your-ollama-model", + "VESTIGE_SANHEDRIN_BACKEND": "ollama", + "VESTIGE_SANHEDRIN_TIMEOUT": "60" + } + }, + "balanced-local-ollama": { + "label": "Balanced local Ollama", + "tier": "balanced-local", + "bestFor": "16-32 GB RAM machines using 7B-14B local models", + "setup": "Install Ollama and pull a balanced verifier model such as qwen3:14b, llama3.1:8b, or another OpenAI-compatible local model.", + "tradeoffs": ["good first local choice", "model quality depends on the exact model selected"], + "env": { + "VESTIGE_SANHEDRIN_ENDPOINT": "http://127.0.0.1:11434/v1/chat/completions", + "VESTIGE_SANHEDRIN_MODEL": "your-ollama-model", + "VESTIGE_SANHEDRIN_BACKEND": "ollama", + "VESTIGE_SANHEDRIN_TIMEOUT": "60" + } + }, + "mlx-qwen3.6-apple-silicon": { + "label": "MLX Qwen3.6 35B A3B, Apple Silicon local", + "tier": "strong-local", + "bestFor": "High-memory Apple Silicon users who explicitly choose the strong MLX path", + "env": { + "VESTIGE_SANHEDRIN_ENDPOINT": "http://127.0.0.1:8080/v1/chat/completions", + "VESTIGE_SANHEDRIN_MODEL": "mlx-community/Qwen3.6-35B-A3B-4bit", + "VESTIGE_SANHEDRIN_BACKEND": "mlx", + "VESTIGE_SANHEDRIN_TIMEOUT": "45" + } + }, + "vllm-openai-compatible": { + "label": "vLLM OpenAI-compatible server", + "tier": "workstation", + "bestFor": "GPU workstations and team servers", + "env": { + "VESTIGE_SANHEDRIN_ENDPOINT": "http://127.0.0.1:8000/v1/chat/completions", + "VESTIGE_SANHEDRIN_MODEL": "your-vllm-model", + "VESTIGE_SANHEDRIN_BACKEND": "vllm", + "VESTIGE_SANHEDRIN_TIMEOUT": "45" + } + }, + "llama-cpp-openai-compatible": { + "label": "llama.cpp server", + "tier": "small-local", + "bestFor": "CPU or small-GPU local deployments", + "env": { + "VESTIGE_SANHEDRIN_ENDPOINT": "http://127.0.0.1:8081/v1/chat/completions", + "VESTIGE_SANHEDRIN_MODEL": "your-gguf-model", + "VESTIGE_SANHEDRIN_BACKEND": "llama.cpp", + "VESTIGE_SANHEDRIN_TIMEOUT": "90" + } + }, + "hosted-openai-compatible": { + "label": "Hosted OpenAI-compatible API", + "tier": "hosted", + "bestFor": "Users who want zero local model setup", + "requires": "VESTIGE_SANHEDRIN_API_KEY exported in the hook environment and a model chosen by the user/provider", + "env": { + "VESTIGE_SANHEDRIN_ENDPOINT": "https://api.openai.com/v1/chat/completions", + "VESTIGE_SANHEDRIN_MODEL": "your-hosted-model", + "VESTIGE_SANHEDRIN_BACKEND": "openai", + "VESTIGE_SANHEDRIN_TIMEOUT": "45" + } + }, + "anthropic-via-litellm": { + "label": "Anthropic through LiteLLM OpenAI-compatible proxy", + "bestFor": "Claude users who already run LiteLLM", + "setup": "Run LiteLLM locally with an Anthropic model, then point Sanhedrin at the proxy.", + "env": { + "VESTIGE_SANHEDRIN_ENDPOINT": "http://127.0.0.1:4000/v1/chat/completions", + "VESTIGE_SANHEDRIN_MODEL": "anthropic/claude-3-5-haiku-latest", + "VESTIGE_SANHEDRIN_BACKEND": "litellm", + "VESTIGE_SANHEDRIN_TIMEOUT": "45" + } + } + } +} diff --git a/hooks/sanhedrin.sh b/hooks/sanhedrin.sh index a875f1f..d8d2424 100755 --- a/hooks/sanhedrin.sh +++ b/hooks/sanhedrin.sh @@ -25,22 +25,63 @@ set -u +load_vestige_sanhedrin_env() { + [ -f "$1" ] || return 0 + command -v python3 >/dev/null 2>&1 || return 0 + while IFS="$(printf '\t')" read -r key value; do + case "$key" in + VESTIGE_SANHEDRIN_ENABLED|VESTIGE_SANHEDRIN_MODEL|VESTIGE_SANHEDRIN_ENDPOINT|VESTIGE_SANHEDRIN_API_KEY|VESTIGE_SANHEDRIN_BACKEND|VESTIGE_SANHEDRIN_CLAIM_MODE|VESTIGE_SANHEDRIN_OUTPUT|VESTIGE_SANHEDRIN_PYTHON|VESTIGE_SANHEDRIN_STATE_DIR|VESTIGE_SANHEDRIN_ALLOW_COMMAND_LEDGER|VESTIGE_SANHEDRIN_ALLOW_LOOSE_LEDGER|VESTIGE_DASHBOARD_PORT) + export "$key=$value" + ;; + esac + done < <(python3 - "$1" <<'PY' +import shlex +import sys + +allowed = { + "VESTIGE_SANHEDRIN_ENABLED", + "VESTIGE_SANHEDRIN_MODEL", + "VESTIGE_SANHEDRIN_ENDPOINT", + "VESTIGE_SANHEDRIN_API_KEY", + "VESTIGE_SANHEDRIN_BACKEND", + "VESTIGE_SANHEDRIN_CLAIM_MODE", + "VESTIGE_SANHEDRIN_OUTPUT", + "VESTIGE_SANHEDRIN_PYTHON", + "VESTIGE_SANHEDRIN_STATE_DIR", + "VESTIGE_SANHEDRIN_ALLOW_COMMAND_LEDGER", + "VESTIGE_SANHEDRIN_ALLOW_LOOSE_LEDGER", + "VESTIGE_DASHBOARD_PORT", +} + +try: + lines = open(sys.argv[1], encoding="utf-8").read().splitlines() +except OSError: + sys.exit(0) + +for raw in lines: + line = raw.strip() + if not line or line.startswith("#"): + continue + try: + parts = shlex.split(line, posix=True) + except ValueError: + continue + if len(parts) != 1 or "=" not in parts[0]: + continue + key, value = parts[0].split("=", 1) + if key in allowed and "\t" not in value and "\0" not in value: + print(f"{key}\t{value}") +PY + ) +} + # === OPT-IN GATE === -# Sanhedrin is heavyweight: the default local backend is a ~19 GB model and -# needs roughly 20+ GB of free RAM. Keep it disabled unless the user explicitly -# opts in. The installer writes this env file only for --enable-sanhedrin. +# Sanhedrin is opt-in and model-agnostic. It never guesses a large verifier +# model; if endpoint/model are unset, the bridge fails open with telemetry. +# The installer writes this env file only for --enable-sanhedrin. SANHEDRIN_ENV="${VESTIGE_SANHEDRIN_ENV:-$HOME/.claude/hooks/vestige-sanhedrin.env}" if [ -f "$SANHEDRIN_ENV" ]; then - set +u - set -a - # shellcheck disable=SC1090 - . "$SANHEDRIN_ENV" 2>/dev/null || { - set +a - set -u - exit 0 - } - set +a - set -u + load_vestige_sanhedrin_env "$SANHEDRIN_ENV" || exit 0 fi case "${VESTIGE_SANHEDRIN_ENABLED:-0}" in @@ -55,9 +96,48 @@ if [ "${VESTIGE_EXECUTIONER_ACTIVE:-0}" = "1" ]; then exit 0 fi +PYTHON_BIN="${VESTIGE_SANHEDRIN_PYTHON:-}" +if [ -z "$PYTHON_BIN" ]; then + PYTHON_BIN="$(command -v python3 2>/dev/null || printf '')" +fi +if [ -z "$PYTHON_BIN" ]; then + PYTHON_BIN="/usr/bin/python3" +fi +if ! "$PYTHON_BIN" -c 'import sys' >/dev/null 2>&1; then + exit 0 +fi + +record_sanhedrin_fail_open() { + REASON="$1" + DETAIL="${2:-}" + "$PYTHON_BIN" - "$REASON" "$DETAIL" <<'PY' 2>/dev/null || true +import json +import os +import sys +from datetime import datetime, timezone +from pathlib import Path + +reason = sys.argv[1] if len(sys.argv) > 1 else "unknown" +detail = sys.argv[2] if len(sys.argv) > 2 else "" +state_dir = Path(os.environ.get("VESTIGE_SANHEDRIN_STATE_DIR") or Path.home() / ".vestige" / "sanhedrin") +try: + state_dir.mkdir(parents=True, exist_ok=True) + with (state_dir / "fail-open.jsonl").open("a", encoding="utf-8") as f: + f.write(json.dumps({ + "timestamp": datetime.now(timezone.utc).isoformat(timespec="seconds"), + "runId": os.environ.get("VESTIGE_SANHEDRIN_RUN_ID"), + "reason": reason, + "detail": detail[:500], + "transcript": os.environ.get("TRANSCRIPT_PATH") or os.environ.get("VESTIGE_SANHEDRIN_TRANSCRIPT"), + }) + "\n") +except OSError: + pass +PY +} + # === READ STOP HOOK INPUT === INPUT="$(cat)" -TRANSCRIPT_PATH="$(printf '%s' "$INPUT" | /usr/bin/python3 -c 'import sys,json;d=json.load(sys.stdin);print(d.get("transcript_path",""))' 2>/dev/null || printf '')" +TRANSCRIPT_PATH="$(printf '%s' "$INPUT" | "$PYTHON_BIN" -c 'import sys,json;d=json.load(sys.stdin);print(d.get("transcript_path",""))' 2>/dev/null || printf '')" if [ -z "$TRANSCRIPT_PATH" ] || [ ! -f "$TRANSCRIPT_PATH" ]; then exit 0 @@ -70,7 +150,7 @@ DRAFT_SCRIPT="$(mktemp -t vestige-sanhedrin-draft.XXXXXX)" trap 'rm -f "$DRAFT_SCRIPT"' EXIT cat > "$DRAFT_SCRIPT" <<'DRAFT_PYEOF' -import json, os, sys +import json, os, re, sys transcript = os.environ.get("TRANSCRIPT_PATH", "") last_assistant = "" @@ -99,19 +179,33 @@ try: except Exception: sys.exit(0) -# Print nothing if no draft or draft too short to contain a technical claim +# Print nothing if no draft. Short verification claims still need Receipt Lock. stripped = last_assistant.strip() -if not stripped or len(stripped) < 100: +if not stripped: sys.exit(0) -# Gate: only check drafts that contain technical indicators -has_code = "`" in stripped or "```" in stripped -has_cmd = any(kw in stripped.lower() for kw in ["install", "run ", "use ", "call ", "invoke", "execute"]) -has_path = "/" in stripped and any(ext in stripped for ext in [".rs", ".ts", ".py", ".sh", ".md", ".json"]) - -if not (has_code or has_cmd or has_path): +# Legacy gate: only check drafts that contain technical indicators. Claim mode +# deliberately broadens this to any substantive assistant draft while keeping +# Sanhedrin opt-in through VESTIGE_SANHEDRIN_ENABLED. +claim_mode = os.environ.get("VESTIGE_SANHEDRIN_CLAIM_MODE", "") == "1" +receipt_gate = bool( + re.search( + r"\b((all\s+)?(tests?|test suite|build|lint|typecheck|checks?|cargo test|npm test|pnpm test|pytest|vitest|jest|playwright|tsc|clippy)\s+(passed|passes|passing|green|succeeded|succeeds|clean)|(verified|validated|confirmed)\s+(with|by|via))\b", + stripped, + re.I, + ) +) +if len(stripped) < 100 and not receipt_gate: sys.exit(0) +if not claim_mode: + has_code = "`" in stripped or "```" in stripped + has_cmd = any(kw in stripped.lower() for kw in ["install", "run ", "use ", "call ", "invoke", "execute"]) + has_path = "/" in stripped and any(ext in stripped for ext in [".rs", ".ts", ".py", ".sh", ".md", ".json"]) + + if not (has_code or has_cmd or has_path or receipt_gate): + sys.exit(0) + # Truncate to 4000 chars to keep Haiku prompt bounded if len(stripped) > 4000: stripped = stripped[:4000] + "... [truncated]" @@ -119,7 +213,7 @@ if len(stripped) > 4000: print(stripped) DRAFT_PYEOF -DRAFT="$(/usr/bin/python3 "$DRAFT_SCRIPT" 2>/dev/null || printf '')" +DRAFT="$("$PYTHON_BIN" "$DRAFT_SCRIPT" 2>/dev/null || printf '')" if [ -z "$DRAFT" ]; then exit 0 @@ -139,9 +233,12 @@ fi # === SPAWN LOCAL EXECUTIONER (background with timeout) === OUTPUT_FILE="$(mktemp -t vestige-sanhedrin-out.XXXXXX)" trap 'rm -f "$DRAFT_SCRIPT" "$OUTPUT_FILE"' EXIT +export VESTIGE_SANHEDRIN_TRANSCRIPT="$TRANSCRIPT_PATH" +export VESTIGE_SANHEDRIN_RUN_ID="${VESTIGE_SANHEDRIN_RUN_ID:-$(date +%s)-$$}" +export VESTIGE_EXECUTIONER_ACTIVE=1 ( - printf '%s\n' "$DRAFT" | /usr/bin/python3 "$BRIDGE" > "$OUTPUT_FILE" 2>/dev/null + printf '%s\n' "$DRAFT" | "$PYTHON_BIN" "$BRIDGE" > "$OUTPUT_FILE" 2>/dev/null ) & EXEC_PID=$! @@ -163,6 +260,7 @@ done if /bin/kill -0 "$EXEC_PID" 2>/dev/null; then /bin/kill "$EXEC_PID" 2>/dev/null wait "$EXEC_PID" 2>/dev/null + record_sanhedrin_fail_open "timeout" "sanhedrin-local.py exceeded 60s" exit 0 fi wait "$EXEC_PID" 2>/dev/null @@ -170,9 +268,117 @@ wait "$EXEC_PID" 2>/dev/null EXECUTIONER_OUTPUT="$(cat "$OUTPUT_FILE" 2>/dev/null || printf '')" # === PARSE VERDICT === +sanhedrin_veto() { + REASON="$1" + REASON="$(printf '%s' "$REASON" | "$PYTHON_BIN" -c 'import sys; print(sys.stdin.read().strip())' 2>/dev/null || printf '%s' "$REASON")" + + if printf '%s' "$REASON" | /usr/bin/grep -qi 'Receipt Lock'; then + cat >&2 <&2 < start: + obj = loads_candidate(raw[start:end + 1]) + +if obj is None: + sys.exit(1) + +decision = obj.get("decision", obj.get("verdict", obj.get("answer", ""))) +reason = obj.get("reason", obj.get("message", obj.get("explanation", ""))) +if isinstance(decision, bool): + decision = "yes" if decision else "no" +elif decision is None: + decision = "" +else: + decision = str(decision) + +if reason is None: + reason = "" +elif not isinstance(reason, str): + reason = json.dumps(reason, ensure_ascii=False) + +print(decision.strip()) +print(reason.strip()) +' 2>/dev/null || printf '')" + + if [ -n "$JSON_PARSED" ]; then + JSON_DECISION="$(printf '%s\n' "$JSON_PARSED" | /usr/bin/sed -n '1p' | "$PYTHON_BIN" -c 'import sys; print(sys.stdin.read().strip().lower())' 2>/dev/null || printf '')" + JSON_REASON="$(printf '%s\n' "$JSON_PARSED" | /usr/bin/sed '1d')" + + case "$JSON_DECISION" in + yes|pass|allow|allowed|clean|true) + exit 0 + ;; + no|fail|block|blocked|veto|false) + sanhedrin_veto "$JSON_REASON" + ;; + esac + fi +fi + TRIMMED="$(printf '%s' "$EXECUTIONER_OUTPUT" | /usr/bin/awk 'NF {print; exit}' | /usr/bin/awk '{$1=$1;print}')" if [ -z "$TRIMMED" ]; then + record_sanhedrin_fail_open "empty_verdict" "sanhedrin-local.py produced no parseable output" exit 0 fi @@ -196,27 +402,10 @@ case "$TRIMMED" in REASON="${TRIMMED#*:}" ;; esac - REASON="$(printf '%s' "$REASON" | /usr/bin/awk '{$1=$1;print}')" - - cat >&2 < str: + return dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds") + + +def ensure_dirs() -> None: + RECEIPTS_DIR.mkdir(parents=True, exist_ok=True) + + +def stable_id(text: str, prefix: str = "sr") -> str: + digest = hashlib.sha256(text.encode("utf-8")).hexdigest()[:16] + return f"{prefix}_{digest}" + + +def claim_fingerprint(text: str) -> str: + normalized = re.sub(r"\s+", " ", text.lower()).strip() + normalized = re.sub(r"[`'\"$]", "", normalized) + return hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:16] + + +def strip_non_assertive_regions(text: str) -> str: + """Remove quoted/code regions before looking for Receipt Lock assertions.""" + text = text[:16_384] + text = re.sub(r"```.*?```", " ", text, flags=re.DOTALL) + text = re.sub(r"`[^`\n]+`", " ", text) + kept_lines = [] + for line in text.splitlines(): + stripped = line.lstrip() + if stripped.startswith(">"): + continue + kept_lines.append(line) + text = "\n".join(kept_lines) + text = re.sub(r'(^|[\s([{])"[^"\n]+"(?=([\s.,;:!?)}\]]|$))', r"\1 ", text) + return text + + +def is_asserted_verification_claim(text: str) -> bool: + match = VERIFICATION_RE.search(text) + if not match: + return False + left_context = text[max(0, match.start() - 100) : match.start()] + return VERIFICATION_HEDGE_LEFT_RE.search(left_context) is None + + +def split_claims(draft: str) -> list[str]: + cleaned = strip_non_assertive_regions(draft) + chunks = re.split(r"(?<=[.!?])\s+|\n+", cleaned) + claims: list[str] = [] + for chunk in chunks: + text = chunk.strip(" -\t") + if len(text) >= 18 or is_asserted_verification_claim(text) or is_hard_user_claim(text): + claims.append(text) + return claims[:24] + + +def detect_claim_type(text: str) -> str: + low = text.lower() + if is_asserted_verification_claim(text): + return "receipt_lock" + if is_hard_user_claim(text): + return "hard_user_claim" + if any(word in low for word in ("won", "prize", "ranked", "placed", "score", "graduated", "worked at")): + return "hard_user_claim" + if any(word in low for word in ("should", "could", "recommend", "plan", "target", "estimate")): + return "advice" + if "`" in text or "/" in text or re.search(r"\bv?\d+\.\d+", text): + return "technical" + return "general" + + +def is_hard_user_claim(text: str) -> bool: + if not re.search(r"\b(Sam|you|your|I|my)\b", text, re.I): + return False + hard_patterns = ( + r"\b(attended|graduated|studied|enrolled|accepted|worked|works|employed|hired)\b", + r"\b(was\s+born|born\s+in|born\s+on|birthdate|birthday)\b", + r"\b(won|placed|ranked|scored|earned|raised|sold|founded|launched)\b", + r"\b(prize|award|payout|grant|scholarship|degree|gpa|employer|school|university|college|birth\s+date)\b", + r"\$[0-9]", + ) + return any(re.search(pattern, text, re.I) for pattern in hard_patterns) + + +def new_manifest(draft: str) -> dict[str, Any]: + draft_id = stable_id(draft, "draft") + claims = [] + for i, text in enumerate(split_claims(draft), start=1): + claim_type = detect_claim_type(text) + claims.append( + { + "id": f"c{i:03d}", + "text": text, + "fingerprint": claim_fingerprint(text), + "class": claim_type, + "subject": infer_subject(text), + "risk": "hard" if claim_type == "receipt_lock" else "normal", + "evidence_state": "unchecked", + "decision": "pending", + "precedent": [], + "fix": "", + "appeal": { + "status": "open", + "actions": ["stale", "wrong", "too_strict"], + }, + } + ) + return { + "schema": SUPPORTED_RECEIPT_SCHEMA, + "id": stable_id(f"{draft_id}:{now_iso()}", "receipt"), + "draftId": draft_id, + "createdAt": now_iso(), + "overall": "pass", + "verdictBar": "PASS", + "summary": "No blocking claim issues found.", + "draftPreview": draft[:1000], + "claims": claims, + "receipts": [], + "source": { + "stateDir": str(STATE_DIR), + "transcript": os.environ.get("VESTIGE_SANHEDRIN_TRANSCRIPT"), + }, + } + + +def infer_subject(text: str) -> str: + if re.search(r"\b(Sam|you|your)\b", text, re.I): + return "Sam" + if re.search(r"\b(test|pytest|cargo test|npm test|pnpm test|vitest|jest)\b", text, re.I): + return "test receipt" + if re.search(r"\b(build|lint|typecheck|clippy|tsc)\b", text, re.I): + return "command receipt" + return "draft" + + +def command_families_for_claim(text: str) -> list[str]: + low = text.lower() + if re.search(r"\b(all\s+checks?|checks)\s+(passed|passes|passing|green|succeeded|succeeds|clean)\b", low) and "cargo check" not in low: + return ["test", "build", "lint", "typecheck"] + families: list[str] = [] + if any(word in low for word in ("test", "pytest", "vitest", "jest", "playwright")): + families.append("test") + if any(word in low for word in ("build", "compiled", "compile")): + families.append("build") + if any(word in low for word in ("lint", "clippy", "eslint", "ruff")): + families.append("lint") + if any(word in low for word in ("typecheck", "tsc", "mypy", "pyright", "cargo check")): + families.append("typecheck") + if "check" in low and "cargo check" not in low and "checks" not in low: + families.append("typecheck") + return families or ["test"] + + +def load_command_receipts() -> list[dict[str, Any]]: + transcript = os.environ.get("VESTIGE_SANHEDRIN_TRANSCRIPT") + if transcript: + return extract_transcript_receipts(Path(transcript)) + if os.environ.get("VESTIGE_SANHEDRIN_ALLOW_COMMAND_LEDGER") != "1": + return [] + return load_jsonl(COMMAND_RECEIPTS_JSONL) + + +def load_jsonl(path: Path) -> list[dict[str, Any]]: + if not path.exists(): + return [] + items: list[dict[str, Any]] = [] + try: + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line: + continue + obj = json.loads(line) + if isinstance(obj, dict): + items.append(obj) + except (OSError, json.JSONDecodeError): + return items + return items + + +def extract_transcript_receipts(path: Path) -> list[dict[str, Any]]: + if not path.exists(): + return [] + receipts: list[dict[str, Any]] = [] + pending_commands: dict[str, dict[str, Any]] = {} + try: + lines = path.read_text(encoding="utf-8", errors="ignore").splitlines() + except OSError: + return receipts + for line in lines: + try: + obj = json.loads(line) + except json.JSONDecodeError: + continue + receipts.extend(extract_structured_receipts(obj, pending_commands)) + if os.environ.get("VESTIGE_SANHEDRIN_ALLOW_LOOSE_LEDGER") != "1": + continue + blob = json.dumps(obj, ensure_ascii=False) + command = extract_command(blob) + if not command: + continue + exit_code = extract_exit_code(blob) + receipts.append( + { + "source": "transcript", + "command": command, + "exitCode": exit_code, + "success": exit_code == 0 if exit_code is not None else None, + "timestamp": obj.get("timestamp") or obj.get("created_at") or now_iso(), + } + ) + return receipts + + +def extract_structured_receipts( + obj: dict[str, Any], + pending_commands: dict[str, dict[str, Any]], +) -> list[dict[str, Any]]: + """Extract Claude Code Bash receipts from assistant tool_use/user tool_result pairs.""" + receipts: list[dict[str, Any]] = [] + timestamp = obj.get("timestamp") or obj.get("created_at") or now_iso() + receipts.extend(extract_codex_receipts(obj, pending_commands, timestamp)) + content = obj.get("message", {}).get("content", obj.get("content", "")) + + blocks = content if isinstance(content, list) else [] + for block in blocks: + if not isinstance(block, dict): + continue + if block.get("type") == "tool_use" and str(block.get("name", "")).lower() in {"bash", "shell", "exec_command"}: + tool_id = str(block.get("id") or "") + tool_input = block.get("input") if isinstance(block.get("input"), dict) else {} + command = str(tool_input.get("command") or tool_input.get("cmd") or "") + if tool_id and command: + pending_commands[tool_id] = { + "source": "transcript", + "toolUseId": tool_id, + "command": command, + "timestamp": timestamp, + } + if block.get("type") == "tool_result": + tool_id = str(block.get("tool_use_id") or "") + if not tool_id or tool_id not in pending_commands: + continue + receipt = dict(pending_commands[tool_id]) + text = stringify_tool_result(block) + explicit_exit = extract_exit_code(text) + is_error = bool(block.get("is_error")) + receipt["exitCode"] = explicit_exit if explicit_exit is not None else (1 if is_error else 0) + receipt["success"] = receipt["exitCode"] == 0 and not is_error + receipt["timestamp"] = timestamp + receipts.append(receipt) + + tool_result = obj.get("toolUseResult") + if isinstance(tool_result, dict): + command = str(obj.get("command") or tool_result.get("command") or "") + if command: + exit_code = tool_result.get("exitCode") + if exit_code is None: + exit_code = tool_result.get("exit_code") + try: + parsed_exit = int(exit_code) if exit_code is not None else None + except (TypeError, ValueError): + parsed_exit = extract_exit_code(json.dumps(tool_result, ensure_ascii=False)) + is_error = bool(tool_result.get("is_error") or tool_result.get("interrupted")) + receipts.append( + { + "source": "transcript", + "command": command, + "exitCode": parsed_exit if parsed_exit is not None else (1 if is_error else 0), + "success": (parsed_exit == 0 if parsed_exit is not None else not is_error), + "timestamp": timestamp, + } + ) + + return receipts + + +def extract_codex_receipts( + obj: dict[str, Any], + pending_commands: dict[str, dict[str, Any]], + timestamp: str, +) -> list[dict[str, Any]]: + receipts: list[dict[str, Any]] = [] + payload = obj.get("payload") + if not isinstance(payload, dict): + return receipts + + payload_type = payload.get("type") + name = str(payload.get("name") or "").lower() + call_id = str(payload.get("call_id") or "") + if payload_type == "function_call" and name in {"exec_command", "bash", "shell"} and call_id: + args = payload.get("arguments") + if isinstance(args, str): + try: + args = json.loads(args) + except json.JSONDecodeError: + args = {} + if isinstance(args, dict): + command = str(args.get("cmd") or args.get("command") or "") + if command: + pending_commands[call_id] = { + "source": "codex-transcript", + "toolUseId": call_id, + "command": command, + "timestamp": timestamp, + } + elif payload_type == "function_call" and name == "write_stdin" and call_id: + args = payload.get("arguments") + if isinstance(args, str): + try: + args = json.loads(args) + except json.JSONDecodeError: + args = {} + if isinstance(args, dict): + session_id = args.get("session_id") + session_receipt = pending_commands.get(f"session:{session_id}") + if session_receipt: + pending_commands[call_id] = dict(session_receipt) + + if payload_type == "function_call_output" and call_id in pending_commands: + receipt = dict(pending_commands[call_id]) + output = str(payload.get("output") or "") + running = re.search(r"Process running with session ID\s+(\d+)", output) + if running: + pending_commands[f"session:{running.group(1)}"] = receipt + return receipts + exit_code = extract_exit_code(output) + receipt["exitCode"] = exit_code + receipt["success"] = exit_code == 0 if exit_code is not None else None + receipt["timestamp"] = timestamp + receipts.append(receipt) + + return receipts + + +def stringify_tool_result(block: dict[str, Any]) -> str: + content = block.get("content", "") + if isinstance(content, str): + return content + return json.dumps(content, ensure_ascii=False) + + +def extract_command(blob: str) -> str | None: + for key in ("cmd", "command"): + match = re.search(rf'"{key}"\s*:\s*"([^"]+)"', blob) + if match: + return bytes(match.group(1), "utf-8").decode("unicode_escape") + match = CLAUDE_TOOL_NAME_RE.search(blob) + if match and match.group(1).lower() in {"bash", "shell", "exec_command"}: + return match.group(1) + return None + + +def extract_exit_code(blob: str) -> int | None: + match = COMMAND_EXIT_RE.search(blob) + if not match: + return None + try: + return int(match.group(2) or match.group(3) or match.group(4)) + except ValueError: + return None + + +def receipt_matches_family(receipt: dict[str, Any], family: str) -> bool: + command = str(receipt.get("command") or "") + pattern = COMMAND_FAMILY_PATTERNS.get(family) + return bool(pattern and pattern.search(command)) + + +def apply_receipt_lock(manifest: dict[str, Any]) -> str | None: + receipts = load_command_receipts() + manifest["receipts"] = receipts[-20:] + appeals = load_appeals() + + for claim in manifest["claims"]: + if claim["class"] != "receipt_lock": + continue + missing_families: list[str] = [] + failed_family: tuple[str, dict[str, Any]] | None = None + supported_families: list[tuple[str, dict[str, Any]]] = [] + + for family in command_families_for_claim(claim["text"]): + matching = [r for r in receipts if receipt_matches_family(r, family)] + latest = matching[-1] if matching else None + if latest is None: + missing_families.append(family) + elif latest.get("success") is not True: + failed_family = (family, latest) + break + else: + supported_families.append((family, latest)) + + if failed_family is not None: + family, latest = failed_family + claim["evidence_state"] = "failed_receipt" if latest.get("success") is False else "unknown_receipt" + claim["decision"] = "veto" + claim["precedent"].append( + { + "type": "command", + "summary": f"Latest {family} command did not produce a successful receipt.", + "command": latest.get("command"), + "exitCode": latest.get("exitCode"), + } + ) + claim["fix"] = f"Replace the claim with: I do not have a successful {family} receipt for this session." + manifest["overall"] = "veto" + manifest["verdictBar"] = "VETO" + manifest["summary"] = "Receipt Lock blocked a contradicted verification claim." + return f"Receipt Lock: Draft claims {family} passed, but latest {family} receipt is not successful." + + if missing_families and is_appealed(claim, appeals): + claim["evidence_state"] = "appealed" + claim["decision"] = "appealed" + claim["precedent"].append({"type": "appeal", "summary": "Prior appeal suppresses this missing-receipt veto."}) + manifest["overall"] = "pass_with_warnings" + manifest["verdictBar"] = "APPEALED" + manifest["summary"] = "Prior appeal suppressed a Receipt Lock veto." + continue + + if missing_families: + family_list = ", ".join(missing_families) + claim["evidence_state"] = "missing_receipt" + claim["decision"] = "veto" + claim["precedent"].append( + { + "type": "receipt_lock", + "summary": f"No {family_list} command receipt found in this session.", + "source": "transcript/command ledger", + } + ) + claim["fix"] = f"Replace the claim with: I do not have recorded {family_list} receipt(s) for this session." + manifest["overall"] = "veto" + manifest["verdictBar"] = "VETO" + manifest["summary"] = "Receipt Lock blocked an unsupported verification claim." + return f"Receipt Lock: Draft claims {family_list} passed, but no {family_list} command receipt exists." + + claim["evidence_state"] = "supported" + claim["decision"] = "pass" + for family, latest in supported_families: + claim["precedent"].append( + { + "type": "command", + "summary": f"{family} receipt found.", + "command": latest.get("command"), + "exitCode": latest.get("exitCode"), + } + ) + + return None + + +def apply_model_verdict(manifest: dict[str, Any], verdict: str, evidence: str = "") -> str: + low = verdict.strip().lower() + if low == "yes" or low.startswith("yes "): + if manifest["overall"] != "veto": + has_appealed = any(c["decision"] == "appealed" for c in manifest["claims"]) + has_unchecked = any(c["decision"] == "pending" for c in manifest["claims"]) + manifest["overall"] = "pass_with_warnings" if has_unchecked or has_appealed else "pass" + manifest["verdictBar"] = "APPEALED" if has_appealed else ("NOTE" if has_unchecked else "PASS") + manifest["summary"] = ( + "Prior appeal suppressed a Sanhedrin veto." + if has_appealed + else "Sanhedrin found no blocking contradiction." + ) + for claim in manifest["claims"]: + if claim["decision"] == "pending": + claim["decision"] = "pass_unverified" + claim["evidence_state"] = "out_of_scope" + return "yes" + + reason = verdict.split(" - ", 1)[1] if " - " in verdict else verdict + appeals = load_appeals() + candidate = first_relevant_claim(manifest) + if candidate and is_appealed(candidate, appeals): + candidate["decision"] = "appealed" + candidate["evidence_state"] = "appealed" + candidate["precedent"].append({"type": "appeal", "summary": "Prior appeal suppresses this model veto."}) + manifest["overall"] = "pass_with_warnings" + manifest["verdictBar"] = "APPEALED" + manifest["summary"] = "Prior appeal suppressed the Sanhedrin veto." + return "yes" + + if candidate: + candidate["decision"] = "veto" + candidate["evidence_state"] = "contradicted" + candidate["precedent"].append({"type": "vestige", "summary": reason[:500], "evidence": evidence[:1000]}) + candidate["fix"] = "Remove or qualify the contradicted claim using the cited Vestige precedent." + manifest["overall"] = "veto" + manifest["verdictBar"] = "VETO" + manifest["summary"] = reason[:500] + return verdict + + +def first_relevant_claim(manifest: dict[str, Any]) -> dict[str, Any] | None: + for claim in manifest["claims"]: + if claim["decision"] in {"pending", "pass_unverified"}: + return claim + return manifest["claims"][0] if manifest["claims"] else None + + +def load_appeals() -> list[dict[str, Any]]: + return load_jsonl(APPEALS_JSONL) + + +def is_appealed(claim: dict[str, Any], appeals: list[dict[str, Any]]) -> bool: + fp = claim.get("fingerprint") + if not fp: + return False + for appeal in appeals: + if ( + appeal.get("claimFingerprint") == fp + and appeal.get("reason") in {"stale", "wrong", "too_strict"} + and appeal.get("status", "active") == "active" + ): + return True + return False + + +def save_manifest(manifest: dict[str, Any]) -> None: + ensure_dirs() + receipt_path = RECEIPTS_DIR / f"{manifest['id']}.json" + html_path = RECEIPTS_DIR / f"{manifest['id']}.html" + json_blob = json.dumps(manifest, indent=2) + write_text_atomic(receipt_path, json_blob) + write_text_atomic(LATEST_JSON, json_blob) + rendered = render_receipt_html(manifest) + write_text_atomic(html_path, rendered) + write_text_atomic(LATEST_HTML, rendered) + + +def record_fail_open(reason: str, detail: str = "", transcript: str | None = None) -> None: + ensure_dirs() + run_id = os.environ.get("VESTIGE_SANHEDRIN_RUN_ID") or stable_id(f"{now_iso()}:{os.getpid()}", "run") + event = { + "timestamp": now_iso(), + "runId": run_id, + "reason": reason, + "detail": detail[:500], + "transcript": transcript or os.environ.get("VESTIGE_SANHEDRIN_TRANSCRIPT"), + } + try: + with FAIL_OPEN_JSONL.open("a", encoding="utf-8") as f: + f.write(json.dumps(event) + "\n") + except OSError: + pass + + +def write_text_atomic(path: Path, content: str) -> None: + ensure_dirs() + tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp") + tmp.write_text(content, encoding="utf-8") + tmp.replace(path) + + +def render_receipt_html(manifest: dict[str, Any]) -> str: + status = html.escape(str(manifest.get("verdictBar", "PASS"))) + summary = html.escape(str(manifest.get("summary", ""))) + claims = [] + for claim in manifest.get("claims", []): + precedents = "".join( + f"
    • {html.escape(str(p.get('summary', p)))}
    • " + for p in claim.get("precedent", []) + ) + claims.append( + "
      " + f"
      {html.escape(str(claim.get('decision')))} / {html.escape(str(claim.get('evidence_state')))}
      " + f"

      {html.escape(str(claim.get('text')))}

      " + f"

      Fix: {html.escape(str(claim.get('fix') or 'No change required.'))}

      " + f"

      Appeal: stale | wrong | too_strict

      " + f"
        {precedents}
      " + "
      " + ) + return f""" +Vestige Veto Receipt + +
      Verdict{status}
      +

      Veto Receipt

      {summary}

      {''.join(claims)} +""" + + +def appeal_latest(reason: str, note: str = "", claim_id: str | None = None) -> dict[str, Any]: + if not LATEST_JSON.exists(): + raise FileNotFoundError(str(LATEST_JSON)) + manifest = json.loads(LATEST_JSON.read_text(encoding="utf-8")) + claims = manifest.get("claims", []) + claim = next((c for c in claims if c.get("id") == claim_id), None) if claim_id else None + if claim is None: + claim = next((c for c in claims if c.get("decision") == "veto"), claims[0] if claims else None) + if claim is None: + raise ValueError("latest receipt has no claims") + appeal = { + "timestamp": now_iso(), + "receiptId": manifest.get("id"), + "claimId": claim.get("id"), + "claimFingerprint": claim.get("fingerprint"), + "claim": claim.get("text"), + "reason": reason, + "note": note, + "status": "active", + } + ensure_dirs() + with APPEALS_JSONL.open("a", encoding="utf-8") as f: + f.write(json.dumps(appeal) + "\n") + claim["appeal"]["status"] = "appealed" + claim["appeal"]["lastReason"] = reason + manifest["overall"] = "appealed" + manifest["verdictBar"] = "APPEALED" + manifest["summary"] = f"Appealed as {reason}." + save_manifest(manifest) + return appeal diff --git a/package.json b/package.json index e940f81..20bb78a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vestige", - "version": "2.1.2", + "version": "2.1.25", "private": true, "description": "Cognitive memory for AI - MCP server with FSRS-6 spaced repetition", "author": "Sam Valladares", diff --git a/packages/vestige-init/bin/init.js b/packages/vestige-init/bin/init.js index 2c26b55..d7c5da2 100755 --- a/packages/vestige-init/bin/init.js +++ b/packages/vestige-init/bin/init.js @@ -13,8 +13,8 @@ const PLATFORM = os.platform(); const BANNER = ` vestige init v${PACKAGE_VERSION} - Give your AI a brain in 10 seconds. - Now with 3D dashboard at localhost:3927/dashboard + Configure local Vestige memory for MCP-compatible agents. + Dashboard: localhost:3927/dashboard `; // ─── IDE Definitions ──────────────────────────────────────────────────────── @@ -173,11 +173,64 @@ function findBinary() { return null; } +function stripJsonComments(input) { + let output = ''; + let inString = false; + let escaped = false; + + for (let i = 0; i < input.length; i++) { + const current = input[i]; + const next = input[i + 1]; + + if (inString) { + output += current; + if (escaped) { + escaped = false; + } else if (current === '\\') { + escaped = true; + } else if (current === '"') { + inString = false; + } + continue; + } + + if (current === '"') { + inString = true; + output += current; + continue; + } + + if (current === '/' && next === '/') { + while (i < input.length && input[i] !== '\n') i++; + output += '\n'; + continue; + } + + if (current === '/' && next === '*') { + i += 2; + while (i < input.length && !(input[i] === '*' && input[i + 1] === '/')) i++; + i++; + continue; + } + + output += current; + } + + return output; +} + +function removeTrailingCommas(input) { + return input.replace(/,\s*([}\]])/g, '$1'); +} + function readJsonSafe(filePath) { try { const content = fs.readFileSync(filePath, 'utf8'); - return JSON.parse(content); - } catch { + return JSON.parse(removeTrailingCommas(stripJsonComments(content))); + } catch (err) { + if (fs.existsSync(filePath)) { + throw new Error(`Could not parse ${filePath}: ${err.message}`); + } return null; } } @@ -188,6 +241,29 @@ function ensureDir(filePath) { fs.mkdirSync(dir, { recursive: true }); } +function backupFile(filePath) { + if (!fs.existsSync(filePath)) return null; + const stamp = new Date().toISOString().replace(/[:.]/g, '-'); + const backupPath = `${filePath}.bak.${stamp}`; + fs.copyFileSync(filePath, backupPath); + try { + fs.chmodSync(backupPath, 0o600); + } catch {} + return backupPath; +} + +function writeJsonAtomic(filePath, value) { + ensureDir(filePath); + const backupPath = backupFile(filePath); + const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`; + fs.writeFileSync(tempPath, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 }); + fs.renameSync(tempPath, filePath); + try { + fs.chmodSync(filePath, 0o600); + } catch {} + return backupPath; +} + function buildVestigeConfig(binaryPath) { return { command: binaryPath, @@ -210,7 +286,6 @@ function buildXcodeConfig(binaryPath) { }, }, }, - hasTrustDialogAccepted: true, }, }, }; @@ -240,7 +315,6 @@ function injectConfig(ide, ideName, binaryPath) { if (!config.projects['*']) config.projects['*'] = {}; if (!config.projects['*'].mcpServers) config.projects['*'].mcpServers = {}; config.projects['*'].mcpServers.vestige = xcodeConfig.projects['*'].mcpServers.vestige; - config.projects['*'].hasTrustDialogAccepted = true; } else if (ide.format === 'vscode') { // VS Code uses "mcp" key in settings.json with "servers" subkey if (!config.mcp) config.mcp = {}; @@ -261,7 +335,10 @@ function injectConfig(ide, ideName, binaryPath) { config[key].vestige = buildVestigeConfig(binaryPath); } - fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n'); + const backupPath = writeJsonAtomic(configPath, config); + if (backupPath) { + console.log(` [backup] ${path.basename(backupPath)}`); + } return true; } diff --git a/packages/vestige-init/package.json b/packages/vestige-init/package.json index 7296dab..cb9b4f7 100644 --- a/packages/vestige-init/package.json +++ b/packages/vestige-init/package.json @@ -1,7 +1,7 @@ { "name": "@vestige/init", - "version": "2.1.2", - "description": "Give your AI a brain in 10 seconds — zero-config Vestige installer with 3D dashboard", + "version": "2.1.25", + "description": "Configure Vestige local memory for MCP-compatible AI agents", "bin": { "vestige-init": "bin/init.js" }, diff --git a/packages/vestige-mcp-npm/README.md b/packages/vestige-mcp-npm/README.md index 25312d2..98e6575 100644 --- a/packages/vestige-mcp-npm/README.md +++ b/packages/vestige-mcp-npm/README.md @@ -18,15 +18,17 @@ Already installed? Update without copying release URLs: vestige update ``` -This refreshes the binaries and Cognitive Sandwich companion files while keeping -all hooks disabled by default. +This refreshes the binaries only. Optional Claude Code Cognitive Sandwich +companion files are refreshed with `vestige update --sandwich-companion` or +`vestige sandwich install`. ### What gets installed | Command | Description | |---------|-------------| -| `vestige-mcp` | MCP server for Claude integration | +| `vestige-mcp` | MCP server for local agent memory | | `vestige` | CLI for stats, health checks, and maintenance | +| `vestige-restore` | Restore helper for backup recovery | ### Verify installation @@ -34,13 +36,23 @@ all hooks disabled by default. vestige health ``` -## Usage with Claude Code +## Usage with MCP Clients + +Vestige works with any client that can register a stdio MCP server. + +**Claude Code** ```bash claude mcp add vestige vestige-mcp -s user ``` -Then restart Claude. +**Codex** + +```bash +codex mcp add vestige -- vestige-mcp +``` + +Then restart your MCP client. ## Usage with Claude Desktop @@ -66,8 +78,9 @@ vestige stats # Memory statistics vestige stats --states # Cognitive state distribution vestige health # System health check vestige consolidate # Run memory maintenance cycle -vestige update # Update binaries + companion files -vestige sandwich install # Refresh optional Claude Code hook files +vestige update # Update binaries +vestige update --sandwich-companion # Also refresh optional Claude Code files +vestige sandwich install # Manage optional Claude Code hook files ``` ## Features @@ -92,7 +105,7 @@ You'll never run out of space. A heavy user creating 100 memories/day would use On first use, Vestige downloads the nomic-embed-text-v1.5 model (~130MB). This is a one-time download and all subsequent operations are fully offline. -The model is stored in `.fastembed_cache/` in your working directory, or you can set a global location: +The model is stored in Vestige's OS cache directory, or you can set a global location: ```bash export FASTEMBED_CACHE_PATH="$HOME/.fastembed_cache" @@ -103,7 +116,7 @@ export FASTEMBED_CACHE_PATH="$HOME/.fastembed_cache" | Variable | Description | Default | |----------|-------------|---------| | `RUST_LOG` | Log verbosity + per-module filter | `info` | -| `FASTEMBED_CACHE_PATH` | Embeddings model cache | `./.fastembed_cache` | +| `FASTEMBED_CACHE_PATH` | Embeddings model cache override | OS cache dir | | `VESTIGE_DATA_DIR` | Storage directory fallback; database lives at `/vestige.db` | OS data dir | | `VESTIGE_DASHBOARD_PORT` | Dashboard port | `3927` | | `VESTIGE_AUTH_TOKEN` | Bearer auth for dashboard + HTTP MCP | auto-generated | @@ -116,7 +129,7 @@ Storage precedence is `--data-dir `, then `VESTIGE_DATA_DIR`, then your OS 1. Verify binary exists: `which vestige-mcp` 2. Test directly: `vestige-mcp` (should wait for stdio input) -3. Check Claude logs: `~/Library/Logs/Claude/` (macOS) +3. Check your MCP client's server logs. ### "vestige: command not found" @@ -127,7 +140,9 @@ npm install -g vestige-mcp-server ### Embeddings not downloading -The model downloads on first `ingest` or `search` operation. If Claude can't connect to the MCP server, no memory operations happen and no model downloads. +The model downloads on first memory ingest or search operation. If your MCP +client cannot connect to the MCP server, no memory operations happen and no +model downloads. Fix the MCP connection first, then the model will download automatically. diff --git a/packages/vestige-mcp-npm/bin/vestige-restore.js b/packages/vestige-mcp-npm/bin/vestige-restore.js new file mode 100755 index 0000000..1d00b17 --- /dev/null +++ b/packages/vestige-mcp-npm/bin/vestige-restore.js @@ -0,0 +1,31 @@ +#!/usr/bin/env node + +const { spawn } = require('child_process'); +const path = require('path'); +const fs = require('fs'); +const os = require('os'); + +const platform = os.platform(); +const binaryName = platform === 'win32' ? 'vestige-restore.exe' : 'vestige-restore'; +const binaryPath = path.join(__dirname, binaryName); + +if (!fs.existsSync(binaryPath)) { + console.error('Error: vestige-restore binary not found.'); + console.error(`Expected at: ${binaryPath}`); + console.error(''); + console.error('Try reinstalling: npm install -g vestige-mcp-server'); + process.exit(1); +} + +const child = spawn(binaryPath, process.argv.slice(2), { + stdio: 'inherit', +}); + +child.on('error', (err) => { + console.error('Failed to start vestige-restore:', err.message); + process.exit(1); +}); + +child.on('exit', (code) => { + process.exit(code ?? 0); +}); diff --git a/packages/vestige-mcp-npm/package.json b/packages/vestige-mcp-npm/package.json index e4edf57..a7e7829 100644 --- a/packages/vestige-mcp-npm/package.json +++ b/packages/vestige-mcp-npm/package.json @@ -1,11 +1,12 @@ { "name": "vestige-mcp-server", - "version": "2.1.2", + "version": "2.1.25", "mcpName": "io.github.samvallad33/vestige", - "description": "Vestige MCP Server — Cognitive memory for AI with FSRS-6, 3D dashboard, and 29 brain modules", + "description": "Vestige MCP Server — local cognitive memory for MCP-compatible AI agents", "bin": { "vestige-mcp": "bin/vestige-mcp.js", - "vestige": "bin/vestige.js" + "vestige": "bin/vestige.js", + "vestige-restore": "bin/vestige-restore.js" }, "scripts": { "postinstall": "node scripts/postinstall.js" diff --git a/packages/vestige-mcp-npm/scripts/postinstall.js b/packages/vestige-mcp-npm/scripts/postinstall.js index 78b1ae6..65fe54b 100644 --- a/packages/vestige-mcp-npm/scripts/postinstall.js +++ b/packages/vestige-mcp-npm/scripts/postinstall.js @@ -4,7 +4,8 @@ const https = require('https'); const fs = require('fs'); const path = require('path'); const os = require('os'); -const { execSync } = require('child_process'); +const crypto = require('crypto'); +const { execFileSync } = require('child_process'); const packageJson = require('../package.json'); const VERSION = packageJson.version; @@ -28,11 +29,26 @@ const archStr = ARCH_MAP[ARCH]; if (!platformStr || !archStr) { console.error(`Unsupported platform: ${PLATFORM}-${ARCH}`); - console.error('Supported: darwin/linux/win32 on x64/arm64'); + console.error('Supported release assets: macOS x64/arm64, Linux x64, Windows x64'); process.exit(1); } const target = `${archStr}-${platformStr}`; +const SUPPORTED_TARGETS = new Set([ + 'aarch64-apple-darwin', + 'x86_64-apple-darwin', + 'x86_64-unknown-linux-gnu', + 'x86_64-pc-windows-msvc', +]); +if (!SUPPORTED_TARGETS.has(target)) { + console.error(`Unsupported Vestige release target: ${target}`); + console.error('Supported release assets:'); + for (const supported of SUPPORTED_TARGETS) { + console.error(` - ${supported}`); + } + process.exit(1); +} + const isWindows = PLATFORM === 'win32'; const archiveExt = isWindows ? 'zip' : 'tar.gz'; const archiveName = `vestige-mcp-${target}.${archiveExt}`; @@ -40,6 +56,10 @@ const downloadUrl = `https://github.com/samvallad33/vestige/releases/download/v$ const targetDir = path.join(__dirname, '..', 'bin'); const archivePath = path.join(targetDir, archiveName); +const checksumPath = path.join(targetDir, `${archiveName}.sha256`); +const expectedArchiveMembers = new Set( + ['vestige-mcp', 'vestige', 'vestige-restore'].map((name) => (isWindows ? `${name}.exe` : name)) +); function isWorkspaceCheckout() { const packageRoot = path.resolve(__dirname, '..'); @@ -107,15 +127,65 @@ function download(url, dest) { * Extract archive based on platform */ function extract(archivePath, destDir) { + validateArchiveEntries(archivePath); if (isWindows) { // Use PowerShell to extract zip on Windows - execSync( - `powershell -Command "Expand-Archive -Path '${archivePath}' -DestinationPath '${destDir}' -Force"`, + execFileSync( + 'powershell', + [ + '-NoProfile', + '-Command', + `Expand-Archive -LiteralPath ${powershellQuote(archivePath)} -DestinationPath ${powershellQuote(destDir)} -Force`, + ], { stdio: 'inherit' } ); } else { // Use tar on Unix - execSync(`tar -xzf "${archivePath}" -C "${destDir}"`, { stdio: 'inherit' }); + execFileSync('tar', ['-xzf', archivePath, '-C', destDir], { stdio: 'inherit' }); + } +} + +function powershellQuote(value) { + return `'${String(value).replace(/'/g, "''")}'`; +} + +function listArchiveEntries(archivePath) { + if (!isWindows) { + return execFileSync('tar', ['-tzf', archivePath], { encoding: 'utf8' }); + } + + const script = [ + 'Add-Type -AssemblyName System.IO.Compression.FileSystem;', + `$zip = [System.IO.Compression.ZipFile]::OpenRead(${powershellQuote(archivePath)});`, + 'try { $zip.Entries | ForEach-Object { $_.FullName } } finally { $zip.Dispose() }', + ].join(' '); + return execFileSync('powershell', ['-NoProfile', '-Command', script], { encoding: 'utf8' }); +} + +function normalizeArchiveEntry(entry) { + let normalized = entry.replace(/\\/g, '/').replace(/^\.\//, ''); + if ( + !normalized || + normalized.startsWith('/') || + /^[A-Za-z]:/.test(normalized) || + normalized.split('/').some((part) => part === '' || part === '..') + ) { + throw new Error(`Unsafe archive entry: ${entry}`); + } + return normalized; +} + +function validateArchiveEntries(archivePath) { + const entries = listArchiveEntries(archivePath) + .split(/\r?\n/) + .map((entry) => entry.trim()) + .filter(Boolean); + + for (const entry of entries) { + const normalized = normalizeArchiveEntry(entry); + if (!expectedArchiveMembers.has(normalized)) { + throw new Error(`Unexpected archive entry: ${entry}`); + } } } @@ -134,11 +204,26 @@ function makeExecutable(binDir) { } } +function verifyChecksum(archivePath, checksumPath) { + const checksumText = fs.readFileSync(checksumPath, 'utf8').trim(); + const expected = checksumText.split(/\s+/)[0]?.toLowerCase(); + if (!expected || !/^[a-f0-9]{64}$/.test(expected)) { + throw new Error(`Invalid checksum file for ${archiveName}`); + } + + const actual = crypto.createHash('sha256').update(fs.readFileSync(archivePath)).digest('hex'); + if (actual !== expected) { + throw new Error(`Checksum mismatch for ${archiveName}`); + } +} + async function main() { try { // Download console.log(`Downloading from ${downloadUrl}...`); await download(downloadUrl, archivePath); + await download(`${downloadUrl}.sha256`, checksumPath); + verifyChecksum(archivePath, checksumPath); console.log('Download complete.'); // Extract @@ -147,6 +232,7 @@ async function main() { // Cleanup archive fs.unlinkSync(archivePath); + fs.unlinkSync(checksumPath); // Make executable makeExecutable(targetDir); @@ -169,9 +255,11 @@ async function main() { } console.log(''); console.log('Next steps:'); - console.log(' 1. Add to Claude: claude mcp add vestige vestige-mcp -s user'); - console.log(' 2. Restart Claude'); - console.log(' 3. Test with: "remember that my favorite color is blue"'); + console.log(' 1. Add vestige-mcp to any MCP-compatible agent.'); + console.log(' Claude Code: claude mcp add vestige vestige-mcp -s user'); + console.log(' Codex: codex mcp add vestige -- vestige-mcp'); + console.log(' 2. Restart your MCP client.'); + console.log(' 3. Test with: "remember that my preferred editor is VS Code"'); console.log(''); } catch (err) { diff --git a/packages/vestige-mcpb/README.md b/packages/vestige-mcpb/README.md index b6750b3..841193e 100644 --- a/packages/vestige-mcpb/README.md +++ b/packages/vestige-mcpb/README.md @@ -4,7 +4,7 @@ One-click installation bundle for Claude Desktop. ## For Users -1. Download `vestige-2.1.0.mcpb` from [GitHub Releases](https://github.com/samvallad33/vestige/releases) +1. Download `vestige-2.1.23.mcpb` from [GitHub Releases](https://github.com/samvallad33/vestige/releases) 2. Double-click to install 3. Restart Claude Desktop @@ -34,5 +34,5 @@ vestige-mcpb/ │ ├── vestige-mcp-darwin-arm64 │ ├── vestige-mcp-linux-x64 │ └── vestige-mcp-win32-x64.exe -└── vestige-2.1.0.mcpb # Final bundle (generated) +└── vestige-2.1.23.mcpb # Final bundle (generated) ``` diff --git a/packages/vestige-mcpb/build.sh b/packages/vestige-mcpb/build.sh index a144cbb..886d686 100755 --- a/packages/vestige-mcpb/build.sh +++ b/packages/vestige-mcpb/build.sh @@ -1,33 +1,101 @@ #!/bin/bash -set -e +set -euo pipefail -VERSION="${1:-2.1.0}" +VERSION="${1:-2.1.23}" REPO="samvallad33/vestige" +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT echo "Building Vestige MCPB v${VERSION}..." # Create server directory mkdir -p server +die() { + echo "error: $*" >&2 + exit 1 +} + +verify_checksum() { + local archive="$1" + local checksum="$2" + local expected actual + expected="$(awk '{print tolower($1)}' "$checksum")" + case "$expected" in + [0-9a-f][0-9a-f][0-9a-f][0-9a-f]*) ;; + *) die "invalid checksum file: $checksum" ;; + esac + [ "${#expected}" -eq 64 ] || die "invalid checksum length for $archive" + actual="$(shasum -a 256 "$archive" | awk '{print tolower($1)}')" + [ "$actual" = "$expected" ] || die "checksum mismatch for $(basename "$archive")" +} + +validate_member() { + local member="${1#./}" + shift + case "$member" in + ""|/*|../*|*/../*|*"/.."|*":"*) die "unsafe archive member: $member" ;; + esac + for expected in "$@"; do + [ "$member" = "$expected" ] && return 0 + done + die "unexpected archive member: $member" +} + +validate_tar() { + local archive="$1" + shift + while IFS= read -r member; do + [ -n "$member" ] || continue + validate_member "$member" "$@" + done < <(tar -tzf "$archive") +} + +validate_zip() { + local archive="$1" + shift + while IFS= read -r member; do + [ -n "$member" ] || continue + validate_member "$member" "$@" + done < <(unzip -Z1 "$archive") +} + +download_release_asset() { + local name="$1" + local archive="$TMPDIR/$name" + local checksum="$TMPDIR/$name.sha256" + curl -fsSL "https://github.com/${REPO}/releases/download/v${VERSION}/${name}" -o "$archive" + curl -fsSL "https://github.com/${REPO}/releases/download/v${VERSION}/${name}.sha256" -o "$checksum" + verify_checksum "$archive" "$checksum" + printf '%s\n' "$archive" +} + # Download macOS ARM64 echo "Downloading macOS ARM64 binary..." -curl -sL "https://github.com/${REPO}/releases/download/v${VERSION}/vestige-mcp-aarch64-apple-darwin.tar.gz" | tar -xz -C server +ARCHIVE="$(download_release_asset "vestige-mcp-aarch64-apple-darwin.tar.gz")" +validate_tar "$ARCHIVE" vestige-mcp vestige vestige-restore +tar -xzf "$ARCHIVE" -C server mv server/vestige-mcp server/vestige-mcp-darwin-arm64 mv server/vestige server/vestige-darwin-arm64 +rm -f server/vestige-restore # Download Linux x64 echo "Downloading Linux x64 binary..." -curl -sL "https://github.com/${REPO}/releases/download/v${VERSION}/vestige-mcp-x86_64-unknown-linux-gnu.tar.gz" | tar -xz -C server +ARCHIVE="$(download_release_asset "vestige-mcp-x86_64-unknown-linux-gnu.tar.gz")" +validate_tar "$ARCHIVE" vestige-mcp vestige vestige-restore +tar -xzf "$ARCHIVE" -C server mv server/vestige-mcp server/vestige-mcp-linux-x64 mv server/vestige server/vestige-linux-x64 +rm -f server/vestige-restore # Download Windows x64 echo "Downloading Windows x64 binary..." -curl -sL "https://github.com/${REPO}/releases/download/v${VERSION}/vestige-mcp-x86_64-pc-windows-msvc.zip" -o /tmp/win.zip -unzip -q /tmp/win.zip -d server +ARCHIVE="$(download_release_asset "vestige-mcp-x86_64-pc-windows-msvc.zip")" +validate_zip "$ARCHIVE" vestige-mcp.exe vestige.exe vestige-restore.exe +unzip -q "$ARCHIVE" -d server mv server/vestige-mcp.exe server/vestige-mcp-win32-x64.exe mv server/vestige.exe server/vestige-win32-x64.exe -rm /tmp/win.zip +rm -f server/vestige-restore.exe # Make executable chmod +x server/* diff --git a/packages/vestige-mcpb/manifest.json b/packages/vestige-mcpb/manifest.json index 1018e1c..9d8c786 100644 --- a/packages/vestige-mcpb/manifest.json +++ b/packages/vestige-mcpb/manifest.json @@ -2,7 +2,7 @@ "manifest_version": "0.2", "name": "vestige", "display_name": "Vestige", - "version": "2.1.0", + "version": "2.1.23", "description": "AI memory system built on 130 years of cognitive science. FSRS-6 spaced repetition, synaptic tagging, and local-first storage.", "author": { "name": "Sam Valladares", diff --git a/scripts/check-sandwich-prereqs.sh b/scripts/check-sandwich-prereqs.sh index b6f3509..c196cf6 100755 --- a/scripts/check-sandwich-prereqs.sh +++ b/scripts/check-sandwich-prereqs.sh @@ -7,6 +7,52 @@ warn() { printf ' \033[1;33m[WARN]\033[0m %s\n' "$*"; FAIL=1; } miss() { printf ' \033[1;31m[MISS]\033[0m %s\n' "$*"; FAIL=1; } info() { printf ' \033[1;36m[INFO]\033[0m %s\n' "$*"; } +load_vestige_sanhedrin_env() { + [ -f "$1" ] || return 0 + command -v python3 >/dev/null 2>&1 || return 0 + while IFS="$(printf '\t')" read -r key value; do + case "$key" in + VESTIGE_SANHEDRIN_ENABLED|VESTIGE_SANHEDRIN_MODEL|VESTIGE_SANHEDRIN_ENDPOINT|VESTIGE_SANHEDRIN_BACKEND|VESTIGE_SANHEDRIN_CLAIM_MODE|VESTIGE_SANHEDRIN_OUTPUT|VESTIGE_SANHEDRIN_PYTHON|VESTIGE_DASHBOARD_PORT) + export "$key=$value" + ;; + esac + done < <(python3 - "$1" <<'PY' +import shlex +import sys + +allowed = { + "VESTIGE_SANHEDRIN_ENABLED", + "VESTIGE_SANHEDRIN_MODEL", + "VESTIGE_SANHEDRIN_ENDPOINT", + "VESTIGE_SANHEDRIN_BACKEND", + "VESTIGE_SANHEDRIN_CLAIM_MODE", + "VESTIGE_SANHEDRIN_OUTPUT", + "VESTIGE_SANHEDRIN_PYTHON", + "VESTIGE_DASHBOARD_PORT", +} + +try: + lines = open(sys.argv[1], encoding="utf-8").read().splitlines() +except OSError: + sys.exit(0) + +for raw in lines: + line = raw.strip() + if not line or line.startswith("#"): + continue + try: + parts = shlex.split(line, posix=True) + except ValueError: + continue + if len(parts) != 1 or "=" not in parts[0]: + continue + key, value = parts[0].split("=", 1) + if key in allowed and "\t" not in value and "\0" not in value: + print(f"{key}\t{value}") +PY + ) +} + FAIL=0 CHECK_PREFLIGHT=0 CHECK_SANHEDRIN=0 @@ -31,17 +77,15 @@ EOF done if [ -f "$SANHEDRIN_ENV" ]; then - set +u - set -a - # shellcheck disable=SC1090 - . "$SANHEDRIN_ENV" 2>/dev/null || true - set +a - set -u + load_vestige_sanhedrin_env "$SANHEDRIN_ENV" || true fi -SANHEDRIN_ENDPOINT="${VESTIGE_SANHEDRIN_ENDPOINT:-${MLX_ENDPOINT:-http://127.0.0.1:8080/v1/chat/completions}}" +SANHEDRIN_ENDPOINT="${VESTIGE_SANHEDRIN_ENDPOINT:-${MLX_ENDPOINT:-}}" SANHEDRIN_ENDPOINT="${SANHEDRIN_ENDPOINT%/}" -SANHEDRIN_MODELS_URL="${SANHEDRIN_ENDPOINT%/chat/completions}/models" +SANHEDRIN_MODELS_URL="" +[ -n "$SANHEDRIN_ENDPOINT" ] && SANHEDRIN_MODELS_URL="${SANHEDRIN_ENDPOINT%/chat/completions}/models" +SANHEDRIN_CLAIM_MODE="${VESTIGE_SANHEDRIN_CLAIM_MODE:-0}" +SANHEDRIN_OUTPUT="${VESTIGE_SANHEDRIN_OUTPUT:-text}" echo "Vestige Cognitive Sandwich — Prereq Check" echo @@ -58,8 +102,8 @@ fi if command -v python3 >/dev/null; then PY="$(python3 -c 'import sys;print(".".join(map(str,sys.version_info[:2])))' 2>/dev/null)" case "$PY" in - 3.1[0-9]|3.[2-9]*) ok "Python $PY" ;; - *) warn "Python $PY (need 3.10+)" ;; + 3.9|3.1[0-9]|3.[2-9]*) ok "Python $PY" ;; + *) warn "Python $PY (need 3.9+)" ;; esac else miss "python3 not found" @@ -112,16 +156,19 @@ if [ "$CHECK_SANHEDRIN" -eq 1 ]; then else warn "Sanhedrin env file missing — run: install-sandwich.sh --enable-sanhedrin" fi + info "Sanhedrin claim mode: $SANHEDRIN_CLAIM_MODE; output: $SANHEDRIN_OUTPUT" if [ "$OS_NAME" = "Darwin" ] && [ "$ARCH_NAME" = "arm64" ]; then command -v uv >/dev/null && ok "uv" || warn "uv missing — brew install uv" command -v mlx_lm.server >/dev/null && ok "mlx-lm" || warn "mlx-lm — uv tool install mlx-lm" command -v hf >/dev/null && ok "huggingface_hub CLI" || warn "hf — uv tool install 'huggingface_hub[cli]'" - MODEL="${VESTIGE_SANHEDRIN_MODEL:-${VESTIGE_SANDWICH_MODEL:-mlx-community/Qwen3.6-35B-A3B-4bit}}" + MODEL="${VESTIGE_SANHEDRIN_MODEL:-${VESTIGE_SANDWICH_MODEL:-}}" HF_HOME_DEFAULT="${HF_HOME:-$HOME/.cache/huggingface}" ENC_MODEL="models--$(printf '%s' "$MODEL" | sed 's|/|--|g')" - if [ -d "$HF_HOME_DEFAULT/hub/$ENC_MODEL" ]; then + if [ -z "$MODEL" ]; then + info "No local MLX model configured; choose any OpenAI-compatible Sanhedrin model or preset." + elif [ -d "$HF_HOME_DEFAULT/hub/$ENC_MODEL" ]; then ok "Model cached: $MODEL" else info "Model not cached: $MODEL (local MLX path downloads ~19GB)" @@ -136,7 +183,9 @@ if [ "$CHECK_SANHEDRIN" -eq 1 ]; then info "Skipping MLX/launchd checks on $OS_NAME $ARCH_NAME" fi - if curl -fsS -m 2 "$SANHEDRIN_MODELS_URL" >/dev/null 2>&1; then + if [ -z "$SANHEDRIN_MODELS_URL" ]; then + warn "Sanhedrin endpoint/model not configured yet; hook will fail open until configured" + elif curl -fsS -m 2 "$SANHEDRIN_MODELS_URL" >/dev/null 2>&1; then ok "Sanhedrin model endpoint responding at $SANHEDRIN_MODELS_URL" else warn "Sanhedrin endpoint not responding at $SANHEDRIN_MODELS_URL" diff --git a/scripts/install-sandwich.sh b/scripts/install-sandwich.sh index 280a4c3..88d1fc5 100755 --- a/scripts/install-sandwich.sh +++ b/scripts/install-sandwich.sh @@ -19,11 +19,24 @@ set -euo pipefail VERSION="${VESTIGE_SANDWICH_VERSION:-v2.1.1}" REPO="samvallad33/vestige" -MODEL_ID="${VESTIGE_SANHEDRIN_MODEL:-${VESTIGE_SANDWICH_MODEL:-mlx-community/Qwen3.6-35B-A3B-4bit}}" +MODEL_ID="${VESTIGE_SANHEDRIN_MODEL:-${VESTIGE_SANDWICH_MODEL:-}}" DASHBOARD_PORT="${VESTIGE_DASHBOARD_PORT:-3927}" -SANHEDRIN_ENDPOINT="${VESTIGE_SANHEDRIN_ENDPOINT:-${MLX_ENDPOINT:-http://127.0.0.1:8080/v1/chat/completions}}" +SANHEDRIN_ENDPOINT="${VESTIGE_SANHEDRIN_ENDPOINT:-${MLX_ENDPOINT:-}}" SANHEDRIN_ENDPOINT="${SANHEDRIN_ENDPOINT%/}" -SANHEDRIN_MODELS_URL="${SANHEDRIN_ENDPOINT%/chat/completions}/models" +SANHEDRIN_MODELS_URL="" +[ -n "$SANHEDRIN_ENDPOINT" ] && SANHEDRIN_MODELS_URL="${SANHEDRIN_ENDPOINT%/chat/completions}/models" +SANHEDRIN_CLAIM_MODE="${VESTIGE_SANHEDRIN_CLAIM_MODE:-1}" +SANHEDRIN_OUTPUT="${VESTIGE_SANHEDRIN_OUTPUT:-json}" +MODEL_ID_FROM_INSTALLER=0 +DASHBOARD_PORT_FROM_INSTALLER=0 +SANHEDRIN_ENDPOINT_FROM_INSTALLER=0 +SANHEDRIN_CLAIM_MODE_FROM_INSTALLER=0 +SANHEDRIN_OUTPUT_FROM_INSTALLER=0 +[ -n "${VESTIGE_SANHEDRIN_MODEL:-${VESTIGE_SANDWICH_MODEL:-}}" ] && MODEL_ID_FROM_INSTALLER=1 +[ -n "${VESTIGE_DASHBOARD_PORT:-}" ] && DASHBOARD_PORT_FROM_INSTALLER=1 +[ -n "${VESTIGE_SANHEDRIN_ENDPOINT:-${MLX_ENDPOINT:-}}" ] && SANHEDRIN_ENDPOINT_FROM_INSTALLER=1 +[ -n "${VESTIGE_SANHEDRIN_CLAIM_MODE:-}" ] && SANHEDRIN_CLAIM_MODE_FROM_INSTALLER=1 +[ -n "${VESTIGE_SANHEDRIN_OUTPUT:-}" ] && SANHEDRIN_OUTPUT_FROM_INSTALLER=1 HOOKS_DIR="$HOME/.claude/hooks" AGENTS_DIR="$HOME/.claude/agents" @@ -50,9 +63,11 @@ for arg in "$@"; do SANHEDRIN_ENDPOINT="${arg#*=}" SANHEDRIN_ENDPOINT="${SANHEDRIN_ENDPOINT%/}" SANHEDRIN_MODELS_URL="${SANHEDRIN_ENDPOINT%/chat/completions}/models" + SANHEDRIN_ENDPOINT_FROM_INSTALLER=1 ;; --sanhedrin-model=*|--model=*) MODEL_ID="${arg#*=}" + MODEL_ID_FROM_INSTALLER=1 ;; --src=*) SRC="${arg#--src=}" ;; -h|--help) @@ -74,18 +89,32 @@ die() { printf '\033[1;31m[sandwich]\033[0m %s\n' "$*" >&2; exit 1; } OS_NAME="$(uname -s)" ARCH_NAME="$(uname -m)" say "platform: $OS_NAME $ARCH_NAME" -if [ "$ENABLE_SANHEDRIN" -eq 1 ] && [ "$WITH_LAUNCHD" -eq 0 ]; then - say "Sanhedrin enabled without launchd; using OpenAI-compatible endpoint: $SANHEDRIN_ENDPOINT" -fi if [ "$WITH_LAUNCHD" -eq 1 ] && { [ "$OS_NAME" != "Darwin" ] || [ "$ARCH_NAME" != "arm64" ]; }; then warn "--with-launchd is Apple Silicon only; skipping local MLX autostart on $OS_NAME $ARCH_NAME" warn "Sanhedrin can still run on x86 via --sanhedrin-endpoint or VESTIGE_SANHEDRIN_ENDPOINT." WITH_LAUNCHD=0 fi +if [ "$WITH_LAUNCHD" -eq 1 ]; then + if [ "$SANHEDRIN_ENDPOINT_FROM_INSTALLER" -eq 0 ]; then + SANHEDRIN_ENDPOINT="${SANHEDRIN_ENDPOINT:-http://127.0.0.1:8080/v1/chat/completions}" + SANHEDRIN_MODELS_URL="" + [ -n "$SANHEDRIN_ENDPOINT" ] && SANHEDRIN_MODELS_URL="${SANHEDRIN_ENDPOINT%/chat/completions}/models" + fi + if [ "$MODEL_ID_FROM_INSTALLER" -eq 0 ]; then + MODEL_ID="${MODEL_ID:-mlx-community/Qwen3.6-35B-A3B-4bit}" + fi +fi +if [ "$ENABLE_SANHEDRIN" -eq 1 ] && [ "$WITH_LAUNCHD" -eq 0 ]; then + if [ -n "$SANHEDRIN_ENDPOINT" ] && [ -n "$MODEL_ID" ]; then + say "Sanhedrin enabled with custom OpenAI-compatible model: $MODEL_ID" + else + warn "Sanhedrin enabled with no verifier model configured yet; it will fail open until VESTIGE_SANHEDRIN_ENDPOINT and VESTIGE_SANHEDRIN_MODEL are set." + fi +fi # --- Prereqs (warnings only, install proceeds) --- command -v jq >/dev/null || die "jq required: brew install jq" -command -v python3 >/dev/null || die "python3 required (3.10+)" +command -v python3 >/dev/null || die "python3 required" if [ "$ENABLE_PREFLIGHT" -eq 1 ]; then command -v claude >/dev/null || warn "'claude' CLI not found — preflight-swarm.sh will fail open." command -v vestige-mcp >/dev/null || warn "'vestige-mcp' not found — Vestige preflight hooks will fail open." @@ -132,7 +161,7 @@ fi # --- Copy hooks --- copied=0; skipped=0 -for f in "$SCRIPT_DIR/hooks"/*.sh "$SCRIPT_DIR/hooks"/*.py; do +for f in "$SCRIPT_DIR/hooks"/*.sh "$SCRIPT_DIR/hooks"/*.py "$SCRIPT_DIR/hooks"/sanhedrin-presets.json; do [ -f "$f" ] || continue base="$(basename "$f")" # load-all-memory.sh dumps every memory MD — opt-in only @@ -144,7 +173,10 @@ for f in "$SCRIPT_DIR/hooks"/*.sh "$SCRIPT_DIR/hooks"/*.py; do skipped=$((skipped + 1)) continue fi - install -m 0755 "$f" "$HOOKS_DIR/$base" + case "$base" in + *.json) install -m 0644 "$f" "$HOOKS_DIR/$base" ;; + *) install -m 0755 "$f" "$HOOKS_DIR/$base" ;; + esac copied=$((copied + 1)) done say "hooks: $copied installed, $skipped skipped (use --force to overwrite)" @@ -165,14 +197,117 @@ quote_env() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")" } +load_vestige_sanhedrin_env() { + [ -f "$1" ] || return 0 + while IFS="$(printf '\t')" read -r key value; do + case "$key" in + VESTIGE_SANHEDRIN_ENABLED|VESTIGE_SANHEDRIN_MODEL|VESTIGE_SANHEDRIN_ENDPOINT|VESTIGE_SANHEDRIN_API_KEY|VESTIGE_SANHEDRIN_BACKEND|VESTIGE_SANHEDRIN_CLAIM_MODE|VESTIGE_SANHEDRIN_OUTPUT|VESTIGE_SANHEDRIN_PYTHON|VESTIGE_SANHEDRIN_ALLOW_LOOSE_LEDGER|VESTIGE_DASHBOARD_PORT) + export "$key=$value" + ;; + esac + done < <(python3 - "$1" <<'PY' +import shlex +import sys + +allowed = { + "VESTIGE_SANHEDRIN_ENABLED", + "VESTIGE_SANHEDRIN_MODEL", + "VESTIGE_SANHEDRIN_ENDPOINT", + "VESTIGE_SANHEDRIN_API_KEY", + "VESTIGE_SANHEDRIN_BACKEND", + "VESTIGE_SANHEDRIN_CLAIM_MODE", + "VESTIGE_SANHEDRIN_OUTPUT", + "VESTIGE_SANHEDRIN_PYTHON", + "VESTIGE_SANHEDRIN_ALLOW_LOOSE_LEDGER", + "VESTIGE_DASHBOARD_PORT", +} + +try: + lines = open(sys.argv[1], encoding="utf-8").read().splitlines() +except OSError: + sys.exit(0) + +for raw in lines: + line = raw.strip() + if not line or line.startswith("#"): + continue + try: + parts = shlex.split(line, posix=True) + except ValueError: + continue + if len(parts) != 1 or "=" not in parts[0]: + continue + key, value = parts[0].split("=", 1) + if key in allowed and "\t" not in value and "\0" not in value: + print(f"{key}\t{value}") +PY + ) +} + if [ "$ENABLE_SANHEDRIN" -eq 1 ]; then SANHEDRIN_ENV="$HOOKS_DIR/vestige-sanhedrin.env" + INSTALLER_MODEL_ID="$MODEL_ID" + INSTALLER_DASHBOARD_PORT="$DASHBOARD_PORT" + INSTALLER_SANHEDRIN_ENDPOINT="$SANHEDRIN_ENDPOINT" + INSTALLER_SANHEDRIN_CLAIM_MODE="$SANHEDRIN_CLAIM_MODE" + INSTALLER_SANHEDRIN_OUTPUT="$SANHEDRIN_OUTPUT" + if [ -f "$SANHEDRIN_ENV" ]; then + load_vestige_sanhedrin_env "$SANHEDRIN_ENV" || true + if [ "$MODEL_ID_FROM_INSTALLER" -eq 1 ]; then + MODEL_ID="$INSTALLER_MODEL_ID" + else + MODEL_ID="${VESTIGE_SANHEDRIN_MODEL:-$MODEL_ID}" + fi + if [ "$DASHBOARD_PORT_FROM_INSTALLER" -eq 1 ]; then + DASHBOARD_PORT="$INSTALLER_DASHBOARD_PORT" + else + DASHBOARD_PORT="${VESTIGE_DASHBOARD_PORT:-$DASHBOARD_PORT}" + fi + if [ "$SANHEDRIN_ENDPOINT_FROM_INSTALLER" -eq 1 ]; then + SANHEDRIN_ENDPOINT="$INSTALLER_SANHEDRIN_ENDPOINT" + else + SANHEDRIN_ENDPOINT="${VESTIGE_SANHEDRIN_ENDPOINT:-$SANHEDRIN_ENDPOINT}" + SANHEDRIN_ENDPOINT="${SANHEDRIN_ENDPOINT%/}" + fi + SANHEDRIN_MODELS_URL="" + [ -n "$SANHEDRIN_ENDPOINT" ] && SANHEDRIN_MODELS_URL="${SANHEDRIN_ENDPOINT%/chat/completions}/models" + if [ "$SANHEDRIN_CLAIM_MODE_FROM_INSTALLER" -eq 1 ]; then + SANHEDRIN_CLAIM_MODE="$INSTALLER_SANHEDRIN_CLAIM_MODE" + else + SANHEDRIN_CLAIM_MODE="${VESTIGE_SANHEDRIN_CLAIM_MODE:-$SANHEDRIN_CLAIM_MODE}" + fi + if [ "$SANHEDRIN_OUTPUT_FROM_INSTALLER" -eq 1 ]; then + SANHEDRIN_OUTPUT="$INSTALLER_SANHEDRIN_OUTPUT" + else + SANHEDRIN_OUTPUT="${VESTIGE_SANHEDRIN_OUTPUT:-$SANHEDRIN_OUTPUT}" + fi + fi + if [ "$WITH_LAUNCHD" -eq 0 ] \ + && [ "$MODEL_ID_FROM_INSTALLER" -eq 0 ] \ + && [ "$SANHEDRIN_ENDPOINT_FROM_INSTALLER" -eq 0 ] \ + && [ "$MODEL_ID" = "mlx-community/Qwen3.6-35B-A3B-4bit" ] \ + && [ "$SANHEDRIN_ENDPOINT" = "http://127.0.0.1:8080/v1/chat/completions" ]; then + MODEL_ID="" + SANHEDRIN_ENDPOINT="" + SANHEDRIN_MODELS_URL="" + warn "Cleared legacy implicit MLX/Qwen Sanhedrin default. Choose a preset or set VESTIGE_SANHEDRIN_ENDPOINT and VESTIGE_SANHEDRIN_MODEL." + fi + TMP_ENV="$(mktemp)" + if [ -f "$SANHEDRIN_ENV" ]; then + awk -F= ' + $1 !~ /^(VESTIGE_SANHEDRIN_ENABLED|VESTIGE_SANHEDRIN_ENDPOINT|VESTIGE_SANHEDRIN_MODEL|VESTIGE_DASHBOARD_PORT|VESTIGE_SANHEDRIN_CLAIM_MODE|VESTIGE_SANHEDRIN_OUTPUT)$/ + ' "$SANHEDRIN_ENV" > "$TMP_ENV" + fi { + cat "$TMP_ENV" printf 'VESTIGE_SANHEDRIN_ENABLED=1\n' printf 'VESTIGE_SANHEDRIN_ENDPOINT=%s\n' "$(quote_env "$SANHEDRIN_ENDPOINT")" printf 'VESTIGE_SANHEDRIN_MODEL=%s\n' "$(quote_env "$MODEL_ID")" printf 'VESTIGE_DASHBOARD_PORT=%s\n' "$(quote_env "$DASHBOARD_PORT")" + printf 'VESTIGE_SANHEDRIN_CLAIM_MODE=%s\n' "$(quote_env "$SANHEDRIN_CLAIM_MODE")" + printf 'VESTIGE_SANHEDRIN_OUTPUT=%s\n' "$(quote_env "$SANHEDRIN_OUTPUT")" } > "$SANHEDRIN_ENV" + rm -f "$TMP_ENV" chmod 0600 "$SANHEDRIN_ENV" say "Sanhedrin opt-in config written to $SANHEDRIN_ENV" fi @@ -262,7 +397,8 @@ cat < --sanhedrin-model= + ./scripts/install-sandwich.sh --enable-sanhedrin --with-launchd # explicit MLX/Qwen path On Apple Silicon with >20 GB free RAM, add --with-launchd to auto-start the local MLX Qwen server. On x86, point --sanhedrin-endpoint at vLLM, Ollama, llama.cpp, or another OpenAI-compatible /v1/chat/completions URL. diff --git a/server.json b/server.json index 995f685..2b9a927 100644 --- a/server.json +++ b/server.json @@ -2,17 +2,17 @@ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", "name": "io.github.samvallad33/vestige", "title": "Vestige", - "description": "Local-first cognitive memory server for AI agents with SQLite, smart ingest, and portable sync.", + "description": "Local-first cognitive memory and accountability MCP server for AI agents. Uses local SQLite by default, exposes memory/search tools, and adds Receipt Lock to check verification claims against command receipts without requiring cloud services.", "repository": { "url": "https://github.com/samvallad33/vestige", "source": "github" }, - "version": "2.1.2", + "version": "2.1.25", "packages": [ { "registryType": "npm", "identifier": "vestige-mcp-server", - "version": "2.1.2", + "version": "2.1.25", "transport": { "type": "stdio" } diff --git a/tests/e2e/tests/cognitive/comparative_benchmarks.rs b/tests/e2e/tests/cognitive/comparative_benchmarks.rs index bac2582..52fffed 100644 --- a/tests/e2e/tests/cognitive/comparative_benchmarks.rs +++ b/tests/e2e/tests/cognitive/comparative_benchmarks.rs @@ -14,18 +14,18 @@ //! - Synaptic Tagging: Frey & Morris (1997), Redondo & Morris (2011) //! - Hippocampal Indexing: Teyler & Rudy (2007) -use chrono::{DateTime, Duration, Utc}; +use chrono::{Duration, Utc}; use std::collections::{HashMap, HashSet}; use vestige_core::neuroscience::hippocampal_index::{ BarcodeGenerator, ContentPointer, ContentType, HippocampalIndex, HippocampalIndexConfig, - INDEX_EMBEDDING_DIM, IndexQuery, MemoryBarcode, MemoryIndex, + INDEX_EMBEDDING_DIM, IndexQuery, MemoryBarcode, }; use vestige_core::neuroscience::spreading_activation::{ - ActivatedMemory, ActivationConfig, ActivationNetwork, LinkType, + ActivationConfig, ActivationNetwork, LinkType, }; use vestige_core::neuroscience::synaptic_tagging::{ - CaptureWindow, DecayFunction, ImportanceEvent, ImportanceEventType, SynapticTaggingConfig, + CaptureWindow, ImportanceEvent, ImportanceEventType, SynapticTaggingConfig, SynapticTaggingSystem, }; @@ -53,6 +53,7 @@ impl Default for SM2State { /// SM-2 grade (0-5) #[derive(Debug, Clone, Copy)] +#[allow(dead_code)] enum SM2Grade { CompleteBlackout = 0, Incorrect = 1, @@ -142,6 +143,7 @@ impl Default for FSRS6State { /// FSRS-6 grade (1-4) #[derive(Debug, Clone, Copy)] +#[allow(dead_code)] enum FSRS6Grade { Again = 1, Hard = 2, @@ -273,6 +275,7 @@ fn leitner_review(state: &LeitnerState, correct: bool) -> LeitnerState { // ============================================================================ /// Fixed interval - always reviews at same interval +#[allow(dead_code)] fn fixed_interval_schedule(_correct: bool) -> i32 { 7 // Always 7 days } @@ -409,7 +412,7 @@ fn test_fsrs6_vs_sm2_retention_same_reviews() { // FSRS-6: Same number of reviews let mut fsrs_state = FSRS6State::default(); let mut total_elapsed = 0.0; - for i in 0..TOTAL_REVIEWS { + for _ in 0..TOTAL_REVIEWS { let interval = fsrs6_interval(fsrs_state.stability, 0.9, FSRS6_WEIGHTS[20]).max(1); total_elapsed += interval as f64; fsrs_state = fsrs6_review(&fsrs_state, FSRS6Grade::Good, interval as f64); @@ -426,6 +429,11 @@ fn test_fsrs6_vs_sm2_retention_same_reviews() { "FSRS-6 should maintain high retention: {:.2}%", fsrs_retention * 100.0 ); + assert!(sm2_retention > 0.0, "SM-2 retention should be positive"); + assert!( + total_elapsed > 0.0, + "FSRS review elapsed time should accumulate" + ); } /// Test that FSRS-6 achieves better retention efficiency over time. @@ -442,7 +450,8 @@ fn test_fsrs6_vs_sm2_reviews_same_retention() { // SM-2: Interval growth is linear with EF // After n successful reviews: interval ≈ previous * 2.5 - let sm2_intervals = vec![1, 6, 15, 38, 95]; // Approximate SM-2 progression + let sm2_intervals = [1, 6, 15, 38, 95]; // Approximate SM-2 progression + let sm2_final_interval = sm2_intervals[sm2_intervals.len() - 1]; // FSRS-6: Stability grows based on forgetting curve parameters // This allows for more nuanced interval optimization @@ -467,6 +476,10 @@ fn test_fsrs6_vs_sm2_reviews_same_retention() { "FSRS-6 should produce positive intervals: {}", fsrs_final_interval ); + assert!( + sm2_final_interval > 0, + "SM-2 comparison interval should be positive" + ); // Test that stability has grown from initial value assert!( @@ -545,6 +558,12 @@ fn test_fsrs6_vs_fixed_interval() { final_interval, FIXED_INTERVAL ); + assert!( + fsrs_reviews <= fixed_reviews, + "FSRS-6 should need no more reviews than fixed interval: {} <= {}", + fsrs_reviews, + fixed_reviews + ); } /// Test that FSRS-6 beats Leitner box system. @@ -590,6 +609,12 @@ fn test_fsrs6_vs_leitner() { fsrs_final_interval, leitner_max_interval ); + assert!( + fsrs_reviews <= leitner_reviews, + "FSRS-6 should need no more reviews than Leitner: {} <= {}", + fsrs_reviews, + leitner_reviews + ); } /// Test that personalized w20 parameter improves FSRS-6 results. diff --git a/tests/e2e/tests/cognitive/dreams_tests.rs b/tests/e2e/tests/cognitive/dreams_tests.rs index 25084b1..7506699 100644 --- a/tests/e2e/tests/cognitive/dreams_tests.rs +++ b/tests/e2e/tests/cognitive/dreams_tests.rs @@ -790,9 +790,9 @@ async fn test_consolidation_connection_strengthening() { let _ = conn_stats.total_memories; } - // Both cycles should complete successfully - verify duration is tracked + // Both cycles should complete successfully and record monotonically. assert!( - first_report.duration_ms > 0 || second_report.duration_ms > 0 || true, + second_report.completed_at >= first_report.completed_at, "Both consolidation cycles should complete" ); } diff --git a/tests/e2e/tests/extreme/adversarial_tests.rs b/tests/e2e/tests/extreme/adversarial_tests.rs index c179d65..6fa1fc2 100644 --- a/tests/e2e/tests/extreme/adversarial_tests.rs +++ b/tests/e2e/tests/extreme/adversarial_tests.rs @@ -67,7 +67,7 @@ fn test_adversarial_empty_inputs() { let _ = whitespace_results.len(); // System should still work with normal nodes - let normal_results = network.activate("source", 1.0); + let _normal_results = network.activate("source", 1.0); assert!( network.node_count() >= 2, "Network should contain normal nodes" @@ -323,7 +323,7 @@ fn test_adversarial_config_boundaries() { low_decay_net.add_edge("a".to_string(), "b".to_string(), LinkType::Semantic, 0.9); low_decay_net.add_edge("b".to_string(), "c".to_string(), LinkType::Semantic, 0.9); - let low_results = low_decay_net.activate("a", 1.0); + let _low_results = low_decay_net.activate("a", 1.0); // With 0.01 decay, activation drops to 0.9 * 0.01 = 0.009 after one hop // Then 0.009 * 0.9 * 0.01 = 0.000081 after two hops (below most thresholds) @@ -411,7 +411,7 @@ fn test_adversarial_cyclic_graphs() { cycle_net.add_edge("c".to_string(), "a".to_string(), LinkType::Semantic, 0.9); let start = std::time::Instant::now(); - let cycle_results = cycle_net.activate("a", 1.0); + let _cycle_results = cycle_net.activate("a", 1.0); let duration = start.elapsed(); // Should still complete quickly @@ -487,7 +487,7 @@ fn test_adversarial_special_numeric_values() { // (The implementation should clamp or validate these) // Test with 0.0 activation (should produce no results or minimal) - let zero_results = network.activate("normal", 0.0); + let _zero_results = network.activate("normal", 0.0); // Might be empty or have very low activation // Test with very small activation diff --git a/tests/e2e/tests/extreme/chaos_tests.rs b/tests/e2e/tests/extreme/chaos_tests.rs index 5811f6a..5493540 100644 --- a/tests/e2e/tests/extreme/chaos_tests.rs +++ b/tests/e2e/tests/extreme/chaos_tests.rs @@ -144,6 +144,13 @@ fn test_chaos_add_remove_cycles() { // Stable structure should be preserved (edges reinforced) let stable_edge_count = network.edge_count(); + let stable_node_count = network.node_count(); + assert!( + stable_node_count >= initial_node_count, + "Stable nodes should be preserved: {} >= {}", + stable_node_count, + initial_node_count + ); assert!( stable_edge_count >= initial_edge_count, "Stable edges should be preserved: {} >= {}", @@ -450,8 +457,8 @@ fn test_chaos_ancient_memories() { // System should handle this gracefully assert!( - result.captured_count() >= 0, - "System should handle importance triggering" + result.captured_count() <= 3, + "Importance triggering should stay bounded by active memories" ); // All memories should be accessible diff --git a/tests/e2e/tests/extreme/proof_of_superiority.rs b/tests/e2e/tests/extreme/proof_of_superiority.rs index a63acc1..20a86c7 100644 --- a/tests/e2e/tests/extreme/proof_of_superiority.rs +++ b/tests/e2e/tests/extreme/proof_of_superiority.rs @@ -373,7 +373,7 @@ fn test_proof_hippocampal_indexing_efficiency() { bf_results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap()); bf_results.truncate(10); - let bf_duration = bf_start.elapsed(); + let _bf_duration = bf_start.elapsed(); // === PROOF OF EFFICIENCY === @@ -566,7 +566,7 @@ fn test_proof_comprehensive_capability_summary() { // === CAPABILITY 4: Asymmetric Temporal Windows === // Traditional: NO temporal reasoning | Vestige: Biologically-grounded windows - let window = CaptureWindow::new(9.0, 2.0); + let _window = CaptureWindow::new(9.0, 2.0); let asymmetric = 9.0 / 2.0; assert!( asymmetric > 4.0, diff --git a/tests/e2e/tests/extreme/research_validation_tests.rs b/tests/e2e/tests/extreme/research_validation_tests.rs index 11006c9..bef12cd 100644 --- a/tests/e2e/tests/extreme/research_validation_tests.rs +++ b/tests/e2e/tests/extreme/research_validation_tests.rs @@ -303,7 +303,7 @@ fn test_research_frey_morris_synaptic_tagging() { /// theory and episodic memory: updating the index. Hippocampus, 17(12), 1158-1169. #[test] fn test_research_teyler_rudy_hippocampal_indexing() { - let config = HippocampalIndexConfig::default(); + let _config = HippocampalIndexConfig::default(); let index = HippocampalIndex::new(); let now = Utc::now(); diff --git a/tests/e2e/tests/journeys/consolidation_workflow.rs b/tests/e2e/tests/journeys/consolidation_workflow.rs index 633c5a3..7229b13 100644 --- a/tests/e2e/tests/journeys/consolidation_workflow.rs +++ b/tests/e2e/tests/journeys/consolidation_workflow.rs @@ -38,6 +38,7 @@ fn make_dream_memory(id: &str, content: &str, tags: Vec<&str>) -> DreamMemory { } /// Create a memory with specific age +#[allow(dead_code)] fn make_aged_memory(id: &str, content: &str, tags: Vec<&str>, hours_ago: i64) -> DreamMemory { DreamMemory { id: id.to_string(), @@ -50,6 +51,7 @@ fn make_aged_memory(id: &str, content: &str, tags: Vec<&str>, hours_ago: i64) -> } /// Create a memory with access count +#[allow(dead_code)] fn make_accessed_memory( id: &str, content: &str, @@ -391,14 +393,14 @@ fn test_connection_graph_decay_and_pruning() { graph.apply_decay(0.5); // Prune weak connections - let pruned = graph.prune_weak(0.2); + let _pruned = graph.prune_weak(0.2); // Weak connection (0.3 * 0.5 = 0.15) should be pruned // The pruned count depends on implementation details let stats = graph.get_stats(); assert!( - stats.total_connections >= 0, - "Should have non-negative connections after pruning" + stats.total_connections <= 3, + "Pruning should not increase connection count" ); } diff --git a/tests/e2e/tests/journeys/ingest_recall_review.rs b/tests/e2e/tests/journeys/ingest_recall_review.rs index cecb5b8..3f81585 100644 --- a/tests/e2e/tests/journeys/ingest_recall_review.rs +++ b/tests/e2e/tests/journeys/ingest_recall_review.rs @@ -106,13 +106,10 @@ fn test_recall_finds_memories_by_content() { assert_eq!(recall.min_retention, 0.5); // Verify search mode - match recall.search_mode { - SearchMode::Keyword => { - // Keyword search uses FTS5 - assert!(true, "Keyword mode should be supported"); - } - _ => panic!("Expected Keyword search mode"), - } + assert!( + matches!(&recall.search_mode, SearchMode::Keyword), + "Expected Keyword search mode" + ); } // ============================================================================ @@ -218,15 +215,10 @@ fn test_memory_lifecycle_follows_expected_pattern() { ); // Verify state is Review (mature) - match state.state { - LearningState::Review => { - assert!(true, "Mature memory should be in Review state"); - } - _ => { - // Also acceptable - depends on FSRS parameters - assert!(state.reps >= 10, "Should have processed all reviews"); - } - } + assert!( + matches!(&state.state, LearningState::Review) || state.reps >= 10, + "Mature memory should be in Review or have processed all reviews" + ); } // ============================================================================ diff --git a/tests/e2e/tests/journeys/intentions_workflow.rs b/tests/e2e/tests/journeys/intentions_workflow.rs index f0530ed..20102c4 100644 --- a/tests/e2e/tests/journeys/intentions_workflow.rs +++ b/tests/e2e/tests/journeys/intentions_workflow.rs @@ -91,7 +91,7 @@ fn test_debugging_intent_detection() { match &result.primary_intent { DetectedIntent::Debugging { suspected_area, - symptoms, + symptoms: _, } => { assert!(!suspected_area.is_empty(), "Should identify suspected area"); // Symptoms may or may not be captured depending on action order @@ -125,7 +125,7 @@ fn test_learning_intent_detection() { // Should detect learning with high confidence match &result.primary_intent { - DetectedIntent::Learning { topic, level } => { + DetectedIntent::Learning { topic, level: _ } => { assert!(!topic.is_empty(), "Should identify learning topic"); // Level may vary } @@ -137,7 +137,7 @@ fn test_learning_intent_detection() { } // Verify relevant tags - let tags = result.primary_intent.relevant_tags(); + let _tags = result.primary_intent.relevant_tags(); // Tags depend on detected intent type } @@ -173,9 +173,10 @@ fn test_refactoring_intent_detection() { related_components, .. } => { // Multiple edits could also suggest new feature + let _ = related_components; assert!( - related_components.len() >= 0, - "Should track related components" + result.confidence > 0.0, + "New feature intent should have positive confidence" ); } _ => { diff --git a/tests/hooks/test_sanhedrin_claim_mode.py b/tests/hooks/test_sanhedrin_claim_mode.py new file mode 100644 index 0000000..4ff1560 --- /dev/null +++ b/tests/hooks/test_sanhedrin_claim_mode.py @@ -0,0 +1,728 @@ +import contextlib +import importlib.util +import io +import json +import os +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + + +REPO_ROOT = Path(__file__).resolve().parents[2] +HOOK_PATH = REPO_ROOT / "hooks" / "sanhedrin-local.py" + + +def load_sanhedrin(): + spec = importlib.util.spec_from_file_location("sanhedrin_local_under_test", HOOK_PATH) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +@contextlib.contextmanager +def patched_attr(obj, name, value): + sentinel = object() + old = getattr(obj, name, sentinel) + setattr(obj, name, value) + try: + yield + finally: + if old is sentinel: + delattr(obj, name) + else: + setattr(obj, name, old) + + +class SanhedrinClaimModeTests(unittest.TestCase): + def setUp(self): + for key in ( + "VESTIGE_SANHEDRIN_CLAIM_MODE", + "VESTIGE_SANHEDRIN_OUTPUT", + "VESTIGE_SANHEDRIN_STAGE_FILE", + "VESTIGE_SANHEDRIN_TRANSCRIPT", + "VESTIGE_SANHEDRIN_ALLOW_COMMAND_LEDGER", + ): + os.environ.pop(key, None) + self.sanhedrin = load_sanhedrin() + self.sanhedrin.SANHEDRIN_ENDPOINT = "http://127.0.0.1:8080/v1/chat/completions" + self.sanhedrin.MODEL = "test-verifier" + + @contextlib.contextmanager + def isolated_receipt_state(self): + with tempfile.TemporaryDirectory() as tmp: + state_dir = Path(tmp) + core = self.sanhedrin.sanhedrin_core + with patched_attr(core, "STATE_DIR", state_dir), patched_attr( + core, "RECEIPTS_DIR", state_dir / "receipts" + ), patched_attr(core, "LATEST_JSON", state_dir / "latest.json"), patched_attr( + core, "LATEST_HTML", state_dir / "latest.html" + ), patched_attr( + core, "APPEALS_JSONL", state_dir / "appeals.jsonl" + ), patched_attr( + core, "COMMAND_RECEIPTS_JSONL", state_dir / "command-receipts.jsonl" + ): + yield state_dir + + def run_main(self, draft): + stdin = io.StringIO(draft) + stdout = io.StringIO() + with mock.patch.object(sys, "stdin", stdin), mock.patch.object(sys, "stdout", stdout): + self.sanhedrin.main() + return stdout.getvalue().strip() + + def test_runtime_has_no_implicit_verifier_model_default(self): + with mock.patch.dict(os.environ, {}, clear=True): + module = load_sanhedrin() + + self.assertEqual(module.SANHEDRIN_ENDPOINT, "") + self.assertEqual(module.MODEL, "") + + def test_receipt_lock_blocks_unbacked_test_claim(self): + with self.isolated_receipt_state() as state_dir: + out = self.run_main("All tests passed.") + + self.assertIn("Receipt Lock", out) + receipt = json.loads((state_dir / "latest.json").read_text(encoding="utf-8")) + + self.assertEqual(receipt["verdictBar"], "VETO") + self.assertEqual(receipt["claims"][0]["decision"], "veto") + self.assertEqual(receipt["claims"][0]["evidence_state"], "missing_receipt") + + def test_receipt_lock_allows_matching_success_receipt(self): + with self.isolated_receipt_state() as state_dir, mock.patch.dict( + os.environ, {"VESTIGE_SANHEDRIN_ALLOW_COMMAND_LEDGER": "1"}, clear=False + ): + (state_dir / "command-receipts.jsonl").write_text( + json.dumps({ + "command": "cargo test --workspace --release", + "exitCode": 0, + "success": True, + }) + "\n", + encoding="utf-8", + ) + out = self.run_main("All tests passed.") + receipt = json.loads((state_dir / "latest.json").read_text(encoding="utf-8")) + + self.assertEqual(out, "yes") + self.assertNotEqual(receipt["verdictBar"], "VETO") + self.assertEqual(receipt["claims"][0]["decision"], "pass") + + def test_receipt_lock_appeal_suppresses_same_fingerprint(self): + with self.isolated_receipt_state() as state_dir: + fingerprint = self.sanhedrin.sanhedrin_core.claim_fingerprint("All tests passed.") + (state_dir / "appeals.jsonl").write_text( + json.dumps({ + "claimFingerprint": fingerprint, + "reason": "too_strict", + "status": "active", + }) + "\n", + encoding="utf-8", + ) + out = self.run_main("All tests passed.") + receipt = json.loads((state_dir / "latest.json").read_text(encoding="utf-8")) + + self.assertEqual(out, "yes") + self.assertEqual(receipt["verdictBar"], "APPEALED") + self.assertEqual(receipt["claims"][0]["decision"], "appealed") + + def test_receipt_lock_ignores_quotes_fences_and_hedged_verification(self): + examples = [ + 'The user said "all tests passed" earlier.', + "> all tests passed\nI still need to verify this myself.", + "```text\nall tests passed\n```", + "I think the tests passed before, but let me verify.", + ] + for example in examples: + with self.subTest(example=example), self.isolated_receipt_state() as state_dir: + out = self.run_main(example) + self.assertEqual(out, "yes") + latest = state_dir / "latest.json" + if latest.exists(): + receipt = json.loads(latest.read_text(encoding="utf-8")) + self.assertNotEqual(receipt["verdictBar"], "VETO") + + def test_claim_mode_ignores_quoted_and_blockquoted_verification_text(self): + examples = [ + 'The user said "all tests passed" earlier.', + "> all tests passed\nI still need to verify this myself.", + "```text\nall tests passed\n```", + ] + env = {"VESTIGE_SANHEDRIN_CLAIM_MODE": "1", "VESTIGE_SANHEDRIN_OUTPUT": "json"} + with mock.patch.dict(os.environ, env, clear=False), patched_attr( + self.sanhedrin, "fetch_claim_evidence", lambda _claim: ([], True) + ): + for example in examples: + with self.subTest(example=example): + out = self.run_main(example) + result = json.loads(out) + + self.assertTrue(result["passed"], result) + + def test_receipt_lock_still_blocks_temporal_or_apostrophe_claims(self): + examples = [ + "All tests passed before I pushed the fix.", + "All tests passed earlier on the staging branch.", + "All tests passed last run.", + "Sam's tests passed today.", + ] + for example in examples: + with self.subTest(example=example), self.isolated_receipt_state() as state_dir: + out = self.run_main(example) + receipt = json.loads((state_dir / "latest.json").read_text(encoding="utf-8")) + + self.assertIn("Receipt Lock", out) + self.assertEqual(receipt["verdictBar"], "VETO") + + def test_loose_transcript_command_scan_is_disabled_by_default(self): + with self.isolated_receipt_state() as state_dir: + transcript = state_dir / "transcript.jsonl" + transcript.write_text( + json.dumps({ + "role": "assistant", + "message": { + "content": 'I will not run it, but here is {"command":"cargo test","exit_code":0}.' + }, + }) + "\n", + encoding="utf-8", + ) + with mock.patch.dict(os.environ, {"VESTIGE_SANHEDRIN_TRANSCRIPT": str(transcript)}, clear=False): + out = self.run_main("All tests passed.") + receipt = json.loads((state_dir / "latest.json").read_text(encoding="utf-8")) + + self.assertIn("Receipt Lock", out) + self.assertEqual(receipt["verdictBar"], "VETO") + self.assertEqual(receipt["receipts"], []) + + def test_plain_sam_biographical_achievement_claim_is_check_worthy(self): + claims = self.sanhedrin.extract_check_worthy_claims( + "Sam graduated from Example University and won the Example AI Challenge." + ) + + self.assertGreaterEqual(len(claims), 1) + self.assertTrue(any(claim.sam_critical for claim in claims)) + self.assertTrue( + any(claim.claim_class in {"BIOGRAPHICAL", "ACHIEVEMENT"} for claim in claims) + ) + self.assertTrue(any("Sam" in claim.text for claim in claims)) + + def test_zero_high_trust_evidence_on_sam_critical_claim_blocks(self): + def fail_if_judge_is_called(_claim, _evidence): + self.fail("zero-evidence absence decisions should not require model judgment") + + env = {"VESTIGE_SANHEDRIN_CLAIM_MODE": "1", "VESTIGE_SANHEDRIN_OUTPUT": "json"} + with mock.patch.dict(os.environ, env, clear=False), patched_attr( + self.sanhedrin, "fetch_claim_evidence", lambda _claim: ([], True) + ), patched_attr(self.sanhedrin, "judge_claim_with_model", fail_if_judge_is_called): + out = self.run_main("Sam won first place at the Example AI Challenge.") + + result = json.loads(out) + self.assertFalse(result["passed"]) + self.assertTrue(result["legacy_verdict"].startswith("no - "), result) + self.assertEqual(result["verdicts"][0]["status"], "REFUTED_BY_ABSENCE") + + def test_missing_model_configuration_fails_open_except_receipt_lock(self): + env = { + "VESTIGE_SANHEDRIN_CLAIM_MODE": "1", + "VESTIGE_SANHEDRIN_OUTPUT": "json", + } + with mock.patch.dict(os.environ, env, clear=False), patched_attr( + self.sanhedrin, "SANHEDRIN_ENDPOINT", "" + ), patched_attr(self.sanhedrin, "MODEL", ""), patched_attr( + self.sanhedrin, "fetch_claim_evidence", lambda _claim: ([], True) + ): + out = self.run_main("Sam attended Example University.") + + result = json.loads(out) + self.assertTrue(result["passed"], result) + self.assertEqual(result["verdicts"][0]["status"], "NEI") + self.assertIn("model not configured", result["verdicts"][0]["reason"]) + + def test_vague_user_positive_claim_fails_closed(self): + env = {"VESTIGE_SANHEDRIN_CLAIM_MODE": "1", "VESTIGE_SANHEDRIN_OUTPUT": "json"} + with mock.patch.dict(os.environ, env, clear=False), patched_attr( + self.sanhedrin, "fetch_claim_evidence", lambda _claim: ([], True) + ): + out = self.run_main("Sam won a few competitions and earned some prize money.") + + result = json.loads(out) + self.assertFalse(result["passed"], result) + self.assertEqual(result["verdicts"][0]["claim"]["claim_class"], "VAGUE-QUANTIFIER") + self.assertEqual(result["verdicts"][0]["status"], "REFUTED_BY_ABSENCE") + + def test_retrieval_failure_on_sam_critical_claim_fails_open(self): + def fail_if_judge_is_called(_claim, _evidence): + self.fail("retrieval failures should fail open before model judgment") + + env = {"VESTIGE_SANHEDRIN_CLAIM_MODE": "1", "VESTIGE_SANHEDRIN_OUTPUT": "json"} + with mock.patch.dict(os.environ, env, clear=False), patched_attr( + self.sanhedrin, "fetch_claim_evidence", lambda _claim: ([], False) + ), patched_attr(self.sanhedrin, "judge_claim_with_model", fail_if_judge_is_called): + out = self.run_main("Sam won first place at the Example AI Challenge.") + + result = json.loads(out) + self.assertTrue(result["passed"], result) + self.assertEqual(result["legacy_verdict"], "yes") + self.assertEqual(result["verdicts"][0]["status"], "NEI") + self.assertIn("retrieval unavailable", result["verdicts"][0]["reason"]) + + def test_current_turn_attribution_discourse_is_not_absence_blocked(self): + env = {"VESTIGE_SANHEDRIN_CLAIM_MODE": "1", "VESTIGE_SANHEDRIN_OUTPUT": "json"} + with mock.patch.dict(os.environ, env, clear=False), patched_attr( + self.sanhedrin, "fetch_claim_evidence", lambda _claim: ([], True) + ): + out = self.run_main( + "You asked me to audit the Sanhedrin hook, and I reviewed your requested changes." + ) + + result = json.loads(out) + self.assertTrue(result["passed"], result) + self.assertEqual(result["legacy_verdict"], "yes") + self.assertEqual(result["claims_extracted"], 0) + + def test_discourse_framing_does_not_hide_embedded_sam_claim(self): + examples = [ + "Per your request, Sam won first place at the Example AI Challenge.", + "Sam won first place at the Example AI Challenge, which would be impressive.", + ] + env = {"VESTIGE_SANHEDRIN_CLAIM_MODE": "1", "VESTIGE_SANHEDRIN_OUTPUT": "json"} + for example in examples: + with self.subTest(example=example), mock.patch.dict(os.environ, env, clear=False), patched_attr( + self.sanhedrin, "fetch_claim_evidence", lambda _claim: ([], True) + ): + out = self.run_main(example) + + result = json.loads(out) + self.assertFalse(result["passed"], result) + self.assertEqual(result["verdicts"][0]["status"], "REFUTED_BY_ABSENCE") + self.assertIn("Sam won", result["verdicts"][0]["claim"]["text"]) + + def test_leading_hypothetical_still_skips_embedded_claim(self): + env = {"VESTIGE_SANHEDRIN_CLAIM_MODE": "1", "VESTIGE_SANHEDRIN_OUTPUT": "json"} + with mock.patch.dict(os.environ, env, clear=False), patched_attr( + self.sanhedrin, "fetch_claim_evidence", lambda _claim: ([], True) + ): + out = self.run_main("If Sam wins first place next time, he could claim the prize.") + + result = json.loads(out) + self.assertTrue(result["passed"], result) + self.assertEqual(result["claims_extracted"], 0) + + def test_subject_modal_prefix_skips_without_hiding_asserted_claim(self): + env = {"VESTIGE_SANHEDRIN_CLAIM_MODE": "1", "VESTIGE_SANHEDRIN_OUTPUT": "json"} + with mock.patch.dict(os.environ, env, clear=False), patched_attr( + self.sanhedrin, "fetch_claim_evidence", lambda _claim: ([], True) + ): + nonassertive = self.run_main("Sam could win first place next time.") + asserted = self.run_main( + "Sam won first place at the Example AI Challenge and could collect prize money." + ) + + nonassertive_result = json.loads(nonassertive) + asserted_result = json.loads(asserted) + self.assertTrue(nonassertive_result["passed"], nonassertive_result) + self.assertEqual(nonassertive_result["claims_extracted"], 0) + self.assertFalse(asserted_result["passed"], asserted_result) + self.assertEqual(asserted_result["verdicts"][0]["status"], "REFUTED_BY_ABSENCE") + + def test_malformed_deep_reference_response_fails_open(self): + def fail_if_judge_is_called(_claim, _evidence): + self.fail("malformed retrieval responses should fail open before model judgment") + + env = {"VESTIGE_SANHEDRIN_CLAIM_MODE": "1", "VESTIGE_SANHEDRIN_OUTPUT": "json"} + for response in ({}, {"status": "error"}, {"errors": ["timeout"]}): + with self.subTest(response=response): + def fake_post_json(_url, _body, _timeout): + return response + + with mock.patch.dict(os.environ, env, clear=False), patched_attr( + self.sanhedrin, "post_json", fake_post_json + ), patched_attr(self.sanhedrin, "judge_claim_with_model", fail_if_judge_is_called): + out = self.run_main("Sam won first place at the Example AI Challenge.") + + result = json.loads(out) + self.assertTrue(result["passed"], result) + self.assertEqual(result["verdicts"][0]["status"], "NEI") + self.assertIn("retrieval unavailable", result["verdicts"][0]["reason"]) + + def test_non_critical_technical_zero_evidence_does_not_block(self): + def fail_if_judge_is_called(_claim, _evidence): + self.fail("zero-evidence technical claims should fail open without model judgment") + + env = {"VESTIGE_SANHEDRIN_CLAIM_MODE": "1", "VESTIGE_SANHEDRIN_OUTPUT": "json"} + with mock.patch.dict(os.environ, env, clear=False), patched_attr( + self.sanhedrin, "fetch_claim_evidence", lambda _claim: ([], True) + ), patched_attr(self.sanhedrin, "judge_claim_with_model", fail_if_judge_is_called): + out = self.run_main( + "Qwen3.6-35B can be served through an OpenAI-compatible chat endpoint." + ) + + result = json.loads(out) + self.assertTrue(result["passed"]) + self.assertEqual(result["legacy_verdict"], "yes") + self.assertEqual(result["verdicts"][0]["status"], "NEI") + self.assertEqual(result["verdicts"][0]["claim"]["claim_class"], "TECHNICAL") + + def test_claim_sampling_keeps_late_high_severity_claim(self): + technical = " ".join( + f"The /tmp/example_{i}.py script calls the MCP endpoint successfully." + for i in range(12) + ) + claims = self.sanhedrin.extract_check_worthy_claims( + f"{technical} Sam won first place at the Example AI Challenge." + ) + + self.assertLessEqual(len(claims), self.sanhedrin.MAX_CLAIMS) + self.assertTrue( + any( + claim.sam_critical and claim.claim_class == "ACHIEVEMENT" + for claim in claims + ), + claims, + ) + + def test_fetch_evidence_truncates_on_python_character_boundary(self): + emoji_out = self.sanhedrin.truncate_chars(("a" * 4) + "🙂" + "tail", 8) + combining_out = self.sanhedrin.truncate_chars("Cafe\u0301 tail", 8) + + self.assertEqual(emoji_out, "aaaa🙂...") + self.assertEqual(combining_out, "Cafe...") + self.assertNotIn("\ufffd", emoji_out + combining_out) + self.assertFalse(self.sanhedrin.unicodedata.combining(combining_out[-4])) + (emoji_out + combining_out).encode("utf-8") + + def test_staged_evidence_is_used_without_smart_ingest_or_durable_write(self): + with tempfile.TemporaryDirectory() as tmp: + staged_path = Path(tmp) / "sanhedrin-staged-evidence.json" + staged = [ + { + "id": "samstage2", + "role": "memory", + "trust": 0.89, + "preview": "Sam's final result was second place with no payout.", + } + ] + staged_path.write_text(json.dumps(staged), encoding="utf-8") + + post_urls = [] + + def fake_post_json(url, body, _timeout): + post_urls.append(url) + if "smart_ingest" in url or "/api/memories" in url: + self.fail(f"staged evidence path attempted durable write to {url}: {body}") + self.assertEqual(url, "http://127.0.0.1:3927/api/deep_reference") + return {"confidence": 0.0, "evidence": []} + + env = { + "VESTIGE_SANHEDRIN_CLAIM_MODE": "1", + "VESTIGE_SANHEDRIN_OUTPUT": "json", + "VESTIGE_SANHEDRIN_STAGE_FILE": str(staged_path), + } + with mock.patch.dict(os.environ, env, clear=False), patched_attr( + self.sanhedrin, "post_json", fake_post_json + ), patched_attr(self.sanhedrin, "VESTIGE_ENDPOINT", "http://127.0.0.1:3927/api/deep_reference"): + out = self.run_main("Sam won first place and earned prize money.") + + result = json.loads(out) + verdict = result["verdicts"][0] + self.assertFalse(result["passed"], result) + self.assertEqual(result["staged_evidence_count"], 1) + self.assertEqual(verdict["status"], "REFUTED_BY_ABSENCE") + self.assertEqual(verdict["durable_evidence_count"], 0) + self.assertEqual(verdict["high_trust_evidence_count"], 1) + self.assertEqual(post_urls, ["http://127.0.0.1:3927/api/deep_reference"]) + + def test_staged_only_refuted_verdict_is_downgraded_without_durable_evidence(self): + with tempfile.TemporaryDirectory() as tmp: + staged_path = Path(tmp) / "sanhedrin-staged-evidence.json" + staged_path.write_text( + json.dumps( + [ + { + "id": "stage-tech", + "trust": 0.95, + "preview": "Qwen3.6-35B cannot be served through a chat endpoint.", + } + ] + ), + encoding="utf-8", + ) + + def fake_post_json(url, _body, _timeout): + if url == self.sanhedrin.VESTIGE_ENDPOINT: + return {"confidence": 0.0, "evidence": []} + if url == self.sanhedrin.SANHEDRIN_ENDPOINT: + return { + "choices": [ + { + "message": { + "content": json.dumps( + { + "status": "REFUTED", + "class": "TECHNICAL", + "reason": "Staged evidence contradicts the claim.", + "evidence_ids": ["stage-tech"], + } + ) + } + } + ] + } + self.fail(f"unexpected post_json URL: {url}") + + env = { + "VESTIGE_SANHEDRIN_CLAIM_MODE": "1", + "VESTIGE_SANHEDRIN_OUTPUT": "json", + "VESTIGE_SANHEDRIN_STAGE_FILE": str(staged_path), + } + with mock.patch.dict(os.environ, env, clear=False), patched_attr( + self.sanhedrin, "post_json", fake_post_json + ), patched_attr( + self.sanhedrin, "SANHEDRIN_ENDPOINT", "http://127.0.0.1:8080/v1/chat/completions" + ), patched_attr(self.sanhedrin, "MODEL", "test-verifier"): + out = self.run_main( + "Qwen3.6-35B can be served through an OpenAI-compatible chat endpoint." + ) + + result = json.loads(out) + verdict = result["verdicts"][0] + self.assertTrue(result["passed"], result) + self.assertEqual(verdict["status"], "NEI") + self.assertEqual(verdict["durable_evidence_count"], 0) + self.assertIn("Durable evidence required", verdict["reason"]) + + def test_staged_only_supported_verdict_is_downgraded_without_durable_evidence(self): + with tempfile.TemporaryDirectory() as tmp: + staged_path = Path(tmp) / "sanhedrin-staged-evidence.json" + staged_path.write_text( + json.dumps( + [ + { + "id": "stage-tech", + "trust": 0.95, + "preview": "Qwen3.6-35B can be served through a chat endpoint.", + } + ] + ), + encoding="utf-8", + ) + + def fake_post_json(url, _body, _timeout): + if url == self.sanhedrin.VESTIGE_ENDPOINT: + return {"confidence": 0.0, "evidence": []} + if url == self.sanhedrin.SANHEDRIN_ENDPOINT: + return { + "choices": [ + { + "message": { + "content": json.dumps( + { + "status": "SUPPORTED", + "class": "TECHNICAL", + "reason": "Staged evidence supports the claim.", + "evidence_ids": ["stage-tech"], + } + ) + } + } + ] + } + self.fail(f"unexpected post_json URL: {url}") + + env = { + "VESTIGE_SANHEDRIN_CLAIM_MODE": "1", + "VESTIGE_SANHEDRIN_OUTPUT": "json", + "VESTIGE_SANHEDRIN_STAGE_FILE": str(staged_path), + } + with mock.patch.dict(os.environ, env, clear=False), patched_attr( + self.sanhedrin, "post_json", fake_post_json + ), patched_attr( + self.sanhedrin, "SANHEDRIN_ENDPOINT", "http://127.0.0.1:8080/v1/chat/completions" + ), patched_attr(self.sanhedrin, "MODEL", "test-verifier"): + out = self.run_main( + "Qwen3.6-35B can be served through an OpenAI-compatible chat endpoint." + ) + + result = json.loads(out) + verdict = result["verdicts"][0] + self.assertTrue(result["passed"], result) + self.assertEqual(verdict["status"], "NEI") + self.assertEqual(verdict["durable_evidence_count"], 0) + self.assertIn("Durable evidence required", verdict["reason"]) + + def test_supported_verdict_with_durable_evidence_is_preserved(self): + evidence = [ + self.sanhedrin.EvidenceItem( + id="mem-durable", + preview="A reliable memory says this backend can use a compatible endpoint.", + trust=0.95, + durable=True, + source="vestige", + ) + ] + claim = self.sanhedrin.Claim( + text="Qwen3.6-35B can be served through an OpenAI-compatible chat endpoint.", + claim_class="TECHNICAL", + source_index=0, + sam_critical=False, + ) + verdict = self.sanhedrin.validate_structured_verdict( + claim, + {"status": "SUPPORTED", "class": "TECHNICAL", "reason": "Evidence supports it."}, + evidence, + ) + + self.assertEqual(verdict.status, "SUPPORTED") + + def test_openai_key_is_not_forwarded_to_arbitrary_or_vestige_endpoints(self): + captured_headers = [] + + class FakeResponse: + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def read(self): + return b"{}" + + def fake_urlopen(req, timeout=None): + captured_headers.append(dict(req.header_items())) + return FakeResponse() + + env = {"OPENAI_API_KEY": "real-openai-key"} + with mock.patch.dict(os.environ, env, clear=False), patched_attr( + self.sanhedrin, "SANHEDRIN_ENDPOINT", "http://127.0.0.1:8080/v1/chat/completions" + ), mock.patch.object( + self.sanhedrin.urllib.request, "urlopen", fake_urlopen + ): + self.sanhedrin.post_json(self.sanhedrin.SANHEDRIN_ENDPOINT, {}, 1) + self.sanhedrin.post_json(self.sanhedrin.VESTIGE_ENDPOINT, {}, 1) + + self.assertTrue(captured_headers) + self.assertTrue(all("Authorization" not in headers for headers in captured_headers)) + + def test_sanhedrin_api_key_only_goes_to_configured_sanhedrin_endpoint(self): + captured_headers = [] + + class FakeResponse: + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def read(self): + return b"{}" + + def fake_urlopen(req, timeout=None): + captured_headers.append(dict(req.header_items())) + return FakeResponse() + + env = {"VESTIGE_SANHEDRIN_API_KEY": "sanhedrin-only-key"} + with mock.patch.dict(os.environ, env, clear=False), patched_attr( + self.sanhedrin, "SANHEDRIN_ENDPOINT", "http://127.0.0.1:8080/v1/chat/completions" + ), mock.patch.object( + self.sanhedrin.urllib.request, "urlopen", fake_urlopen + ): + self.sanhedrin.post_json(self.sanhedrin.SANHEDRIN_ENDPOINT, {}, 1) + self.sanhedrin.post_json(self.sanhedrin.VESTIGE_ENDPOINT, {}, 1) + + self.assertIn("Authorization", captured_headers[0]) + self.assertNotIn("Authorization", captured_headers[1]) + + def test_strict_openai_body_omits_backend_specific_fields(self): + with patched_attr(self.sanhedrin, "SANHEDRIN_BACKEND", "openai"): + body = self.sanhedrin.sanhedrin_body( + [{"role": "user", "content": "judge"}], + 128, + ) + + self.assertNotIn("top_k", body) + self.assertNotIn("seed", body) + self.assertNotIn("chat_template_kwargs", body) + + def test_mlx_body_keeps_backend_specific_fields(self): + with patched_attr(self.sanhedrin, "SANHEDRIN_BACKEND", "mlx"): + body = self.sanhedrin.sanhedrin_body( + [{"role": "user", "content": "judge"}], + 128, + ) + + self.assertEqual(body["top_k"], 1) + self.assertEqual(body["chat_template_kwargs"], {"enable_thinking": False}) + + def test_staged_only_legacy_refuted_line_is_downgraded_without_durable_evidence(self): + with tempfile.TemporaryDirectory() as tmp: + staged_path = Path(tmp) / "sanhedrin-staged-evidence.json" + staged_path.write_text( + json.dumps( + [ + { + "id": "stage-tech", + "trust": 0.95, + "preview": "Qwen3.6-35B cannot be served through a chat endpoint.", + } + ] + ), + encoding="utf-8", + ) + + def fake_post_json(url, _body, _timeout): + if url == self.sanhedrin.VESTIGE_ENDPOINT: + return {"confidence": 0.0, "evidence": []} + if url == self.sanhedrin.SANHEDRIN_ENDPOINT: + return { + "choices": [ + { + "message": { + "content": ( + "no - [Sanhedrin Veto] [TECHNICAL]: " + "Staged evidence contradicts the claim." + ) + } + } + ] + } + self.fail(f"unexpected post_json URL: {url}") + + env = { + "VESTIGE_SANHEDRIN_CLAIM_MODE": "1", + "VESTIGE_SANHEDRIN_OUTPUT": "json", + "VESTIGE_SANHEDRIN_STAGE_FILE": str(staged_path), + } + with mock.patch.dict(os.environ, env, clear=False), patched_attr( + self.sanhedrin, "post_json", fake_post_json + ), patched_attr( + self.sanhedrin, "SANHEDRIN_ENDPOINT", "http://127.0.0.1:8080/v1/chat/completions" + ), patched_attr(self.sanhedrin, "MODEL", "test-verifier"): + out = self.run_main( + "Qwen3.6-35B can be served through an OpenAI-compatible chat endpoint." + ) + + result = json.loads(out) + verdict = result["verdicts"][0] + self.assertTrue(result["passed"], result) + self.assertEqual(verdict["status"], "NEI") + self.assertEqual(verdict["durable_evidence_count"], 0) + self.assertIn("Durable evidence required", verdict["reason"]) + + def test_current_turn_discourse_patterns_are_not_claims(self): + examples = [ + "You asked for maximum subagents, so I audited the hook.", + "Your request was to verify the installer env preservation.", + "Per your request, I reviewed the Sanhedrin stop hook.", + "Sam asked me to go all in on the Sanhedrin patch.", + "The user requested maximum subagents for this implementation.", + ] + for example in examples: + with self.subTest(example=example): + self.assertEqual(self.sanhedrin.extract_check_worthy_claims(example), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/hooks/test_sanhedrin_shell_env.py b/tests/hooks/test_sanhedrin_shell_env.py new file mode 100644 index 0000000..5de950c --- /dev/null +++ b/tests/hooks/test_sanhedrin_shell_env.py @@ -0,0 +1,48 @@ +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] +SANHEDRIN_HOOK = REPO_ROOT / "hooks" / "sanhedrin.sh" + + +class SanhedrinShellEnvTests(unittest.TestCase): + def test_env_file_is_parsed_not_executed(self): + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + marker = tmp_path / "executed" + env_file = tmp_path / "vestige-sanhedrin.env" + env_file.write_text( + "\n".join( + [ + "VESTIGE_SANHEDRIN_ENABLED='1'", + "VESTIGE_SANHEDRIN_PYTHON='python3'", + f"VESTIGE_SANHEDRIN_MODEL='$(touch {marker})'", + "UNKNOWN_KEY='$(touch should-not-run)'", + ] + ) + + "\n", + encoding="utf-8", + ) + + env = os.environ.copy() + env["VESTIGE_SANHEDRIN_ENV"] = str(env_file) + result = subprocess.run( + ["bash", str(SANHEDRIN_HOOK)], + input='{"transcript_path":"/does/not/exist"}', + text=True, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse(marker.exists()) + + +if __name__ == "__main__": + unittest.main()