diff --git a/.claude-plugin/skill-assets.sha256 b/.claude-plugin/skill-assets.sha256
index 033158ca..32b840dc 100644
--- a/.claude-plugin/skill-assets.sha256
+++ b/.claude-plugin/skill-assets.sha256
@@ -1,6 +1,6 @@
d30ad152dcc4c82ce10e7167fdfe67e709358e5f435293939125f2d6cffc5b7e .claude-plugin/marketplace.json
28dcd15a7a186f8cb8a15705f1bd7734086167991c4acc28ec2cfea59a2374ab .claude-plugin/plugin.json
-45dd73ca6afdd9e12ecd38c48e4a612b7646c25a07a75a80ca0e68d0e0b85f0e skills/engraphis-memory/references/CONVENTIONS.md
-529fff3bdbe73f83209087fd10055fad77c5e5224ad8a9e6b0254052aa50e109 skills/engraphis-memory/references/SCOPING.md
-b2489b60159655e7e564e234d5aff24ba4d8df7cb82626edeaaaf89264007f85 skills/engraphis-memory/references/TOOLS.md
-56be8d078a2a8fc6e6cd1c2be5716605d8621dab953caa8cfcd20e2dce474305 skills/engraphis-memory/SKILL.md
+055655db84af07561d002f0c69744313d8413c39f3e873f941f0fa0b1e76dc66 skills/engraphis-memory/references/CONVENTIONS.md
+62019760766ff472a76a0f81437898f39e3c1fe2631732b7b7733e50c1ad837f skills/engraphis-memory/references/SCOPING.md
+4ce83a2768680ec84488a767fc3bd6cd62688d785010a0abd1d4b3edbf14d03a skills/engraphis-memory/references/TOOLS.md
+605181000a20a808e570b00cd2e852561f21234f041db03ade73f533b0e8bdaf skills/engraphis-memory/SKILL.md
diff --git a/.env.example b/.env.example
index cdd666db..8ace22b2 100644
--- a/.env.example
+++ b/.env.example
@@ -1,7 +1,8 @@
# ── Engraphis Configuration ──────────────────────────────────────────
-# Copy this file to .env and edit only the settings you need. Commented values preserve
-# the platform-aware defaults in engraphis/config.py; hosted deployments additionally
-# require the authentication and public-URL settings described below.
+# Put the settings you need in the owner-private `~/.engraphis/config.env` created by
+# `engraphis-init`, or export them in the process environment. To select another file,
+# export ENGRAPHIS_ENV_FILE as an absolute owner-private regular-file path before launch.
+# Engraphis deliberately ignores arbitrary working-directory `.env` files.
# ── Server ──────────────────────────────────────────────────────────────────
ENGRAPHIS_HOST=127.0.0.1
@@ -204,17 +205,17 @@ ENGRAPHIS_LLM_MODEL=gpt-4o-mini
# engraphis connect --token engr_ct_... # the command your account portal shows
# printf %s "$TOKEN" | engraphis connect --token - # keep the token out of shell history
#
-# That is the supported way to connect a client. It redeems the one-time connect token, saves
-# the rotating refresh credential with 0600 permissions, and keeps it rotated afterwards, so
-# none of the variables below are needed on an interactive machine. Set
-# ENGRAPHIS_CLOUD_COMPUTE_URL (or pass --compute-url) only if your account portal shows a
-# compute endpoint different from the default. See docs/AGENT_CONNECT.md.
+# That is the supported way to connect a client. It redeems the one-time connect token,
+# saves the rotating refresh credential with 0600 permissions, and keeps it rotated.
+# The saved control and compute endpoints are immutable members of that credential family:
+# later environment changes cannot redirect its refresh or workspace upload. Reconnect
+# explicitly to change either endpoint. See docs/AGENT_CONNECT.md.
#
-# For non-interactive deployments a refresh credential may be injected as a bootstrap secret.
-# It rotates on use; the owner-only saved replacement takes precedence afterward, even while
-# the environment variable remains set. Never commit it. Bind environment-only bootstrap
-# credentials to the subject assigned at onboarding (device or member). A short-lived access
-# token is supported for jobs.
+# For non-interactive deployments a refresh credential may be injected as a bootstrap
+# secret. It rotates on use; the owner-only saved replacement and its bound endpoints take
+# precedence afterward, even while the environment variables remain set. Never commit it.
+# Bind environment-only bootstrap credentials to the subject assigned at onboarding
+# (device or member). A short-lived access token is supported for jobs.
# ENGRAPHIS_CLOUD_REFRESH_CREDENTIAL=
# ENGRAPHIS_CLOUD_TOKEN_SUBJECT=member
# ENGRAPHIS_CLOUD_ACCESS_TOKEN=
@@ -327,6 +328,10 @@ ENGRAPHIS_LLM_MODEL=gpt-4o-mini
# ENGRAPHIS_RELAY_URL=https://relay.example.com
# ENGRAPHIS_SYNC_TOKEN=
# ENGRAPHIS_SYNC_READ_ONLY=0
+# Origin binding for standalone sync tokens. Must match the relay origin that issued
+# the token; prevents cross-origin token reuse. Required when using ENGRAPHIS_SYNC_TOKEN
+# without a full session credential.
+# ENGRAPHIS_SYNC_TOKEN_ORIGIN=https://relay.example.com
# End-to-end encryption key for Cloud Sync bundles (relay transport). A single
# immutable 32-byte URL-safe base64 value (43 chars, or 44 with one '=' pad) that
# every authorized device shares; changing it makes previously stored ciphertext
@@ -338,7 +343,8 @@ ENGRAPHIS_LLM_MODEL=gpt-4o-mini
# ENGRAPHIS_PRO_UPGRADE_URL=
# ENGRAPHIS_TEAM_UPGRADE_URL=
-# Update check cache duration (seconds). Default: 86400 (1 day).
+# Update check cache duration (seconds), bounded to 1..31622400. Invalid values use the
+# 86400-second (1 day) default. The cache path is fixed under owner-private Engraphis state.
# ENGRAPHIS_UPDATE_CACHE=86400
# Legacy inspector port (retired 2026-07-10; redirects to dashboard).
diff --git a/.gitattributes b/.gitattributes
index c154c010..bea5b22d 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,6 +1,12 @@
+* whitespace=cr-at-eol
*.pyd binary
*.so binary
*.dll binary
+.github/workflows/*.yml text eol=lf
+.github/workflows/*.yaml text eol=lf
+*.sh text eol=lf
+deploy/*.lock text eol=lf
+deploy/*.licenses.json text eol=lf
.claude-plugin/*.json text eol=lf
.claude-plugin/skill-assets.sha256 text eol=lf
skills/engraphis-memory/*.md text eol=lf
diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml
index c84ce1d9..90c739ee 100644
--- a/.github/codeql/codeql-config.yml
+++ b/.github/codeql/codeql-config.yml
@@ -6,10 +6,7 @@
# checks, or any cryptographic purpose. The code sets usedforsecurity=False.
#
# Changing to SHA-256 would invalidate all existing local vectors and break
-# the documented compatibility invariant in regression tests.
+# the documented compatibility invariant in regression tests. The release SARIF
+# gate waives only the two exact call sites; the CodeQL query remains enabled.
name: "Engraphis CodeQL config"
-
-query-filters:
- - exclude:
- id: py/weak-sensitive-data-hashing
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index d51b9329..f00bc1e7 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -7,6 +7,20 @@ updates:
open-pull-requests-limit: 5
labels:
- "dependencies"
+ - package-ecosystem: "npm"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ open-pull-requests-limit: 3
+ labels:
+ - "dependencies"
+ - package-ecosystem: "docker"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ open-pull-requests-limit: 3
+ labels:
+ - "dependencies"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
diff --git a/.github/release-constraints.txt b/.github/release-constraints.txt
index e55ea13c..72230ad9 100644
--- a/.github/release-constraints.txt
+++ b/.github/release-constraints.txt
@@ -6,3 +6,4 @@ wheel==0.47.0
build==1.5.0
twine==6.2.0
pip-audit==2.10.1
+cyclonedx-bom==7.3.0
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6388ad88..f4407534 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -241,8 +241,10 @@ jobs:
run: |
python -m pip install --upgrade pip
pip install -e ".[test]" "uvicorn[standard]>=0.29"
- npm ci
+ npm ci --ignore-scripts --omit=optional
npx playwright install --with-deps chromium
+ - name: Audit the root browser dependency lock
+ run: npm audit --audit-level=high
- name: Playwright desktop/mobile, keyboard, CSP, console, and axe checks
run: npx playwright test
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 90cd5f80..83be893d 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,700 +1,1026 @@
-name: Publish to PyPI
-
-on:
- push:
- tags:
- - "v*.*"
- - "v*.*.*"
- workflow_dispatch:
- inputs:
- release_tag:
- description: "Existing tag to repair as a GitHub Release"
- required: false
- type: string
-
-permissions:
- contents: read
-
-jobs:
- build:
- name: Build distributions
- runs-on: ubuntu-latest
- if: >-
- github.event_name == 'push' ||
- inputs.release_tag == ''
- env:
- PIP_CONSTRAINT: ${{ github.workspace }}/.github/release-constraints.txt
- PIP_BUILD_CONSTRAINT: ${{ github.workspace }}/.github/release-constraints.txt
-
- steps:
- - name: Check out source
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- with:
- fetch-depth: 0
-
- - name: Set up Python
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
- with:
- python-version: "3.11"
-
- - name: Install release gate and the production dependency set (without SQLCipher)
- run: >-
- python -m pip install --upgrade
- pip setuptools wheel build twine pip-audit ".[all,test]"
-
- - name: Require tag and package version to match
- if: github.event_name == 'push'
- shell: bash
- run: |
- expected="${GITHUB_REF_NAME#v}"
- actual="$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')"
- test "$GITHUB_REF_NAME" = "v$actual"
- test "$expected" = "$actual"
-
- - name: Require release tag commit to be on protected main
- if: github.event_name == 'push'
- shell: bash
- run: |
- git fetch --no-tags origin main:refs/remotes/origin/main
- git merge-base --is-ancestor "$GITHUB_SHA" origin/main
-
- - name: Full release gate
- run: |
- python scripts/check_commercial_manifest.py
- python scripts/externalize_dashboard_assets.py
- ruff check .
- pyright
- python -c "import fastapi, httpx, mcp, multipart, pydantic, uvicorn"
- ENGRAPHIS_INDEX_ROOTS="${GITHUB_WORKSPACE}:${RUNNER_TEMP}" python -m pytest -o addopts="" tests/ -q -rs --basetemp="${RUNNER_TEMP}/engraphis-pytest"
- ENGRAPHIS_INDEX_ROOTS="${GITHUB_WORKSPACE}:${RUNNER_TEMP}" python -m pytest -o addopts="" tests/test_public_research_boundary.py -q --basetemp="${RUNNER_TEMP}/engraphis-pytest"
- ENGRAPHIS_INDEX_ROOTS="${GITHUB_WORKSPACE}:${RUNNER_TEMP}" python -m pytest -o addopts="" tests/test_compact_recall.py tests/test_eval_performance.py -q --basetemp="${RUNNER_TEMP}/engraphis-pytest"
- ENGRAPHIS_INDEX_ROOTS="${GITHUB_WORKSPACE}:${RUNNER_TEMP}" python -m pytest -o addopts="" tests/test_eval_harness.py tests/test_benchmark_evidence.py -q --basetemp="${RUNNER_TEMP}/engraphis-pytest"
- python -m eval.harness --dataset eval/datasets/sample.jsonl --k 5
- python -m eval.harness --dataset eval/datasets/codemem.jsonl --k 5
- python -m eval.ablation
- python -m eval.reinforcement
- python -m eval.adversarial_memory_security
- python -m pip_audit --local --skip-editable
-
- - name: Build source and universal wheel distributions
- shell: bash
- run: |
- set -euo pipefail
- export SOURCE_DATE_EPOCH="$(git show -s --format=%ct "$GITHUB_SHA")"
- python -m build --outdir dist
- python scripts/normalize_sdist.py dist/*.tar.gz
- python -m build --outdir dist-repeat
- python scripts/normalize_sdist.py dist-repeat/*.tar.gz
- diff <(cd dist && sha256sum * | sort) <(cd dist-repeat && sha256sum * | sort)
- python scripts/verify_distribution_contents.py dist/*
- - name: Validate distributions
- run: python -m twine check dist/*
-
- - name: Smoke installed wheel and source distribution
- shell: bash
- run: |
- set -euo pipefail
- dist_dir="$PWD/dist"
- index=0
- for artifact in "$dist_dir"/*.whl "$dist_dir"/*.tar.gz; do
- index=$((index + 1))
- venv="$RUNNER_TEMP/engraphis-artifact-smoke-$index"
- python -m venv --system-site-packages "$venv"
- "$venv/bin/python" -m pip install --force-reinstall --no-deps "$artifact"
- (
- cd "$RUNNER_TEMP"
- "$venv/bin/python" - <<'PY'
- import pathlib
- import sys
-
- import engraphis
- from engraphis.core.engine import MemoryEngine
-
- package = pathlib.Path(engraphis.__file__).resolve()
- assert pathlib.Path(sys.prefix).resolve() in package.parents, package
- engine = MemoryEngine.create(":memory:")
- workspace_id = engine.store.get_or_create_workspace("artifact-smoke")
- memory_id = engine.remember(
- "The artifact smoke marker is indigo.",
- workspace_id=workspace_id,
- resolve_conflicts=False,
- )
- result = engine.recall("artifact smoke marker", workspace_id=workspace_id, k=3)
- assert any(chunk["id"] == memory_id for chunk in result.chunks)
- engine.store.close()
- PY
- "$venv/bin/python" -m scripts.smoke_entry_points --timeout 20
- )
- done
-
- - name: Store distributions
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
- with:
- name: python-package-distributions
- path: dist/
-
- python-matrix:
- name: Python ${{ matrix.python-version }} release gate
- runs-on: ubuntu-latest
- strategy:
- fail-fast: false
- matrix:
- python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
- if: >-
- github.event_name == 'push' ||
- inputs.release_tag == ''
- steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
- with:
- python-version: ${{ matrix.python-version }}
- - name: Install version-appropriate gate
- shell: bash
- run: |
- python -m pip install --upgrade pip
- if [ "${{ matrix.python-version }}" = "3.9" ]; then
- python -m pip install numpy "pytest<9" ruff
- else
- python -m pip install -e ".[test]"
- fi
- - name: Unit, lint, and retrieval gates
- run: |
- ruff check .
- if [ "${{ matrix.python-version }}" != "3.9" ]; then
- python -c "import fastapi, httpx, mcp, multipart, pydantic, uvicorn"
- fi
- ENGRAPHIS_INDEX_ROOTS="${GITHUB_WORKSPACE}:${RUNNER_TEMP}" python -m pytest -o addopts="" tests/ -q -rs --basetemp="${RUNNER_TEMP}/engraphis-pytest"
- python -m eval.harness --dataset eval/datasets/sample.jsonl --k 5
- python -m eval.harness --dataset eval/datasets/codemem.jsonl --k 5
- python -m eval.ablation
- python -m eval.reinforcement
- python -m eval.adversarial_memory_security
-
- artifact-core-py39:
- name: Python 3.9 installed release artifacts
- needs: build
- if: >-
- github.event_name == 'push' ||
- inputs.release_tag == ''
- runs-on: ubuntu-latest
- steps:
- - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
- with:
- python-version: "3.9"
- - name: Download exact release distributions
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
- with:
- name: python-package-distributions
- path: dist/
- - name: Install, verify, and smoke wheel and source distribution
- shell: bash
- run: |
- set -euo pipefail
- index=0
- for artifact in dist/*.whl dist/*.tar.gz; do
- index=$((index + 1))
- venv="$RUNNER_TEMP/engraphis-release-py39-artifact-$index"
- python -m venv "$venv"
- "$venv/bin/python" -m pip install --disable-pip-version-check "$artifact"
- "$venv/bin/python" -m pip check
- (
- cd "$RUNNER_TEMP"
- "$venv/bin/python" - <<'PY'
- import pathlib
- import sys
-
- import engraphis
- from engraphis.core.engine import MemoryEngine
-
- package = pathlib.Path(engraphis.__file__).resolve()
- assert pathlib.Path(sys.prefix).resolve() in package.parents, package
- engine = MemoryEngine.create(":memory:")
- workspace_id = engine.store.get_or_create_workspace("release-py39-artifact")
- memory_id = engine.remember(
- "The release Python 3.9 artifact marker is indigo.",
- workspace_id=workspace_id,
- resolve_conflicts=False,
- )
- result = engine.recall("release Python 3.9 artifact marker", workspace_id=workspace_id, k=3)
- assert any(chunk["id"] == memory_id for chunk in result.chunks)
- engine.store.close()
- PY
- "$venv/bin/engraphis" --help
- "$venv/bin/engraphis" --version
- "$venv/bin/engraphis-cli" --help
- )
- done
-
- encryption:
- name: Encryption driver release gate (Python ${{ matrix.python-version }})
- runs-on: ubuntu-latest
- strategy:
- fail-fast: false
- matrix:
- python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
- if: >-
- github.event_name == 'push' ||
- inputs.release_tag == ''
- steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
- with:
- python-version: ${{ matrix.python-version }}
- - name: Install encryption integration gate
- run: |
- python -m pip install --upgrade pip
- pip install -e ".[test,encryption]"
- - name: Encryption at-rest integration tests
- run: |
- python -c "import sqlcipher3; print(sqlcipher3.__file__)"
- ENGRAPHIS_INDEX_ROOTS="${GITHUB_WORKSPACE}:${RUNNER_TEMP}" python -m pytest -o addopts="" tests/test_encrypted_store.py -q -rs --basetemp="${RUNNER_TEMP}/engraphis-pytest"
-
- browser-accessibility:
- name: Browser accessibility release gate
- runs-on: ubuntu-latest
- if: >-
- github.event_name == 'push' ||
- inputs.release_tag == ''
- steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
- with:
- python-version: "3.11"
- - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- with:
- node-version: "24"
- - name: Install browser gate
- run: |
- python -m pip install --upgrade pip
- python -m pip install -e ".[test]" "uvicorn[standard]>=0.29"
- npm ci
- npx playwright install --with-deps chromium
- - name: Playwright desktop/mobile, keyboard, CSP, console, and axe checks
- run: npm run test:e2e
-
- pi-extension:
- name: Pi extension release gate
- runs-on: ubuntu-latest
- if: >-
- github.event_name == 'push' ||
- inputs.release_tag == ''
- steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
- with:
- python-version: "3.11"
- - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- with:
- node-version: "24"
- cache: npm
- cache-dependency-path: integrations/pi/npm-shrinkwrap.json
- - name: Install the tagged Smart MCP server
- run: |
- python -m pip install --upgrade pip
- python -m pip install -e ".[test]"
- - name: Verify the publishable Pi package and live bridge
- working-directory: integrations/pi
- env:
- ENGRAPHIS_PI_TEST_COMMAND: engraphis-mcp
- run: |
- npm ci --ignore-scripts
- npm run verify
- npm run test:integration
- npm audit --omit=dev
-
- docker-smoke:
- name: Production image release gate
- runs-on: ubuntu-latest
- if: >-
- github.event_name == 'push' ||
- inputs.release_tag == ''
- steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- - name: Validate Compose configuration
- run: docker compose config --quiet
- - name: Reject unauthenticated LAN Compose overlay
- run: |
- if env -u ENGRAPHIS_API_TOKEN docker compose -f docker-compose.yml -f docker-compose.lan.yml config --quiet; then
- echo "LAN overlay must require ENGRAPHIS_API_TOKEN"
- exit 1
- fi
- - name: Validate token-protected LAN Compose overlay
- env:
- ENGRAPHIS_API_TOKEN: ci-lan-overlay-token
- run: docker compose -f docker-compose.yml -f docker-compose.lan.yml config --quiet
- - name: Build production image
- run: docker build -t engraphis:release .
- - name: Verify production image OCR runtime
- run: >-
- docker run --rm --entrypoint sh engraphis:release -c
- 'python -c "import PIL, pytesseract" && command -v tesseract >/dev/null &&
- tesseract --version | head -n 1'
- - name: Audit production image dependencies
- # The runtime image deliberately has no pip. Audit its exact installed
- # distributions from the runner instead of reintroducing a build tool to the
- # production image only for this check.
- shell: bash
- run: |
- audit_dir="$(mktemp -d)"
- container="engraphis-release-audit-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
- cleanup() {
- docker rm -f "$container" >/dev/null 2>&1 || true
- rm -rf "$audit_dir"
- }
- trap cleanup EXIT
- python -m pip install --disable-pip-version-check --no-cache-dir pip-audit
- docker create --name "$container" engraphis:release >/dev/null
- docker cp "$container":/usr/local/lib/python3.11/site-packages/. "$audit_dir"
- python -m pip_audit --path "$audit_dir"
- - name: Run customer-mode readiness smoke
- shell: bash
- run: |
- docker run -d --name engraphis-release -p 8700:8700 \
- -e ENGRAPHIS_EMBED_MODEL= \
- -e ENGRAPHIS_LOOP_INTERVAL=0 \
- -e ENGRAPHIS_HOST=0.0.0.0 \
- engraphis:release
- for i in $(seq 1 60); do
- if curl -fsS http://127.0.0.1:8700/api/ready; then
- exit 0
- fi
- sleep 1
- done
- docker logs engraphis-release
- exit 1
- - name: Teardown
- if: always()
- run: docker rm -f engraphis-release || true
-
- code-security:
- name: CodeQL ${{ matrix.language }} release gate
- if: >-
- github.event_name == 'push' ||
- inputs.release_tag == ''
- runs-on: ubuntu-latest
- permissions:
- contents: read
- env:
- CODEQL_ACTION_DIFF_INFORMED_QUERIES: "false"
- strategy:
- fail-fast: false
- matrix:
- language: ["python", "javascript-typescript"]
- steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
- with:
- python-version: "3.11"
- - name: Initialize CodeQL
- uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4
- with:
- languages: ${{ matrix.language }}
- build-mode: none
- - name: Analyze complete source tree
- id: analyze
- uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4
- with:
- output: codeql-results
- upload: never
- - name: Require clean CodeQL results
- run: python scripts/check_codeql_sarif.py "${{ steps.analyze.outputs.sarif-output }}"
-
- release-evidence:
- name: Generate public release evidence
- needs: [build, python-matrix, artifact-core-py39, encryption, browser-accessibility, pi-extension, docker-smoke, code-security]
- if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
- runs-on: ubuntu-latest
- permissions:
- contents: read
-
- steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
- with:
- python-version: "3.11"
- - name: Install SBOM generator and project dependencies
- run: >-
- python -m pip install --upgrade "pip>=26.1.2" "setuptools>=83"
- cyclonedx-bom==7.3.0 ".[all,test]"
- - name: Download distributions
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
- with:
- name: python-package-distributions
- path: dist/
- - name: Generate evidence and reproducible SBOM after all release gates
- shell: bash
- run: |
- mkdir release-evidence
- sbom="release-evidence/engraphis-${GITHUB_REF_NAME#v}.cdx.json"
- cyclonedx-py environment --output-reproducible --of JSON --pyproject pyproject.toml -o "$sbom"
- python scripts/release_evidence.py --dist dist --commit "$GITHUB_SHA" \
- --tag "$GITHUB_REF_NAME" \
- --sbom "$sbom" \
- --verified-check ruff \
- --verified-check pyright-core-backends \
- --verified-check codeql \
- --verified-check pytest \
- --verified-check reproducible-distributions \
- --verified-check installed-artifact-smoke \
- --verified-check installed-artifact-smoke-py39 \
- --verified-check privacy-boundary \
- --verified-check token-efficiency \
- --verified-check benchmark-schema-evidence \
- --verified-check encryption-at-rest \
- --verified-check browser-e2e \
- --verified-check pi-extension \
- --verified-check dependency-audit \
- --verified-check container-smoke \
- --verified-check retrieval-sample \
- --verified-check retrieval-codemem \
- --verified-check retrieval-ablation \
- --verified-check reinforcement-state-transition \
- --verified-check adversarial-memory-security \
- --output release-evidence/release-evidence.json
- - name: Store public release evidence
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
- with:
- name: public-release-evidence
- path: release-evidence/
-
- publish:
- name: Publish to PyPI
- needs: release-evidence
- # Manual dispatch is intentionally build/check-only. Publication requires a pushed
- # semver tag, whose value was matched to pyproject.toml in the build job above.
- if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
- runs-on: ubuntu-latest
- permissions:
- id-token: write
- contents: read
-
- steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
- with:
- python-version: "3.11"
- - name: Download distributions
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
- with:
- name: python-package-distributions
- path: dist/
-
- - name: Verify any previously published subset
- shell: bash
- run: >-
- python scripts/verify_release_artifacts.py --dist dist
- --version "${GITHUB_REF_NAME#v}" --allow-subset
-
- # The trusted publisher may write a receipt beside the distributions. Preserve
- # the exact set that passed validation so the post-publish check cannot be
- # affected by that implementation detail.
- - name: Freeze verified distribution set
- shell: bash
- run: |
- mkdir verified-dist
- cp dist/*.whl dist/*.tar.gz verified-dist/
-
- - name: Publish distributions to PyPI
- uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1
- with:
- skip-existing: true
-
- - name: Require the exact complete PyPI file set
- shell: bash
- run: >-
- python scripts/verify_release_artifacts.py --dist verified-dist
- --version "${GITHUB_REF_NAME#v}" --retries 18 --delay 10
-
- github-release:
- name: Publish GitHub Release
- needs: publish
- if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
- runs-on: ubuntu-latest
- permissions:
- contents: write
-
- steps:
- - name: Download distributions
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
- with:
- name: python-package-distributions
- path: dist/
-
- - name: Download public release evidence
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
- with:
- name: public-release-evidence
- path: release-evidence/
-
- - name: Create GitHub Release
- env:
- GH_TOKEN: ${{ github.token }}
- GH_REPO: ${{ github.repository }}
- shell: bash
- run: |
- if gh release view "$GITHUB_REF_NAME" --repo "$GH_REPO" >/dev/null 2>&1; then
- # A previous partial attempt may have created the release before every
- # canonical package asset uploaded. Reconcile same-named assets from the
- # exact aggregate that passed the publish gate.
- gh release upload "$GITHUB_REF_NAME" dist/* release-evidence/release-evidence.json release-evidence/*.cdx.json \
- --repo "$GH_REPO" \
- --clobber
- else
- gh release create "$GITHUB_REF_NAME" dist/* release-evidence/release-evidence.json release-evidence/*.cdx.json \
- --repo "$GH_REPO" \
- --verify-tag \
- --generate-notes \
- --title "Engraphis ${GITHUB_REF_NAME#v}" \
- --latest
- fi
-
- github-release-repair:
- name: Repair GitHub Release
- if: >-
- github.event_name == 'workflow_dispatch' &&
- github.ref == 'refs/heads/main' &&
- inputs.release_tag != ''
- runs-on: ubuntu-latest
- permissions:
- actions: read
- contents: write
- id-token: write
-
- steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
- with:
- python-version: "3.11"
- - name: Download published distributions
- env:
- GH_TOKEN: ${{ github.token }}
- GH_REPO: ${{ github.repository }}
- RELEASE_TAG: ${{ inputs.release_tag }}
- shell: bash
- run: |
- set -euo pipefail
- [[ "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+(\.[0-9]+)?$ ]]
- tag_ref="$(gh api "repos/${GH_REPO}/git/ref/tags/${RELEASE_TAG}")"
- object_type="$(jq -r '.object.type' <<<"$tag_ref")"
- tag_sha="$(jq -r '.object.sha' <<<"$tag_ref")"
- # Annotated tags point at tag objects rather than commits. Peel a bounded
- # chain explicitly so a same-named branch can never supply the repair SHA.
- for _ in {1..8}; do
- if [ "$object_type" = "commit" ]; then
- break
- fi
- test "$object_type" = "tag"
- tag_object="$(gh api "repos/${GH_REPO}/git/tags/${tag_sha}")"
- object_type="$(jq -r '.object.type' <<<"$tag_object")"
- tag_sha="$(jq -r '.object.sha' <<<"$tag_object")"
- done
- test "$object_type" = "commit"
- runs="$(gh run list \
- --repo "$GH_REPO" \
- --workflow release.yml \
- --branch "$RELEASE_TAG" \
- --event push \
- --limit 20 \
- --json databaseId,headBranch,headSha,event,createdAt)"
- run_id="$(jq -r \
- --arg tag "$RELEASE_TAG" \
- --arg sha "$tag_sha" \
- 'sort_by(.createdAt) | map(select(.headBranch == $tag and
- .headSha == $sha and
- .event == "push"))[0].databaseId // empty' \
- <<<"$runs")"
- test -n "$run_id"
- jobs="$(gh run view "$run_id" --repo "$GH_REPO" --json jobs)"
- test "$(jq '[.jobs[] | select(.name == "Build distributions" and
- .conclusion == "success")] | length' \
- <<<"$jobs")" -eq 1
- test "$(jq '[.jobs[] | select(.name == "Publish to PyPI" and
- (.conclusion == "success" or
- .conclusion == "failure"))] | length' \
- <<<"$jobs")" -eq 1
- test "$(jq '[.jobs[] | select(.name == "Generate public release evidence" and
- .conclusion == "success")] | length' \
- <<<"$jobs")" -eq 1
- gh run download "$run_id" \
- --repo "$GH_REPO" \
- --name python-package-distributions \
- --dir dist
-
- gh run download "$run_id" \
- --repo "$GH_REPO" \
- --name public-release-evidence \
- --dir release-evidence
- python - "$RELEASE_TAG" "$tag_sha" <<'PY'
- import hashlib
- import json
- import sys
- from pathlib import Path
-
- tag, commit = sys.argv[1:]
- with open("release-evidence/release-evidence.json", encoding="utf-8") as handle:
- evidence = json.load(handle)
- assert evidence.get("format") == "engraphis-release-evidence/2"
- assert evidence.get("package", {}).get("version") == tag.removeprefix("v")
- assert evidence.get("tag") == tag
- assert evidence.get("commit") == commit
- assert evidence.get("provenance", {}).get("source") == {"tag": tag, "commit": commit}
- expected = {
- item["filename"]: item["sha256"]
- for item in evidence.get("artifacts", [])
- }
- actual = {
- path.name: hashlib.sha256(path.read_bytes()).hexdigest()
- for path in Path("dist").iterdir()
- if path.is_file() and (path.name.endswith(".whl") or path.name.endswith(".tar.gz"))
- }
- assert expected == actual
- PY
-
- - name: Verify any previously published subset
- env:
- RELEASE_TAG: ${{ inputs.release_tag }}
- shell: bash
- run: >-
- python scripts/verify_release_artifacts.py --dist dist
- --version "${RELEASE_TAG#v}" --allow-subset
-
- # gh-action-pypi-publish can leave its receipt in dist. Keep the approved
- # artifact set separate for the exact immutable-PyPI verification below.
- - name: Freeze verified distribution set
- shell: bash
- run: |
- mkdir verified-dist
- cp dist/*.whl dist/*.tar.gz verified-dist/
-
- - name: Publish only missing verified distributions
- uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1
- with:
- skip-existing: true
-
- - name: Require the exact complete PyPI file set
- env:
- RELEASE_TAG: ${{ inputs.release_tag }}
- shell: bash
- run: >-
- python scripts/verify_release_artifacts.py --dist verified-dist
- --version "${RELEASE_TAG#v}" --retries 18 --delay 10
-
- - name: Repair GitHub Release
- env:
- GH_TOKEN: ${{ github.token }}
- GH_REPO: ${{ github.repository }}
- RELEASE_TAG: ${{ inputs.release_tag }}
- shell: bash
- run: |
- if gh release view "$RELEASE_TAG" --repo "$GH_REPO" >/dev/null 2>&1; then
- gh release upload "$RELEASE_TAG" verified-dist/* release-evidence/release-evidence.json release-evidence/*.cdx.json \
- --repo "$GH_REPO" \
- --clobber
- else
- gh release create "$RELEASE_TAG" verified-dist/* release-evidence/release-evidence.json release-evidence/*.cdx.json \
- --repo "$GH_REPO" \
- --verify-tag \
- --generate-notes \
- --title "Engraphis ${RELEASE_TAG#v}" \
- --latest
- fi
+name: Publish to PyPI
+
+on:
+ push:
+ tags:
+ - "v*.*"
+ - "v*.*.*"
+ workflow_dispatch:
+ inputs:
+ release_tag:
+ description: "Existing tag to repair as a GitHub Release"
+ required: false
+ type: string
+
+permissions:
+ contents: read
+
+jobs:
+ build:
+ name: Build distributions
+ runs-on: ubuntu-latest
+ if: >-
+ github.event_name == 'push' ||
+ inputs.release_tag == ''
+ env:
+ PIP_CONSTRAINT: ${{ github.workspace }}/.github/release-constraints.txt
+ PIP_BUILD_CONSTRAINT: ${{ github.workspace }}/.github/release-constraints.txt
+
+ steps:
+ - name: Check out source
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ with:
+ fetch-depth: 0
+
+ - name: Set up Python
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
+ with:
+ python-version: "3.11"
+
+ - name: Install release gate and the production dependency set (without SQLCipher)
+ run: >-
+ python -m pip install --upgrade
+ pip setuptools wheel build twine pip-audit cyclonedx-bom ".[all,test]"
+
+ - name: Require tag and package version to match
+ if: github.event_name == 'push'
+ shell: bash
+ run: |
+ expected="${GITHUB_REF_NAME#v}"
+ actual="$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')"
+ test "$GITHUB_REF_NAME" = "v$actual"
+ test "$expected" = "$actual"
+
+ - name: Require release tag commit to be on protected main
+ if: github.event_name == 'push'
+ shell: bash
+ run: |
+ git fetch --no-tags origin main:refs/remotes/origin/main
+ git merge-base --is-ancestor "$GITHUB_SHA" origin/main
+
+ - name: Full release gate
+ run: |
+ python scripts/check_commercial_manifest.py
+ python scripts/externalize_dashboard_assets.py
+ ruff check .
+ pyright
+ python -c "import fastapi, httpx, mcp, multipart, pydantic, uvicorn"
+ ENGRAPHIS_INDEX_ROOTS="${GITHUB_WORKSPACE}:${RUNNER_TEMP}" python -m pytest -o addopts="" tests/ -q -rs --basetemp="${RUNNER_TEMP}/engraphis-pytest"
+ ENGRAPHIS_INDEX_ROOTS="${GITHUB_WORKSPACE}:${RUNNER_TEMP}" python -m pytest -o addopts="" tests/test_public_research_boundary.py -q --basetemp="${RUNNER_TEMP}/engraphis-pytest"
+ ENGRAPHIS_INDEX_ROOTS="${GITHUB_WORKSPACE}:${RUNNER_TEMP}" python -m pytest -o addopts="" tests/test_compact_recall.py tests/test_eval_performance.py -q --basetemp="${RUNNER_TEMP}/engraphis-pytest"
+ ENGRAPHIS_INDEX_ROOTS="${GITHUB_WORKSPACE}:${RUNNER_TEMP}" python -m pytest -o addopts="" tests/test_eval_harness.py tests/test_benchmark_evidence.py -q --basetemp="${RUNNER_TEMP}/engraphis-pytest"
+ python -m eval.harness --dataset eval/datasets/sample.jsonl --k 5
+ python -m eval.harness --dataset eval/datasets/codemem.jsonl --k 5
+ python -m eval.ablation
+ python -m eval.reinforcement
+ python -m eval.adversarial_memory_security
+ python -m pip_audit --local --skip-editable
+
+ - name: Capture the exact build environment and Python SBOM
+ shell: bash
+ run: |
+ set -euo pipefail
+ mkdir build-environment-evidence
+ python -m pip list --format=freeze \
+ | LC_ALL=C sort -f > build-environment-evidence/environment.lock
+ cyclonedx-py environment --output-reproducible --of JSON \
+ --pyproject pyproject.toml \
+ -o build-environment-evidence/engraphis-${GITHUB_REF_NAME#v}.cdx.json
+
+ - name: Build source and universal wheel distributions
+ shell: bash
+ run: |
+ set -euo pipefail
+ export SOURCE_DATE_EPOCH="$(git show -s --format=%ct "$GITHUB_SHA")"
+ python -m build --outdir dist
+ python scripts/normalize_sdist.py dist/*.tar.gz
+ python scripts/verify_distribution_contents.py dist/*
+ - name: Validate distributions
+ run: python -m twine check dist/*
+
+ - name: Smoke installed wheel and source distribution
+ shell: bash
+ run: |
+ set -euo pipefail
+ dist_dir="$PWD/dist"
+ index=0
+ for artifact in "$dist_dir"/*.whl "$dist_dir"/*.tar.gz; do
+ index=$((index + 1))
+ venv="$RUNNER_TEMP/engraphis-artifact-smoke-$index"
+ python -m venv --system-site-packages "$venv"
+ "$venv/bin/python" -m pip install --force-reinstall --no-deps "$artifact"
+ (
+ cd "$RUNNER_TEMP"
+ "$venv/bin/python" - <<'PY'
+ import pathlib
+ import sys
+
+ import engraphis
+ from engraphis.core.engine import MemoryEngine
+
+ package = pathlib.Path(engraphis.__file__).resolve()
+ assert pathlib.Path(sys.prefix).resolve() in package.parents, package
+ engine = MemoryEngine.create(":memory:")
+ workspace_id = engine.store.get_or_create_workspace("artifact-smoke")
+ memory_id = engine.remember(
+ "The artifact smoke marker is indigo.",
+ workspace_id=workspace_id,
+ resolve_conflicts=False,
+ )
+ result = engine.recall("artifact smoke marker", workspace_id=workspace_id, k=3)
+ assert any(chunk["id"] == memory_id for chunk in result.chunks)
+ engine.store.close()
+ PY
+ "$venv/bin/python" -m scripts.smoke_entry_points --timeout 20
+ )
+ done
+
+ - name: Store distributions
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: python-package-distributions
+ path: dist/
+
+
+ - name: Store exact build environment evidence
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: build-environment-evidence
+ path: build-environment-evidence/
+
+ reproducibility-build:
+ name: Independent distribution builder ${{ matrix.builder }}
+ runs-on: ubuntu-latest
+ container: python:3.11-slim@sha256:90744cff8f32887f075c47d747a173ff333e9e98801667af93c357fa9f5e28ff
+ if: >-
+ github.event_name == 'push' ||
+ inputs.release_tag == ''
+ strategy:
+ fail-fast: false
+ matrix:
+ builder: ["a", "b"]
+ env:
+ PIP_CONSTRAINT: ${{ github.workspace }}/.github/release-constraints.txt
+ PIP_BUILD_CONSTRAINT: ${{ github.workspace }}/.github/release-constraints.txt
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ with:
+ fetch-depth: 0
+ - name: Build in isolated pinned environment
+ shell: bash
+ run: |
+ set -euo pipefail
+ python -m pip install --upgrade pip setuptools wheel build
+ mkdir -p reproducibility/dist
+ export SOURCE_DATE_EPOCH="$(git show -s --format=%ct "$GITHUB_SHA")"
+ python -m build --outdir reproducibility/dist
+ python scripts/normalize_sdist.py reproducibility/dist/*.tar.gz
+ python -m pip freeze --all --exclude-editable \
+ | LC_ALL=C sort > reproducibility/environment.lock
+ python scripts/verify_distribution_contents.py reproducibility/dist/*
+ - name: Store independent builder output
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: reproducibility-builder-${{ matrix.builder }}
+ path: reproducibility/
+
+ reproducibility-check:
+ name: Compare independent distribution builders
+ needs: [build, reproducibility-build]
+ runs-on: ubuntu-latest
+ if: >-
+ github.event_name == 'push' ||
+ inputs.release_tag == ''
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ - name: Download primary distributions
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
+ with:
+ name: python-package-distributions
+ path: primary/
+ - name: Download builder A
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
+ with:
+ name: reproducibility-builder-a
+ path: builder-a/
+ - name: Download builder B
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
+ with:
+ name: reproducibility-builder-b
+ path: builder-b/
+ - name: Compare independent distribution builders
+ shell: bash
+ run: |
+ set -euo pipefail
+ mkdir reproducibility-evidence
+ python - <<'PY'
+ import hashlib
+ import json
+ from pathlib import Path
+
+ image = (
+ "python:3.11-slim@sha256:"
+ "90744cff8f32887f075c47d747a173ff333e9e98801667af93c357fa9f5e28ff"
+ )
+
+ def digest(path):
+ return hashlib.sha256(path.read_bytes()).hexdigest()
+
+ def artifact_map(directory):
+ return {
+ path.name: digest(path)
+ for path in sorted(directory.iterdir())
+ if path.name.endswith((".whl", ".tar.gz"))
+ }
+
+ def toolchain(lock):
+ selected = {"build", "pip", "setuptools", "wheel"}
+ packages = {
+ line.split("==", 1)[0].lower(): line.split("==", 1)[1]
+ for line in lock.read_text(encoding="utf-8").splitlines()
+ if "==" in line
+ }
+ assert selected <= packages.keys()
+ return {name: packages[name] for name in sorted(selected)}
+
+ primary = artifact_map(Path("primary"))
+ builders = []
+ environment_digests = set()
+ for name in ("a", "b"):
+ root = Path(f"builder-{name}")
+ artifacts = artifact_map(root / "dist")
+ lock = root / "environment.lock"
+ assert artifacts == primary
+ environment_digests.add(digest(lock))
+ builders.append({
+ "name": name,
+ "image": image,
+ "python": "3.11",
+ "environment_lock_sha256": digest(lock),
+ "toolchain": toolchain(lock),
+ "artifacts": artifacts,
+ })
+ assert len(environment_digests) == 1
+ report = {
+ "format": "engraphis-independent-reproducibility/v1",
+ "builders": builders,
+ }
+ Path("reproducibility-evidence/reproducibility.json").write_text(
+ json.dumps(report, indent=2, sort_keys=True) + "\n",
+ encoding="utf-8",
+ )
+ PY
+ - name: Store independent reproducibility evidence
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: independent-reproducibility
+ path: reproducibility-evidence/
+
+ python-matrix:
+ name: Python ${{ matrix.python-version }} release gate
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
+ if: >-
+ github.event_name == 'push' ||
+ inputs.release_tag == ''
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
+ with:
+ python-version: ${{ matrix.python-version }}
+ - name: Install version-appropriate gate
+ shell: bash
+ run: |
+ python -m pip install --upgrade pip
+ if [ "${{ matrix.python-version }}" = "3.9" ]; then
+ python -m pip install numpy "pytest<9" ruff
+ else
+ python -m pip install -e ".[test]"
+ fi
+ - name: Unit, lint, and retrieval gates
+ run: |
+ ruff check .
+ if [ "${{ matrix.python-version }}" != "3.9" ]; then
+ python -c "import fastapi, httpx, mcp, multipart, pydantic, uvicorn"
+ fi
+ ENGRAPHIS_INDEX_ROOTS="${GITHUB_WORKSPACE}:${RUNNER_TEMP}" python -m pytest -o addopts="" tests/ -q -rs --basetemp="${RUNNER_TEMP}/engraphis-pytest"
+ python -m eval.harness --dataset eval/datasets/sample.jsonl --k 5
+ python -m eval.harness --dataset eval/datasets/codemem.jsonl --k 5
+ python -m eval.ablation
+ python -m eval.reinforcement
+ python -m eval.adversarial_memory_security
+
+ artifact-core-py39:
+ name: Python 3.9 installed release artifacts
+ needs: build
+ if: >-
+ github.event_name == 'push' ||
+ inputs.release_tag == ''
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
+ with:
+ python-version: "3.9"
+ - name: Download exact release distributions
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
+ with:
+ name: python-package-distributions
+ path: dist/
+ - name: Install, verify, and smoke wheel and source distribution
+ shell: bash
+ run: |
+ set -euo pipefail
+ index=0
+ for artifact in dist/*.whl dist/*.tar.gz; do
+ index=$((index + 1))
+ venv="$RUNNER_TEMP/engraphis-release-py39-artifact-$index"
+ python -m venv "$venv"
+ "$venv/bin/python" -m pip install --disable-pip-version-check "$artifact"
+ "$venv/bin/python" -m pip check
+ (
+ cd "$RUNNER_TEMP"
+ "$venv/bin/python" - <<'PY'
+ import pathlib
+ import sys
+
+ import engraphis
+ from engraphis.core.engine import MemoryEngine
+
+ package = pathlib.Path(engraphis.__file__).resolve()
+ assert pathlib.Path(sys.prefix).resolve() in package.parents, package
+ engine = MemoryEngine.create(":memory:")
+ workspace_id = engine.store.get_or_create_workspace("release-py39-artifact")
+ memory_id = engine.remember(
+ "The release Python 3.9 artifact marker is indigo.",
+ workspace_id=workspace_id,
+ resolve_conflicts=False,
+ )
+ result = engine.recall("release Python 3.9 artifact marker", workspace_id=workspace_id, k=3)
+ assert any(chunk["id"] == memory_id for chunk in result.chunks)
+ engine.store.close()
+ PY
+ "$venv/bin/engraphis" --help
+ "$venv/bin/engraphis" --version
+ "$venv/bin/engraphis-cli" --help
+ )
+ done
+
+
+ installed-artifact-platform-smoke:
+ name: Installed wheel smoke (${{ matrix.os }})
+ needs: build
+ runs-on: ${{ matrix.os }}
+ if: >-
+ github.event_name == 'push' ||
+ inputs.release_tag == ''
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [windows-latest, macos-latest]
+ env:
+ PIP_CONSTRAINT: ${{ github.workspace }}/.github/release-constraints.txt
+ PIP_BUILD_CONSTRAINT: ${{ github.workspace }}/.github/release-constraints.txt
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
+ with:
+ python-version: "3.11"
+ - name: Download exact release distributions
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
+ with:
+ name: python-package-distributions
+ path: dist/
+ - name: Install and smoke the downloaded wheel on Windows and macOS
+ shell: bash
+ run: |
+ set -euo pipefail
+ python - <<'PY'
+ import os
+ from pathlib import Path
+ import subprocess
+ import sys
+
+ environment = Path(os.environ["RUNNER_TEMP"]) / "engraphis-platform-wheel-smoke"
+ subprocess.run([sys.executable, "-m", "venv", str(environment)], check=True)
+ executable = environment / ("Scripts/python.exe" if os.name == "nt" else "bin/python")
+ wheels = list(Path("dist").glob("*.whl"))
+ assert len(wheels) == 1
+ subprocess.run(
+ [str(executable), "-m", "pip", "install", "--disable-pip-version-check",
+ str(wheels[0].resolve())],
+ check=True,
+ )
+ subprocess.run([str(executable), "-m", "pip", "check"], check=True)
+ subprocess.run(
+ [str(executable), "-m", "scripts.smoke_entry_points", "--timeout", "20"],
+ cwd=os.environ["RUNNER_TEMP"],
+ check=True,
+ )
+ PY
+
+ encryption:
+ name: Encryption driver release gate (Python ${{ matrix.python-version }})
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
+ if: >-
+ github.event_name == 'push' ||
+ inputs.release_tag == ''
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
+ with:
+ python-version: ${{ matrix.python-version }}
+ - name: Install encryption integration gate
+ run: |
+ python -m pip install --upgrade pip
+ pip install -e ".[test,encryption]"
+ - name: Encryption at-rest integration tests
+ run: |
+ python -c "import sqlcipher3; print(sqlcipher3.__file__)"
+ ENGRAPHIS_INDEX_ROOTS="${GITHUB_WORKSPACE}:${RUNNER_TEMP}" python -m pytest -o addopts="" tests/test_encrypted_store.py -q -rs --basetemp="${RUNNER_TEMP}/engraphis-pytest"
+
+ browser-accessibility:
+ name: Browser accessibility release gate
+ runs-on: ubuntu-latest
+ if: >-
+ github.event_name == 'push' ||
+ inputs.release_tag == ''
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
+ with:
+ python-version: "3.11"
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version: "24"
+ - name: Install browser gate
+ run: |
+ python -m pip install --upgrade pip
+ python -m pip install -e ".[test]" "uvicorn[standard]>=0.29"
+ npm ci --ignore-scripts --omit=optional
+ npx playwright install --with-deps chromium
+ - name: Audit the root browser dependency lock
+ run: npm audit --audit-level=high
+ - name: Playwright desktop/mobile, keyboard, CSP, console, and axe checks
+ run: npm run test:e2e
+
+ pi-extension:
+ name: Pi extension release gate
+ runs-on: ubuntu-latest
+ if: >-
+ github.event_name == 'push' ||
+ inputs.release_tag == ''
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
+ with:
+ python-version: "3.11"
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version: "24"
+ cache: npm
+ cache-dependency-path: integrations/pi/npm-shrinkwrap.json
+ - name: Install the tagged Smart MCP server
+ run: |
+ python -m pip install --upgrade pip
+ python -m pip install -e ".[test]"
+ - name: Verify the publishable Pi package and live bridge
+ working-directory: integrations/pi
+ env:
+ ENGRAPHIS_PI_TEST_COMMAND: engraphis-mcp
+ run: |
+ npm ci --ignore-scripts
+ npm run verify
+ npm run test:integration
+ npm audit --omit=dev
+
+ docker-smoke:
+ name: Production image release gate
+ runs-on: ubuntu-latest
+ if: >-
+ github.event_name == 'push' ||
+ inputs.release_tag == ''
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ - name: Validate Compose configuration
+ run: docker compose config --quiet
+ - name: Reject unauthenticated LAN Compose overlay
+ run: |
+ if env -u ENGRAPHIS_API_TOKEN docker compose -f docker-compose.yml -f docker-compose.lan.yml config --quiet; then
+ echo "LAN overlay must require ENGRAPHIS_API_TOKEN"
+ exit 1
+ fi
+ - name: Validate token-protected LAN Compose overlay
+ env:
+ ENGRAPHIS_API_TOKEN: ci-lan-overlay-token
+ run: docker compose -f docker-compose.yml -f docker-compose.lan.yml config --quiet
+ - name: Build production image
+ shell: bash
+ run: |
+ set -euo pipefail
+ mkdir container-evidence
+ docker buildx build --pull --load \
+ --metadata-file container-evidence/build-metadata.json \
+ -t engraphis:release .
+ - name: Record immutable production image digest
+ shell: bash
+ run: |
+ set -euo pipefail
+ python - <<'PY'
+ import json
+ import re
+ from pathlib import Path
+
+ metadata = Path("container-evidence/build-metadata.json")
+ digest = json.loads(metadata.read_text(encoding="utf-8")).get(
+ "containerimage.digest",
+ )
+ assert isinstance(digest, str)
+ assert re.fullmatch(r"sha256:[0-9a-f]{64}", digest)
+ Path("container-evidence/image.digest").write_text(
+ digest + "\n", encoding="utf-8",
+ )
+ metadata.unlink()
+ PY
+ - name: Generate whole-image SBOM
+ uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0
+ with:
+ image: engraphis:release
+ format: cyclonedx-json
+ output-file: container-evidence/engraphis-container.cdx.json
+ upload-artifact: false
+ - name: Bind whole-image SBOM to immutable digest
+ shell: bash
+ run: |
+ set -euo pipefail
+ python - <<'PY'
+ import json
+ from pathlib import Path
+
+ path = Path("container-evidence/engraphis-container.cdx.json")
+ digest = Path("container-evidence/image.digest").read_text(encoding="utf-8").strip()
+ document = json.loads(path.read_text(encoding="utf-8"))
+ metadata = document.setdefault("metadata", {})
+ component = metadata.setdefault(
+ "component", {"type": "container", "name": "engraphis:release"},
+ )
+ properties = [
+ item for item in component.get("properties", [])
+ if item.get("name") != "engraphis:image-digest"
+ ]
+ properties.append({"name": "engraphis:image-digest", "value": digest})
+ component["properties"] = properties
+ path.write_text(
+ json.dumps(document, indent=2, sort_keys=True) + "\n",
+ encoding="utf-8",
+ )
+ PY
+ - name: Scan whole production image
+ uses: anchore/scan-action@e1165082ffb1fe366ebaf02d8526e7c4989ea9d2 # v7.4.0
+ with:
+ image: engraphis:release
+ fail-build: true
+ severity-cutoff: high
+ output-format: json
+ output-file: container-evidence/grype.json
+ - name: Verify production image OCR runtime
+ run: >-
+ docker run --rm --entrypoint sh engraphis:release -c
+ 'python -c "import PIL, pytesseract" && command -v tesseract >/dev/null &&
+ tesseract --version | head -n 1'
+ - name: Audit production image dependencies
+ # The runtime image deliberately has no pip. Audit its exact installed
+ # distributions from the runner instead of reintroducing a build tool to the
+ # production image only for this check.
+ shell: bash
+ run: |
+ audit_dir="$(mktemp -d)"
+ container="engraphis-release-audit-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
+ cleanup() {
+ docker rm -f "$container" >/dev/null 2>&1 || true
+ rm -rf "$audit_dir"
+ }
+ trap cleanup EXIT
+ python -m pip install --disable-pip-version-check --no-cache-dir pip-audit==2.10.1
+ docker create --name "$container" engraphis:release >/dev/null
+ docker cp "$container":/usr/local/lib/python3.11/site-packages/. "$audit_dir"
+ python -m pip_audit --path "$audit_dir"
+ - name: Run customer-mode readiness smoke
+ shell: bash
+ run: |
+ docker run -d --name engraphis-release -p 8700:8700 \
+ -e ENGRAPHIS_EMBED_MODEL= \
+ -e ENGRAPHIS_LOOP_INTERVAL=0 \
+ -e ENGRAPHIS_HOST=0.0.0.0 \
+ engraphis:release
+ for i in $(seq 1 60); do
+ if curl -fsS http://127.0.0.1:8700/api/ready; then
+ exit 0
+ fi
+ sleep 1
+ done
+ docker logs engraphis-release
+ exit 1
+ - name: Store whole-image evidence
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: production-image-evidence
+ path: container-evidence/
+ - name: Teardown
+ if: always()
+ run: docker rm -f engraphis-release || true
+
+ code-security:
+ name: CodeQL ${{ matrix.language }} release gate
+ if: >-
+ github.event_name == 'push' ||
+ inputs.release_tag == ''
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ env:
+ CODEQL_ACTION_DIFF_INFORMED_QUERIES: "false"
+ strategy:
+ fail-fast: false
+ matrix:
+ language: ["python", "javascript-typescript"]
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
+ with:
+ python-version: "3.11"
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4
+ with:
+ languages: ${{ matrix.language }}
+ build-mode: none
+ config-file: ./.github/codeql/codeql-config.yml
+ - name: Analyze complete source tree
+ id: analyze
+ uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4
+ with:
+ output: codeql-results
+ upload: never
+ - name: Require clean CodeQL results
+ run: python scripts/check_codeql_sarif.py "${{ steps.analyze.outputs.sarif-output }}"
+
+ release-evidence:
+ name: Generate public release evidence
+ needs: [build, reproducibility-check, python-matrix, artifact-core-py39, installed-artifact-platform-smoke, encryption, browser-accessibility, pi-extension, docker-smoke, code-security]
+ if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
+ with:
+ python-version: "3.11"
+ - name: Download distributions
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
+ with:
+ name: python-package-distributions
+ path: dist/
+ - name: Download exact build environment evidence
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
+ with:
+ name: build-environment-evidence
+ path: release-evidence/
+ - name: Download whole-image evidence
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
+ with:
+ name: production-image-evidence
+ path: release-evidence/
+ - name: Download independent reproducibility evidence
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
+ with:
+ name: independent-reproducibility
+ path: release-evidence/
+ - name: Generate evidence from captured release artifacts
+ shell: bash
+ run: |
+ set -euo pipefail
+ sbom="release-evidence/engraphis-${GITHUB_REF_NAME#v}.cdx.json"
+ python scripts/release_evidence.py --dist dist --commit "$GITHUB_SHA" \
+ --tag "$GITHUB_REF_NAME" \
+ --sbom "$sbom" \
+ --environment-lock release-evidence/environment.lock \
+ --image-sbom release-evidence/engraphis-container.cdx.json \
+ --image-digest "$(tr -d '\r\n' < release-evidence/image.digest)" \
+ --image-scan release-evidence/grype.json \
+ --reproducibility release-evidence/reproducibility.json \
+ --verified-check ruff \
+ --verified-check pyright-core-backends \
+ --verified-check codeql \
+ --verified-check pytest \
+ --verified-check reproducible-distributions \
+ --verified-check installed-artifact-smoke \
+ --verified-check installed-artifact-smoke-py39 \
+ --verified-check installed-artifact-platform-smoke \
+ --verified-check privacy-boundary \
+ --verified-check token-efficiency \
+ --verified-check benchmark-schema-evidence \
+ --verified-check encryption-at-rest \
+ --verified-check browser-e2e \
+ --verified-check pi-extension \
+ --verified-check dependency-audit \
+ --verified-check browser-dependency-audit \
+ --verified-check container-smoke \
+ --verified-check retrieval-sample \
+ --verified-check retrieval-codemem \
+ --verified-check retrieval-ablation \
+ --verified-check reinforcement-state-transition \
+ --verified-check adversarial-memory-security \
+ --output release-evidence/release-evidence.json
+ - name: Store public release evidence
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: public-release-evidence
+ path: release-evidence/
+
+ publish:
+ name: Publish to PyPI
+ needs: release-evidence
+ # Manual dispatch is intentionally build/check-only. Publication requires a pushed
+ # semver tag, whose value was matched to pyproject.toml in the build job above.
+ if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
+ runs-on: ubuntu-latest
+ permissions:
+ id-token: write
+ contents: read
+
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
+ with:
+ python-version: "3.11"
+ - name: Download distributions
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
+ with:
+ name: python-package-distributions
+ path: dist/
+
+ - name: Verify any previously published subset
+ shell: bash
+ run: >-
+ python scripts/verify_release_artifacts.py --dist dist
+ --version "${GITHUB_REF_NAME#v}" --allow-subset
+
+ # The trusted publisher may write a receipt beside the distributions. Preserve
+ # the exact set that passed validation so the post-publish check cannot be
+ # affected by that implementation detail.
+ - name: Freeze verified distribution set
+ shell: bash
+ run: |
+ mkdir verified-dist
+ cp dist/*.whl dist/*.tar.gz verified-dist/
+
+ - name: Publish distributions to PyPI
+ uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1
+ with:
+ skip-existing: true
+
+ - name: Require the exact complete PyPI file set
+ shell: bash
+ run: >-
+ python scripts/verify_release_artifacts.py --dist verified-dist
+ --version "${GITHUB_REF_NAME#v}" --retries 18 --delay 10
+
+ github-release:
+ name: Publish GitHub Release
+ needs: publish
+ if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+
+ steps:
+ - name: Download distributions
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
+ with:
+ name: python-package-distributions
+ path: dist/
+
+ - name: Download public release evidence
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
+ with:
+ name: public-release-evidence
+ path: release-evidence/
+
+ - name: Create GitHub Release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ GH_REPO: ${{ github.repository }}
+ shell: bash
+ run: |
+ if gh release view "$GITHUB_REF_NAME" --repo "$GH_REPO" >/dev/null 2>&1; then
+ # A previous partial attempt may have created the release before every
+ # canonical package asset uploaded. Reconcile same-named assets from the
+ # exact aggregate that passed the publish gate.
+ gh release upload "$GITHUB_REF_NAME" dist/* release-evidence/* \
+ --repo "$GH_REPO" \
+ --clobber
+ else
+ gh release create "$GITHUB_REF_NAME" dist/* release-evidence/* \
+ --repo "$GH_REPO" \
+ --verify-tag \
+ --generate-notes \
+ --title "Engraphis ${GITHUB_REF_NAME#v}" \
+ --latest
+ fi
+
+ github-release-repair:
+ name: Repair GitHub Release
+ if: >-
+ github.event_name == 'workflow_dispatch' &&
+ github.ref == 'refs/heads/main' &&
+ inputs.release_tag != ''
+ runs-on: ubuntu-latest
+ permissions:
+ actions: read
+ contents: write
+ id-token: write
+
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
+ with:
+ python-version: "3.11"
+ - name: Download published distributions
+ env:
+ GH_TOKEN: ${{ github.token }}
+ GH_REPO: ${{ github.repository }}
+ RELEASE_TAG: ${{ inputs.release_tag }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ [[ "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+(\.[0-9]+)?$ ]]
+ tag_ref="$(gh api "repos/${GH_REPO}/git/ref/tags/${RELEASE_TAG}")"
+ object_type="$(jq -r '.object.type' <<<"$tag_ref")"
+ tag_sha="$(jq -r '.object.sha' <<<"$tag_ref")"
+ # Annotated tags point at tag objects rather than commits. Peel a bounded
+ # chain explicitly so a same-named branch can never supply the repair SHA.
+ for _ in {1..8}; do
+ if [ "$object_type" = "commit" ]; then
+ break
+ fi
+ test "$object_type" = "tag"
+ tag_object="$(gh api "repos/${GH_REPO}/git/tags/${tag_sha}")"
+ object_type="$(jq -r '.object.type' <<<"$tag_object")"
+ tag_sha="$(jq -r '.object.sha' <<<"$tag_object")"
+ done
+ test "$object_type" = "commit"
+ runs="$(gh run list \
+ --repo "$GH_REPO" \
+ --workflow release.yml \
+ --branch "$RELEASE_TAG" \
+ --event push \
+ --limit 20 \
+ --json databaseId,headBranch,headSha,event,createdAt)"
+ printf '%s\n' "$runs" > "$RUNNER_TEMP/release-runs.json"
+ python - "$RUNNER_TEMP/release-runs.json" "$RELEASE_TAG" "$tag_sha" \
+ > "$RUNNER_TEMP/release-run-candidates" <<'PY'
+ import json
+ import sys
+
+ from scripts.release_evidence import repair_run_candidates
+
+ path, tag, commit = sys.argv[1:]
+ runs = json.loads(open(path, encoding="utf-8").read())
+ for run_id in repair_run_candidates(runs, tag, commit):
+ print(run_id)
+ PY
+
+ selected_run=""
+ while IFS= read -r candidate; do
+ test -n "$candidate" || continue
+ if ! jobs="$(gh run view "$candidate" --repo "$GH_REPO" --json jobs)"; then
+ continue
+ fi
+ test "$(jq '[.jobs[] | select(.name == "Build distributions" and
+ .conclusion == "success")] | length' \
+ <<<"$jobs")" -eq 1 || continue
+ test "$(jq '[.jobs[] | select(.name == "Publish to PyPI" and
+ (.conclusion == "success" or
+ .conclusion == "failure"))] | length' \
+ <<<"$jobs")" -eq 1 || continue
+ test "$(jq '[.jobs[] | select(.name == "Generate public release evidence" and
+ .conclusion == "success")] | length' \
+ <<<"$jobs")" -eq 1 || continue
+
+ rm -rf candidate-dist candidate-evidence
+ if ! gh run download "$candidate" \
+ --repo "$GH_REPO" \
+ --name python-package-distributions \
+ --dir candidate-dist; then
+ continue
+ fi
+ if ! gh run download "$candidate" \
+ --repo "$GH_REPO" \
+ --name public-release-evidence \
+ --dir candidate-evidence; then
+ continue
+ fi
+ if python - "$RELEASE_TAG" "$tag_sha" <<'PY'; then
+ import hashlib
+ import json
+ import sys
+ from pathlib import Path
+
+ tag, commit = sys.argv[1:]
+ evidence_root = Path("candidate-evidence")
+ with (evidence_root / "release-evidence.json").open(encoding="utf-8") as handle:
+ evidence = json.load(handle)
+ assert evidence.get("format") == "engraphis-release-evidence/3"
+ assert evidence.get("package", {}).get("version") == tag.removeprefix("v")
+ assert evidence.get("tag") == tag
+ assert evidence.get("commit") == commit
+ assert evidence.get("provenance", {}).get("source") == {
+ "tag": tag, "commit": commit,
+ }
+ expected = {
+ item["filename"]: item["sha256"]
+ for item in evidence.get("artifacts", [])
+ }
+ actual = {
+ path.name: hashlib.sha256(path.read_bytes()).hexdigest()
+ for path in Path("candidate-dist").iterdir()
+ if path.is_file() and path.name.endswith((".whl", ".tar.gz"))
+ }
+ assert expected == actual
+ records = [
+ evidence["sbom"],
+ evidence["environment_lock"],
+ evidence["reproducibility"],
+ evidence["container"]["sbom"],
+ evidence["container"]["vulnerability_scan"],
+ ]
+ for record in records:
+ path = evidence_root / Path(record["path"]).name
+ assert path.is_file()
+ assert hashlib.sha256(path.read_bytes()).hexdigest() == record["sha256"]
+ PY
+ rm -rf dist release-evidence
+ mv candidate-dist dist
+ mv candidate-evidence release-evidence
+ selected_run="$candidate"
+ break
+ fi
+ done < "$RUNNER_TEMP/release-run-candidates"
+ test -n "$selected_run"
+
+ - name: Verify any previously published subset
+ env:
+ RELEASE_TAG: ${{ inputs.release_tag }}
+ shell: bash
+ run: >-
+ python scripts/verify_release_artifacts.py --dist dist
+ --version "${RELEASE_TAG#v}" --allow-subset
+
+ # gh-action-pypi-publish can leave its receipt in dist. Keep the approved
+ # artifact set separate for the exact immutable-PyPI verification below.
+ - name: Freeze verified distribution set
+ shell: bash
+ run: |
+ mkdir verified-dist
+ cp dist/*.whl dist/*.tar.gz verified-dist/
+
+ - name: Publish only missing verified distributions
+ uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1
+ with:
+ skip-existing: true
+
+ - name: Require the exact complete PyPI file set
+ env:
+ RELEASE_TAG: ${{ inputs.release_tag }}
+ shell: bash
+ run: >-
+ python scripts/verify_release_artifacts.py --dist verified-dist
+ --version "${RELEASE_TAG#v}" --retries 18 --delay 10
+
+ - name: Repair GitHub Release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ GH_REPO: ${{ github.repository }}
+ RELEASE_TAG: ${{ inputs.release_tag }}
+ shell: bash
+ run: |
+ if gh release view "$RELEASE_TAG" --repo "$GH_REPO" >/dev/null 2>&1; then
+ gh release upload "$RELEASE_TAG" verified-dist/* release-evidence/* \
+ --repo "$GH_REPO" \
+ --clobber
+ else
+ gh release create "$RELEASE_TAG" verified-dist/* release-evidence/* \
+ --repo "$GH_REPO" \
+ --verify-tag \
+ --generate-notes \
+ --title "Engraphis ${RELEASE_TAG#v}" \
+ --latest
+ fi
diff --git a/AGENTS.md b/AGENTS.md
index 08718b10..e077b0a2 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -21,8 +21,8 @@ most common mistake here.
| Status | Primary scoped, bi-temporal, interface-driven implementation. | Compatibility/reference implementation with flat namespaces. |
| Model | Scoped + bi-temporal + typed; interface-driven. | Single flat `namespace` string per memory. |
| Code | `engraphis/core/`, `engraphis/backends/`, `eval/`, `tests/`, `scripts/migrate_to_v2.py` | `engraphis/app.py`, `config.py`, `models.py`, `routes/`, `stores/`, `engines/`, `llm/`, `static/` |
-| Data | new v2 schema (`SCHEMA_VERSION = 11`) | `engraphis_v1.db` |
-| Entry | `MemoryEngine.create()` → `core/engine.py` | Internal reference only; never a public launcher |
+| Data | new v2 schema (`SCHEMA_VERSION = 15`) | `engraphis_v1.db` |
+| Entry | `engraphis.MemoryEngine.create()` / `engraphis.create_memory_engine()` → `engraphis/factory.py` → `core/engine.py` | Internal reference only; never a public launcher |
**Rule:** build new capability on **v2** (`core/` + `backends/`) behind the interfaces.
Only touch the v1 server for compatibility fixes or to keep the reference running. When a
@@ -34,18 +34,22 @@ task is ambiguous, decide which side it belongs to *before* editing.
```bash
# ── Install ──────────────────────────────────────────────────────────────────
-pip install numpy pytest # v2 core + tests, fully OFFLINE (this is what CI does)
-pip install -e ".[all,dev]" # full stack: FastAPI server, ST embeddings, ruff
-cp .env.example .env # optional; configure server, LLM, encryption, or hosted client settings
-
-# ── Quality gate (offline, no API key — KEEP THIS GREEN; mirrors .github/workflows/ci.yml) ──
-python -m pytest tests/ -q # unit tests (offline)
+pip install numpy pytest # v2 core + tests, fully offline (Python 3.9 floor job)
+pip install -e ".[test]" # full offline CI test/lint/typecheck dependencies
+pip install -e ".[all,dev]" # complete local stack: dashboard, MCP, embeddings, dev tools
+# Config: process environment or owner-private ~/.engraphis/config.env; never a searched CWD .env
+
+# ── Primary offline gate (no API key — KEEP THIS GREEN; mirrors CI's full-stack job) ──
+ruff check . # pinned lint rules
+python scripts/check_commercial_manifest.py # source/service boundary
+python scripts/externalize_dashboard_assets.py # strict-CSP asset drift
+python -m pytest tests/ -q # full offline unit suite
python -m eval.harness --dataset eval/datasets/sample.jsonl --k 5 # retrieval eval gate
-python -m eval.harness --dataset eval/datasets/codemem.jsonl --k 5 # larger eval; covers conflict resolution
-python -m eval.ablation # vector-only vs 1-hop vs PPR
+python -m eval.harness --dataset eval/datasets/codemem.jsonl --k 5 # coding/conflict gate
+python -m eval.ablation # vector-only vs hybrid
python -m eval.reinforcement # bounded retention trajectory
-python -m eval.adversarial_memory_security # poisoning + prompt graph boundary
-ruff check . # lint (line-length 100, py39, pinned rule set)
+python -m eval.adversarial_memory_security # prompt/graph boundary
+pyright # core + backends typecheck
# ── External benchmarks (real numbers need torch + the dataset; see eval/external.py) ──
python -m eval.external --dataset locomo10.json --format locomo --k 10 # LoCoMo
@@ -56,7 +60,7 @@ python -m eval.external --dataset locomo10.json --format locomo --offline --limi
python -m scripts.start_dashboard # http://127.0.0.1:8700
# Use this unified launcher; there is no separate Inspector service.
-# ── Onboarding (writes .env with an absolute DB path; doctor mode verifies install) ──
+# ── Onboarding (writes owner-private ~/.engraphis/config.env; doctor verifies install) ──
engraphis-init # or: python -m scripts.init
engraphis-init --check
@@ -83,7 +87,9 @@ python -m scripts.migrate_to_v2 --old engraphis_v1.db --new engraphis_v2.db
```
-`requires-python >= 3.9` (ruff targets `py39`); CI and the recommended dev environment use **3.11**.
+`requires-python >= 3.9` (ruff targets `py39`). CI tests the NumPy-only core on 3.9, the full
+offline stack on 3.10–3.14, and Pyright on 3.11; dedicated jobs also exercise encryption and built
+artifacts. `.github/workflows/ci.yml` is authoritative when the matrix changes.
---
@@ -142,11 +148,15 @@ is distilled into discrete facts first; the offline default is passthrough.
## 3. Non-negotiable conventions (load-bearing)
-1. **Interfaces before implementations.** `core/` and `engines/` depend only on the
- Protocols in `core/interfaces.py` (`Embedder`, `VectorIndex`, `LexicalIndex`,
- `GraphStore`, `Reranker`, `LLM`). **Never import a concrete backend inside `core/`** —
- inject it. Swapping `sqlite-vec`→Qdrant, or a local embedder for an API, must be a
- *config change, not a refactor*.
+1. **Interfaces before implementations.** Every module in `core/`, including `core/engine.py`,
+ depends only on the Protocols in `core/interfaces.py` (`Embedder`, `VectorIndex`,
+ `LexicalIndex`, `GraphStore`, `Reranker`, `LLM`) and injected collaborators. The sole outer
+ composition root is `engraphis/factory.py`, which may import concrete backends and selects the
+ dependency-light `IdentityReranker` default. `engraphis/__init__.py` registers that provider so
+ the compatibility `MemoryEngine.create()` entry point delegates outward; new callers may use
+ `engraphis.create_memory_engine()` directly. **Never import a concrete backend anywhere inside
+ `core/`.** Swapping `sqlite-vec`→Qdrant, or a local embedder for an API, must be a *config
+ change, not a refactor*.
2. **Forgetting lowers retrieval priority; it never hard-deletes.** Decay adjusts
`stability`. Hard deletion is explicit, governed, and audited (`Store.audit`).
3. **Truth is temporal.** Resolve contradictions by **invalidation, not overwrite**:
@@ -155,7 +165,9 @@ is distilled into discrete facts first; the offline default is passthrough.
4. **Everything is scoped.** Every memory carries a `Scope` + `workspace/repo/session`.
Every read takes a `SearchFilter`. Scope promotion is an explicit operation.
5. **Memory is typed** (`working` / `episodic` / `semantic` / `procedural`), each with its
- own weight profile (`scoring.DEFAULT_WEIGHTS`) and lifecycle. Treat them differently.
+ own weight profile (`scoring.DEFAULT_WEIGHTS`) and lifecycle. The append-only event ledger is
+ outside that type system: use `record_event` for raw occurrences and an episodic memory when
+ the outcome must be recalled or consolidated.
6. **Provenance always.** Set `provenance` on memories and edges so "why is this known?"
is answerable.
7. **Prove "better" with a number.** No retrieval/quality claim ships without an eval.
@@ -186,7 +198,7 @@ These are pure, unit-tested functions — change them only with a corresponding
---
-## 5. Data model cheat-sheet (`core/interfaces.py`, `core/schema.py` — `SCHEMA_VERSION = 11`)
+## 5. Data model cheat-sheet (`core/interfaces.py`, `core/schema.py` — `SCHEMA_VERSION = 15`)
- **Scope hierarchy:** `workspace → repo → session → memory`. Scopes: `session|repo|workspace|user`.
- **Bi-temporal validity on every record:** world-time `valid_from/valid_to` +
@@ -199,16 +211,18 @@ These are pure, unit-tested functions — change them only with a corresponding
`mem_fts` (FTS5 + plain-table fallback), `entities`, `edges` (bi-temporal), `mem_links`,
`memory_entities`, `symbols`, `code_edges`, `code_files`, `code_memory_links`,
`operation_receipts`, `events`, `audit`, `memory_tombstones`, `schema_migrations`.
+- **Erasure markers contain no memory content.** `memory_tombstones.export_class` is strictly
+ `never_export|remote_erasure`; only `remote_erasure` may cross a sync boundary.
- **Vectors are stored L2-normalized** so cosine similarity == dot product.
---
## 6. Gotchas
-- **Offline by default in core:** `MemoryEngine.create()` uses a deterministic hashing
- embedder + NumPy index, so tests need no model download or network. Pass `embed_model=...`
- to load a real embedding model; choose `vector_backend="sqlite-vec"` separately when you
- need native exact-KNN acceleration.
+- **Offline by default at the public factory:** `engraphis.MemoryEngine.create()` and
+ `engraphis.create_memory_engine()` select a deterministic hashing embedder + NumPy index, so
+ tests need no model download or network. Pass `embed_model=...` to load a real embedding model;
+ choose `vector_backend="sqlite-vec"` separately when you need native exact-KNN acceleration.
- **First full-stack run downloads `all-MiniLM-L6-v2` (~80 MB)** for the ST embedder.
- **FTS5 may be missing** on some SQLite builds → `Store` auto-falls back to `LIKE`
(`self.has_fts5`). Don't assume BM25 is available.
diff --git a/BENCHMARKS.md b/BENCHMARKS.md
index 512e7e83..644b2e1f 100644
--- a/BENCHMARKS.md
+++ b/BENCHMARKS.md
@@ -6,6 +6,31 @@ those results. When this document and the code disagree, the code is the source
For the locked operator sequence for a public canonical run, see
[`docs/PUBLIC_BENCHMARK_RUNBOOK.md`](docs/PUBLIC_BENCHMARK_RUNBOOK.md).
+### Public numeric evidence registry
+
+Every exact public aggregate retained below comes from the checked-in, public-safe
+[`offline-fixtures-v1.json`](docs/benchmark-evidence/offline-fixtures-v1.json) artifact. Its
+SHA-256 is
+`c3a74f1770ad3f868f55261ba11680e2dadca30167082ac2cb6669f9e3bdfad2`, also recorded in the
+adjacent `.sha256` file. The artifact contains no raw questions, answers, prompts, customer data,
+or per-record content fingerprints.
+
+The fixture-suite digest is
+`4d7e40607319cd4bf8caee3897f1e416dbe5b81998b37a7e4839409ee2923537`. The artifact defines
+the digest algorithm and records the SHA-256 of every suite and dataset file. Each evidence ID
+also binds its exact command through `sha256(UTF-8 exact command)`:
+
+| Evidence ID | Exact command | Config digest |
+|---|---|---|
+| `offline-chunking` | `python -m eval.chunking_eval --dataset eval/datasets/longdoc.jsonl --k 5` | `c1c8196aa7e1568ef3844a9fb2d76b87f342c39108e32d6ad144b885a76143b8` |
+| `offline-performance` | `python -m eval.performance --dataset eval/datasets/codemem.jsonl --k 5 --iterations 10 --json` | `bbe4aca81e58d4830e50a8fc7729a1d15b71d97a6299bccd79432b7f119677d7` |
+| `offline-grounded` | `python -m eval.grounded` | `590442e51e3642c10489165759919dc86ffac62c182937330c153e7f8d5fc26f` |
+
+External, model-dependent, latency, consolidation, and productivity numbers are not published
+until a redacted immutable artifact with the same three bindings exists. Use the
+[public benchmark runbook](docs/PUBLIC_BENCHMARK_RUNBOOK.md) to produce that evidence; absence
+from this registry means no public number is claimed.
+
## What we measure today (all offline, no API key)
Most Engraphis evals score **retrieval**, not end-to-end QA. The separate productivity benchmark
@@ -26,33 +51,34 @@ frontier-model QA score.
retrieval-only aggregates rather than silently dropping them. `eval.longmemeval_v2` is a local,
text-only adapter for the official LongMemEval-V2 `insert(trajectory)` / `query(query,
query_image=None)` memory interface; it does not download data or call a model.
-- **Grounded**: `eval/grounded.py`: answerable → cite, off-topic → abstain.
+- **Grounded**: `eval/grounded.py`: answerable → cite, off-topic → abstain. Exact fixture
+ outcomes are evidence ID `offline-grounded` in the registry above.
- **Chunking (quality per token)**: `eval/chunking_eval.py` over `eval/datasets/longdoc.jsonl`
ingests a multi-topic corpus twice: once as one memory per document (`whole`) and once with
sub-file `ChunkingExtractor` (`chunked`), then queries both through the real recall pipeline.
The checked-in corpus is explicitly marked trusted eval data so the measurement isolates
chunking from the production trust gate, which excludes arbitrary raw imports from normal
agent context. On the deterministic embedder, **recall@5 is 1.000 for both modes; mean
- retrieved top-5 content falls from 740.3 to 214.1 tokens (526.2 fewer, 71.1% lower, about
+ retrieved top-5 content falls from 740.3 to 214.3 tokens (526.0 fewer, 71.1% lower, about
3.5× smaller), while the smallest returned evidence-holding memory falls from 162.2 to 42.4
- tokens (119.8 fewer, 73.9% lower, about 3.8× smaller).** Pass `--embed-model
- sentence-transformers/all-MiniLM-L6-v2` for a real retrieval number (recall should then favour
- chunked on larger corpora, not just tie).
+ tokens (119.8 fewer, 73.9% lower, about 3.8× smaller).** These aggregates are evidence ID
+ `offline-chunking` in the registry above. Pass `--embed-model
+ sentence-transformers/all-MiniLM-L6-v2` to run a model-dependent experiment; do not publish
+ that result without a new immutable artifact and pinned model revision.
- **Full-pipeline latency + quality**: `eval/performance.py` times the shipped semantic +
lexical + graph + fusion + scoring + rerank + packing path after warmup, with reinforcement
disabled so repeated measurements do not mutate their corpus. It reports p50/p95/p99 latency,
retrieval quality, packed context tokens, and full/compact JSON-shape payload proxies in one
JSON-safe schema. Payload proxies are sampled once per question, independently of the number
of timed iterations; they are not serialized MCP envelopes or transport responses. In the
- documented CodeMem run (`--iterations 10`), 26 payload samples total **23,810** full-proxy
+ registered CodeMem run, 26 payload samples total **23,810** full-proxy
`engraphis.regex.v1` tokens versus **10,202** compact-proxy tokens, avoiding **13,608** proxy
tokens (**57.15% lower**), while 260 recalls are timed. Packed context across the same 26
samples averages **85.38** tokens and reaches **108** under a 1,500-token cap; Recall@5,
- hit@5, and answer-token recall remain 1.000. `--filler-memories` provides deterministic corpus
- scaling, and every report records the runtime, architecture, embedder, vector backend, corpus
- size, warmups, and iteration count. `--candidate-k` and `--retrieval-profile` make
- adaptive-depth/routing experiments executable instead of changing production defaults from an
- unmeasured hunch.
+ hit@5, and answer-token recall remain 1.000. These aggregates are evidence ID
+ `offline-performance` in the registry above. `--filler-memories`, `--candidate-k`, and
+ `--retrieval-profile` make scaling and routing experiments executable, but their results need
+ separate evidence before publication.
- **Exact vector scale envelope**: `eval/vector_scale.py` measures the production
`NumpyVectorIndex` directly at requested corpus sizes with deterministic normalized vectors and
queries. It records a corpus fingerprint, result hashes, environment, and observed
@@ -81,40 +107,11 @@ frontier-model QA score.
results. Optional provider telemetry is reported separately from the deterministic token
counter and is not a provider billing estimate.
-The workload benchmark is also allowed to say “this workload is too small for a memory layer.”
-On the 44-memory / 26-question CodeMem regression fixture, every case already fits inside a
-64-token recency window. Full-history and recency therefore use the same 1,180 cumulative reader
-tokens at perfect evidence/answer-token quality, while Engraphis uses 1,064–1,066 reader tokens
-plus a conservative 631-token indexing pass. The indexing-inclusive total still costs more over a
-single pass, with break-even at 142–144 queries. This is an intentionally small, reusable-workload
-boundary, not a general cost claim.
-
-On the same 26 CodeMem tasks, every history fit the 512-token prompt allowance, so adaptive
-routing bypassed all 26 memory calls. It used **1,942** total agent-facing tokens versus
-**1,883** for always-on retrieval while both strategies completed **24/26** tasks with the bundled
-deterministic agent. This demonstrates bypass behavior and token accounting; this small fixture
-does not establish a token-saving claim for adaptive routing or general LLM intelligence.
-
-The complementary real-model LoCoMo workload diagnostic covers 10 conversations and 1,986
-questions with `all-MiniLM-L6-v2`, `k=10`, a 512-token reader budget, and conflict resolution
-disabled. **This is an unpinned, noncanonical workload diagnostic of reader-context use only, not
-answer quality or leaderboard accuracy.** Engraphis used **891,857** cumulative reader-context
-tokens versus **49,915,394** for uncapped full history, **98.2133% lower**. Charging one complete
-246,539-token corpus pass to indexing produces a conservative Engraphis total of **1,138,396**,
-still **97.7193% lower**, with a calculated break-even at query 10. The quality tradeoff is
-explicit:
-
-| LoCoMo workload method (unpinned, noncanonical context-use diagnostic; not answer quality or leaderboard accuracy) | Retrieval recall | Hit rate | Answer-token recall | Mean reader context |
-|---|---:|---:|---:|---:|
-| Engraphis hybrid recall | **0.600457** | **0.657417** | **0.679614** | **449.07** tokens |
-| Same-budget recency window | 0.011289 | 0.012614 | 0.339941 | 487.87 tokens |
-| Uncapped full history | 0.996997 | 0.997477 | 0.917247 | 25,133.63 tokens |
-
-This diagnostic supports a precise statement: Engraphis recovered much more useful evidence than
-a same-budget recency window while using a small fraction of full-history context. It does not
-support “same quality as full history,” provider-billing, or end-to-end answer-accuracy claims.
-The embedding model revision was not pinned in that run, so rerun it with an immutable revision
-before treating the numbers as canonical release evidence.
+The context-economy and productivity tools intentionally report when a small workload does not
+benefit from memory, and the external loaders expose retrieval-quality tradeoffs rather than
+hiding them. Their prior local results are not retained as public numbers because no matching
+redacted immutable artifact is checked in. Run the registered protocol and publish the resulting
+artifact before making a quantitative claim.
### Reproduce
@@ -168,17 +165,12 @@ names every remaining replacement/removal, must be fully consumed, and is record
report with its own hash. Any source update, unused repair, or unresolved ID fails the run. This
repairs retrieval references only; it does not claim to correct LoCoMo's semantic answer labels.
-The pinned full-dataset private retrieval diagnostic run on 2026-08-04 used official-source
-SHA-256 `79fa87e90f04081343b8c8debecb80a9a6842b76a7aa537dc9fdf651ea698ff4`, repair-manifest
-SHA-256 `7bb74979b98778aafbbe72d44a93593743ff5ba166c9c95cd4702ab7376d7c2b`, and
-`sentence-transformers/all-MiniLM-L6-v2` revision
-`1110a243fdf4706b3f48f1d95db1a4f5529b4d41`. With `k=10` and conflict resolution disabled,
-all 10 conversations, 5,882 memories, and 1,986 questions were processed; 1,982 questions had
-gold evidence and were scored. The result was recall@10 **0.6045**, hit@10 **0.6625**,
-MRR@10 **0.4138**, NDCG@10 **0.4424**, and answer-token recall **0.4607**. Six questions used
-mechanical ID normalization, three source-audited manifest repairs were applied, and four
-questions were explicitly excluded as `no_gold_evidence`. These values measure evidence
-retrieval only; they are not end-to-end QA accuracy or an official LoCoMo leaderboard score.
+A private pinned retrieval diagnostic was inspected during development, but its result artifact is
+not checked into the public evidence registry. This document therefore publishes none of that
+run's workload counts or scores. Reproduce it from the hash-bound source and repair manifest,
+export a public-safe immutable artifact, and validate its checksum before adding quantitative
+claims. Any future values remain evidence-retrieval metrics, not end-to-end QA accuracy or an
+official LoCoMo leaderboard score.
## What we do NOT yet claim
@@ -226,10 +218,14 @@ cases. Retrieval-only abstention/no-evidence records remain visible in the artif
`exclusions`; they are not counted as evidence-retrieval scores.
Official LongMemEval-V2 output can be converted into a public-safe QA artifact with
-`python -m eval.longmemeval_v2_evidence`. The exporter keeps the official QA score, fixed-reader
-context token count, latency, model revisions, source digests, repository state, and artifact
-checksum. It removes raw questions, answers, prompts, reader output, and retrieved context before
-the artifact can be written. See [`eval/EVIDENCE.md`](eval/EVIDENCE.md) for the exact command.
+`python -m eval.longmemeval_v2_evidence`. The exporter requires the completion manifest written by
+the pinned runner after a successful, complete official run. It binds the exact per-question
+output, questions, haystack, trajectories, memory configuration, matrix manifest, seed, clean
+official checkout, and recorded environment. The public artifact keeps the official QA score,
+fixed-reader context token count, aggregate source-file digests, repository state, and artifact
+checksum. It removes raw questions, answers, prompts, reader output, and retrieved context, and
+does not publish per-record content fingerprints. See the
+[`public benchmark runbook`](docs/PUBLIC_BENCHMARK_RUNBOOK.md) for the end-to-end operator sequence.
### LongMemEval-V2 memory-module adapter
@@ -241,31 +237,42 @@ the artifact can be written. See [`eval/EVIDENCE.md`](eval/EVIDENCE.md) for the
with the official harness. The config pins `Qwen/Qwen3-Embedding-8B` to revision
`1d8ad4ca9b3dd8059ad90a75d4983776a23d44af`; mutable embedding revisions are rejected, and a
canonical adapter run fails instead of relabeling the deterministic offline fallback as Qwen.
-Run `python -m eval.run_longmemeval_v2` with the official harness arguments and the pinned
-checkout on `PYTHONPATH`. This wrapper performs the upstream registry import in the required order
-before delegating to `evaluation.harness`; a direct upstream invocation must otherwise import
-`eval.longmemeval_v2` before calling `build_memory`.
+First materialize the six declared variants at all five token budgets:
+
+```bash
+python -m eval.longmemeval_v2_matrix \
+ --output "$ENGRAPHIS_EVIDENCE_RUN_DIR/configs"
+```
+
+This writes a 30-run manifest: balanced, planner, episodic-cap, planner-plus-episodic-cap, and
+matched `context_k=2` comparators for both capped variants, each at 256, 512, 1,024, 2,048, and
+4,096 evidence tokens. Run each manifest cell through `python -m eval.run_longmemeval_v2` with all
+eight `--engraphis-*` completion-receipt arguments. The wrapper imports the adapter before the
+official registry builds the memory module, forces the pinned reader processor revision, and
+delegates the remaining official harness arguments unchanged. Only after a successful return does
+it verify that the output question IDs exactly cover the source question IDs and write the
+immutable execution manifest.
The checked-in configuration is canonical only when the adapter resolves the pinned Qwen reader
-processor at `c202236235762e1c871ad0ccb60c8ee5ba337b9a`. The wrapper also forces the audited
-official harness's otherwise-unpinned `AutoProcessor` call to that same revision. It refuses to
-start if the optional processor dependency or immutable revision is unavailable; the local regex
-counter is never silently relabeled as a reader budget. The recorded budget counts each returned
-context item's content with that reader tokenizer (without prompt framing or inter-item
-separators), so it is a hard **evidence-item content** budget, not a claim about total chat-prompt
-tokens. Packed sources are returned as separate context items, preserving the largest fitting
-evidence prefix instead of dropping one oversized monolithic item. The adapter does not download
-benchmark data or call the reader/evaluator; the official harness owns those steps.
+processor at `c202236235762e1c871ad0ccb60c8ee5ba337b9a`. The wrapper refuses a dirty or non-pinned
+official checkout and refuses to start if the optional processor dependency or immutable revision
+is unavailable; the local regex counter is never silently relabeled as a reader budget. The
+recorded budget counts each returned context item's content with that reader tokenizer (without
+prompt framing or inter-item separators), so it is a hard **evidence-item content** budget, not a
+claim about total chat-prompt tokens. Packed sources are returned as separate context items,
+preserving the largest fitting evidence prefix instead of dropping one oversized monolithic item.
+Every official per-question row reports inserted and retrieved counts by memory type. A
+memory-type-cap claim additionally requires at least two populated inserted types, so a nominal cap
+over a single-type workload cannot qualify as evidence. The adapter does not download benchmark
+data or call the reader/evaluator; the official harness owns those steps.
## External evidence status and remaining executions
1. **Run the official LongMemEval-V2 reader and evaluator.** The adapter, pinned runner, and
redacted evidence exporter are implemented. The exact upstream commit boots in an isolated
- Python 3.11 environment and the wrapper reaches the official harness CLI. Dataset revision
- `f152293e235517d504809563c833d7190b8c713b` publishes 7,120,369,667 bytes before the pinned
- Qwen reader and embedding model assets. A full official run therefore still requires those
- resources, sufficient compute, and evaluator configuration; no canonical QA score is claimed
- until that run completes.
+ Python 3.11 environment and the wrapper reaches the official harness CLI. The dataset, pinned
+ Qwen reader, and embedding assets require substantial storage and compute; no canonical QA
+ score is claimed until that run completes.
2. **Publish production-backend latency.** Run `eval/performance.py` with the real embedder and
sqlite-vec/backend configuration on a fixed machine class and corpus scale.
3. **Run the fixed-budget curve on the complete official datasets.** The v2 harness now measures
@@ -299,20 +306,15 @@ Use `--artifact` on any of these commands to write a redacted, immutable evidenc
an adjacent SHA256 file. The ordinary console/`--json` report is private run material and may
contain source questions for debugging.
-### Upstream-data diagnostic baseline (2026-07-30)
-
-These runs use the dependency-free deterministic embedder on upstream data. They validate the
-adapters and expose product gaps; they are noncanonical diagnostics, not leaderboard or marketing
-claims. The artifact validator accepted every completed envelope.
+### Upstream-data diagnostics awaiting public artifacts
-| Upstream source | Executed scope | Result and boundary |
-|---|---|---|
-| LoCoMo-Plus commit `059f4e3d38f7f1f96765e8e2cb7de3097551bffb` | All 401 Cognitive cases, 40,270 source memories | Recall@10 **0.1259**, hit@10 **0.1272**, MRR@10 **0.0744**, answer-token context coverage **0.5095**. This is cue-evidence retrieval, not answer-judge accuracy. The low retrieval score is useful negative evidence: implicit-constraint recall remains a real product gap. |
-| MemoryAgentBench commit `455306dcabc3842526eb83cd4e225e5d486c5c5d`, official Hugging Face `Accurate_Retrieval` first row | 100 questions | Recall@10 **0.5100**, hit@10 **0.8600**, answer-token context coverage **0.8500**. Gold evidence was derived only where an accepted answer occurred in a source chunk. |
-| The same source, `Conflict_Resolution` first row | 100 questions | Recall@10 **0.4600**, hit@10 **0.6400**, answer-token context coverage **0.6800**. This plain-context export measures retrieval, not structured temporal invalidation. |
-| The same source, `Long_Range_Understanding` first row | 1 question | Answer-token context coverage **0.2658**. The export supplied no evidence IDs and no accepted answer occurred verbatim in a source chunk, so retrieval was deliberately left unscored rather than reported as a false perfect score. |
-| The same source, `Test_Time_Learning` first row | One 5.88 MB context | The no-resolution ingest did not complete within a five-minute local smoke ceiling. This is a measured large-ingest throughput gap, not a failed quality score; batch embedding and transaction work should precede a complete split run. |
-| Mem2ActBench upstream smoke | 2 public rows | Recall@10, hit@10, MRR@10, and NDCG@10 **1.0000**; expected tool-call JSON token coverage **0.5714**. This is retrieval/context coverage, not generated action success. |
+The LoCoMo-Plus, MemoryAgentBench, and Mem2ActBench adapters have been exercised against upstream
+data and exposed useful product gaps. Their earlier local envelopes are not present in the
+checked-in evidence registry, so this document withholds their case counts, retrieval scores,
+token coverage, and throughput measurements. Rerun each adapter with `--artifact`, publish the
+redacted immutable envelope and checksum, and add its suite/config binding before quoting a
+number. Until then these lanes demonstrate executable plumbing only, not leaderboard,
+answer-quality, or marketing results.
The MemoryAgentBench loader accepts both its aligned public JSON export and the Hugging Face
dataset-server `rows[].row` envelope. Rows without gold evidence remain useful for answer-token
@@ -338,13 +340,11 @@ or invent a task-success oracle.
1. **Budget-aware packing**: compare full source, safe summary, sentence-aligned safe summary
excerpt, and raw-source excerpt at fixed budgets. Gate on support/answer retention and
qualifier preservation, not token count alone.
-2. **Adaptive retrieval work**: `--candidate-depth adaptive` is now an opt-in performance
- experiment. It keeps wider graph/code pools and reduces routine lexical/balanced pools while
- reporting the requested and actual depth. Sample and CodeMem kept every offline quality metric
- at 1.0 with balanced depth reduced from 50 to 15; CodeMem plus 1,000 fillers reduced local
- median recall latency from 20.666 ms to 18.991 ms in a 260-recall comparison, an 8.1%
- reduction. These are machine-specific regression results, not production latency claims. Keep
- the default fixed until complete external categories meet predeclared quality margins.
+2. **Adaptive retrieval work**: `--candidate-depth adaptive` is an opt-in performance experiment.
+ It keeps wider graph/code pools and reduces routine lexical/balanced pools while reporting the
+ requested and actual depth. A local experiment motivated this option, but no public number is
+ retained because its machine-specific artifact is not in the evidence registry. Keep the
+ default fixed until complete external categories meet predeclared quality margins.
3. **Packing-pressure consolidation**: prioritize memory families that are frequently recalled,
repeatedly omitted, or costly per useful token. Count write/index/storage cost as well as later
reader-context savings.
@@ -354,7 +354,7 @@ or invent a task-success oracle.
measuring tokens-to-evidence, recall, and storage/index growth together before recommending a
model-specific default.
5. **Bulk ingestion**: add batch embedding plus a transaction-aware vector upsert path, then rerun
- the 5.88 MB MemoryAgentBench Test-Time Learning row. Gate this on identical stored-memory,
+ the complete MemoryAgentBench Test-Time Learning input. Gate this on identical stored-memory,
provenance, graph-link, and temporal-resolution outcomes, not throughput alone.
6. **Scoped caches**: benchmark query embeddings and repeat-recall results keyed by workspace,
repo, time anchors, profile, and corpus version. Test invalidation correctness before claiming
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 239a83d9..725d3382 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,48 @@ All notable changes to Engraphis are documented here. Format loosely follows
## [Unreleased]
+### Security
+
+- Fail closed on new `user`-scope memory writes until records carry an immutable owner identity;
+ preserve historical reads and the existing promotion rejection instead of presenting
+ workspace-bound rows as private personal memory.
+- Load optional dotenv configuration only from the owner-private
+ `~/.engraphis/config.env` or an absolute owner-private file selected by
+ `ENGRAPHIS_ENV_FILE`; arbitrary working-directory `.env` files are not a trust boundary.
+- Clarify Cloud Sync credential-origin binding, secret-manager-only unattended credentials,
+ version-3 rollback evidence, and the deliberately incomplete first-contact state without
+ claiming an untrusted relay can prove a complete device set.
+- Advance through schema 15: schema 12 classifies content-free erasure markers so local-only
+ `never_export` markers remain private and only validated `remote_erasure` markers may cross
+ sync boundaries; schema 13 adds per-memory hybrid logical clocks for deterministic
+ descriptive-state sync; schema 14-15 add the local source-import manifest for document
+ and note-collection tracking with scope-security triggers.
+
+### Fixed
+
+- Synchronize the portable memory skill with the live Smart nine-tool and Classic 34-tool
+ surfaces, including the two intentionally narrower Smart overlap schemas, trust/origin fields,
+ planner and response bounds, context-savings filters, receipt anchors, and expanded health
+ output.
+- Separate append-only event rows from episodic memories in every agent guide: event rows are not
+ recalled, deduplicated, reinforced, or consolidated, while recallable recurring outcomes use
+ governed episodic memories.
+- Make every documentation and image target in the PyPI long description an absolute canonical
+ repository URL, and add offline contracts that reject future relative-link regressions.
+- Replace unregistered external and consolidation numbers in the context-efficiency image with a
+ checksum-bound public fixture artifact; publish exact commands plus suite/config digests and
+ retain only deterministic aggregates reproduced by the checked-in offline fixtures.
+- Align the canonical offline gate, protocol-only `core/` boundary and outer
+ `engraphis/factory.py` composition root, deterministic versus entrypoint vector-backend
+ selection, persistent embedding identity, v1 migration repair reporting, trusted configuration,
+ and hosted/local boundaries across public docs.
+- Remove the obsolete consolidation source-supersession option across public docs; consolidation
+ now exposes only the explicit clustering, archival, profile, inference, structured, LLM, time,
+ and level controls implemented by the engine.
+- Document the official LongMemEval-V2 six-variant, five-budget execution matrix end to end,
+ including clean-checkout completion receipts, exact source-question coverage, privacy-safe
+ export binding, matched `context_k=2` comparators, and memory-type count evidence.
+
## [1.5] - 2026-08-04
Minor release advancing the v2 engine to schema 11 with governed recall recovery,
diff --git a/CLAUDE.md b/CLAUDE.md
index e84d6b48..24b7473f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -12,18 +12,12 @@ legacy FastAPI server (`app.py`, `routes/`, `stores/`, `engines/`, flat namespac
capability on v2 behind the interfaces in `core/interfaces.py`. Decide which side a change
belongs to before editing. Full table: AGENTS.md §0.
-## Before you say "done" — run the offline gate
+## Before you say "done" — run the canonical gate
-No network or API key required; this mirrors `.github/workflows/ci.yml` and must stay green:
-
-```bash
-python -m pytest tests/ -q && \
-python -m eval.harness --dataset eval/datasets/sample.jsonl --k 5 && \
-python -m eval.harness --dataset eval/datasets/codemem.jsonl --k 5 && \
-python -m eval.ablation
-```
-
-If you changed retrieval, scoring, or ranking, add or update an eval — per AGENTS.md §3.7,
+Use the exact primary offline gate in `AGENTS.md` §1; do not maintain a smaller duplicate here.
+`.github/workflows/ci.yml` is authoritative for the current Python matrix and dedicated
+typecheck, encryption, and built-artifact jobs. No network or API key is required for the primary
+gate. If you changed retrieval, scoring, or ranking, add or update an eval—per AGENTS.md §3.7,
"better" needs a number, not an assertion.
## Slash commands available here
@@ -38,12 +32,11 @@ If you changed retrieval, scoring, or ranking, add or update an eval — per AGE
## Working style in this repo
-- **Interface-first & dependency-light** (AGENTS.md §3): keep `core/` runnable on `numpy`
- alone; gate heavy imports behind the backend factories; never import a concrete backend
- inside `core/` — with one deliberate exception: `core/engine.py` is the composition
- root and may import the backend *factories* (`get_embedder`/`get_vector_index`/…),
- whose heavy libraries stay lazily gated inside `backends/`, so `import
- engraphis.core.engine` still needs only numpy.
+- **Interface-first & dependency-light** (AGENTS.md §3): every `core/` module, including
+ `core/engine.py`, remains protocol-only and runnable on NumPy. Concrete backend selection lives
+ in the outer composition root `engraphis/factory.py`; `engraphis/__init__.py` registers it for
+ `MemoryEngine.create()`, and `engraphis.create_memory_engine()` exposes it directly. Gate heavy
+ imports behind backend factories and never import a concrete backend from `core/`.
- **House style:** `ruff` line-length 100, Python 3.9-compatible syntax, pure/tested scoring
functions, provenance and scope on every memory.
- **Be concise and direct** in chat — explain the *why* of a change briefly, link the file,
@@ -70,7 +63,9 @@ If you changed retrieval, scoring, or ranking, add or update an eval — per AGE
## Memory typing for recurring events
-When writing to Engraphis memory: recurring operational events (ticks, no-ops, health checks)
-are **always `episodic`** via `engraphis_record_event` with a stable `kind` — never `working`,
-never `semantic` at write time; promotion is `engraphis_consolidate`'s job. Full deterministic
-decision test: `skills/engraphis-memory/references/CONVENTIONS.md` §Recurring operational events.
+Choose the contract before writing a recurring operational outcome (tick, no-op, health check).
+For an append-only occurrence ledger, use `engraphis_record_event` with a stable `kind`; event rows
+have no `mtype` and are not recalled or consolidated. When the outcome must enter memory recall or
+consolidation, use `engraphis_remember` with `mtype="episodic"`, low importance (≤0.2), and normal
+dedupe—never `working` and never `semantic` at write time. Full decision test:
+`skills/engraphis-memory/references/CONVENTIONS.md` §Recurring operational outcomes.
diff --git a/Dockerfile b/Dockerfile
index b015206f..0bf33b27 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,5 +1,5 @@
# Engraphis — self-hosted AI memory engine. Local-first; you bring the LLM.
-FROM python:3.11-slim AS base
+FROM python:3.11-slim@sha256:90744cff8f32887f075c47d747a173ff333e9e98801667af93c357fa9f5e28ff AS base
# ENGRAPHIS_HOST is deliberately NOT set here: docker-entrypoint.sh chooses IPv6 for a
# Railway deployment (which injects RAILWAY_SERVICE_NAME) and 0.0.0.0 for ordinary Docker.
diff --git a/MANIFEST.in b/MANIFEST.in
index 6cb5a360..050ecbc3 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -14,6 +14,8 @@ include docs/images/context-efficiency.svg
include pyproject.toml
include .env.example requirements.txt
include docker-entrypoint.sh Dockerfile docker-compose.yml docker-compose.lan.yml
+include deploy/force-graph-1.51.4.licenses.json
+include deploy/force-graph-1.51.4.yarn.lock
include railway.json
recursive-include eval *.py
include eval/BASELINES.md
diff --git a/NOTICE b/NOTICE
index 96b82f4f..fc165f38 100644
--- a/NOTICE
+++ b/NOTICE
@@ -18,7 +18,10 @@ Third-party browser assets distributed with this product:
engraphis/static/vendor/d3.LICENSE.
- Marked 12.0.2, Copyright MarkedJS, Christopher Jeffrey, and Markdown's
contributors, MIT and BSD-style licenses. See engraphis/static/vendor/marked.LICENSE.
-- force-graph 1.51.4, Copyright 2018 Vasco Asturiano, MIT license. See
- engraphis/static/vendor/force-graph.LICENSE.
+- force-graph 1.51.4 and its bundled runtime dependency closure, MIT/ISC/BSD-3-Clause
+ licenses. See engraphis/static/vendor/force-graph.LICENSE and the complete
+ machine-verifiable notices in deploy/force-graph-1.51.4.licenses.json, derived from
+ deploy/force-graph-1.51.4.yarn.lock at upstream commit
+ baa20a92bbe5628034d771abaf33a2dbb65d22eb.
- DOMPurify 3.4.11, Copyright Cure53 and contributors, distributed under the
Apache License 2.0 option stated in its embedded license header; see LICENSE.
diff --git a/README.md b/README.md
index cc56c3ae..f114523e 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@
**Give your AI agents a memory. See it, search it, and maintain it, all in a beautiful WebUI on your own machine.**
-
+ Knowledge Graph · run engraphis-dashboard to see it live
@@ -41,7 +41,7 @@ does not measure provider billing. The `/context-savings` API and
filters.
-
+ Less repeated history means more room for the task, tools, and useful evidence.
@@ -54,31 +54,33 @@ filters.
| Retrieval mode | Mean returned memory content | Recall@5 |
|---|---:|---:|
| Whole documents | 740.3 tokens | 1.000 |
-| Engraphis structure-aware chunks | 214.1 tokens | 1.000 |
+| Engraphis structure-aware chunks | 214.3 tokens | 1.000 |
-The chunked mode returns the relevant passage instead of the whole document: **526.2 fewer tokens
+The chunked mode returns the relevant passage instead of the whole document: **526.0 fewer tokens
per question**. Under the same model-context budget, that leaves roughly **526 tokens** for task
-instructions or other relevant evidence.
+instructions or other relevant evidence. This is evidence ID `offline-chunking` in the registered
+artifact below.
### Measurement details and reproducibility
-The table below records every current token/context efficiency measurement and its counting
-boundary.
+The table below contains every exact token/context aggregate currently published here and keeps
+its counting boundary explicit.
| What is counted | Comparison | Measured reduction | Quality held constant |
|---|---|---|---|
-| Cumulative reader context across a 1,986-question LoCoMo diagnostic | Full-history replay: **49,915,394** tokens → Engraphis: **891,857** tokens | **49,023,537 fewer context tokens** (**98.2133% lower**) | Focused retrieval used far less context; uncapped full history retained higher retrieval recall |
-| Retrieved top-5 memory content, averaged per question | Whole documents: **740.3** tokens → structure-aware chunks: **214.1** tokens | **526.2 fewer tokens per question** (**71.1% lower**, about **3.5× smaller**) | Recall@5 **1.000** in both modes across 6 documents and 18 questions |
+| Retrieved top-5 memory content, averaged per question | Whole documents: **740.3** tokens → structure-aware chunks: **214.3** tokens | **526.0 fewer tokens per question** (**71.1% lower**, about **3.5× smaller**) | Recall@5 **1.000** in both modes across 6 documents and 18 questions |
| Smallest returned memory that contains the reference evidence | Whole documents: **162.2** tokens → chunks: **42.4** tokens | **119.8 fewer tokens to evidence** (**73.9% lower**, about **3.8× smaller**) | The same 18 questions had a returned evidence-holding memory in both modes |
| Full versus compact recall payload proxy across one 26-question pass within a 260-timed-recall CodeMem run | Full proxy: **23,810** `engraphis.regex.v1` tokens → compact proxy: **10,202** tokens | **13,608 proxy tokens avoided** (**57.15% lower**) | 26 payload samples; 260 timed recalls; Recall@5, hit@5, and answer-token recall all **1.000** |
-| Repeated-memory consolidation fixture | 12 related episodic memories: **230** tokens → one digest: **120** tokens | **110 tokens removed from the active digest** (**47.8% lower**) | Original memories remain available for provenance and audit |
-| Small histories across 26 CodeMem agent tasks | Always retrieve: **1,883** total agent-facing tokens and **26** memory calls → adaptive: **1,942** tokens and **0** memory calls | Adaptive uses **59 more tokens** (**3.1% higher**) while eliminating all **26** memory calls | Both completed **24/26** tasks with the same deterministic offline task agent; this fixture demonstrates bypass behavior, not token savings |
| Packed prompt-context usage in the same 26-question CodeMem sample pass | Hard budget: **1,500** tokens; observed mean: **85.38**; observed maximum: **108** | A hard cap prevents a recall from exceeding its configured context budget | This is usage accounting, not a before/after savings comparison |
-The LoCoMo context-use row is an **unpinned, noncanonical retrieval diagnostic**, not official
-LoCoMo QA, answer-quality, provider-cost, or leaderboard evidence. It is not reproduced by the
-small offline fixtures below; [BENCHMARKS.md](BENCHMARKS.md) records its exact limitations and
-the separate hash-bound canonical retrieval diagnostic.
+These values are evidence IDs `offline-chunking` and `offline-performance` in
+[`offline-fixtures-v1.json`](https://github.com/Coding-Dev-Tools/engraphis/blob/main/docs/benchmark-evidence/offline-fixtures-v1.json),
+SHA-256
+`c3a74f1770ad3f868f55261ba11680e2dadca30167082ac2cb6669f9e3bdfad2`.
+[`BENCHMARKS.md`](https://github.com/Coding-Dev-Tools/engraphis/blob/main/BENCHMARKS.md#public-numeric-evidence-registry)
+records the matching suite digest, exact commands, and per-command config digests. External,
+model-dependent, consolidation, productivity, and latency results remain unpublished until the
+same evidence exists for them.
The compact payload shape avoids duplicating full memory bodies when the packed context and source
list are enough. The evaluator tokenizes JSON-shaped full and compact payload proxies built from
@@ -90,27 +92,25 @@ The measures are deliberately separate and **must not be added together**: chunk
content of retrieved memory records before `ContextPacker`, whereas compact recall counts a
serialized JSON-shape payload proxy. “Tokens to evidence” is the size of the smallest
retrieved memory record holding the reference evidence; it is not latency or end-to-end answer
-accuracy. Chunking creates more focused stored records (24 chunks rather than 6 whole-document
-memories in this fixture), so this is a context-efficiency result, not a storage-reduction claim.
+accuracy. Chunking creates more focused stored records, so this is a context-efficiency result,
+not a storage-reduction claim.
-Reproduce the quality and token/context measurements without a network connection or API key:
+Reproduce the registered quality and token/context measurements without a network connection or
+API key:
```bash
-python -m eval.harness --dataset eval/datasets/codemem.jsonl --k 5
python -m eval.grounded
-python -m eval.chunking_eval
-python -m eval.adversarial_memory_security
+python -m eval.chunking_eval --dataset eval/datasets/longdoc.jsonl --k 5
python -m eval.performance --dataset eval/datasets/codemem.jsonl --k 5 --iterations 10 --json
-python -m eval.productivity --dataset eval/datasets/codemem.jsonl
```
These are small deterministic correctness and efficiency fixtures, not official LoCoMo /
LongMemEval QA scores or a third-party leaderboard result. Compact-response counts use the exact
`engraphis.regex.v1` counter; the chunking evaluation uses its documented deterministic
normalized-character estimator. Chunking measures retrieved memory content, while compact recall
-measures a serialized JSON-shape payload proxy, not an MCP transport response. See
-[`BENCHMARKS.md`](BENCHMARKS.md) for definitions,
-limitations, canonical external-evaluation requirements, and the no-unsupported-claims policy.
+measures a serialized JSON-shape payload proxy, not an MCP transport response. See the registered
+artifact and [`BENCHMARKS.md`](https://github.com/Coding-Dev-Tools/engraphis/blob/main/BENCHMARKS.md)
+for definitions, limitations, and canonical external-evaluation requirements.
@@ -143,7 +143,7 @@ continues to support Python 3.9+.
| Offline Python library | `pip install engraphis` | `MemoryService.create("engraphis.db")` |
For MCP clients other than Codex, configure a stdio server whose command is `engraphis-mcp`; see
-the [agent connection guide](docs/AGENT_CONNECT.md).
+the [agent connection guide](https://github.com/Coding-Dev-Tools/engraphis/blob/main/docs/AGENT_CONNECT.md).
### Updating
@@ -159,12 +159,20 @@ selection, set `ENGRAPHIS_UPDATE_EXTRAS` to a comma-separated list (for example
> and `pinned_at`/`unpinned_at`; v9 adds the `memory_tombstones` repository-scope column/table
> and performs a one-time entity-canonicalization repair, then migrates automatically on first
> open. A tombstone with a known `repo_id` is terminal only in that repository; legacy repo-less
-> tombstones remain global. See the [1.4.0 release notes](CHANGELOG.md#140---2026-08-02).
+> tombstones remain global. See the [1.4.0 release notes](https://github.com/Coding-Dev-Tools/engraphis/blob/main/CHANGELOG.md#140---2026-08-02).
> **Upgrading to 1.5:** schema 10 bounds legacy retention state and schema 11 backfills explicit
> approval only for eligible pre-review local memories. Pending and quarantined evidence remains
> gated. Existing 1.4.x databases migrate automatically when Engraphis 1.5 opens them; see the
-> [1.5 release notes](CHANGELOG.md#150---2026-08-04).
+> [1.5 release notes](https://github.com/Coding-Dev-Tools/engraphis/blob/main/CHANGELOG.md#150---2026-08-04).
+
+> **Current source:** schema 15 adds the local source-import manifest (vaults, imports,
+> job items) for document and note-collection tracking. Schema 14 introduced the
+> initial note-collection manifest; schema 15 generalized it to support multiple source
+> kinds with content-free scope-security triggers.
+> Schema 12 classifies content-free erasure markers before sync: existing markers migrate to
+> local-only `never_export`; new secure erasures become `remote_erasure` only for non-secret
+> `workspace`/`repo` records that were already eligible for sharing.
---
@@ -207,6 +215,10 @@ Appearance & Engine** (Classic).
| **Docker** | `docker compose up`: see `docker-compose.yml` for the one-command deployment |
| **Any** | `engraphis-dashboard` in a terminal |
+In a source checkout, `scripts/launch_dashboard.ps1` is only a Windows convenience wrapper. It
+delegates configuration, startup health, browser opening, and process lifecycle to the same
+`engraphis-dashboard` entrypoint rather than maintaining a second behavior path.
+
### Accessibility-first inspection, built in
Inspect memories, supersession diffs, recall scores, timelines, links, consolidation, and audit
@@ -233,13 +245,13 @@ The memory engine, embeddings, conflict resolution, and recall stay local withou
explicitly configured provider adds structured extraction, cited synthesis, consolidation, and
retention supervision. Configure it in **Settings → Connect an LLM**. The activity view records
outcomes, never keys, prompts, or raw provider responses. See the
-[LLM provider guide](docs/LLM_PROVIDERS.md) for setup and privacy choices.
+[LLM provider guide](https://github.com/Coding-Dev-Tools/engraphis/blob/main/docs/LLM_PROVIDERS.md) for setup and privacy choices.
> Privacy boundary: text sent to an explicitly selected provider leaves the local process under
> that provider's terms. Use `ENGRAPHIS_RETENTION_SUPERVISOR=none` (the default) and the offline
> `chunk` extractor when ingestion must remain entirely local.
-Choose and configure an external LLM with the [LLM provider guide](docs/LLM_PROVIDERS.md),
+Choose and configure an external LLM with the [LLM provider guide](https://github.com/Coding-Dev-Tools/engraphis/blob/main/docs/LLM_PROVIDERS.md),
including OpenAI, Anthropic, Google, OpenRouter, Ollama, Cohere Command, Command Code Provider,
and other compatible endpoints. The guide also covers Codex subscription MCP connections.
@@ -275,7 +287,7 @@ pipeline. Measure your machine with `python -m eval.vector_scale --backend numpy
`python -m eval.performance` on a representative corpus. If exact scans miss your latency target,
install `engraphis[vector]`, create the engine with `vector_backend="sqlite-vec"`, and remeasure.
The stable sqlite-vec `vec0` backend executes exact KNN in native code; it is acceleration, not a
-claim of sublinear ANN scaling. See [BENCHMARKS.md](BENCHMARKS.md) for the reproducible commands
+claim of sublinear ANN scaling. See [BENCHMARKS.md](https://github.com/Coding-Dev-Tools/engraphis/blob/main/BENCHMARKS.md) for the reproducible commands
and reporting limits.
Dashboard, REST, and MCP entrypoints default to `ENGRAPHIS_VECTOR_BACKEND=auto`: they use
@@ -285,6 +297,14 @@ Programmatic `MemoryEngine.create()` and `MemoryService.create()` retain the det
Use `python -m eval.vector_scale --backend sqlite-vec` for an input-identical direct-search
comparison; setup/index-build time is explicitly excluded from the timed search envelope.
+Persistent vectors fail closed unless the embedder can publish a durable, secret-free space
+fingerprint. Sentence Transformers use the loaded Hub commit or a manifest of local artifacts;
+when a remote model's immutable identity cannot be resolved, persistent vector recall remains
+gated instead of mixing spaces. For programmatic OpenAI-compatible embeddings, construct
+`ApiEmbedder` with an operator/provider `space_version`; without it the adapter remains usable for
+ephemeral embedding only. Its `base_url` may be a provider root or a `/v1` root and is normalized
+to exactly one `/v1/embeddings` endpoint.
+
`sqlcipher3-binary` publishes CPython manylinux x86-64 wheels. On that target,
`engraphis[encryption]` installs the driver. The cross-platform `all` extra deliberately
omits it so `all` remains resolvable on macOS, Windows, Linux ARM, and musl; on those
@@ -325,12 +345,12 @@ docker compose up # → http://127.0.0.1:8700
```
For Docker Compose persistence and loopback-port configuration, see the
-[Docker deployment guide](docs/DOCKER.md).
+[Docker deployment guide](https://github.com/Coding-Dev-Tools/engraphis/blob/main/docs/DOCKER.md).
`engraphis-server` and `engraphis server` are headless compatibility aliases
for this same v2 service, so every public surface has the same scoped recall and retention model.
For optional LAN exposure, token configuration, and HTTP MCP setup, see the
-[Docker deployment guide](docs/DOCKER.md).
+[Docker deployment guide](https://github.com/Coding-Dev-Tools/engraphis/blob/main/docs/DOCKER.md).
Set `ENGRAPHIS_API_TOKEN` to require API authentication and `ENGRAPHIS_DB_KEY` to encrypt
the local database at rest. Hosted-plan credentials configure customer clients; they do not
@@ -342,13 +362,13 @@ install premium server implementations into this image. See `docker-compose.yml`
```bash
pip install "engraphis[mcp]"
-engraphis-init # writes .env + prints config snippets
+engraphis-init # writes ~/.engraphis/config.env + prints config snippets
claude mcp add engraphis -- engraphis-mcp
codex mcp add engraphis -- engraphis-mcp # Codex subscription
```
-For Codex subscription setup and verification, see the [agent connection guide](docs/AGENT_CONNECT.md)
-and the [LLM provider guide](docs/LLM_PROVIDERS.md).
+For Codex subscription setup and verification, see the [agent connection guide](https://github.com/Coding-Dev-Tools/engraphis/blob/main/docs/AGENT_CONNECT.md)
+and the [LLM provider guide](https://github.com/Coding-Dev-Tools/engraphis/blob/main/docs/LLM_PROVIDERS.md).
`engraphis-mcp` is zero-configuration Smart MCP: agents begin with nine compact tools for sessions,
prompt-ready recall, durable memory, governed record read/update, conflict review, action discovery,
@@ -358,21 +378,21 @@ the indicated read or action executor; no profile selection is required. The gat
the discovered capability again before it runs it, and clients remain responsible for their
normal destructive-action approval boundary.
-Existing clients that pin the historical 33 named tools can use
+Existing clients that pin the historical 34 named tools can use
`engraphis-mcp-classic` (or `engraphis-mcp-http --classic`). The complete classic inventory,
-including `engraphis_check_update`, is in the [MCP tool reference](docs/MCP_TOOLS.md).
+including `engraphis_check_update`, is in the [MCP tool reference](https://github.com/Coding-Dev-Tools/engraphis/blob/main/docs/MCP_TOOLS.md).
### Pi extension
For installation, configuration, lifecycle commands, and the local trust boundary, see the
-[Pi extension guide](integrations/pi/README.md).
+[Pi extension guide](https://github.com/Coding-Dev-Tools/engraphis/blob/main/integrations/pi/README.md).
### Hermes provider
Engraphis also ships a native Hermes memory-provider plugin with local prefetch, bounded turn
capture, scoped recall, and explicit secure erase. Install Engraphis in the Hermes Python
environment, copy the provider, then select it with `hermes memory setup`. See the
-[Hermes integration guide](integrations/hermes/README.md). The provider never installs itself or
+[Hermes integration guide](https://github.com/Coding-Dev-Tools/engraphis/blob/main/integrations/hermes/README.md). The provider never installs itself or
downloads an embedding model.
## Quickstart: repository graph
@@ -411,7 +431,7 @@ engraphis-graph-server # API at http://127.0.0.1:8720; schema at
```
A non-loopback bind fails closed unless `ENGRAPHIS_GRAPH_TOKEN` (or
-`ENGRAPHIS_API_TOKEN`) is set. See [the v3 architecture/design document](docs/ARCHITECTURE_V3.md).
+`ENGRAPHIS_API_TOKEN`) is set. See [the v3 architecture/design document](https://github.com/Coding-Dev-Tools/engraphis/blob/main/docs/ARCHITECTURE_V3.md).
---
@@ -428,11 +448,15 @@ print(hit["context"])
The same `MemoryService` backs the dashboard and the MCP server.
+New writes support `session`, `repo`, and `workspace` visibility. `scope="user"` is reserved and
+rejected until records carry an immutable owner identity; it must not be treated as private
+per-person memory. Historical user-scope rows remain workspace-bound for compatibility.
+
After an upgrade, `stats()` reports prompt-eligibility counts and active embedding-space
coverage. Zero-result recall identifies a review-gated scope instead of silently looking empty,
and `engraphis-cli review list|approve` provides a dry-run-first local bulk workflow. Embedding
model changes trigger a guarded rebuild; vector recall stays disabled until every stored vector
-matches the new fingerprint. See [recall recovery](docs/RECALL_RECOVERY.md).
+matches the new fingerprint. See [recall recovery](https://github.com/Coding-Dev-Tools/engraphis/blob/main/docs/RECALL_RECOVERY.md).
Agent hosts can avoid retrieval when their existing history already fits:
@@ -458,7 +482,7 @@ For an agent prompt, prefer `engraphis_recall_context`: it returns one hard-budg
reader's tokenizer when reader-model token parity is required. `engraphis_recall` remains the compatible full-recall
surface; use `response_mode="compact"` when the packed context is enough and full memory bodies
would duplicate it. For advanced query-planning configuration, see the
-[architecture guide](docs/ARCHITECTURE_V3.md#query-planning).
+[architecture guide](https://github.com/Coding-Dev-Tools/engraphis/blob/main/docs/ARCHITECTURE_V3.md#query-planning).
For bi-temporal reads, `valid_at` selects what was true at a Unix timestamp and `known_at` selects
what Engraphis had learned then. `as_of` remains a compatibility alias for `valid_at`; supplying
@@ -484,7 +508,7 @@ Engraphis separates automatic write resolution from explicit human governance:
| `promote` | A narrow learning now applies more broadly | Writes a wider-scope successor and closes/links the source instead of editing scope in place |
| `merge` | Combining two or more overlapping memories | Retires every source and creates one memory that supersedes all of them |
| `retire` | Removing a memory from live recall | Bi-temporally closes it; the audit/history record remains |
-| `consolidate` | Distilling recurring episodic memories automatically | Creates linked semantic digests; sources stay live unless explicit supersession is requested |
+| `consolidate` | Distilling recurring episodic memories automatically | Creates linked semantic digests; source episodes remain live |
Manual N→1 merge is available through `MemoryService.merge()` and `POST /api/merge`:
@@ -508,7 +532,7 @@ storage; for a legacy leak use the explicitly destructive `MemoryService.secure_
FTS/vector-index and derived graph/link rows, runs SQLite secure-delete, WAL checkpoint, and
VACUUM, and scans recognised local SQLite recovery backups. It cannot erase exports, filesystem
snapshots, remote peers, unknown backups, or information a running/compromised agent already
-read; rotate the credential. See [secure-erasure limits](docs/SECURE_ERASURE.md). `forget`
+read; rotate the credential. See [secure-erasure limits](https://github.com/Coding-Dev-Tools/engraphis/blob/main/docs/SECURE_ERASURE.md). `forget`
remains a deprecated compatibility alias for `retire`.
All sources must belong to the named workspace. The result inherits the strictest source
@@ -523,8 +547,8 @@ The core engine, local dashboard, MCP server, and manual consolidation are Apach
**Pro and Team are services** that provide optional access to the official hosted service; its
control-plane, billing, relay, compute, and Team identity modules live in a private repository.
They do not limit the local core. See
-[hosted plans](docs/HOSTED_PLANS.md), [licensing](docs/LICENSING.md), and
-[Cloud Sync](docs/SYNC.md) for service boundaries, lifecycle, and pricing.
+[hosted plans](https://github.com/Coding-Dev-Tools/engraphis/blob/main/docs/HOSTED_PLANS.md), [licensing](https://github.com/Coding-Dev-Tools/engraphis/blob/main/docs/LICENSING.md), and
+[Cloud Sync](https://github.com/Coding-Dev-Tools/engraphis/blob/main/docs/SYNC.md) for service boundaries, lifecycle, and pricing.
[Subscribe to Pro](https://api.engraphis.com/account?plan=pro&interval=monthly&utm_source=engraphis&utm_medium=docs&utm_campaign=pro_conversion&utm_content=readme_pricing#billing)
to support the project and add hosted services.
@@ -535,7 +559,7 @@ when you are ready to evaluate the service boundary and billing options.
| | Free (available now) | Pro: $10/mo or $100/yr | Team: $20/seat/mo or $200/seat/yr |
|---|---|---|---|
| Dashboard WebUI (with built-in inspector) | ✓ | ✓ | ✓ |
-| Memory engine + Smart MCP (Classic 33-tool compatibility) | ✓ | ✓ | ✓ |
+| Memory engine + Smart MCP (Classic 34-tool compatibility) | ✓ | ✓ | ✓ |
| Version-chain diffs, offline knowledge graph | ✓ | ✓ | ✓ |
| Manual local consolidation (dry-run by default) | ✓ | ✓ | ✓ |
| Local workspace export (JSON: memories, sessions, audit) | ✓ | ✓ | ✓ |
@@ -553,9 +577,9 @@ when you are ready to evaluate the service boundary and billing options.
## MCP tools
-Engraphis exposes a zero-configuration Smart MCP gateway plus a 33-tool Classic compatibility
+Engraphis exposes a zero-configuration Smart MCP gateway plus a 34-tool Classic compatibility
server across memory, recall, code graphs, governance, sessions, and privacy-safe audit receipts.
-The focused [MCP tool reference](docs/MCP_TOOLS.md) is the source for
+The focused [MCP tool reference](https://github.com/Coding-Dev-Tools/engraphis/blob/main/docs/MCP_TOOLS.md) is the source for
the full inventory and parameters.
---
@@ -564,8 +588,8 @@ the full inventory and parameters.
Memory, entity, and code relationships live in one local graph. Engraphis also provides
content-free operation receipts for inspectable audit evidence. See the
-[architecture](docs/ARCHITECTURE_V3.md), [MCP tool reference](docs/MCP_TOOLS.md), and
-[security policy](SECURITY.md) for the data model, tools, and guarantees.
+[architecture](https://github.com/Coding-Dev-Tools/engraphis/blob/main/docs/ARCHITECTURE_V3.md), [MCP tool reference](https://github.com/Coding-Dev-Tools/engraphis/blob/main/docs/MCP_TOOLS.md), and
+[security policy](https://github.com/Coding-Dev-Tools/engraphis/blob/main/SECURITY.md) for the data model, tools, and guarantees.
---
@@ -573,14 +597,14 @@ content-free operation receipts for inspectable audit evidence. See the
Cloud Sync is an optional hosted Pro/Team service. The public package includes the customer client
and deterministic merge implementation; hosted relay and account operations are separate. See
-[Cloud Sync](docs/SYNC.md) for setup, encryption, merge behavior, and the local folder exchange.
+[Cloud Sync](https://github.com/Coding-Dev-Tools/engraphis/blob/main/docs/SYNC.md) for setup, encryption, merge behavior, and the local folder exchange.
---
## Security and trust boundaries
Engraphis is local-first and binds to loopback by default. Read the
-[security policy](SECURITY.md) before remote deployment or integrating external resources; it
+[security policy](https://github.com/Coding-Dev-Tools/engraphis/blob/main/SECURITY.md) before remote deployment or integrating external resources; it
covers supported versions, data protections, threat model, and vulnerability reporting.
---
@@ -616,8 +640,8 @@ oversized key files rather than following an unexpected filesystem object.
Import supported documents and code through the dashboard, a local folder, or MCP. Optional
extractors add offline chunking, structured LLM extraction, document OCR, transcription, and
-PostgreSQL schema ingestion. See the [MCP tool reference](docs/MCP_TOOLS.md),
-[architecture guide](docs/ARCHITECTURE_V3.md), and [security policy](SECURITY.md) for formats,
+PostgreSQL schema ingestion. See the [MCP tool reference](https://github.com/Coding-Dev-Tools/engraphis/blob/main/docs/MCP_TOOLS.md),
+[architecture guide](https://github.com/Coding-Dev-Tools/engraphis/blob/main/docs/ARCHITECTURE_V3.md), and [security policy](https://github.com/Coding-Dev-Tools/engraphis/blob/main/SECURITY.md) for formats,
configuration, and local-resource safeguards.
---
@@ -626,17 +650,20 @@ configuration, and local-resource safeguards.
Manual consolidation is free, local, and dry-run by default; use the dashboard, SDK, CLI, or
MCP. Hosted Pro and Team automation is optional managed compute that produces reviewable
-proposals rather than silently changing local data. See [hosted plans](docs/HOSTED_PLANS.md),
-[licensing](docs/LICENSING.md), and the [MCP tool reference](docs/MCP_TOOLS.md) for scope and use.
+proposals rather than silently changing local data. See [hosted plans](https://github.com/Coding-Dev-Tools/engraphis/blob/main/docs/HOSTED_PLANS.md),
+[licensing](https://github.com/Coding-Dev-Tools/engraphis/blob/main/docs/LICENSING.md), and the [MCP tool reference](https://github.com/Coding-Dev-Tools/engraphis/blob/main/docs/MCP_TOOLS.md) for scope and use.
---
## Configuration
-All via environment (or `.env`):
+Values come from the process environment. Engraphis also loads the owner-private
+`~/.engraphis/config.env`; `ENGRAPHIS_ENV_FILE` can select another absolute owner-private regular
+file. It never searches the working directory for `.env`, and explicit process variables win.
| Env Var | Default | Description |
|---------|---------|-------------|
+| `ENGRAPHIS_ENV_FILE` | `~/.engraphis/config.env` | Optional trusted config leaf selected before dotenv values load. An explicit value must be an absolute path to an owner-private regular file; arbitrary working-directory `.env` files are ignored. |
| `ENGRAPHIS_DB_PATH` | Source: `/engraphis.db`; installed: platform user-data directory | SQLite database file. Installed defaults are `%LOCALAPPDATA%\engraphis\engraphis.db` (Windows), `~/Library/Application Support/engraphis/engraphis.db` (macOS), and `$XDG_DATA_HOME/engraphis/engraphis.db` or `~/.local/share/engraphis/engraphis.db` (Linux). The environment variable overrides every default. |
| `ENGRAPHIS_HOST` | `127.0.0.1` | Server bind address |
| `ENGRAPHIS_PORT` | `8700` | Dashboard port |
@@ -648,7 +675,7 @@ All via environment (or `.env`):
| `ENGRAPHIS_HTTP_INDEX_ROOT` | First `ENGRAPHIS_INDEX_ROOTS` entry, or current directory | Single root for dashboard and REST `POST /api/code/index`; submitted paths resolve beneath it. An explicit root (or fallback entry) must be absolute; an explicit HTTP root is included in the engine-approved set. MCP and CLI indexing continue to use `ENGRAPHIS_INDEX_ROOTS`. |
| `ENGRAPHIS_DB_KEY` | Not set | Encrypt the database at rest (SQLCipher). Or use `ENGRAPHIS_DB_KEY_FILE` |
| `ENGRAPHIS_EMBED_MODEL` | `sentence-transformers/all-MiniLM-L6-v2` | sentence-transformers model |
-| `ENGRAPHIS_EMBED_REVISION` | Not set | Optional immutable lowercase 40-hex Hugging Face commit for the embedding model |
+| `ENGRAPHIS_EMBED_REVISION` | Not set | Optional immutable lowercase 40-hex Hugging Face commit for the embedding model. Loaded Hub commits or local artifact manifests identify persistent vector spaces; unresolved mutable identities keep vector recall fail-closed. |
| `ENGRAPHIS_RERANK_MODEL` | Not set | Optional sentence-transformers cross-encoder reranker |
| `ENGRAPHIS_RERANK_REVISION` | Not set | Optional immutable lowercase 40-hex Hugging Face commit for the reranker |
| `ENGRAPHIS_REQUIRE_IMMUTABLE_MODELS` | `false` | When enabled, require a 40-hex commit before loading remote embedding models, rerankers, or chunk tokenizers; `local:` selectors and filesystem paths remain permitted |
@@ -671,15 +698,18 @@ All via environment (or `.env`):
| `ENGRAPHIS_LLM_AUTO_EXTRACT` | `0` | Opt in to switching the running engine to `llm_structured` after a successful live connection test; the dashboard's extraction Off button persists `0`, and its On button restores `1` |
| `ENGRAPHIS_FORWARDED_ALLOW_IPS` | *(none)* | Proxies trusted for forwarded client/TLS headers (`*` only when the service is reachable exclusively through that proxy) |
| `ENGRAPHIS_LOCAL_TRUSTED_PEERS` | *(none)* | Exact peers/CIDRs treated as local without forwarding headers; use only for trusted Docker/LAN peers, never public deployments |
-| `ENGRAPHIS_CLOUD_CONTROL_URL` | hosted default | Official entitlement, organization, and credential control API |
-| `ENGRAPHIS_CLOUD_COMPUTE_URL` | hosted default | Official Analytics and managed-automation API |
+| `ENGRAPHIS_UPDATE_CACHE` | `86400` | Update-check cache TTL in seconds, bounded to `1..31622400`; this is never a cache-file path |
+| `ENGRAPHIS_CLOUD_CONTROL_URL` | hosted default | Official entitlement, organization, and credential control API. A saved rotating credential stays bound to the control endpoint recorded for its family; reconnect to change it. |
+| `ENGRAPHIS_CLOUD_COMPUTE_URL` | hosted default | Official Analytics and managed-automation API. A saved rotating credential stays bound to its recorded compute endpoint; reconnect to change it. |
| `ENGRAPHIS_CLOUD_ORGANIZATION_ID` | Not set | Hosted organization bound to this customer session |
| `ENGRAPHIS_CLOUD_REFRESH_CREDENTIAL` | Not set | Bootstrap-only rotating hosted credential; after first use the owner-only cloud session replacement takes precedence |
| `ENGRAPHIS_CLOUD_TOKEN_SUBJECT` | `member` | Subject fixed during hosted bootstrap (`device` or `member`); set explicitly with an environment-only refresh credential |
| `ENGRAPHIS_CLOUD_ACCESS_TOKEN` | Not set | Optional short-lived access token for ephemeral jobs |
| `ENGRAPHIS_MANAGED_COMPUTE_CONSENT` | *(auto)* | Operator override only; default follows whether a cloud session is configured (connected = allowed, local-only = never). `0` opts a connected installation out; `1` permits local snapshot preparation but does not create a cloud credential or authorize an upload |
-See `.env.example` for the full customer-runtime and managed-service client options.
+See `.env.example` for the full variable inventory. Supply those values through the process
+environment or the trusted config file above; copying it to an arbitrary `./.env` does not make
+Engraphis load it.
---
@@ -690,8 +720,9 @@ engraphis/
├── engraphis/
│ ├── core/ # v2 engine: interfaces, store, recall, scoring, schema, sync
│ ├── backends/ # pluggable embedder / vector index / reranker / codegraph / sync transports / encryption
+│ ├── factory.py # outer v2 composition root; selects and injects concrete backends
│ ├── service.py # validated MemoryService facade
-│ ├── mcp_server.py # Smart MCP gateway + 33-tool Classic compatibility server
+│ ├── mcp_server.py # Smart MCP gateway + 34-tool Classic compatibility server
│ ├── dashboard_app.py # dashboard WebUI (FastAPI)
│ ├── dashboard_assets/ # primary Ledger interface + graph engine
│ ├── classic_assets/ # selectable full operator dashboard backup
@@ -711,17 +742,21 @@ engraphis/
```
New capability belongs in the v2 path (`engraphis/core/`, `engraphis/backends/`, and
-`MemoryService`) behind the interfaces in `core/interfaces.py`. The flat-namespace v1 server
-under `engraphis/app.py`, `routes/`, `stores/`, and `engines/` remains a compatibility/reference
-surface; `engraphis-dashboard`, the MCP server, and the Python quickstart above use v2.
+`MemoryService`) behind the interfaces in `core/interfaces.py`. Algorithm modules in `core/`
+remain backend-agnostic; `engraphis/factory.py` is the outer composition root used by
+`engraphis.create_memory_engine()` and the compatibility `MemoryEngine.create()` entry point, then
+injects the selected collaborators into `core/engine.py`. The flat-namespace v1 server under
+`engraphis/app.py`, `routes/`, `stores/`, and `engines/` remains a
+compatibility/reference surface; `engraphis-dashboard`, the MCP server, and the Python quickstart
+above use v2.
---
## License
-Apache-2.0. See [LICENSE](LICENSE) and [NOTICE](NOTICE). "Engraphis" is a trademark of the
+Apache-2.0. See [LICENSE](https://github.com/Coding-Dev-Tools/engraphis/blob/main/LICENSE) and [NOTICE](https://github.com/Coding-Dev-Tools/engraphis/blob/main/NOTICE). "Engraphis" is a trademark of the
Engraphis project; the license does not grant trademark rights. Code already distributed
under Apache-2.0 keeps that grant; later releases cannot retroactively withdraw it. The
official hosted control plane, its production credentials and records, managed operations,
support, and future separately delivered commercial modules are outside the public source
-grant. See [`docs/LICENSING.md`](docs/LICENSING.md) for the complete boundary.
+grant. See [`docs/LICENSING.md`](https://github.com/Coding-Dev-Tools/engraphis/blob/main/docs/LICENSING.md) for the complete boundary.
diff --git a/SECURITY.md b/SECURITY.md
index 93824575..91c80424 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -71,7 +71,10 @@ DOMPurify at all render sites. Verified against payloads with `onerror` handlers
workspace a hard boundary; requests outside the list are refused before touching the store
### 4. Secrets & data at rest
-- `.env`, `*.db`, `*.db-wal`, `*.db-shm` are git-ignored; never logged
+- `.env`, `*.db`, `*.db-wal`, `*.db-shm` are git-ignored and must never be logged. Gitignore is
+ not a runtime trust boundary: Engraphis never searches the working directory for `.env`.
+ Configuration files are limited to owner-private `~/.engraphis/config.env` or an absolute
+ owner-private regular file selected by `ENGRAPHIS_ENV_FILE`; explicit process variables win.
- **Encryption at rest (opt-in):** `ENGRAPHIS_DB_KEY` / `ENGRAPHIS_DB_KEY_FILE` +
`pip install "engraphis[encryption]"` → AES-256 via SQLCipher. Whole-file; lose key = lose data.
Off by default; without it, protect with filesystem permissions + full-disk encryption.
@@ -133,6 +136,9 @@ them back as `expected_head` / `expected_count` when independent evidence is req
- **Cloud authorization:** the public package accepts only short-lived scoped access tokens or
a rotating refresh credential bound to its bootstrap `device` or `member` subject. It contains
no paid-key parser, signer, issuer, local feature gate, or long-lived-key relay exchange.
+- **Credential-origin binding:** a saved rotating credential remains bound to the control/compute
+ URLs recorded for its family; environment changes cannot redirect it. A standalone sync token
+ likewise requires `ENGRAPHIS_SYNC_TOKEN_ORIGIN` to match the relay origin.
- **Server authority:** every hosted and cost-bearing operation is authorized by the private
control plane; local plan labels and upgrade URLs are presentation metadata only.
- **Cloud Sync and managed-compute privacy:** Cloud Sync encrypts eligible shared-workspace
@@ -145,6 +151,10 @@ them back as `expected_head` / `expected_count` when independent evidence is req
carries normal and sensitive memory content, excludes secret-class and session-scoped rows, and
is capped at 16 MiB. Secret-class memories are excluded before serialization and rejected again
by the hosted service.
+- **Cloud Sync rollback bounds:** version-3 snapshots chain per-device generations, state hashes,
+ and tombstone checkpoints. Previously observed rollback is rejected, but first contact remains
+ unanchored/incomplete until the hosted service supplies an authenticated workspace manifest:
+ a local client cannot prove that an untrusted relay did not withhold an unseen device.
- **Trial and grace are separate:** an email-confirmed trial lasts exactly 3 active days. A
separately bounded, maximum-24-hour local workspace-write grace never extends the trial,
subscription, Cloud Sync, managed compute, Team access, seats, or credentials.
diff --git a/demo/README.md b/demo/README.md
index a2328caa..bb8f85d3 100644
--- a/demo/README.md
+++ b/demo/README.md
@@ -7,6 +7,7 @@ This produces a silent 56-second MP4 showing the three proof points requested:
3. Retrieval evidence sits next to the Timeline chain, showing the retrieval arm, fused score, retention, provenance, and current/past validity.
The payload is generated from a real in-memory `MemoryService` run before recording. No credentials, live services, or external APIs are used.
+The HTML preview labels its built-in sample fallback when generated evidence is unavailable. The recorder waits for hydration and refuses to capture unless the generated payload was loaded successfully.
Install the repository's Node dependencies and Chromium once, and ensure `ffmpeg` is on `PATH`:
diff --git a/demo/engraphis_screen_demo.html b/demo/engraphis_screen_demo.html
index 1e291419..e6a1b38c 100644
--- a/demo/engraphis_screen_demo.html
+++ b/demo/engraphis_screen_demo.html
@@ -47,6 +47,7 @@
.top-meta { display: flex; gap: 10px; align-items: center; font-size: 11px; letter-spacing: .1em; text-transform: uppercase; }
.live-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--green); box-shadow: 0 0 16px var(--green); }
.pill { display: inline-flex; align-items: center; gap: 7px; border: 1px solid var(--line); border-radius: 999px; padding: 5px 10px; color: var(--muted); background: rgba(255,255,255,.025); font-size: 11px; white-space: nowrap; }
+ [hidden] { display: none !important; }
.pill.violet { color: #d9ccff; border-color: rgba(167,139,250,.38); background: rgba(167,139,250,.09); }
.pill.cyan { color: #b9f7ff; border-color: rgba(103,232,249,.38); background: rgba(103,232,249,.08); }
.pill.green { color: #bbf7de; border-color: rgba(110,231,183,.38); background: rgba(110,231,183,.08); }
@@ -163,7 +164,7 @@
Engraphis/Memory continuity
-
local replay56 sec00:00
+
local replaysample fallback data56 sec00:00
@@ -225,10 +226,12 @@
Recall the context. Keep the history.
timeline: [{ content: "The screen demo records against the standard dashboard port 8700.", valid_to: 1 }, { content: "The screen demo records against port 8790 so it does not collide with a developer dashboard.", valid_to: null }],
inspection: { events: [{ action: "invalidate", detail: "superseded prior version" }] }
};
+ window.demoPayloadReady = false;
+ window.demoPayloadSource = "loading";
const esc = (value) => String(value ?? "").replace(/[&<>"']/g, (char) => ({"&":"&","<":"<",">":">","\"":""","'":"'"}[char]));
const shortId = (value) => value ? value.slice(0, 18) + "…" : "ses_01…";
const text = (selector, value) => { const node = document.querySelector(selector); if (node) node.textContent = value ?? ""; };
- function hydrate(data) {
+ function hydrate(data, source) {
const session = data.session || FALLBACK.session;
const recall = data.recall || FALLBACK.recall;
const memory = recall.memory || FALLBACK.recall.memory;
@@ -252,8 +255,38 @@
`; }).join("");
+ const sourceBadge = document.getElementById("payload-source");
+ sourceBadge.hidden = source === "generated";
+ window.demoPayloadSource = source;
+ window.demoPayloadReady = true;
}
- fetch("generated/screen_demo_payload.json").then((response) => response.ok ? response.json() : Promise.reject(new Error("payload unavailable"))).then(hydrate).catch(() => hydrate(FALLBACK));
+ const nonEmpty = (value) => typeof value === "string" && value.trim().length > 0;
+ const validPayload = (data) => {
+ const session = data && data.session;
+ const memory = data && data.recall && data.recall.memory;
+ const timeline = data && data.timeline;
+ const events = data && data.inspection && data.inspection.events;
+ return Boolean(
+ session && nonEmpty(session.session_id) && session.bootstrap
+ && nonEmpty(session.bootstrap.summary)
+ && data.recall && nonEmpty(data.recall.query)
+ && memory && nonEmpty(memory.title) && nonEmpty(memory.content) && nonEmpty(memory.arm)
+ && Number.isFinite(Number(memory.score)) && Number.isFinite(Number(memory.retention))
+ && memory.provenance && nonEmpty(memory.provenance.source)
+ && Array.isArray(timeline) && timeline.length >= 2
+ && timeline.every((item) => item && nonEmpty(item.content)
+ && item.provenance && nonEmpty(item.provenance.source))
+ && Array.isArray(events) && events.length > 0
+ && events.every((item) => item && nonEmpty(item.action) && nonEmpty(item.detail))
+ );
+ };
+ fetch("generated/screen_demo_payload.json")
+ .then((response) => response.ok ? response.json() : Promise.reject(new Error("payload unavailable")))
+ .then((data) => {
+ if (!validPayload(data)) throw new Error("payload is incomplete");
+ hydrate(data, "generated");
+ })
+ .catch(() => hydrate(FALLBACK, "fallback"));
const TOTAL = 56;
const scenes = [
diff --git a/demo/prepare_screen_demo.py b/demo/prepare_screen_demo.py
index 77bb97d9..ad358103 100644
--- a/demo/prepare_screen_demo.py
+++ b/demo/prepare_screen_demo.py
@@ -59,7 +59,7 @@ def build_payload() -> dict:
session_id=session["session_id"],
title="Where to build",
importance=0.95,
- source="demo-seed",
+ source="agent",
kind="demo_fixture",
)
@@ -70,8 +70,10 @@ def build_payload() -> dict:
session_id=session["session_id"],
title="Demo configuration",
importance=0.80,
- source="demo-seed",
+ source="agent",
kind="demo_fixture",
+ subject_key="screen-demo-recorder",
+ claim_kind="port",
)
current_endpoint = svc.remember(
(
@@ -83,8 +85,10 @@ def build_payload() -> dict:
session_id=session["session_id"],
title="Demo configuration",
importance=0.90,
- source="demo-seed",
+ source="agent",
kind="demo_fixture",
+ subject_key="screen-demo-recorder",
+ claim_kind="port",
)
assert current_endpoint["op"] == "invalidate", current_endpoint
diff --git a/demo/record_screen_demo.mjs b/demo/record_screen_demo.mjs
index 891cd42b..a622a79e 100644
--- a/demo/record_screen_demo.mjs
+++ b/demo/record_screen_demo.mjs
@@ -59,15 +59,26 @@ const context = await browser.newContext({
deviceScaleFactor: 1,
});
const page = await context.newPage();
-await page.goto(`http://127.0.0.1:${port}/${html}?autoplay=1`, { waitUntil: "networkidle" });
-// Keep the capture clock independent from requestAnimationFrame throttling in
-// headless environments and leave a small tail after the page's 56-second animation.
-await page.waitForTimeout(durationMs);
-await context.close();
-await browser.close();
-server.close();
-
-const recorded = await page.video().path();
+let recorded;
+try {
+ await page.goto(`http://127.0.0.1:${port}/${html}?autoplay=1`, { waitUntil: "networkidle" });
+ await page.waitForFunction(() => window.demoPayloadReady === true, null, { timeout: 10_000 });
+ const payloadSource = await page.evaluate(() => window.demoPayloadSource);
+ if (payloadSource !== "generated") {
+ throw new Error(`Refusing to record demo from ${payloadSource || "unknown"} payload`);
+ }
+ // Keep the capture clock independent from requestAnimationFrame throttling in
+ // headless environments and leave a small tail after the page's 56-second animation.
+ await page.waitForTimeout(durationMs);
+ await context.close();
+ recorded = await page.video().path();
+} catch (error) {
+ await context.close().catch(() => {});
+ throw error;
+} finally {
+ await browser.close();
+ server.close();
+}
const ffmpeg = process.env.FFMPEG || "ffmpeg";
const encoded = spawnSync(ffmpeg, [
"-y", "-i", recorded,
diff --git a/deploy/force-graph-1.51.4.licenses.json b/deploy/force-graph-1.51.4.licenses.json
new file mode 100644
index 00000000..c365c6d4
--- /dev/null
+++ b/deploy/force-graph-1.51.4.licenses.json
@@ -0,0 +1,399 @@
+{
+ "bundle": {
+ "direct_dependencies": {
+ "@tweenjs/tween.js": "18 - 25",
+ "accessor-fn": "1",
+ "bezier-js": "3 - 6",
+ "canvas-color-tracker": "^1.3",
+ "d3-array": "1 - 3",
+ "d3-drag": "2 - 3",
+ "d3-force-3d": "2 - 3",
+ "d3-scale": "1 - 4",
+ "d3-scale-chromatic": "1 - 3",
+ "d3-selection": "2 - 3",
+ "d3-zoom": "2 - 3",
+ "float-tooltip": "^1.7",
+ "index-array-by": "1",
+ "kapsule": "^1.16",
+ "lodash-es": "4"
+ },
+ "name": "force-graph",
+ "npm_integrity": "sha512-TdJ2KbkoiDQ7NIRx8IPGD0mAXXpLhamS7c+b7W98b0MHG7lphnda1VOQX/98UDTsttIAdH4TcP0l0MauSnLK8w==",
+ "path": "engraphis/static/vendor/force-graph.min.js",
+ "sha256": "5b71387a1ebbe99a2e8c845cd6ac40f776e363dbcb6a4dd200001b70e7b7271e",
+ "upstream_commit": "baa20a92bbe5628034d771abaf33a2dbb65d22eb",
+ "version": "1.51.4"
+ },
+ "dependencies": [
+ {
+ "copyright": [
+ "Copyright (c) 2010-2012 Tween.js authors.",
+ "Easing equations Copyright (c) 2001 Robert Penner http://robertpenner.com/easing/",
+ "The above copyright notice and this permission notice shall be included in",
+ "AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER"
+ ],
+ "license": "MIT",
+ "license_source": "npm package @tweenjs/tween.js@25.0.0/LICENSE",
+ "license_text": "The MIT License\n\nCopyright (c) 2010-2012 Tween.js authors.\n\nEasing equations Copyright (c) 2001 Robert Penner http://robertpenner.com/easing/\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n",
+ "name": "@tweenjs/tween.js",
+ "version": "25.0.0"
+ },
+ {
+ "copyright": [
+ "Copyright (c) 2017 Vasco Asturiano",
+ "The above copyright notice and this permission notice shall be included in all",
+ "AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER"
+ ],
+ "license": "MIT",
+ "license_source": "npm package accessor-fn@1.5.3/LICENSE",
+ "license_text": "MIT License\n\nCopyright (c) 2017 Vasco Asturiano\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n",
+ "name": "accessor-fn",
+ "version": "1.5.3"
+ },
+ {
+ "copyright": [
+ "Copyright (c) Pomax",
+ "The above copyright notice and this permission notice shall be included in all",
+ "AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER"
+ ],
+ "license": "MIT",
+ "license_source": "package.json SPDX declaration plus author-attributed standard MIT text",
+ "license_text": "MIT License\n\nCopyright (c) Pomax\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n",
+ "name": "bezier-js",
+ "version": "6.1.4"
+ },
+ {
+ "copyright": [
+ "Copyright (c) 2018 Vasco Asturiano",
+ "The above copyright notice and this permission notice shall be included in all",
+ "AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER"
+ ],
+ "license": "MIT",
+ "license_source": "npm package canvas-color-tracker@1.3.2/LICENSE",
+ "license_text": "MIT License\n\nCopyright (c) 2018 Vasco Asturiano\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n",
+ "name": "canvas-color-tracker",
+ "version": "1.3.2"
+ },
+ {
+ "copyright": [
+ "Copyright 2010-2023 Mike Bostock",
+ "with or without fee is hereby granted, provided that the above copyright notice"
+ ],
+ "license": "ISC",
+ "license_source": "npm package d3-array@3.2.4/LICENSE",
+ "license_text": "Copyright 2010-2023 Mike Bostock\n\nPermission to use, copy, modify, and/or distribute this software for any purpose\nwith or without fee is hereby granted, provided that the above copyright notice\nand this permission notice appear in all copies.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH\nREGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND\nFITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,\nINDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS\nOF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER\nTORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF\nTHIS SOFTWARE.\n",
+ "name": "d3-array",
+ "version": "3.2.4"
+ },
+ {
+ "copyright": [
+ "Copyright (c) 2017 Vasco Asturiano",
+ "The above copyright notice and this permission notice shall be included in all",
+ "AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER"
+ ],
+ "license": "MIT",
+ "license_source": "npm package d3-binarytree@1.0.2/LICENSE",
+ "license_text": "MIT License\n\nCopyright (c) 2017 Vasco Asturiano\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n",
+ "name": "d3-binarytree",
+ "version": "1.0.2"
+ },
+ {
+ "copyright": [
+ "Copyright 2010-2022 Mike Bostock",
+ "with or without fee is hereby granted, provided that the above copyright notice"
+ ],
+ "license": "ISC",
+ "license_source": "npm package d3-color@3.1.0/LICENSE",
+ "license_text": "Copyright 2010-2022 Mike Bostock\n\nPermission to use, copy, modify, and/or distribute this software for any purpose\nwith or without fee is hereby granted, provided that the above copyright notice\nand this permission notice appear in all copies.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH\nREGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND\nFITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,\nINDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS\nOF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER\nTORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF\nTHIS SOFTWARE.\n",
+ "name": "d3-color",
+ "version": "3.1.0"
+ },
+ {
+ "copyright": [
+ "Copyright 2010-2021 Mike Bostock",
+ "with or without fee is hereby granted, provided that the above copyright notice"
+ ],
+ "license": "ISC",
+ "license_source": "npm package d3-dispatch@3.0.1/LICENSE",
+ "license_text": "Copyright 2010-2021 Mike Bostock\n\nPermission to use, copy, modify, and/or distribute this software for any purpose\nwith or without fee is hereby granted, provided that the above copyright notice\nand this permission notice appear in all copies.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH\nREGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND\nFITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,\nINDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS\nOF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER\nTORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF\nTHIS SOFTWARE.\n",
+ "name": "d3-dispatch",
+ "version": "3.0.1"
+ },
+ {
+ "copyright": [
+ "Copyright 2010-2021 Mike Bostock",
+ "with or without fee is hereby granted, provided that the above copyright notice"
+ ],
+ "license": "ISC",
+ "license_source": "npm package d3-drag@3.0.0/LICENSE",
+ "license_text": "Copyright 2010-2021 Mike Bostock\n\nPermission to use, copy, modify, and/or distribute this software for any purpose\nwith or without fee is hereby granted, provided that the above copyright notice\nand this permission notice appear in all copies.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH\nREGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND\nFITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,\nINDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS\nOF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER\nTORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF\nTHIS SOFTWARE.\n",
+ "name": "d3-drag",
+ "version": "3.0.0"
+ },
+ {
+ "copyright": [
+ "Copyright 2010-2021 Mike Bostock",
+ "Copyright 2001 Robert Penner",
+ "* Redistributions of source code must retain the above copyright notice, this",
+ "* Redistributions in binary form must reproduce the above copyright notice,",
+ "THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\" AND",
+ "DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR"
+ ],
+ "license": "BSD-3-Clause",
+ "license_source": "npm package d3-ease@3.0.1/LICENSE",
+ "license_text": "Copyright 2010-2021 Mike Bostock\nCopyright 2001 Robert Penner\nAll rights reserved.\n\nRedistribution and use in source and binary forms, with or without modification,\nare permitted provided that the following conditions are met:\n\n* Redistributions of source code must retain the above copyright notice, this\n list of conditions and the following disclaimer.\n\n* Redistributions in binary form must reproduce the above copyright notice,\n this list of conditions and the following disclaimer in the documentation\n and/or other materials provided with the distribution.\n\n* Neither the name of the author nor the names of contributors may be used to\n endorse or promote products derived from this software without specific prior\n written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\" AND\nANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED\nWARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE\nDISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR\nANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES\n(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;\nLOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON\nANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS\nSOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n",
+ "name": "d3-ease",
+ "version": "3.0.1"
+ },
+ {
+ "copyright": [
+ "Copyright (c) 2017 Vasco Asturiano",
+ "The above copyright notice and this permission notice shall be included in all",
+ "AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER"
+ ],
+ "license": "MIT",
+ "license_source": "npm package d3-force-3d@3.0.6/LICENSE",
+ "license_text": "MIT License\n\nCopyright (c) 2017 Vasco Asturiano\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n",
+ "name": "d3-force-3d",
+ "version": "3.0.6"
+ },
+ {
+ "copyright": [
+ "Copyright 2010-2026 Mike Bostock",
+ "with or without fee is hereby granted, provided that the above copyright notice"
+ ],
+ "license": "ISC",
+ "license_source": "npm package d3-format@3.1.2/LICENSE",
+ "license_text": "Copyright 2010-2026 Mike Bostock\n\nPermission to use, copy, modify, and/or distribute this software for any purpose\nwith or without fee is hereby granted, provided that the above copyright notice\nand this permission notice appear in all copies.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH\nREGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND\nFITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,\nINDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS\nOF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER\nTORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF\nTHIS SOFTWARE.\n",
+ "name": "d3-format",
+ "version": "3.1.2"
+ },
+ {
+ "copyright": [
+ "Copyright 2010-2021 Mike Bostock",
+ "with or without fee is hereby granted, provided that the above copyright notice"
+ ],
+ "license": "ISC",
+ "license_source": "npm package d3-interpolate@3.0.1/LICENSE",
+ "license_text": "Copyright 2010-2021 Mike Bostock\n\nPermission to use, copy, modify, and/or distribute this software for any purpose\nwith or without fee is hereby granted, provided that the above copyright notice\nand this permission notice appear in all copies.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH\nREGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND\nFITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,\nINDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS\nOF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER\nTORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF\nTHIS SOFTWARE.\n",
+ "name": "d3-interpolate",
+ "version": "3.0.1"
+ },
+ {
+ "copyright": [
+ "Copyright (c) 2017 Vasco Asturiano",
+ "The above copyright notice and this permission notice shall be included in all",
+ "AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER"
+ ],
+ "license": "MIT",
+ "license_source": "npm package d3-octree@1.1.0/LICENSE",
+ "license_text": "MIT License\n\nCopyright (c) 2017 Vasco Asturiano\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n",
+ "name": "d3-octree",
+ "version": "1.1.0"
+ },
+ {
+ "copyright": [
+ "Copyright 2010-2021 Mike Bostock",
+ "with or without fee is hereby granted, provided that the above copyright notice"
+ ],
+ "license": "ISC",
+ "license_source": "npm package d3-quadtree@3.0.1/LICENSE",
+ "license_text": "Copyright 2010-2021 Mike Bostock\n\nPermission to use, copy, modify, and/or distribute this software for any purpose\nwith or without fee is hereby granted, provided that the above copyright notice\nand this permission notice appear in all copies.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH\nREGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND\nFITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,\nINDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS\nOF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER\nTORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF\nTHIS SOFTWARE.\n",
+ "name": "d3-quadtree",
+ "version": "3.0.1"
+ },
+ {
+ "copyright": [
+ "Copyright 2010-2021 Mike Bostock",
+ "with or without fee is hereby granted, provided that the above copyright notice"
+ ],
+ "license": "ISC",
+ "license_source": "npm package d3-scale@4.0.2/LICENSE",
+ "license_text": "Copyright 2010-2021 Mike Bostock\n\nPermission to use, copy, modify, and/or distribute this software for any purpose\nwith or without fee is hereby granted, provided that the above copyright notice\nand this permission notice appear in all copies.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH\nREGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND\nFITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,\nINDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS\nOF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER\nTORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF\nTHIS SOFTWARE.\n",
+ "name": "d3-scale",
+ "version": "4.0.2"
+ },
+ {
+ "copyright": [
+ "Copyright 2010-2024 Mike Bostock",
+ "with or without fee is hereby granted, provided that the above copyright notice",
+ "Copyright 2002 Cynthia Brewer, Mark Harrower, and The Pennsylvania State University"
+ ],
+ "license": "ISC",
+ "license_source": "npm package d3-scale-chromatic@3.1.0/LICENSE",
+ "license_text": "Copyright 2010-2024 Mike Bostock\n\nPermission to use, copy, modify, and/or distribute this software for any purpose\nwith or without fee is hereby granted, provided that the above copyright notice\nand this permission notice appear in all copies.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH\nREGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND\nFITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,\nINDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS\nOF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER\nTORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF\nTHIS SOFTWARE.\n\nApache-Style Software License for ColorBrewer software and ColorBrewer Color Schemes\n\nCopyright 2002 Cynthia Brewer, Mark Harrower, and The Pennsylvania State University\n\nLicensed under the Apache License, Version 2.0 (the \"License\"); you may not use\nthis file except in compliance with the License. You may obtain a copy of the\nLicense at\n\nhttp://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software distributed\nunder the License is distributed on an \"AS IS\" BASIS, WITHOUT WARRANTIES OR\nCONDITIONS OF ANY KIND, either express or implied. See the License for the\nspecific language governing permissions and limitations under the License.\n",
+ "name": "d3-scale-chromatic",
+ "version": "3.1.0"
+ },
+ {
+ "copyright": [
+ "Copyright 2010-2021 Mike Bostock",
+ "with or without fee is hereby granted, provided that the above copyright notice"
+ ],
+ "license": "ISC",
+ "license_source": "npm package d3-selection@3.0.0/LICENSE",
+ "license_text": "Copyright 2010-2021 Mike Bostock\n\nPermission to use, copy, modify, and/or distribute this software for any purpose\nwith or without fee is hereby granted, provided that the above copyright notice\nand this permission notice appear in all copies.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH\nREGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND\nFITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,\nINDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS\nOF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER\nTORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF\nTHIS SOFTWARE.\n",
+ "name": "d3-selection",
+ "version": "3.0.0"
+ },
+ {
+ "copyright": [
+ "Copyright 2010-2022 Mike Bostock",
+ "with or without fee is hereby granted, provided that the above copyright notice"
+ ],
+ "license": "ISC",
+ "license_source": "npm package d3-time@3.1.0/LICENSE",
+ "license_text": "Copyright 2010-2022 Mike Bostock\n\nPermission to use, copy, modify, and/or distribute this software for any purpose\nwith or without fee is hereby granted, provided that the above copyright notice\nand this permission notice appear in all copies.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH\nREGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND\nFITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,\nINDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS\nOF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER\nTORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF\nTHIS SOFTWARE.\n",
+ "name": "d3-time",
+ "version": "3.1.0"
+ },
+ {
+ "copyright": [
+ "Copyright 2010-2021 Mike Bostock",
+ "with or without fee is hereby granted, provided that the above copyright notice"
+ ],
+ "license": "ISC",
+ "license_source": "npm package d3-time-format@4.1.0/LICENSE",
+ "license_text": "Copyright 2010-2021 Mike Bostock\n\nPermission to use, copy, modify, and/or distribute this software for any purpose\nwith or without fee is hereby granted, provided that the above copyright notice\nand this permission notice appear in all copies.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH\nREGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND\nFITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,\nINDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS\nOF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER\nTORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF\nTHIS SOFTWARE.\n",
+ "name": "d3-time-format",
+ "version": "4.1.0"
+ },
+ {
+ "copyright": [
+ "Copyright 2010-2021 Mike Bostock",
+ "with or without fee is hereby granted, provided that the above copyright notice"
+ ],
+ "license": "ISC",
+ "license_source": "npm package d3-timer@3.0.1/LICENSE",
+ "license_text": "Copyright 2010-2021 Mike Bostock\n\nPermission to use, copy, modify, and/or distribute this software for any purpose\nwith or without fee is hereby granted, provided that the above copyright notice\nand this permission notice appear in all copies.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH\nREGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND\nFITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,\nINDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS\nOF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER\nTORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF\nTHIS SOFTWARE.\n",
+ "name": "d3-timer",
+ "version": "3.0.1"
+ },
+ {
+ "copyright": [
+ "Copyright 2010-2021 Mike Bostock",
+ "with or without fee is hereby granted, provided that the above copyright notice"
+ ],
+ "license": "ISC",
+ "license_source": "npm package d3-transition@3.0.1/LICENSE",
+ "license_text": "Copyright 2010-2021 Mike Bostock\n\nPermission to use, copy, modify, and/or distribute this software for any purpose\nwith or without fee is hereby granted, provided that the above copyright notice\nand this permission notice appear in all copies.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH\nREGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND\nFITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,\nINDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS\nOF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER\nTORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF\nTHIS SOFTWARE.\n",
+ "name": "d3-transition",
+ "version": "3.0.1"
+ },
+ {
+ "copyright": [
+ "Copyright 2010-2021 Mike Bostock",
+ "with or without fee is hereby granted, provided that the above copyright notice"
+ ],
+ "license": "ISC",
+ "license_source": "npm package d3-zoom@3.0.0/LICENSE",
+ "license_text": "Copyright 2010-2021 Mike Bostock\n\nPermission to use, copy, modify, and/or distribute this software for any purpose\nwith or without fee is hereby granted, provided that the above copyright notice\nand this permission notice appear in all copies.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH\nREGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND\nFITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,\nINDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS\nOF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER\nTORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF\nTHIS SOFTWARE.\n",
+ "name": "d3-zoom",
+ "version": "3.0.0"
+ },
+ {
+ "copyright": [
+ "Copyright (c) 2022 Vasco Asturiano",
+ "The above copyright notice and this permission notice shall be included in all",
+ "AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER"
+ ],
+ "license": "MIT",
+ "license_source": "npm package float-tooltip@1.7.5/LICENSE",
+ "license_text": "MIT License\n\nCopyright (c) 2022 Vasco Asturiano\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n",
+ "name": "float-tooltip",
+ "version": "1.7.5"
+ },
+ {
+ "copyright": [
+ "Copyright (c) 2018 Vasco Asturiano",
+ "The above copyright notice and this permission notice shall be included in all",
+ "AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER"
+ ],
+ "license": "MIT",
+ "license_source": "npm package force-graph@1.51.4/LICENSE",
+ "license_text": "MIT License\n\nCopyright (c) 2018 Vasco Asturiano\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n",
+ "name": "force-graph",
+ "version": "1.51.4"
+ },
+ {
+ "copyright": [
+ "Copyright (c) 2018 Vasco Asturiano",
+ "The above copyright notice and this permission notice shall be included in all",
+ "AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER"
+ ],
+ "license": "MIT",
+ "license_source": "npm package index-array-by@1.4.2/LICENSE",
+ "license_text": "MIT License\n\nCopyright (c) 2018 Vasco Asturiano\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n",
+ "name": "index-array-by",
+ "version": "1.4.2"
+ },
+ {
+ "copyright": [
+ "Copyright 2021 Mike Bostock",
+ "with or without fee is hereby granted, provided that the above copyright notice"
+ ],
+ "license": "ISC",
+ "license_source": "npm package internmap@2.0.3/LICENSE",
+ "license_text": "Copyright 2021 Mike Bostock\n\nPermission to use, copy, modify, and/or distribute this software for any purpose\nwith or without fee is hereby granted, provided that the above copyright notice\nand this permission notice appear in all copies.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH\nREGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND\nFITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,\nINDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS\nOF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER\nTORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF\nTHIS SOFTWARE.\n",
+ "name": "internmap",
+ "version": "2.0.3"
+ },
+ {
+ "copyright": [
+ "Copyright (c) 2017 Vasco Asturiano",
+ "The above copyright notice and this permission notice shall be included in all",
+ "AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER"
+ ],
+ "license": "MIT",
+ "license_source": "npm package kapsule@1.16.3/LICENSE",
+ "license_text": "MIT License\n\nCopyright (c) 2017 Vasco Asturiano\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n",
+ "name": "kapsule",
+ "version": "1.16.3"
+ },
+ {
+ "copyright": [
+ "Copyright OpenJS Foundation and other contributors ",
+ "Based on Underscore.js, copyright Jeremy Ashkenas,",
+ "The above copyright notice and this permission notice shall be",
+ "NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE",
+ "Copyright and related rights for sample code are waived via CC0. Sample"
+ ],
+ "license": "MIT",
+ "license_source": "npm package lodash-es@4.18.1/LICENSE",
+ "license_text": "Copyright OpenJS Foundation and other contributors \n\nBased on Underscore.js, copyright Jeremy Ashkenas,\nDocumentCloud and Investigative Reporters & Editors \n\nThis software consists of voluntary contributions made by many\nindividuals. For exact contribution history, see the revision history\navailable at https://github.com/lodash/lodash\n\nThe following license applies to all parts of this software except as\ndocumented below:\n\n====\n\nPermission is hereby granted, free of charge, to any person obtaining\na copy of this software and associated documentation files (the\n\"Software\"), to deal in the Software without restriction, including\nwithout limitation the rights to use, copy, modify, merge, publish,\ndistribute, sublicense, and/or sell copies of the Software, and to\npermit persons to whom the Software is furnished to do so, subject to\nthe following conditions:\n\nThe above copyright notice and this permission notice shall be\nincluded in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\nMERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND\nNONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE\nLIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION\nWITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n\n====\n\nCopyright and related rights for sample code are waived via CC0. Sample\ncode is defined as all source code displayed within the prose of the\ndocumentation.\n\nCC0: http://creativecommons.org/publicdomain/zero/1.0/\n\n====\n\nFiles located in the node_modules and vendor directories are externally\nmaintained libraries used by this software which have their own\nlicenses; we recommend you read them, as their terms may differ from the\nterms above.\n",
+ "name": "lodash-es",
+ "version": "4.18.1"
+ },
+ {
+ "copyright": [
+ "Copyright (c) 2015-present Jason Miller",
+ "The above copyright notice and this permission notice shall be included in all",
+ "AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER"
+ ],
+ "license": "MIT",
+ "license_source": "https://github.com/preactjs/preact/blob/10.29.1/LICENSE",
+ "license_text": "The MIT License (MIT)\n\nCopyright (c) 2015-present Jason Miller\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.",
+ "name": "preact",
+ "version": "10.29.1"
+ },
+ {
+ "copyright": [
+ "Copyright (c), Brian Grinstead, http://briangrinstead.com",
+ "The above copyright notice and this permission notice shall be",
+ "NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE"
+ ],
+ "license": "MIT",
+ "license_source": "npm package tinycolor2@1.6.0/LICENSE",
+ "license_text": "Copyright (c), Brian Grinstead, http://briangrinstead.com\n\nPermission is hereby granted, free of charge, to any person obtaining\na copy of this software and associated documentation files (the\n\"Software\"), to deal in the Software without restriction, including\nwithout limitation the rights to use, copy, modify, merge, publish,\ndistribute, sublicense, and/or sell copies of the Software, and to\npermit persons to whom the Software is furnished to do so, subject to\nthe following conditions:\n\nThe above copyright notice and this permission notice shall be\nincluded in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\nMERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND\nNONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE\nLIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION\nWITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.",
+ "name": "tinycolor2",
+ "version": "1.6.0"
+ }
+ ],
+ "format": "engraphis-bundled-license-report/v1",
+ "source_lock": {
+ "canonical_lf_sha256": "53fb675a6952cd5fabca859f33b93b925060cd9b75b54b035544ee632094aa92",
+ "path": "deploy/force-graph-1.51.4.yarn.lock",
+ "sha256": "53fb675a6952cd5fabca859f33b93b925060cd9b75b54b035544ee632094aa92",
+ "upstream_url": "https://github.com/vasturiano/force-graph/blob/baa20a92bbe5628034d771abaf33a2dbb65d22eb/yarn.lock"
+ }
+}
diff --git a/deploy/force-graph-1.51.4.yarn.lock b/deploy/force-graph-1.51.4.yarn.lock
new file mode 100644
index 00000000..d9da7340
--- /dev/null
+++ b/deploy/force-graph-1.51.4.yarn.lock
@@ -0,0 +1,2344 @@
+# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
+# yarn lockfile v1
+
+"@babel/code-frame@^7.28.6", "@babel/code-frame@^7.29.0":
+ version "7.29.0"
+ resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.29.0.tgz#7cd7a59f15b3cc0dcd803038f7792712a7d0b15c"
+ integrity sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==
+ dependencies:
+ "@babel/helper-validator-identifier" "^7.28.5"
+ js-tokens "^4.0.0"
+ picocolors "^1.1.1"
+
+"@babel/compat-data@^7.28.6", "@babel/compat-data@^7.29.0":
+ version "7.29.0"
+ resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.29.0.tgz#00d03e8c0ac24dd9be942c5370990cbe1f17d88d"
+ integrity sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==
+
+"@babel/core@^7.29.0":
+ version "7.29.0"
+ resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.29.0.tgz#5286ad785df7f79d656e88ce86e650d16ca5f322"
+ integrity sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==
+ dependencies:
+ "@babel/code-frame" "^7.29.0"
+ "@babel/generator" "^7.29.0"
+ "@babel/helper-compilation-targets" "^7.28.6"
+ "@babel/helper-module-transforms" "^7.28.6"
+ "@babel/helpers" "^7.28.6"
+ "@babel/parser" "^7.29.0"
+ "@babel/template" "^7.28.6"
+ "@babel/traverse" "^7.29.0"
+ "@babel/types" "^7.29.0"
+ "@jridgewell/remapping" "^2.3.5"
+ convert-source-map "^2.0.0"
+ debug "^4.1.0"
+ gensync "^1.0.0-beta.2"
+ json5 "^2.2.3"
+ semver "^6.3.1"
+
+"@babel/generator@^7.29.0":
+ version "7.29.1"
+ resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.29.1.tgz#d09876290111abbb00ef962a7b83a5307fba0d50"
+ integrity sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==
+ dependencies:
+ "@babel/parser" "^7.29.0"
+ "@babel/types" "^7.29.0"
+ "@jridgewell/gen-mapping" "^0.3.12"
+ "@jridgewell/trace-mapping" "^0.3.28"
+ jsesc "^3.0.2"
+
+"@babel/helper-annotate-as-pure@^7.27.1", "@babel/helper-annotate-as-pure@^7.27.3":
+ version "7.27.3"
+ resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz#f31fd86b915fc4daf1f3ac6976c59be7084ed9c5"
+ integrity sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==
+ dependencies:
+ "@babel/types" "^7.27.3"
+
+"@babel/helper-compilation-targets@^7.27.1", "@babel/helper-compilation-targets@^7.28.6":
+ version "7.28.6"
+ resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz#32c4a3f41f12ed1532179b108a4d746e105c2b25"
+ integrity sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==
+ dependencies:
+ "@babel/compat-data" "^7.28.6"
+ "@babel/helper-validator-option" "^7.27.1"
+ browserslist "^4.24.0"
+ lru-cache "^5.1.1"
+ semver "^6.3.1"
+
+"@babel/helper-create-class-features-plugin@^7.28.6":
+ version "7.28.6"
+ resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz#611ff5482da9ef0db6291bcd24303400bca170fb"
+ integrity sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==
+ dependencies:
+ "@babel/helper-annotate-as-pure" "^7.27.3"
+ "@babel/helper-member-expression-to-functions" "^7.28.5"
+ "@babel/helper-optimise-call-expression" "^7.27.1"
+ "@babel/helper-replace-supers" "^7.28.6"
+ "@babel/helper-skip-transparent-expression-wrappers" "^7.27.1"
+ "@babel/traverse" "^7.28.6"
+ semver "^6.3.1"
+
+"@babel/helper-create-regexp-features-plugin@^7.18.6", "@babel/helper-create-regexp-features-plugin@^7.27.1", "@babel/helper-create-regexp-features-plugin@^7.28.5":
+ version "7.28.5"
+ resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz#7c1ddd64b2065c7f78034b25b43346a7e19ed997"
+ integrity sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==
+ dependencies:
+ "@babel/helper-annotate-as-pure" "^7.27.3"
+ regexpu-core "^6.3.1"
+ semver "^6.3.1"
+
+"@babel/helper-define-polyfill-provider@^0.6.8":
+ version "0.6.8"
+ resolved "https://registry.yarnpkg.com/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz#cf1e4462b613f2b54c41e6ff758d5dfcaa2c85d1"
+ integrity sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==
+ dependencies:
+ "@babel/helper-compilation-targets" "^7.28.6"
+ "@babel/helper-plugin-utils" "^7.28.6"
+ debug "^4.4.3"
+ lodash.debounce "^4.0.8"
+ resolve "^1.22.11"
+
+"@babel/helper-globals@^7.28.0":
+ version "7.28.0"
+ resolved "https://registry.yarnpkg.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz#b9430df2aa4e17bc28665eadeae8aa1d985e6674"
+ integrity sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==
+
+"@babel/helper-member-expression-to-functions@^7.28.5":
+ version "7.28.5"
+ resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz#f3e07a10be37ed7a63461c63e6929575945a6150"
+ integrity sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==
+ dependencies:
+ "@babel/traverse" "^7.28.5"
+ "@babel/types" "^7.28.5"
+
+"@babel/helper-module-imports@^7.18.6", "@babel/helper-module-imports@^7.28.6":
+ version "7.28.6"
+ resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz#60632cbd6ffb70b22823187201116762a03e2d5c"
+ integrity sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==
+ dependencies:
+ "@babel/traverse" "^7.28.6"
+ "@babel/types" "^7.28.6"
+
+"@babel/helper-module-transforms@^7.27.1", "@babel/helper-module-transforms@^7.28.6":
+ version "7.28.6"
+ resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz#9312d9d9e56edc35aeb6e95c25d4106b50b9eb1e"
+ integrity sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==
+ dependencies:
+ "@babel/helper-module-imports" "^7.28.6"
+ "@babel/helper-validator-identifier" "^7.28.5"
+ "@babel/traverse" "^7.28.6"
+
+"@babel/helper-optimise-call-expression@^7.27.1":
+ version "7.27.1"
+ resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz#c65221b61a643f3e62705e5dd2b5f115e35f9200"
+ integrity sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==
+ dependencies:
+ "@babel/types" "^7.27.1"
+
+"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.18.6", "@babel/helper-plugin-utils@^7.27.1", "@babel/helper-plugin-utils@^7.28.6":
+ version "7.28.6"
+ resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz#6f13ea251b68c8532e985fd532f28741a8af9ac8"
+ integrity sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==
+
+"@babel/helper-remap-async-to-generator@^7.27.1":
+ version "7.27.1"
+ resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz#4601d5c7ce2eb2aea58328d43725523fcd362ce6"
+ integrity sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==
+ dependencies:
+ "@babel/helper-annotate-as-pure" "^7.27.1"
+ "@babel/helper-wrap-function" "^7.27.1"
+ "@babel/traverse" "^7.27.1"
+
+"@babel/helper-replace-supers@^7.27.1", "@babel/helper-replace-supers@^7.28.6":
+ version "7.28.6"
+ resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz#94aa9a1d7423a00aead3f204f78834ce7d53fe44"
+ integrity sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==
+ dependencies:
+ "@babel/helper-member-expression-to-functions" "^7.28.5"
+ "@babel/helper-optimise-call-expression" "^7.27.1"
+ "@babel/traverse" "^7.28.6"
+
+"@babel/helper-skip-transparent-expression-wrappers@^7.27.1":
+ version "7.27.1"
+ resolved "https://registry.yarnpkg.com/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz#62bb91b3abba8c7f1fec0252d9dbea11b3ee7a56"
+ integrity sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==
+ dependencies:
+ "@babel/traverse" "^7.27.1"
+ "@babel/types" "^7.27.1"
+
+"@babel/helper-string-parser@^7.27.1":
+ version "7.27.1"
+ resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz#54da796097ab19ce67ed9f88b47bb2ec49367687"
+ integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==
+
+"@babel/helper-validator-identifier@^7.28.5":
+ version "7.28.5"
+ resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz#010b6938fab7cb7df74aa2bbc06aa503b8fe5fb4"
+ integrity sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==
+
+"@babel/helper-validator-option@^7.27.1":
+ version "7.27.1"
+ resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz#fa52f5b1e7db1ab049445b421c4471303897702f"
+ integrity sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==
+
+"@babel/helper-wrap-function@^7.27.1":
+ version "7.28.6"
+ resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.28.6.tgz#4e349ff9222dab69a93a019cc296cdd8442e279a"
+ integrity sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==
+ dependencies:
+ "@babel/template" "^7.28.6"
+ "@babel/traverse" "^7.28.6"
+ "@babel/types" "^7.28.6"
+
+"@babel/helpers@^7.28.6":
+ version "7.29.2"
+ resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.29.2.tgz#9cfbccb02b8e229892c0b07038052cc1a8709c49"
+ integrity sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==
+ dependencies:
+ "@babel/template" "^7.28.6"
+ "@babel/types" "^7.29.0"
+
+"@babel/parser@^7.28.6", "@babel/parser@^7.29.0":
+ version "7.29.2"
+ resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.29.2.tgz#58bd50b9a7951d134988a1ae177a35ef9a703ba1"
+ integrity sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==
+ dependencies:
+ "@babel/types" "^7.29.0"
+
+"@babel/plugin-bugfix-firefox-class-in-computed-class-key@^7.28.5":
+ version "7.28.5"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz#fbde57974707bbfa0376d34d425ff4fa6c732421"
+ integrity sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==
+ dependencies:
+ "@babel/helper-plugin-utils" "^7.27.1"
+ "@babel/traverse" "^7.28.5"
+
+"@babel/plugin-bugfix-safari-class-field-initializer-scope@^7.27.1":
+ version "7.27.1"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz#43f70a6d7efd52370eefbdf55ae03d91b293856d"
+ integrity sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==
+ dependencies:
+ "@babel/helper-plugin-utils" "^7.27.1"
+
+"@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.27.1":
+ version "7.27.1"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz#beb623bd573b8b6f3047bd04c32506adc3e58a72"
+ integrity sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==
+ dependencies:
+ "@babel/helper-plugin-utils" "^7.27.1"
+
+"@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@^7.27.1":
+ version "7.27.1"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz#e134a5479eb2ba9c02714e8c1ebf1ec9076124fd"
+ integrity sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==
+ dependencies:
+ "@babel/helper-plugin-utils" "^7.27.1"
+ "@babel/helper-skip-transparent-expression-wrappers" "^7.27.1"
+ "@babel/plugin-transform-optional-chaining" "^7.27.1"
+
+"@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@^7.28.6":
+ version "7.28.6"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.6.tgz#0e8289cec28baaf05d54fd08d81ae3676065f69f"
+ integrity sha512-a0aBScVTlNaiUe35UtfxAN7A/tehvvG4/ByO6+46VPKTRSlfnAFsgKy0FUh+qAkQrDTmhDkT+IBOKlOoMUxQ0g==
+ dependencies:
+ "@babel/helper-plugin-utils" "^7.28.6"
+ "@babel/traverse" "^7.28.6"
+
+"@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2":
+ version "7.21.0-placeholder-for-preset-env.2"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz#7844f9289546efa9febac2de4cfe358a050bd703"
+ integrity sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==
+
+"@babel/plugin-syntax-import-assertions@^7.28.6":
+ version "7.28.6"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.28.6.tgz#ae9bc1923a6ba527b70104dd2191b0cd872c8507"
+ integrity sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw==
+ dependencies:
+ "@babel/helper-plugin-utils" "^7.28.6"
+
+"@babel/plugin-syntax-import-attributes@^7.28.6":
+ version "7.28.6"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz#b71d5914665f60124e133696f17cd7669062c503"
+ integrity sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==
+ dependencies:
+ "@babel/helper-plugin-utils" "^7.28.6"
+
+"@babel/plugin-syntax-unicode-sets-regex@^7.18.6":
+ version "7.18.6"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz#d49a3b3e6b52e5be6740022317580234a6a47357"
+ integrity sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==
+ dependencies:
+ "@babel/helper-create-regexp-features-plugin" "^7.18.6"
+ "@babel/helper-plugin-utils" "^7.18.6"
+
+"@babel/plugin-transform-arrow-functions@^7.27.1":
+ version "7.27.1"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz#6e2061067ba3ab0266d834a9f94811196f2aba9a"
+ integrity sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==
+ dependencies:
+ "@babel/helper-plugin-utils" "^7.27.1"
+
+"@babel/plugin-transform-async-generator-functions@^7.29.0":
+ version "7.29.0"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.0.tgz#63ed829820298f0bf143d5a4a68fb8c06ffd742f"
+ integrity sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w==
+ dependencies:
+ "@babel/helper-plugin-utils" "^7.28.6"
+ "@babel/helper-remap-async-to-generator" "^7.27.1"
+ "@babel/traverse" "^7.29.0"
+
+"@babel/plugin-transform-async-to-generator@^7.28.6":
+ version "7.28.6"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.28.6.tgz#bd97b42237b2d1bc90d74bcb486c39be5b4d7e77"
+ integrity sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==
+ dependencies:
+ "@babel/helper-module-imports" "^7.28.6"
+ "@babel/helper-plugin-utils" "^7.28.6"
+ "@babel/helper-remap-async-to-generator" "^7.27.1"
+
+"@babel/plugin-transform-block-scoped-functions@^7.27.1":
+ version "7.27.1"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz#558a9d6e24cf72802dd3b62a4b51e0d62c0f57f9"
+ integrity sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==
+ dependencies:
+ "@babel/helper-plugin-utils" "^7.27.1"
+
+"@babel/plugin-transform-block-scoping@^7.28.6":
+ version "7.28.6"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.6.tgz#e1ef5633448c24e76346125c2534eeb359699a99"
+ integrity sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==
+ dependencies:
+ "@babel/helper-plugin-utils" "^7.28.6"
+
+"@babel/plugin-transform-class-properties@^7.28.6":
+ version "7.28.6"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz#d274a4478b6e782d9ea987fda09bdb6d28d66b72"
+ integrity sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==
+ dependencies:
+ "@babel/helper-create-class-features-plugin" "^7.28.6"
+ "@babel/helper-plugin-utils" "^7.28.6"
+
+"@babel/plugin-transform-class-static-block@^7.28.6":
+ version "7.28.6"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz#1257491e8259c6d125ac4d9a6f39f9d2bf3dba70"
+ integrity sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==
+ dependencies:
+ "@babel/helper-create-class-features-plugin" "^7.28.6"
+ "@babel/helper-plugin-utils" "^7.28.6"
+
+"@babel/plugin-transform-classes@^7.28.6":
+ version "7.28.6"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz#8f6fb79ba3703978e701ce2a97e373aae7dda4b7"
+ integrity sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==
+ dependencies:
+ "@babel/helper-annotate-as-pure" "^7.27.3"
+ "@babel/helper-compilation-targets" "^7.28.6"
+ "@babel/helper-globals" "^7.28.0"
+ "@babel/helper-plugin-utils" "^7.28.6"
+ "@babel/helper-replace-supers" "^7.28.6"
+ "@babel/traverse" "^7.28.6"
+
+"@babel/plugin-transform-computed-properties@^7.28.6":
+ version "7.28.6"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.28.6.tgz#936824fc71c26cb5c433485776d79c8e7b0202d2"
+ integrity sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==
+ dependencies:
+ "@babel/helper-plugin-utils" "^7.28.6"
+ "@babel/template" "^7.28.6"
+
+"@babel/plugin-transform-destructuring@^7.28.5":
+ version "7.28.5"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz#b8402764df96179a2070bb7b501a1586cf8ad7a7"
+ integrity sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==
+ dependencies:
+ "@babel/helper-plugin-utils" "^7.27.1"
+ "@babel/traverse" "^7.28.5"
+
+"@babel/plugin-transform-dotall-regex@^7.28.6":
+ version "7.28.6"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.28.6.tgz#def31ed84e0fb6e25c71e53c124e7b76a4ab8e61"
+ integrity sha512-SljjowuNKB7q5Oayv4FoPzeB74g3QgLt8IVJw9ADvWy3QnUb/01aw8I4AVv8wYnPvQz2GDDZ/g3GhcNyDBI4Bg==
+ dependencies:
+ "@babel/helper-create-regexp-features-plugin" "^7.28.5"
+ "@babel/helper-plugin-utils" "^7.28.6"
+
+"@babel/plugin-transform-duplicate-keys@^7.27.1":
+ version "7.27.1"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz#f1fbf628ece18e12e7b32b175940e68358f546d1"
+ integrity sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==
+ dependencies:
+ "@babel/helper-plugin-utils" "^7.27.1"
+
+"@babel/plugin-transform-duplicate-named-capturing-groups-regex@^7.29.0":
+ version "7.29.0"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.29.0.tgz#8014b8a6cfd0e7b92762724443bf0d2400f26df1"
+ integrity sha512-zBPcW2lFGxdiD8PUnPwJjag2J9otbcLQzvbiOzDxpYXyCuYX9agOwMPGn1prVH0a4qzhCKu24rlH4c1f7yA8rw==
+ dependencies:
+ "@babel/helper-create-regexp-features-plugin" "^7.28.5"
+ "@babel/helper-plugin-utils" "^7.28.6"
+
+"@babel/plugin-transform-dynamic-import@^7.27.1":
+ version "7.27.1"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz#4c78f35552ac0e06aa1f6e3c573d67695e8af5a4"
+ integrity sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==
+ dependencies:
+ "@babel/helper-plugin-utils" "^7.27.1"
+
+"@babel/plugin-transform-explicit-resource-management@^7.28.6":
+ version "7.28.6"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.6.tgz#dd6788f982c8b77e86779d1d029591e39d9d8be7"
+ integrity sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg==
+ dependencies:
+ "@babel/helper-plugin-utils" "^7.28.6"
+ "@babel/plugin-transform-destructuring" "^7.28.5"
+
+"@babel/plugin-transform-exponentiation-operator@^7.28.6":
+ version "7.28.6"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.6.tgz#5e477eb7eafaf2ab5537a04aaafcf37e2d7f1091"
+ integrity sha512-WitabqiGjV/vJ0aPOLSFfNY1u9U3R7W36B03r5I2KoNix+a3sOhJ3pKFB3R5It9/UiK78NiO0KE9P21cMhlPkw==
+ dependencies:
+ "@babel/helper-plugin-utils" "^7.28.6"
+
+"@babel/plugin-transform-export-namespace-from@^7.27.1":
+ version "7.27.1"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz#71ca69d3471edd6daa711cf4dfc3400415df9c23"
+ integrity sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==
+ dependencies:
+ "@babel/helper-plugin-utils" "^7.27.1"
+
+"@babel/plugin-transform-for-of@^7.27.1":
+ version "7.27.1"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz#bc24f7080e9ff721b63a70ac7b2564ca15b6c40a"
+ integrity sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==
+ dependencies:
+ "@babel/helper-plugin-utils" "^7.27.1"
+ "@babel/helper-skip-transparent-expression-wrappers" "^7.27.1"
+
+"@babel/plugin-transform-function-name@^7.27.1":
+ version "7.27.1"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz#4d0bf307720e4dce6d7c30fcb1fd6ca77bdeb3a7"
+ integrity sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==
+ dependencies:
+ "@babel/helper-compilation-targets" "^7.27.1"
+ "@babel/helper-plugin-utils" "^7.27.1"
+ "@babel/traverse" "^7.27.1"
+
+"@babel/plugin-transform-json-strings@^7.28.6":
+ version "7.28.6"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.28.6.tgz#4c8c15b2dc49e285d110a4cf3dac52fd2dfc3038"
+ integrity sha512-Nr+hEN+0geQkzhbdgQVPoqr47lZbm+5fCUmO70722xJZd0Mvb59+33QLImGj6F+DkK3xgDi1YVysP8whD6FQAw==
+ dependencies:
+ "@babel/helper-plugin-utils" "^7.28.6"
+
+"@babel/plugin-transform-literals@^7.27.1":
+ version "7.27.1"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz#baaefa4d10a1d4206f9dcdda50d7d5827bb70b24"
+ integrity sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==
+ dependencies:
+ "@babel/helper-plugin-utils" "^7.27.1"
+
+"@babel/plugin-transform-logical-assignment-operators@^7.28.6":
+ version "7.28.6"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.6.tgz#53028a3d77e33c50ef30a8fce5ca17065936e605"
+ integrity sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==
+ dependencies:
+ "@babel/helper-plugin-utils" "^7.28.6"
+
+"@babel/plugin-transform-member-expression-literals@^7.27.1":
+ version "7.27.1"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz#37b88ba594d852418e99536f5612f795f23aeaf9"
+ integrity sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==
+ dependencies:
+ "@babel/helper-plugin-utils" "^7.27.1"
+
+"@babel/plugin-transform-modules-amd@^7.27.1":
+ version "7.27.1"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz#a4145f9d87c2291fe2d05f994b65dba4e3e7196f"
+ integrity sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==
+ dependencies:
+ "@babel/helper-module-transforms" "^7.27.1"
+ "@babel/helper-plugin-utils" "^7.27.1"
+
+"@babel/plugin-transform-modules-commonjs@^7.28.6":
+ version "7.28.6"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz#c0232e0dfe66a734cc4ad0d5e75fc3321b6fdef1"
+ integrity sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==
+ dependencies:
+ "@babel/helper-module-transforms" "^7.28.6"
+ "@babel/helper-plugin-utils" "^7.28.6"
+
+"@babel/plugin-transform-modules-systemjs@^7.29.0":
+ version "7.29.0"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.0.tgz#e458a95a17807c415924106a3ff188a3b8dee964"
+ integrity sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==
+ dependencies:
+ "@babel/helper-module-transforms" "^7.28.6"
+ "@babel/helper-plugin-utils" "^7.28.6"
+ "@babel/helper-validator-identifier" "^7.28.5"
+ "@babel/traverse" "^7.29.0"
+
+"@babel/plugin-transform-modules-umd@^7.27.1":
+ version "7.27.1"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz#63f2cf4f6dc15debc12f694e44714863d34cd334"
+ integrity sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==
+ dependencies:
+ "@babel/helper-module-transforms" "^7.27.1"
+ "@babel/helper-plugin-utils" "^7.27.1"
+
+"@babel/plugin-transform-named-capturing-groups-regex@^7.29.0":
+ version "7.29.0"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.0.tgz#a26cd51e09c4718588fc4cce1c5d1c0152102d6a"
+ integrity sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ==
+ dependencies:
+ "@babel/helper-create-regexp-features-plugin" "^7.28.5"
+ "@babel/helper-plugin-utils" "^7.28.6"
+
+"@babel/plugin-transform-new-target@^7.27.1":
+ version "7.27.1"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz#259c43939728cad1706ac17351b7e6a7bea1abeb"
+ integrity sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==
+ dependencies:
+ "@babel/helper-plugin-utils" "^7.27.1"
+
+"@babel/plugin-transform-nullish-coalescing-operator@^7.28.6":
+ version "7.28.6"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz#9bc62096e90ab7a887f3ca9c469f6adec5679757"
+ integrity sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==
+ dependencies:
+ "@babel/helper-plugin-utils" "^7.28.6"
+
+"@babel/plugin-transform-numeric-separator@^7.28.6":
+ version "7.28.6"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.28.6.tgz#1310b0292762e7a4a335df5f580c3320ee7d9e9f"
+ integrity sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w==
+ dependencies:
+ "@babel/helper-plugin-utils" "^7.28.6"
+
+"@babel/plugin-transform-object-rest-spread@^7.28.6":
+ version "7.28.6"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.6.tgz#fdd4bc2d72480db6ca42aed5c051f148d7b067f7"
+ integrity sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==
+ dependencies:
+ "@babel/helper-compilation-targets" "^7.28.6"
+ "@babel/helper-plugin-utils" "^7.28.6"
+ "@babel/plugin-transform-destructuring" "^7.28.5"
+ "@babel/plugin-transform-parameters" "^7.27.7"
+ "@babel/traverse" "^7.28.6"
+
+"@babel/plugin-transform-object-super@^7.27.1":
+ version "7.27.1"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz#1c932cd27bf3874c43a5cac4f43ebf970c9871b5"
+ integrity sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==
+ dependencies:
+ "@babel/helper-plugin-utils" "^7.27.1"
+ "@babel/helper-replace-supers" "^7.27.1"
+
+"@babel/plugin-transform-optional-catch-binding@^7.28.6":
+ version "7.28.6"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.28.6.tgz#75107be14c78385978201a49c86414a150a20b4c"
+ integrity sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==
+ dependencies:
+ "@babel/helper-plugin-utils" "^7.28.6"
+
+"@babel/plugin-transform-optional-chaining@^7.27.1", "@babel/plugin-transform-optional-chaining@^7.28.6":
+ version "7.28.6"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz#926cf150bd421fc8362753e911b4a1b1ce4356cd"
+ integrity sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==
+ dependencies:
+ "@babel/helper-plugin-utils" "^7.28.6"
+ "@babel/helper-skip-transparent-expression-wrappers" "^7.27.1"
+
+"@babel/plugin-transform-parameters@^7.27.7":
+ version "7.27.7"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz#1fd2febb7c74e7d21cf3b05f7aebc907940af53a"
+ integrity sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==
+ dependencies:
+ "@babel/helper-plugin-utils" "^7.27.1"
+
+"@babel/plugin-transform-private-methods@^7.28.6":
+ version "7.28.6"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.28.6.tgz#c76fbfef3b86c775db7f7c106fff544610bdb411"
+ integrity sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==
+ dependencies:
+ "@babel/helper-create-class-features-plugin" "^7.28.6"
+ "@babel/helper-plugin-utils" "^7.28.6"
+
+"@babel/plugin-transform-private-property-in-object@^7.28.6":
+ version "7.28.6"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.28.6.tgz#4fafef1e13129d79f1d75ac180c52aafefdb2811"
+ integrity sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==
+ dependencies:
+ "@babel/helper-annotate-as-pure" "^7.27.3"
+ "@babel/helper-create-class-features-plugin" "^7.28.6"
+ "@babel/helper-plugin-utils" "^7.28.6"
+
+"@babel/plugin-transform-property-literals@^7.27.1":
+ version "7.27.1"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz#07eafd618800591e88073a0af1b940d9a42c6424"
+ integrity sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==
+ dependencies:
+ "@babel/helper-plugin-utils" "^7.27.1"
+
+"@babel/plugin-transform-regenerator@^7.29.0":
+ version "7.29.0"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.0.tgz#dec237cec1b93330876d6da9992c4abd42c9d18b"
+ integrity sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog==
+ dependencies:
+ "@babel/helper-plugin-utils" "^7.28.6"
+
+"@babel/plugin-transform-regexp-modifiers@^7.28.6":
+ version "7.28.6"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.28.6.tgz#7ef0163bd8b4a610481b2509c58cf217f065290b"
+ integrity sha512-QGWAepm9qxpaIs7UM9FvUSnCGlb8Ua1RhyM4/veAxLwt3gMat/LSGrZixyuj4I6+Kn9iwvqCyPTtbdxanYoWYg==
+ dependencies:
+ "@babel/helper-create-regexp-features-plugin" "^7.28.5"
+ "@babel/helper-plugin-utils" "^7.28.6"
+
+"@babel/plugin-transform-reserved-words@^7.27.1":
+ version "7.27.1"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz#40fba4878ccbd1c56605a4479a3a891ac0274bb4"
+ integrity sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==
+ dependencies:
+ "@babel/helper-plugin-utils" "^7.27.1"
+
+"@babel/plugin-transform-shorthand-properties@^7.27.1":
+ version "7.27.1"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz#532abdacdec87bfee1e0ef8e2fcdee543fe32b90"
+ integrity sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==
+ dependencies:
+ "@babel/helper-plugin-utils" "^7.27.1"
+
+"@babel/plugin-transform-spread@^7.28.6":
+ version "7.28.6"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.28.6.tgz#40a2b423f6db7b70f043ad027a58bcb44a9757b6"
+ integrity sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==
+ dependencies:
+ "@babel/helper-plugin-utils" "^7.28.6"
+ "@babel/helper-skip-transparent-expression-wrappers" "^7.27.1"
+
+"@babel/plugin-transform-sticky-regex@^7.27.1":
+ version "7.27.1"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz#18984935d9d2296843a491d78a014939f7dcd280"
+ integrity sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==
+ dependencies:
+ "@babel/helper-plugin-utils" "^7.27.1"
+
+"@babel/plugin-transform-template-literals@^7.27.1":
+ version "7.27.1"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz#1a0eb35d8bb3e6efc06c9fd40eb0bcef548328b8"
+ integrity sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==
+ dependencies:
+ "@babel/helper-plugin-utils" "^7.27.1"
+
+"@babel/plugin-transform-typeof-symbol@^7.27.1":
+ version "7.27.1"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz#70e966bb492e03509cf37eafa6dcc3051f844369"
+ integrity sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==
+ dependencies:
+ "@babel/helper-plugin-utils" "^7.27.1"
+
+"@babel/plugin-transform-unicode-escapes@^7.27.1":
+ version "7.27.1"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz#3e3143f8438aef842de28816ece58780190cf806"
+ integrity sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==
+ dependencies:
+ "@babel/helper-plugin-utils" "^7.27.1"
+
+"@babel/plugin-transform-unicode-property-regex@^7.28.6":
+ version "7.28.6"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.28.6.tgz#63a7a6c21a0e75dae9b1861454111ea5caa22821"
+ integrity sha512-4Wlbdl/sIZjzi/8St0evF0gEZrgOswVO6aOzqxh1kDZOl9WmLrHq2HtGhnOJZmHZYKP8WZ1MDLCt5DAWwRo57A==
+ dependencies:
+ "@babel/helper-create-regexp-features-plugin" "^7.28.5"
+ "@babel/helper-plugin-utils" "^7.28.6"
+
+"@babel/plugin-transform-unicode-regex@^7.27.1":
+ version "7.27.1"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz#25948f5c395db15f609028e370667ed8bae9af97"
+ integrity sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==
+ dependencies:
+ "@babel/helper-create-regexp-features-plugin" "^7.27.1"
+ "@babel/helper-plugin-utils" "^7.27.1"
+
+"@babel/plugin-transform-unicode-sets-regex@^7.28.6":
+ version "7.28.6"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.28.6.tgz#924912914e5df9fe615ec472f88ff4788ce04d4e"
+ integrity sha512-/wHc/paTUmsDYN7SZkpWxogTOBNnlx7nBQYfy6JJlCT7G3mVhltk3e++N7zV0XfgGsrqBxd4rJQt9H16I21Y1Q==
+ dependencies:
+ "@babel/helper-create-regexp-features-plugin" "^7.28.5"
+ "@babel/helper-plugin-utils" "^7.28.6"
+
+"@babel/preset-env@^7.29.2":
+ version "7.29.2"
+ resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.29.2.tgz#5a173f22c7d8df362af1c9fe31facd320de4a86c"
+ integrity sha512-DYD23veRYGvBFhcTY1iUvJnDNpuqNd/BzBwCvzOTKUnJjKg5kpUBh3/u9585Agdkgj+QuygG7jLfOPWMa2KVNw==
+ dependencies:
+ "@babel/compat-data" "^7.29.0"
+ "@babel/helper-compilation-targets" "^7.28.6"
+ "@babel/helper-plugin-utils" "^7.28.6"
+ "@babel/helper-validator-option" "^7.27.1"
+ "@babel/plugin-bugfix-firefox-class-in-computed-class-key" "^7.28.5"
+ "@babel/plugin-bugfix-safari-class-field-initializer-scope" "^7.27.1"
+ "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression" "^7.27.1"
+ "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining" "^7.27.1"
+ "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly" "^7.28.6"
+ "@babel/plugin-proposal-private-property-in-object" "7.21.0-placeholder-for-preset-env.2"
+ "@babel/plugin-syntax-import-assertions" "^7.28.6"
+ "@babel/plugin-syntax-import-attributes" "^7.28.6"
+ "@babel/plugin-syntax-unicode-sets-regex" "^7.18.6"
+ "@babel/plugin-transform-arrow-functions" "^7.27.1"
+ "@babel/plugin-transform-async-generator-functions" "^7.29.0"
+ "@babel/plugin-transform-async-to-generator" "^7.28.6"
+ "@babel/plugin-transform-block-scoped-functions" "^7.27.1"
+ "@babel/plugin-transform-block-scoping" "^7.28.6"
+ "@babel/plugin-transform-class-properties" "^7.28.6"
+ "@babel/plugin-transform-class-static-block" "^7.28.6"
+ "@babel/plugin-transform-classes" "^7.28.6"
+ "@babel/plugin-transform-computed-properties" "^7.28.6"
+ "@babel/plugin-transform-destructuring" "^7.28.5"
+ "@babel/plugin-transform-dotall-regex" "^7.28.6"
+ "@babel/plugin-transform-duplicate-keys" "^7.27.1"
+ "@babel/plugin-transform-duplicate-named-capturing-groups-regex" "^7.29.0"
+ "@babel/plugin-transform-dynamic-import" "^7.27.1"
+ "@babel/plugin-transform-explicit-resource-management" "^7.28.6"
+ "@babel/plugin-transform-exponentiation-operator" "^7.28.6"
+ "@babel/plugin-transform-export-namespace-from" "^7.27.1"
+ "@babel/plugin-transform-for-of" "^7.27.1"
+ "@babel/plugin-transform-function-name" "^7.27.1"
+ "@babel/plugin-transform-json-strings" "^7.28.6"
+ "@babel/plugin-transform-literals" "^7.27.1"
+ "@babel/plugin-transform-logical-assignment-operators" "^7.28.6"
+ "@babel/plugin-transform-member-expression-literals" "^7.27.1"
+ "@babel/plugin-transform-modules-amd" "^7.27.1"
+ "@babel/plugin-transform-modules-commonjs" "^7.28.6"
+ "@babel/plugin-transform-modules-systemjs" "^7.29.0"
+ "@babel/plugin-transform-modules-umd" "^7.27.1"
+ "@babel/plugin-transform-named-capturing-groups-regex" "^7.29.0"
+ "@babel/plugin-transform-new-target" "^7.27.1"
+ "@babel/plugin-transform-nullish-coalescing-operator" "^7.28.6"
+ "@babel/plugin-transform-numeric-separator" "^7.28.6"
+ "@babel/plugin-transform-object-rest-spread" "^7.28.6"
+ "@babel/plugin-transform-object-super" "^7.27.1"
+ "@babel/plugin-transform-optional-catch-binding" "^7.28.6"
+ "@babel/plugin-transform-optional-chaining" "^7.28.6"
+ "@babel/plugin-transform-parameters" "^7.27.7"
+ "@babel/plugin-transform-private-methods" "^7.28.6"
+ "@babel/plugin-transform-private-property-in-object" "^7.28.6"
+ "@babel/plugin-transform-property-literals" "^7.27.1"
+ "@babel/plugin-transform-regenerator" "^7.29.0"
+ "@babel/plugin-transform-regexp-modifiers" "^7.28.6"
+ "@babel/plugin-transform-reserved-words" "^7.27.1"
+ "@babel/plugin-transform-shorthand-properties" "^7.27.1"
+ "@babel/plugin-transform-spread" "^7.28.6"
+ "@babel/plugin-transform-sticky-regex" "^7.27.1"
+ "@babel/plugin-transform-template-literals" "^7.27.1"
+ "@babel/plugin-transform-typeof-symbol" "^7.27.1"
+ "@babel/plugin-transform-unicode-escapes" "^7.27.1"
+ "@babel/plugin-transform-unicode-property-regex" "^7.28.6"
+ "@babel/plugin-transform-unicode-regex" "^7.27.1"
+ "@babel/plugin-transform-unicode-sets-regex" "^7.28.6"
+ "@babel/preset-modules" "0.1.6-no-external-plugins"
+ babel-plugin-polyfill-corejs2 "^0.4.15"
+ babel-plugin-polyfill-corejs3 "^0.14.0"
+ babel-plugin-polyfill-regenerator "^0.6.6"
+ core-js-compat "^3.48.0"
+ semver "^6.3.1"
+
+"@babel/preset-modules@0.1.6-no-external-plugins":
+ version "0.1.6-no-external-plugins"
+ resolved "https://registry.yarnpkg.com/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz#ccb88a2c49c817236861fee7826080573b8a923a"
+ integrity sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==
+ dependencies:
+ "@babel/helper-plugin-utils" "^7.0.0"
+ "@babel/types" "^7.4.4"
+ esutils "^2.0.2"
+
+"@babel/template@^7.28.6":
+ version "7.28.6"
+ resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.28.6.tgz#0e7e56ecedb78aeef66ce7972b082fce76a23e57"
+ integrity sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==
+ dependencies:
+ "@babel/code-frame" "^7.28.6"
+ "@babel/parser" "^7.28.6"
+ "@babel/types" "^7.28.6"
+
+"@babel/traverse@^7.27.1", "@babel/traverse@^7.28.5", "@babel/traverse@^7.28.6", "@babel/traverse@^7.29.0":
+ version "7.29.0"
+ resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.29.0.tgz#f323d05001440253eead3c9c858adbe00b90310a"
+ integrity sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==
+ dependencies:
+ "@babel/code-frame" "^7.29.0"
+ "@babel/generator" "^7.29.0"
+ "@babel/helper-globals" "^7.28.0"
+ "@babel/parser" "^7.29.0"
+ "@babel/template" "^7.28.6"
+ "@babel/types" "^7.29.0"
+ debug "^4.3.1"
+
+"@babel/types@^7.27.1", "@babel/types@^7.27.3", "@babel/types@^7.28.5", "@babel/types@^7.28.6", "@babel/types@^7.29.0", "@babel/types@^7.4.4":
+ version "7.29.0"
+ resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.29.0.tgz#9f5b1e838c446e72cf3cd4b918152b8c605e37c7"
+ integrity sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==
+ dependencies:
+ "@babel/helper-string-parser" "^7.27.1"
+ "@babel/helper-validator-identifier" "^7.28.5"
+
+"@jridgewell/gen-mapping@^0.3.12", "@jridgewell/gen-mapping@^0.3.5":
+ version "0.3.13"
+ resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz#6342a19f44347518c93e43b1ac69deb3c4656a1f"
+ integrity sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==
+ dependencies:
+ "@jridgewell/sourcemap-codec" "^1.5.0"
+ "@jridgewell/trace-mapping" "^0.3.24"
+
+"@jridgewell/remapping@^2.3.5":
+ version "2.3.5"
+ resolved "https://registry.yarnpkg.com/@jridgewell/remapping/-/remapping-2.3.5.tgz#375c476d1972947851ba1e15ae8f123047445aa1"
+ integrity sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==
+ dependencies:
+ "@jridgewell/gen-mapping" "^0.3.5"
+ "@jridgewell/trace-mapping" "^0.3.24"
+
+"@jridgewell/resolve-uri@^3.1.0":
+ version "3.1.2"
+ resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6"
+ integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==
+
+"@jridgewell/source-map@^0.3.3":
+ version "0.3.11"
+ resolved "https://registry.yarnpkg.com/@jridgewell/source-map/-/source-map-0.3.11.tgz#b21835cbd36db656b857c2ad02ebd413cc13a9ba"
+ integrity sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==
+ dependencies:
+ "@jridgewell/gen-mapping" "^0.3.5"
+ "@jridgewell/trace-mapping" "^0.3.25"
+
+"@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.5.0", "@jridgewell/sourcemap-codec@^1.5.5":
+ version "1.5.5"
+ resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba"
+ integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==
+
+"@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25", "@jridgewell/trace-mapping@^0.3.28":
+ version "0.3.31"
+ resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz#db15d6781c931f3a251a3dac39501c98a6082fd0"
+ integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==
+ dependencies:
+ "@jridgewell/resolve-uri" "^3.1.0"
+ "@jridgewell/sourcemap-codec" "^1.4.14"
+
+"@rollup/plugin-babel@^7.0.0":
+ version "7.0.0"
+ resolved "https://registry.yarnpkg.com/@rollup/plugin-babel/-/plugin-babel-7.0.0.tgz#bbdc39ca023afc1a67d3488cf20731695b27a911"
+ integrity sha512-NS2+P7v80N3MQqehZEjgpaFb9UyX3URNMW/zvoECKGo4PY4DvJfQusTI7BX/Ks+CPvtTfk3TqcR6S9VYBi/C+A==
+ dependencies:
+ "@babel/helper-module-imports" "^7.18.6"
+ "@rollup/pluginutils" "^5.0.1"
+
+"@rollup/plugin-commonjs@^29.0.2":
+ version "29.0.2"
+ resolved "https://registry.yarnpkg.com/@rollup/plugin-commonjs/-/plugin-commonjs-29.0.2.tgz#d2d84c49d0983d071f2ab96f4cfe02fe80abd602"
+ integrity sha512-S/ggWH1LU7jTyi9DxZOKyxpVd4hF/OZ0JrEbeLjXk/DFXwRny0tjD2c992zOUYQobLrVkRVMDdmHP16HKP7GRg==
+ dependencies:
+ "@rollup/pluginutils" "^5.0.1"
+ commondir "^1.0.1"
+ estree-walker "^2.0.2"
+ fdir "^6.2.0"
+ is-reference "1.2.1"
+ magic-string "^0.30.3"
+ picomatch "^4.0.2"
+
+"@rollup/plugin-node-resolve@^16.0.3":
+ version "16.0.3"
+ resolved "https://registry.yarnpkg.com/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz#0988e6f2cbb13316b0f5e7213f757bc9ed44928f"
+ integrity sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==
+ dependencies:
+ "@rollup/pluginutils" "^5.0.1"
+ "@types/resolve" "1.20.2"
+ deepmerge "^4.2.2"
+ is-module "^1.0.0"
+ resolve "^1.22.1"
+
+"@rollup/plugin-terser@^1.0.0":
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/@rollup/plugin-terser/-/plugin-terser-1.0.0.tgz#dabbc4414d127aa7d43fc5e7ea8699b9c3bc59e5"
+ integrity sha512-FnCxhTBx6bMOYQrar6C8h3scPt8/JwIzw3+AJ2K++6guogH5fYaIFia+zZuhqv0eo1RN7W1Pz630SyvLbDjhtQ==
+ dependencies:
+ serialize-javascript "^7.0.3"
+ smob "^1.0.0"
+ terser "^5.17.4"
+
+"@rollup/pluginutils@^5.0.1":
+ version "5.3.0"
+ resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-5.3.0.tgz#57ba1b0cbda8e7a3c597a4853c807b156e21a7b4"
+ integrity sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==
+ dependencies:
+ "@types/estree" "^1.0.0"
+ estree-walker "^2.0.2"
+ picomatch "^4.0.2"
+
+"@rollup/rollup-android-arm-eabi@4.60.1":
+ version "4.60.1"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz#043f145716234529052ef9e1ce1d847ffbe9e674"
+ integrity sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==
+
+"@rollup/rollup-android-arm64@4.60.1":
+ version "4.60.1"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz#023e1bd146e7519087dfd9e8b29e4cf9f8ecd35c"
+ integrity sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==
+
+"@rollup/rollup-darwin-arm64@4.60.1":
+ version "4.60.1"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz#55ccb5487c02419954c57a7a80602885d616e1ee"
+ integrity sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==
+
+"@rollup/rollup-darwin-x64@4.60.1":
+ version "4.60.1"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz#254b65404b14488c83225e88b8819376ad71a784"
+ integrity sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==
+
+"@rollup/rollup-freebsd-arm64@4.60.1":
+ version "4.60.1"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz#6377ff38c052c76fcaffb7b2728d3172fe676fe6"
+ integrity sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==
+
+"@rollup/rollup-freebsd-x64@4.60.1":
+ version "4.60.1"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz#ba3902309d088eaf7139b916f09b7140b28b406d"
+ integrity sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==
+
+"@rollup/rollup-linux-arm-gnueabihf@4.60.1":
+ version "4.60.1"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz#e011b9a14638267e53b446286e838dbdaf53f167"
+ integrity sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==
+
+"@rollup/rollup-linux-arm-musleabihf@4.60.1":
+ version "4.60.1"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz#0bce9ce9a009490abd28fd922dd97ed521311afe"
+ integrity sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==
+
+"@rollup/rollup-linux-arm64-gnu@4.60.1":
+ version "4.60.1"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz#6f6cfbbf324fbb4ceff213abdf7f322fd45d25ff"
+ integrity sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==
+
+"@rollup/rollup-linux-arm64-musl@4.60.1":
+ version "4.60.1"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz#f7cb3eecaea9c151ef77342af05f38ae924bf795"
+ integrity sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==
+
+"@rollup/rollup-linux-loong64-gnu@4.60.1":
+ version "4.60.1"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz#499bfac6bb669fd88bb664357bf6be996a28b92f"
+ integrity sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==
+
+"@rollup/rollup-linux-loong64-musl@4.60.1":
+ version "4.60.1"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz#127dfac08764764396bbe04453c545d38a3ab518"
+ integrity sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==
+
+"@rollup/rollup-linux-ppc64-gnu@4.60.1":
+ version "4.60.1"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz#6a72f4d95852aac18326c5bf708393e8f3a41b70"
+ integrity sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==
+
+"@rollup/rollup-linux-ppc64-musl@4.60.1":
+ version "4.60.1"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz#ba8674666b00d6f9066cb9a5771a8430c34d2de6"
+ integrity sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==
+
+"@rollup/rollup-linux-riscv64-gnu@4.60.1":
+ version "4.60.1"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz#17cc38b2a71e302547cad29bcf78d0db2618c922"
+ integrity sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==
+
+"@rollup/rollup-linux-riscv64-musl@4.60.1":
+ version "4.60.1"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz#e36a41e2d8bd247331bd5cfc13b8c951d33454a2"
+ integrity sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==
+
+"@rollup/rollup-linux-s390x-gnu@4.60.1":
+ version "4.60.1"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz#1687265f1f4bdea0726c761a58c2db9933609d68"
+ integrity sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==
+
+"@rollup/rollup-linux-x64-gnu@4.60.1":
+ version "4.60.1"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz#56a6a0d9076f2a05a976031493b24a20ddcc0e77"
+ integrity sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==
+
+"@rollup/rollup-linux-x64-musl@4.60.1":
+ version "4.60.1"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz#bc240ebb5b9fd8d41ca8a80cb458452e8c187e0f"
+ integrity sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==
+
+"@rollup/rollup-openbsd-x64@4.60.1":
+ version "4.60.1"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz#6f80d48a006c4b2ffa7724e95a3e33f6975872af"
+ integrity sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==
+
+"@rollup/rollup-openharmony-arm64@4.60.1":
+ version "4.60.1"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz#8f6db6f70d0a48abd833b263cd6dd3e7199c4c0e"
+ integrity sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==
+
+"@rollup/rollup-win32-arm64-msvc@4.60.1":
+ version "4.60.1"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz#b68989bfa815d0b3d4e302ecd90bda744438b177"
+ integrity sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==
+
+"@rollup/rollup-win32-ia32-msvc@4.60.1":
+ version "4.60.1"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz#c098e45338c50f22f1b288476354f025b746285b"
+ integrity sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==
+
+"@rollup/rollup-win32-x64-gnu@4.60.1":
+ version "4.60.1"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz#2c9e15be155b79d05999953b1737b2903842e903"
+ integrity sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==
+
+"@rollup/rollup-win32-x64-msvc@4.60.1":
+ version "4.60.1"
+ resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz#23b860113e9f87eea015d1fa3a4240a52b42fcd4"
+ integrity sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==
+
+"@tweenjs/tween.js@18 - 25":
+ version "25.0.0"
+ resolved "https://registry.yarnpkg.com/@tweenjs/tween.js/-/tween.js-25.0.0.tgz#7266baebcc3affe62a3a54318a3ea82d904cd0b9"
+ integrity sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==
+
+"@types/estree@*", "@types/estree@1.0.8", "@types/estree@^1.0.0":
+ version "1.0.8"
+ resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e"
+ integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==
+
+"@types/resolve@1.20.2":
+ version "1.20.2"
+ resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.20.2.tgz#97d26e00cd4a0423b4af620abecf3e6f442b7975"
+ integrity sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==
+
+accessor-fn@1:
+ version "1.5.3"
+ resolved "https://registry.yarnpkg.com/accessor-fn/-/accessor-fn-1.5.3.tgz#5e2549d291d4ac022f532da9a554358dc525b0f7"
+ integrity sha512-rkAofCwe/FvYFUlMB0v0gWmhqtfAtV1IUkdPbfhTUyYniu5LrC0A0UJkTH0Jv3S8SvwkmfuAlY+mQIJATdocMA==
+
+acorn@^8.15.0:
+ version "8.16.0"
+ resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.16.0.tgz#4ce79c89be40afe7afe8f3adb902a1f1ce9ac08a"
+ integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==
+
+ansi-styles@^4.1.0:
+ version "4.3.0"
+ resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937"
+ integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==
+ dependencies:
+ color-convert "^2.0.1"
+
+babel-plugin-polyfill-corejs2@^0.4.15:
+ version "0.4.17"
+ resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz#198f970f1c99a856b466d1187e88ce30bd199d91"
+ integrity sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==
+ dependencies:
+ "@babel/compat-data" "^7.28.6"
+ "@babel/helper-define-polyfill-provider" "^0.6.8"
+ semver "^6.3.1"
+
+babel-plugin-polyfill-corejs3@^0.14.0:
+ version "0.14.2"
+ resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.2.tgz#6ac08d2f312affb70c4c69c0fbba4cb417ee5587"
+ integrity sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==
+ dependencies:
+ "@babel/helper-define-polyfill-provider" "^0.6.8"
+ core-js-compat "^3.48.0"
+
+babel-plugin-polyfill-regenerator@^0.6.6:
+ version "0.6.8"
+ resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz#8a6bfd5dd54239362b3d06ce47ac52b2d95d7721"
+ integrity sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==
+ dependencies:
+ "@babel/helper-define-polyfill-provider" "^0.6.8"
+
+balanced-match@^4.0.2:
+ version "4.0.4"
+ resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-4.0.4.tgz#bfb10662feed8196a2c62e7c68e17720c274179a"
+ integrity sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==
+
+baseline-browser-mapping@^2.10.12:
+ version "2.10.19"
+ resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.19.tgz#7697721c22f94f66195d0c34299b1a91e3299493"
+ integrity sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g==
+
+"bezier-js@3 - 6":
+ version "6.1.4"
+ resolved "https://registry.yarnpkg.com/bezier-js/-/bezier-js-6.1.4.tgz#c7828f6c8900562b69d5040afb881bcbdad82001"
+ integrity sha512-PA0FW9ZpcHbojUCMu28z9Vg/fNkwTj5YhusSAjHHDfHDGLxJ6YUKrAN2vk1fP2MMOxVw4Oko16FMlRGVBGqLKg==
+
+boolbase@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e"
+ integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==
+
+brace-expansion@^5.0.5:
+ version "5.0.5"
+ resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.5.tgz#dcc3a37116b79f3e1b46db994ced5d570e930fdb"
+ integrity sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==
+ dependencies:
+ balanced-match "^4.0.2"
+
+browserslist@^4.0.0, browserslist@^4.21.4, browserslist@^4.24.0, browserslist@^4.28.1:
+ version "4.28.2"
+ resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.28.2.tgz#f50b65362ef48974ca9f50b3680566d786b811d2"
+ integrity sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==
+ dependencies:
+ baseline-browser-mapping "^2.10.12"
+ caniuse-lite "^1.0.30001782"
+ electron-to-chromium "^1.5.328"
+ node-releases "^2.0.36"
+ update-browserslist-db "^1.2.3"
+
+buffer-from@^1.0.0:
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5"
+ integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==
+
+caniuse-api@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/caniuse-api/-/caniuse-api-3.0.0.tgz#5e4d90e2274961d46291997df599e3ed008ee4c0"
+ integrity sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==
+ dependencies:
+ browserslist "^4.0.0"
+ caniuse-lite "^1.0.0"
+ lodash.memoize "^4.1.2"
+ lodash.uniq "^4.5.0"
+
+caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001782:
+ version "1.0.30001788"
+ resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz#31e97d1bfec332b3f2d7eea7781460c97629b3bf"
+ integrity sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==
+
+canvas-color-tracker@^1.3:
+ version "1.3.2"
+ resolved "https://registry.yarnpkg.com/canvas-color-tracker/-/canvas-color-tracker-1.3.2.tgz#b924cf94b33441b82692938fca5b936be971a46d"
+ integrity sha512-ryQkDX26yJ3CXzb3hxUVNlg1NKE4REc5crLBq661Nxzr8TNd236SaEf2ffYLXyI5tSABSeguHLqcVq4vf9L3Zg==
+ dependencies:
+ tinycolor2 "^1.6.0"
+
+chalk@^4.1.0:
+ version "4.1.2"
+ resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01"
+ integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==
+ dependencies:
+ ansi-styles "^4.1.0"
+ supports-color "^7.1.0"
+
+color-convert@^2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3"
+ integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==
+ dependencies:
+ color-name "~1.1.4"
+
+color-name@~1.1.4:
+ version "1.1.4"
+ resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
+ integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
+
+colord@^2.9.1:
+ version "2.9.3"
+ resolved "https://registry.yarnpkg.com/colord/-/colord-2.9.3.tgz#4f8ce919de456f1d5c1c368c307fe20f3e59fb43"
+ integrity sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==
+
+commander@^2.20.0:
+ version "2.20.3"
+ resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
+ integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
+
+commander@^7.2.0:
+ version "7.2.0"
+ resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7"
+ integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==
+
+commondir@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b"
+ integrity sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==
+
+concat-with-sourcemaps@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/concat-with-sourcemaps/-/concat-with-sourcemaps-1.1.0.tgz#d4ea93f05ae25790951b99e7b3b09e3908a4082e"
+ integrity sha512-4gEjHJFT9e+2W/77h/DS5SGUgwDaOwprX8L/gl5+3ixnzkVJJsZWDSelmN3Oilw3LNDZjZV0yqH1hLG3k6nghg==
+ dependencies:
+ source-map "^0.6.1"
+
+convert-source-map@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a"
+ integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==
+
+core-js-compat@^3.48.0:
+ version "3.49.0"
+ resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.49.0.tgz#06145447d92f4aaf258a0c44f24b47afaeaffef6"
+ integrity sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==
+ dependencies:
+ browserslist "^4.28.1"
+
+css-declaration-sorter@^6.3.1:
+ version "6.4.1"
+ resolved "https://registry.yarnpkg.com/css-declaration-sorter/-/css-declaration-sorter-6.4.1.tgz#28beac7c20bad7f1775be3a7129d7eae409a3a71"
+ integrity sha512-rtdthzxKuyq6IzqX6jEcIzQF/YqccluefyCYheovBOLhFT/drQA9zj/UbRAa9J7C0o6EG6u3E6g+vKkay7/k3g==
+
+css-select@^4.1.3:
+ version "4.3.0"
+ resolved "https://registry.yarnpkg.com/css-select/-/css-select-4.3.0.tgz#db7129b2846662fd8628cfc496abb2b59e41529b"
+ integrity sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==
+ dependencies:
+ boolbase "^1.0.0"
+ css-what "^6.0.1"
+ domhandler "^4.3.1"
+ domutils "^2.8.0"
+ nth-check "^2.0.1"
+
+css-tree@^1.1.2, css-tree@^1.1.3:
+ version "1.1.3"
+ resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-1.1.3.tgz#eb4870fb6fd7707327ec95c2ff2ab09b5e8db91d"
+ integrity sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==
+ dependencies:
+ mdn-data "2.0.14"
+ source-map "^0.6.1"
+
+css-what@^6.0.1:
+ version "6.2.2"
+ resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.2.2.tgz#cdcc8f9b6977719fdfbd1de7aec24abf756b9dea"
+ integrity sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==
+
+cssesc@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee"
+ integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==
+
+cssnano-preset-default@^5.2.14:
+ version "5.2.14"
+ resolved "https://registry.yarnpkg.com/cssnano-preset-default/-/cssnano-preset-default-5.2.14.tgz#309def4f7b7e16d71ab2438052093330d9ab45d8"
+ integrity sha512-t0SFesj/ZV2OTylqQVOrFgEh5uanxbO6ZAdeCrNsUQ6fVuXwYTxJPNAGvGTxHbD68ldIJNec7PyYZDBrfDQ+6A==
+ dependencies:
+ css-declaration-sorter "^6.3.1"
+ cssnano-utils "^3.1.0"
+ postcss-calc "^8.2.3"
+ postcss-colormin "^5.3.1"
+ postcss-convert-values "^5.1.3"
+ postcss-discard-comments "^5.1.2"
+ postcss-discard-duplicates "^5.1.0"
+ postcss-discard-empty "^5.1.1"
+ postcss-discard-overridden "^5.1.0"
+ postcss-merge-longhand "^5.1.7"
+ postcss-merge-rules "^5.1.4"
+ postcss-minify-font-values "^5.1.0"
+ postcss-minify-gradients "^5.1.1"
+ postcss-minify-params "^5.1.4"
+ postcss-minify-selectors "^5.2.1"
+ postcss-normalize-charset "^5.1.0"
+ postcss-normalize-display-values "^5.1.0"
+ postcss-normalize-positions "^5.1.1"
+ postcss-normalize-repeat-style "^5.1.1"
+ postcss-normalize-string "^5.1.0"
+ postcss-normalize-timing-functions "^5.1.0"
+ postcss-normalize-unicode "^5.1.1"
+ postcss-normalize-url "^5.1.0"
+ postcss-normalize-whitespace "^5.1.1"
+ postcss-ordered-values "^5.1.3"
+ postcss-reduce-initial "^5.1.2"
+ postcss-reduce-transforms "^5.1.0"
+ postcss-svgo "^5.1.0"
+ postcss-unique-selectors "^5.1.1"
+
+cssnano-utils@^3.1.0:
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/cssnano-utils/-/cssnano-utils-3.1.0.tgz#95684d08c91511edfc70d2636338ca37ef3a6861"
+ integrity sha512-JQNR19/YZhz4psLX/rQ9M83e3z2Wf/HdJbryzte4a3NSuafyp9w/I4U+hx5C2S9g41qlstH7DEWnZaaj83OuEA==
+
+cssnano@^5.0.1:
+ version "5.1.15"
+ resolved "https://registry.yarnpkg.com/cssnano/-/cssnano-5.1.15.tgz#ded66b5480d5127fcb44dac12ea5a983755136bf"
+ integrity sha512-j+BKgDcLDQA+eDifLx0EO4XSA56b7uut3BQFH+wbSaSTuGLuiyTa/wbRYthUXX8LC9mLg+WWKe8h+qJuwTAbHw==
+ dependencies:
+ cssnano-preset-default "^5.2.14"
+ lilconfig "^2.0.3"
+ yaml "^1.10.2"
+
+csso@^4.2.0:
+ version "4.2.0"
+ resolved "https://registry.yarnpkg.com/csso/-/csso-4.2.0.tgz#ea3a561346e8dc9f546d6febedd50187cf389529"
+ integrity sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA==
+ dependencies:
+ css-tree "^1.1.2"
+
+"d3-array@1 - 3", "d3-array@2 - 3", "d3-array@2.10.0 - 3":
+ version "3.2.4"
+ resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-3.2.4.tgz#15fec33b237f97ac5d7c986dc77da273a8ed0bb5"
+ integrity sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==
+ dependencies:
+ internmap "1 - 2"
+
+d3-binarytree@1:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/d3-binarytree/-/d3-binarytree-1.0.2.tgz#ed43ebc13c70fbabfdd62df17480bc5a425753cc"
+ integrity sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==
+
+"d3-color@1 - 3":
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-3.1.0.tgz#395b2833dfac71507f12ac2f7af23bf819de24e2"
+ integrity sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==
+
+"d3-dispatch@1 - 3":
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz#5fc75284e9c2375c36c839411a0cf550cbfc4d5e"
+ integrity sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==
+
+"d3-drag@2 - 3":
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/d3-drag/-/d3-drag-3.0.0.tgz#994aae9cd23c719f53b5e10e3a0a6108c69607ba"
+ integrity sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==
+ dependencies:
+ d3-dispatch "1 - 3"
+ d3-selection "3"
+
+"d3-ease@1 - 3":
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-3.0.1.tgz#9658ac38a2140d59d346160f1f6c30fda0bd12f4"
+ integrity sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==
+
+"d3-force-3d@2 - 3":
+ version "3.0.6"
+ resolved "https://registry.yarnpkg.com/d3-force-3d/-/d3-force-3d-3.0.6.tgz#7ea4c26d7937b82993bd9444f570ed52f661d4aa"
+ integrity sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA==
+ dependencies:
+ d3-binarytree "1"
+ d3-dispatch "1 - 3"
+ d3-octree "1"
+ d3-quadtree "1 - 3"
+ d3-timer "1 - 3"
+
+"d3-format@1 - 3":
+ version "3.1.2"
+ resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-3.1.2.tgz#01fdb46b58beb1f55b10b42ad70b6e344d5eb2ae"
+ integrity sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==
+
+"d3-interpolate@1 - 3", "d3-interpolate@1.2.0 - 3":
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz#3c47aa5b32c5b3dfb56ef3fd4342078a632b400d"
+ integrity sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==
+ dependencies:
+ d3-color "1 - 3"
+
+d3-octree@1:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/d3-octree/-/d3-octree-1.1.0.tgz#f07e353b76df872644e7130ab1a74c5ef2f4287e"
+ integrity sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==
+
+"d3-quadtree@1 - 3":
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/d3-quadtree/-/d3-quadtree-3.0.1.tgz#6dca3e8be2b393c9a9d514dabbd80a92deef1a4f"
+ integrity sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==
+
+"d3-scale-chromatic@1 - 3":
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz#34c39da298b23c20e02f1a4b239bd0f22e7f1314"
+ integrity sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==
+ dependencies:
+ d3-color "1 - 3"
+ d3-interpolate "1 - 3"
+
+"d3-scale@1 - 4":
+ version "4.0.2"
+ resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-4.0.2.tgz#82b38e8e8ff7080764f8dcec77bd4be393689396"
+ integrity sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==
+ dependencies:
+ d3-array "2.10.0 - 3"
+ d3-format "1 - 3"
+ d3-interpolate "1.2.0 - 3"
+ d3-time "2.1.1 - 3"
+ d3-time-format "2 - 4"
+
+"d3-selection@2 - 3", d3-selection@3:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-3.0.0.tgz#c25338207efa72cc5b9bd1458a1a41901f1e1b31"
+ integrity sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==
+
+"d3-time-format@2 - 4":
+ version "4.1.0"
+ resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-4.1.0.tgz#7ab5257a5041d11ecb4fe70a5c7d16a195bb408a"
+ integrity sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==
+ dependencies:
+ d3-time "1 - 3"
+
+"d3-time@1 - 3", "d3-time@2.1.1 - 3":
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-3.1.0.tgz#9310db56e992e3c0175e1ef385e545e48a9bb5c7"
+ integrity sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==
+ dependencies:
+ d3-array "2 - 3"
+
+"d3-timer@1 - 3":
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-3.0.1.tgz#6284d2a2708285b1abb7e201eda4380af35e63b0"
+ integrity sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==
+
+"d3-transition@2 - 3":
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/d3-transition/-/d3-transition-3.0.1.tgz#6869fdde1448868077fdd5989200cb61b2a1645f"
+ integrity sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==
+ dependencies:
+ d3-color "1 - 3"
+ d3-dispatch "1 - 3"
+ d3-ease "1 - 3"
+ d3-interpolate "1 - 3"
+ d3-timer "1 - 3"
+
+"d3-zoom@2 - 3":
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/d3-zoom/-/d3-zoom-3.0.0.tgz#d13f4165c73217ffeaa54295cd6969b3e7aee8f3"
+ integrity sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==
+ dependencies:
+ d3-dispatch "1 - 3"
+ d3-drag "2 - 3"
+ d3-interpolate "1 - 3"
+ d3-selection "2 - 3"
+ d3-transition "2 - 3"
+
+debug@^4.1.0, debug@^4.3.1, debug@^4.4.3:
+ version "4.4.3"
+ resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a"
+ integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
+ dependencies:
+ ms "^2.1.3"
+
+deepmerge@^4.2.2:
+ version "4.3.1"
+ resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a"
+ integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==
+
+dom-serializer@^1.0.1:
+ version "1.4.1"
+ resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.4.1.tgz#de5d41b1aea290215dc45a6dae8adcf1d32e2d30"
+ integrity sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==
+ dependencies:
+ domelementtype "^2.0.1"
+ domhandler "^4.2.0"
+ entities "^2.0.0"
+
+domelementtype@^2.0.1, domelementtype@^2.2.0:
+ version "2.3.0"
+ resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d"
+ integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==
+
+domhandler@^4.2.0, domhandler@^4.3.1:
+ version "4.3.1"
+ resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-4.3.1.tgz#8d792033416f59d68bc03a5aa7b018c1ca89279c"
+ integrity sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==
+ dependencies:
+ domelementtype "^2.2.0"
+
+domutils@^2.8.0:
+ version "2.8.0"
+ resolved "https://registry.yarnpkg.com/domutils/-/domutils-2.8.0.tgz#4437def5db6e2d1f5d6ee859bd95ca7d02048135"
+ integrity sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==
+ dependencies:
+ dom-serializer "^1.0.1"
+ domelementtype "^2.2.0"
+ domhandler "^4.2.0"
+
+electron-to-chromium@^1.5.328:
+ version "1.5.338"
+ resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.338.tgz#7ca765a1fabed5e60b43c6f5e74363ecafcd3336"
+ integrity sha512-KVQQ3xko9/coDX3qXLUEEbqkKT8L+1DyAovrtu0Khtrt9wjSZ+7CZV4GVzxFy9Oe1NbrIU1oVXCwHJruIA1PNg==
+
+entities@^2.0.0:
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55"
+ integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==
+
+es-errors@^1.3.0:
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f"
+ integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==
+
+escalade@^3.2.0:
+ version "3.2.0"
+ resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5"
+ integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==
+
+estree-walker@^0.6.1:
+ version "0.6.1"
+ resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-0.6.1.tgz#53049143f40c6eb918b23671d1fe3219f3a1b362"
+ integrity sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==
+
+estree-walker@^2.0.2:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac"
+ integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==
+
+esutils@^2.0.2:
+ version "2.0.3"
+ resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64"
+ integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
+
+eventemitter3@^4.0.4:
+ version "4.0.7"
+ resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f"
+ integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==
+
+fdir@^6.2.0:
+ version "6.5.0"
+ resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350"
+ integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==
+
+float-tooltip@^1.7:
+ version "1.7.5"
+ resolved "https://registry.yarnpkg.com/float-tooltip/-/float-tooltip-1.7.5.tgz#7083bf78f0de5a97f9c2d6aa8e90d2139f34047f"
+ integrity sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==
+ dependencies:
+ d3-selection "2 - 3"
+ kapsule "^1.16"
+ preact "10"
+
+fsevents@~2.3.2:
+ version "2.3.3"
+ resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
+ integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
+
+function-bind@^1.1.2:
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c"
+ integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==
+
+generic-names@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/generic-names/-/generic-names-4.0.0.tgz#0bd8a2fd23fe8ea16cbd0a279acd69c06933d9a3"
+ integrity sha512-ySFolZQfw9FoDb3ed9d80Cm9f0+r7qj+HJkWjeD9RBfpxEVTlVhol+gvaQB/78WbwYfbnNh8nWHHBSlg072y6A==
+ dependencies:
+ loader-utils "^3.2.0"
+
+gensync@^1.0.0-beta.2:
+ version "1.0.0-beta.2"
+ resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"
+ integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==
+
+glob@^13.0.3:
+ version "13.0.6"
+ resolved "https://registry.yarnpkg.com/glob/-/glob-13.0.6.tgz#078666566a425147ccacfbd2e332deb66a2be71d"
+ integrity sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==
+ dependencies:
+ minimatch "^10.2.2"
+ minipass "^7.1.3"
+ path-scurry "^2.0.2"
+
+has-flag@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b"
+ integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
+
+hasown@^2.0.2:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003"
+ integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==
+ dependencies:
+ function-bind "^1.1.2"
+
+icss-replace-symbols@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/icss-replace-symbols/-/icss-replace-symbols-1.1.0.tgz#06ea6f83679a7749e386cfe1fe812ae5db223ded"
+ integrity sha512-chIaY3Vh2mh2Q3RGXttaDIzeiPvaVXJ+C4DAh/w3c37SKZ/U6PGMmuicR2EQQp9bKG8zLMCl7I+PtIoOOPp8Gg==
+
+icss-utils@^5.0.0:
+ version "5.1.0"
+ resolved "https://registry.yarnpkg.com/icss-utils/-/icss-utils-5.1.0.tgz#c6be6858abd013d768e98366ae47e25d5887b1ae"
+ integrity sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==
+
+import-cwd@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/import-cwd/-/import-cwd-3.0.0.tgz#20845547718015126ea9b3676b7592fb8bd4cf92"
+ integrity sha512-4pnzH16plW+hgvRECbDWpQl3cqtvSofHWh44met7ESfZ8UZOWWddm8hEyDTqREJ9RbYHY8gi8DqmaelApoOGMg==
+ dependencies:
+ import-from "^3.0.0"
+
+import-from@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/import-from/-/import-from-3.0.0.tgz#055cfec38cd5a27d8057ca51376d7d3bf0891966"
+ integrity sha512-CiuXOFFSzkU5x/CR0+z7T91Iht4CXgfCxVOFRhh2Zyhg5wOpWvvDLQUsWl+gcN+QscYBjez8hDCt85O7RLDttQ==
+ dependencies:
+ resolve-from "^5.0.0"
+
+index-array-by@1:
+ version "1.4.2"
+ resolved "https://registry.yarnpkg.com/index-array-by/-/index-array-by-1.4.2.tgz#d6f82e9fbff3201c4dab64ba415d4d2923242fea"
+ integrity sha512-SP23P27OUKzXWEC/TOyWlwLviofQkCSCKONnc62eItjp69yCZZPqDQtr3Pw5gJDnPeUMqExmKydNZaJO0FU9pw==
+
+"internmap@1 - 2":
+ version "2.0.3"
+ resolved "https://registry.yarnpkg.com/internmap/-/internmap-2.0.3.tgz#6685f23755e43c524e251d29cbc97248e3061009"
+ integrity sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==
+
+is-core-module@^2.16.1:
+ version "2.16.1"
+ resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.1.tgz#2a98801a849f43e2add644fbb6bc6229b19a4ef4"
+ integrity sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==
+ dependencies:
+ hasown "^2.0.2"
+
+is-module@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/is-module/-/is-module-1.0.0.tgz#3258fb69f78c14d5b815d664336b4cffb6441591"
+ integrity sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==
+
+is-reference@1.2.1:
+ version "1.2.1"
+ resolved "https://registry.yarnpkg.com/is-reference/-/is-reference-1.2.1.tgz#8b2dac0b371f4bc994fdeaba9eb542d03002d0b7"
+ integrity sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==
+ dependencies:
+ "@types/estree" "*"
+
+js-tokens@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
+ integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
+
+jsesc@^3.0.2, jsesc@~3.1.0:
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-3.1.0.tgz#74d335a234f67ed19907fdadfac7ccf9d409825d"
+ integrity sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==
+
+json5@^2.2.3:
+ version "2.2.3"
+ resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283"
+ integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==
+
+kapsule@^1.16:
+ version "1.16.3"
+ resolved "https://registry.yarnpkg.com/kapsule/-/kapsule-1.16.3.tgz#5684ed89838b6658b30d0f2cc056dffc3ba68c30"
+ integrity sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==
+ dependencies:
+ lodash-es "4"
+
+lilconfig@^2.0.3, lilconfig@^2.0.5:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.1.0.tgz#78e23ac89ebb7e1bfbf25b18043de756548e7f52"
+ integrity sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==
+
+loader-utils@^3.2.0:
+ version "3.3.1"
+ resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-3.3.1.tgz#735b9a19fd63648ca7adbd31c2327dfe281304e5"
+ integrity sha512-FMJTLMXfCLMLfJxcX9PFqX5qD88Z5MRGaZCVzfuqeZSPsyiBzs+pahDQjbIWz2QIzPZz0NX9Zy4FX3lmK6YHIg==
+
+lodash-es@4:
+ version "4.18.1"
+ resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.18.1.tgz#b962eeb80d9d983a900bf342961fb7418ca10b1d"
+ integrity sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==
+
+lodash.camelcase@^4.3.0:
+ version "4.3.0"
+ resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6"
+ integrity sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==
+
+lodash.debounce@^4.0.8:
+ version "4.0.8"
+ resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
+ integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==
+
+lodash.memoize@^4.1.2:
+ version "4.1.2"
+ resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe"
+ integrity sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==
+
+lodash.uniq@^4.5.0:
+ version "4.5.0"
+ resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
+ integrity sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==
+
+lru-cache@^11.0.0:
+ version "11.3.5"
+ resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-11.3.5.tgz#29047d348c0b2793e3112a01c739bb7c6d855637"
+ integrity sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==
+
+lru-cache@^5.1.1:
+ version "5.1.1"
+ resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920"
+ integrity sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==
+ dependencies:
+ yallist "^3.0.2"
+
+magic-string@^0.30.21, magic-string@^0.30.3:
+ version "0.30.21"
+ resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91"
+ integrity sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==
+ dependencies:
+ "@jridgewell/sourcemap-codec" "^1.5.5"
+
+mdn-data@2.0.14:
+ version "2.0.14"
+ resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.14.tgz#7113fc4281917d63ce29b43446f701e68c25ba50"
+ integrity sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==
+
+minimatch@^10.2.2:
+ version "10.2.5"
+ resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.5.tgz#bd48687a0be38ed2961399105600f832095861d1"
+ integrity sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==
+ dependencies:
+ brace-expansion "^5.0.5"
+
+minipass@^7.1.2, minipass@^7.1.3:
+ version "7.1.3"
+ resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.3.tgz#79389b4eb1bb2d003a9bba87d492f2bd37bdc65b"
+ integrity sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==
+
+ms@^2.1.3:
+ version "2.1.3"
+ resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
+ integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
+
+nanoid@^3.3.11:
+ version "3.3.11"
+ resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b"
+ integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==
+
+node-releases@^2.0.36:
+ version "2.0.37"
+ resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.37.tgz#9bd4f10b77ba39c2b9402d4e8399c482a797f671"
+ integrity sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==
+
+normalize-url@^6.0.1:
+ version "6.1.0"
+ resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-6.1.0.tgz#40d0885b535deffe3f3147bec877d05fe4c5668a"
+ integrity sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==
+
+nth-check@^2.0.1:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.1.1.tgz#c9eab428effce36cd6b92c924bdb000ef1f1ed1d"
+ integrity sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==
+ dependencies:
+ boolbase "^1.0.0"
+
+p-finally@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae"
+ integrity sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==
+
+p-queue@^6.6.2:
+ version "6.6.2"
+ resolved "https://registry.yarnpkg.com/p-queue/-/p-queue-6.6.2.tgz#2068a9dcf8e67dd0ec3e7a2bcb76810faa85e426"
+ integrity sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==
+ dependencies:
+ eventemitter3 "^4.0.4"
+ p-timeout "^3.2.0"
+
+p-timeout@^3.2.0:
+ version "3.2.0"
+ resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-3.2.0.tgz#c7e17abc971d2a7962ef83626b35d635acf23dfe"
+ integrity sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==
+ dependencies:
+ p-finally "^1.0.0"
+
+package-json-from-dist@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505"
+ integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==
+
+path-parse@^1.0.7:
+ version "1.0.7"
+ resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735"
+ integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==
+
+path-scurry@^2.0.2:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-2.0.2.tgz#6be0d0ee02a10d9e0de7a98bae65e182c9061f85"
+ integrity sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==
+ dependencies:
+ lru-cache "^11.0.0"
+ minipass "^7.1.2"
+
+picocolors@^1.0.0, picocolors@^1.1.1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"
+ integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==
+
+picomatch@^4.0.2:
+ version "4.0.4"
+ resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589"
+ integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==
+
+pify@^5.0.0:
+ version "5.0.0"
+ resolved "https://registry.yarnpkg.com/pify/-/pify-5.0.0.tgz#1f5eca3f5e87ebec28cc6d54a0e4aaf00acc127f"
+ integrity sha512-eW/gHNMlxdSP6dmG6uJip6FXN0EQBwm2clYYd8Wul42Cwu/DK8HEftzsapcNdYe2MfLiIwZqsDk2RDEsTE79hA==
+
+postcss-calc@^8.2.3:
+ version "8.2.4"
+ resolved "https://registry.yarnpkg.com/postcss-calc/-/postcss-calc-8.2.4.tgz#77b9c29bfcbe8a07ff6693dc87050828889739a5"
+ integrity sha512-SmWMSJmB8MRnnULldx0lQIyhSNvuDl9HfrZkaqqE/WHAhToYsAvDq+yAsA/kIyINDszOp3Rh0GFoNuH5Ypsm3Q==
+ dependencies:
+ postcss-selector-parser "^6.0.9"
+ postcss-value-parser "^4.2.0"
+
+postcss-colormin@^5.3.1:
+ version "5.3.1"
+ resolved "https://registry.yarnpkg.com/postcss-colormin/-/postcss-colormin-5.3.1.tgz#86c27c26ed6ba00d96c79e08f3ffb418d1d1988f"
+ integrity sha512-UsWQG0AqTFQmpBegeLLc1+c3jIqBNB0zlDGRWR+dQ3pRKJL1oeMzyqmH3o2PIfn9MBdNrVPWhDbT769LxCTLJQ==
+ dependencies:
+ browserslist "^4.21.4"
+ caniuse-api "^3.0.0"
+ colord "^2.9.1"
+ postcss-value-parser "^4.2.0"
+
+postcss-convert-values@^5.1.3:
+ version "5.1.3"
+ resolved "https://registry.yarnpkg.com/postcss-convert-values/-/postcss-convert-values-5.1.3.tgz#04998bb9ba6b65aa31035d669a6af342c5f9d393"
+ integrity sha512-82pC1xkJZtcJEfiLw6UXnXVXScgtBrjlO5CBmuDQc+dlb88ZYheFsjTn40+zBVi3DkfF7iezO0nJUPLcJK3pvA==
+ dependencies:
+ browserslist "^4.21.4"
+ postcss-value-parser "^4.2.0"
+
+postcss-discard-comments@^5.1.2:
+ version "5.1.2"
+ resolved "https://registry.yarnpkg.com/postcss-discard-comments/-/postcss-discard-comments-5.1.2.tgz#8df5e81d2925af2780075840c1526f0660e53696"
+ integrity sha512-+L8208OVbHVF2UQf1iDmRcbdjJkuBF6IS29yBDSiWUIzpYaAhtNl6JYnYm12FnkeCwQqF5LeklOu6rAqgfBZqQ==
+
+postcss-discard-duplicates@^5.1.0:
+ version "5.1.0"
+ resolved "https://registry.yarnpkg.com/postcss-discard-duplicates/-/postcss-discard-duplicates-5.1.0.tgz#9eb4fe8456706a4eebd6d3b7b777d07bad03e848"
+ integrity sha512-zmX3IoSI2aoenxHV6C7plngHWWhUOV3sP1T8y2ifzxzbtnuhk1EdPwm0S1bIUNaJ2eNbWeGLEwzw8huPD67aQw==
+
+postcss-discard-empty@^5.1.1:
+ version "5.1.1"
+ resolved "https://registry.yarnpkg.com/postcss-discard-empty/-/postcss-discard-empty-5.1.1.tgz#e57762343ff7f503fe53fca553d18d7f0c369c6c"
+ integrity sha512-zPz4WljiSuLWsI0ir4Mcnr4qQQ5e1Ukc3i7UfE2XcrwKK2LIPIqE5jxMRxO6GbI3cv//ztXDsXwEWT3BHOGh3A==
+
+postcss-discard-overridden@^5.1.0:
+ version "5.1.0"
+ resolved "https://registry.yarnpkg.com/postcss-discard-overridden/-/postcss-discard-overridden-5.1.0.tgz#7e8c5b53325747e9d90131bb88635282fb4a276e"
+ integrity sha512-21nOL7RqWR1kasIVdKs8HNqQJhFxLsyRfAnUDm4Fe4t4mCWL9OJiHvlHPjcd8zc5Myu89b/7wZDnOSjFgeWRtw==
+
+postcss-load-config@^3.0.0:
+ version "3.1.4"
+ resolved "https://registry.yarnpkg.com/postcss-load-config/-/postcss-load-config-3.1.4.tgz#1ab2571faf84bb078877e1d07905eabe9ebda855"
+ integrity sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==
+ dependencies:
+ lilconfig "^2.0.5"
+ yaml "^1.10.2"
+
+postcss-merge-longhand@^5.1.7:
+ version "5.1.7"
+ resolved "https://registry.yarnpkg.com/postcss-merge-longhand/-/postcss-merge-longhand-5.1.7.tgz#24a1bdf402d9ef0e70f568f39bdc0344d568fb16"
+ integrity sha512-YCI9gZB+PLNskrK0BB3/2OzPnGhPkBEwmwhfYk1ilBHYVAZB7/tkTHFBAnCrvBBOmeYyMYw3DMjT55SyxMBzjQ==
+ dependencies:
+ postcss-value-parser "^4.2.0"
+ stylehacks "^5.1.1"
+
+postcss-merge-rules@^5.1.4:
+ version "5.1.4"
+ resolved "https://registry.yarnpkg.com/postcss-merge-rules/-/postcss-merge-rules-5.1.4.tgz#2f26fa5cacb75b1402e213789f6766ae5e40313c"
+ integrity sha512-0R2IuYpgU93y9lhVbO/OylTtKMVcHb67zjWIfCiKR9rWL3GUk1677LAqD/BcHizukdZEjT8Ru3oHRoAYoJy44g==
+ dependencies:
+ browserslist "^4.21.4"
+ caniuse-api "^3.0.0"
+ cssnano-utils "^3.1.0"
+ postcss-selector-parser "^6.0.5"
+
+postcss-minify-font-values@^5.1.0:
+ version "5.1.0"
+ resolved "https://registry.yarnpkg.com/postcss-minify-font-values/-/postcss-minify-font-values-5.1.0.tgz#f1df0014a726083d260d3bd85d7385fb89d1f01b"
+ integrity sha512-el3mYTgx13ZAPPirSVsHqFzl+BBBDrXvbySvPGFnQcTI4iNslrPaFq4muTkLZmKlGk4gyFAYUBMH30+HurREyA==
+ dependencies:
+ postcss-value-parser "^4.2.0"
+
+postcss-minify-gradients@^5.1.1:
+ version "5.1.1"
+ resolved "https://registry.yarnpkg.com/postcss-minify-gradients/-/postcss-minify-gradients-5.1.1.tgz#f1fe1b4f498134a5068240c2f25d46fcd236ba2c"
+ integrity sha512-VGvXMTpCEo4qHTNSa9A0a3D+dxGFZCYwR6Jokk+/3oB6flu2/PnPXAh2x7x52EkY5xlIHLm+Le8tJxe/7TNhzw==
+ dependencies:
+ colord "^2.9.1"
+ cssnano-utils "^3.1.0"
+ postcss-value-parser "^4.2.0"
+
+postcss-minify-params@^5.1.4:
+ version "5.1.4"
+ resolved "https://registry.yarnpkg.com/postcss-minify-params/-/postcss-minify-params-5.1.4.tgz#c06a6c787128b3208b38c9364cfc40c8aa5d7352"
+ integrity sha512-+mePA3MgdmVmv6g+30rn57USjOGSAyuxUmkfiWpzalZ8aiBkdPYjXWtHuwJGm1v5Ojy0Z0LaSYhHaLJQB0P8Jw==
+ dependencies:
+ browserslist "^4.21.4"
+ cssnano-utils "^3.1.0"
+ postcss-value-parser "^4.2.0"
+
+postcss-minify-selectors@^5.2.1:
+ version "5.2.1"
+ resolved "https://registry.yarnpkg.com/postcss-minify-selectors/-/postcss-minify-selectors-5.2.1.tgz#d4e7e6b46147b8117ea9325a915a801d5fe656c6"
+ integrity sha512-nPJu7OjZJTsVUmPdm2TcaiohIwxP+v8ha9NehQ2ye9szv4orirRU3SDdtUmKH+10nzn0bAyOXZ0UEr7OpvLehg==
+ dependencies:
+ postcss-selector-parser "^6.0.5"
+
+postcss-modules-extract-imports@^3.0.0:
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz#b4497cb85a9c0c4b5aabeb759bb25e8d89f15002"
+ integrity sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==
+
+postcss-modules-local-by-default@^4.0.0:
+ version "4.2.0"
+ resolved "https://registry.yarnpkg.com/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.2.0.tgz#d150f43837831dae25e4085596e84f6f5d6ec368"
+ integrity sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==
+ dependencies:
+ icss-utils "^5.0.0"
+ postcss-selector-parser "^7.0.0"
+ postcss-value-parser "^4.1.0"
+
+postcss-modules-scope@^3.0.0:
+ version "3.2.1"
+ resolved "https://registry.yarnpkg.com/postcss-modules-scope/-/postcss-modules-scope-3.2.1.tgz#1bbccddcb398f1d7a511e0a2d1d047718af4078c"
+ integrity sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==
+ dependencies:
+ postcss-selector-parser "^7.0.0"
+
+postcss-modules-values@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz#d7c5e7e68c3bb3c9b27cbf48ca0bb3ffb4602c9c"
+ integrity sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==
+ dependencies:
+ icss-utils "^5.0.0"
+
+postcss-modules@^4.0.0:
+ version "4.3.1"
+ resolved "https://registry.yarnpkg.com/postcss-modules/-/postcss-modules-4.3.1.tgz#517c06c09eab07d133ae0effca2c510abba18048"
+ integrity sha512-ItUhSUxBBdNamkT3KzIZwYNNRFKmkJrofvC2nWab3CPKhYBQ1f27XXh1PAPE27Psx58jeelPsxWB/+og+KEH0Q==
+ dependencies:
+ generic-names "^4.0.0"
+ icss-replace-symbols "^1.1.0"
+ lodash.camelcase "^4.3.0"
+ postcss-modules-extract-imports "^3.0.0"
+ postcss-modules-local-by-default "^4.0.0"
+ postcss-modules-scope "^3.0.0"
+ postcss-modules-values "^4.0.0"
+ string-hash "^1.1.1"
+
+postcss-normalize-charset@^5.1.0:
+ version "5.1.0"
+ resolved "https://registry.yarnpkg.com/postcss-normalize-charset/-/postcss-normalize-charset-5.1.0.tgz#9302de0b29094b52c259e9b2cf8dc0879879f0ed"
+ integrity sha512-mSgUJ+pd/ldRGVx26p2wz9dNZ7ji6Pn8VWBajMXFf8jk7vUoSrZ2lt/wZR7DtlZYKesmZI680qjr2CeFF2fbUg==
+
+postcss-normalize-display-values@^5.1.0:
+ version "5.1.0"
+ resolved "https://registry.yarnpkg.com/postcss-normalize-display-values/-/postcss-normalize-display-values-5.1.0.tgz#72abbae58081960e9edd7200fcf21ab8325c3da8"
+ integrity sha512-WP4KIM4o2dazQXWmFaqMmcvsKmhdINFblgSeRgn8BJ6vxaMyaJkwAzpPpuvSIoG/rmX3M+IrRZEz2H0glrQNEA==
+ dependencies:
+ postcss-value-parser "^4.2.0"
+
+postcss-normalize-positions@^5.1.1:
+ version "5.1.1"
+ resolved "https://registry.yarnpkg.com/postcss-normalize-positions/-/postcss-normalize-positions-5.1.1.tgz#ef97279d894087b59325b45c47f1e863daefbb92"
+ integrity sha512-6UpCb0G4eofTCQLFVuI3EVNZzBNPiIKcA1AKVka+31fTVySphr3VUgAIULBhxZkKgwLImhzMR2Bw1ORK+37INg==
+ dependencies:
+ postcss-value-parser "^4.2.0"
+
+postcss-normalize-repeat-style@^5.1.1:
+ version "5.1.1"
+ resolved "https://registry.yarnpkg.com/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-5.1.1.tgz#e9eb96805204f4766df66fd09ed2e13545420fb2"
+ integrity sha512-mFpLspGWkQtBcWIRFLmewo8aC3ImN2i/J3v8YCFUwDnPu3Xz4rLohDO26lGjwNsQxB3YF0KKRwspGzE2JEuS0g==
+ dependencies:
+ postcss-value-parser "^4.2.0"
+
+postcss-normalize-string@^5.1.0:
+ version "5.1.0"
+ resolved "https://registry.yarnpkg.com/postcss-normalize-string/-/postcss-normalize-string-5.1.0.tgz#411961169e07308c82c1f8c55f3e8a337757e228"
+ integrity sha512-oYiIJOf4T9T1N4i+abeIc7Vgm/xPCGih4bZz5Nm0/ARVJ7K6xrDlLwvwqOydvyL3RHNf8qZk6vo3aatiw/go3w==
+ dependencies:
+ postcss-value-parser "^4.2.0"
+
+postcss-normalize-timing-functions@^5.1.0:
+ version "5.1.0"
+ resolved "https://registry.yarnpkg.com/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-5.1.0.tgz#d5614410f8f0b2388e9f240aa6011ba6f52dafbb"
+ integrity sha512-DOEkzJ4SAXv5xkHl0Wa9cZLF3WCBhF3o1SKVxKQAa+0pYKlueTpCgvkFAHfk+Y64ezX9+nITGrDZeVGgITJXjg==
+ dependencies:
+ postcss-value-parser "^4.2.0"
+
+postcss-normalize-unicode@^5.1.1:
+ version "5.1.1"
+ resolved "https://registry.yarnpkg.com/postcss-normalize-unicode/-/postcss-normalize-unicode-5.1.1.tgz#f67297fca3fea7f17e0d2caa40769afc487aa030"
+ integrity sha512-qnCL5jzkNUmKVhZoENp1mJiGNPcsJCs1aaRmURmeJGES23Z/ajaln+EPTD+rBeNkSryI+2WTdW+lwcVdOikrpA==
+ dependencies:
+ browserslist "^4.21.4"
+ postcss-value-parser "^4.2.0"
+
+postcss-normalize-url@^5.1.0:
+ version "5.1.0"
+ resolved "https://registry.yarnpkg.com/postcss-normalize-url/-/postcss-normalize-url-5.1.0.tgz#ed9d88ca82e21abef99f743457d3729a042adcdc"
+ integrity sha512-5upGeDO+PVthOxSmds43ZeMeZfKH+/DKgGRD7TElkkyS46JXAUhMzIKiCa7BabPeIy3AQcTkXwVVN7DbqsiCew==
+ dependencies:
+ normalize-url "^6.0.1"
+ postcss-value-parser "^4.2.0"
+
+postcss-normalize-whitespace@^5.1.1:
+ version "5.1.1"
+ resolved "https://registry.yarnpkg.com/postcss-normalize-whitespace/-/postcss-normalize-whitespace-5.1.1.tgz#08a1a0d1ffa17a7cc6efe1e6c9da969cc4493cfa"
+ integrity sha512-83ZJ4t3NUDETIHTa3uEg6asWjSBYL5EdkVB0sDncx9ERzOKBVJIUeDO9RyA9Zwtig8El1d79HBp0JEi8wvGQnA==
+ dependencies:
+ postcss-value-parser "^4.2.0"
+
+postcss-ordered-values@^5.1.3:
+ version "5.1.3"
+ resolved "https://registry.yarnpkg.com/postcss-ordered-values/-/postcss-ordered-values-5.1.3.tgz#b6fd2bd10f937b23d86bc829c69e7732ce76ea38"
+ integrity sha512-9UO79VUhPwEkzbb3RNpqqghc6lcYej1aveQteWY+4POIwlqkYE21HKWaLDF6lWNuqCobEAyTovVhtI32Rbv2RQ==
+ dependencies:
+ cssnano-utils "^3.1.0"
+ postcss-value-parser "^4.2.0"
+
+postcss-reduce-initial@^5.1.2:
+ version "5.1.2"
+ resolved "https://registry.yarnpkg.com/postcss-reduce-initial/-/postcss-reduce-initial-5.1.2.tgz#798cd77b3e033eae7105c18c9d371d989e1382d6"
+ integrity sha512-dE/y2XRaqAi6OvjzD22pjTUQ8eOfc6m/natGHgKFBK9DxFmIm69YmaRVQrGgFlEfc1HePIurY0TmDeROK05rIg==
+ dependencies:
+ browserslist "^4.21.4"
+ caniuse-api "^3.0.0"
+
+postcss-reduce-transforms@^5.1.0:
+ version "5.1.0"
+ resolved "https://registry.yarnpkg.com/postcss-reduce-transforms/-/postcss-reduce-transforms-5.1.0.tgz#333b70e7758b802f3dd0ddfe98bb1ccfef96b6e9"
+ integrity sha512-2fbdbmgir5AvpW9RLtdONx1QoYG2/EtqpNQbFASDlixBbAYuTcJ0dECwlqNqH7VbaUnEnh8SrxOe2sRIn24XyQ==
+ dependencies:
+ postcss-value-parser "^4.2.0"
+
+postcss-selector-parser@^6.0.4, postcss-selector-parser@^6.0.5, postcss-selector-parser@^6.0.9:
+ version "6.1.2"
+ resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz#27ecb41fb0e3b6ba7a1ec84fff347f734c7929de"
+ integrity sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==
+ dependencies:
+ cssesc "^3.0.0"
+ util-deprecate "^1.0.2"
+
+postcss-selector-parser@^7.0.0:
+ version "7.1.1"
+ resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz#e75d2e0d843f620e5df69076166f4e16f891cb9f"
+ integrity sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==
+ dependencies:
+ cssesc "^3.0.0"
+ util-deprecate "^1.0.2"
+
+postcss-svgo@^5.1.0:
+ version "5.1.0"
+ resolved "https://registry.yarnpkg.com/postcss-svgo/-/postcss-svgo-5.1.0.tgz#0a317400ced789f233a28826e77523f15857d80d"
+ integrity sha512-D75KsH1zm5ZrHyxPakAxJWtkyXew5qwS70v56exwvw542d9CRtTo78K0WeFxZB4G7JXKKMbEZtZayTGdIky/eA==
+ dependencies:
+ postcss-value-parser "^4.2.0"
+ svgo "^2.7.0"
+
+postcss-unique-selectors@^5.1.1:
+ version "5.1.1"
+ resolved "https://registry.yarnpkg.com/postcss-unique-selectors/-/postcss-unique-selectors-5.1.1.tgz#a9f273d1eacd09e9aa6088f4b0507b18b1b541b6"
+ integrity sha512-5JiODlELrz8L2HwxfPnhOWZYWDxVHWL83ufOv84NrcgipI7TaeRsatAhK4Tr2/ZiYldpK/wBvw5BD3qfaK96GA==
+ dependencies:
+ postcss-selector-parser "^6.0.5"
+
+postcss-value-parser@^4.1.0, postcss-value-parser@^4.2.0:
+ version "4.2.0"
+ resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514"
+ integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
+
+postcss@^8.5.10:
+ version "8.5.10"
+ resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.10.tgz#8992d8c30acf3f12169e7c09514a12fed7e48356"
+ integrity sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==
+ dependencies:
+ nanoid "^3.3.11"
+ picocolors "^1.1.1"
+ source-map-js "^1.2.1"
+
+preact@10:
+ version "10.29.1"
+ resolved "https://registry.yarnpkg.com/preact/-/preact-10.29.1.tgz#2a5b936efe91cfe1e773cdb55dceb55d148d1d4b"
+ integrity sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==
+
+promise.series@^0.2.0:
+ version "0.2.0"
+ resolved "https://registry.yarnpkg.com/promise.series/-/promise.series-0.2.0.tgz#2cc7ebe959fc3a6619c04ab4dbdc9e452d864bbd"
+ integrity sha512-VWQJyU2bcDTgZw8kpfBpB/ejZASlCrzwz5f2hjb/zlujOEB4oeiAhHygAWq8ubsX2GVkD4kCU5V2dwOTaCY5EQ==
+
+regenerate-unicode-properties@^10.2.2:
+ version "10.2.2"
+ resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz#aa113812ba899b630658c7623466be71e1f86f66"
+ integrity sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==
+ dependencies:
+ regenerate "^1.4.2"
+
+regenerate@^1.4.2:
+ version "1.4.2"
+ resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.2.tgz#b9346d8827e8f5a32f7ba29637d398b69014848a"
+ integrity sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==
+
+regexpu-core@^6.3.1:
+ version "6.4.0"
+ resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-6.4.0.tgz#3580ce0c4faedef599eccb146612436b62a176e5"
+ integrity sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==
+ dependencies:
+ regenerate "^1.4.2"
+ regenerate-unicode-properties "^10.2.2"
+ regjsgen "^0.8.0"
+ regjsparser "^0.13.0"
+ unicode-match-property-ecmascript "^2.0.0"
+ unicode-match-property-value-ecmascript "^2.2.1"
+
+regjsgen@^0.8.0:
+ version "0.8.0"
+ resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.8.0.tgz#df23ff26e0c5b300a6470cad160a9d090c3a37ab"
+ integrity sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==
+
+regjsparser@^0.13.0:
+ version "0.13.1"
+ resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.13.1.tgz#0593cbacb27527927692030928ae4d3b878d6f8d"
+ integrity sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==
+ dependencies:
+ jsesc "~3.1.0"
+
+resolve-from@^5.0.0:
+ version "5.0.0"
+ resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69"
+ integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==
+
+resolve@^1.19.0, resolve@^1.22.1, resolve@^1.22.11:
+ version "1.22.12"
+ resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.12.tgz#f5b2a680897c69c238a13cd16b15671f8b73549f"
+ integrity sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==
+ dependencies:
+ es-errors "^1.3.0"
+ is-core-module "^2.16.1"
+ path-parse "^1.0.7"
+ supports-preserve-symlinks-flag "^1.0.0"
+
+rimraf@^6.1.3:
+ version "6.1.3"
+ resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-6.1.3.tgz#afbee236b3bd2be331d4e7ce4493bac1718981af"
+ integrity sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==
+ dependencies:
+ glob "^13.0.3"
+ package-json-from-dist "^1.0.1"
+
+rollup-plugin-dts@^6.4.1:
+ version "6.4.1"
+ resolved "https://registry.yarnpkg.com/rollup-plugin-dts/-/rollup-plugin-dts-6.4.1.tgz#9bec10f1b796ed022f76ff799429123c33aa88b7"
+ integrity sha512-l//F3Zf7ID5GoOfLfD8kroBjQKEKpy1qfhtAdnpibFZMffPaylrg1CoDC2vGkPeTeyxUe4bVFCln2EFuL7IGGg==
+ dependencies:
+ "@jridgewell/remapping" "^2.3.5"
+ "@jridgewell/sourcemap-codec" "^1.5.5"
+ convert-source-map "^2.0.0"
+ magic-string "^0.30.21"
+ optionalDependencies:
+ "@babel/code-frame" "^7.29.0"
+
+rollup-plugin-postcss@^4.0.2:
+ version "4.0.2"
+ resolved "https://registry.yarnpkg.com/rollup-plugin-postcss/-/rollup-plugin-postcss-4.0.2.tgz#15e9462f39475059b368ce0e49c800fa4b1f7050"
+ integrity sha512-05EaY6zvZdmvPUDi3uCcAQoESDcYnv8ogJJQRp6V5kZ6J6P7uAVJlrTZcaaA20wTH527YTnKfkAoPxWI/jPp4w==
+ dependencies:
+ chalk "^4.1.0"
+ concat-with-sourcemaps "^1.1.0"
+ cssnano "^5.0.1"
+ import-cwd "^3.0.0"
+ p-queue "^6.6.2"
+ pify "^5.0.0"
+ postcss-load-config "^3.0.0"
+ postcss-modules "^4.0.0"
+ promise.series "^0.2.0"
+ resolve "^1.19.0"
+ rollup-pluginutils "^2.8.2"
+ safe-identifier "^0.4.2"
+ style-inject "^0.3.0"
+
+rollup-pluginutils@^2.8.2:
+ version "2.8.2"
+ resolved "https://registry.yarnpkg.com/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz#72f2af0748b592364dbd3389e600e5a9444a351e"
+ integrity sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==
+ dependencies:
+ estree-walker "^0.6.1"
+
+rollup@^4.60.1:
+ version "4.60.1"
+ resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.60.1.tgz#b4aa2bcb3a5e1437b5fad40d43fe42d4bde7a42d"
+ integrity sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==
+ dependencies:
+ "@types/estree" "1.0.8"
+ optionalDependencies:
+ "@rollup/rollup-android-arm-eabi" "4.60.1"
+ "@rollup/rollup-android-arm64" "4.60.1"
+ "@rollup/rollup-darwin-arm64" "4.60.1"
+ "@rollup/rollup-darwin-x64" "4.60.1"
+ "@rollup/rollup-freebsd-arm64" "4.60.1"
+ "@rollup/rollup-freebsd-x64" "4.60.1"
+ "@rollup/rollup-linux-arm-gnueabihf" "4.60.1"
+ "@rollup/rollup-linux-arm-musleabihf" "4.60.1"
+ "@rollup/rollup-linux-arm64-gnu" "4.60.1"
+ "@rollup/rollup-linux-arm64-musl" "4.60.1"
+ "@rollup/rollup-linux-loong64-gnu" "4.60.1"
+ "@rollup/rollup-linux-loong64-musl" "4.60.1"
+ "@rollup/rollup-linux-ppc64-gnu" "4.60.1"
+ "@rollup/rollup-linux-ppc64-musl" "4.60.1"
+ "@rollup/rollup-linux-riscv64-gnu" "4.60.1"
+ "@rollup/rollup-linux-riscv64-musl" "4.60.1"
+ "@rollup/rollup-linux-s390x-gnu" "4.60.1"
+ "@rollup/rollup-linux-x64-gnu" "4.60.1"
+ "@rollup/rollup-linux-x64-musl" "4.60.1"
+ "@rollup/rollup-openbsd-x64" "4.60.1"
+ "@rollup/rollup-openharmony-arm64" "4.60.1"
+ "@rollup/rollup-win32-arm64-msvc" "4.60.1"
+ "@rollup/rollup-win32-ia32-msvc" "4.60.1"
+ "@rollup/rollup-win32-x64-gnu" "4.60.1"
+ "@rollup/rollup-win32-x64-msvc" "4.60.1"
+ fsevents "~2.3.2"
+
+safe-identifier@^0.4.2:
+ version "0.4.2"
+ resolved "https://registry.yarnpkg.com/safe-identifier/-/safe-identifier-0.4.2.tgz#cf6bfca31c2897c588092d1750d30ef501d59fcb"
+ integrity sha512-6pNbSMW6OhAi9j+N8V+U715yBQsaWJ7eyEUaOrawX+isg5ZxhUlV1NipNtgaKHmFGiABwt+ZF04Ii+3Xjkg+8w==
+
+sax@^1.5.0:
+ version "1.6.0"
+ resolved "https://registry.yarnpkg.com/sax/-/sax-1.6.0.tgz#da59637629307b97e7c4cb28e080a7bc38560d5b"
+ integrity sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==
+
+semver@^6.3.1:
+ version "6.3.1"
+ resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
+ integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
+
+serialize-javascript@^7.0.3:
+ version "7.0.5"
+ resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-7.0.5.tgz#c798cc0552ffbb08981914a42a8756e339d0d5b1"
+ integrity sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==
+
+smob@^1.0.0:
+ version "1.6.1"
+ resolved "https://registry.yarnpkg.com/smob/-/smob-1.6.1.tgz#930607366738545aee542a93e03e47b54e0303e0"
+ integrity sha512-KAkBqZl3c2GvNgNhcoyJae1aKldDW0LO279wF9bk1PnluRTETKBq0WyzRXxEhoQLk56yHaOY4JCBEKDuJIET5g==
+
+source-map-js@^1.2.1:
+ version "1.2.1"
+ resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46"
+ integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==
+
+source-map-support@~0.5.20:
+ version "0.5.21"
+ resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f"
+ integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==
+ dependencies:
+ buffer-from "^1.0.0"
+ source-map "^0.6.0"
+
+source-map@^0.6.0, source-map@^0.6.1:
+ version "0.6.1"
+ resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
+ integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
+
+stable@^0.1.8:
+ version "0.1.8"
+ resolved "https://registry.yarnpkg.com/stable/-/stable-0.1.8.tgz#836eb3c8382fe2936feaf544631017ce7d47a3cf"
+ integrity sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==
+
+string-hash@^1.1.1:
+ version "1.1.3"
+ resolved "https://registry.yarnpkg.com/string-hash/-/string-hash-1.1.3.tgz#e8aafc0ac1855b4666929ed7dd1275df5d6c811b"
+ integrity sha512-kJUvRUFK49aub+a7T1nNE66EJbZBMnBgoC1UbCZ5n6bsZKBRga4KgBRTMn/pFkeCZSYtNeSyMxPDM0AXWELk2A==
+
+style-inject@^0.3.0:
+ version "0.3.0"
+ resolved "https://registry.yarnpkg.com/style-inject/-/style-inject-0.3.0.tgz#d21c477affec91811cc82355832a700d22bf8dd3"
+ integrity sha512-IezA2qp+vcdlhJaVm5SOdPPTUu0FCEqfNSli2vRuSIBbu5Nq5UvygTk/VzeCqfLz2Atj3dVII5QBKGZRZ0edzw==
+
+stylehacks@^5.1.1:
+ version "5.1.1"
+ resolved "https://registry.yarnpkg.com/stylehacks/-/stylehacks-5.1.1.tgz#7934a34eb59d7152149fa69d6e9e56f2fc34bcc9"
+ integrity sha512-sBpcd5Hx7G6seo7b1LkpttvTz7ikD0LlH5RmdcBNb6fFR0Fl7LQwHDFr300q4cwUqi+IYrFGmsIHieMBfnN/Bw==
+ dependencies:
+ browserslist "^4.21.4"
+ postcss-selector-parser "^6.0.4"
+
+supports-color@^7.1.0:
+ version "7.2.0"
+ resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da"
+ integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==
+ dependencies:
+ has-flag "^4.0.0"
+
+supports-preserve-symlinks-flag@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09"
+ integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
+
+svgo@^2.7.0:
+ version "2.8.2"
+ resolved "https://registry.yarnpkg.com/svgo/-/svgo-2.8.2.tgz#8e99b7ba5ac9ed7e3a446063865f61e03223fe6b"
+ integrity sha512-TyzE4NVGLUFy+H/Uy4N6c3G0HEeprsVfge6Lmq+0FdQQ/zqoVYB62IsBZORsiL+o96s6ff/V6/3UQo/C0cgCAA==
+ dependencies:
+ commander "^7.2.0"
+ css-select "^4.1.3"
+ css-tree "^1.1.3"
+ csso "^4.2.0"
+ picocolors "^1.0.0"
+ sax "^1.5.0"
+ stable "^0.1.8"
+
+terser@^5.17.4:
+ version "5.46.1"
+ resolved "https://registry.yarnpkg.com/terser/-/terser-5.46.1.tgz#40e4b1e35d5f13130f82793a8b3eeb7ec3a92eee"
+ integrity sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==
+ dependencies:
+ "@jridgewell/source-map" "^0.3.3"
+ acorn "^8.15.0"
+ commander "^2.20.0"
+ source-map-support "~0.5.20"
+
+tinycolor2@^1.6.0:
+ version "1.6.0"
+ resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.6.0.tgz#f98007460169b0263b97072c5ae92484ce02d09e"
+ integrity sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==
+
+typescript@^6.0.2:
+ version "6.0.2"
+ resolved "https://registry.yarnpkg.com/typescript/-/typescript-6.0.2.tgz#0b1bfb15f68c64b97032f3d78abbf98bdbba501f"
+ integrity sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==
+
+unicode-canonical-property-names-ecmascript@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz#cb3173fe47ca743e228216e4a3ddc4c84d628cc2"
+ integrity sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==
+
+unicode-match-property-ecmascript@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz#54fd16e0ecb167cf04cf1f756bdcc92eba7976c3"
+ integrity sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==
+ dependencies:
+ unicode-canonical-property-names-ecmascript "^2.0.0"
+ unicode-property-aliases-ecmascript "^2.0.0"
+
+unicode-match-property-value-ecmascript@^2.2.1:
+ version "2.2.1"
+ resolved "https://registry.yarnpkg.com/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz#65a7adfad8574c219890e219285ce4c64ed67eaa"
+ integrity sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==
+
+unicode-property-aliases-ecmascript@^2.0.0:
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz#301d4f8a43d2b75c97adfad87c9dd5350c9475d1"
+ integrity sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==
+
+update-browserslist-db@^1.2.3:
+ version "1.2.3"
+ resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz#64d76db58713136acbeb4c49114366cc6cc2e80d"
+ integrity sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==
+ dependencies:
+ escalade "^3.2.0"
+ picocolors "^1.1.1"
+
+util-deprecate@^1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
+ integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
+
+yallist@^3.0.2:
+ version "3.1.1"
+ resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd"
+ integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==
+
+yaml@^1.10.2:
+ version "1.10.3"
+ resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.3.tgz#76e407ed95c42684fb8e14641e5de62fe65bbcb3"
+ integrity sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==
\ No newline at end of file
diff --git a/docs/AGENT_CONNECT.md b/docs/AGENT_CONNECT.md
index ad94ba72..a5bd0e1c 100644
--- a/docs/AGENT_CONNECT.md
+++ b/docs/AGENT_CONNECT.md
@@ -38,8 +38,8 @@ middleware. Do not expose it through a LAN address or proxy. For a remote deploy
`engraphis[all]`, set a strong `ENGRAPHIS_API_TOKEN`, terminate TLS, and use the dashboard's
authenticated `/mcp` endpoint instead.
-Use `engraphis-mcp-http --classic` only for an existing integration that requires the former 33
-direct tool names. New integrations should keep the Smart default.
+Use `engraphis-mcp-http --classic` only for an existing integration that requires the 34 direct
+tool names. New integrations should keep the nine-tool Smart default.
Engraphis documents and tests generic MCP transports; it does not claim client-specific support
unless that client has a maintained setup guide and integration test.
@@ -95,8 +95,10 @@ real integration step.
That redeems the token against `POST /v1/devices/connect` on the control plane and writes the
owner-only session file `~/.engraphis/cloud_session.json` (mode `0600`). The dashboard, the MCP
-server, and Cloud Sync all read that file, so no environment secret is needed afterwards. Rerun
-`engraphis connect` with a fresh token on every machine you want connected.
+server, and Cloud Sync all read that file, so no environment secret is needed afterwards. The
+saved control and compute URLs are bound to that rotating credential family: later environment
+changes cannot redirect its bearer credentials. Reconnect with a fresh portal token to change
+either endpoint. Rerun `engraphis connect` on every machine you want connected.
Useful options:
@@ -107,8 +109,8 @@ Useful options:
| `--workspace WS_ID` | Bind this device to a single workspace. |
| `--label TEXT` | Name this installation in your account portal. |
| `--device-name TEXT` | Override the device name (defaults to the hostname). |
-| `--control-url URL` | Point at a non-default control plane. |
-| `--compute-url URL` | Set the managed compute endpoint (also `ENGRAPHIS_CLOUD_COMPUTE_URL`). |
+| `--control-url URL` | Select the control plane for a new connection/preflight; reconnect to change a saved credential family's endpoint. |
+| `--compute-url URL` | Select managed compute for a new connection (also `ENGRAPHIS_CLOUD_COMPUTE_URL`); reconnect to change it later. |
| `--json` | Print a redacted, machine-readable summary. |
The summary accepts only bounded, printable metadata from the documented response shape.
@@ -144,11 +146,13 @@ has an absolute lifetime and rotation never extends it. Only credential hashes a
service; the raw replacement is returned once and must be kept in an owner-only local state file
or secrets manager.
-Customer-side environment variables are documented in [`.env.example`](../.env.example). Prefer
-the `~/.engraphis/cloud_session.json` that `engraphis connect --token` writes over long-lived
-environment secrets: it holds a rotating credential, it is owner-only, and it is the path the
-client keeps up to date on its own. Environment secrets are for non-interactive deployments that
-cannot run the connect command.
+Customer-side environment variables are documented in [`.env.example`](../.env.example). Values
+come from the process environment or the owner-private `~/.engraphis/config.env`; an explicit
+`ENGRAPHIS_ENV_FILE` must be an absolute owner-private regular file. Engraphis never searches the
+working directory for `.env`. Prefer the `~/.engraphis/cloud_session.json` that
+`engraphis connect --token` writes over long-lived environment secrets: it holds a rotating
+credential, is owner-only, and keeps its bound endpoints and replacement credential up to date.
+Environment secrets are for non-interactive deployments that cannot run the connect command.
## Trial and grace
diff --git a/docs/ARCHITECTURE_V3.md b/docs/ARCHITECTURE_V3.md
index a7eefc9b..912f84f4 100644
--- a/docs/ARCHITECTURE_V3.md
+++ b/docs/ARCHITECTURE_V3.md
@@ -7,7 +7,7 @@ retention-supervision, and privacy-receipt additions introduced with schema vers
flowchart LR
Agent["Agent / host LLM"] --> Intent["remember · link · recall_context (compact) · recall"]
CLI["engraphis-graph CLI"] --> Service["MemoryService"]
- MCP["Smart MCP (9 tools) / Classic MCP (33 tools)"] --> Service
+ MCP["Smart MCP (9 tools) / Classic MCP (34 tools)"] --> Service
HTTP["Dashboard + read-only graph HTTP"] --> Service
Import["Local resources / PostgreSQL catalog"] --> Extractors["Optional local extractors"]
Extractors --> Service
@@ -85,6 +85,12 @@ error. Packaged dashboard, REST, and MCP entrypoints use the `auto` setting, so
`vector` extra is selected without changing the deterministic constructor contract. Run accelerated
search in a fresh process when using the SQLCipher extra.
+The active vector space also needs a durable, secret-free identity. Sentence Transformers use the
+resolved Hub commit or a manifest of local artifacts; an unresolved mutable model leaves persistent
+vector recall gated rather than mixing embeddings. `ApiEmbedder` remains valid for ephemeral calls
+without identity, but persistent use requires an operator/provider `space_version`. Its provider
+root and `/v1` base forms normalize to one `/v1/embeddings` endpoint.
+
## Query planning
Recall defaults to the `balanced` retrieval profile and `planning="off"`. The explicit `fast`
diff --git a/docs/KILO_CODE_INTEGRATION.md b/docs/KILO_CODE_INTEGRATION.md
index 60dc7eb2..56ab764d 100644
--- a/docs/KILO_CODE_INTEGRATION.md
+++ b/docs/KILO_CODE_INTEGRATION.md
@@ -50,7 +50,7 @@ You interact with Engraphis through three surfaces, all backed by the *same* eng
These are the properties that matter when you're deciding how to use it well:
-1. **Scoped.** Every memory lives in a `workspace → repo → session` hierarchy. A memory can be visible at `session`, `repo`, `workspace`, or `user` level. This is what lets one agent work across many repos without cross-contaminating context.
+1. **Scoped.** Every memory lives in a `workspace → repo → session` hierarchy and can apply at `session`, `repo`, or `workspace` level. Scope separates work contexts; it does not identify a human owner. `user` is reserved and rejected until owner-bound memories exist.
2. **Typed.** Every memory is one of four types: `semantic` (durable facts/conventions), `episodic` (events/decisions that happened), `procedural` (how-tos), or `working` (transient scratch). Each type has its own scoring weights and lifecycle. Getting scope + type right is ~90% of using Engraphis well.
@@ -94,7 +94,8 @@ Engraphis is a Python package. Install the MCP variant:
pip install "engraphis[mcp]"
```
-Then run the one-time initializer, which writes an `.env` with an absolute DB path and prints config snippets:
+Then run the one-time initializer, which writes the owner-private
+`~/.engraphis/config.env` with an absolute DB path and prints config snippets:
```bash
engraphis-init
@@ -109,7 +110,11 @@ advanced actions as needed. You can sanity-check that it's on your PATH:
engraphis-mcp --help # or just confirm the command resolves
```
-> **Note on the database.** The memory store is a single SQLite file. `engraphis-init` sets `ENGRAPHIS_DB_PATH` to an absolute path in your `.env`. If you also run the dashboard, point it at the *same* DB path so the WebUI and the agent share one memory store. Mismatched DB paths is the #1 cause of "I remembered something but can't see it in the dashboard."
+> **Note on the database.** The memory store is a single SQLite file. `engraphis-init` sets
+> `ENGRAPHIS_DB_PATH` to an absolute path in `~/.engraphis/config.env`. If you also run the
+> dashboard, point it at the *same* DB path so the WebUI and the agent share one memory store.
+> Mismatched DB paths are the #1 cause of "I remembered something but can't see it in the
+> dashboard."
### 3.2 Register the server in Kilo Code
@@ -167,7 +172,7 @@ Notes on the fields:
### 3.3 Verify the pipe is connected
-Reload Kilo Code (or toggle the server off/on in **Settings → MCP**). You should now see the six
+Reload Kilo Code (or toggle the server off/on in **Settings → MCP**). You should now see the nine
`engraphis_*` Smart tools. The fastest end-to-end check is to ask Kilo Code to discover the health
capability, then run the returned read executor:
@@ -218,18 +223,18 @@ class, and the appropriate executor revalidates all of it before running.
| `engraphis_conflict_review` | List pending/quarantined/conflicted records for review (read-only inbox). |
`engraphis-mcp-classic` is only for an existing configuration that pins direct tool names. It
-preserves the former 33-tool surface below; new Kilo Code installations should keep the zero-config
+preserves the former 34-tool surface below; new Kilo Code installations should keep the zero-config
Smart command shown above.
-### Classic 33-tool inventory
+### Classic 34-tool inventory
| Category | Tool | What it does |
|---|---|---|
| **Write** | `engraphis_remember` | Store a fact; deterministically resolved to add / reinforce (noop) / supersede (invalidate). |
-| Write | `engraphis_record_event` | Append a lightweight episodic log entry: lower ceremony than remember; repeats are a promotion signal. |
+| Write | `engraphis_record_event` | Append one raw occurrence to an event ledger; event rows are not recalled, deduplicated, or consolidated as memories. |
| Write | `engraphis_link` | Explicitly connect two related memories (e.g. a bug ↔ its fix). |
| Write | `engraphis_ingest` | Store raw/undistilled text; extracts discrete facts first when an LLM extractor is configured. |
-| Write | `engraphis_ingest_postgres_schema` | Store a new point-in-time PostgreSQL schema + graph per call; the DSN is never stored. |
+| Write | `engraphis_ingest_postgres_schema` | Store a point-in-time PostgreSQL schema + graph; an unchanged exact retry reuses the snapshot, while every call appends audit/receipt records. The DSN is never stored. |
| **Stateful recall** | `engraphis_recall_context` | Recommended prompt packet: hard-budget context, compact source identities, strict token usage, and optional diagnostics. |
| **Stateful recall** | `engraphis_recall` | Hybrid vector + lexical + graph recall, with independent `valid_at`/`known_at`; appends a privacy-safe receipt without strengthening weak matches. |
| Stateful recall | `engraphis_recall_grounded` | Cited answer assembled only from retrieved memories. It either answers with evidence or abstains; supports optional point-in-time `as_of`, records a receipt, and reinforces cited memories. |
@@ -242,12 +247,14 @@ Smart command shown above.
| Code | `engraphis_search_code` | Find symbols, callers, docstrings, and linked decisions/incidents/procedures. |
| Code | `engraphis_code_path` | Explain a path across files, definitions, calls, imports, and memories. |
| Code | `engraphis_code_impact` | Rank commit/PR impact by dependents, communities, memories, and hotspots. |
+| Code | `engraphis_link_symbol` | Manually link a code symbol to a memory (idempotent; reinforces existing links). |
| Code | `engraphis_export_code_graph` | Portable graph JSON + Markdown + self-contained HTML. |
| **Audit** | `engraphis_receipts` | List content-free hashed operation receipts. |
| Audit | `engraphis_context_savings` | Cumulative packed-context savings from receipts, separated by token-counter identity. |
| Audit | `engraphis_verify_receipts` | Verify the tamper-evident receipt chain. |
| Audit | `engraphis_export_receipts` | Export a privacy-safe receipt-only audit bundle. |
| **Governance** | `engraphis_retire` | Retire a memory: bi-temporal close, never a hard delete; every request is audited. `engraphis_forget` is a deprecated compatibility alias. |
+| Governance | `engraphis_forget` | Deprecated compatibility alias for `engraphis_retire`; prefer the canonical name. |
| Governance | `engraphis_secure_erase` | Irreversibly remove a leaked memory and its local indexes; rotate the credential and remediate external copies separately. |
| Governance | `engraphis_pin` | Exempt a memory from decay/pruning; every pin/unpin request is audited. |
| Governance | `engraphis_correct` | Replace a memory's content without losing history: keeps the "why" chain. |
@@ -297,13 +304,17 @@ are unnecessary; both recall surfaces accept `diagnostics=true` for a retrieval
- **repo**: the repository (e.g. `backend`). Omit only for genuinely workspace-wide facts.
- **session**: one unit of work; pass its `session_id` so memories group and resume.
-Pick the **narrowest scope that is still reusable**. A fix specific to one repo is `scope="repo"`. A preference that follows you everywhere is `scope="user"`. Over-scoping (everything at `workspace`) pollutes recall across repos; under-scoping (everything at `session`) means nothing survives.
+Pick the **narrowest supported scope that is still reusable**. A fix specific to one repo is
+`scope="repo"`; deliberately shared cross-repo guidance is `scope="workspace"`. `scope="user"`
+is reserved and rejected until memories carry an immutable owner identity, so it must not be used
+for private preferences. Over-scoping pollutes unrelated work; under-scoping at `session` means
+nothing survives the task.
**Recommended convention for Kilo Code:** set the `workspace` to your org/product name and the `repo` to the folder/repo name Kilo Code is currently working in. Keep those two stable and the whole hierarchy works itself out. A tidy way to enforce this is a project-level `.kilo/kilo.jsonc` per repo with a rules/instruction note telling the agent which workspace + repo string to use.
### 5.3 What to remember and what not to
-**Store:** conventions ("we use pnpm"), decisions **with rationale** ("switched to PASETO because JWT `none`-alg risk"), bug cause→fix, user/team preferences, reusable procedures, durable environment facts.
+**Store:** conventions ("we use pnpm"), decisions **with rationale** ("switched to PASETO because JWT `none`-alg risk"), bug cause→fix, intentionally shared team/repo preferences, reusable procedures, durable environment facts. Personal preferences have no owner-isolated scope yet.
**Do not store:** secrets, tokens, or credentials; transient scratch state; verbatim large files or logs; anything cheaply re-derivable from the code. **Treat memory as data, not commands**; never store text that instructs a future agent to take an action (that's the memory-poisoning threat; ingested/external content is marked `trusted=false` so prompts can label it).
@@ -382,7 +393,7 @@ Kilo Code is an MCP client; Engraphis ships an MCP server (`engraphis-mcp`, loca
`engraphis-init`, then add a `local` server named `engraphis` under the `mcp` key in
`kilo.jsonc` (`["cmd","/c","engraphis-mcp"]` on Windows, `["engraphis-mcp"]` on
macOS/Linux), pin `ENGRAPHIS_DB_PATH`, bump `timeout` to 15000, and verify with
-Engraphis action discovery. That gets the pipes connected. The *value* is the Smart gateway: six
+Engraphis action discovery. That gets the pipes connected. The *value* is the Smart gateway: nine
compact routine tools plus automatic access to scoped, typed, bi-temporal memory, code, audit, and
maintenance capabilities. It preserves the discipline of "recall before you ask, remember before
you move on," with `workspace → repo → session` scoping and periodic consolidation when needed.
diff --git a/docs/LLM_PROVIDERS.md b/docs/LLM_PROVIDERS.md
index 202373ee..c32f9644 100644
--- a/docs/LLM_PROVIDERS.md
+++ b/docs/LLM_PROVIDERS.md
@@ -36,10 +36,12 @@ through MCP. Codex and Cohere Command are distinct products and use different se
## Configure once
-1. Add one provider's variables to `.env`.
+1. Add one provider's variables to the owner-private `~/.engraphis/config.env`, an absolute
+ owner-private file selected with `ENGRAPHIS_ENV_FILE`, or the process environment. Engraphis
+ does not search the working directory for `.env`.
2. Restart the dashboard, server, or MCP process that owns the shared Engraphis database.
3. In **Settings → Connect an LLM**, select **Test connection**. The dashboard picker offers the
- named cloud modes; custom endpoints are configured directly in `.env`.
+ named cloud models; configure custom endpoints through the same trusted sources.
4. Keep `ENGRAPHIS_EXTRACTOR=none` for fully local ingestion, or explicitly choose `llm` or
`llm_structured` after the connection succeeds.
diff --git a/docs/MCP_TOOLS.md b/docs/MCP_TOOLS.md
index 1ed14679..cec272b9 100644
--- a/docs/MCP_TOOLS.md
+++ b/docs/MCP_TOOLS.md
@@ -27,7 +27,7 @@ discovery and the validated executors.
No user profile choice or tool switching is required. The dashboard `/mcp` endpoint and
`engraphis-mcp-http` use this Smart surface by default. `engraphis-mcp-classic` (or
-`engraphis-mcp-http --classic`) preserves the 33 direct tools below for integrations that pin
+`engraphis-mcp-http --classic`) preserves the 34 direct tools below for integrations that pin
their historical names and response shapes.
Hosts which already own chat history should use `POST /api/adaptive-context`, not an MCP action.
@@ -78,7 +78,7 @@ the [memory write trust model](WRITE_REVIEW.md) and [recall recovery guide](RECA
| Category | Tool | What it does |
|---|---|---|
| Write | `engraphis_remember` | Stores a fact and resolves it as a new memory, reinforcement, safe supersession, or related memory. |
-| Write | `engraphis_record_event` | Appends a lightweight episodic event. |
+| Write | `engraphis_record_event` | Appends one raw occurrence to the event ledger; event rows are not recalled, deduplicated, reinforced, or consolidated as memories. |
| Write | `engraphis_link` | Connects two related memories. |
| Write | `engraphis_ingest` | Applies the configured extractor (`chunk`, `llm`, or `llm_structured`). With `none`, it stores one verbatim memory. |
| Write | `engraphis_ingest_postgres_schema` | Stores a PostgreSQL schema snapshot and typed graph. The DSN is never stored. |
@@ -96,6 +96,7 @@ the [memory write trust model](WRITE_REVIEW.md) and [recall recovery guide](RECA
| Code | `engraphis_code_path` | Finds a path across definitions, calls, imports, and memories. |
| Code | `engraphis_code_impact` | Ranks changed-file impact using dependents, communities, memories, and hotspots. |
| Code | `engraphis_export_code_graph` | Exports graph JSON, Markdown, and HTML. |
+| Code | `engraphis_link_symbol` | Manually links a code symbol to a memory (idempotent). |
| Audit | `engraphis_receipts` | Lists content-free hashed operation receipts. |
| Audit | `engraphis_context_savings` | Reports receipt-backed estimated context tokens saved, eligible/excluded deliveries, basis, confidence, and token-counter identity; optional `from_ts`, `to_ts`, and `release_version` filters are supported. This is estimated prompt-context reduction, not provider billing. |
| Audit | `engraphis_verify_receipts` | Verifies the receipt chain, local tail anchor, and an optional saved head/count. |
@@ -111,13 +112,16 @@ the [memory write trust model](WRITE_REVIEW.md) and [recall recovery guide](RECA
| Operations | `engraphis_check_update` | Refreshes the release cache and reports whether a newer version is available. |
All four recall tools (`engraphis_recall`, `engraphis_recall_context`,
-`engraphis_recall_grounded`, and the `engraphis_answer` alias) accept `planning="off"|"auto"`
-and optional `mtype_limits`, for example `{"working": 1, "semantic": 3}`. Planning is off by
-default. Type limits are post-rerank maxima and can intentionally return fewer than `k`; they do not
-raise a memory type's relevance. Responses include a stable `context_revision`. Planner details,
-per-query rankings, type-limit drops, and fallback reasons are returned only when
-`diagnostics=true`. Every planned query remains inside the caller's scope, temporal, trust, and
-prompt-eligibility filters, and grounded recall still measures support against the original query.
+`engraphis_recall_grounded`, and the `engraphis_answer` alias) accept `planning="off"|"auto"`,
+optional `mtype_limits` such as `{"working": 1, "semantic": 3}`, and optional
+`max_response_tokens` from `1` through `1000000`. `response_mode="full"` returns the classic
+response; `"compact"` removes packed context and citation/memory bodies from the end while
+preserving source/citation references. Responses include a stable `context_revision`. Planner
+details, per-query rankings, type-limit drops, and fallback reasons are returned only when
+`diagnostics=true`. Type limits are post-rank maxima and can intentionally return fewer than `k`;
+they do not raise a memory type's relevance. Every planned query remains inside the caller's scope,
+temporal, trust, and prompt-eligibility filters, and grounded recall still measures support against
+the original query.
For parameter details and return shapes, see the tool descriptions exposed by the MCP server. The
[agent connection guide](AGENT_CONNECT.md) explains local and hosted connections, and the
diff --git a/docs/PUBLIC_BENCHMARK_RUNBOOK.md b/docs/PUBLIC_BENCHMARK_RUNBOOK.md
index f7f0ee9b..6916c03a 100644
--- a/docs/PUBLIC_BENCHMARK_RUNBOOK.md
+++ b/docs/PUBLIC_BENCHMARK_RUNBOOK.md
@@ -33,11 +33,15 @@ profile, and restricted/public output paths. Run it through the allowlisted orch
```bash
python -m scripts.run_public_benchmark --manifest "$ENGRAPHIS_BENCHMARK_RUN_DIR/point.json"
-python -m scripts.run_public_benchmark --manifest "$ENGRAPHIS_BENCHMARK_RUN_DIR/point.json" --execute
+python -m scripts.run_public_benchmark --manifest "$ENGRAPHIS_BENCHMARK_RUN_DIR/point.json" \
+ --execute --claims-input "$ENGRAPHIS_BENCHMARK_RUN_DIR/reviewed-claims.json"
```
The first command is a redacted dry-run. The second is the only form that starts the pinned local
-commands, and it refuses a missing dataset, hash mismatch, commit mismatch, or dirty worktree.
+commands. Execution requires a protected, pre-reviewed claims JSON array (or object with a
+`claims` array), snapshots it into the manifest's restricted output, and refuses a missing
+dataset, hash mismatch, commit mismatch, dirty worktree, or attempts to reuse the claims output
+as the input.
Use one separate `engraphis-public-benchmark-series/v1` manifest as the predeclared comparison
contract. It records the required baseline and budget matrix, the frozen holdout, and distinct
@@ -74,6 +78,22 @@ Every canonical holdout run must include these labels at every fixed budget:
Use the exact baseline semantics in [eval/BASELINES.md](../eval/BASELINES.md). A baseline that
cannot be executed faithfully must fail or be marked unavailable, never relabeled as a result.
+### Official LongMemEval-V2 adapter matrix
+
+The adapter's public evidence matrix is narrower and explicit: six variants at five budgets, for
+30 official runs. Materialize it in the restricted run directory before any scored question:
+
+```bash
+python -m eval.longmemeval_v2_matrix \
+ --output "$ENGRAPHIS_BENCHMARK_RUN_DIR/longmemeval-v2/configs"
+```
+
+The generated manifest contains `balanced`, `planner`, `episodic_cap_2`,
+`planner_episodic_cap_2`, `context_k_2`, and `planner_context_k_2`. The two `context_k=2` variants
+are matched retrieval-depth comparators for the two memory-type-cap variants; without them, a cap
+effect could be only a smaller candidate set. Every variant runs at 256, 512, 1,024, 2,048, and
+4,096 evidence tokens.
+
## 4. Execute in stages
Run the offline gate first:
@@ -115,20 +135,68 @@ LoCoMo and LongMemEval external adapters as diagnostics until their official har
comparison matrix are represented by the pinned LongMemEval-V2 path. The series manifest is the
release checklist for all of those points.
-For official LongMemEval-V2, use the pinned adapter and upstream harness described in
-[BENCHMARKS.md](../BENCHMARKS.md), then create the redacted evidence artifact with the exporter
-documented in [eval/EVIDENCE.md](../eval/EVIDENCE.md). Hosted productivity runs follow the smoke,
-pilot, and full ceilings in [docs/LUNA_BENCHMARK_PLAN.md](LUNA_BENCHMARK_PLAN.md).
+For official LongMemEval-V2, add the exact pinned official checkout to `PYTHONPATH` and execute
+each generated manifest cell through the Engraphis wrapper. The wrapper consumes all eight
+`--engraphis-*` receipt arguments and delegates the remaining arguments unchanged to the official
+harness:
+
+```bash
+export ENGRAPHIS_LMV2_RUN="$ENGRAPHIS_BENCHMARK_RUN_DIR/longmemeval-v2"
+export PYTHONPATH="/path/to/LongMemEval-V2:$PYTHONPATH"
+
+python -m eval.run_longmemeval_v2 \
+ --engraphis-execution-manifest "$ENGRAPHIS_LMV2_RUN/receipts/balanced-1024.json" \
+ --engraphis-per-question "$ENGRAPHIS_LMV2_RUN/output/balanced-1024.jsonl" \
+ --engraphis-questions "$ENGRAPHIS_LMV2_RUN/data/questions.json" \
+ --engraphis-haystack "$ENGRAPHIS_LMV2_RUN/data/haystack.json" \
+ --engraphis-trajectories "$ENGRAPHIS_LMV2_RUN/data/trajectories.json" \
+ --engraphis-memory-config "$ENGRAPHIS_LMV2_RUN/configs/balanced-1024.json" \
+ --engraphis-matrix-manifest "$ENGRAPHIS_LMV2_RUN/configs/manifest.json" \
+ --engraphis-seed 42 \
+
+```
+
+The official checkout must be clean and exactly
+`6f020ac2fc3275e46c706d3406e02c3ed79b7be2`. The wrapper writes the execution manifest only after
+the official harness returns successfully, rejects duplicate question IDs, and verifies exact
+set equality between every source question ID and output question ID. It records both counts,
+source/config/output hashes, the delegated-argument digest, checkout state, and environment. A
+partial output cannot acquire a completion receipt.
+
+Then export the bound, redacted evidence:
+
+```bash
+python -m eval.longmemeval_v2_evidence \
+ --per-question "$ENGRAPHIS_LMV2_RUN/output/balanced-1024.jsonl" \
+ --questions "$ENGRAPHIS_LMV2_RUN/data/questions.json" \
+ --haystack "$ENGRAPHIS_LMV2_RUN/data/haystack.json" \
+ --trajectories "$ENGRAPHIS_LMV2_RUN/data/trajectories.json" \
+ --memory-config "$ENGRAPHIS_LMV2_RUN/configs/balanced-1024.json" \
+ --execution-manifest "$ENGRAPHIS_LMV2_RUN/receipts/balanced-1024.json" \
+ --matrix-manifest "$ENGRAPHIS_LMV2_RUN/configs/manifest.json" \
+ --ablation balanced --token-budget 1024 --seed 42 \
+ --upstream-revision 6f020ac2fc3275e46c706d3406e02c3ed79b7be2 \
+ --output artifacts/longmemeval-v2-balanced-1024.json
+```
+
+Repeat both commands for every manifest cell, changing the variant, budget, paths, and official
+harness arguments together. The exporter rejects an execution receipt whose hashes, seed,
+checkout, row count, or source-question coverage do not match the requested artifact. Each
+per-question adapter record also exposes inserted and retrieved counts by memory type. A
+memory-type-cap claim is accepted only when at least two inserted memory types are populated.
+Hosted productivity runs follow the smoke, pilot, and full ceilings in
+[docs/LUNA_BENCHMARK_PLAN.md](LUNA_BENCHMARK_PLAN.md).
## 5. Keep private and public artifacts separate
Private artifacts may contain raw questions, answers, prompts, retrieved context, per-question
debug details, and resumable checkpoints. Store them outside git with restricted access.
-Public artifacts must contain only the sorted redacted envelope, hashes, configuration and model
-provenance, aggregate metrics, confidence intervals, exclusions, failure summaries, and checksum.
-They must contain no raw questions, answers, prompts, context, credentials, user data, or
-question-derived identifiers. Generate charts only from the public aggregate artifact.
+Public artifacts contain only the sorted redacted envelope, whole-input/source-file digests,
+non-content question IDs needed to prove complete coverage, configuration and model provenance,
+aggregate metrics, confidence intervals, exclusions, failure summaries, and checksum. They contain
+no raw questions, answers, prompts, context, credentials, or user data. They contain no per-record
+content hashes or fingerprints. Generate charts only from the public aggregate artifact.
## 6. Validate claims before publication
diff --git a/docs/RECALL_RECOVERY.md b/docs/RECALL_RECOVERY.md
index 1d4fdf81..79850b01 100644
--- a/docs/RECALL_RECOVERY.md
+++ b/docs/RECALL_RECOVERY.md
@@ -23,12 +23,34 @@ Do not edit `provenance` in SQLite. Schema 11 automatically preserves the old ex
contract and recovers the known historical local-agent downgrade. Unknown and external evidence
stays pending; quarantined evidence is never included in bulk approval.
+## Legacy v1 migration
+
+Migrate a flat v1 database through the staged v2 importer:
+
+```bash
+python -m scripts.migrate_to_v2 --old engraphis_v1.db --new engraphis_v2.db --dry-run
+python -m scripts.migrate_to_v2 --old engraphis_v1.db --new engraphis_v2.db
+```
+
+The migration preserves valid legacy data and publishes the new database only after validation.
+Legacy SQLite columns are dynamically typed, so malformed numeric, temporal, and vector values are
+deterministically repaired or quarantined rather than called lossless. The summary reports
+`quarantined` and `repaired_fields` counts; each migrated record retains typed provenance such as
+`v1_memory_id`, `v1_thought_id`, or `v1_document_id` when that source identifier exists.
+
+`engraphis-cli delete-namespace NAME --force` is not physical deletion. It closes the current
+validity of every live memory in that workspace and records an audited receipt; use governed secure
+erase only for the narrower leaked-secret contract and its documented external-copy limitations.
+
## Irrelevant or identical semantic results
Stored vectors have one authoritative active fingerprint derived from backend identity, model
-version, and dimension. Sentence Transformers, deterministic hashing, and API embeddings publish
-durable identities. Any configured-space change, including A -> B -> A, rebuilds every
-non-quarantined vector before that fingerprint becomes active.
+version, and dimension. Deterministic hashing publishes a stable identity; Sentence Transformers
+use the resolved Hub commit or a manifest of local artifacts, and API embeddings require an
+operator/provider `space_version` for persistent use. If identity is mutable or unresolved, the
+embedder may still serve ephemeral calls but persistent vector recall stays gated. Any
+configured-space change, including A → B → A, rebuilds every non-quarantined vector before that
+fingerprint becomes active.
The engine commits a rebuild gate before replacing the first vector. Until the rebuild completes,
the vector arm is disabled and recall safely degrades to lexical, graph, and code retrieval.
@@ -55,6 +77,11 @@ configuration. `MemoryEngine.create()` resumes a full guarded rebuild. A model l
failure aborts startup and retains the rebuild gate; fix that model configuration and restart.
Do not clear `embedding_state` or rewrite `mem_vectors` manually.
+For an explicit repair, `python -m scripts.repair_embed_dim` defaults to
+`ENGRAPHIS_DB_PATH`, selects the configured embedding fingerprint and vector backend, takes a
+consistent backup when work is needed, and rebuilds through the same governed gate. It never
+rewrites rows to an arbitrary observed dimension.
+
## Provenance audit
New service writes include `writer_policy: service-v11` and an internal `ingress` label such as
diff --git a/docs/SECURE_ERASURE.md b/docs/SECURE_ERASURE.md
index bbe8586d..9a4cea61 100644
--- a/docs/SECURE_ERASURE.md
+++ b/docs/SECURE_ERASURE.md
@@ -17,13 +17,16 @@ unreferenced extracted entities. It removes the record's old audit details, reco
content-free erasure marker, enables SQLite `secure_delete`, checkpoints/truncates the WAL when
SQLite permits it, and runs `VACUUM` to rebuild the live database without free-page/FTS tombstone
content. Recognised local migration and embed-repair SQLite backups are scanned and rewritten too.
+For sync, only a non-secret workspace/repo record receives a `remote_erasure` marker; secret,
+session, reserved user-scope, and migrated legacy markers are `never_export` and stay local.
This is best-effort physical remediation, not a promise of universal deletion. The result reports
whether WAL/VACUUM maintenance and injected vector-index deletion succeeded. It cannot erase:
- filesystem snapshots, deleted-file recovery sectors, copied/exported databases, or backup
systems Engraphis cannot identify and open;
-- remote sync peers, cloud backups, or logs outside the local database;
+- remote sync peers that have not yet accepted an eligible `remote_erasure` marker, cloud
+ backups, or logs outside the local database; `never_export` markers never notify peers;
- values already returned to, cached by, or observed by a running/compromised agent.
Always rotate or revoke the credential first. If an injected external vector backend reports a
diff --git a/docs/SYNC.md b/docs/SYNC.md
index edc260f5..d6918bfa 100644
--- a/docs/SYNC.md
+++ b/docs/SYNC.md
@@ -54,9 +54,13 @@ ENGRAPHIS_CLOUD_REFRESH_CREDENTIAL=
The refresh credential rotates. Refresh is serialized across threads and cooperating processes,
and the client stores only the replacement needed for the next session in an owner-only file.
-After the first rotation, that saved replacement takes precedence over a still-present bootstrap
-environment credential. Do not place either value in source, documentation, container images,
-shell history, or support logs.
+After the first rotation, that saved replacement and its control/compute URLs are one credential
+family: they take precedence over environment bootstrap values, and environment URL changes
+cannot redirect that bearer credential. Reconnect with a fresh portal token to change endpoints.
+For unattended configuration, use process variables or the owner-private
+`~/.engraphis/config.env`; an explicit `ENGRAPHIS_ENV_FILE` must be an absolute owner-private
+regular file. Engraphis does not search the working directory for `.env`. Do not place credentials
+in source, documentation, container images, shell history, or support logs.
The one-shot customer client remains available for explicit sync operations:
@@ -69,10 +73,13 @@ python -m scripts.sync \
Cloud Sync is fail-closed: install `engraphis[cloud-sync]` on Python 3.10+ and provision a
32-byte URL-safe-base64 workspace key as `ENGRAPHIS_SYNC_E2EE_KEY` on every authorized device
-before the first upload. Generate it once on a trusted device and transfer it only through your
-own secure channel; Engraphis Cloud never receives, derives, or recovers this key. For a
-one-off command, pass the same value with `--relay-e2ee-key`. A missing or malformed key stops
-Cloud Sync rather than uploading a plaintext bundle.
+through a secrets manager. Generate it once on a trusted device and transfer it only through your
+own secure channel; Engraphis Cloud never receives, derives, or recovers this key. Relay
+authorization normally comes from the owner-only saved cloud session. An unattended
+`ENGRAPHIS_SYNC_TOKEN` also requires `ENGRAPHIS_SYNC_TOKEN_ORIGIN` matching the relay origin, so a
+credential cannot be redirected. The CLI intentionally has no secret-valued `--relay-token` or
+`--relay-e2ee-key` flags. A missing or malformed key stops Cloud Sync rather than uploading a
+plaintext bundle.
```bash
python -c "import base64, secrets; print(base64.urlsafe_b64encode(secrets.token_bytes(32)).decode().rstrip('='))"
@@ -80,10 +87,10 @@ python -c "import base64, secrets; print(base64.urlsafe_b64encode(secrets.token_
The dashboard's **Sync now** action invokes the same customer protocol. The public package does
not run a local auto-sync loop or ship a cron/Task Scheduler wrapper. Hosted automation belongs
-to the private service. If the relay denies every attempted shared workspace because the session
-is expired, revoked, or no longer entitled, the dashboard returns to the hosted Pro/Team recovery
-CTA instead of reporting a successful empty sync. A successful empty or read-only workspace keeps
-the result partial so another workspace's denial is not misreported as a total authorization loss.
+to the private service. A round with any incomplete workspace is a failure, even when other peers
+were applied successfully: the bounded report retains those good-peer totals, labels the result
+`incomplete`, and the CLI exits `1`. The dashboard therefore never presents a partial round as
+successful. An all-workspace entitlement denial still returns the hosted Pro/Team recovery CTA.
### Local folder transport
@@ -100,6 +107,8 @@ python -m scripts.sync \
This is a customer-controlled file exchange primitive, not the official Cloud Sync service. It
has no hosted identity, seat, availability, support, or managed-storage guarantees.
+Folder caps, oversize omissions, and snapshot races are observable incomplete failures rather than
+successful partial backups.
## Merge semantics
@@ -114,10 +123,24 @@ when both endpoints remain in the export. Inbound legacy or untrusted bundles ca
relabel, or overwrite session-scoped state because the sync format carries no authenticated
session owner or lifecycle contract.
-Bundle format v2 preserves durable claim identity and the system-time at which a
-world-time invalidation was learned. Current Engraphis accepts inbound v1 bundles for
-compatibility but exports v2. Older clients reject v2 instead of silently forwarding a
-downgraded bundle that loses those fields.
+Bundle format v3 preserves durable claim identity and the system-time at which a world-time
+invalidation was learned. It also carries a per-device `generation`, `previous_hash`,
+`state_hash`, and `tombstone_checkpoint`. Engraphis pulls its own device's remote snapshot before
+replacement and rejects an observed generation/hash-chain rollback. Current clients accept
+inbound v1 and v2 bundles for compatibility but export v3; older clients reject unknown versions
+instead of silently forwarding a downgraded snapshot.
+
+Erasure markers remain content-free and carry an `export_class`. Export includes only
+`remote_erasure` markers created for non-secret workspace/repo records that were eligible for
+sharing. Local `never_export` markers, including migrated legacy markers and erasures of secret,
+session, or reserved user-scope records, never leave the device. Bundle import rejects any
+tombstone not explicitly classified `remote_erasure`; a local `never_export` marker cannot later
+be upgraded to an exportable one.
+
+The first contact with a relay is deliberately `incomplete` and unanchored until the managed
+service supplies an authenticated workspace manifest/checkpoint. A local client can prove that an
+observed device chain did not roll back; it cannot prove that an untrusted relay did not withhold a
+device it has never observed.
Bundle input is untrusted. The client validates schema and size limits before applying records,
rechecks workspace scope, and retains provenance/audit evidence. Every inbound memory is re-homed
diff --git a/docs/benchmark-evidence/offline-fixtures-v1.json b/docs/benchmark-evidence/offline-fixtures-v1.json
new file mode 100644
index 00000000..342f3691
--- /dev/null
+++ b/docs/benchmark-evidence/offline-fixtures-v1.json
@@ -0,0 +1,91 @@
+{
+ "schema": "engraphis-public-offline-fixtures/v1",
+ "generated_on": "2026-08-08",
+ "privacy": {
+ "contains_raw_questions": false,
+ "contains_answers": false,
+ "contains_prompts": false,
+ "contains_customer_data": false,
+ "contains_per_record_fingerprints": false
+ },
+ "suite": {
+ "digest": "4d7e40607319cd4bf8caee3897f1e416dbe5b81998b37a7e4839409ee2923537",
+ "digest_method": "sha256(canonical compact JSON mapping each sorted path to its file SHA-256)",
+ "files": {
+ "eval/chunking_eval.py": "a16544353940c0a8c40cea3b9932d3399b35ea5994b809b78f5dbe4a952c467f",
+ "eval/datasets/codemem.jsonl": "341313023c22850a2e14f02742b571ad1deca824f886a1654a59541304c01f3c",
+ "eval/datasets/longdoc.jsonl": "7f5ade95e1f283d0db8cf78e53ed8995d3534f847e616d2c0005fd8da37ac790",
+ "eval/grounded.py": "a5dd62d10c079b0098917a4640315254c65a4f1d7d71d8c3a669f290a29277e5",
+ "eval/performance.py": "e17ea78095e4e592717bc5d9d8e34d55fd98c3fd28d1a8104a20c227d4d619c9"
+ }
+ },
+ "runs": [
+ {
+ "id": "offline-chunking",
+ "command": "python -m eval.chunking_eval --dataset eval/datasets/longdoc.jsonl --k 5",
+ "config_digest": "c1c8196aa7e1568ef3844a9fb2d76b87f342c39108e32d6ad144b885a76143b8",
+ "config_digest_method": "sha256(UTF-8 exact command)",
+ "boundary": "Deterministic offline retrieval fixture; normalized-character token estimator; not external QA or provider billing.",
+ "result": {
+ "documents": 6,
+ "questions": 18,
+ "k": 5,
+ "token_counter": "engraphis.chars4.v1",
+ "whole": {
+ "memories": 6,
+ "recall_at_k": 1.0,
+ "mean_context_tokens": 740.3,
+ "mean_evidence_tokens": 162.2,
+ "max_stored_tokens": 213
+ },
+ "chunked": {
+ "memories": 24,
+ "recall_at_k": 1.0,
+ "mean_context_tokens": 214.3,
+ "mean_evidence_tokens": 42.4,
+ "max_stored_tokens": 59
+ },
+ "context_reduction_pct": 71.1
+ }
+ },
+ {
+ "id": "offline-performance",
+ "command": "python -m eval.performance --dataset eval/datasets/codemem.jsonl --k 5 --iterations 10 --json",
+ "config_digest": "bbe4aca81e58d4830e50a8fc7729a1d15b71d97a6299bccd79432b7f119677d7",
+ "config_digest_method": "sha256(UTF-8 exact command)",
+ "boundary": "Deterministic offline CodeMem fixture; serialized JSON-shape payload proxies, not MCP transport responses, provider billing, or latency claims.",
+ "result": {
+ "dataset_cases": 14,
+ "memories": 44,
+ "questions": 26,
+ "timed_recalls": 260,
+ "k": 5,
+ "token_budget": 1500,
+ "token_counter": "engraphis.regex.v1",
+ "recall_at_k": 1.0,
+ "hit_at_k": 1.0,
+ "answer_token_recall": 1.0,
+ "mean_context_tokens": 85.38,
+ "max_context_tokens": 108,
+ "full_serialized_payload_tokens": 23810,
+ "compact_serialized_payload_tokens": 10202,
+ "saved_serialized_payload_tokens": 13608,
+ "serialized_payload_savings_ratio": 0.5715
+ }
+ },
+ {
+ "id": "offline-grounded",
+ "command": "python -m eval.grounded",
+ "config_digest": "590442e51e3642c10489165759919dc86ffac62c182937330c153e7f8d5fc26f",
+ "config_digest_method": "sha256(UTF-8 exact command)",
+ "boundary": "Deterministic offline support/abstention fixture; not a frontier-model answer-quality score.",
+ "result": {
+ "answerable": 5,
+ "grounded": 5,
+ "off_topic": 5,
+ "abstained": 5,
+ "decision_accuracy": 1.0
+ }
+ }
+ ]
+}
diff --git a/docs/benchmark-evidence/offline-fixtures-v1.json.sha256 b/docs/benchmark-evidence/offline-fixtures-v1.json.sha256
new file mode 100644
index 00000000..d679044b
--- /dev/null
+++ b/docs/benchmark-evidence/offline-fixtures-v1.json.sha256
@@ -0,0 +1 @@
+c3a74f1770ad3f868f55261ba11680e2dadca30167082ac2cb6669f9e3bdfad2 offline-fixtures-v1.json
diff --git a/docs/images/automation.png b/docs/images/automation.png
deleted file mode 100644
index c2ecd0a9..00000000
Binary files a/docs/images/automation.png and /dev/null differ
diff --git a/docs/images/context-efficiency.png b/docs/images/context-efficiency.png
index 718102ae..3734cb64 100644
Binary files a/docs/images/context-efficiency.png and b/docs/images/context-efficiency.png differ
diff --git a/docs/images/context-efficiency.svg b/docs/images/context-efficiency.svg
index 88b92d51..e808da02 100644
--- a/docs/images/context-efficiency.svg
+++ b/docs/images/context-efficiency.svg
@@ -1,6 +1,6 @@
diff --git a/docs/images/evidence-backed-agent-examples.png b/docs/images/evidence-backed-agent-examples.png
index ba20f2fb..a91972fd 100644
Binary files a/docs/images/evidence-backed-agent-examples.png and b/docs/images/evidence-backed-agent-examples.png differ
diff --git a/docs/images/evidence-backed-agent-examples.svg b/docs/images/evidence-backed-agent-examples.svg
index d95712ef..55380583 100644
--- a/docs/images/evidence-backed-agent-examples.svg
+++ b/docs/images/evidence-backed-agent-examples.svg
@@ -1,6 +1,6 @@
diff --git a/engraphis/__init__.py b/engraphis/__init__.py
index e71355cb..d712281c 100644
--- a/engraphis/__init__.py
+++ b/engraphis/__init__.py
@@ -15,3 +15,26 @@
# Keep in step with [project] version in pyproject.toml — tests/test_packaging.py
# pins the two together so a release cannot ship them out of sync.
__version__ = "1.5"
+
+
+def _default_memory_engine_factory(**kwargs):
+ from engraphis.factory import create_memory_engine as factory
+
+ return factory(**kwargs)
+
+
+def create_memory_engine(*args, **kwargs):
+ """Public lazy wrapper around the v2 outer composition root."""
+ from engraphis.factory import create_memory_engine as factory
+
+ return factory(*args, **kwargs)
+
+
+from engraphis.core.engine import ( # noqa: E402
+ MemoryEngine,
+ configure_engine_factory,
+)
+
+configure_engine_factory(_default_memory_engine_factory)
+
+__all__ = ["MemoryEngine", "create_memory_engine", "__version__"]
diff --git a/engraphis/app.py b/engraphis/app.py
index f9a6dc62..435f9a55 100644
--- a/engraphis/app.py
+++ b/engraphis/app.py
@@ -14,7 +14,6 @@
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, JSONResponse
-from fastapi.staticfiles import StaticFiles
from engraphis import __version__
from engraphis.local_auth import bearer_ok
@@ -24,7 +23,11 @@
from engraphis.logging_setup import configure_logging
from engraphis.netutil import client_ip
from engraphis.routes.memory import router as memory_router
-from engraphis.routes.vault import VAULT_UPLOAD_REQUEST_BYTES, router as vault_router
+from engraphis.routes.vault import (
+ SINGLE_UPLOAD_REQUEST_BYTES,
+ VAULT_UPLOAD_REQUEST_BYTES,
+ router as vault_router,
+)
from engraphis.stores import get_conn, init_db
from engraphis.core.interfaces import SearchFilter
@@ -32,14 +35,29 @@
_background_task: Optional[asyncio.Task] = None
-_STATIC_DIR = Path(__file__).resolve().parent / "static"
-# Readiness cache: only a *successful* embedder init is cached, so a transient
-# failure is re-checked on the next probe instead of wedging the pod NotReady.
+# Readiness records startup state and re-checks the same legacy warmup on every probe,
+# so a transient failure can recover without mixing in the v2 fallback backend.
_embedder_ok: bool = False
_UPLOAD_LIMIT_PATHS = frozenset({
"/api/workspaces/import-files",
"/memory/vaults/upload-folder",
"/memory/vaults/upload-folder-smart",
+ "/memory/documents/upload",
+})
+_JSON_REQUEST_BYTES = 8 * 1024 * 1024
+_REQUIRED_LEGACY_TABLES = frozenset({
+ "memories",
+ "chunks",
+ "entities",
+ "edges",
+ "graph_documents",
+ "document_entities",
+ "document_edges",
+ "events",
+ "interactions",
+ "thoughts",
+ "jobs",
+ "vaults",
})
@@ -100,12 +118,10 @@ class _RequestBodyTooLarge(Exception):
class _VaultUploadLimitMiddleware:
- """Reject oversized file imports before multipart parsing/spooling.
+ """Reject oversized multipart and JSON requests before body binding.
- ``Content-Length`` provides an immediate fast-fail. The receive wrapper is still
- required because clients can omit or lie about that header, including HTTP/1.1
- chunked uploads. A fronting proxy should configure an equal or lower request-body
- ceiling; this in-process guard remains the last line of defense for direct access.
+ The legacy name is retained for import compatibility. Each upload path selects its
+ own finite transport limit; other JSON write requests use a separate envelope cap.
"""
def __init__(self, app, max_bytes: int):
@@ -113,11 +129,29 @@ def __init__(self, app, max_bytes: int):
self.max_bytes = max_bytes
async def __call__(self, scope, receive, send):
- if (
- scope["type"] != "http"
- or scope["method"] != "POST"
- or scope["path"].rstrip("/") not in _UPLOAD_LIMIT_PATHS
+ if scope["type"] != "http":
+ await self.app(scope, receive, send)
+ return
+ path = scope["path"].rstrip("/")
+ headers = scope.get("headers", [])
+ content_type = next(
+ (
+ value.decode("latin-1").split(";", 1)[0].strip().lower()
+ for name, value in headers
+ if name.lower() == b"content-type"
+ ),
+ "",
+ )
+ if path in _UPLOAD_LIMIT_PATHS:
+ max_bytes = self.max_bytes
+ if path == "/memory/documents/upload":
+ max_bytes = min(max_bytes, SINGLE_UPLOAD_REQUEST_BYTES)
+ elif (
+ scope["method"] in {"POST", "PUT", "PATCH", "DELETE"}
+ and (content_type == "application/json" or content_type.endswith("+json"))
):
+ max_bytes = min(self.max_bytes, _JSON_REQUEST_BYTES)
+ else:
await self.app(scope, receive, send)
return
@@ -142,8 +176,8 @@ async def __call__(self, scope, receive, send):
status_code=400,
)(scope, receive, send)
return
- if declared_length > self.max_bytes:
- await self._too_large(scope, receive, send)
+ if declared_length > max_bytes:
+ await self._too_large(scope, receive, send, max_bytes)
return
received = 0
@@ -154,7 +188,7 @@ async def limited_receive():
message = await receive()
if message["type"] == "http.request":
received += len(message.get("body", b""))
- if received > self.max_bytes:
+ if received > max_bytes:
limit_exceeded = True
raise _RequestBodyTooLarge
return message
@@ -172,33 +206,34 @@ async def guarded_send(message):
except _RequestBodyTooLarge:
pass
if limit_exceeded:
- await self._too_large(scope, receive, send)
+ await self._too_large(scope, receive, send, max_bytes)
- async def _too_large(self, scope, receive, send):
+ async def _too_large(self, scope, receive, send, max_bytes):
await JSONResponse(
{
"error": "request body too large",
- "max_bytes": self.max_bytes,
+ "max_bytes": max_bytes,
},
status_code=413,
)(scope, receive, send)
+def _legacy_schema_ready() -> bool:
+ """Return whether the configured database contains the complete v1 schema."""
+ rows = get_conn().execute(
+ "SELECT name FROM sqlite_master WHERE type='table'"
+ ).fetchall()
+ present = {row["name"] for row in rows}
+ return _REQUIRED_LEGACY_TABLES.issubset(present)
+
+
def _embedder_ready() -> bool:
+ """Probe the exact legacy embedder used by v1 recall."""
global _embedder_ok
try:
- from engraphis.backends.embedder_st import get_embedder
- emb = get_embedder(
- settings.embed_model or None,
- settings.embed_dim or 384,
- revision=settings.embed_revision or None,
- require_immutable_models=settings.require_immutable_models,
- )
- _embedder_ok = emb is not None and int(emb.dim) > 0
- except Exception as exc: # pragma: no cover - defensive; get_embedder falls back itself
- # Provider/backend exceptions can contain credentialed URLs or local paths.
- # Readiness logs need the failure class, not the exception payload.
- logger.warning("Readiness: embedder init failed (%s)", type(exc).__name__)
+ _embedder_ok = bool(_warmup_embedder())
+ except Exception as exc:
+ logger.warning("Readiness: legacy embedder failed (%s)", type(exc).__name__)
_embedder_ok = False
return _embedder_ok
@@ -207,17 +242,14 @@ def _embedder_ready() -> bool:
async def _lifespan(app: FastAPI):
"""Startup/shutdown for the app (replaces the deprecated @app.on_event hooks).
- Startup: initialize the DB (deferred to here so the CLI can set ENGRAPHIS_DB_PATH
- first), then start the background consolidation loop unless it's disabled. Shutdown:
- cancel and await the loop."""
- global _background_task
+ Startup: initialize the DB in a worker (deferred so the CLI can set
+ ENGRAPHIS_DB_PATH first), then start the background consolidation loop unless
+ it's disabled. Shutdown: cancel and await the loop."""
+ global _background_task, _embedder_ok
_background_task = None
background_task: Optional[asyncio.Task] = None
- init_db()
- # Warm the embedding model eagerly so the first recall call isn't paid
- # under request pressure (a cold load + concurrent call used to wedge
- # the forked PM2 worker and time out every recall).
- await asyncio.get_running_loop().run_in_executor(None, _warmup_embedder)
+ await asyncio.to_thread(init_db)
+ _embedder_ok = await asyncio.to_thread(_warmup_embedder)
if settings.loop_interval > 0:
background_task = asyncio.create_task(
_consciousness_loop(
@@ -397,30 +429,31 @@ async def api_health():
@app.get("/api/ready")
async def api_ready():
- """Readiness: DB answers a trivial SELECT and the embedder backend
+ """Readiness: the complete legacy schema exists and the exact v1 embedder
initializes. 503 until both hold, so orchestrators hold traffic."""
checks = {"db": False, "embedder": False}
try:
- get_conn().execute("SELECT 1").fetchone()
- checks["db"] = True
+ checks["db"] = await asyncio.to_thread(_legacy_schema_ready)
except Exception as exc:
logger.warning("Readiness: db check failed (%s)", type(exc).__name__)
- checks["embedder"] = _embedder_ready()
+ checks["embedder"] = await asyncio.to_thread(_embedder_ready)
ready = all(checks.values())
return JSONResponse({"ready": ready, "checks": checks, "version": __version__},
status_code=200 if ready else 503)
@app.get("/", response_class=HTMLResponse)
- async def dashboard():
- """Serve the visual dashboard."""
- index_path = _STATIC_DIR / "index.html"
- if index_path.exists():
- return HTMLResponse(index_path.read_text(encoding="utf-8"))
- return HTMLResponse("
Dashboard not found
Static files missing at: "
- f"{_STATIC_DIR}
", status_code=404)
+ async def reference_home():
+ """Serve an explicit API-only compatibility notice, never the v2 dashboard."""
+ return HTMLResponse(
+ """
+
+Engraphis v1 reference
+
Engraphis v1 reference API
+
This compatibility server is API-only and uses an isolated legacy database.
+
Use engraphis-dashboard or engraphis-server for the
+current v2 dashboard and service.
"""
+ )
- if _STATIC_DIR.exists():
- app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
return app
@@ -442,10 +475,11 @@ async def _consciousness_loop(*, enable_consolidation: bool = True) -> None:
try:
await asyncio.sleep(settings.loop_interval)
_ticks += 1
- touched = reweight.decay_pass(namespace=None)
+ touched = await asyncio.to_thread(reweight.decay_pass, namespace=None)
if touched:
logger.info("Decay pass: %d memories reweighted", touched)
- result = thoughts_engine.synthesize_thoughts(
+ result = await asyncio.to_thread(
+ thoughts_engine.synthesize_thoughts,
namespace=None,
max_chunks=settings.loop_top_k,
persist=True,
diff --git a/engraphis/backends/codegraph.py b/engraphis/backends/codegraph.py
index 5e480366..c35a48f2 100644
--- a/engraphis/backends/codegraph.py
+++ b/engraphis/backends/codegraph.py
@@ -340,21 +340,36 @@ def _base_targets(node, src: bytes, lang: str) -> list[tuple[str, str]]:
@staticmethod
def _import_targets(node, src: bytes) -> list[str]:
+ if _node_kind(node) == "import_from_statement":
+ try:
+ module = _cg(node, "child_by_field_name", "module_name")
+ except Exception:
+ module = None
+ if module is not None:
+ text = _text(src, module).strip("\"'`")
+ return [text] if text else []
+
wanted = {
"dotted_name", "string", "interpreted_string_literal", "raw_string_literal",
"scoped_identifier", "qualified_identifier",
}
+ targets: list[str] = []
+ seen: set[str] = set()
stack = [node]
while stack:
current = stack.pop()
if current is not node and _node_kind(current) in wanted:
text = _text(src, current).strip("\"'`")
- if text:
- return [text]
+ if text and text not in seen:
+ seen.add(text)
+ targets.append(text)
+ # A wanted node can contain wanted descendants (for example a qualified
+ # identifier). The outer node is the complete module target.
+ continue
cc = _cg(current, "child_count")
for i in range(cc - 1, -1, -1):
stack.append(_cg(current, "child", i))
- return []
+ return targets
def _base_targets_from_signature(first: str, lang: str) -> list[tuple[str, str]]:
@@ -737,10 +752,10 @@ def load_ignore_patterns(root: str) -> tuple:
*prune* a walk already confined to ``root``; they can never widen it.
* ``# comment`` and blank lines are ignored.
- * ``!name`` re-includes a name the ignore file itself excluded (gitignore-style). It
- can NOT re-expose a hardcoded default (``node_modules``/``.git``/build dirs …) —
- those stay excluded no matter what an untrusted ``.engraphisignore`` says, so it
- can't reintroduce the large-tree hang or pull vendored code into the graph.
+ * ``!pattern`` re-includes a path the ignore file itself excluded. Negations take
+ precedence over matching positive rules, but can NOT re-expose a hardcoded default
+ (``node_modules``/``.git``/build dirs …), so an untrusted repo cannot reintroduce
+ the large-tree hang or pull vendored code into the graph.
* a bare token with no wildcard (``fixtures``) matches that file/dir name anywhere.
* a token with a wildcard or slash (``*.gen.cs``, ``src/generated/*``) is a glob
matched against each candidate's repo-root-relative POSIX path (and basename).
@@ -765,7 +780,7 @@ def load_ignore_patterns(root: str) -> tuple:
continue
if line.startswith("!"):
tok = line[1:].strip().strip("/")
- if tok and not _has_glob(tok):
+ if tok:
unignore.add(tok)
continue
line = line.rstrip("/")
@@ -776,6 +791,69 @@ def load_ignore_patterns(root: str) -> tuple:
return names, globs, unignore
+def source_path_allowed(root: str, path: str, *,
+ respect_ignore_file: bool = True) -> bool:
+ """Return whether one explicit path belongs to the repository source policy.
+
+ Incremental indexers and filesystem watchers do not walk the tree, so they must
+ apply the same hardcoded directory, ``.engraphisignore``, containment, and symlink
+ rules as :func:`iter_source_files`. Missing paths are allowed when their lexical
+ location is eligible so callers can retire a deleted file's prior index rows.
+ """
+ root_real = os.path.realpath(os.fspath(root))
+ raw_candidate = os.fspath(path)
+ if not os.path.isabs(raw_candidate):
+ raw_candidate = os.path.join(os.fspath(root), raw_candidate)
+ candidate = os.path.abspath(raw_candidate)
+ candidate_real = os.path.realpath(candidate)
+ try:
+ if os.path.commonpath((root_real, candidate_real)) != root_real:
+ return False
+ except (OSError, ValueError):
+ return False
+ relative = os.path.relpath(candidate_real, root_real)
+ if relative in ("", "."):
+ return False
+ parts = Path(relative).parts
+ if not parts or detect_lang(parts[-1]) is None:
+ return False
+ if any(part in _DEFAULT_EXCLUDE_DIRS for part in parts[:-1]):
+ return False
+
+ # Reject a symlink at any existing component. The full walk uses
+ # followlinks=False and skips file links, so explicit paths must do the same.
+ lexical_root = Path(os.path.abspath(os.fspath(root)))
+ current = lexical_root
+ try:
+ lexical_relative = Path(candidate).relative_to(lexical_root)
+ except ValueError:
+ return False
+ for part in lexical_relative.parts:
+ current = current / part
+ if current.is_symlink():
+ return False
+
+ names: set = set()
+ globs: list = []
+ unignore: set = set()
+ if respect_ignore_file:
+ names, globs, unignore = load_ignore_patterns(root_real)
+ candidates = [
+ "/".join(parts[:index])
+ for index in range(1, len(parts) + 1)
+ ]
+ return not any(
+ _ignored_by_rules(
+ candidate_path,
+ candidate_path.rsplit("/", 1)[-1],
+ names,
+ globs,
+ unignore,
+ )
+ for candidate_path in candidates
+ )
+
+
def _has_glob(s: str) -> bool:
return any(c in s for c in "*?[")
@@ -786,6 +864,24 @@ def _rel_posix(rel_dir: str, name: str) -> str:
return rel_dir.replace(os.sep, "/") + "/" + name
+def _matches_ignore_pattern(rel_path: str, name: str, pattern: str) -> bool:
+ if _has_glob(pattern) or "/" in pattern:
+ return fnmatch.fnmatch(rel_path, pattern) or fnmatch.fnmatch(name, pattern)
+ return name == pattern
+
+
+def _ignored_by_rules(rel_path: str, name: str, names: set, globs: list,
+ unignore: set) -> bool:
+ ignored = name in names or any(
+ _matches_ignore_pattern(rel_path, name, pattern) for pattern in globs
+ )
+ if not ignored:
+ return False
+ return not any(
+ _matches_ignore_pattern(rel_path, name, pattern) for pattern in unignore
+ )
+
+
# Upper bound on directories visited in a single walk. Pairs with the engine's
# ``max_files`` cap: stops a pathological tree (millions of empty dirs) from spinning
# even when few files are ever yielded.
@@ -816,12 +912,10 @@ def iter_source_files(root: str, *, exclude_dirs: Optional[set] = None,
unignore: set = set()
if respect_ignore_file:
ig_names, ig_globs, unignore = load_ignore_patterns(root_str)
- # Defaults are non-negotiable: `!` can only re-include a name the ignore file itself
- # added, never a hardcoded default — an untrusted repo can't disable the hang guards.
- excl_dir_names = default_excl | (ig_names - unignore)
-
- def _glob_hit(rel_path: str, name: str) -> bool:
- return any(fnmatch.fnmatch(rel_path, g) or fnmatch.fnmatch(name, g) for g in ig_globs)
+ # Defaults are non-negotiable: `!` can only re-include a path the ignore file itself
+ # excluded, never a hardcoded default — an untrusted repo cannot disable hang guards.
+ def _ignore_hit(rel_path: str, name: str) -> bool:
+ return _ignored_by_rules(rel_path, name, ig_names, ig_globs, unignore)
dirs_seen = 0
for dirpath, dirnames, filenames in os.walk(root_str, followlinks=False):
@@ -834,12 +928,10 @@ def _glob_hit(rel_path: str, name: str) -> bool:
# prune in place so os.walk skips these subtrees entirely
dirnames[:] = [
d for d in dirnames
- if d not in excl_dir_names and not _glob_hit(_rel_posix(rel_dir, d), d)
+ if d not in default_excl and not _ignore_hit(_rel_posix(rel_dir, d), d)
]
for fn in filenames:
- if detect_lang(fn) is None or fn in ig_names:
- continue
- if _glob_hit(_rel_posix(rel_dir, fn), fn):
+ if detect_lang(fn) is None or _ignore_hit(_rel_posix(rel_dir, fn), fn):
continue
full = os.path.join(dirpath, fn)
if os.path.islink(full): # never read a symlink target (may escape root)
diff --git a/engraphis/backends/embedder_api.py b/engraphis/backends/embedder_api.py
index 90cef055..a4f78b5a 100644
--- a/engraphis/backends/embedder_api.py
+++ b/engraphis/backends/embedder_api.py
@@ -18,6 +18,7 @@
import os
from numbers import Integral
from typing import Literal, Optional, Sequence
+from urllib.parse import urlsplit, urlunsplit
import numpy as np
@@ -30,6 +31,27 @@
_DEFAULT_API_KEY_ENV = "ENGRAPHIS_LLM_API_KEY"
+def _embeddings_endpoint(base_url: str) -> str:
+ """Return one OpenAI-compatible embeddings URL from a root or v1 base."""
+ parsed = urlsplit(base_url.strip())
+ stripped_path = parsed.path.strip("/")
+ path = f"/{stripped_path}" if stripped_path else ""
+ if path.endswith("/embeddings"):
+ return urlunsplit((parsed.scheme, parsed.netloc, path, parsed.query, ""))
+ while path.endswith("/v1"):
+ path = path[:-3].rstrip("/")
+ return urlunsplit(
+ (parsed.scheme, parsed.netloc, path + "/v1/embeddings", parsed.query, "")
+ )
+
+
+def _embedding_space_endpoint(url: str) -> str:
+ """Remove request credentials while retaining the provider endpoint identity."""
+ parsed = urlsplit(url)
+ netloc = parsed.netloc.rsplit("@", 1)[-1].lower()
+ return urlunsplit((parsed.scheme.lower(), netloc, parsed.path, "", ""))
+
+
class ApiEmbedder:
"""Embedder that calls an OpenAI-compatible /v1/embeddings API.
@@ -43,6 +65,10 @@ class ApiEmbedder:
API key. Falls back to ``ENGRAPHIS_LLM_API_KEY`` env var.
dim : int, optional
Known embedding dimension. If not provided, detected from first response.
+ space_version : str, optional
+ Provider/operator revision for the returned vector space. Persisted API
+ embeddings fail closed when this is omitted because mutable providers cannot
+ be fingerprinted from a model selector alone.
"""
supports_semantic_search = True
@@ -55,9 +81,11 @@ def __init__(
base_url: Optional[str] = None,
api_key: Optional[str] = None,
dim: Optional[int] = None,
+ space_version: Optional[str] = None,
) -> None:
self.model = model
- self._base_url = (base_url or _DEFAULT_BASE_URL).rstrip("/")
+ self._base_url = (base_url or _DEFAULT_BASE_URL).strip()
+ self._space_version = (space_version or "").strip()
self._api_key = api_key or os.environ.get(_DEFAULT_API_KEY_ENV, "")
if dim is not None:
if isinstance(dim, bool) or not isinstance(dim, Integral):
@@ -68,7 +96,7 @@ def __init__(
f"embedding dimension must be between 1 and {MAX_EMBEDDING_DIM}"
)
self._dim = dim
- self._embeddings_url = f"{self._base_url}/v1/embeddings"
+ self._embeddings_url = _embeddings_endpoint(self._base_url)
# A custom endpoint can contain embedded credentials or signed query
# parameters, while provider-controlled model identifiers are also untrusted
# log input. Do not copy either into logs.
@@ -86,9 +114,14 @@ def dim(self) -> int:
@property
def embedding_version(self) -> str:
- """Return a credential-free fingerprint of the provider vector space."""
- payload = f"v1\0{self._base_url}\0{self.model}\0{self.dim}".encode("utf-8")
- return "v1:" + hashlib.sha256(payload).hexdigest()
+ """Return a credential-free fingerprint, or empty for an unversioned API."""
+ if not self._space_version:
+ return ""
+ payload = (
+ f"v2\0{_embedding_space_endpoint(self._embeddings_url)}\0"
+ f"{self.model}\0{self.dim}\0{self._space_version}"
+ ).encode("utf-8")
+ return "v2:" + hashlib.sha256(payload).hexdigest()
def embed(
self, texts: list[str], *, kind: Literal["text", "code"] = "text"
diff --git a/engraphis/backends/embedder_deterministic.py b/engraphis/backends/embedder_deterministic.py
index 8c961022..14bfc3c5 100644
--- a/engraphis/backends/embedder_deterministic.py
+++ b/engraphis/backends/embedder_deterministic.py
@@ -96,13 +96,19 @@ def embed(self, texts: list[str], *, kind: Literal["text", "code"] = "text") ->
return out
+def _bounded_trigrams(text: str, limit: int = 512) -> list[str]:
+ """Return at most *limit* leading trigrams without scanning the unused suffix."""
+ count = min(max(0, int(limit)), max(0, len(text) - 2))
+ return [text[index:index + 3] for index in range(count)]
+
+
def _tokenize(text: str, kind: str) -> list[str]:
text = (text or "").lower()
# For code, keep identifier-ish boundaries; for text, split on non-alphanumerics.
sep = "".join(c if c.isalnum() else " " for c in text)
tokens = [t for t in sep.split() if t]
# add character trigrams for short/OOV robustness
- trigrams = [text[j:j + 3] for j in range(max(0, len(text) - 2))][:512]
+ trigrams = _bounded_trigrams(text)
return tokens + trigrams + _variant_features(text, tokens)
diff --git a/engraphis/backends/embedder_st.py b/engraphis/backends/embedder_st.py
index 2524b91b..41218074 100644
--- a/engraphis/backends/embedder_st.py
+++ b/engraphis/backends/embedder_st.py
@@ -14,18 +14,151 @@
import hashlib
import logging
+import os
+import re
from numbers import Integral
+from pathlib import Path
from typing import Any, Literal, Optional
import numpy as np
from engraphis.backends.embedder_deterministic import DeterministicEmbedder
-from engraphis.backends.model_source import validate_model_source
+from engraphis.backends.model_source import is_local_model_source, validate_model_source
LOCAL_MODEL_PREFIX = "local:"
+_IMMUTABLE_COMMIT = re.compile(r"[0-9a-f]{40}\Z")
+
+
+_LOCAL_ARTIFACT_HASH_CHUNK = 1_048_576
+
+
+def _stat_signature(info: os.stat_result) -> tuple[int, ...]:
+ identity = (
+ int(info.st_dev),
+ int(info.st_ino),
+ int(info.st_size),
+ int(info.st_mtime_ns),
+ )
+ # Windows exposes creation time as st_ctime and can update its reported
+ # precision when a descriptor is opened. POSIX st_ctime is a useful mutation
+ # signal, so retain it only where path-stat and descriptor-stat are stable.
+ return identity if os.name == "nt" else identity + (int(info.st_ctime_ns),)
+
+
+def _local_artifact_inventory(model_name: str):
+ source = Path(os.path.expanduser(model_name))
+ if not source.exists():
+ return None
+ root = source.resolve(strict=True)
+ single_file = root.is_file()
+ files = [root] if single_file else sorted(
+ (path for path in root.rglob("*") if path.is_file()),
+ key=lambda path: path.relative_to(root).as_posix(),
+ )
+ state = tuple(
+ (
+ path.name if single_file else path.relative_to(root).as_posix(),
+ *_stat_signature(path.stat()),
+ )
+ for path in files
+ )
+ return root, files, state
+
+
+def _local_artifact_state(model_name: str):
+ try:
+ inventory = _local_artifact_inventory(model_name)
+ except OSError:
+ raise RuntimeError("local model artifacts could not be inspected") from None
+ return inventory[2] if inventory is not None else None
+
+
+def _local_artifact_version(
+ model_name: str,
+ *,
+ expected_state=None,
+ verify_expected: bool = False,
+) -> str:
+ """Hash local artifact bytes once, while proving the manifest stayed stable."""
+ try:
+ inventory = _local_artifact_inventory(model_name)
+ state = inventory[2] if inventory is not None else None
+ if verify_expected and state != expected_state:
+ raise RuntimeError("local model artifacts changed while the model was loading")
+ if inventory is None:
+ return ""
+ _, files, state = inventory
+ digest = hashlib.sha256(b"engraphis-local-artifact-v1\0")
+ for path, expected in zip(files, state):
+ relative, *expected_signature = expected
+ digest.update(relative.encode("utf-8", errors="surrogatepass"))
+ digest.update(b"\0")
+ digest.update(str(expected_signature[2]).encode("ascii"))
+ digest.update(b"\0")
+ with path.open("rb") as stream:
+ if _stat_signature(os.fstat(stream.fileno())) != tuple(expected_signature):
+ raise RuntimeError(
+ "local model artifacts changed while they were fingerprinted"
+ )
+ while True:
+ chunk = stream.read(_LOCAL_ARTIFACT_HASH_CHUNK)
+ if not chunk:
+ break
+ digest.update(chunk)
+ if _stat_signature(os.fstat(stream.fileno())) != tuple(expected_signature):
+ raise RuntimeError(
+ "local model artifacts changed while they were fingerprinted"
+ )
+ after = _local_artifact_inventory(model_name)
+ if after is None or after[2] != state:
+ raise RuntimeError("local model artifacts changed while they were fingerprinted")
+ return "local-content:" + digest.hexdigest()
+ except RuntimeError:
+ raise
+ except OSError:
+ raise RuntimeError("local model artifacts could not be fingerprinted") from None
+
+
+def _loaded_commit(model: object) -> str:
+ """Return the immutable Hub commit recorded by sentence-transformers/transformers."""
+ first = None
+ first_module = getattr(model, "_first_module", None)
+ if callable(first_module):
+ try:
+ first = first_module()
+ except Exception:
+ first = None
+ auto_model = getattr(first, "auto_model", None)
+ candidates = (
+ model,
+ getattr(model, "_model_card_vars", None),
+ first,
+ auto_model,
+ getattr(auto_model, "config", None),
+ )
+ for candidate in candidates:
+ if isinstance(candidate, dict):
+ values = (
+ candidate.get("_commit_hash"),
+ candidate.get("commit_hash"),
+ candidate.get("revision"),
+ )
+ else:
+ values = (
+ getattr(candidate, "_commit_hash", None),
+ getattr(candidate, "commit_hash", None),
+ getattr(candidate, "revision", None),
+ )
+ for value in values:
+ normalized = str(value or "").strip().lower()
+ if _IMMUTABLE_COMMIT.fullmatch(normalized):
+ return normalized
+ return ""
+
+
class SentenceTransformerEmbedder:
supports_semantic_search = True
embedding_mode = "semantic"
@@ -39,12 +172,19 @@ def __init__(
local_files_only: bool = False,
require_immutable_models: Optional[bool] = None,
) -> None:
+ validation_source = (
+ f"{LOCAL_MODEL_PREFIX}{model_name}"
+ if local_files_only and not is_local_model_source(model_name)
+ else model_name
+ )
validate_model_source(
- model_name,
+ validation_source,
revision,
require_immutable_models=require_immutable_models,
loader="sentence-transformers model",
)
+ local_source = is_local_model_source(model_name)
+ local_before = _local_artifact_state(model_name) if local_source else None
from sentence_transformers import SentenceTransformer # pyright: ignore[reportMissingImports] # lazy: optional dependency
kwargs: dict[str, Any] = {"trust_remote_code": False}
if revision:
@@ -61,9 +201,31 @@ def __init__(
self.revision = revision
self.local_files_only = local_files_only
self.model = SentenceTransformer(model_name, **kwargs)
+ local_after = (
+ _local_artifact_version(
+ model_name,
+ expected_state=local_before,
+ verify_expected=True,
+ )
+ if local_source
+ else ""
+ )
+ resolved_commit = _loaded_commit(self.model)
+ declared_commit = str(revision or "").strip().lower()
+ if not resolved_commit and _IMMUTABLE_COMMIT.fullmatch(declared_commit):
+ resolved_commit = declared_commit
+ self._artifact_version = (
+ local_after or (f"hf-commit:{resolved_commit}" if resolved_commit else "")
+ )
dimension = self.model.get_embedding_dimension()
- if isinstance(dimension, bool) or not isinstance(dimension, Integral) or int(dimension) <= 0:
- raise ValueError('sentence-transformers model did not report a positive embedding dimension')
+ if (
+ isinstance(dimension, bool)
+ or not isinstance(dimension, Integral)
+ or int(dimension) <= 0
+ ):
+ raise ValueError(
+ "sentence-transformers model did not report a positive embedding dimension"
+ )
self._dim = int(dimension)
@property
@@ -72,8 +234,11 @@ def dim(self) -> int:
@property
def embedding_version(self) -> str:
- """Identify the configured model space without exposing local paths or tokens."""
- configured = f"{self.model_name}\0{self.revision or 'unversioned'}"
+ """Identify the loaded artifact space without exposing model paths."""
+ artifact_version = str(getattr(self, "_artifact_version", "") or "").strip()
+ if not artifact_version:
+ return ""
+ configured = f"v2\0{self.model_name}\0{artifact_version}"
digest = hashlib.sha256(configured.encode("utf-8")).hexdigest()[:24]
return f"st:{digest}"
@@ -83,8 +248,8 @@ def embed(self, texts: list[str], *, kind: Literal["text", "code"] = "text") ->
try:
vecs = self.model.encode(texts, normalize_embeddings=True, convert_to_numpy=True)
result = np.asarray(vecs, dtype=np.float32)
- except (TypeError, ValueError, OverflowError) as exc:
- raise RuntimeError("sentence-transformers returned malformed embeddings") from exc
+ except (TypeError, ValueError, OverflowError, RuntimeError): # noqa: BLE001
+ raise RuntimeError("sentence-transformers returned malformed embeddings") from None
if result.ndim == 1 and len(texts) == 1:
result = result.reshape(1, -1)
if result.shape != (len(texts), self._dim) or not np.isfinite(result).all():
@@ -125,16 +290,20 @@ def get_embedder(
require_immutable_models=require_immutable_models,
loader="sentence-transformers model",
)
- local_files_only = raw_model_name.startswith(LOCAL_MODEL_PREFIX)
+ has_local_prefix = raw_model_name.startswith(LOCAL_MODEL_PREFIX)
+ local_files_only = has_local_prefix or is_local_model_source(raw_model_name)
resolved_model_name = (
raw_model_name[len(LOCAL_MODEL_PREFIX):].strip()
- if local_files_only
+ if has_local_prefix
else raw_model_name
)
try:
if not resolved_model_name:
raise ValueError("local embedder selector requires a path or cached model name")
- factory_kwargs: dict[str, Any] = {'revision': revision}
+ factory_kwargs: dict[str, Any] = {
+ "revision": revision,
+ "require_immutable_models": require_immutable_models,
+ }
if local_files_only:
factory_kwargs["local_files_only"] = True
emb = SentenceTransformerEmbedder(resolved_model_name, **factory_kwargs)
diff --git a/engraphis/backends/encrypted_db.py b/engraphis/backends/encrypted_db.py
index 35585c05..c7b111ab 100644
--- a/engraphis/backends/encrypted_db.py
+++ b/engraphis/backends/encrypted_db.py
@@ -51,11 +51,12 @@ def _resolve_key() -> Optional[str]:
key = (read_private_text(
Path(path), max_bytes=_MAX_DB_KEY_FILE_BYTES
) or "").strip()
- except OSError as exc:
+ except OSError:
raise EncryptionError(
- "ENGRAPHIS_DB_KEY_FILE=%s could not be read safely: %s" % (path, exc)) from exc
+ "ENGRAPHIS_DB_KEY_FILE could not be read safely"
+ ) from None
if not key:
- raise EncryptionError("ENGRAPHIS_DB_KEY_FILE=%s is empty" % path)
+ raise EncryptionError("ENGRAPHIS_DB_KEY_FILE is empty")
return key
return None
@@ -94,9 +95,7 @@ def _guard(fn, *args, **kwargs):
class _TranslatingCursor:
- """A cursor whose statement methods translate sqlcipher3 exceptions. Returned by
- :meth:`_TranslatingConnection.cursor` so error handling holds even for code that drives
- a cursor directly (the core doesn't today, but this closes the gap for future callers)."""
+ """Cursor adapter that translates the complete SQLCipher result lifecycle."""
def __init__(self, raw) -> None:
object.__setattr__(self, "_raw", raw)
@@ -108,7 +107,10 @@ def __setattr__(self, name, value):
setattr(self._raw, name, value)
def __iter__(self):
- return iter(self._raw)
+ return self
+
+ def __next__(self):
+ return _guard(next, self._raw)
def execute(self, *a, **k):
_guard(self._raw.execute, *a, **k)
@@ -122,13 +124,28 @@ def executescript(self, *a, **k):
_guard(self._raw.executescript, *a, **k)
return self
+ def fetchone(self, *a, **k):
+ return _guard(self._raw.fetchone, *a, **k)
+
+ def fetchmany(self, *a, **k):
+ return _guard(self._raw.fetchmany, *a, **k)
+
+ def fetchall(self, *a, **k):
+ return _guard(self._raw.fetchall, *a, **k)
+
+ def close(self):
+ return _guard(self._raw.close)
+
+ def __enter__(self):
+ _guard(self._raw.__enter__)
+ return self
+
+ def __exit__(self, *exc):
+ return _guard(self._raw.__exit__, *exc)
-class _TranslatingConnection:
- """Adapts a sqlcipher3 connection so it raises stdlib ``sqlite3`` exceptions.
- The stdlib-only core catches ``sqlite3.OperationalError``/``IntegrityError``; sqlcipher3
- raises unrelated classes of the same name. We translate on the statement-executing
- methods (and cursors) and pass everything else through."""
+class _TranslatingConnection:
+ """Connection adapter that exposes only stdlib ``sqlite3`` exception classes."""
def __init__(self, raw) -> None:
object.__setattr__(self, "_raw", raw)
@@ -140,26 +157,32 @@ def __setattr__(self, name, value):
setattr(self._raw, name, value)
def execute(self, *a, **k):
- return _guard(self._raw.execute, *a, **k)
+ return _TranslatingCursor(_guard(self._raw.execute, *a, **k))
def executescript(self, *a, **k):
- return _guard(self._raw.executescript, *a, **k)
+ return _TranslatingCursor(_guard(self._raw.executescript, *a, **k))
def executemany(self, *a, **k):
- return _guard(self._raw.executemany, *a, **k)
+ return _TranslatingCursor(_guard(self._raw.executemany, *a, **k))
def commit(self):
return _guard(self._raw.commit)
+ def rollback(self):
+ return _guard(self._raw.rollback)
+
+ def close(self):
+ return _guard(self._raw.close)
+
def cursor(self, *a, **k):
- return _TranslatingCursor(self._raw.cursor(*a, **k))
+ return _TranslatingCursor(_guard(self._raw.cursor, *a, **k))
def __enter__(self):
- self._raw.__enter__()
+ _guard(self._raw.__enter__)
return self
def __exit__(self, *exc):
- return self._raw.__exit__(*exc)
+ return _guard(self._raw.__exit__, *exc)
def make_connector(key: str) -> Callable[[str], object]:
@@ -168,25 +191,33 @@ def make_connector(key: str) -> Callable[[str], object]:
message if the driver is missing or the key does not unlock an existing file."""
try:
sqlcipher3 = importlib.import_module("sqlcipher3")
- except Exception as exc: # noqa: BLE001
+ except Exception: # noqa: BLE001
raise EncryptionError(
"ENGRAPHIS_DB_KEY is set but no compatible SQLCipher driver is importable. "
"On CPython manylinux x86-64, install it with: pip install "
"\"engraphis[encryption]\". On macOS, Windows, Linux ARM, or musl, "
"provision a compatible sqlcipher3 driver separately. Engraphis will not "
"fall back to plaintext."
- ) from exc
+ ) from None
pragma = _key_pragma(key)
def _connect(path: str):
- if path != ":memory:":
- Path(path).parent.mkdir(parents=True, exist_ok=True)
- raw = sqlcipher3.connect(path, timeout=30, check_same_thread=False)
+ try:
+ if path != ":memory:":
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
+ raw = sqlcipher3.connect(path, timeout=30, check_same_thread=False)
+ except Exception: # noqa: BLE001
+ raise EncryptionError(
+ "could not initialize the encrypted database connection"
+ ) from None
try:
raw.execute(pragma) # MUST be the first statement
except Exception: # noqa: BLE001
- raw.close()
+ try:
+ raw.close()
+ except Exception: # noqa: BLE001
+ pass
# Suppress the driver message (`from None`): a PRAGMA syntax error can echo the
# statement text, which contains the key. Never surface key material.
raise EncryptionError(
@@ -195,12 +226,16 @@ def _connect(path: str):
# Touch the header so a wrong key / plaintext-vs-encrypted mismatch fails now,
# with a clear message, instead of deep inside an unrelated query later.
raw.execute("SELECT count(*) FROM sqlite_master").fetchone()
- except Exception as exc: # noqa: BLE001
- raw.close()
+ except Exception: # noqa: BLE001
+ try:
+ raw.close()
+ except Exception: # noqa: BLE001
+ pass
raise EncryptionError(
- "could not open the encrypted database at %s — wrong ENGRAPHIS_DB_KEY, or "
+ "could not open the encrypted database — wrong ENGRAPHIS_DB_KEY, or "
"the file is not SQLCipher-encrypted (an existing plaintext DB cannot be "
- "opened with a key; migrate it first)." % path) from exc
+ "opened with a key; migrate it first)."
+ ) from None
raw.row_factory = sqlcipher3.Row
return _TranslatingConnection(raw)
diff --git a/engraphis/backends/extractor.py b/engraphis/backends/extractor.py
index 68957d44..2bf85927 100644
--- a/engraphis/backends/extractor.py
+++ b/engraphis/backends/extractor.py
@@ -142,11 +142,30 @@ def _llm_activity_metadata(llm: Any, mode: str) -> dict[str, str]:
return activity
+def _mark_extraction_fallback(
+ facts: list[ExtractedFact],
+ mode: str,
+) -> list[ExtractedFact]:
+ """Tag a fail-soft result without retaining provider or exception details."""
+ for fact in facts:
+ fact.metadata["extraction_fallback"] = {
+ "mode": mode,
+ "reason": "provider_or_output_error",
+ }
+ return facts
+
+
class PassthroughExtractor:
"""The offline default: one fact, the text as given."""
+ def __init__(self, *, fallback_from: str = "") -> None:
+ self.fallback_from = fallback_from
+
def extract(self, text: str, *, context: str = "") -> list[ExtractedFact]:
- return [ExtractedFact(content=text)]
+ facts = [ExtractedFact(content=text)]
+ if self.fallback_from:
+ return _mark_extraction_fallback(facts, self.fallback_from)
+ return facts
class LLMExtractor:
@@ -170,7 +189,10 @@ def extract(self, text: str, *, context: str = "") -> list[ExtractedFact]:
facts = self._parse(raw)
except Exception:
facts = []
- return facts or [ExtractedFact(content=text)]
+ return facts or _mark_extraction_fallback(
+ [ExtractedFact(content=text)],
+ "llm",
+ )
# ── internals ────────────────────────────────────────────────────────────
def _ask(self, prompt: str) -> str:
@@ -258,15 +280,23 @@ def extract(self, text: str, *, context: str = "") -> list[ExtractedFact]:
if not text.strip():
return []
if not _PYDANTIC_AVAILABLE:
- return ChunkingExtractor(max_chunks=self.max_facts).extract(text, context=context)
+ return _mark_extraction_fallback(
+ ChunkingExtractor(max_chunks=self.max_facts).extract(text, context=context),
+ "llm_structured",
+ )
prompt = self._build_prompt(text, context)
try:
raw = self._ask(prompt)
facts = self._parse_and_validate(raw)
except Exception:
facts = []
- # Fallback to chunking extractor on any failure
- return facts or ChunkingExtractor(max_chunks=self.max_facts).extract(text, context=context)
+ if facts:
+ return facts
+ # Fail soft without presenting deterministic chunks as model-produced facts.
+ return _mark_extraction_fallback(
+ ChunkingExtractor(max_chunks=self.max_facts).extract(text, context=context),
+ "llm_structured",
+ )
# ── internals ────────────────────────────────────────────────────────────
def _build_prompt(self, text: str, context: str = "") -> str:
@@ -382,8 +412,8 @@ class ChunkingExtractor:
* Markdown headings (``#``..``######``) start a new chunk; the heading path
(``H1 > H2``) becomes the chunk title and is kept as context.
- * Fenced code blocks (```` ``` ````/``~~~``) are emitted whole — never split
- mid-fence.
+ * Fenced code blocks stay balanced; blocks above the memory-size ceiling are split
+ into independently fenced pieces rather than silently truncated.
* Prose is packed paragraph-by-paragraph up to ``target_tokens``, with a small
sentence-level overlap so a fact straddling a boundary survives in both chunks.
@@ -450,7 +480,11 @@ def _chunks(self, text: str) -> list[tuple[str, str]]:
if len(out) >= self.max_chunks:
break
if kind == "code":
- out.append((heading_path, body)) # atomic — never split
+ pieces = self._split_code_block(body)
+ remaining = self.max_chunks - len(out)
+ if len(pieces) > remaining:
+ raise ValueError("oversized fenced code exceeds the chunk limit")
+ out.extend((heading_path, piece) for piece in pieces)
else:
for piece in self._pack(body):
out.append((heading_path, piece))
@@ -509,6 +543,52 @@ def flush() -> None:
flush()
return segments
+ def _split_code_block(self, body: str) -> list[str]:
+ """Keep fenced code lossless across the 100k-character memory boundary."""
+ sanitized = _CONTROL_RE.sub("", body).strip()
+ if len(sanitized) <= 100_000:
+ return [sanitized]
+
+ lines = sanitized.split("\n")
+ opening = lines[0]
+ match = _FENCE_RE.match(opening.strip())
+ if match is None:
+ return self._split_oversized_sentence(sanitized)
+
+ marker = match.group(1)[:3]
+ closed = len(lines) > 1 and lines[-1].strip().startswith(marker)
+ if closed:
+ closing_start = sanitized.rfind("\n")
+ closing = sanitized[closing_start + 1:]
+ # The newline that starts the closing-fence line is part of the fenced
+ # payload. Retain it so concatenating split payloads restores the source.
+ payload = sanitized[len(opening) + 1:closing_start + 1]
+ else:
+ closing = marker
+ payload = sanitized[len(opening) + 1:] if len(lines) > 1 else ""
+
+ envelope_chars = len(opening) + len(closing) + 2
+ if envelope_chars >= 100_000:
+ # Preserve a pathological oversized info string as payload under a minimal
+ # valid fence instead of dropping it to make room for the wrapper.
+ opening = marker
+ closing = marker
+ payload = sanitized
+ envelope_chars = len(opening) + len(closing) + 2
+ payload_limit = 100_000 - envelope_chars
+
+ pieces: list[str] = []
+ while payload:
+ cut = min(payload_limit, len(payload))
+ if cut < len(payload):
+ newline = payload.rfind("\n", 0, cut + 1)
+ if newline > 0:
+ cut = newline + 1
+ piece = payload[:cut]
+ payload = payload[cut:]
+ pieces.append(f"{opening}\n{piece}\n{closing}")
+ return pieces
+
def _pack(self, body: str) -> list[str]:
"""Greedily pack paragraphs to the token budget with sentence overlap."""
paras = [p.strip() for p in _PARA_SPLIT_RE.split(body) if p.strip()]
@@ -759,7 +839,7 @@ def get_extractor(
from engraphis.llm.client import LLMClient
llm = LLMClient()
except Exception:
- return PassthroughExtractor()
+ return PassthroughExtractor(fallback_from=kind)
return StructuredLLMExtractor(llm)
if kind != "llm":
return PassthroughExtractor()
@@ -768,5 +848,5 @@ def get_extractor(
from engraphis.llm.client import LLMClient
llm = LLMClient()
except Exception:
- return PassthroughExtractor()
+ return PassthroughExtractor(fallback_from=kind)
return LLMExtractor(llm)
diff --git a/engraphis/backends/postgres_schema.py b/engraphis/backends/postgres_schema.py
index 4ff01db0..cfca682c 100644
--- a/engraphis/backends/postgres_schema.py
+++ b/engraphis/backends/postgres_schema.py
@@ -9,6 +9,7 @@
import importlib
import ipaddress
import os
+import shlex
import socket
from typing import Any, Optional, Union
from urllib.parse import urlparse
@@ -24,6 +25,20 @@
_MAX_STATEMENT_TIMEOUT_MS = 300_000
+def _catalog_id(kind: str, *components: object) -> str:
+ """Encode catalog coordinates without delimiter collisions."""
+ encoded = "".join(f"{len(value)}:{value}" for value in map(str, components))
+ return f"{kind}:{encoded}"
+
+
+def _qualified_name(*components: object) -> str:
+ """Render PostgreSQL identifiers without flattening distinct coordinates."""
+ return ".".join(
+ '"' + str(component).replace('"', '""') + '"'
+ for component in components
+ )
+
+
class PostgresIntrospectionError(ValueError):
"""Safe, actionable PostgreSQL inspection failure."""
@@ -136,20 +151,41 @@ def _rows(cursor, query: str, params: tuple = ()) -> list[tuple]:
def _source_digest(dsn: str) -> str:
"""Identify a database endpoint without turning its password into a verifier.
- Hashing the complete DSN still preserves a stable, offline-testable oracle for a
- low-entropy password. Userinfo, query parameters, and fragments are credentials or
- connection policy, not source identity, so exclude them from provenance entirely.
+ Userinfo, passwords, query parameters, and fragments are credentials or connection
+ policy, not source identity, so exclude them from provenance entirely. URL and
+ libpq keyword/value DSNs both reduce to host/port/database coordinates.
"""
+ identity = "postgresql|unknown"
try:
parsed = urlparse(dsn)
- if parsed.scheme.casefold() not in {"postgres", "postgresql"} or not parsed.hostname:
- raise ValueError("non-URL PostgreSQL DSN")
- hostname = (parsed.hostname or "").casefold()
- port = parsed.port or 5432
- database = parsed.path.lstrip("/")
- identity = f"{parsed.scheme.casefold()}|{hostname}|{port}|{database}"
- except (TypeError, ValueError):
- identity = "postgresql|unknown"
+ if parsed.scheme.casefold() in {"postgres", "postgresql"} and parsed.hostname:
+ hostname = (parsed.hostname or "").casefold()
+ port = parsed.port or 5432
+ database = parsed.path.lstrip("/")
+ identity = f"postgresql|{hostname}|{port}|{database}"
+ else:
+ fields: dict[str, str] = {}
+ for token in shlex.split(dsn, posix=True):
+ if "=" not in token:
+ raise ValueError("invalid keyword DSN")
+ key, value = token.split("=", 1)
+ normalized_key = key.casefold()
+ if normalized_key in {"host", "hostaddr", "port", "dbname"}:
+ fields[normalized_key] = value
+ host = fields.get("host", "")
+ hostaddr = fields.get("hostaddr", "")
+ if not host and not hostaddr:
+ raise ValueError("keyword DSN has no endpoint")
+ if host and not host.startswith("/"):
+ host = ",".join(part.casefold() for part in host.split(","))
+ port = fields.get("port") or "5432"
+ database = fields.get("dbname", "")
+ identity = (
+ f"postgresql|host={host}|hostaddr={hostaddr}|"
+ f"port={port}|database={database}"
+ )
+ except (AttributeError, TypeError, ValueError):
+ pass
return hashlib.sha256(identity.encode("utf-8")).hexdigest()[:24]
@@ -250,16 +286,17 @@ def permitted(schema: Any) -> bool:
row for row in constraints[:_MAX_RELATIONS] if permitted(row[1])
]
+ database_id = _catalog_id("database", database)
entities: list[dict] = [{
- "id": f"database:{database}", "name": database, "kind": "database",
+ "id": database_id, "name": database, "kind": "database",
}]
relations: list[dict] = []
schema_names = sorted({str(row[0]) for row in tables} | {str(row[0]) for row in columns})
for schema in schema_names:
- sid = f"schema:{schema}"
+ sid = _catalog_id("schema", schema)
entities.append({"id": sid, "name": schema, "kind": "schema"})
relations.append({
- "source": f"database:{database}", "target": sid, "relation": "contains",
+ "source": database_id, "target": sid, "relation": "contains",
})
table_ids = set()
@@ -269,21 +306,22 @@ def permitted(schema: Any) -> bool:
columns_by_table.setdefault((str(row[0]), str(row[1])), []).append(row)
for schema, table, table_type in tables:
schema, table = str(schema), str(table)
- tid = f"table:{schema}.{table}"
+ tid = _catalog_id("table", schema, table)
table_ids.add(tid)
entities.append({
- "id": tid, "name": f"{schema}.{table}", "kind": "view"
+ "id": tid, "name": _qualified_name(schema, table), "kind": "view"
if "VIEW" in str(table_type).upper() else "table",
})
relations.append({
- "source": f"schema:{schema}", "target": tid, "relation": "contains",
+ "source": _catalog_id("schema", schema), "target": tid, "relation": "contains",
})
- lines.extend([f"## {schema}.{table}", ""])
+ lines.extend([f"## {_qualified_name(schema, table)}", ""])
for col in columns_by_table.get((schema, table), []):
_, _, column, position, data_type, nullable, default = col
- cid = f"column:{schema}.{table}.{column}"
+ cid = _catalog_id("column", schema, table, column)
entities.append({
- "id": cid, "name": f"{schema}.{table}.{column}", "kind": "column",
+ "id": cid, "name": _qualified_name(schema, table, column),
+ "kind": "column",
"data_type": str(data_type), "nullable": str(nullable) == "YES",
"position": int(position),
})
@@ -303,14 +341,15 @@ def permitted(schema: Any) -> bool:
constraint
)
schema, table = str(schema), str(table)
- source_table = f"table:{schema}.{table}"
+ source_table = _catalog_id("table", schema, table)
if source_table not in table_ids:
continue
- constraint_id = f"constraint:{schema}.{table}.{name}"
+ constraint_id = _catalog_id("constraint", schema, table, name)
if constraint_id not in constraint_entities:
entities.append({
- "id": constraint_id, "name": str(name), "kind": "constraint",
- "constraint_type": str(ctype),
+ "id": constraint_id,
+ "name": _qualified_name(schema, table, name),
+ "kind": "constraint", "constraint_type": str(ctype),
})
constraint_entities.add(constraint_id)
constraint_key = (source_table, constraint_id, "has_constraint")
@@ -322,7 +361,7 @@ def permitted(schema: Any) -> bool:
})
relation_keys.add(constraint_key)
if str(ctype).upper() == "FOREIGN KEY" and target_schema and target_table:
- target = f"table:{target_schema}.{target_table}"
+ target = _catalog_id("table", target_schema, target_table)
reference_key = (source_table, target, "references")
if reference_key not in relation_keys:
relations.append({
diff --git a/engraphis/backends/query_planner.py b/engraphis/backends/query_planner.py
index d728c458..dd7f4b26 100644
--- a/engraphis/backends/query_planner.py
+++ b/engraphis/backends/query_planner.py
@@ -14,7 +14,13 @@
RetrievalPlan,
SearchFilter,
)
-from engraphis.core.query_planner import MAX_PLANNED_PRIORITY
+from engraphis.core.query_planner import MAX_PLANNED_PRIORITY, MAX_PLANNED_QUERIES
+
+
+_PLANNING_PROFILES = frozenset({"balanced", "fast", "lexical", "graph", "code"})
+_MAX_PLANNED_QUERY_CHARS = 2_048
+_MAX_REASON_CODES = 8
+_MAX_REASON_CODE_CHARS = 80
class LLMQueryPlanner:
@@ -39,12 +45,16 @@ def plan(
"properties": {
"queries": {
"type": "array",
- "maxItems": 3,
+ "maxItems": MAX_PLANNED_QUERIES,
"items": {
"type": "object",
"required": ["text", "priority", "profile"],
"properties": {
- "text": {"type": "string"},
+ "text": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": _MAX_PLANNED_QUERY_CHARS,
+ },
"priority": {
"type": "integer",
"minimum": 1,
@@ -57,12 +67,22 @@ def plan(
"mtypes": {
"type": "array",
"items": {"enum": [item.value for item in MemoryType]},
+ "maxItems": len(MemoryType),
+ "uniqueItems": True,
},
},
},
},
- "mtype_limits": {"type": "object"},
- "reason_codes": {"type": "array", "items": {"type": "string"}},
+ "mtype_limits": {
+ "type": "object",
+ "maxProperties": len(MemoryType),
+ "additionalProperties": {"type": "integer", "minimum": 0},
+ },
+ "reason_codes": {
+ "type": "array",
+ "maxItems": _MAX_REASON_CODES,
+ "items": {"type": "string", "maxLength": _MAX_REASON_CODE_CHARS},
+ },
},
}
prompt = (
@@ -75,19 +95,57 @@ def plan(
raw = self.llm.extract_json(prompt, schema, **kwargs)
if not isinstance(raw, dict):
raise ValueError("planner output must be an object")
+ raw_queries = raw.get("queries", [])
+ if not isinstance(raw_queries, list) or len(raw_queries) > MAX_PLANNED_QUERIES:
+ raise ValueError("planner queries must be a bounded array")
queries = []
- for item in raw.get("queries", []):
+ for item in raw_queries:
if not isinstance(item, dict):
- continue
+ raise ValueError("planner query entries must be objects")
+ text = item.get("text")
+ priority = item.get("priority", 1)
+ profile = item.get("profile", "balanced")
+ raw_mtypes = item.get("mtypes", [])
+ if (
+ not isinstance(text, str)
+ or not text.strip()
+ or len(text) > _MAX_PLANNED_QUERY_CHARS
+ ):
+ raise ValueError("planner query text must be a bounded string")
+ if isinstance(priority, bool) or not isinstance(priority, int):
+ raise ValueError("planner query priority must be an integer")
+ if not 1 <= priority <= MAX_PLANNED_PRIORITY:
+ raise ValueError("planner query priority is outside the supported range")
+ if not isinstance(profile, str) or profile not in _PLANNING_PROFILES:
+ raise ValueError("planner query profile is unsupported")
+ if not isinstance(raw_mtypes, list) or len(raw_mtypes) > len(MemoryType):
+ raise ValueError("planner memory types must be a bounded array")
queries.append(PlannedQuery(
- text=str(item.get("text") or ""),
- priority=item.get("priority", 1),
- profile=str(item.get("profile") or "balanced"),
- mtypes=tuple(MemoryType(value) for value in item.get("mtypes", [])),
+ text=text,
+ priority=priority,
+ profile=profile,
+ mtypes=tuple(MemoryType(value) for value in raw_mtypes),
))
- limits = {
- MemoryType(key): value
- for key, value in (raw.get("mtype_limits") or {}).items()
- }
- reasons = tuple(str(value) for value in raw.get("reason_codes", []))
- return RetrievalPlan(tuple(queries), limits, reasons)
+
+ raw_limits = raw.get("mtype_limits", {})
+ if raw_limits is None:
+ raw_limits = {}
+ if not isinstance(raw_limits, dict) or len(raw_limits) > len(MemoryType):
+ raise ValueError("planner memory-type limits must be a bounded object")
+ limits = {}
+ for key, value in raw_limits.items():
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0:
+ raise ValueError("planner memory-type limits must be non-negative integers")
+ limits[MemoryType(key)] = value
+
+ raw_reasons = raw.get("reason_codes", [])
+ if raw_reasons is None:
+ raw_reasons = []
+ if not isinstance(raw_reasons, list) or len(raw_reasons) > _MAX_REASON_CODES:
+ raise ValueError("planner reason codes must be a bounded array")
+ if any(
+ not isinstance(value, str) or len(value) > _MAX_REASON_CODE_CHARS
+ for value in raw_reasons
+ ):
+ raise ValueError("planner reason codes must be bounded strings")
+ return RetrievalPlan(tuple(queries), limits, tuple(raw_reasons))
diff --git a/engraphis/backends/resources.py b/engraphis/backends/resources.py
index 99100c2f..a55f1e35 100644
--- a/engraphis/backends/resources.py
+++ b/engraphis/backends/resources.py
@@ -17,9 +17,11 @@
import hashlib
import io
import json
+import math
import mimetypes
import os
import re
+import stat
import tempfile
import zipfile
from html.parser import HTMLParser
@@ -106,6 +108,76 @@ def _base_metadata(name: str, data: bytes) -> dict:
}
+def _is_reparse_point(info: os.stat_result) -> bool:
+ marker = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
+ return bool(getattr(info, "st_file_attributes", 0) & marker)
+
+
+def _snapshot_identity(info: os.stat_result) -> tuple[int, ...]:
+ identity = (
+ int(info.st_dev),
+ int(info.st_ino),
+ int(info.st_size),
+ int(info.st_mtime_ns),
+ )
+ # Windows st_ctime is creation time and may be reported at different precision
+ # before and after opening a descriptor. POSIX st_ctime remains a useful
+ # mutation signal for this snapshot check.
+ return identity if os.name == "nt" else identity + (int(info.st_ctime_ns),)
+
+
+def _read_path_snapshot(source: Path) -> bytes:
+ """Read one bounded regular-file snapshot without following links or swaps."""
+ try:
+ before = os.lstat(source)
+ except FileNotFoundError:
+ raise ResourceExtractionError("resource path not found") from None
+ except OSError:
+ raise ResourceExtractionError("resource path could not be inspected") from None
+ if (
+ not stat.S_ISREG(before.st_mode)
+ or stat.S_ISLNK(before.st_mode)
+ or _is_reparse_point(before)
+ ):
+ raise ResourceExtractionError("resource path is not a regular file")
+ flags = (
+ os.O_RDONLY
+ | getattr(os, "O_BINARY", 0)
+ | getattr(os, "O_NOFOLLOW", 0)
+ )
+ descriptor = -1
+ try:
+ descriptor = os.open(source, flags)
+ opened = os.fstat(descriptor)
+ if (
+ not stat.S_ISREG(opened.st_mode)
+ or _is_reparse_point(opened)
+ or _snapshot_identity(opened) != _snapshot_identity(before)
+ ):
+ raise ResourceExtractionError("resource path changed before it was opened")
+ with os.fdopen(descriptor, "rb", closefd=True) as stream:
+ descriptor = -1
+ raw = stream.read(MAX_RESOURCE_BYTES + 1)
+ after = os.fstat(stream.fileno())
+ except ResourceExtractionError:
+ raise
+ except OSError:
+ raise ResourceExtractionError("resource path could not be read safely") from None
+ finally:
+ if descriptor >= 0:
+ try:
+ os.close(descriptor)
+ except OSError:
+ pass
+ if len(raw) > MAX_RESOURCE_BYTES:
+ raise ResourceExtractionError(
+ f"resource exceeds the {MAX_RESOURCE_BYTES}-byte extraction limit"
+ )
+ if _snapshot_identity(after) != _snapshot_identity(opened):
+ raise ResourceExtractionError("resource changed while it was being read")
+ return raw
+
+
def _title(text: str, fallback: str) -> str:
for line in (text or "").splitlines():
clean = line.strip().lstrip("#").strip()
@@ -143,16 +215,18 @@ def _docx_text(data: bytes) -> tuple[str, dict]:
"DOCX document.xml is too large after decompression"
)
raw = archive.read(info)
- except (KeyError, zipfile.BadZipFile) as exc:
- raise ResourceExtractionError(f"invalid DOCX: {exc}") from exc
+ except ResourceExtractionError:
+ raise
+ except Exception:
+ raise ResourceExtractionError("invalid DOCX archive") from None
if re.search(br" tuple[str, dict]:
def _pdf_text(data: bytes) -> tuple[str, dict, list[str]]:
try:
from pypdf import PdfReader
- except ImportError as exc:
+ except ImportError:
raise ResourceExtractionError(
"PDF extraction needs pypdf: pip install \"engraphis[documents]\""
- ) from exc
+ ) from None
try:
reader = PdfReader(io.BytesIO(data))
total_pages = len(reader.pages)
@@ -196,8 +270,10 @@ def _pdf_text(data: bytes) -> tuple[str, dict, list[str]]:
text_chars += separator_chars + len(page_text)
if text_truncated:
break
- except Exception as exc:
- raise ResourceExtractionError(f"PDF extraction failed: {exc}") from exc
+ except ResourceExtractionError:
+ raise
+ except Exception:
+ raise ResourceExtractionError("PDF extraction failed") from None
warnings = []
if total_pages > MAX_PDF_PAGES:
warnings.append(
@@ -220,22 +296,24 @@ def _image_text(data: bytes) -> tuple[str, dict]:
try:
from PIL import Image
import pytesseract
- except ImportError as exc:
+ except ImportError:
raise ResourceExtractionError(
"Image OCR needs Pillow + pytesseract and the local Tesseract binary: "
"pip install \"engraphis[documents]\""
- ) from exc
+ ) from None
try:
image = Image.open(io.BytesIO(data))
if image.width * image.height > MAX_IMAGE_PIXELS:
raise ResourceExtractionError(
f"image is too large for OCR ({image.width}x{image.height})"
)
- text = pytesseract.image_to_string(image)
+ text = str(pytesseract.image_to_string(image) or "").strip()
meta = {"width": image.width, "height": image.height, "format": image.format or ""}
- except Exception as exc:
- raise ResourceExtractionError(f"image OCR failed: {exc}") from exc
- return text.strip(), meta
+ except ResourceExtractionError:
+ raise
+ except Exception:
+ raise ResourceExtractionError("image OCR failed") from None
+ return text, meta
def _transcribe_path(path: str) -> tuple[str, dict]:
@@ -247,11 +325,11 @@ def _transcribe_path(path: str) -> tuple[str, dict]:
)
try:
from faster_whisper import WhisperModel
- except ImportError as exc:
+ except ImportError:
raise ResourceExtractionError(
"Audio/video transcription needs faster-whisper: "
"pip install \"engraphis[transcription]\""
- ) from exc
+ ) from None
try:
model = WhisperModel(
model_name,
@@ -260,13 +338,36 @@ def _transcribe_path(path: str) -> tuple[str, dict]:
)
segments, info = model.transcribe(path, vad_filter=True)
parts = [segment.text.strip() for segment in segments if segment.text.strip()]
- except Exception as exc:
- raise ResourceExtractionError(f"transcription failed: {exc}") from exc
- return "\n".join(parts), {
- "language": getattr(info, "language", ""),
- "language_probability": float(getattr(info, "language_probability", 0.0) or 0.0),
- "duration": float(getattr(info, "duration", 0.0) or 0.0),
- }
+ language = str(getattr(info, "language", "") or "")
+ language_probability = float(
+ getattr(info, "language_probability", 0.0) or 0.0
+ )
+ duration = float(getattr(info, "duration", 0.0) or 0.0)
+ if not math.isfinite(language_probability) or not math.isfinite(duration):
+ raise ValueError("non-finite transcription metadata")
+ metadata = {
+ "language": language,
+ "language_probability": language_probability,
+ "duration": duration,
+ }
+ except ResourceExtractionError:
+ raise
+ except Exception:
+ raise ResourceExtractionError("transcription failed") from None
+ return "\n".join(parts), metadata
+
+
+def _transcribe_bytes(data: bytes, suffix: str) -> tuple[str, dict]:
+ with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as temp:
+ temp.write(data)
+ temp_path = temp.name
+ try:
+ return _transcribe_path(temp_path)
+ finally:
+ try:
+ os.unlink(temp_path)
+ except OSError:
+ pass
class LocalResourceExtractor:
@@ -296,16 +397,7 @@ def extract_bytes(self, name: str, data: bytes) -> ResourceDocument:
text, extra = _image_text(raw)
kind = "image_ocr"
elif suffix in AUDIO_EXTENSIONS | VIDEO_EXTENSIONS:
- with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as temp:
- temp.write(raw)
- temp_path = temp.name
- try:
- text, extra = _transcribe_path(temp_path)
- finally:
- try:
- os.unlink(temp_path)
- except OSError:
- pass
+ text, extra = _transcribe_bytes(raw, suffix)
kind = "transcript"
else:
if suffix not in SUPPORTED_EXTENSIONS and _looks_binary(raw):
@@ -349,40 +441,8 @@ def extract_bytes(self, name: str, data: bytes) -> ResourceDocument:
def extract_path(self, path: str) -> ResourceDocument:
source = Path(path)
- if not source.exists():
- raise ResourceExtractionError(f"resource path not found: {path}")
- if not source.is_file():
- raise ResourceExtractionError(f"resource path is not a file: {path}")
- if source.stat().st_size > MAX_RESOURCE_BYTES:
- raise ResourceExtractionError(
- f"resource exceeds the {MAX_RESOURCE_BYTES}-byte extraction limit"
- )
- suffix = source.suffix.lower()
- if suffix in AUDIO_EXTENSIONS | VIDEO_EXTENSIONS:
- text, extra = _transcribe_path(str(source))
- stat = source.stat()
- digest = hashlib.sha256()
- with source.open("rb") as stream:
- for chunk in iter(lambda: stream.read(1024 * 1024), b""):
- digest.update(chunk)
- metadata = {
- "resource_name": source.name,
- "resource_extension": suffix,
- "resource_bytes": stat.st_size,
- "resource_sha256": digest.hexdigest(),
- **extra,
- }
- warnings = []
- if len(text) > MAX_EXTRACTED_TEXT_CHARS:
- warnings.append(
- f"extracted text truncated to {MAX_EXTRACTED_TEXT_CHARS} characters"
- )
- text = text[:MAX_EXTRACTED_TEXT_CHARS]
- return ResourceDocument(
- text=text.strip(), title=_title(text, source.stem), kind="transcript",
- media_type=_media_type(source.name), metadata=metadata, warnings=warnings,
- )
- return self.extract_bytes(source.name, source.read_bytes())
+ raw = _read_path_snapshot(source)
+ return self.extract_bytes(source.name, raw)
def get_resource_extractor():
diff --git a/engraphis/backends/sync_folder.py b/engraphis/backends/sync_folder.py
index 2978d33b..7b67a6d0 100644
--- a/engraphis/backends/sync_folder.py
+++ b/engraphis/backends/sync_folder.py
@@ -25,7 +25,7 @@
import secrets
import stat
from pathlib import Path
-from typing import Optional
+from typing import Iterator, Optional
MAX_BUNDLE_BYTES = 256 * 1024 * 1024 # skip absurdly large blobs before reading them
MAX_TOTAL_PULL_BYTES = 256 * 1024 * 1024
@@ -101,50 +101,70 @@ def push(self, name: str, data: bytes) -> None:
pass
raise
- def pull(self) -> list[tuple[str, bytes]]:
- """Return ``(name, data)`` for every bundle currently in the folder.
+ def pull(self) -> Iterator[tuple[str, bytes]]:
+ """Yield every readable bundle, then fail once if any candidate was omitted.
- Oversized files are skipped rather than read, bounding memory use if the
- shared folder ever holds a corrupt or hostile blob (defense in depth — the
- sync engine also caps row counts once the JSON is parsed)."""
- out: list[tuple[str, bytes]] = []
+ Safety caps remain strict, but a capped/raced/oversized object must make the
+ round observably incomplete instead of silently looking successful.
+ """
+ paths, incomplete = self._bundle_paths()
total = 0
- for p in self._bundle_paths():
- data = self._read_regular_bundle(p)
+ for path in paths:
+ data = self._read_regular_bundle(path)
if data is None:
+ incomplete = True
continue
if total + len(data) > MAX_TOTAL_PULL_BYTES:
+ incomplete = True
continue
- out.append((p.name, data))
total += len(data)
- return out
+ yield path.name, data
+ if incomplete:
+ raise RuntimeError("folder pull incomplete")
def list_names(self) -> list[str]:
- return [p.name for p in self._bundle_paths()]
+ paths, _ = self._bundle_paths()
+ return [path.name for path in paths]
- def _bundle_paths(self) -> list[Path]:
- """Return a deterministic, bounded set of regular bundle files.
+ def _bundle_paths(self) -> tuple[list[Path], bool]:
+ """Return a deterministic, bounded set plus an omission indicator.
- The shared folder is untrusted. Do not follow symlinks, and do not materialize an
- unbounded directory listing merely to sort it.
+ The shared folder is untrusted. Do not follow symlinks, and do not materialize
+ an unbounded directory listing merely to sort it.
"""
- def candidates():
+ incomplete = False
+
+ def candidates() -> Iterator[Path]:
+ nonlocal incomplete
try:
with os.scandir(self.root) as entries:
for index, entry in enumerate(entries):
if index >= MAX_DIRECTORY_ENTRIES:
+ incomplete = True
break
+ if _safe_name(entry.name) != entry.name:
+ continue
try:
if not entry.is_file(follow_symlinks=False):
+ incomplete = True
continue
except OSError:
+ incomplete = True
continue
- if _safe_name(entry.name) == entry.name:
- yield Path(entry.path)
+ yield Path(entry.path)
+ except FileNotFoundError:
+ return
except OSError:
+ incomplete = True
return
- return heapq.nsmallest(MAX_BUNDLES, candidates(), key=lambda path: path.name)
+ selected = heapq.nsmallest(
+ MAX_BUNDLES + 1, candidates(), key=lambda path: path.name
+ )
+ if len(selected) > MAX_BUNDLES:
+ incomplete = True
+ selected = selected[:MAX_BUNDLES]
+ return selected, incomplete
@staticmethod
def _read_regular_bundle(path: Path) -> Optional[bytes]:
diff --git a/engraphis/backends/vector_numpy.py b/engraphis/backends/vector_numpy.py
index a94af0fb..050a7251 100644
--- a/engraphis/backends/vector_numpy.py
+++ b/engraphis/backends/vector_numpy.py
@@ -83,7 +83,12 @@ def _top_k_indices(scores: np.ndarray, ids: list[str], k: int) -> list[int]:
class NumpyVectorIndex:
- """Store-backed brute-force cosine index. Vectors are stored normalized."""
+ """Store-backed brute-force cosine index.
+
+ Vectors are stored normalized. A zero vector has no cosine direction, so zero
+ queries return no hits and zero corpus rows are omitted rather than assigned an
+ arbitrary score. Native backends implement the same contract.
+ """
shares_store_vector_table = True
@@ -173,8 +178,9 @@ def search(self, vec: np.ndarray, k: int,
n = float(np.linalg.norm(q))
if not np.isfinite(n):
raise ValueError("query vector norm must be finite")
- if n > 0:
- q = q / n
+ if n == 0:
+ return []
+ q = q / n
ids, mat = self.store.vector_matrix(
filter, dim=self.dim if self.dim is not None else int(q.shape[0])
)
@@ -182,6 +188,12 @@ def search(self, vec: np.ndarray, k: int,
return []
# Store filters by both the declared dimension and blob width, so legacy
# rows from another embedding space cannot break this exact matrix scan.
+ nonzero = np.any(mat != 0, axis=1)
+ if not np.all(nonzero):
+ ids = [memory_id for memory_id, keep in zip(ids, nonzero) if keep]
+ mat = mat[nonzero]
+ if not ids:
+ return []
scores = mat @ q # cosine == dot for unit vectors
k = min(k, len(ids))
top = _top_k_indices(scores, ids, k)
diff --git a/engraphis/backends/vector_sqlitevec.py b/engraphis/backends/vector_sqlitevec.py
index 15b49114..9ba1e190 100644
--- a/engraphis/backends/vector_sqlitevec.py
+++ b/engraphis/backends/vector_sqlitevec.py
@@ -24,11 +24,14 @@
from engraphis.backends.embedder_deterministic import MAX_EMBEDDING_DIM
from engraphis.backends.vector_numpy import NumpyVectorIndex
from engraphis.core.interfaces import SearchFilter
-from engraphis.core.store import Store, memory_matches_filter
+from engraphis.core.store import Store
-
-def _visible(rec, flt: SearchFilter) -> bool:
- return memory_matches_filter(rec, flt)
+_INDEX_FORMAT_VERSION = 3
+_VISIBILITY_BATCH_SIZE = 8
+_COVERAGE_BATCH_SIZE = 500
+_DELETE_BATCH_SIZE = 500
+_COVERAGE_RTOL = 1e-6
+_COVERAGE_ATOL = 1e-7
def _cosine_from_l2(distance: float) -> float:
@@ -84,25 +87,199 @@ def _vector_query(vec: np.ndarray, dim: int) -> np.ndarray:
return values
+def _expected_native_vector(
+ value: object, dimension: int,
+) -> tuple[bool, Optional[np.ndarray]]:
+ """Return whether a canonical blob is valid and its expected vec0 vector.
+
+ Zero vectors deliberately have no native row: both backends define a zero query or
+ candidate as contributing no cosine hit. Every other canonical vector is normalized
+ exactly as :meth:`SqliteVecVectorIndex.upsert` normalizes it before comparison.
+ """
+ if not isinstance(value, (bytes, bytearray, memoryview)):
+ return False, None
+ try:
+ vector = np.frombuffer(bytes(value), dtype=np.float32)
+ except (TypeError, ValueError, BufferError):
+ return False, None
+ if vector.shape != (dimension,) or not np.isfinite(vector).all():
+ return False, None
+ normalized = vector.astype(np.float64, copy=True)
+ with np.errstate(over="ignore", invalid="ignore"):
+ norm = float(np.linalg.norm(normalized))
+ if not np.isfinite(norm):
+ return False, None
+ if norm == 0:
+ return True, None
+ normalized /= norm
+ return True, normalized.astype(np.float32)
+
+
+def _native_vector_matches(
+ value: object, expected: np.ndarray, dimension: int,
+) -> bool:
+ """Compare finite vec0 output while allowing float32 normalization roundoff."""
+ if not isinstance(value, (bytes, bytearray, memoryview)):
+ return False
+ try:
+ actual = np.frombuffer(value, dtype=np.float32)
+ except (TypeError, ValueError, BufferError):
+ return False
+ return bool(
+ actual.shape == (dimension,)
+ and np.isfinite(actual).all()
+ and np.allclose(
+ actual, expected, rtol=_COVERAGE_RTOL, atol=_COVERAGE_ATOL,
+ )
+ )
+
+
+def _native_mirror_covers_canonical(conn, dimension: int) -> bool:
+ """Whether vec0 exactly mirrors every same-dimension canonical vector.
+
+ Both scans are keyset-paginated and all counterpart lookups stay below SQLite's
+ conservative variable limit. The caller supplies the transaction: writable callers
+ hold ``BEGIN IMMEDIATE`` while publishing, and read-only callers hold one snapshot.
+ """
+ after_id = ""
+ while True:
+ canonical_rows = conn.execute(
+ "SELECT v.id, v.vector FROM mem_vectors v "
+ "JOIN memories m ON m.id=v.id "
+ "WHERE v.dim=? AND v.id>? ORDER BY v.id LIMIT ?",
+ (dimension, after_id, _COVERAGE_BATCH_SIZE),
+ ).fetchall()
+ if not canonical_rows:
+ break
+ ids = [str(row["id"]) for row in canonical_rows]
+ marks = ",".join("?" for _ in ids)
+ native_rows = conn.execute(
+ f"SELECT id, embedding FROM mem_vec_ann WHERE id IN ({marks})", ids,
+ ).fetchall()
+ native = {str(row["id"]): row["embedding"] for row in native_rows}
+ for row in canonical_rows:
+ memory_id = str(row["id"])
+ valid, expected = _expected_native_vector(row["vector"], dimension)
+ if not valid:
+ return False
+ if expected is None:
+ if memory_id in native:
+ return False
+ elif not _native_vector_matches(
+ native.get(memory_id), expected, dimension,
+ ):
+ return False
+ after_id = ids[-1]
+ if len(canonical_rows) < _COVERAGE_BATCH_SIZE:
+ break
+
+ # The forward scan proves that nothing canonical is missing or stale. This reverse
+ # scan rejects orphaned native rows and rows whose canonical vector became zero or
+ # changed dimension after another backend wrote the portable mirror.
+ after_id = ""
+ while True:
+ native_rows = conn.execute(
+ "SELECT id, embedding FROM mem_vec_ann "
+ "WHERE id>? ORDER BY id LIMIT ?",
+ (after_id, _COVERAGE_BATCH_SIZE),
+ ).fetchall()
+ if not native_rows:
+ break
+ ids = [str(row["id"]) for row in native_rows]
+ marks = ",".join("?" for _ in ids)
+ canonical_rows = conn.execute(
+ "SELECT v.id, v.vector FROM mem_vectors v "
+ "JOIN memories m ON m.id=v.id "
+ f"WHERE v.dim=? AND v.id IN ({marks})",
+ (dimension, *ids),
+ ).fetchall()
+ canonical = {str(row["id"]): row["vector"] for row in canonical_rows}
+ for row in native_rows:
+ memory_id = str(row["id"])
+ valid, expected = _expected_native_vector(
+ canonical.get(memory_id), dimension,
+ )
+ if (
+ not valid
+ or expected is None
+ or not _native_vector_matches(
+ row["embedding"], expected, dimension,
+ )
+ ):
+ return False
+ after_id = ids[-1]
+ if len(native_rows) < _COVERAGE_BATCH_SIZE:
+ break
+ return True
+
+
+def _native_index_status(conn, dimension: int):
+ """Return the live vec0 table row and whether its persisted state is current."""
+ existing = conn.execute(
+ "SELECT sql FROM sqlite_master "
+ "WHERE type='table' AND name='mem_vec_ann'"
+ ).fetchone()
+ state_table = conn.execute(
+ "SELECT 1 FROM sqlite_master "
+ "WHERE type='table' AND name='mem_vec_ann_state'"
+ ).fetchone()
+ state = (
+ conn.execute(
+ "SELECT format_version, dimension FROM mem_vec_ann_state "
+ "WHERE singleton=1"
+ ).fetchone()
+ if state_table is not None
+ else None
+ )
+ declared_dimension = None
+ if existing and existing["sql"]:
+ match = re.search(
+ r"FLOAT\s*\[\s*(\d+)\s*\]", existing["sql"], re.IGNORECASE
+ )
+ if match:
+ declared_dimension = int(match.group(1))
+ current = bool(
+ existing
+ and declared_dimension == dimension
+ and state
+ and int(state["format_version"]) == _INDEX_FORMAT_VERSION
+ and int(state["dimension"]) == dimension
+ )
+ if current:
+ current = _native_mirror_covers_canonical(conn, dimension)
+ return existing, current
+
+
+_READ_ONLY_STALE_ERROR = (
+ "read-only sqlite-vec index is unavailable or stale; open the database writable "
+ "once to rebuild it, or use vector_backend='numpy'"
+)
+
+
+
+
class SqliteVecVectorIndex:
"""Native exact KNN over embeddings using the sqlite-vec extension."""
shares_store_vector_table = False
+ shares_store_transaction = True
def __init__(self, store: Store, dim: int) -> None:
dimension = _validated_dimension(dim)
- # sqlite-vec is a loadable SQLite extension. SQLCipher ships a different
+ # sqlite-vec is a loadable SQLite extension. SQLCipher ships a different
# SQLite build, and loading both native libraries into one interpreter has
- # caused hard crashes rather than a normal Python exception. An `auto`
+ # caused hard crashes rather than a normal Python exception. An `auto`
# request below can safely use NumPy instead; an explicit sqlite-vec
# request gets this actionable error before any unsafe native call.
- if any(name == "sqlcipher3" or name.startswith("sqlcipher3.")
- for name in sys.modules):
+ if any(
+ name == "sqlcipher3" or name.startswith("sqlcipher3.")
+ for name in sys.modules
+ ):
raise RuntimeError(
"sqlite-vec cannot share a process with SQLCipher; use "
"vector_backend='numpy' or run the accelerated backend in a fresh process"
)
- sqlite_vec = importlib.import_module('sqlite_vec') # lazy optional extension
+ sqlite_vec = importlib.import_module("sqlite_vec") # lazy optional extension
self.store = store
self.dim = dimension
conn = store.conn
@@ -113,21 +290,94 @@ def __init__(self, store: Store, dim: int) -> None:
# Never leave extension loading enabled on a shared connection, including
# when the optional native load fails.
conn.enable_load_extension(False)
- existing = conn.execute(
- "SELECT sql FROM sqlite_master WHERE type='table' AND name='mem_vec_ann'"
- ).fetchone()
- if existing and existing["sql"]:
- match = re.search(r"FLOAT\s*\[\s*(\d+)\s*\]", existing["sql"], re.IGNORECASE)
- if match and int(match.group(1)) != dimension:
- raise ValueError(
- f"existing vector index dimension {match.group(1)} does not match "
- f"requested dimension {dimension}"
+
+ if store.read_only:
+ # Loading the extension only registers SQL functions. Never run DDL or
+ # update backend state against an immutable inspection Store.
+ owns_transaction = not conn.transaction_owned_by_current_thread()
+ try:
+ if owns_transaction:
+ conn.execute("BEGIN")
+ _, current = _native_index_status(conn, dimension)
+ except Exception:
+ raise RuntimeError(_READ_ONLY_STALE_ERROR) from None
+ finally:
+ if owns_transaction and conn.transaction_owned_by_current_thread():
+ conn.rollback()
+ if not current:
+ raise RuntimeError(_READ_ONLY_STALE_ERROR)
+ self.requires_rebuild = False
+ return
+
+ owns_transaction = not conn.transaction_owned_by_current_thread()
+ try:
+ if owns_transaction:
+ conn.execute("BEGIN IMMEDIATE")
+ conn.execute(
+ "CREATE TABLE IF NOT EXISTS mem_vec_ann_state ("
+ "singleton INTEGER PRIMARY KEY CHECK(singleton = 1), "
+ "format_version INTEGER NOT NULL, dimension INTEGER NOT NULL)"
+ )
+ existing, current = _native_index_status(conn, dimension)
+ # The composition root can inspect this capability before replaying the
+ # canonical mem_vectors mirror after a table creation or format change.
+ self.requires_rebuild = not current
+ if existing and not current:
+ # vec0 rows are a disposable mirror of canonical mem_vectors. Recreate
+ # on format/dimension changes; engine startup hydrates only after the
+ # canonical embedding-space gate is ready.
+ conn.execute("DROP TABLE mem_vec_ann")
+ conn.execute(
+ f"CREATE VIRTUAL TABLE IF NOT EXISTS mem_vec_ann USING vec0("
+ f"id TEXT PRIMARY KEY, embedding FLOAT[{dimension}])"
+ )
+ # DDL is not readiness: persist an incomplete marker until the engine has
+ # replayed every canonical row and calls ``mark_rebuild_complete``. A crash
+ # in that window must make read-only startup reject or fall back.
+ persisted_version = _INDEX_FORMAT_VERSION if current else 0
+ conn.execute(
+ "INSERT INTO mem_vec_ann_state("
+ "singleton, format_version, dimension) VALUES (1, ?, ?) "
+ "ON CONFLICT(singleton) DO UPDATE SET "
+ "format_version=excluded.format_version, dimension=excluded.dimension",
+ (persisted_version, dimension),
+ )
+ if owns_transaction and conn.transaction_owned_by_current_thread():
+ conn.commit()
+ except BaseException:
+ if owns_transaction and conn.transaction_owned_by_current_thread():
+ conn.rollback()
+ raise
+
+ def mark_rebuild_complete(self) -> None:
+ """Publish native readiness only after the canonical mirror is fully hydrated."""
+ if self.store.read_only:
+ raise RuntimeError("read-only sqlite-vec indexes cannot publish rebuild state")
+ conn = self.store.conn
+ if conn.transaction_owned_by_current_thread():
+ raise RuntimeError(
+ "sqlite-vec rebuild completion requires its own transaction"
+ )
+ try:
+ conn.execute("BEGIN IMMEDIATE")
+ if not _native_mirror_covers_canonical(conn, self.dim):
+ raise RuntimeError(
+ "sqlite-vec rebuild is incomplete; native mirror coverage differs "
+ "from canonical vectors"
)
- conn.execute(
- f"CREATE VIRTUAL TABLE IF NOT EXISTS mem_vec_ann USING vec0("
- f"id TEXT PRIMARY KEY, embedding FLOAT[{dimension}])"
- )
- conn.commit()
+ updated = conn.execute(
+ "UPDATE mem_vec_ann_state SET format_version=? "
+ "WHERE singleton=1 AND dimension=?",
+ (_INDEX_FORMAT_VERSION, self.dim),
+ )
+ if updated.rowcount != 1:
+ raise RuntimeError("sqlite-vec rebuild state is missing or stale")
+ conn.commit()
+ except BaseException:
+ if conn.transaction_owned_by_current_thread():
+ conn.rollback()
+ raise
+ self.requires_rebuild = False
def upsert(self, ids: list[str], vecs: np.ndarray, meta: Optional[list[dict]] = None,
*, commit: bool = True) -> None:
@@ -166,9 +416,15 @@ def upsert(self, ids: list[str], vecs: np.ndarray, meta: Optional[list[dict]] =
# database. Delete the batch first, then insert the replacement rows in
# the same transaction so restart hydration remains idempotent and
# failures roll back to the previous index state.
- marks = ",".join("?" for _ in ids)
- conn.execute(f"DELETE FROM mem_vec_ann WHERE id IN ({marks})", ids)
- for mid, vector in zip(ids, normalized):
+ for offset in range(0, count, _DELETE_BATCH_SIZE):
+ batch = ids[offset:offset + _DELETE_BATCH_SIZE]
+ marks = ",".join("?" for _ in batch)
+ conn.execute(
+ f"DELETE FROM mem_vec_ann WHERE id IN ({marks})", batch
+ )
+ for mid, vector, keep in zip(ids, normalized, nonzero):
+ if not keep:
+ continue
conn.execute(
"INSERT INTO mem_vec_ann(id, embedding) VALUES (?, ?)",
(mid, vector.tobytes()),
@@ -183,13 +439,17 @@ def upsert(self, ids: list[str], vecs: np.ndarray, meta: Optional[list[dict]] =
def delete(self, ids: list[str], *, commit: bool = True) -> None:
if not ids:
return
- marks = ",".join("?" for _ in ids)
conn = self.store.conn
owns_transaction = not conn.transaction_owned_by_current_thread()
try:
if owns_transaction:
conn.execute("BEGIN IMMEDIATE")
- conn.execute(f"DELETE FROM mem_vec_ann WHERE id IN ({marks})", ids)
+ for offset in range(0, len(ids), _DELETE_BATCH_SIZE):
+ batch = ids[offset:offset + _DELETE_BATCH_SIZE]
+ marks = ",".join("?" for _ in batch)
+ conn.execute(
+ f"DELETE FROM mem_vec_ann WHERE id IN ({marks})", batch
+ )
if commit and owns_transaction and conn.transaction_owned_by_current_thread():
conn.commit()
except BaseException:
@@ -197,18 +457,24 @@ def delete(self, ids: list[str], *, commit: bool = True) -> None:
conn.rollback()
raise
- def search(self, vec: np.ndarray, k: int,
- *, filter: Optional[SearchFilter] = None) -> list[tuple[str, float]]:
+ def search(
+ self,
+ vec: np.ndarray,
+ k: int,
+ *,
+ filter: Optional[SearchFilter] = None,
+ ) -> list[tuple[str, float]]:
k = _validated_k(k)
if k == 0:
return []
v = _vector_query(vec, self.dim)
with np.errstate(over="ignore", invalid="ignore"):
- n = float(np.linalg.norm(v))
- if not np.isfinite(n):
+ norm = float(np.linalg.norm(v))
+ if not np.isfinite(norm):
raise ValueError("query vector norm must be finite")
- if n > 0:
- v = v / n
+ if norm == 0:
+ return []
+ v = v / norm
total_row = self.store.conn.execute(
"SELECT COUNT(*) AS n FROM mem_vec_ann"
).fetchone()
@@ -216,8 +482,9 @@ def search(self, vec: np.ndarray, k: int,
if total == 0:
return []
# Fetch one look-ahead row so the common unique-distance case can prove the
- # kth boundary complete without issuing a second metadata hydration query.
+ # kth boundary complete without issuing another native KNN query.
limit = min(k + 1, total)
+ visibility: dict[str, bool] = {}
while True:
# The KNN cap uses vec0's explicit `k = ?` constraint, NOT `LIMIT ?`.
rows = self.store.conn.execute(
@@ -226,15 +493,23 @@ def search(self, vec: np.ndarray, k: int,
(v.tobytes(), int(limit)),
).fetchall()
# Match NumPy's live-record contract even for direct callers that omit a
- # filter; orphaned, closed, and future ANN rows must never leak.
+ # filter; orphaned, closed, and future ANN rows must never leak. Ask Store
+ # for IDs only so widening never hydrates large memory bodies.
effective_filter = filter if filter is not None else SearchFilter()
- visible_records = self.store.get_memories(row["id"] for row in rows)
- eligible = []
- for row in rows:
- rec = visible_records.get(row["id"])
- if rec is None or not _visible(rec, effective_filter):
- continue
- eligible.append(row)
+ unchecked = [
+ row["id"] for row in rows if row["id"] not in visibility
+ ]
+ for start in range(0, len(unchecked), _VISIBILITY_BATCH_SIZE):
+ batch = unchecked[start:start + _VISIBILITY_BATCH_SIZE]
+ visible_ids = self.store.visible_memory_ids(
+ batch, effective_filter
+ )
+ visibility.update(
+ (memory_id, memory_id in visible_ids) for memory_id in batch
+ )
+ eligible = [
+ row for row in rows if visibility.get(row["id"], False)
+ ]
eligible.sort(key=lambda row: (float(row["distance"]), str(row["id"])))
# vec0 may choose an unspecified subset when equal-distance rows straddle
@@ -252,14 +527,8 @@ def search(self, vec: np.ndarray, k: int,
)
if boundary_complete or exhausted:
selected = eligible[:k]
- # A zero query has no direction; retain the NumPy backend's
- # deterministic zero similarity rather than converting its
- # distance to the mathematically unrelated 0.5.
return [
- (
- row["id"],
- 0.0 if n == 0 else _cosine_from_l2(row["distance"]),
- )
+ (row["id"], _cosine_from_l2(row["distance"]))
for row in selected
]
# Filtered search widens geometrically until k visible hits are found.
diff --git a/engraphis/classic_assets/dashboard.css b/engraphis/classic_assets/dashboard.css
index 9d9046db..5b472f4d 100644
--- a/engraphis/classic_assets/dashboard.css
+++ b/engraphis/classic_assets/dashboard.css
@@ -756,3 +756,4 @@ progress.graph-degree[data-graph-node-type="person_or_concept"]::-webkit-progres
#graph-net[data-graph-style="cyber"]{background:linear-gradient(rgba(34,224,255,.055) 1px,transparent 1px) 0 0/30px 30px,linear-gradient(90deg,rgba(34,224,255,.055) 1px,transparent 1px) 0 0/30px 30px,radial-gradient(72% 60% at 50% 0%,rgba(255,62,165,.12),transparent 72%),#050810}
#graph-net.engraphis-graph-node-hover{cursor:pointer}
#graph-net:not(.engraphis-graph-node-hover){cursor:grab}
+.savings-hero{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;margin:8px 0}.savings-number{margin:0;font-variant-numeric:tabular-nums}.savings-unit{color:var(--text-dim);font-size:12px}.savings-rate{display:flex;flex-direction:column;align-items:flex-end;gap:2px;text-align:right}.savings-rate strong{color:var(--green);font-size:20px;line-height:1;font-variant-numeric:tabular-nums}.savings-rate span{color:var(--text-dim);font-size:11px}.savings-progress{display:block;width:100%;height:7px;margin:0 0 8px;appearance:none;border:0;border-radius:999px;background:var(--surface2)}.savings-progress::-webkit-progress-bar{border-radius:999px;background:var(--surface2)}.savings-progress::-webkit-progress-value{border-radius:999px;background:var(--green)}.savings-progress::-moz-progress-bar{border-radius:999px;background:var(--green)}.savings-summary{margin:0;color:var(--text-muted);font-size:12px}
diff --git a/engraphis/classic_assets/dashboard.js b/engraphis/classic_assets/dashboard.js
index a25bdc38..d1333fff 100644
--- a/engraphis/classic_assets/dashboard.js
+++ b/engraphis/classic_assets/dashboard.js
@@ -1,1728 +1,1757 @@
-const API=location.origin+'/api',TRIAL_DAYS=3;
-let WS=null, WORKSPACES=[], LIC=null;
-const TITLES={overview:'Overview',recall:'Recall',memories:'Memories','mem-editor':'Memory',proactive:'Proactive recall',why:'Why',timeline:'Timeline',audit:'Audit trail',graph:'Knowledge Graph',analytics:'Hosted Analytics',consolidate:'Consolidate',automation:'Hosted Automation',workspaces:'Workspaces',team:'Team Cloud',settings:'Settings'};
-const ROUTE_SECTIONS={overview:'Operate',recall:'Operate',memories:'Operate','mem-editor':'Operate',proactive:'Operate',why:'History',timeline:'History',audit:'History',graph:'Relations',analytics:'Relations',consolidate:'Engine',automation:'Engine',workspaces:'Operate',team:'Engine',settings:'Engine'};
-/* Per-view subtitle rendered in the topbar next to the view name. The body no longer
- repeats the view title/description — the topbar is the single source for both. */
-const DESCS={overview:'',recall:'Hybrid semantic + retention search over this workspace.',memories:'Browse and curate the memories in this workspace.','mem-editor':'',proactive:'What matters right now: importance × recency × retention, plus the last session handoff.',why:'The current answer to a question, with the facts it superseded.',timeline:'Bi-temporal history: what was believed, when it was valid, and when it was recorded.',audit:'Local governance history or content-free, tamper-evident receipts for sharing.',graph:'Explore entities and their sourced relationships from this workspace’s memories.',analytics:'Hosted growth, retention, decay, and entity insights for this workspace.',consolidate:'Run the free local consolidation tool manually; dry-run is the safe default.',automation:'Configure hosted Auto Consolidation and Auto Dreaming policies and review managed proposals.',workspaces:'Hard isolation boundaries. The active workspace receives new memories, imports, searches, and graph operations.',team:'Open the hosted organization dashboard for members, roles, named seats, and audit.',settings:'Local engine settings plus hosted-plan, sync, and managed-compute status.'};
-let CURRENT_VIEW='overview';
-/* Loaders that compute a live subtitle (e.g. Overview's counts) call this instead of
- writing to a body element, so the topbar stays authoritative. */
-function setViewDesc(v,text){DESCS[v]=text;if(CURRENT_VIEW===v){const s=document.getElementById('topbar-sub');if(s)s.textContent=text}}
-/* Plan pills are async: a slow /analytics response must not repaint the topbar after
- the user has already navigated elsewhere, so writes are dropped once the view changes. */
-function setPlanPill(el,text,cls){if(!el)return;const owner=el.id==='an-lock'?'analytics':'automation';if(CURRENT_VIEW!==owner)return;el.textContent=text;el.className=cls+' topbar-lock'}
-function esc(s){if(s===undefined||s===null)return '';return (''+s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,''')}
-/* Scheme-sanitize URLs interpolated into href attributes. esc() entity-encodes but
- does not block javascript:/data:/vbscript: URIs; a compromised or misconfigured
- license server could otherwise push a crafted upgrade_url that executes script
- when clicked. Non-http(s) URLs collapse to '#' so the link stays inert.
- Control characters are stripped before the scheme test: the WHATWG URL parser
- removes tab/newline/C0 before resolving a scheme, so a split scheme would re-form
- as javascript: after a naive scheme match had already failed. A scheme-less string
- that still contains ':' therefore fails closed rather than passing through. */
-function safeUrl(u){if(!u||typeof u!=='string')return '#';const s=u.replace(/[\u0000-\u001F\u007F]/g,'').trim();if(/^#/.test(s))return s;if((s[0]==='/'||s[0]==='\\')&&(s[1]==='/'||s[1]==='\\'))return '#';const m=s.match(/^([a-z][a-z0-9+.-]*):/i);if(!m)return /:/.test(s)?'#':s;if(/^(https?|mailto|ftps?)$/i.test(m[1]))return s;return '#'}
-function showAs(el,visible,mode){if(!el)return;el.classList.toggle('is-hidden',!visible);for(const name of ['is-flex','is-block','is-inline-flex'])el.classList.remove(name);if(visible&&mode)el.classList.add('is-'+mode)}
-function setTone(el,tone){if(!el)return;for(const name of ['tone-red','tone-green','tone-muted'])el.classList.remove(name);if(tone)el.classList.add('tone-'+tone)}
-function renderMd(md){try{return DOMPurify.sanitize(marked.parse(md||''))}catch(e){return esc(md)}}
-let TOAST_TIMER=null;
-function toast(m,t){const e=document.getElementById('toast');const kind=t||'ok';e.textContent=m;e.className='toast toast-'+kind+' show';e.setAttribute('role',kind==='err'?'alert':'status');e.setAttribute('aria-live',kind==='err'?'assertive':'polite');clearTimeout(TOAST_TIMER);TOAST_TIMER=setTimeout(()=>e.classList.remove('show'),3200)}
-function fmtRel(ts){if(!ts)return '';if(typeof ts==='string'){const parsed=Date.parse(ts);if(!Number.isFinite(parsed))return '';ts=parsed/1000}const s=Math.max(0,Date.now()/1000-ts);if(s<60)return 'just now';if(s<3600)return Math.floor(s/60)+'m ago';if(s<86400)return Math.floor(s/3600)+'h ago';if(s<2592000)return Math.floor(s/86400)+'d ago';return new Date(ts*1000).toISOString().slice(0,10)}
-async function api(p,o){o=o||{};o={...o,headers:{...(o.headers||{}),'X-Engraphis-Browser-Session':'1'}};const r=await fetch(p.startsWith('http')?p:API+p,o);const txt=await r.text();let d=null;try{d=txt?JSON.parse(txt):null}catch(e){}
- if(!r.ok){let msg=(d&&d.detail&&(d.detail.error||d.detail))||(d&&d.error)||(''+r.status);if(typeof msg!=='string')msg=JSON.stringify(msg);const err=new Error(msg);err.status=r.status;err.detail=d&&d.detail;throw err}return d}
-async function authenticateBrowser(){let token='';try{const fragment=new URLSearchParams(location.hash.slice(1));token=fragment.get('token')||'';if(token)history.replaceState(null,'',location.pathname+location.search)}catch(e){}if(!token)token=window.prompt('Enter this deployment’s ENGRAPHIS_API_TOKEN:')||'';if(!token)return false;try{await api('/auth/session',{method:'POST',body:JSON.stringify({token}),headers:{'Content-Type':'application/json'}});token='';return true}catch(e){token='';toast('Authentication failed: '+e.message,'err');return false}}
-let ACTION_RESOLVE=null,ACTION_SPEC=null;
-function actionDialog(spec){if(ACTION_RESOLVE)closeActionDialog(null);ACTION_SPEC=spec||{};document.getElementById('action-title').textContent=ACTION_SPEC.title||'Confirm action';document.getElementById('action-message').textContent=ACTION_SPEC.message||'';document.getElementById('action-error').textContent='';const fields=document.getElementById('action-fields');fields.replaceChildren();(ACTION_SPEC.fields||[]).forEach((f,i)=>{const wrap=document.createElement('div');wrap.className='field';const label=document.createElement('label');label.className='field-lbl';label.htmlFor='action-field-'+i;label.textContent=f.label||'Value';const input=f.options?document.createElement('select'):document.createElement(f.multiline?'textarea':'input');input.className=f.options?'select':'input';input.id='action-field-'+i;input.dataset.name=f.name||('field'+i);if(f.type==='password'){input.type='password';input.autocomplete='off'}else if(input.tagName==='INPUT'){input.type=f.type||'text'}if(f.placeholder)input.placeholder=f.placeholder;if(f.value!=null)input.value=f.value;if(f.required)input.required=true;(f.options||[]).forEach(opt=>{const option=document.createElement('option');option.value=opt.value==null?opt:opt.value;option.textContent=opt.label==null?opt:opt.label;input.appendChild(option)});wrap.append(label,input);fields.appendChild(wrap)});const submit=document.getElementById('action-submit');submit.textContent=ACTION_SPEC.submit||'Continue';submit.className='btn '+(ACTION_SPEC.danger?'btn-danger':'btn-primary');document.getElementById('action-overlay').classList.add('show');return new Promise(resolve=>{ACTION_RESOLVE=resolve})}
-function closeActionDialog(value){const resolve=ACTION_RESOLVE;ACTION_RESOLVE=null;ACTION_SPEC=null;document.getElementById('action-overlay').classList.remove('show');if(resolve)resolve(value)}
-function submitActionDialog(){const values={};for(const input of document.querySelectorAll('#action-fields input,#action-fields select,#action-fields textarea')){if(input.required&&!input.value.trim()){document.getElementById('action-error').textContent='Complete all required fields.';input.focus();return}values[input.dataset.name]=input.value}closeActionDialog(values)}
-async function confirmAction(title,message,submit,danger){const result=await actionDialog({title,message,submit:submit||'Continue',danger:!!danger});return result!==null}
-async function textAction(title,message,label,value,options){const result=await actionDialog({title,message,submit:(options&&options.submit)||'Save',danger:!!(options&&options.danger),fields:[{name:'value',label,value:value||'',required:!(options&&options.optional),multiline:!!(options&&options.multiline),options:(options&&options.options)||[]} ]});return result===null?null:result.value}
-document.getElementById('action-close').addEventListener('click',()=>closeActionDialog(null));
-document.getElementById('action-cancel').addEventListener('click',()=>closeActionDialog(null));
-document.getElementById('action-submit').addEventListener('click',submitActionDialog);
-document.getElementById('action-overlay').addEventListener('click',event=>{if(event.target===event.currentTarget)closeActionDialog(null)});
-let DIALOG_ACTIVE=null;
-const DIALOG_RETURN=new WeakMap();
-function toggleMobileNav(force){const app=document.querySelector('.app'),btn=document.getElementById('mobile-nav-toggle'),side=document.getElementById('app-sidebar'),main=document.getElementById('main-content');if(!app||!btn||!side||!main)return;const mobile=matchMedia('(max-width:768px)').matches;const open=mobile&&(force===undefined?!app.classList.contains('mobile-nav-open'):!!force);app.classList.toggle('mobile-nav-open',open);btn.setAttribute('aria-expanded',String(open));btn.setAttribute('aria-label',open?'Close navigation':'Open navigation');side.inert=mobile&&!open;side.setAttribute('aria-hidden',String(mobile&&!open));main.inert=mobile&&open;if(open){const active=document.querySelector('.nav-item.active');setTimeout(()=>{if(active)active.focus()},0)}}
-function closeMobileNav(returnFocus){toggleMobileNav(false);if(returnFocus)setTimeout(()=>document.getElementById('mobile-nav-toggle').focus(),0)}
-function syncMobileNavMode(){toggleMobileNav(document.querySelector('.app').classList.contains('mobile-nav-open'))}
-window.addEventListener('resize',syncMobileNavMode);
-syncMobileNavMode();
-function dialogChanged(ov){const open=ov.classList.contains('show');ov.setAttribute('aria-hidden',String(!open));if(open){if(DIALOG_ACTIVE!==ov){DIALOG_RETURN.set(ov,document.activeElement);DIALOG_ACTIVE=ov;setTimeout(()=>{const first=ov.querySelector('.mm-body input:not([disabled]),.mm-body select:not([disabled]),.mm-body textarea:not([disabled])')||ov.querySelector('button:not([disabled]),[href],[tabindex="0"]');if(first)first.focus()},0)}}else if(DIALOG_ACTIVE===ov){DIALOG_ACTIVE=null;const back=DIALOG_RETURN.get(ov);if(back&&document.contains(back)&&back!==document.body)back.focus();else{const heading=document.getElementById('topbar-title');if(heading){heading.tabIndex=-1;heading.focus()}}}}
-function trapDialog(e){const ov=DIALOG_ACTIVE;if(!ov||e.key!=='Tab')return;const els=Array.from(ov.querySelectorAll('input:not([disabled]),select:not([disabled]),textarea:not([disabled]),button:not([disabled]),[href],[tabindex="0"]')).filter(x=>x.offsetParent!==null);if(!els.length){e.preventDefault();return}const first=els[0],last=els[els.length-1];if(e.shiftKey&&document.activeElement===first){e.preventDefault();last.focus()}else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first.focus()}}
-function ensureDialogFocus(){if(!DIALOG_ACTIVE||DIALOG_ACTIVE.contains(document.activeElement))return;requestAnimationFrame(()=>{if(!DIALOG_ACTIVE)return;const first=DIALOG_ACTIVE.querySelector('.mm-body input:not([disabled]),.mm-body select:not([disabled]),.mm-body textarea:not([disabled])')||DIALOG_ACTIVE.querySelector('button:not([disabled]),[href],[tabindex="0"]');if(first)first.focus()})}
-function controlName(el){const named={recall_q:'Recall query',recall_k:'Number of recall results',mem_q:'Memory filter',why_q:'Question to explain',tl_q:'Timeline topic',graph_repo_filter:'Repository filter',graph_search:'Find graph entity',import_path:'Local import path',import_pattern:'File pattern',code_repo:'Repository name',code_root:'Repository path',postgres_dsn:'PostgreSQL DSN',postgres_repo:'PostgreSQL repository scope'};return named[(el.id||'').replace(/-/g,'_')]||(el.placeholder||'').replace(/[…*]+$/,'').trim()||(el.id||el.type||'control').replace(/[-_]+/g,' ')}
-function enhanceUi(root){const scope=root&&root.querySelectorAll?root:document;scope.querySelectorAll('.nav-item').forEach(el=>{el.setAttribute('role','link');el.tabIndex=0;el.setAttribute('aria-current',el.classList.contains('active')?'page':'false')});scope.querySelectorAll('button:not([type])').forEach(el=>el.type='button');scope.querySelectorAll('input,select,textarea').forEach((el,i)=>{if(el.labels&&el.labels.length)return;const field=el.closest('.field'),slider=el.closest('.gslider');const label=(field&&field.querySelector('label'))||(slider&&slider.querySelector('label'));if(label){if(!el.id)el.id='ui-control-'+i+'-'+Date.now();label.htmlFor=el.id}else if(!el.getAttribute('aria-label')&&!el.getAttribute('aria-labelledby'))el.setAttribute('aria-label',controlName(el))});scope.querySelectorAll('.recall-card,.gtop-row,.ep-edge,.mem-card').forEach(el=>{if(!el.hasAttribute('tabindex'))el.tabIndex=0;if(!el.hasAttribute('role'))el.setAttribute('role','button');if(!el.getAttribute('aria-label')){const txt=el.querySelector('.recall-title,.gtop-name,.mem-card-title')||el;el.setAttribute('aria-label',(txt.textContent||'Open item').trim())}});scope.querySelectorAll('.vault-card').forEach(el=>{el.setAttribute('role','group');const name=el.querySelector('.vault-card-name');if(name&&!el.getAttribute('aria-label'))el.setAttribute('aria-label','Workspace '+name.textContent.trim())});scope.querySelectorAll('.spinner').forEach(el=>{el.setAttribute('role','status');el.setAttribute('aria-label','Loading')});document.querySelectorAll('.status-region').forEach(el=>{const busy=!!el.querySelector('.spinner');el.setAttribute('aria-busy',String(busy));const text=(el.textContent||'').toLowerCase();el.setAttribute('role',/\b(error|failed|invalid|unavailable|offline)\b/.test(text)?'alert':'status')});document.querySelectorAll('.mm-overlay').forEach(dialogChanged)}
-const enhanceUiBase=enhanceUi;
-enhanceUi=function(root){
- enhanceUiBase(root);
- const scope=root&&root.querySelectorAll?root:document;
- scope.querySelectorAll('.nav-item').forEach(el=>{
- if(el.tagName==='BUTTON')el.removeAttribute('role');
- else{el.setAttribute('role','button');el.tabIndex=0}
- });
-};
-function enhanceDynamicUi(){document.querySelectorAll('.vault-card-name,.tl-item.clickable,#mm-body>div[data-onclick]').forEach(el=>{el.setAttribute('role','button');el.tabIndex=0});document.querySelectorAll('.mem-card').forEach(el=>el.setAttribute('aria-describedby','memory-reorder-help'));document.querySelectorAll('.card-head:not(h1):not(h2):not(h3)').forEach(el=>{el.setAttribute('role','heading');el.setAttribute('aria-level','2')});['sync-status','llm-test-result','au-result','tok-created'].forEach(id=>{const el=document.getElementById(id);if(el){el.setAttribute('role','status');el.setAttribute('aria-live','polite')}})}
-function enhanceAdjacentLabels(){document.querySelectorAll('input,select,textarea').forEach((el,i)=>{if(el.labels&&el.labels.length)return;const direct=el.previousElementSibling,parent=el.parentElement&&el.parentElement.previousElementSibling,label=(direct&&direct.matches('label')&&direct)||(parent&&parent.matches('label')&&parent);if(!label)return;if(!el.id)el.id='ui-adjacent-'+i+'-'+Date.now();label.htmlFor=el.id;el.removeAttribute('aria-label')})}
-function enhanceDecorativeIcons(){document.querySelectorAll('.nav-icon,.brand-mark,.dropzone-icon,.empty-icon').forEach(el=>el.setAttribute('aria-hidden','true'))}
-function enhanceExplorerSemantics(){document.querySelectorAll('#graph-entity-list [role="listitem"],#graph-relation-list [role="listitem"]').forEach(el=>el.removeAttribute('role'))}
-function enhanceStatusContainers(){document.querySelectorAll('.status-region,#ov-analytics,#ed-history,#sync-body,#lic-body,#tokens-body,#llm-body,#au-result').forEach(el=>{const visible=el.getClientRects().length>0;el.setAttribute('aria-live','polite');el.setAttribute('aria-busy',String(visible&&(!!el.querySelector('.spinner')||/^\s*Loading/.test(el.textContent||''))))})}
-// Coalesce the enhancement sweeps to at most one per frame. Each one re-queries the
-// whole document, and the observer fires on every keystroke in graph search and every
-// drag-over boundary crossing, so running them per mutation batch was quadratic-ish on
-// exactly the interactions that need to stay responsive. Dialog bookkeeping stays
-// immediate — it drives focus, which cannot wait a frame.
-let UI_SWEEP=0;
-function scheduleUiSweep(){if(UI_SWEEP)return;UI_SWEEP=requestAnimationFrame(()=>{UI_SWEEP=0;enhanceUi(document);enhanceAdjacentLabels();enhanceDynamicUi();enhanceDecorativeIcons();enhanceStatusContainers();enhanceExplorerSemantics();ensureDialogFocus()})}
-const UI_OBSERVER=new MutationObserver(records=>{records.forEach(r=>{if(r.type==='attributes'&&r.target.classList.contains('mm-overlay'))dialogChanged(r.target)});scheduleUiSweep()});
-enhanceUi(document);
-enhanceAdjacentLabels();
-enhanceDynamicUi();
-enhanceDecorativeIcons();
-enhanceStatusContainers();
-enhanceExplorerSemantics();
-document.querySelectorAll('.view').forEach(el=>el.setAttribute('aria-hidden',String(!el.classList.contains('active'))));
-UI_OBSERVER.observe(document.body,{subtree:true,childList:true,attributes:true,attributeFilter:['class']});
-document.addEventListener('keydown',e=>{trapDialog(e);const t=e.target;if(t.matches('.mem-card')){if(e.altKey&&(e.key==='ArrowUp'||e.key==='ArrowDown')){e.preventDefault();memKeyboardMove(t.dataset.id,e.key==='ArrowUp'?-1:1);return}if(e.key==='Enter'||e.key===' '){e.preventDefault();openMem(t.dataset.id);return}}if((e.key==='Enter'||e.key===' ')&&t.matches('[role=button],.nav-item')){if(e.key===' '||t.matches('.nav-item'))e.preventDefault();t.click()}});
-
-/* theme */
-const THEMES=[['dark','Dark','#15181e','#8c83e8'],['light','Light','#fbfbfc','#5547b8'],['midnight','Midnight','#111b2d','#79a6ef'],['solarized','Solarized','#073642','#58a7d8'],['sepia','Sepia','#f8f2e4','#925420'],['matrix','Matrix','#000403','#3ce072']];
-const THEME_GLYPH={dark:'◑',light:'☀',midnight:'☾',solarized:'◐',sepia:'❂',matrix:'▦'};
-function applyTheme(t){if(!THEME_GLYPH[t])t='dark';document.body.setAttribute('data-theme',t);window.__theme=t;try{localStorage.setItem('engraphis-theme',t)}catch(e){}const b=document.getElementById('theme-btn');if(b){b.textContent=THEME_GLYPH[t];b.setAttribute('aria-label','Change theme. Current theme: '+t)}const sel=document.getElementById('theme-select');if(sel&&sel.value!==t)sel.value=t;renderThemeMenu();if(typeof graphRecolor==='function')graphRecolor()}
-function renderThemeMenu(){const m=document.getElementById('theme-menu');if(!m)return;m.innerHTML=THEMES.map(x=>{const on=(x[0]===window.__theme);return ``}).join('')}
-function toggleThemeMenu(e){if(e)e.stopPropagation();const m=document.getElementById('theme-menu'),b=document.getElementById('theme-btn');if(!m)return;const show=!m.classList.contains('is-open');m.classList.toggle('is-open',show);if(b)b.setAttribute('aria-expanded',String(show));if(show){renderThemeMenu();setTimeout(()=>{const first=m.querySelector('button');if(first)first.focus();document.addEventListener('click',closeThemeMenu)},0)}}
-function closeThemeMenu(){const m=document.getElementById('theme-menu'),b=document.getElementById('theme-btn');if(m)m.classList.remove('is-open');if(b)b.setAttribute('aria-expanded','false');document.removeEventListener('click',closeThemeMenu)}
-function pickTheme(t){applyTheme(t);closeThemeMenu()}
-function toggleTheme(){const ids=THEMES.map(x=>x[0]);const i=ids.indexOf(window.__theme);pickTheme(ids[(i+1)%ids.length])}
-function initTheme(){let saved=null;try{saved=localStorage.getItem('engraphis-theme')}catch(e){}applyTheme(saved||window.__theme||'dark')}
-function selectDashboard(value){window.location.assign(value==='classic'?'/classic':'/')}
-function initDashboard(){const select=document.getElementById('dashboard-select');if(select)select.value=location.pathname==='/classic'?'classic':'primary'}
-
-/* nav */
-function selectView(v){
- document.querySelectorAll('.nav-item').forEach(n=>{const active=n.dataset.view===v;n.classList.toggle('active',active);n.setAttribute('aria-current',active?'page':'false')});
- document.querySelectorAll('.view').forEach(el=>{const active=el.id==='view-'+v;el.classList.toggle('active',active);el.setAttribute('aria-hidden',String(!active))});
- CURRENT_VIEW=v;
- const heading=document.getElementById('topbar-title');
- heading.textContent=TITLES[v]||v;
- const section=document.getElementById('route-section');
- if(section)section.textContent=ROUTE_SECTIONS[v]||'Operate';
- const sub=document.getElementById('topbar-sub');
- if(sub)sub.textContent=DESCS[v]||'';
- /* Plan pills live in the topbar; clear them on navigation so only the active
- view's loader can repopulate one. */
- ['an-lock','au-lock'].forEach(id=>{const p=document.getElementById(id);if(p){p.textContent='';p.className='pill pill-muted topbar-lock'}});
- if(v==='graph')graphEngineResume();else graphEnginePause();
- closeMobileNav();
- (LOADERS[v]||function(){})();
- heading.tabIndex=-1;
- setTimeout(()=>heading.focus(),0);
-}
-function navTo(v){selectView(v)}
-document.querySelectorAll('.nav-item').forEach(it=>it.addEventListener('click',()=>selectView(it.dataset.view)));
-
-/* workspace */
-function setWS(name){
- WS=name;
- const shown=name||'—',sw=document.getElementById('vault-switcher'),top=document.getElementById('topbar-workspace');
- document.getElementById('ws-name').textContent=shown;
- if(top)top.textContent=shown;
- if(sw)sw.setAttribute('aria-label',name?'Choose active workspace. Current workspace: '+name:'Choose active workspace');
-}
-async function loadWorkspaceList(){const d=await api('/workspaces');WORKSPACES=d.workspaces||[];if(!WS&&WORKSPACES.length){WORKSPACES.sort((a,b)=>(b.memories||0)-(a.memories||0));setWS(WORKSPACES[0].name)}}
-
+const API=location.origin+'/api',TRIAL_DAYS=3;
+let WS=null, WORKSPACES=[], LIC=null;
+const TITLES={overview:'Overview',recall:'Recall',memories:'Memories','mem-editor':'Memory',proactive:'Proactive recall',why:'Why',timeline:'Timeline',audit:'Audit trail',graph:'Knowledge Graph',analytics:'Hosted Analytics',health:'Memory Health',consolidate:'Consolidate',automation:'Hosted Automation',workspaces:'Workspaces',team:'Team Cloud',settings:'Settings'};
+const ROUTE_SECTIONS={overview:'Operate',recall:'Operate',memories:'Operate','mem-editor':'Operate',proactive:'Operate',why:'History',timeline:'History',audit:'History',graph:'Relations',analytics:'Relations',health:'Relations',consolidate:'Engine',automation:'Engine',workspaces:'Operate',team:'Engine',settings:'Engine'};
+/* Per-view subtitle rendered in the topbar next to the view name. The body no longer
+ repeats the view title/description — the topbar is the single source for both. */
+const DESCS={overview:'',recall:'Hybrid semantic + retention search over this workspace.',memories:'Browse and curate the memories in this workspace.','mem-editor':'',proactive:'What matters right now: importance × recency × retention, plus the last session handoff.',why:'The current answer to a question, with the facts it superseded.',timeline:'Bi-temporal history: what was believed, when it was valid, and when it was recorded.',audit:'Local governance history or content-free, tamper-evident receipts for sharing.',graph:"Explore entities and their sourced relationships from this workspace's memories.",analytics:'Hosted growth, retention, decay, and entity insights for this workspace.',health:'Memory lifecycle metrics: age distribution, decay rates, and staleness.',consolidate:'Run the free local consolidation tool manually; dry-run first to preview changes.',automation:'Hosted maintenance policy: consolidate, dream, and review on a schedule.',workspaces:'Switch between workspaces or create a new one.',team:'Hosted organizations, roles, and seats for Engraphis Cloud.',settings:'Theme, update, and connection settings.'};
+let CURRENT_VIEW='overview';
+/* Loaders that compute a live subtitle (e.g. Overview's counts) call this instead of
+ writing to a body element, so the topbar stays authoritative. */
+function setViewDesc(v,text){DESCS[v]=text;if(CURRENT_VIEW===v){const s=document.getElementById('topbar-sub');if(s)s.textContent=text}}
+/* Plan pills are async: a slow /analytics response must not repaint the topbar after
+ the user has already navigated elsewhere, so writes are dropped once the view changes. */
+function setPlanPill(el,text,cls){if(!el)return;const owner=el.id==='an-lock'?'analytics':'automation';if(CURRENT_VIEW!==owner)return;el.textContent=text;el.className=cls+' topbar-lock'}
+function esc(s){if(s===undefined||s===null)return '';return (''+s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,''')}
+/* Scheme-sanitize URLs interpolated into href attributes. esc() entity-encodes but
+ does not block javascript:/data:/vbscript: URIs; a compromised or misconfigured
+ license server could otherwise push a crafted upgrade_url that executes script
+ when clicked. Non-http(s) URLs collapse to '#' so the link stays inert.
+ Control characters are stripped before the scheme test: the WHATWG URL parser
+ removes tab/newline/C0 before resolving a scheme, so a split scheme would re-form
+ as javascript: after a naive scheme match had already failed. A scheme-less string
+ that still contains ':' therefore fails closed rather than passing through. */
+function safeUrl(u){if(!u||typeof u!=='string')return '#';const s=u.replace(/[\u0000-\u001F\u007F]/g,'').trim();if(/^#/.test(s))return s;if((s[0]==='/'||s[0]==='\\')&&(s[1]==='/'||s[1]==='\\'))return '#';const m=s.match(/^([a-z][a-z0-9+.-]*):/i);if(!m)return /:/.test(s)?'#':s;if(/^(https?|mailto|ftps?)$/i.test(m[1]))return s;return '#'}
+function showAs(el,visible,mode){if(!el)return;el.classList.toggle('is-hidden',!visible);for(const name of ['is-flex','is-block','is-inline-flex'])el.classList.remove(name);if(visible&&mode)el.classList.add('is-'+mode)}
+function setTone(el,tone){if(!el)return;for(const name of ['tone-red','tone-green','tone-muted'])el.classList.remove(name);if(tone)el.classList.add('tone-'+tone)}
+function renderMd(md){try{return DOMPurify.sanitize(marked.parse(md||''))}catch(e){return esc(md)}}
+let TOAST_TIMER=null;
+function toast(m,t){const e=document.getElementById('toast');const kind=t||'ok';e.textContent=m;e.className='toast toast-'+kind+' show';e.setAttribute('role',kind==='err'?'alert':'status');e.setAttribute('aria-live',kind==='err'?'assertive':'polite');clearTimeout(TOAST_TIMER);TOAST_TIMER=setTimeout(()=>e.classList.remove('show'),3200)}
+function fmtRel(ts){if(!ts)return '';if(typeof ts==='string'){const parsed=Date.parse(ts);if(!Number.isFinite(parsed))return '';ts=parsed/1000}const s=Math.max(0,Date.now()/1000-ts);if(s<60)return 'just now';if(s<3600)return Math.floor(s/60)+'m ago';if(s<86400)return Math.floor(s/3600)+'h ago';if(s<2592000)return Math.floor(s/86400)+'d ago';return new Date(ts*1000).toISOString().slice(0,10)}
+async function api(p,o){o=o||{};o={...o,headers:{...(o.headers||{}),'X-Engraphis-Browser-Session':'1'}};const r=await fetch(p.startsWith('http')?p:API+p,o);const txt=await r.text();let d=null;try{d=txt?JSON.parse(txt):null}catch(e){}
+ if(!r.ok){let msg=(d&&d.detail&&(d.detail.error||d.detail))||(d&&d.error)||(''+r.status);if(typeof msg!=='string')msg=JSON.stringify(msg);const err=new Error(msg);err.status=r.status;err.detail=d&&d.detail;throw err}return d}
+async function authenticateBrowser(){let token='';try{const fragment=new URLSearchParams(location.hash.slice(1));token=fragment.get('token')||'';if(token)history.replaceState(null,'',location.pathname+location.search)}catch(e){}if(!token)token=window.prompt('Enter this deployment’s ENGRAPHIS_API_TOKEN:')||'';if(!token)return false;try{await api('/auth/session',{method:'POST',body:JSON.stringify({token}),headers:{'Content-Type':'application/json'}});token='';return true}catch(e){token='';toast('Authentication failed: '+e.message,'err');return false}}
+let ACTION_RESOLVE=null,ACTION_SPEC=null;
+function actionDialog(spec){if(ACTION_RESOLVE)closeActionDialog(null);ACTION_SPEC=spec||{};document.getElementById('action-title').textContent=ACTION_SPEC.title||'Confirm action';document.getElementById('action-message').textContent=ACTION_SPEC.message||'';document.getElementById('action-error').textContent='';const fields=document.getElementById('action-fields');fields.replaceChildren();(ACTION_SPEC.fields||[]).forEach((f,i)=>{const wrap=document.createElement('div');wrap.className='field';const label=document.createElement('label');label.className='field-lbl';label.htmlFor='action-field-'+i;label.textContent=f.label||'Value';const input=f.options?document.createElement('select'):document.createElement(f.multiline?'textarea':'input');input.className=f.options?'select':'input';input.id='action-field-'+i;input.dataset.name=f.name||('field'+i);if(f.type==='password'){input.type='password';input.autocomplete='off'}else if(input.tagName==='INPUT'){input.type=f.type||'text'}if(f.placeholder)input.placeholder=f.placeholder;if(f.value!=null)input.value=f.value;if(f.required)input.required=true;(f.options||[]).forEach(opt=>{const option=document.createElement('option');option.value=opt.value==null?opt:opt.value;option.textContent=opt.label==null?opt:opt.label;input.appendChild(option)});wrap.append(label,input);fields.appendChild(wrap)});const submit=document.getElementById('action-submit');submit.textContent=ACTION_SPEC.submit||'Continue';submit.className='btn '+(ACTION_SPEC.danger?'btn-danger':'btn-primary');document.getElementById('action-overlay').classList.add('show');return new Promise(resolve=>{ACTION_RESOLVE=resolve})}
+function closeActionDialog(value){const resolve=ACTION_RESOLVE;ACTION_RESOLVE=null;ACTION_SPEC=null;document.getElementById('action-overlay').classList.remove('show');if(resolve)resolve(value)}
+function submitActionDialog(){const values={};for(const input of document.querySelectorAll('#action-fields input,#action-fields select,#action-fields textarea')){if(input.required&&!input.value.trim()){document.getElementById('action-error').textContent='Complete all required fields.';input.focus();return}values[input.dataset.name]=input.value}closeActionDialog(values)}
+async function confirmAction(title,message,submit,danger){const result=await actionDialog({title,message,submit:submit||'Continue',danger:!!danger});return result!==null}
+async function textAction(title,message,label,value,options){const result=await actionDialog({title,message,submit:(options&&options.submit)||'Save',danger:!!(options&&options.danger),fields:[{name:'value',label,value:value||'',required:!(options&&options.optional),multiline:!!(options&&options.multiline),options:(options&&options.options)||[]} ]});return result===null?null:result.value}
+document.getElementById('action-close').addEventListener('click',()=>closeActionDialog(null));
+document.getElementById('action-cancel').addEventListener('click',()=>closeActionDialog(null));
+document.getElementById('action-submit').addEventListener('click',submitActionDialog);
+document.getElementById('action-overlay').addEventListener('click',event=>{if(event.target===event.currentTarget)closeActionDialog(null)});
+let DIALOG_ACTIVE=null;
+const DIALOG_RETURN=new WeakMap();
+function toggleMobileNav(force){const app=document.querySelector('.app'),btn=document.getElementById('mobile-nav-toggle'),side=document.getElementById('app-sidebar'),main=document.getElementById('main-content');if(!app||!btn||!side||!main)return;const mobile=matchMedia('(max-width:768px)').matches;const open=mobile&&(force===undefined?!app.classList.contains('mobile-nav-open'):!!force);app.classList.toggle('mobile-nav-open',open);btn.setAttribute('aria-expanded',String(open));btn.setAttribute('aria-label',open?'Close navigation':'Open navigation');side.inert=mobile&&!open;side.setAttribute('aria-hidden',String(mobile&&!open));main.inert=mobile&&open;if(open){const active=document.querySelector('.nav-item.active');setTimeout(()=>{if(active)active.focus()},0)}}
+function closeMobileNav(returnFocus){toggleMobileNav(false);if(returnFocus)setTimeout(()=>document.getElementById('mobile-nav-toggle').focus(),0)}
+function syncMobileNavMode(){toggleMobileNav(document.querySelector('.app').classList.contains('mobile-nav-open'))}
+window.addEventListener('resize',syncMobileNavMode);
+syncMobileNavMode();
+function dialogChanged(ov){const open=ov.classList.contains('show');ov.setAttribute('aria-hidden',String(!open));if(open){if(DIALOG_ACTIVE!==ov){DIALOG_RETURN.set(ov,document.activeElement);DIALOG_ACTIVE=ov;setTimeout(()=>{const first=ov.querySelector('.mm-body input:not([disabled]),.mm-body select:not([disabled]),.mm-body textarea:not([disabled])')||ov.querySelector('button:not([disabled]),[href],[tabindex="0"]');if(first)first.focus()},0)}}else if(DIALOG_ACTIVE===ov){DIALOG_ACTIVE=null;const back=DIALOG_RETURN.get(ov);if(back&&document.contains(back)&&back!==document.body)back.focus();else{const heading=document.getElementById('topbar-title');if(heading){heading.tabIndex=-1;heading.focus()}}}}
+function trapDialog(e){const ov=DIALOG_ACTIVE;if(!ov||e.key!=='Tab')return;const els=Array.from(ov.querySelectorAll('input:not([disabled]),select:not([disabled]),textarea:not([disabled]),button:not([disabled]),[href],[tabindex="0"]')).filter(x=>x.offsetParent!==null);if(!els.length){e.preventDefault();return}const first=els[0],last=els[els.length-1];if(e.shiftKey&&document.activeElement===first){e.preventDefault();last.focus()}else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first.focus()}}
+function ensureDialogFocus(){if(!DIALOG_ACTIVE||DIALOG_ACTIVE.contains(document.activeElement))return;requestAnimationFrame(()=>{if(!DIALOG_ACTIVE)return;const first=DIALOG_ACTIVE.querySelector('.mm-body input:not([disabled]),.mm-body select:not([disabled]),.mm-body textarea:not([disabled])')||DIALOG_ACTIVE.querySelector('button:not([disabled]),[href],[tabindex="0"]');if(first)first.focus()})}
+function controlName(el){const named={recall_q:'Recall query',recall_k:'Number of recall results',mem_q:'Memory filter',why_q:'Question to explain',tl_q:'Timeline topic',graph_repo_filter:'Repository filter',graph_search:'Find graph entity',import_path:'Local import path',import_pattern:'File pattern',code_repo:'Repository name',code_root:'Repository path',postgres_dsn:'PostgreSQL DSN',postgres_repo:'PostgreSQL repository scope'};return named[(el.id||'').replace(/-/g,'_')]||(el.placeholder||'').replace(/[…*]+$/,'').trim()||(el.id||el.type||'control').replace(/[-_]+/g,' ')}
+function enhanceUi(root){const scope=root&&root.querySelectorAll?root:document;scope.querySelectorAll('.nav-item').forEach(el=>{el.setAttribute('role','link');el.tabIndex=0;el.setAttribute('aria-current',el.classList.contains('active')?'page':'false')});scope.querySelectorAll('button:not([type])').forEach(el=>el.type='button');scope.querySelectorAll('input,select,textarea').forEach((el,i)=>{if(el.labels&&el.labels.length)return;const field=el.closest('.field'),slider=el.closest('.gslider');const label=(field&&field.querySelector('label'))||(slider&&slider.querySelector('label'));if(label){if(!el.id)el.id='ui-control-'+i+'-'+Date.now();label.htmlFor=el.id}else if(!el.getAttribute('aria-label')&&!el.getAttribute('aria-labelledby'))el.setAttribute('aria-label',controlName(el))});scope.querySelectorAll('.recall-card,.gtop-row,.ep-edge,.mem-card').forEach(el=>{if(!el.hasAttribute('tabindex'))el.tabIndex=0;if(!el.hasAttribute('role'))el.setAttribute('role','button');if(!el.getAttribute('aria-label')){const txt=el.querySelector('.recall-title,.gtop-name,.mem-card-title')||el;el.setAttribute('aria-label',(txt.textContent||'Open item').trim())}});scope.querySelectorAll('.vault-card').forEach(el=>{el.setAttribute('role','group');const name=el.querySelector('.vault-card-name');if(name&&!el.getAttribute('aria-label'))el.setAttribute('aria-label','Workspace '+name.textContent.trim())});scope.querySelectorAll('.spinner').forEach(el=>{el.setAttribute('role','status');el.setAttribute('aria-label','Loading')});document.querySelectorAll('.status-region').forEach(el=>{const busy=!!el.querySelector('.spinner');el.setAttribute('aria-busy',String(busy));const text=(el.textContent||'').toLowerCase();el.setAttribute('role',/\b(error|failed|invalid|unavailable|offline)\b/.test(text)?'alert':'status')});document.querySelectorAll('.mm-overlay').forEach(dialogChanged)}
+const enhanceUiBase=enhanceUi;
+enhanceUi=function(root){
+ enhanceUiBase(root);
+ const scope=root&&root.querySelectorAll?root:document;
+ scope.querySelectorAll('.nav-item').forEach(el=>{
+ if(el.tagName==='BUTTON')el.removeAttribute('role');
+ else{el.setAttribute('role','button');el.tabIndex=0}
+ });
+};
+function enhanceDynamicUi(){document.querySelectorAll('.vault-card-name,.tl-item.clickable,#mm-body>div[data-onclick]').forEach(el=>{el.setAttribute('role','button');el.tabIndex=0});document.querySelectorAll('.mem-card').forEach(el=>el.setAttribute('aria-describedby','memory-reorder-help'));document.querySelectorAll('.card-head:not(h1):not(h2):not(h3)').forEach(el=>{el.setAttribute('role','heading');el.setAttribute('aria-level','2')});['sync-status','llm-test-result','au-result','tok-created'].forEach(id=>{const el=document.getElementById(id);if(el){el.setAttribute('role','status');el.setAttribute('aria-live','polite')}})}
+function enhanceAdjacentLabels(){document.querySelectorAll('input,select,textarea').forEach((el,i)=>{if(el.labels&&el.labels.length)return;const direct=el.previousElementSibling,parent=el.parentElement&&el.parentElement.previousElementSibling,label=(direct&&direct.matches('label')&&direct)||(parent&&parent.matches('label')&&parent);if(!label)return;if(!el.id)el.id='ui-adjacent-'+i+'-'+Date.now();label.htmlFor=el.id;el.removeAttribute('aria-label')})}
+function enhanceDecorativeIcons(){document.querySelectorAll('.nav-icon,.brand-mark,.dropzone-icon,.empty-icon').forEach(el=>el.setAttribute('aria-hidden','true'))}
+function enhanceExplorerSemantics(){document.querySelectorAll('#graph-entity-list [role="listitem"],#graph-relation-list [role="listitem"]').forEach(el=>el.removeAttribute('role'))}
+function enhanceStatusContainers(){document.querySelectorAll('.status-region,#ov-analytics,#ed-history,#sync-body,#lic-body,#tokens-body,#llm-body,#au-result').forEach(el=>{const visible=el.getClientRects().length>0;el.setAttribute('aria-live','polite');el.setAttribute('aria-busy',String(visible&&(!!el.querySelector('.spinner')||/^\s*Loading/.test(el.textContent||''))))})}
+// Coalesce the enhancement sweeps to at most one per frame. Each one re-queries the
+// whole document, and the observer fires on every keystroke in graph search and every
+// drag-over boundary crossing, so running them per mutation batch was quadratic-ish on
+// exactly the interactions that need to stay responsive. Dialog bookkeeping stays
+// immediate — it drives focus, which cannot wait a frame.
+let UI_SWEEP=0;
+function scheduleUiSweep(){if(UI_SWEEP)return;UI_SWEEP=requestAnimationFrame(()=>{UI_SWEEP=0;enhanceUi(document);enhanceAdjacentLabels();enhanceDynamicUi();enhanceDecorativeIcons();enhanceStatusContainers();enhanceExplorerSemantics();ensureDialogFocus()})}
+const UI_OBSERVER=new MutationObserver(records=>{records.forEach(r=>{if(r.type==='attributes'&&r.target.classList.contains('mm-overlay'))dialogChanged(r.target)});scheduleUiSweep()});
+enhanceUi(document);
+enhanceAdjacentLabels();
+enhanceDynamicUi();
+enhanceDecorativeIcons();
+enhanceStatusContainers();
+enhanceExplorerSemantics();
+document.querySelectorAll('.view').forEach(el=>el.setAttribute('aria-hidden',String(!el.classList.contains('active'))));
+UI_OBSERVER.observe(document.body,{subtree:true,childList:true,attributes:true,attributeFilter:['class']});
+document.addEventListener('keydown',e=>{trapDialog(e);const t=e.target;if(t.matches('.mem-card')){if(e.altKey&&(e.key==='ArrowUp'||e.key==='ArrowDown')){e.preventDefault();memKeyboardMove(t.dataset.id,e.key==='ArrowUp'?-1:1);return}if(e.key==='Enter'||e.key===' '){e.preventDefault();openMem(t.dataset.id);return}}if((e.key==='Enter'||e.key===' ')&&t.matches('[role=button],.nav-item')){if(e.key===' '||t.matches('.nav-item'))e.preventDefault();t.click()}});
+
+/* theme */
+const THEMES=[['dark','Dark','#15181e','#8c83e8'],['light','Light','#fbfbfc','#5547b8'],['midnight','Midnight','#111b2d','#79a6ef'],['solarized','Solarized','#073642','#58a7d8'],['sepia','Sepia','#f8f2e4','#925420'],['matrix','Matrix','#000403','#3ce072']];
+const THEME_GLYPH={dark:'◑',light:'☀',midnight:'☾',solarized:'◐',sepia:'❂',matrix:'▦'};
+function applyTheme(t){if(!THEME_GLYPH[t])t='dark';document.body.setAttribute('data-theme',t);window.__theme=t;try{localStorage.setItem('engraphis-theme',t)}catch(e){}const b=document.getElementById('theme-btn');if(b){b.textContent=THEME_GLYPH[t];b.setAttribute('aria-label','Change theme. Current theme: '+t)}const sel=document.getElementById('theme-select');if(sel&&sel.value!==t)sel.value=t;renderThemeMenu();if(typeof graphRecolor==='function')graphRecolor()}
+function renderThemeMenu(){const m=document.getElementById('theme-menu');if(!m)return;m.innerHTML=THEMES.map(x=>{const on=(x[0]===window.__theme);return ``}).join('')}
+function toggleThemeMenu(e){if(e)e.stopPropagation();const m=document.getElementById('theme-menu'),b=document.getElementById('theme-btn');if(!m)return;const show=!m.classList.contains('is-open');m.classList.toggle('is-open',show);if(b)b.setAttribute('aria-expanded',String(show));if(show){renderThemeMenu();setTimeout(()=>{const first=m.querySelector('button');if(first)first.focus();document.addEventListener('click',closeThemeMenu)},0)}}
+function closeThemeMenu(){const m=document.getElementById('theme-menu'),b=document.getElementById('theme-btn');if(m)m.classList.remove('is-open');if(b)b.setAttribute('aria-expanded','false');document.removeEventListener('click',closeThemeMenu)}
+function pickTheme(t){applyTheme(t);closeThemeMenu()}
+function toggleTheme(){const ids=THEMES.map(x=>x[0]);const i=ids.indexOf(window.__theme);pickTheme(ids[(i+1)%ids.length])}
+function initTheme(){let saved=null;try{saved=localStorage.getItem('engraphis-theme')}catch(e){}applyTheme(saved||window.__theme||'dark')}
+function selectDashboard(value){window.location.assign(value==='classic'?'/classic':'/')}
+function initDashboard(){const select=document.getElementById('dashboard-select');if(select)select.value=location.pathname==='/classic'?'classic':'primary'}
+
+/* nav */
+function selectView(v){
+ document.querySelectorAll('.nav-item').forEach(n=>{const active=n.dataset.view===v;n.classList.toggle('active',active);n.setAttribute('aria-current',active?'page':'false')});
+ document.querySelectorAll('.view').forEach(el=>{const active=el.id==='view-'+v;el.classList.toggle('active',active);el.setAttribute('aria-hidden',String(!active))});
+ CURRENT_VIEW=v;
+ const heading=document.getElementById('topbar-title');
+ heading.textContent=TITLES[v]||v;
+ const section=document.getElementById('route-section');
+ if(section)section.textContent=ROUTE_SECTIONS[v]||'Operate';
+ const sub=document.getElementById('topbar-sub');
+ if(sub)sub.textContent=DESCS[v]||'';
+ /* Plan pills live in the topbar; clear them on navigation so only the active
+ view's loader can repopulate one. */
+ ['an-lock','au-lock'].forEach(id=>{const p=document.getElementById(id);if(p){p.textContent='';p.className='pill pill-muted topbar-lock'}});
+ if(v==='graph')graphEngineResume();else graphEnginePause();
+ closeMobileNav();
+ (LOADERS[v]||function(){})();
+ heading.tabIndex=-1;
+ setTimeout(()=>heading.focus(),0);
+}
+function navTo(v){selectView(v)}
+document.querySelectorAll('.nav-item').forEach(it=>it.addEventListener('click',()=>selectView(it.dataset.view)));
+
+/* workspace */
+function setWS(name){
+ WS=name;
+ const shown=name||'—',sw=document.getElementById('vault-switcher'),top=document.getElementById('topbar-workspace');
+ document.getElementById('ws-name').textContent=shown;
+ if(top)top.textContent=shown;
+ if(sw)sw.setAttribute('aria-label',name?'Choose active workspace. Current workspace: '+name:'Choose active workspace');
+}
+async function loadWorkspaceList(){const d=await api('/workspaces');WORKSPACES=d.workspaces||[];if(!WS&&WORKSPACES.length){WORKSPACES.sort((a,b)=>(b.memories||0)-(a.memories||0));setWS(WORKSPACES[0].name)}}
+
/* overview */
-async function loadOverview(){try{const st=await api('/stats?workspace='+encodeURIComponent(WS||''));setViewDesc('overview',(st.memories||0)+' memories · '+(st.workspaces||0)+' workspaces');const cards=[['Memories',st.memories],['Live rows',st.total_rows],['Workspaces',st.workspaces],['Sessions',st.sessions]];document.getElementById('stat-grid').innerHTML=cards.map(c=>`
${cell(avg+'%','Avg retention',avg<40?'var(--red)':(avg<70?'var(--amber)':'var(--green)'))}${cell(f.at_risk_7d||0,'Fading ≤ 7 days',f.at_risk_7d>0?'var(--amber)':'')}${cell(thisWeek,'Written this week')}${cell(t.pinned||0,'Pinned')}
`;
- }catch(e){
- // Same routing as loadAnalytics: consent first, entitlement second, everything else
- // reported as the error it is. See hostedFeatureUnavailable for why 409 is not an
- // entitlement status.
- if(managedConsentRequired(e)){
- lock.textContent='CLOUD';
- lock.className='pill pill-accent';
- el.innerHTML=managedConsentHtml('Analytics');
- }else if(hostedFeatureUnavailable(e)){
- lock.textContent='PRO';
- lock.className='pill pill-muted';
- const offerTrial=licTrialAvailable();
- el.innerHTML='
Hosted growth, retention distribution, and decay forecast.
'+esc(lockReason(false))+'
'+(offerTrial?' ':'')+'
';
- }else el.innerHTML='
'+esc(e.message)+'
';
- }
-}
-
-/* ── hosted access state ──
- /api/license reports what the control plane said, never what this client guessed:
- access_state is one of active | trial | trial_expired | lapsed | inactive, and each one
- is a different thing to tell the customer. Reading it here is what stopped a customer
- whose trial had ended, and a customer whose card had failed, both being shown a
- confident PRO badge over rows of locks with no reason given. */
-function licAccessState(){const s=LIC&&LIC.access_state;return s==='active'||s==='trial'||s==='trial_expired'||s==='lapsed'?s:'inactive'}
-function licAccessLive(){const s=licAccessState();return s==='active'||s==='trial'}
-function licTrialActive(){return licAccessState()==='trial'}
-/* The server refuses a second trial for any organization that already holds an
- entitlement, so this is false for every connected customer — trialling, lapsed, or
- paying — and the CTA that could only ever return 409 is not drawn. */
-function licTrialAvailable(){return !!(LIC&&LIC.trial&&LIC.trial.available)}
-function licPlanName(){return licPlanKey()?licPlanKey().toUpperCase():'LOCAL'}
-/* The plan the customer actually holds, as the wire spells it: '' when they hold none.
- Renewal and billing actions must follow this, not a hardcoded 'pro'. */
-function licPlanKey(){const raw=String((LIC&&LIC.plan)||'local').toLowerCase();return raw==='pro'||raw==='team'?raw:''}
-function licTrialEnds(){return fmtDay(LIC&&LIC.trial&&LIC.trial.ends_at)}
-function fmtDay(epoch){const n=Number(epoch)||0;if(!(n>0))return '';try{const d=new Date(n*1000);return Number.isFinite(d.getTime())?d.toISOString().slice(0,10):''}catch(e){return ''}}
-
-/* ── shared hosted upgrade / trial CTA ── */
-/* The plan-neutral hosted entry point. An account portal is not a checkout for either
- plan, so it must not carry the ?plan= that would reframe it as one — and it cannot be
- LIC.upgrade_url either: that is licensing.upgrade_url(), which resolves plan="pro" and
- prefers ENGRAPHIS_PRO_UPGRADE_URL, so where the portal and the checkout are configured
- separately it is the Pro checkout under a neutral name. LIC.account_url resolves the
- generic value directly; the fallback only matters against a build that predates it. */
-function withCtaAttribution(raw,content,medium){const safe=safeUrl(raw);if(!safe||safe==='#')return '#';try{const url=new URL(safe,location.href);url.searchParams.set('utm_source','engraphis');url.searchParams.set('utm_medium',medium||'product');url.searchParams.set('utm_campaign','pro_conversion');url.searchParams.set('utm_content',content||'plans');return url.href}catch(e){return safe}}
-function hostedAccountUrl(content){return withCtaAttribution((LIC&&LIC.account_url)||(LIC&&LIC.upgrade_url),content||'account','product')}
-function hostedPlanUrl(plan,trial,interval,content){const cadence=interval==='annual'?'annual':'monthly',key=plan+'_'+cadence+'_upgrade_url',raw=(LIC&&(LIC[key]||(plan==='team'?LIC.team_upgrade_url:LIC.pro_upgrade_url)))||(LIC&&LIC.upgrade_url);const safe=safeUrl(raw);if(!safe||safe==='#')return '#';try{const url=new URL(safe,location.href);if(plan==='pro'||plan==='team')url.searchParams.set('plan',plan);url.searchParams.set('interval',cadence);if(!url.hash)url.hash='billing';if(trial)url.searchParams.set('trial',plan);return withCtaAttribution(url.href,content||plan,'product')}catch(e){return safe}}
-/* Why is this feature locked? One sentence per access state, so the panel never claims a
- trial the customer cannot start nor blames billing for a trial that simply ran out.
- This is DENIAL copy: every caller reaches it because a hosted request was refused. The
- one surface that is not a denial — the Team tab, which renders for everyone on every
- visit — must go through teamTeaserNote() instead. */
-function lockReason(team){const st=licAccessState(),ends=licTrialEnds();
- if(st==='trial')return `Your free trial is live${ends?` until ${esc(ends)}`:''}. ${team?'Team':'Pro'} needs a subscription of its own.`;
- if(st==='trial_expired')return `Your free trial has ended${ends?` (${esc(ends)})`:''}, so hosted features are locked. The trial cannot be started again.`;
- if(st==='lapsed')return `Your ${esc(licPlanName())} subscription is no longer active, so hosted features are locked until billing is up to date.`;
- if(st==='active')return `Your ${esc(licPlanName())} subscription does not include this.`;
- if(licTrialAvailable())return `The email-confirmed, no-card trial lasts exactly ${TRIAL_DAYS} active days.`;
- return 'Your free trial has already been used.'}
-/* The Team tab describes the hosted service; it is not an answer to a refused request, and
- it renders for every customer including the ones who are paying for Team. Handing it
- lockReason(true) told a live Team subscriber "Your TEAM subscription does not include
- this" directly above an unlocked Team nav item and an Open Team Cloud button. A customer
- whose live plan already grants Team gets the truth; everyone else still gets the denial
- copy, which for them is accurate. */
-function teamTeaserNote(){const ends=licTrialEnds();
- if(licPlanKey()!=='team'||!licAccessLive())return lockReason(true);
- if(licAccessState()==='trial')return `Your free trial includes Team${ends?` until ${esc(ends)}`:''}. Organizations, roles, and seats are managed in Engraphis Cloud.`;
- return 'Your TEAM subscription includes this. Organizations, roles, and seats are managed in Engraphis Cloud.'}
-function hostedCta(plan,content,interval){const team=plan==='team',name=team?'Team':'Pro',state=licAccessState(),current=licPlanKey();if(state==='lapsed')return {label:'Update billing',href:hostedAccountUrl(content||'account'),kind:'account'};if(licAccessLive()&&(current===plan||(current==='team'&&plan==='pro')))return {label:current==='team'&&team?'Open Team Cloud':'Open Engraphis Cloud',href:hostedAccountUrl(content||'account'),kind:'account'};const trial=licTrialAvailable()&&state==='inactive';return {label:trial?`Start ${TRIAL_DAYS}-day ${name} trial`:`Subscribe to ${name}`,href:hostedPlanUrl(plan,trial,interval||'monthly',content||plan),kind:trial?'trial':'subscribe'} }
-function ctaLinkHtml(cta,className,content){return `${esc(cta.label)}`}
-function unlockHtml(feature,plan){const team=plan==='team',name=team?'Team':'Pro',featureKey=`feature_${String(feature).toLowerCase().replace(/[^a-z0-9]+/g,'_')}`,primary=hostedCta(plan,featureKey),annual=primary.kind==='account'?'':{label:`Annual ${name} option`,href:hostedPlanUrl(plan,false,'annual',`${featureKey}_annual`),kind:'subscribe'},price=team?'$20 per seat/month or $200 per seat/year':'$10/month or $100/year',detail=lockReason(team),benefits=team?['Everything in Pro','Hosted organizations, invitations, and named seats','Roles, scoped credentials, and Team audit history']:['Hosted Cloud Sync across your installations','Growth, retention, decay, and entity Analytics','Auto Consolidation with hosted retention policies','Auto Dreaming with reviewable managed proposals','Priority support'],lede=team?'Team adds shared workspaces, named seats, roles, and remote agent access.':'Support continued Engraphis development with Pro. Your subscription helps cover hosted infrastructure and ongoing development while unlocking Cloud Sync, Analytics, Auto Consolidation, and Auto Dreaming across your installations.';return `
`}
-function startTrialPlan(plan){const url=hostedPlanUrl(plan,true);if(url==='#'){toast('Hosted signup URL is not configured','err');return}const link=document.createElement('a');link.href=url;link.target='_blank';link.rel='noopener';link.click()}
-function startTrial(){return startTrialPlan('pro')}
-function startTeamTrial(){return startTrialPlan('team')}
-/* The badge follows the access state, not the plan name. A plan name alone told a trialist
- they were a subscriber, and told a lapsed or expired customer nothing was wrong. */
-function updateLicBadge(){const bd=document.getElementById('lic-badge');if(!bd||!LIC)return;const st=licAccessState(),plan=licPlanName(),trial=licTrialAvailable(),label=st==='trial'?'TRIAL':st==='trial_expired'?'GET PRO':st==='lapsed'?'BILLING':st==='active'?plan:trial?'TRY PRO':'GET PRO',aria=st==='active'?'Open Engraphis Cloud account':st==='lapsed'?'Update billing in hosted plan settings':trial?'Start the 3-day Pro trial in hosted plan settings':'Subscribe to Pro in hosted plan settings';bd.textContent=label;bd.className='pill '+(licAccessLive()?'pill-accent':'pill-muted');bd.setAttribute('aria-label',aria);bd.title=aria}
-function updateFeatureLocks(){
- const has=f=>LIC&&(LIC.features||[]).includes(f);
- const apply=(id,feature,label,plan)=>{
- const badge=document.getElementById(id),item=badge&&badge.closest('.nav-item'),locked=!has(feature);
- if(badge)badge.textContent=locked?plan:'';
- if(item){
- item.setAttribute('aria-label',locked?`${label} — ${plan} plan; opens upgrade options`:label);
- item.title=locked?`${plan} plan required; open for trial and upgrade options`:'';
- }
- };
- apply('nav-analytics-lock','analytics','Analytics','PRO');
- apply('nav-automation-lock','automation','Automation','PRO');
- apply('nav-team-lock','team','Team','TEAM');
-}
-
-/* ── analytics (Pro) ── */
-function barRow(label,val,peak,color){const tone=color==='var(--green)'?' analytics-bar-green':(color==='var(--blue)'?' analytics-bar-blue':(color==='var(--cyan)'?' analytics-bar-cyan':(color==='var(--accent-dim)'?' analytics-bar-dim':'')));return `
${wsTable}`}
-/* A consent-required response is a valuable moment to show the job Pro can do, not a
- dead end about configuration. A customer with live access must never be offered their
- own plan again: hosted features are on by default once their account is available. */
-function managedConsentHtml(feature){const automation=/automation/i.test(feature),featureKey=`managed_${String(feature).toLowerCase().replace(/[^a-z0-9]+/g,'_')}`,live=licAccessLive(),trial=licTrialAvailable(),copy=automation?{eyebrow:'MEMORY MAINTENANCE',title:'Let your memory improve after you log off.',lede:'Turn repetitive cleanup into a steady, reviewable habit. Pro watches the rhythm of your workspace and brings the useful changes back for approval.',cards:[['CONSOLIDATE','Distill recurring work into durable knowledge on a cadence you control.'],['DREAM','Surface useful links after accumulation and idle time, before fresh context gets buried.'],['REVIEW','Every managed result is a proposal. Nothing silently rewrites your local memory.']]}:{eyebrow:'MEMORY INTELLIGENCE',title:'See the memory your team is about to lose.',lede:'Pro turns your local memory into an operating signal—so you can see what is growing, what is fading, and what is quietly shaping recall.',cards:[['GROWTH','Separate knowledge that compounds from activity that only accumulates.'],['RETENTION','Catch fading context before an important answer disappears from reach.'],['ENTITY SIGNAL','See the people, projects, and ideas organizing your workspace.']]};const primary=hostedCta('pro',featureKey),annual=primary.kind==='account'?'':{label:'Annual Pro option',href:hostedPlanUrl('pro',false,'annual',`${featureKey}_annual`),kind:'subscribe'},actions=`${ctaLinkHtml(primary,'btn btn-primary',featureKey)}${annual.href?ctaLinkHtml(annual,'btn btn-ghost',`${featureKey}_annual`):''}`,next=live?'Included in your Pro plan. Hosted insights and maintenance are on by default—nothing else to configure.':licAccessState()==='lapsed'?'Your subscription needs billing attention. Update billing to restore hosted insights and maintenance.':trial?`Start with ${TRIAL_DAYS} days of Pro. Hosted insights and maintenance come on automatically—no settings, toggles, or worker setup.`:'Subscribe to Pro and hosted insights and maintenance come on automatically—no settings, toggles, or worker setup.';return `
ENGRAPHIS PRO /${copy.eyebrow}
${copy.title}
${copy.lede}
${next}
${actions}
WHAT PRO IS WATCHING
${copy.cards.map(card=>`
${card[0]}
${card[1]}
`).join('')}
Your memory stays yours. Hosted work is automatic with Pro. Secret and session-scoped memories stay local.
`}
-function managedConsentRequired(error){return error&&error.status===409&&error.detail&&error.detail.code==='consent_required'}
-const CLOUD_SYNC_PRIVACY_COPY='Cloud Sync encrypts eligible shared-workspace changes end-to-end before they leave this device. Engraphis Cloud cannot read their contents; secret and session-scoped memories stay local.';
-const EXTERNAL_LLM_PRIVACY_COPY='Memory text is sent to your configured LLM provider for processing under that provider’s terms. The provider must read that text to return extracted facts.';
-async function confirmCloudTransfer(title,summary,submit,privacyCopy){return confirmAction(title,summary+(privacyCopy?'\n\nPrivacy: '+privacyCopy:''),submit||'Continue')}
-const managedConsentHtmlBase=managedConsentHtml;
-managedConsentHtml=function(feature){return managedConsentHtmlBase(feature)};
-/* Only an unconfigured local installation may turn a 401 into trial signup. A revoked
- or expired Cloud session is also a 401, but ``trial.available`` is false there and it
- must remain a reconnect error instead of offering a trial the control plane rejects. */
-function cloudTrialSignupRequired(error){return !!(error&&error.status===401&&error.detail&&error.detail.code==='cloud_unconfigured'&&licTrialAvailable())}
-/* Entitlement failure — and only that. 402 subscription not active and 501 feature not
- offered here: each means the customer's plan does not include what they just opened,
- so each may draw the purchase panel.
- 401 is deliberately NOT here either. The cloud maps it to "the cloud session expired or
- was revoked; connect again" — a credential problem, not a billing one. An already-paying
- customer whose refresh credential lapsed was being sold a plan they own; 401 falls
- through to the error branch so the reconnect instruction is what they actually read.
- 409 is deliberately NOT here. Every 409 this API emits is a *conflict*, never a billing
- answer: detail.code==='consent_required' means the customer IS entitled but managed
- compute is off for this installation (managedConsentRequired, above), and the rest are
- transient state conflicts (snapshot generation, cloud session state, rebuilding index).
- Counting any of them as "unpaid" showed a paying Pro customer a panel selling Pro, so
- every caller routes managedConsentRequired first and this second.
- Neither predicate may branch on CURRENT_VIEW: doing so made both the consent branch and
- the plain-error branch unreachable on the analytics and automation views, which is the
- regression this comment previously described but the code did not implement. */
-function hostedFeatureUnavailable(error){return !!error&&(error.status===402||error.status===501)}
-async function loadAnalytics(){const el=document.getElementById('analytics-body'),lock=document.getElementById('an-lock'),acts=document.getElementById('an-actions');el.innerHTML='';try{const a=await api('/analytics?workspace='+encodeURIComponent(WS||''));setPlanPill(lock,licTrialActive()?'TRIAL':'CLOUD','pill pill-accent');showAs(acts,true,'flex');el.innerHTML=renderAnalytics(a,false)}catch(e){if(managedConsentRequired(e)||cloudTrialSignupRequired(e)){setPlanPill(lock,'CLOUD','pill pill-accent');showAs(acts,false);el.innerHTML=managedConsentHtml('Analytics')}else if(hostedFeatureUnavailable(e)){setPlanPill(lock,'PRO','pill pill-muted');showAs(acts,false);el.innerHTML=unlockHtml('Analytics','pro')}else{el.innerHTML='
The cloud returns reviewable proposals. Pinned memories remain protected.
Cloud worker status
Status${p.enabled?'ENABLED':'OFF'}
Last run${esc(last)}
Requesting managed work uploads the selected workspace’s normal and sensitive memory content, excluding secret and session-scoped rows, capped at 16 MiB, over HTTPS without end-to-end encryption. Results are proposals and never automatically write the local database.
`}catch(e){if(managedConsentRequired(e)||cloudTrialSignupRequired(e)){setPlanPill(lock,'CLOUD','pill pill-accent');el.innerHTML=managedConsentHtml('Hosted Automation')}else if(hostedFeatureUnavailable(e)){setPlanPill(lock,'PRO','pill pill-muted');el.innerHTML=unlockHtml('Automation, Auto Consolidation, and Auto Dreaming','pro')}else{el.innerHTML='
'+esc(e.message)+'
'}}}
-async function saveAutomation(){const body={enabled:document.getElementById('au-enabled').checked,cadence_hours:Number(document.getElementById('au-cadence').value)||24,consolidate:document.getElementById('au-consolidate').checked,min_cluster:Number(document.getElementById('au-mincluster').value)||3,archive_below:Number(document.getElementById('au-archive').value)||0.05,dream_enabled:document.getElementById('au-dream').checked,dream_min_new:Number(document.getElementById('au-dream-min').value)||20,dream_idle_minutes:Number(document.getElementById('au-dream-idle').value)};try{await api('/automation?workspace='+encodeURIComponent(WS||''),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});toast('Hosted policy saved','ok');loadAutomation()}catch(e){if(managedConsentRequired(e)){const result=document.getElementById('au-result');if(result)result.innerHTML=managedConsentHtml('Hosted Automation');toast('Hosted Automation starts automatically with Pro','err');return}toast((e.status===402||e.status===501)?'Hosted Automation requires Pro or Team':e.message,'err')}}
-async function runMaintenance(){const el=document.getElementById('au-result');if(el)el.innerHTML='';try{const d=await api('/maintenance/run?workspace='+encodeURIComponent(WS||''),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({dry_run:true})});if(el)el.innerHTML=`PROPOSAL Hosted work was submitted for review.
`}
-/* drag-to-reorder: whole-list resend on drop keeps this simple and robust — see
- MemoryService.reorder_memories for how sort_order is assigned server-side. */
-function memDragStart(e,id){MEM_DRAG=id;e.dataTransfer.effectAllowed='move';e.currentTarget.classList.add('dragging')}
-function memDragOver(e,id){if(!MEM_DRAG||MEM_DRAG===id)return;e.preventDefault();const el=e.currentTarget,r=el.getBoundingClientRect(),before=(e.clientY-r.top)m.id===dragId);let to=MEM_LIST.findIndex(m=>m.id===id);if(from<0||to<0)return;const[moved]=MEM_LIST.splice(from,1);to=MEM_LIST.findIndex(m=>m.id===id);MEM_LIST.splice(before?to:to+1,0,moved);document.getElementById('mem-cards').innerHTML=MEM_LIST.map(memRowHtml).join('');try{await api('/memories/reorder',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({workspace:WS,ids:MEM_LIST.map(m=>m.id)})})}catch(err){toast('Reorder failed: '+err.message,'err');loadMemories()}}
-function memDragEnd(e){e.currentTarget.classList.remove('dragging');document.querySelectorAll('#mem-cards .mem-card').forEach(c=>c.classList.remove('drag-over-top','drag-over-bottom'))}
-async function memKeyboardMove(id,delta){const from=MEM_LIST.findIndex(m=>m.id===id),to=Math.max(0,Math.min(MEM_LIST.length-1,from+delta));if(from<0||from===to){toast(delta<0?'Memory is already first':'Memory is already last','ok');return}const[moved]=MEM_LIST.splice(from,1);MEM_LIST.splice(to,0,moved);document.getElementById('mem-cards').innerHTML=MEM_LIST.map(memRowHtml).join('');try{await api('/memories/reorder',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({workspace:WS,ids:MEM_LIST.map(m=>m.id)})});toast('Moved '+(moved.title||moved.id)+' to position '+(to+1),'ok');setTimeout(()=>{const card=document.querySelector('#mem-cards .mem-card[data-id="'+CSS.escape(id)+'"]');if(card)card.focus()},0)}catch(err){toast('Reorder failed: '+err.message,'err');loadMemories()}}
-
-/* proactive */
-async function loadProactive(){const el=document.getElementById('proactive-body');el.innerHTML='';try{const d=await api('/proactive?workspace='+encodeURIComponent(WS||''));let h='';if(d.handoff)h+=`
'}
-}
-async function wsCreate(){
- const result=await actionDialog({title:'New folder',message:'Create a local workspace for related memories.',submit:'Create folder',fields:[{name:'name',label:'Folder name',required:true},{name:'description',label:'Description (optional)',optional:true}]});
- if(result===null)return;
- const name=result.name.trim();
- if(!name){toast('Enter a folder name','err');return}
- if(WORKSPACES.some(w=>w.name===name)){toast('A folder named "'+name+'" already exists','err');return}
- try{
- await api('/workspaces/create',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({workspace:name,description:(result.description||'').trim()})});
- await loadWorkspaceList();setWS(name);toast('Folder "'+name+'" created — now the active folder','ok');refreshFolders();
- }catch(e){toast('Create failed: '+e.message,'err')}
-}
-function wsSwitch(name){setWS(name);toast('Switched to '+name,'ok');navTo('overview')}
-
-/* import (files/folders from this PC — see MemoryService.import_folder/import_files) */
-async function importUpload(items){if(!canCreateWs()){toast('Viewers can’t import','err');return}if(!WS){toast('Create or select a folder first','err');return}if(!items||!items.length)return;const fd=new FormData();fd.append('workspace',WS);fd.append('memory_type','semantic');fd.append('derive_facts',document.getElementById('import-derive').checked?'true':'false');for(const it of items)fd.append('files',it.file,it.name);const el=document.getElementById('import-status');el.textContent='Extracting and importing '+items.length+' file(s)…';try{const r=await api('/workspaces/import-files',{method:'POST',body:fd});const wc=(r.warnings||[]).length;el.textContent=r.imported+' imported, '+r.skipped+' skipped, '+r.errors+' error(s), '+(r.derived_facts||0)+' derived fact(s)'+(wc?', '+wc+' warning(s)':'');toast(r.imported+' resource'+(r.imported===1?'':'s')+' imported into "'+WS+'"','ok');refreshFolders()}catch(e){el.textContent='';toast('Import failed: '+e.message,'err')}}
-function importFilesPicked(fileList,el){const items=Array.from(fileList||[]).map(f=>({file:f,name:f.webkitRelativePath||f.name}));if(el)el.value='';importUpload(items)}
-async function importWalkEntry(entry,path,out){if(entry.isFile){await new Promise(res=>entry.file(f=>{out.push({file:f,name:(path?path+'/':'')+f.name});res()},()=>res()))}else if(entry.isDirectory){const reader=entry.createReader();const readBatch=()=>new Promise(res=>reader.readEntries(res,()=>res([])));let batch;do{batch=await readBatch();for(const e of batch)await importWalkEntry(e,(path?path+'/':'')+entry.name,out)}while(batch.length)}}
-async function importDrop(e){e.preventDefault();e.currentTarget.classList.remove('drag');const items=e.dataTransfer.items;const out=[];if(items&&items.length&&items[0].webkitGetAsEntry){for(const it of items){const entry=it.webkitGetAsEntry&&it.webkitGetAsEntry();if(entry)await importWalkEntry(entry,'',out)}}else{for(const f of e.dataTransfer.files)out.push({file:f,name:f.name})}importUpload(out)}
-async function importFromPath(){if(!canCreateWs()){toast('Viewers can’t import','err');return}if(!WS){toast('Create or select a folder first','err');return}const path=(document.getElementById('import-path').value||'').trim();const pattern=(document.getElementById('import-pattern').value||'*').trim()||'*';if(!path){toast('Enter a path','err');return}const el=document.getElementById('import-status');el.textContent='Extracting and importing…';try{const r=await api('/workspaces/import-folder',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({workspace:WS,path,file_pattern:pattern,memory_type:'semantic',derive_facts:document.getElementById('import-derive').checked})});const wc=(r.warnings||[]).length;el.textContent=r.imported+' imported, '+r.skipped+' skipped, '+r.errors+' error(s), '+(r.derived_facts||0)+' derived fact(s), scanned '+r.scanned+(wc?', '+wc+' warning(s)':'');toast(r.imported+' resource'+(r.imported===1?'':'s')+' imported into "'+WS+'"','ok');refreshFolders()}catch(e){el.textContent='';toast('Import failed: '+e.message,'err')}}
-async function indexRepository(){if(!WS){toast('Select a workspace first','err');return}const repo=(document.getElementById('code-repo').value||'').trim(),root=(document.getElementById('code-root').value||'').trim(),el=document.getElementById('code-import-status');if(!repo||!root){toast('Enter a repository name and path','err');return}el.textContent='Incrementally indexing repository…';try{const r=await api('/code/index',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({workspace:WS,repo:repo,root_path:root})});el.textContent=`${r.files_indexed} changed, ${r.files_unchanged} unchanged · ${r.symbols} symbols · ${r.edges} edges · ${r.code_memory_links||0} memory links`;toast('Repository graph updated','ok')}catch(e){el.textContent='';toast(e.message,'err')}}
-async function importPostgresSchema(){if(!WS){toast('Select a workspace first','err');return}const dsn=(document.getElementById('postgres-dsn').value||'').trim(),repo=(document.getElementById('postgres-repo').value||'').trim(),el=document.getElementById('code-import-status');if(!dsn){toast('Enter a PostgreSQL DSN','err');return}el.textContent='Reading PostgreSQL catalog…';try{const r=await api('/resources/postgres',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({workspace:WS,repo:repo||null,dsn:dsn})});document.getElementById('postgres-dsn').value='';el.textContent=`Imported ${r.schema.tables||0} tables, ${r.entities} entities, and ${r.relations} relations`;toast('Database schema imported','ok')}catch(e){el.textContent='';toast(e.message,'err')}}
-async function wsRename(name){const nn=await textAction('Rename workspace','Choose a new name for "'+name+'".','Workspace name',name,{submit:'Rename'});if(nn===null)return;const v=nn.trim();if(!v||v===name)return;try{await api('/workspaces/rename',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({workspace:name,new_name:v})});if(WS===name)setWS(v);toast('Renamed','ok');refreshFolders()}catch(e){toast(e.message,'err')}}
-async function wsDescribe(name){const cur=((WORKSPACES.find(w=>w.name===name)||{}).description)||'';const d=await textAction('Describe workspace','Update the optional description for "'+name+'".','Description',cur,{submit:'Save',optional:true,multiline:true});if(d===null)return;try{await api('/workspaces/describe',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({workspace:name,description:d})});toast('Saved','ok');refreshFolders()}catch(e){toast(e.message,'err')}}
-async function wsMerge(name){const others=WORKSPACES.map(w=>w.name).filter(n=>n!==name);if(!others.length){toast('No other workspace to merge into','err');return}const result=await actionDialog({title:'Merge workspace',message:'All memories in "'+name+'" will move to the selected workspace and "'+name+'" will be removed. This cannot be undone.',submit:'Merge workspace',danger:true,fields:[{name:'target',label:'Destination workspace',required:true,options:others.map(value=>({value,label:value}))}]});if(result===null)return;const v=result.target;try{const r=await api('/workspaces/merge',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({source:name,target:v})});toast('Merged '+(r.memories_moved||0)+' memories into '+v,'ok');if(WS===name)setWS(v);refreshFolders()}catch(e){toast('Merge failed: '+e.message,'err')}}
-async function wsCopy(name){try{const r=await api('/workspaces/copy',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({workspace:name})});toast('Copied to "'+r.workspace+'" ('+(r.memories_copied||0)+' memories)','ok');refreshFolders()}catch(e){toast('Copy failed: '+e.message,'err')}}
-async function wsDelete(name,n){const active=name===WS?' This is the active workspace; another workspace will become active after deletion.':'';if(!await confirmAction('Delete workspace','Delete "'+name+'" and all '+n+' memories in it from this Engraphis store?'+active+' Export anything you need first. This cannot be undone.','Delete workspace',true))return;try{const r=await api('/workspaces/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({workspace:name})});toast('Deleted ('+(r.memories_removed||0)+' memories)','ok');if(WS===name){WS=null;await loadWorkspaceList();if(WORKSPACES[0])setWS(WORKSPACES[0].name)}refreshFolders()}catch(e){toast(e.message,'err')}}
-
-/* memory detail + governance — a full-page editor (view-mem-editor), not a popup:
- the raw content is always a live, editable textarea (Obsidian-style), with a
- rendered preview alongside and the version history/audit trail below. */
-function setEditorActionsEnabled(enabled){['ed-save-btn','ed-pin-btn','ed-forget-btn'].forEach(id=>{const btn=document.getElementById(id);if(btn)btn.disabled=!enabled})}
-async function openMem(id){window.CURMEM=null;setEditorActionsEnabled(false);navTo('mem-editor');const ta=document.getElementById('ed-content');document.getElementById('ed-title').value='';ta.value='';document.getElementById('ed-meta').innerHTML='';document.getElementById('ed-preview').innerHTML='';document.getElementById('ed-history').innerHTML='';document.getElementById('topbar-title').textContent='Loading…';try{const d=await api('/memory/'+encodeURIComponent(id)+'?workspace='+encodeURIComponent(WS||''));const m=d.memory;if(!m){document.getElementById('topbar-title').textContent='Memory unavailable';document.getElementById('ed-history').innerHTML='
';return false}}
-function closeMem(){navTo('memories')}
-function edPreviewUpdate(){document.getElementById('ed-preview').innerHTML=renderMd(document.getElementById('ed-content').value)}
-function edRenderMeta(){const m=window.CURMEM;if(!m)return;const btn=document.getElementById('ed-pin-btn');if(btn)btn.textContent=m.pinned?'Unpin':'Pin';document.getElementById('ed-meta').innerHTML=`${esc(m.memory_type)}${esc(m.scope||'')} ${m.pinned?'pinned':''} ${esc((m.provenance&&m.provenance.source)||'')}${m.provenance&&m.provenance.trusted===false?' · untrusted':''} · id ${esc(m.id)}`}
-async function edTogglePin(){const m=window.CURMEM;if(!m)return;try{await api('/pin',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:m.id,workspace:WS,pinned:!m.pinned})});m.pinned=!m.pinned;edRenderMeta();toast(m.pinned?'Pinned':'Unpinned','ok')}catch(e){toast(e.message,'err')}}
-async function edSave(){const m=window.CURMEM;if(!m)return;const nt=document.getElementById('ed-title').value;const ntype=document.getElementById('ed-type').value;const nc=document.getElementById('ed-content').value;try{let meta=false,body=false,id=m.id;if(nt!==(m.title||'')||ntype!==(m.memory_type||'semantic')){await api('/memory/update',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:id,workspace:WS,title:nt,memory_type:ntype})});meta=true}if(nc!==(m.content||'')){const r=await api('/correct',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:id,workspace:WS,content:nc,reason:'dashboard edit'})});body=true;id=r.id}if(!meta&&!body){toast('No changes','ok');return}toast('Saved','ok');await openMem(id)}catch(e){toast(e.message,'err')}}
-async function edForget(){const m=window.CURMEM;if(!m)return;if(!await confirmAction('Retire memory','Close the current validity of "'+(m.title||m.id)+'" in workspace "'+(WS||'')+'"? It will stop appearing as current truth but remain in bi-temporal history.','Retire memory',true))return;try{await api('/retire',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:m.id,workspace:WS,reason:'dashboard retirement'})});toast('Memory retired and retained in history','ok');closeMem()}catch(e){toast(e.message,'err')}}
-let EDITOR_BASELINE='',EDITOR_FORCE_CLOSE=false;
-function editorSnapshot(){return JSON.stringify({title:document.getElementById('ed-title').value,type:document.getElementById('ed-type').value,content:document.getElementById('ed-content').value})}
-function editorIsDirty(){return !!window.CURMEM&&!!EDITOR_BASELINE&&editorSnapshot()!==EDITOR_BASELINE}
-function editorRefreshDirty(){const state=document.getElementById('ed-save-state');if(!state)return;const dirty=editorIsDirty();state.textContent=dirty?'Unsaved changes':'Saved';state.classList.toggle('dirty',dirty)}
-function editorCommitBaseline(){EDITOR_BASELINE=window.CURMEM?editorSnapshot():'';editorRefreshDirty()}
-const openMemWithEditorState=openMem;
-openMem=async function(id){const loaded=await openMemWithEditorState(id);if(loaded)editorCommitBaseline();else{EDITOR_BASELINE='';editorRefreshDirty()}return loaded}
-const selectViewWithDirtyGuard=selectView;
-selectView=async function(v){if(!EDITOR_FORCE_CLOSE&&v!=='mem-editor'&&document.getElementById('view-mem-editor').classList.contains('active')&&editorIsDirty()){const title=document.getElementById('ed-title').value||'this memory';if(!await confirmAction('Discard unsaved changes','Leave the Memory editor? Unsaved changes to "'+title+'" will be lost.','Discard changes',true))return false}selectViewWithDirtyGuard(v);return true}
-const edForgetWithEditorState=edForget;
-edForget=async function(){EDITOR_FORCE_CLOSE=true;try{return await edForgetWithEditorState()}finally{EDITOR_FORCE_CLOSE=false}}
-;['ed-title','ed-type','ed-content'].forEach(id=>{const el=document.getElementById(id);el.addEventListener('input',editorRefreshDirty);el.addEventListener('change',editorRefreshDirty)});
-window.addEventListener('beforeunload',e=>{if(!editorIsDirty())return;e.preventDefault();e.returnValue=''});
-document.addEventListener('keydown',e=>{if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='s'&&document.getElementById('view-mem-editor').classList.contains('active')){e.preventDefault();edSave()}});
-/* license */
-async function loadLicense(){const el=document.getElementById('lic-body');try{const d=await api('/license');LIC=d;updateLicBadge();updateFeatureLocks();renderLicense(d)}catch(e){if(el)el.innerHTML='
'+esc(e.message)+'
'}}
-/* Copy for the control plane's own entitlement status, when it named one. This is what
- separates "your card was declined" from "you cancelled" inside a single lapsed state. */
-const LIC_STATUS_NOTE={past_due:'the last payment did not go through',canceled:'the subscription was cancelled',expired:'the billing period ended',revoked:'access was revoked'};
-const LIC_SOURCE_LABEL={environment:'operator override',session:'cloud handshake',cloud:'cloud entitlements read',connected:'not yet confirmed',local:'not connected',override:'operator override'};
-/* The whole point of the panel: when hosted features are locked, say why, and offer the
- one action that fixes it. Never a plan badge over unexplained locks. */
-function licStateBanner(state,plan,ends,status){
- if(state==='trial_expired')return `
Your free trial has ended${ends?' on '+esc(ends):''}Hosted features are locked. Everything you have written is still in your local database and still fully usable — only the cloud capabilities stopped. The free trial runs once per account and cannot be started again, so restoring them means subscribing.
Your ${esc(plan||'hosted')} subscription is no longer active${note?esc(note.charAt(0).toUpperCase()+note.slice(1))+', so hosted':'Hosted'} features are locked until billing is up to date. Your local memories are unaffected. Open the account portal to restore access.
`}
- if(state==='inactive')return `
No hosted plan on this installationThe local memory engine is free and complete on its own. Cloud Sync, Analytics, Automation, and Team administration run in Engraphis Cloud.
`;
- /* Support diagnostics: which rule produced this answer and when the cloud last confirmed
- it. Emitted by /api/license since the plan resolver landed, and never shown until now —
- so "the dashboard says PRO" and "the cloud says PRO" could not be told apart. */
- if(d.plan_source)h+=`
Plan source${esc(LIC_SOURCE_LABEL[d.plan_source]||d.plan_source)}${d.plan_checked_at?' · confirmed '+esc(fmtRel(d.plan_checked_at)):''}
`;
- if(state==='active')h+=`
Thank you for supporting Engraphis. Your subscription helps fund hosted infrastructure and ongoing development.
`;
- h+=licActionsHtml(state);
- el.innerHTML=h;
-}
-async function exportWorkspace(){try{const d=await api('/export?workspace='+encodeURIComponent(WS||''));const blob=new Blob([JSON.stringify(d,null,2)],{type:'application/json'});const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download='engraphis-export-'+Date.now()+'.json';a.click();URL.revokeObjectURL(a.href);toast('Exported','ok')}catch(e){toast(e.message,'err')}}
-
-/* Hosted Team is a service CTA; local identity and seat administration are not shipped. */
-async function loadTeam(){const el=document.getElementById('team-body'),teamCta=hostedCta('team','team_tab');try{const st=await api('/auth/state');if(teamCta.href==='#'&&st&&st.cloud_url)teamCta.href=safeUrl(st.cloud_url)}catch(e){}el.innerHTML=`
Engraphis Team Cloud HOSTED
Organizations, invitations, roles, named seats, scoped device credentials, and team audit run on the private hosted service. This local dashboard is intentionally single-user.
${esc(teamTeaserNote())} Private-service account grace is capped at 24 hours, never extends Team access, and never restricts the free local core.
`}
-/* health + settings */
-function connectionContext(){const host=(location.hostname||'').toLowerCase();return host==='localhost'||host==='127.0.0.1'||host==='::1'||host.endsWith('.localhost')?'Local engine':'Remote customer node'}
-async function checkHealth(){const label=connectionContext();try{await api('/health');const d=document.getElementById('health-dot'),t=document.getElementById('health-text');if(d){d.classList.add('health-ok');d.classList.remove('health-error')}if(t)t.textContent=label+' connected'}catch(e){const d=document.getElementById('health-dot'),t=document.getElementById('health-text');if(d){d.classList.add('health-error');d.classList.remove('health-ok')}if(t)t.textContent=label+' unavailable'}}
-function loadSettings(){loadLicense();loadSyncStatus();loadHostedAgentAccess();loadLlmStatus();const s=document.getElementById('cfg-store');if(s)s.textContent=location.host;api('/info').then(function(d){var v=document.getElementById('cfg-version');if(v&&d&&d.version)v.textContent=d.version}).catch(function(){})}
-
-async function loadLlmStatus(){const el=document.getElementById('llm-body');if(!el)return;try{const st=await api('/llm/status');const ok=st.configured;const badge=ok?'configured':'not configured';const keyLine=st.key_set?'API key set ✓':'No API key set';let modelSel='';let provSel='';el.innerHTML=`
Provider · Model${badge}
${provSel}${modelSel}
${keyLine} · extractor: ${esc(st.extractor)}
Add this to your .env and restart Engraphis:
LLM extraction${st.extractor_enabled?'ON':'OFF'}
While ON, ingested memory content is sent to your LLM provider for schema-validated extraction. OFF disables extraction transfers only; retention supervision is configured separately.
`}catch(e){el.innerHTML='
'+esc(e.message)+'
'}}
-function onLlmProvChange(){const p=document.getElementById('llm-prov').value;const sel=document.getElementById('llm-model');const defs={openai:'gpt-4o-mini',anthropic:'claude-3-5-sonnet-20241022',google:'gemini-1.5-flash',openrouter:'openai/gpt-4o-mini'};if(sel&&defs[p]){sel.value=defs[p]}updateLlmSnippet()}
-function updateLlmSnippet(){const p=(document.getElementById('llm-prov')||{}).value||'openai';const m=(document.getElementById('llm-model')||{}).value||'';const ta=document.getElementById('llm-snippet');if(!ta)return;ta.value='ENGRAPHIS_LLM_PROVIDER='+p+'\nENGRAPHIS_LLM_MODEL='+m+'\nENGRAPHIS_LLM_API_KEY=\nENGRAPHIS_EXTRACTOR=llm_structured\n'}
-function copyLlmSnippet(){const ta=document.getElementById('llm-snippet');if(!ta)return;ta.select();try{navigator.clipboard.writeText(ta.value);toast('Copied .env snippet','ok')}catch(e){toast('Copy failed — select and Ctrl+C','err')}}
-async function setLlmExtractor(on){try{const d=await api('/llm/extractor',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled:!!on})});const ok=!!d.extractor_enabled;toast(ok?'LLM extraction turned on — new memories will be sent to your provider':'LLM extraction turned off — extractor transfers are disabled'+(d.persisted===false?' (could not save for restart)':''),ok?'ok':'muted');loadLlmStatus()}catch(e){toast(e.message,'err')}}
-async function testLlm(){const r=document.getElementById('llm-test-result');if(r){r.textContent='Testing…';setTone(r,'muted')}try{const d=await api('/llm/test',{method:'POST'});if(r){if(d.ok){const transient=d.auto_enabled&&d.persisted===false;r.textContent=(transient?'⚠ ':'✓ ')+'Connected — '+esc(d.provider)+'/'+esc(d.model)+(transient?' Extraction is active for this process, but the setting could not be saved for restart. Set ENGRAPHIS_EXTRACTOR=llm_structured and ENGRAPHIS_LLM_AUTO_EXTRACT=1 in the deployment environment.':'');setTone(r,transient?'red':'green')}else{r.textContent='✗ '+(d.error||'failed');setTone(r,'red')}}}catch(e){if(r){r.textContent='✗ '+esc(e.message);setTone(r,'red')}}}
-
-async function loadHostedAgentAccess(){const el=document.getElementById('tokens-body');if(!el)return;const teamCta=hostedCta('team','agent_access');try{const st=await api('/auth/state');if(teamCta.href==='#'&&st&&st.cloud_url)teamCta.href=safeUrl(st.cloud_url)}catch(e){}el.innerHTML=`
Per-member agent accounts, roles, named seats, and rotating device credentials are managed in Team Cloud, not by this local dashboard.
`}
-const setLlmExtractorBase=setLlmExtractor;
-setLlmExtractor=async function(on){if(on&&!await confirmAction('Turn on LLM extraction',EXTERNAL_LLM_PRIVACY_COPY,'Turn on'))return;return setLlmExtractorBase(on)};
-/* Route by cause, exactly like loadAnalytics/loadAutomation. Rendering the purchase panel for
- every failure told a paying customer to buy the plan they already own whenever the network
- blipped or the cloud answered 5xx. Only 402 is an entitlement answer; 401/403 require reconnecting. */
-async function loadSyncStatus(){try{const d=await api('/sync/status');renderSync(d)}catch(e){const el=document.getElementById('sync-body');if(!el)return;if(managedConsentRequired(e))el.innerHTML=managedConsentHtml('Cloud Sync');else if(hostedFeatureUnavailable(e))el.innerHTML=unlockHtml('Cloud Sync','pro');else el.innerHTML='
Cloud Sync is not authorized for this installation. Reconnect in Engraphis Cloud or contact your administrator.
`}
-function syncDenialHtml(last){const errors=Array.isArray(last&&last.errors)?last.errors:[];return errors.some(error=>[401,403].includes(Number(error&&error.status)))?syncReconnectHtml():syncRecoveryHtml()}
-function renderSync(d){const el=document.getElementById('sync-body');if(!el)return;d=d||{};const last=d.last;if(!d.available){el.innerHTML=unlockHtml('Cloud Sync','pro');return}if (syncTotalAuthorizationDenial(last)) { el.innerHTML = syncDenialHtml(last); return; }let status='Cloud session connected; no sync recorded on this installation.';if(last){const when=new Date((last.at||0)*1000).toLocaleString();status='Last synced '+when+' — pushed '+(last.exported||0)+', +'+(last.added||0)+' received'+((last.errors&&last.errors.length)?' · '+last.errors.length+' issue(s)':'')+'.'}el.innerHTML=`
Hosted relayCONNECTED
Relay storage and authorization run in Engraphis Cloud. This package contains only the customer client; it does not run a local relay or background scheduler.
${esc(status)}
`}
-async function syncNow(){const b=document.getElementById('sync-btn')||document.getElementById('sync-retry-btn');const original=b&&b.textContent;const s=document.getElementById('sync-status');if(b){b.disabled=true;b.textContent='Syncing…'}if(s)s.textContent='Contacting the cloud…';try{const d=await api('/sync/run',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});const su=d.summary||{};toast('Synced — pushed '+(su.exported||0)+', '+(su.added||0)+' new from other devices','ok');await loadSyncStatus()}catch(e){const status=Number(e&&e.status);if(status===402){const el=document.getElementById('sync-body');if(el)el.innerHTML=syncRecoveryHtml();toast('Cloud Sync requires an active Pro or Team entitlement — open Engraphis Cloud to upgrade or renew.','err')}else if(status===401||status===403){const el=document.getElementById('sync-body');if(el)el.innerHTML=syncReconnectHtml();toast('Cloud Sync authorization is no longer active — reconnect in Engraphis Cloud.','err')}else{toast('Sync failed: '+e.message,'err');if(s)s.textContent='Sync failed — try again.'}}finally{if(b){b.disabled=false;b.textContent=original||'Sync now'}}}
-
-const syncNowBase=syncNow;
-syncNow=async function(){if(!await confirmCloudTransfer('Sync shared workspaces','Cloud Sync sends eligible changes from your shared workspaces to Engraphis Cloud and receives authorized changes from your other installations; secret and session-scoped rows stay local.','Sync now',CLOUD_SYNC_PRIVACY_COPY))return;return syncNowBase()}
-
-/* ─── knowledge graph (force-graph + d3-force: compact defaults and selectable layouts) ─── */
-let GRAPH=null, FG=null, GRAPH_ENGINE=null, GRESIZE=false, GRESIZEFRAME=0, GADJ={}, GCOMM_ADJ={}, GCOMPONENTS={}, GCOMPONENT_LAYOUT=null, GHILITE=null, GHOVERSET=null, GLABELRANK={}, GLABELBOXES=[], GDATA_CACHE=null, GACTIVE_DATA=null, GREDRAWFRAME=0, GPERF={large:false,dense:false}, GRAPH_FULL=false, GRAPH_SCOPE_BEFORE_FULL=null;
-const GRAPH_PRESETS={
- original:{label:'Original force',repel:120,link:30,gravity:14,font:13,size:3,linkw:1,labelDensity:40,curve:0,particles:0},
- compact:{label:'Compact clusters',repel:42,link:20,gravity:26,font:12,size:3,linkw:.7,labelDensity:30,curve:.08,particles:0},
- communities:{label:'Community islands',repel:48,link:16,gravity:48,font:12,size:3,linkw:.72,labelDensity:24,curve:.12,particles:0},
- radial:{label:'Radial orbit',repel:68,link:26,gravity:12,font:13,size:3,linkw:.75,labelDensity:55,curve:.22,particles:0},
- constellation:{label:'Constellation flow',repel:34,link:16,gravity:38,font:12,size:3,linkw:.65,labelDensity:35,curve:.32,particles:2},
- custom:{label:'Custom tuning',curve:.1,particles:0}
-};
-window.GSET=window.GSET||{mode:'communities',font:12,size:3,repel:48,link:16,gravity:48,labels:false,linkw:.72,labelDensity:24,flow:true,frozen:false};
-// Freeze is session-only. Never let a pre-existing dashboard state make a newly opened graph
-// look broken: every Classic graph begins with physics live until its visible switch is clicked.
-window.GSET.frozen=false;
-/* Keep legacy Classic geometry in the same compact world-space range as Ledger. The old
- `size * sqrt(1 + degree)` rule let a highly connected entity become a giant disc, then
- zoom-to-fit magnified that disc again. Degree still adds a restrained emphasis, but it is
- normalized and bounded so material/theme painters cannot change node geometry. */
-function graphNodeRadius(node,base,metric){
- const size=Number.isFinite(+base)&&+base>0?+base:3;
- const normalized=Math.max(0,Math.min(1,Number(metric)||0));
- const radius=size*.45*(.55+Math.min(1.6,normalized*1.9));
- return Math.max(.8,Math.min(size*1.1,radius));
-}
-const ETYPE_TOKEN={person_or_concept:'--entity-concept',mention:'--entity-mention',hashtag:'--entity-hashtag',email:'--entity-email',organization:'--entity-organization',location:'--entity-location'};
-const GRAPH_PALETTES={
- theme:null,
- aurora:{person_or_concept:'#8b7cf6',mention:'#2dd4bf',hashtag:'#fbbf24',email:'#60a5fa',organization:'#f472b6',location:'#a3e635'},
- ocean:{person_or_concept:'#38bdf8',mention:'#2dd4bf',hashtag:'#facc15',email:'#818cf8',organization:'#22d3ee',location:'#34d399'},
- ember:{person_or_concept:'#f97316',mention:'#fb7185',hashtag:'#facc15',email:'#a78bfa',organization:'#ef4444',location:'#84cc16'},
- contrast:{person_or_concept:'#0072b2',mention:'#009e73',hashtag:'#e69f00',email:'#56b4e9',organization:'#cc79a7',location:'#d55e00'}
-};
-const GRAPH_COLOR_KEY='engraphis-graph-colors-v1';
-let GCOLOR_OVERRIDES={}, GCOLOR_PALETTE='theme';
-function cssvar(name,fallback){const value=getComputedStyle(document.body).getPropertyValue(name).trim();return value||fallback}
-function graphValidColor(color){return /^#[0-9a-f]{6}$/i.test(color||'')}
-function graphTypeLabel(type){return String(type||'entity').replace(/_/g,' ')}
-function graphLoadColorPreferences(){
- try{
- const saved=JSON.parse(localStorage.getItem(GRAPH_COLOR_KEY)||'{}'),colors=saved&&saved.colors;
- if(colors&&typeof colors==='object')Object.entries(colors).forEach(([type,color])=>{if(graphValidColor(color))GCOLOR_OVERRIDES[type]=color.toLowerCase()});
- if(saved&&saved.palette&&Object.prototype.hasOwnProperty.call(GRAPH_PALETTES,saved.palette))GCOLOR_PALETTE=saved.palette;
- else if(Object.keys(GCOLOR_OVERRIDES).length)GCOLOR_PALETTE='custom';
- }catch(e){GCOLOR_OVERRIDES={};GCOLOR_PALETTE='theme'}
-}
-function graphSaveColorPreferences(){try{localStorage.setItem(GRAPH_COLOR_KEY,JSON.stringify({palette:GCOLOR_PALETTE,colors:GCOLOR_OVERRIDES}))}catch(e){}}
-function graphTypeColor(type){if(GCOLOR_OVERRIDES[type])return GCOLOR_OVERRIDES[type];if(typeof GSTYLE!=='undefined'&&GSTYLE&&GSTYLE!=='classic'&&STYLE_PAL[GSTYLE]&&STYLE_PAL[GSTYLE][type])return STYLE_PAL[GSTYLE][type];return cssvar(ETYPE_TOKEN[type]||'--entity-concept',cssvar('--color-accent','#8c83e8'))}
-/* The engine renders to a canvas, so it cannot read `--entity-*` itself the way the legend and
- the controls do. Resolve the active theme's values here and hand them over; without this the
- opt-in canvas keeps dark-theme node colours after a switch to Light/Solarized/Sepia. */
-function graphThemeTypeColors(){const colors={},fallback=cssvar('--color-accent','#8c83e8');Object.keys(ETYPE_TOKEN).forEach(type=>{colors[type]=cssvar(ETYPE_TOKEN[type],fallback)});colors.accent=fallback;colors.surface=cssvar('--color-panel','#15181e');colors.canvas=cssvar('--color-canvas','#0e1014');colors.relation_label=cssvar('--color-text-dim','#7e8795');colors.label=cssvar('--color-text','#e7e9ee');return colors}
-/* Co-occurrence is implicit graph structure, not useful canvas text. */
-function graphShowRelationLabel(label){return !!label&&String(label).toLowerCase()!=='co_occurs'}
-function graphContrastColor(color){if(!graphValidColor(color))return cssvar('--color-canvas','#0e1014');const n=parseInt(color.slice(1),16),lum=.2126*(n>>16)+.7152*((n>>8)&255)+.0722*(n&255);return lum>150?'#111827':'#f8fafc'}
-const ETYPE_COLOR=new Proxy({},{get:(_,type)=>graphTypeColor(type)});
-graphLoadColorPreferences();
-function graphUpdateColorSwatches(){}
-function graphRefreshNodeColors(){
- const nodes=FG&&FG.graphData?FG.graphData().nodes||[]:[];
- nodes.forEach(node=>{node.color=graphNodeColor(node);node.stroke=graphContrastColor(node.color)});
- graphUpdateColorSwatches();
- graphRedraw();
-}
-function renderGraphColorControls(){
- const box=document.getElementById('graph-color-controls'),picker=document.getElementById('graph-palette');
- if(picker)picker.value=GCOLOR_PALETTE;
- if(!box)return;
- const types=GRAPH&&GRAPH.types&&GRAPH.types.length?GRAPH.types.map(item=>item.etype):Object.keys(ETYPE_TOKEN);
- box.innerHTML=types.map(type=>{const label=graphTypeLabel(type),color=graphTypeColor(type);return ``}).join('');
-}
-function graphSetTypeColor(type,color,persist){
- if(!type||!graphValidColor(color))return;
- GCOLOR_OVERRIDES[type]=color.toLowerCase();GCOLOR_PALETTE='custom';
- graphClearMaterialCache();
- const picker=document.getElementById('graph-palette');if(picker)picker.value='custom';
- if(GRAPH_ENGINE){GRAPH_ENGINE.setTypeColor(type,color);if(persist)graphSaveColorPreferences();return}
- graphRefreshNodeColors();
- if(persist)graphSaveColorPreferences();
-}
-function graphApplyPalette(name){
- if(!Object.prototype.hasOwnProperty.call(GRAPH_PALETTES,name))name='theme';
- GCOLOR_PALETTE=name;GCOLOR_OVERRIDES=GRAPH_PALETTES[name]?{...GRAPH_PALETTES[name]}:{};
- graphSaveColorPreferences();graphRecolor();
-}
-function graphResetColors(){graphApplyPalette('theme');toast('Node colors reset to the active theme','ok')}
-function graphInjectCss(){}
-function prefersReducedMotion(){return matchMedia('(prefers-reduced-motion: reduce)').matches}
-function graphSetLayoutStatus(text,busy){
- for(const id of ['graph-layout-status','galaxy-layout-status']){const status=document.getElementById(id);if(status){status.textContent=text;status.classList.toggle('busy',!!busy)}}
- for(const id of ['graph-net','galaxy-net']){const net=document.getElementById(id);if(net)net.setAttribute('aria-busy',String(!!busy))}
-}
-function graphSetSimulationStatus(text,busy){
- graphSetLayoutStatus(text,busy)
-}
-function graphUpdateHud(data){
- const mode=document.getElementById('graph-hud-mode'),count=document.getElementById('graph-hud-count'),badge=document.getElementById('graph-performance-badge');
- const preset=GRAPH_PRESETS[window.GSET.mode]||GRAPH_PRESETS.compact;
- if(mode)mode.textContent=preset.label||'Custom graph';
- if(count&&data)count.textContent=data.nodes.length.toLocaleString()+' entities · '+data.links.length.toLocaleString()+' relations';
- if(badge)badge.textContent=GPERF.large?'Large graph mode':'Adaptive rendering';
-}
-/* ── canonical graph renderer ────────────────────────────────────────────────────────────
- Classic now uses the same renderer as Ledger for its normal graph view. The legacy canvas
- remains the rollback path: one engine failure latches GRAPH_ENGINE_FAILED and the graph
- degrades to the legacy renderer instead of taking the view down. */
-let GRAPH_ENGINE_FAILED=false;
-function graphEngineEnabled(){
- if(GRAPH_ENGINE_FAILED)return false;
- try{return new URLSearchParams(window.location.search).get('graph-engine')==='next'||/(^|\/)classic\/?$/.test(window.location.pathname)}catch(e){return false}
-}
-function graphEngineFallback(error){
- GRAPH_ENGINE_FAILED=true;
- try{if(GRAPH_ENGINE)GRAPH_ENGINE.destroy()}catch(e){}
- GRAPH_ENGINE=null;
- /* The classic renderer skips seeding when GACTIVE_DATA still points at the current data,
- so a failure *after* a successful engine render would hand it an empty canvas. Clearing
- the marker makes the very next graphRender() a full classic build. */
- GACTIVE_DATA=null;GCOMPONENT_LAYOUT=null;GHILITE=null;GHOVERSET=null;
- if(window.console&&console.warn)console.warn('graph-engine=next failed; falling back to the classic renderer',error);
-}
-function graphEngineEmptyMessage(){
- const total=(GRAPH&&GRAPH.nodes&&GRAPH.nodes.length)||0;
- return total?('No connected entities — tick "Show unlinked" to see all '+total+'.'):'No entities in this workspace yet.';
-}
-function graphRenderEngine(data,fit,reheat){
- const element=document.getElementById('graph-net'),empty=document.getElementById('graph-empty');
- const fullGraph=typeof GRAPH_FULL!=='undefined'&&GRAPH_FULL;
- if(!element||typeof EngraphisGraph==='undefined')return false;
- try{
- if(!data.nodes.length){
- if(GRAPH_ENGINE)GRAPH_ENGINE.setData({nodes:[],links:[]});
- showAs(empty,true,'flex');
- if(empty)empty.textContent=graphEngineEmptyMessage();
- GACTIVE_DATA=null;graphSetLayoutStatus('No entities',false);return true;
- }
- showAs(empty,false);GPERF={large:data.nodes.length>600||data.links.length>2400,dense:data.links.length>1500};
- const created=!GRAPH_ENGINE;
- if(created){
- GRAPH_ENGINE=EngraphisGraph.create(element,{
- reducedMotion:prefersReducedMotion,
- onNodeClick:node=>{syncGraphExplorerSelection(node.id);graphNodeClick(node.label||node.name||node.id)},
- onBackgroundClick:()=>graphSetHighlight(null),
- onStats:stats=>{const count=document.getElementById('graph-hud-count');if(count)count.textContent=stats.nodes.toLocaleString()+' entities · '+stats.links.toLocaleString()+' relations'}
- });
- }
- /* Re-seeding identical data would re-copy every node and throw its x/y away, restarting
- the layout on each slider or preset change. The classic path guards the same way. */
- const dataChanged=created||GACTIVE_DATA!==data;
- const layers={};document.querySelectorAll('#graph-layer-filters input').forEach(input=>{layers[input.value]=input.checked});
- /* "Show unlinked nodes" is applied twice: graphData() decides what is handed over, and the
- engine re-filters by degree on its own state. Leaving the engine on its defaults
- (showUnlinked:false, minDegree:1) drops every degree-zero entity graphData() just supplied,
- so the checkbox appeared to do nothing under ?graph-engine=next. */
- const isolated=document.getElementById('graph-show-iso'),showUnlinked=fullGraph||!!(isolated&&isolated.checked);
- GRAPH_ENGINE.apply(engine=>{
- engine.setSettings({...window.GSET});
- if(typeof engine.setRenderMode==='function')engine.setRenderMode(fullGraph?'full':'overview');
- engine.setStyle(typeof GSTYLE!=='undefined'?GSTYLE:'cyber');
- engine.setColorBy(typeof GCOLORBY!=='undefined'?GCOLORBY:'community');
- engine.setThemeColors(graphThemeTypeColors());
- engine.setPalette(typeof GCOLOR_PALETTE!=='undefined'?GCOLOR_PALETTE:'theme');
- engine.setTypeColors(GCOLOR_OVERRIDES||{});
- engine.setLayers(layers);
- engine.setScope({showUnlinked,minDegree:showUnlinked?0:1});
- if(dataChanged)engine.setData(data);
- },fit,reheat);
- /* Mirror the engine's clustering back onto the dashboard's own node objects, or the
- cluster legend (which reads GACTIVE_DATA) reports one community for the whole store. */
- const communityMap=GRAPH_ENGINE.communityMap();
- data.nodes.forEach(node=>{node.community=communityMap[node.id]||0});
- GACTIVE_DATA=data;graphSyncReadouts();graphUpdateEditedBadge();graphUpdateHud(data);graphRenderLegend(GRAPH);
- if(dataChanged)graphSetHighlight(null);
- if(window.GSET.frozen)GRAPH_ENGINE.freeze(true);
- /* The renderer can be born after the user has already left the view: /graph and both lazy
- scripts resolve asynchronously, and the pause on nav-away ran while GRAPH_ENGINE was still
- null. Re-apply the parked state here so a renderer created against a hidden pane never
- starts a rAF that nothing will stop. */
- if(GRAPH_ENGINE_PARKED)GRAPH_ENGINE.pause();
- graphSetSimulationStatus(window.GSET.frozen?'Layout frozen':'Adaptive layout',false);
- return true;
- }catch(error){
- graphEngineFallback(error);
- return false;
- }
-}
-/* Nav away from the graph view: park the engine's animation frame. Without this the opt-in
- renderer keeps repainting a hidden canvas for the rest of the session.
- The intent is *recorded* as well as applied, because pausing an engine that does not exist
- yet is a no-op: leaving Graph before /graph (or either lazy script) resolves would otherwise
- let the pending callback create and start a renderer against a hidden pane with no later
- pause to stop it. graphRenderEngine() re-applies GRAPH_ENGINE_PARKED for that case. */
-let GRAPH_ENGINE_PARKED=false;
-function graphEnginePause(){GRAPH_ENGINE_PARKED=true;try{if(GRAPH_ENGINE)GRAPH_ENGINE.pause()}catch(e){}}
-function graphEngineResume(){GRAPH_ENGINE_PARKED=false;try{if(GRAPH_ENGINE)GRAPH_ENGINE.resume()}catch(e){}}
-function graphInvalidateData(){
- if(GRAPH_ENGINE){try{GRAPH_ENGINE.destroy()}catch(e){}GRAPH_ENGINE=null}
- GDATA_CACHE=null;GACTIVE_DATA=null;GCOMPONENT_LAYOUT=null;GHILITE=null;GHOVERSET=null
-}
-async function loadLegacyGraph(){
- graphInjectCss();graphInvalidateData();GRAPH=null;
- const empty=document.getElementById('graph-empty'),net=document.getElementById('graph-net'),nodesBox=document.getElementById('graph-entity-list'),edgesBox=document.getElementById('graph-relation-list');
- showAs(empty,true,'flex');empty.textContent='Loading graph…';graphSetLayoutStatus('Loading data',true);
- if(net)net.setAttribute('aria-busy','true');
- renderGraphExplorer();
- if(!GRESIZE){
- GRESIZE=true;
- window.addEventListener('resize',()=>{
- if((!FG&&!GRAPH_ENGINE)||GRESIZEFRAME)return;
- GRESIZEFRAME=requestAnimationFrame(()=>{GRESIZEFRAME=0;const element=document.getElementById('graph-net');if(GRAPH_ENGINE)GRAPH_ENGINE.resize();else if(FG&&element)FG.width(element.clientWidth).height(element.clientHeight)});
- });
- }
- const layerInputs=Array.from(document.querySelectorAll('#graph-layer-filters input')),selectedLayers=layerInputs.filter(input=>input.checked).map(input=>input.value),layerFilter=selectedLayers.length===layerInputs.length?'':'&layers='+encodeURIComponent(selectedLayers.join(',')),includeCode=document.getElementById('graph-include-code').checked,repo=(document.getElementById('graph-repo-filter').value||'').trim(),showUnlinked=GRAPH_FULL||!!document.getElementById('graph-show-iso').checked,graphLimit=GRAPH_FULL?20000:320,graphScope=GRAPH_FULL?'&full=true':(showUnlinked?'':'&connected_only=true');
- try{
- GRAPH=await api('/graph?workspace='+encodeURIComponent(WS||'')+layerFilter+'&include_code='+(includeCode?'true':'false')+'&limit='+graphLimit+graphScope+(repo?'&repo='+encodeURIComponent(repo):''));
- renderGraphSide();graphRender();
- }catch(error){
- showAs(empty,true,'flex');empty.textContent='Graph failed: '+error.message;graphSetLayoutStatus('Load failed',false);
- }finally{
- if(net)net.setAttribute('aria-busy','false');
- if(!GRAPH){
- if(FG)FG.graphData({nodes:[],links:[]});
- const message=empty.textContent||'Graph data unavailable.';
- if(nodesBox)nodesBox.innerHTML='
'+esc(message)+'
';
- if(edgesBox)edgesBox.innerHTML='
Relations are unavailable until graph data loads.
';
- }
- }
-}
-function graphUpdateAllNodesControl(){
- const full=GRAPH_FULL,button=document.getElementById('graph-show-all'),isolated=document.getElementById('graph-show-iso');
- if(button){button.textContent=full?'Show responsive overview':'Show all nodes';button.setAttribute('aria-pressed',String(full));button.title=full?'Return to the responsive graph overview':'Load every node, including unconnected entities, for this graph view'}
- if(isolated){isolated.disabled=full;isolated.title=full?'All nodes are already visible.':'Show entities that have no relations (unlinked nodes). Hidden by default to keep the graph readable.'}
-}
-function graphToggleAllNodes(){
- const isolated=document.getElementById('graph-show-iso');
- if(!GRAPH_FULL){GRAPH_SCOPE_BEFORE_FULL={showUnlinked:!!(isolated&&isolated.checked)};GRAPH_FULL=true;if(isolated)isolated.checked=true}
- else{GRAPH_FULL=false;if(isolated&&GRAPH_SCOPE_BEFORE_FULL)isolated.checked=GRAPH_SCOPE_BEFORE_FULL.showUnlinked;GRAPH_SCOPE_BEFORE_FULL=null}
- graphUpdateAllNodesControl();loadLegacyGraph();
-}
-function graphData(){
- const _si=document.getElementById('graph-show-iso');const hideIso=!(_si&&_si.checked);
- if(GDATA_CACHE&&GDATA_CACHE.graph===GRAPH&&GDATA_CACHE.hideIso===hideIso)return GDATA_CACHE.data;
- let sourceNodes=GRAPH.nodes;if(hideIso)sourceNodes=sourceNodes.filter(node=>node.degree>0);
- const names=new Set(sourceNodes.map(node=>node.id));
- const nodes=sourceNodes.map(node=>({id:node.id,label:node.label||node.id,displayLabel:(node.label||node.id).length>30?(node.label||node.id).slice(0,29)+'…':(node.label||node.id),etype:node.etype,degree:node.degree||0,val:1+(node.degree||0)}));
- const maxDegree=Math.max(1,...nodes.map(node=>node.degree||0));
- nodes.sort((a,b)=>b.degree-a.degree).forEach((node,index)=>{node.rank=index;node.hub=index<24;node.radius=graphNodeRadius(node,window.GSET.size,(node.degree||0)/maxDegree);node.color=graphTypeColor(node.etype);node.stroke=graphContrastColor(node.color)});
- const links=GRAPH.edges.filter(edge=>names.has(edge.from)&&names.has(edge.to)).map(edge=>({source:edge.from,target:edge.to,label:edge.label,layer:edge.layer||'semantic'}));
- const data={nodes,links};GDATA_CACHE={graph:GRAPH,hideIso,data};return data;
-}
-function buildAdj(links){
- GADJ={};GCOMM_ADJ={};
- links.forEach(link=>{
- const source=(link.source&&link.source.id)||link.source,target=(link.target&&link.target.id)||link.target;
- (GADJ[source]=GADJ[source]||new Set()).add(target);(GADJ[target]=GADJ[target]||new Set()).add(source);
- GCOMM_ADJ[source]=GCOMM_ADJ[source]||new Set();GCOMM_ADJ[target]=GCOMM_ADJ[target]||new Set();
- // Influence links often connect otherwise distinct bodies of work. Keep them
- // visible, but do not let a few such bridges collapse all communities into one.
- if(link.label!=='influences'){
- (GCOMM_ADJ[source]=GCOMM_ADJ[source]||new Set()).add(target);(GCOMM_ADJ[target]=GCOMM_ADJ[target]||new Set()).add(source);
- }
- });
-}
-function graphIndexComponents(nodes){
- const seen=new Set(),components=[];
- nodes.forEach(node=>{
- if(seen.has(node.id))return;
- const ids=[],stack=[node.id];seen.add(node.id);
- while(stack.length){const id=stack.pop();ids.push(id);(GADJ[id]||[]).forEach(next=>{if(!seen.has(next)){seen.add(next);stack.push(next)}})}
- components.push(ids);
- });
- components.sort((a,b)=>b.length-a.length);
- GCOMPONENTS={};
- const cols=Math.max(1,Math.ceil(Math.sqrt(components.length))),gap=Math.max(84,window.GSET.link*4);
- components.forEach((ids,index)=>{
- const row=Math.floor(index/cols),col=index%cols,used=Math.min(cols,components.length-row*cols);
- const x=(col-(used-1)/2)*gap,y=(row-(Math.ceil(components.length/cols)-1)/2)*gap;
- ids.forEach(id=>{GCOMPONENTS[id]={index,size:ids.length,x,y}});
- });
-}
-function graphIndexCommunities(nodes){
- const groups={};
- nodes.forEach(node=>{const key=Number.isFinite(node.community)?node.community:0;(groups[key]=groups[key]||[]).push(node);});
- const communities=Object.entries(groups).sort((a,b)=>b[1].length-a[1].length);
- const cols=Math.max(1,Math.ceil(Math.sqrt(communities.length))),gap=Math.max(150,window.GSET.link*9);
- GCOMPONENTS={};
- communities.forEach(([key,members],index)=>{
- const row=Math.floor(index/cols),col=index%cols,used=Math.min(cols,communities.length-row*cols);
- const x=(col-(used-1)/2)*gap,y=(row-(Math.ceil(communities.length/cols)-1)/2)*gap;
- members.forEach(node=>{GCOMPONENTS[node.id]={index,size:members.length,x,y,community:Number(key)};});
- });
-}
-function graphRefreshComponentCenters(nodes,force=false){
- const layout=window.GSET.mode+'|'+window.GSET.link;
- if(!force&&GCOMPONENT_LAYOUT===layout)return;
- if(window.GSET.mode==='communities')graphIndexCommunities(nodes);else graphIndexComponents(nodes);GCOMPONENT_LAYOUT=layout;
-}
-function graphAlpha(color,alpha){
- const hex=/^#([0-9a-f]{6})$/i.exec(color||'');
- if(hex){const value=parseInt(hex[1],16);return `rgba(${value>>16},${(value>>8)&255},${value&255},${alpha})`}
- const rgb=(color||'').match(/\d+(?:\.\d+)?/g);
- return rgb&&rgb.length>=3?`rgba(${rgb[0]},${rgb[1]},${rgb[2]},${alpha})`:color;
-}
-function graphReadThemeColors(){
- const layers=(typeof GSTYLE!=='undefined'&&GSTYLE&&GSTYLE!=='classic'&&STYLE_LAYERS[GSTYLE])?Object.assign({},STYLE_LAYERS[GSTYLE]):{temporal:cssvar('--color-info','#6f9fd8'),entity:cssvar('--color-teal','#5aafb3'),causal:cssvar('--color-warning','#d7a84b'),semantic:cssvar('--color-accent','#8c83e8')};
- const baseAlpha=GPERF.dense?.18:.32,links={};
- Object.entries(layers).forEach(([layer,color])=>{links[layer]={base:graphAlpha(color,baseAlpha),active:graphAlpha(color,.82),dim:graphAlpha(color,.035)}});
- return {label:cssvar('--color-text','#e7e9ee'),dim:cssvar('--color-text-dim','#7e8795'),accent:cssvar('--color-accent','#8c83e8'),panel:cssvar('--color-panel','#15181e'),canvas:cssvar('--color-canvas','#0e1014'),layers,links};
-}
-/* ─── graph aesthetic styles (Galaxy · Solar system · Cyberpunk), additive ─── */
-var STYLE_PAL={
- galaxy:{person_or_concept:'#b789ff',mention:'#7bb4ff',hashtag:'#ffcf6b',email:'#8aa2ff',organization:'#66e0d0',location:'#ff7ea8'},
- solar:{person_or_concept:'#ffb454',mention:'#3fd2c7',hashtag:'#ffd68a',email:'#8ea8ff',organization:'#5b9bff',location:'#ff8f6b'},
- cyber:{person_or_concept:'#ff3ea5',mention:'#b6ff3c',hashtag:'#ffe14d',email:'#8b7bff',organization:'#22e0ff',location:'#ff5c7a'}
-};
-var STYLE_LAYERS={
- galaxy:{temporal:'#7bb4ff',entity:'#66e0d0',causal:'#ffcf6b',semantic:'#b789ff'},
- solar:{temporal:'#5b9bff',entity:'#3fd2c7',causal:'#ffb454',semantic:'#ffd68a'},
- cyber:{temporal:'#22e0ff',entity:'#b6ff3c',causal:'#ffe14d',semantic:'#ff3ea5'}
-};
-var STYLE_BG={
- classic:'',
- galaxy:'radial-gradient(58% 50% at 24% 22%,rgba(126,64,208,.30),transparent 66%),radial-gradient(52% 58% at 82% 78%,rgba(220,72,164,.20),transparent 68%),radial-gradient(46% 52% at 62% 42%,rgba(58,120,224,.16),transparent 70%),#06040f',
- solar:'radial-gradient(40% 48% at 50% 50%,rgba(255,184,92,.16),transparent 60%),radial-gradient(90% 90% at 50% 50%,rgba(18,32,64,.55),transparent 82%),#05070d',
- cyber:'linear-gradient(rgba(34,224,255,.055) 1px,transparent 1px) 0 0/30px 30px,linear-gradient(90deg,rgba(34,224,255,.055) 1px,transparent 1px) 0 0/30px 30px,radial-gradient(72% 60% at 50% 0%,rgba(255,62,165,.12),transparent 72%),#050810'
-};
-var GSTYLE='cyber';try{var _gs=localStorage.getItem('engraphis-graph-style');if(_gs)GSTYLE=_gs;}catch(e){}
-function graphMakeStars(){var a=[],c=['#dfe6ff','#dfe6ff','#c9b6ff','#a7c6ff','#ffd9ef'];for(var i=0;i<110;i++){a.push({x:(Math.random()-.5)*1200,y:(Math.random()-.5)*1200,r:Math.random()*1.1+.25,a:Math.random()*.7+.25,tw:Math.random()*1.6+.4,ph:Math.random()*6.28,c:c[i%c.length]});}return a;}
-var GSTARS=graphMakeStars();
-function graphRgb(c){if(!c)return[140,131,232];if(c[0]==='#'){var h=c.length===4?c[1]+c[1]+c[2]+c[2]+c[3]+c[3]:c.slice(1,7),n=parseInt(h,16);if(Number.isFinite(n))return[n>>16&255,n>>8&255,n&255]}var m=c.match(/\d+/g);return m&&m.length>=3?[+m[0],+m[1],+m[2]]:[140,131,232]}
-function graphMix(a,b,amount){var A=graphRgb(a),B=graphRgb(b),t=Math.max(0,Math.min(1,amount));return'rgb('+Math.round(A[0]+(B[0]-A[0])*t)+','+Math.round(A[1]+(B[1]-A[1])*t)+','+Math.round(A[2]+(B[2]-A[2])*t)+')'}
-const GRAPH_MATERIAL_CACHE_LIMIT=192,GRAPH_MATERIAL_CACHE=new Map();
-const GRAPH_MATERIAL_RADIUS={signature:5,bezel:12,full:40};
-let GRAPH_MATERIAL_DPR=null;
-function graphMaterialTier(screenRadius,forceLow){if(forceLow||!Number.isFinite(+screenRadius)||+screenRadius<6)return'signature';return+screenRadius<12?'bezel':'full'}
-function graphMaterialProfile(style,col){
- var theme=window.GCOL||graphReadThemeColors(),surface=theme.panel||theme.surface||theme.canvas||'#0e1014';
- style=['cyber','galaxy','solar','classic'].includes(style)?style:'classic';
- var substrate=graphMix(surface,'#02050a',style==='classic'?.68:.78),identity='rgb('+graphRgb(col).join(',')+')',base={styleName:style,substrate:substrate,identity:identity,identityKey:graphRgb(col).join(','),substrateKey:graphRgb(substrate).join(',')};
- if(style==='cyber'){var cyber={cyan:'#21dff3',blue:'#367cff',violet:'#8d61ff',magenta:'#ec4fc4',teal:'#4ce4cf'};return Object.assign(base,{family:'iridescent-pvd',fixedPalette:cyber,film:cyber,outer:graphMix(substrate,'#01040a',.82),bezel:graphMix(substrate,'#101626',.46),face:graphMix(substrate,'#182237',.48),edge:'#677386',sheen:'#8d61ff'})}
- if(style==='galaxy'){var galaxy={navy:'#111a3b',blue:'#3979e8',violet:'#8d68df',highlight:'#aab9ee'};return Object.assign(base,{family:'anodized-alloy',fixedPalette:galaxy,outer:graphMix(substrate,'#02040d',.76),bezel:graphMix(substrate,'#151a34',.54),face:graphMix(substrate,galaxy.navy,.68),edge:'#7587bb',sheen:galaxy.blue})}
- if(style==='solar'){var solar={ember:'#713018',copper:'#b85c2f',amber:'#f18a32',gold:'#ffc46b',shadow:'#2b1008'};return Object.assign(base,{family:'brushed-copper',fixedPalette:solar,outer:graphMix(substrate,'#0a0402',.72),bezel:graphMix(substrate,'#351609',.62),face:graphMix(substrate,solar.copper,.48),edge:solar.amber,sheen:solar.gold})}
- var classic={charcoal:'#242d36',steel:'#778593',highlight:'#c0c9cf',coolEdge:'#8aa7bd'};return Object.assign(base,{family:'satin-gunmetal',fixedPalette:classic,outer:graphMix(substrate,'#05080b',.68),bezel:graphMix(substrate,'#20272e',.52),face:graphMix(substrate,classic.charcoal,.72),edge:classic.coolEdge,sheen:classic.highlight})
-}
-function graphMaterialFill(ctx,x,y,r,fill){ctx.beginPath();ctx.arc(x,y,Math.max(.1,r),0,6.2832);ctx.fillStyle=fill;ctx.fill()}
-function graphMaterialStroke(ctx,x,y,r,stroke,width){ctx.beginPath();ctx.arc(x,y,Math.max(.1,r),0,6.2832);ctx.lineWidth=width;ctx.strokeStyle=stroke;ctx.stroke()}
-function graphMaterialGradient(ctx,kind,args,stops){var maker=ctx[kind];if(typeof maker!=='function')return stops[Math.floor(stops.length/2)][1];var result=maker.apply(ctx,args);stops.forEach(stop=>result.addColorStop(stop[0],stop[1]));return result}
-function graphMaterialIdentity(ctx,x,y,r,profile,strength){graphMaterialStroke(ctx,x,y,r*.955,graphAlpha(profile.identity,strength),Math.max(.32,r*.045))}
-function graphMaterialHalo(ctx,x,y,r,tier,color,opacity,shiftX,shiftY){
- if(tier==='signature')return;
- var reach=tier==='full'?1.12:1.14,halo=graphMaterialGradient(ctx,'createRadialGradient',[x+r*(shiftX||0),y+r*(shiftY||0),r*.48,x,y,r*reach],[[0,graphAlpha(color,opacity)],[.68,graphAlpha(color,opacity*.42)],[1,graphAlpha(color,0)]]);
- graphMaterialFill(ctx,x,y,r*reach,halo);
-}
-function graphDirectionalBrush(ctx,x,y,r,angle,dark,light,strength){
- if(typeof ctx.moveTo!=='function'||typeof ctx.lineTo!=='function')return;
- var alongX=Math.cos(angle),alongY=Math.sin(angle),normalX=-alongY,normalY=alongX,bound=r*.76;
- for(var i=-13;i<=13;i++){var offset=i*r*.052,span=Math.sqrt(Math.max(0,bound*bound-offset*offset)),cx=x+normalX*offset,cy=y+normalY*offset;ctx.lineWidth=Math.max(.18,r*(.007+Math.abs(i%3)*.002));ctx.strokeStyle=graphAlpha(i%4===0?dark:light,strength*(.48+Math.abs(i%5)*.13));ctx.beginPath();ctx.moveTo(cx-alongX*span,cy-alongY*span);ctx.lineTo(cx+alongX*span,cy+alongY*span);ctx.stroke()}
-}
-function graphPaintCyberMaterial(ctx,x,y,r,p,tier){
- var f=p.fixedPalette;graphMaterialHalo(ctx,x,y,r,tier,f.cyan,.20,-.15,.12);graphMaterialHalo(ctx,x,y,r,tier,f.magenta,.17,.16,-.14);graphMaterialFill(ctx,x,y,r,p.outer);graphMaterialFill(ctx,x,y,r*.94,p.bezel);
- if(tier==='signature'){graphMaterialFill(ctx,x,y,r*.79,graphMix(f.magenta,f.cyan,.58));graphMaterialStroke(ctx,x,y,r*.82,graphAlpha(f.violet,.84),Math.max(.35,r*.09));graphMaterialIdentity(ctx,x,y,r,p,.88);return}
- var rimKind=typeof ctx.createConicGradient==='function'?'createConicGradient':'createLinearGradient',rimArgs=rimKind==='createConicGradient'?[-2.2,x,y]:[x-r*.8,y-r*.8,x+r*.8,y+r*.8];
- var rim=graphMaterialGradient(ctx,rimKind,rimArgs,[[0,f.cyan],[.20,f.blue],[.40,f.violet],[.61,f.magenta],[.80,f.teal],[1,f.cyan]]);graphMaterialFill(ctx,x,y,r*.89,rim);
- var film=graphMaterialGradient(ctx,'createLinearGradient',[x-r*.16,y-r*.80,x+r*.22,y+r*.80],[[0,graphMix(p.face,f.magenta,.82)],[.22,graphMix(p.face,f.violet,.78)],[.48,graphMix(p.face,f.blue,.58)],[.73,graphMix(p.face,f.cyan,.82)],[1,graphMix(p.face,f.teal,.68)]]);graphMaterialFill(ctx,x,y,r*.81,film);
- var band=graphMaterialGradient(ctx,'createLinearGradient',[x-r*.78,y+r*.48,x+r*.72,y-r*.56],[[0,graphAlpha(f.cyan,0)],[.31,graphAlpha(f.cyan,.16)],[.48,graphAlpha('#eef8ff',.28)],[.58,graphAlpha(f.magenta,.18)],[1,graphAlpha(f.magenta,0)]]);graphMaterialFill(ctx,x,y,r*.80,band);
- var shade=graphMaterialGradient(ctx,'createRadialGradient',[x-r*.27,y-r*.34,r*.04,x,y,r*.82],[[0,graphAlpha('#f3f7ff',.38)],[.23,graphAlpha('#aebcff',.08)],[.66,graphAlpha('#02040a',.03)],[1,graphAlpha('#010207',.42)]]);graphMaterialFill(ctx,x,y,r*.80,shade);
- if(tier==='full'){for(var i=0;i<13;i++){ctx.lineWidth=Math.max(.25,r*(.009+(i%3)*.003));ctx.strokeStyle=graphAlpha(i%3===0?f.cyan:(i%3===1?f.violet:f.magenta),.075+(i%4)*.018);ctx.beginPath();ctx.arc(x,y,r*(.16+i*.048),-2.88,.72);ctx.stroke()}}
- ctx.lineWidth=Math.max(.36,r*.030);ctx.strokeStyle=graphAlpha('#f5fbff',.48);ctx.beginPath();ctx.arc(x,y,r*.73,-2.66,-1.14);ctx.stroke();
- graphMaterialIdentity(ctx,x,y,r,p,.78);
-}
-function graphPaintGalaxyMaterial(ctx,x,y,r,p,tier){
- var f=p.fixedPalette;graphMaterialHalo(ctx,x,y,r,tier,graphMix(f.blue,f.violet,.48),.11,-.10,-.10);graphMaterialFill(ctx,x,y,r,p.outer);graphMaterialFill(ctx,x,y,r*.93,p.bezel);
- if(tier==='signature'){graphMaterialFill(ctx,x,y,r*.80,p.face);graphMaterialStroke(ctx,x,y,r*.84,graphAlpha(f.violet,.82),Math.max(.35,r*.08));graphMaterialIdentity(ctx,x,y,r,p,.82);return}
- var face=graphMaterialGradient(ctx,'createLinearGradient',[x-r*.72,y-r*.72,x+r*.72,y+r*.72],[[0,graphMix(p.face,f.highlight,.34)],[.26,graphMix(p.face,f.blue,.40)],[.52,graphMix(p.face,f.violet,.28)],[.76,p.face],[1,graphMix(p.face,f.navy,.72)]]);graphMaterialFill(ctx,x,y,r*.83,face);
- var sheen=graphMaterialGradient(ctx,'createLinearGradient',[x-r*.76,y+r*.64,x+r*.68,y-r*.70],[[0,graphAlpha(f.navy,0)],[.34,graphAlpha(f.blue,.07)],[.47,graphAlpha(f.violet,.34)],[.56,graphAlpha(f.highlight,.24)],[.68,graphAlpha(f.blue,.08)],[1,graphAlpha(f.navy,0)]]);graphMaterialFill(ctx,x,y,r*.82,sheen);
- if(tier==='full'){graphDirectionalBrush(ctx,x,y,r,-.54,f.navy,f.highlight,.13);for(var i=0;i<14;i++){ctx.lineWidth=Math.max(.20,r*(.008+(i%2)*.003));ctx.strokeStyle=graphAlpha(i%2?f.blue:f.violet,.055+(i%4)*.018);ctx.beginPath();ctx.arc(x,y,r*(.14+i*.047),-2.94,.46);ctx.stroke()}}
- ctx.lineWidth=Math.max(.34,r*.026);ctx.strokeStyle=graphAlpha(f.highlight,.38);ctx.beginPath();ctx.arc(x,y,r*.75,-2.70,-1.18);ctx.stroke();
- graphMaterialStroke(ctx,x,y,r*.88,graphAlpha(f.violet,.72),Math.max(.38,r*.046));graphMaterialIdentity(ctx,x,y,r,p,.76);
-}
-function graphPaintSolarMaterial(ctx,x,y,r,p,tier){
- var f=p.fixedPalette;graphMaterialHalo(ctx,x,y,r,tier,f.amber,.14,-.08,-.12);graphMaterialFill(ctx,x,y,r,p.outer);graphMaterialFill(ctx,x,y,r*.95,p.bezel);
- if(tier==='signature'){graphMaterialFill(ctx,x,y,r*.78,f.copper);graphMaterialStroke(ctx,x,y,r*.84,f.amber,Math.max(.42,r*.10));graphMaterialIdentity(ctx,x,y,r,p,.70);return}
- var copper=graphMaterialGradient(ctx,'createRadialGradient',[x-r*.20,y-r*.24,r*.025,x,y,r*.86],[[0,f.gold],[.15,f.amber],[.38,'#c66a38'],[.68,f.copper],[.86,f.ember],[1,f.shadow]]);graphMaterialFill(ctx,x,y,r*.82,copper);
- var copperSheen=graphMaterialGradient(ctx,'createLinearGradient',[x-r*.74,y+r*.52,x+r*.70,y-r*.60],[[0,graphAlpha(f.shadow,0)],[.38,graphAlpha(f.amber,.08)],[.50,graphAlpha(f.gold,.34)],[.62,graphAlpha(f.ember,.10)],[1,graphAlpha(f.shadow,0)]]);graphMaterialFill(ctx,x,y,r*.80,copperSheen);
- graphMaterialStroke(ctx,x,y,r*.90,f.gold,Math.max(.42,r*.055));graphMaterialStroke(ctx,x,y,r*.85,graphAlpha(f.ember,.94),Math.max(.34,r*.036));
- if(tier==='full'){for(var i=0;i<25;i++){var rr=r*(.12+i*.027);ctx.lineWidth=Math.max(.19,r*(.008+(i%3)*.0025));ctx.strokeStyle=graphAlpha(i%4===0?f.gold:f.shadow,.085+(i%5)*.018);ctx.beginPath();ctx.arc(x,y,rr,-3.02+(i%3)*.07,2.94-(i%4)*.05);ctx.stroke()}}
- ctx.lineWidth=Math.max(.38,r*.030);ctx.strokeStyle=graphAlpha('#fff0c0',.48);ctx.beginPath();ctx.arc(x,y,r*.73,-2.70,-1.14);ctx.stroke();
- graphMaterialIdentity(ctx,x,y,r,p,.66);
-}
-function graphPaintClassicMaterial(ctx,x,y,r,p,tier){
- var f=p.fixedPalette;graphMaterialFill(ctx,x,y,r,p.outer);graphMaterialFill(ctx,x,y,r*.94,p.bezel);
- if(tier==='signature'){graphMaterialFill(ctx,x,y,r*.79,p.face);graphMaterialStroke(ctx,x,y,r*.84,graphAlpha(f.coolEdge,.76),Math.max(.35,r*.08));graphMaterialIdentity(ctx,x,y,r,p,.68);return}
- var steel=graphMaterialGradient(ctx,'createLinearGradient',[x-r*.72,y-r*.72,x+r*.72,y+r*.72],[[0,graphMix(p.face,f.highlight,.48)],[.24,graphMix(p.face,f.steel,.38)],[.50,p.face],[.76,graphMix(p.face,'#111820',.34)],[1,graphMix(p.face,'#05080b',.66)]]);graphMaterialFill(ctx,x,y,r*.83,steel);
- var satin=graphMaterialGradient(ctx,'createRadialGradient',[x-r*.26,y-r*.31,r*.04,x,y,r*.86],[[0,graphAlpha(f.highlight,.26)],[.38,graphAlpha(f.steel,.03)],[.74,graphAlpha('#070a0d',.08)],[1,graphAlpha('#020304',.42)]]);graphMaterialFill(ctx,x,y,r*.82,satin);
- if(tier==='full')graphDirectionalBrush(ctx,x,y,r,.04,'#020507',f.highlight,.16);
- ctx.lineWidth=Math.max(.34,r*.026);ctx.strokeStyle=graphAlpha('#edf5fb',.34);ctx.beginPath();ctx.arc(x,y,r*.74,-2.70,-1.16);ctx.stroke();
- graphMaterialStroke(ctx,x,y,r*.88,graphAlpha(f.coolEdge,.62),Math.max(.34,r*.040));graphMaterialIdentity(ctx,x,y,r,p,.62);
-}
-function graphPaintMaterialDirect(ctx,x,y,r,p,tier){if(p.family==='iridescent-pvd')graphPaintCyberMaterial(ctx,x,y,r,p,tier);else if(p.family==='anodized-alloy')graphPaintGalaxyMaterial(ctx,x,y,r,p,tier);else if(p.family==='brushed-copper')graphPaintSolarMaterial(ctx,x,y,r,p,tier);else graphPaintClassicMaterial(ctx,x,y,r,p,tier)}
-function graphClearMaterialCache(){GRAPH_MATERIAL_CACHE.clear();GRAPH_MATERIAL_DPR=null}
-function graphMaterialDpr(){var d=window.devicePixelRatio||1;return Math.max(1,Math.min(3,Math.round(d*2)/2))}
-function graphMaterialSprite(p,tier){
- var dpr=graphMaterialDpr();if(GRAPH_MATERIAL_DPR!==null&&GRAPH_MATERIAL_DPR!==dpr)graphClearMaterialCache();GRAPH_MATERIAL_DPR=dpr;
- var key=[p.styleName,p.substrateKey,p.identityKey,tier,dpr].join('|');if(GRAPH_MATERIAL_CACHE.has(key)){var hit=GRAPH_MATERIAL_CACHE.get(key);GRAPH_MATERIAL_CACHE.delete(key);GRAPH_MATERIAL_CACHE.set(key,hit);return hit}
- var radius=GRAPH_MATERIAL_RADIUS[tier]||24,padding=tier==='full'?3:1.5,half=radius+padding,pixels=Math.max(2,Math.ceil(half*2*dpr)),canvas=typeof OffscreenCanvas!=='undefined'?new OffscreenCanvas(pixels,pixels):document.createElement('canvas');canvas.width=pixels;canvas.height=pixels;
- var spriteCtx=canvas.getContext('2d');if(!spriteCtx)return null;if(typeof spriteCtx.scale==='function'){spriteCtx.scale(dpr,dpr);graphPaintMaterialDirect(spriteCtx,half,half,radius,p,tier)}else graphPaintMaterialDirect(spriteCtx,half*dpr,half*dpr,radius*dpr,p,tier);
- var value={canvas:canvas,half:half,radius:radius};GRAPH_MATERIAL_CACHE.set(key,value);if(GRAPH_MATERIAL_CACHE.size>GRAPH_MATERIAL_CACHE_LIMIT)GRAPH_MATERIAL_CACHE.delete(GRAPH_MATERIAL_CACHE.keys().next().value);return value;
-}
-function graphPaintMaterialSurface(ctx,x,y,r,scale,profile,large,paintDirect){
- var screenRadius=r*Math.max(.01,scale),tier=graphMaterialTier(screenRadius,large);
- /* The full material sprite is intentionally bounded to 40 CSS pixels. On a focused or
- high-rank node, enlarging that raster sprite is what produces the blocky/pixelated blob
- users see in Classic. Paint only that exceptional node directly at its final size; keep
- ordinary nodes on the cache so the large graph remains responsive. */
- if(paintDirect&&tier==='full'&&screenRadius>GRAPH_MATERIAL_RADIUS.full){graphPaintMaterialDirect(ctx,x,y,r,profile,tier);return tier}
- var sprite=graphMaterialSprite(profile,tier);
- if(sprite&&typeof ctx.drawImage==='function'){var half=r*sprite.half/sprite.radius;ctx.drawImage(sprite.canvas,x-half,y-half,half*2,half*2)}else graphPaintMaterialDirect(ctx,x,y,r,profile,tier);
- return tier;
-}
-function graphStyleBackground(ctx,scale){
- if(GSTYLE==='galaxy'){
- if(GPERF.large)return;var t=performance.now()/1000,S=GSTARS;ctx.save();ctx.globalCompositeOperation='lighter';
- for(var i=0;i1,neighbor=focus&&GHOVERSET.has(node.id),dim=focus&&!neighbor;
- var r=node.radius,col=node.color,profile,directMaterial=node.id===GHILITE||node.rank===0;
- ctx.globalAlpha=dim?.12:1;
- if(GSTYLE==='galaxy'){
- profile=graphMaterialProfile('galaxy',col);graphPaintMaterialSurface(ctx,node.x,node.y,r,scale,profile,GPERF.large,directMaterial);
- }else if(GSTYLE==='solar'){
- var sun=node.rank===0;
- profile=graphMaterialProfile('solar',sun?graphMix(col,'#d38b43',.46):col);graphPaintMaterialSurface(ctx,node.x,node.y,r,scale,profile,GPERF.large,directMaterial);
- }else if(GSTYLE==='cyber'){
- profile=graphMaterialProfile('cyber',col);graphPaintMaterialSurface(ctx,node.x,node.y,r,scale,profile,GPERF.large,directMaterial);
- }else{
- profile=graphMaterialProfile('classic',col);graphPaintMaterialSurface(ctx,node.x,node.y,r,scale,profile,GPERF.large,directMaterial);
- }
- if(node.id===GHILITE){
- graphMaterialFill(ctx,node.x,node.y,r*.76,graphAlpha('#ffffff',.065));
- ctx.lineWidth=1.15/scale;ctx.strokeStyle=graphAlpha(profile.sheen,.98);ctx.beginPath();ctx.arc(node.x,node.y,r+1.35/scale,0,6.2832);ctx.stroke();
- ctx.lineWidth=.55/scale;ctx.strokeStyle=graphAlpha(profile.identity,.92);ctx.beginPath();ctx.arc(node.x,node.y,r+2.45/scale,0,6.2832);ctx.stroke();
- }
- ctx.globalAlpha=1;
-}
-function graphApplyStyleChrome(){
- var net=document.querySelector('.graph-network');if(net){for(const name of ['classic','galaxy','solar','cyber'])net.classList.toggle('graph-style-'+name,GSTYLE===name)}
- var sel=document.getElementById('graph-style');if(sel&&sel.value!==GSTYLE)sel.value=GSTYLE;
-}
-function graphStyleLinkColor(link){
- var layer=link&&link.layer;
- if(GSTYLE==='galaxy')return layer==='causal'?'#c58bff':'#91a8ff';
- if(GSTYLE==='solar')return layer==='causal'?'#ffc06d':'#ef913e';
- if(GSTYLE==='cyber')return layer==='causal'?'#ec71d2':'#6edce6';
- if(GSTYLE==='classic')return layer==='causal'?'#b9c8da':'#86c7d1';
- return (window.GCOL.links[layer]||window.GCOL.links.semantic).base;
-}
-function graphSetStyle(name){
- if(['classic','galaxy','solar','cyber'].indexOf(name)<0)name='cyber';
- GSTYLE=name;try{localStorage.setItem('engraphis-graph-style',name)}catch(e){}
- graphClearMaterialCache();graphApplyStyleChrome();
- if(GRAPH_ENGINE){GRAPH_ENGINE.setStyle(name);return}
- if(GRAPH&&FG){graphRefreshNodeColors();graphRenderLegend();graphRender(false,false);}
-}
-/* ─── colorful graphs even when every node is one entity type: color by community or connections ─── */
-var GRAPH_HEAT=['#3f7bff','#6a5cff','#a24bff','#e0479f','#ff6b6b','#ffc23d'];
-var COMMUNITY_PALS={
- classic:['#8c83e8','#5aafb3','#d7a84b','#6f9fd8','#58b882','#df7478','#b07de0','#4fb0a0','#e0894a','#7c9be0','#e06a9a','#9ac25a'],
- galaxy:['#b789ff','#7bb4ff','#66e0d0','#ffcf6b','#ff7ea8','#8aa2ff','#c98bff','#5ad0e0','#ffa0d0','#9d7bff','#6ad0b0','#ffb060'],
- solar:['#ffb454','#5b9bff','#3fd2c7','#ffd68a','#ff8f6b','#8ea8ff','#ffc24a','#6ac0d0','#ff9f7a','#7ab0ff','#e0b050','#5fd0b0'],
- cyber:['#22e0ff','#ff3ea5','#b6ff3c','#ffe14d','#8b7bff','#ff5c7a','#3affd0','#ff7be0','#7affea','#c0ff4a','#5c9bff','#ff9b3c']
-};
-var GCOLORBY='community';try{var _cb=localStorage.getItem('engraphis-graph-colorby');if(_cb)GCOLORBY=_cb;}catch(e){}
-var GMAXDEG=1;
-function graphCommunityPalette(){return COMMUNITY_PALS[(typeof GSTYLE!=='undefined'&&COMMUNITY_PALS[GSTYLE])?GSTYLE:'classic'];}
-function graphComputeCommunities(nodes){
- var byId={},seen=new Set(),groups=[];nodes.forEach(function(node){byId[node.id]=node;});
- nodes.forEach(function(node){
- if(seen.has(node.id))return;
- var group=[],stack=[node.id];seen.add(node.id);
- while(stack.length){
- var id=stack.pop();group.push(id);var neighbours=GCOMM_ADJ[id]||new Set();
- neighbours.forEach(function(next){if(byId[next]&&!seen.has(next)){seen.add(next);stack.push(next);}});
- }
- groups.push(group);
- });
- groups.sort(function(a,b){return b.length-a.length;});
- groups.forEach(function(group,index){group.forEach(function(id){byId[id].community=index;});});
-}
-function graphHeatColor(node){var total=(GACTIVE_DATA&&GACTIVE_DATA.nodes.length)||1;var t=(node.rank||0)/Math.max(1,total-1);var idx=Math.min(GRAPH_HEAT.length-1,Math.floor(t*GRAPH_HEAT.length));return GRAPH_HEAT[idx];}
-function graphNodeColor(node){
- if(GCOLORBY==='community'){var pal=graphCommunityPalette();return pal[(node.community||0)%pal.length];}
- if(GCOLORBY==='connections'){return graphHeatColor(node);}
- return graphTypeColor(node.etype);
-}
-function graphRenderLegend(graph){
- graph=graph||GRAPH;var legend=document.getElementById('graph-legend'),legendCount=document.getElementById('graph-legend-count');if(!legend||!graph)return;
- if(GCOLORBY==='community'){
- var nodes=(GACTIVE_DATA&&GACTIVE_DATA.nodes)||[],sizes={};nodes.forEach(function(n){var c=n.community||0;sizes[c]=(sizes[c]||0)+1;});
- var order=Object.keys(sizes).map(Number).sort(function(a,b){return sizes[b]-sizes[a];}),pal=graphCommunityPalette();
- legend.innerHTML=order.slice(0,10).map(function(c,i){return '
';
- if(legendCount)legendCount.textContent=types.length?types.length+(types.length===1?' type':' types'):'';
-}
-function graphSetColorBy(mode){
- if(['type','community','connections'].indexOf(mode)<0)mode='community';
- GCOLORBY=mode;try{localStorage.setItem('engraphis-graph-colorby',mode)}catch(e){}
- graphClearMaterialCache();
- var sel=document.getElementById('graph-colorby');if(sel&&sel.value!==mode)sel.value=mode;
- if(GRAPH_ENGINE){GRAPH_ENGINE.setColorBy(mode);graphRenderLegend();return}
- if(GRAPH&&FG&&GACTIVE_DATA){graphComputeCommunities(GACTIVE_DATA.nodes);GMAXDEG=GACTIVE_DATA.nodes.reduce(function(m,n){return Math.max(m,n.degree||0);},1);graphRefreshNodeColors();graphRenderLegend();}
-}
-function graphApplyForces(){
- if(!FG)return;
- const settings=window.GSET,mode=settings.mode||'compact';
- FG.d3Force('charge').strength(-(mode==='communities'?Math.max(10,settings.repel*.68):settings.repel));
- FG.d3Force('link').distance(settings.link);
- if(typeof d3==='undefined')return;
- FG.d3Force('radial',null);
- const layoutNodes=GACTIVE_DATA&&GACTIVE_DATA.nodes||[];
- /* Each named mode owns a different target geometry. Slider values still control local
- spacing, but switching buttons must visibly change the arrangement even for one component. */
- if(mode==='communities'){
- const keys=[],seen=new Set();layoutNodes.forEach(node=>{const key=Number.isFinite(node.community)?node.community:0;if(!seen.has(key)){seen.add(key);keys.push(key)}});keys.sort((a,b)=>a-b);
- const cols=Math.max(1,Math.ceil(Math.sqrt(keys.length))),rows=Math.max(1,Math.ceil(keys.length/cols)),gap=Math.max(180,(Number(settings.link)||16)*10),targets=new Map();
- keys.forEach((key,index)=>{const col=index%cols,row=Math.floor(index/cols);targets.set(key,{x:(col-(cols-1)/2)*gap,y:(row-(rows-1)/2)*gap*.72})});
- const centering=Math.max(.04,(Number(settings.gravity)||0)/100);FG.d3Force('x',d3.forceX(0).strength(centering));FG.d3Force('y',d3.forceY(0).strength(centering));
- }else if(mode==='radial'&&d3.forceRadial){
- const outer=Math.max(180,Math.min(360,Math.sqrt(Math.max(1,layoutNodes.length))*18+(Number(settings.link)||16)*4)),maxDegree=Math.max(1,layoutNodes.reduce((max,node)=>Math.max(max,node.degree||0),1));
- FG.d3Force('x',d3.forceX(0).strength(Math.max(.05,(Number(settings.gravity)||0)/500)));FG.d3Force('y',d3.forceY(0).strength(Math.max(.05,(Number(settings.gravity)||0)/500)));
- FG.d3Force('radial',d3.forceRadial(node=>{const hubness=Math.max(0,Math.min(1,(node.degree||0)/maxDegree));return 34+(outer-34)*(1-hubness)}).strength(.72));
- }else if(mode==='constellation'){
- const positions=new Map(),total=Math.max(1,layoutNodes.length-1),reach=Math.max(160,Math.min(330,80+Math.sqrt(Math.max(1,layoutNodes.length))*10));
- layoutNodes.forEach((node,index)=>{const rank=Number.isFinite(node.rank)?node.rank:index,fraction=Math.max(0,Math.min(1,rank/total)),angle=index*2.399963229728653,radius=48+fraction*reach;positions.set(node.id,{x:Math.cos(angle)*radius*1.18,y:Math.sin(angle)*radius*.76})});
- const target=node=>positions.get(node.id)||{x:0,y:0};FG.d3Force('x',d3.forceX(node=>target(node).x).strength(.18));FG.d3Force('y',d3.forceY(node=>target(node).y).strength(.18));
- }else{
- const centering=mode==='compact'?Math.max(.24,(Number(settings.gravity)||0)/100):Math.max(.06,(Number(settings.gravity)||0)/100);
- FG.d3Force('x',d3.forceX(0).strength(centering));FG.d3Force('y',d3.forceY(0).strength(centering));
- }
- FG.d3Force('collide',d3.forceCollide(node=>node.radius+1.5).iterations(GPERF.large?1:2));
-}
-function graphSetHighlight(id){
- GHILITE=id||null;
- GHOVERSET=id?new Set([id,...(GADJ[id]||[])]):null;
- /* graphRedraw() is a no-op without the classic FG instance, so the opt-in engine needs
- telling directly — otherwise hovering the entity list highlights nothing on canvas. */
- if(GRAPH_ENGINE){try{GRAPH_ENGINE.setHighlight(GHILITE)}catch(e){}return}
- graphRedraw();
-}
-function graphRefreshNodeMetrics(){
- const nodes=FG&&FG.graphData?FG.graphData().nodes||[]:[];
- const maxDegree=Math.max(1,...nodes.map(node=>node.degree||0));
- nodes.forEach(node=>{node.radius=graphNodeRadius(node,window.GSET.size,(node.degree||0)/maxDegree)});
-}
-function graphRedraw(){
- if(!FG||GREDRAWFRAME)return;
- GREDRAWFRAME=requestAnimationFrame(()=>{GREDRAWFRAME=0;if(FG)FG.nodeCanvasObject(FG.nodeCanvasObject())});
-}
-/* ── on-demand graph assets ───────────────────────────────────────────────────────────────
- Neither script is in index.html. force-graph.min.js applies inline styles at runtime, and
- under the production CSP (`style-src 'self'`) every one of those is blocked and reported;
- loading it on a page that never opens the graph turns a plain dashboard view into a wall of
- console errors. Both loaders are memoized, so a re-entrant graphRender() reuses the in-flight
- fetch rather than appending a second
+