diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..0f1c53fab --- /dev/null +++ b/.gitattributes @@ -0,0 +1,29 @@ +# Line-ending policy. +# +# Shell scripts MUST be checked out with LF endings on every platform. +# Git for Windows installs with `core.autocrlf=true` by default, which +# rewrites LF -> CRLF on checkout. A strict bash (WSL, Linux CI, macOS) +# then chokes on the trailing CR: +# +# scripts/run-unit-parallel.sh: line 23: $'\r': command not found +# scripts/run-unit-parallel.sh: line 24: set: pipefail : invalid option name +# scripts/run-unit-parallel.sh: line 32: syntax error near unexpected token `$'{\r'' +# +# That silently disabled `bun run test`, `bun run verify`, `bun run ci:local` +# and `bun run test:e2e` for Windows contributors, since all four dispatch +# through bash. `eol=lf` pins the checkout regardless of the user's +# core.autocrlf setting. +*.sh text eol=lf + +# Markdown gets the same pin, for a different failure mode: the frontmatter +# parsers anchor on LF. Under a CRLF checkout the opening fence becomes +# "---\r\n", which an LF-only /^---\n/ (or a startsWith("---\n")) does not +# match, so a well-formed document silently parses as having no frontmatter. +# There is no error -- the field just comes back empty. That has surfaced as +# blank skill descriptions, a fixer inserting its banner above the +# frontmatter instead of below it, resolver trigger extraction dropping +# entries, and a generated-doc freshness check reporting every line as +# drifted. The parsers stay CR-tolerant on their own merits (gbrain reads +# Markdown it does not own), but pinning this repo's own .md checkout to LF +# removes the whole class for anyone working here. +*.md text eol=lf diff --git a/.github/workflows/actionlint.yml b/.github/workflows/actionlint.yml index b4b6b8272..a6973ec4b 100644 --- a/.github/workflows/actionlint.yml +++ b/.github/workflows/actionlint.yml @@ -28,5 +28,5 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - uses: rhysd/actionlint@393031adb9afb225ee52ae2ccd7a5af5525e03e8 # v1.7.11 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 5142a47c2..9d467f513 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -45,7 +45,7 @@ jobs: --health-timeout 5s --health-retries 5 steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: 1.3.13 @@ -61,7 +61,10 @@ jobs: - name: Run JSONB double-encode parity tests on real Postgres env: DATABASE_URL: postgresql://postgres:postgres@localhost:5432/gbrain_test - run: bun test test/e2e/op-checkpoint-jsonb-parity.test.ts test/e2e/jsonb-roundtrip.test.ts + # --timeout also raises bun's 5s default hook budget (beforeAll/afterAll + # do NOT inherit a test's third-arg timeout; verified on bun 1.3.x). + # Every runner script in scripts/ passes it; bare invocations must too. + run: bun test --timeout=60000 test/e2e/op-checkpoint-jsonb-parity.test.ts test/e2e/jsonb-roundtrip.test.ts tier1: name: Tier 1 (Mechanical) @@ -82,13 +85,13 @@ jobs: --health-timeout 5s --health-retries 5 steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: 1.3.13 - run: bun install - name: Run Tier 1 E2E tests - run: bun test test/e2e/mechanical.test.ts test/e2e/mcp.test.ts + run: bun test --timeout=60000 test/e2e/mechanical.test.ts test/e2e/mcp.test.ts env: DATABASE_URL: postgresql://postgres:postgres@localhost:5432/gbrain_test @@ -116,7 +119,7 @@ jobs: --health-timeout 5s --health-retries 5 steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: 1.3.13 @@ -155,7 +158,7 @@ jobs: } EOF - name: Run Tier 2 skill tests - run: bun test test/e2e/skills.test.ts test/e2e/zeroentropy-live.test.ts + run: bun test --timeout=60000 test/e2e/skills.test.ts test/e2e/zeroentropy-live.test.ts env: DATABASE_URL: postgresql://postgres:postgres@localhost:5432/gbrain_test OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} diff --git a/.github/workflows/heavy-tests.yml b/.github/workflows/heavy-tests.yml index bf21a88da..8d3b761f2 100644 --- a/.github/workflows/heavy-tests.yml +++ b/.github/workflows/heavy-tests.yml @@ -55,7 +55,7 @@ jobs: --health-timeout 5s --health-retries 5 steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: 1.3.13 diff --git a/.github/workflows/osv-scanner.yml b/.github/workflows/osv-scanner.yml new file mode 100644 index 000000000..8c8b4f9b1 --- /dev/null +++ b/.github/workflows/osv-scanner.yml @@ -0,0 +1,33 @@ +name: OSV-Scanner + +# Dependency vulnerability scan (#2182) via Google's official reusable +# workflow. Runs weekly and on any PR that touches the dependency manifests. +# Tokenless: needs zero secrets. Findings are reported in the job log and as +# a SARIF artifact on the run; code-scanning upload is deliberately disabled +# so the workflow stays read-only (no security-events: write). + +on: + pull_request: + branches: [master] + paths: + - 'bun.lock' + - 'package.json' + schedule: + - cron: '30 6 * * 1' # weekly, Monday 06:30 UTC + workflow_dispatch: + +permissions: + contents: read + +jobs: + osv-scan: + permissions: + actions: read + contents: read + # Required by the reusable workflow's own top-level permissions block — + # GitHub validates the caller grants a superset AT STARTUP, even with + # upload-sarif: false (nothing is actually uploaded; see #2117 upstream). + security-events: write + uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8 + with: + upload-sarif: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5245f5ec3..aee63a510 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,14 +1,67 @@ name: Release +# Publishes a GitHub release for every VERSION bump that lands on master: +# tag + title `v`, notes from that version's CHANGELOG.md entry, +# compiled binaries attached (#3521). +# +# Why every bump: `gbrain check-update` resolves the latest version from the +# VERSION file on master, but binary self-update +# (src/core/binary-self-update.ts) downloads assets from `releases/latest`. +# If releases lag VERSION, binary installs are told an upgrade exists that +# self-update cannot apply. Keeping releases/latest == VERSION closes that gap. +# +# Idempotent: the `version` job skips build+release when a release for +# v already exists WITH all expected assets. A half-published release +# (tag exists / assets incomplete) is repaired on the next run — softprops +# updates the existing release in place. Historical 3-segment tags are never +# touched; a new 4-segment VERSION always mints a new tag. +# +# The asset names are a contract with expectedAssetName() in +# src/core/binary-self-update.ts, pinned by test/release-workflow.test.ts. + on: push: - tags: ['v*'] + branches: [master] + paths: [VERSION] + workflow_dispatch: {} # manual first run / backfill of the current VERSION permissions: - contents: write + contents: read + +concurrency: + group: release + cancel-in-progress: false jobs: + version: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.v.outputs.version }} + exists: ${{ steps.v.outputs.exists }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - id: v + name: Read VERSION and check for an existing complete release + env: + GH_TOKEN: ${{ github.token }} + run: | + version="$(tr -d '[:space:]' < VERSION)" + echo "version=$version" >> "$GITHUB_OUTPUT" + # Complete = release exists AND carries every asset the self-updater + # can request. A partial release must NOT short-circuit, so a re-run + # can repair it. + assets="$(gh release view "v$version" --repo "$GITHUB_REPOSITORY" \ + --json assets --jq '[.assets[].name] | sort | join(",")' 2>/dev/null || true)" + if [ "$assets" = "gbrain-darwin-arm64,gbrain-linux-x64" ]; then + echo "exists=true" >> "$GITHUB_OUTPUT" + echo "Release v$version already published with all assets — nothing to do." + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + build: + needs: version + if: needs.version.outputs.exists == 'false' strategy: matrix: include: @@ -19,30 +72,68 @@ jobs: target: bun-linux-x64 artifact: gbrain-linux-x64 runs-on: ${{ matrix.os }} + permissions: + contents: read + id-token: write # for attest-build-provenance (Sigstore OIDC) + attestations: write # for attest-build-provenance steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: 1.3.13 - run: bun install - - run: bun test + # No test re-run here: the Test workflow already gated this exact SHA at + # merge (10 shards + E2E). Re-running the whole suite serially on the + # release runner is a flakier duplicate gate — it blocked the first + # release on ambient-env tests (run 30698650484). The build job's gate + # is the artifact itself: compile, then smoke-test the binary. - run: bun build --compile --target=${{ matrix.target }} --outfile bin/${{ matrix.artifact }} src/cli.ts + - name: Smoke-test the compiled binary + run: | + chmod +x bin/${{ matrix.artifact }} + out="$(./bin/${{ matrix.artifact }} --version)" + echo "binary reports: $out" + v="$(tr -d '[:space:]' < VERSION)" + case "$out" in *"$v"*) echo "version matches VERSION file" ;; *) echo "binary version '$out' does not contain '$v'" >&2; exit 1 ;; esac + - name: Attest build provenance + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: bin/${{ matrix.artifact }} - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: ${{ matrix.artifact }} path: bin/${{ matrix.artifact }} release: - needs: build + needs: [version, build] + if: needs.version.outputs.exists == 'false' runs-on: ubuntu-latest + permissions: + contents: write # create the tag + release (scoped to this job only) steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: path: artifacts + - name: Extract CHANGELOG entry for release notes + # env-bound, not inlined into the script: VERSION comes from master so + # it isn't attacker-reachable today, but a `${{ }}` inside `run:` is + # shell injection by construction if that ever changes. + env: + RELEASE_VERSION: ${{ needs.version.outputs.version }} + run: | + v="$RELEASE_VERSION" + if ! bash scripts/changelog-entry.sh "$v" > /tmp/release-notes.md || ! [ -s /tmp/release-notes.md ]; then + echo "See [CHANGELOG.md](https://github.com/${GITHUB_REPOSITORY}/blob/master/CHANGELOG.md) for v$v." > /tmp/release-notes.md + fi - name: Create release - uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2 + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 with: + tag_name: v${{ needs.version.outputs.version }} + name: v${{ needs.version.outputs.version }} + target_commitish: ${{ github.sha }} + body_path: /tmp/release-notes.md + fail_on_unmatched_files: true files: | artifacts/gbrain-darwin-arm64/gbrain-darwin-arm64 artifacts/gbrain-linux-x64/gbrain-linux-x64 - generate_release_notes: true diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml new file mode 100644 index 000000000..d51ac05f1 --- /dev/null +++ b/.github/workflows/semgrep.yml @@ -0,0 +1,36 @@ +name: Semgrep + +# Static analysis (SAST) with Semgrep Community Edition (#2272). Tokenless: +# uses the public registry rulesets, needs zero secrets. Findings print in +# the job log; no code-scanning/SARIF upload by design (keeps permissions +# read-only, no security-events: write). + +on: + pull_request: + branches: [master] + schedule: + - cron: '30 7 * * 1' # weekly, Monday 07:30 UTC + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + semgrep: + runs-on: ubuntu-latest + timeout-minutes: 20 + container: + image: semgrep/semgrep:1.170.0@sha256:c98f8829eea377274ee4b10656458b078b88232469b2ff913f091c2317347c9d + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + # Non-blocking initially (continue-on-error): the first runs establish a + # baseline without failing unrelated PRs. Graduation path: once the + # baseline findings are triaged (fixed or `# nosemgrep`'d), remove + # continue-on-error so new findings block PRs. + - name: Semgrep scan (report-only) + run: semgrep scan --config p/default --config p/typescript --error + continue-on-error: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4b6430895..4f8a06caa 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -43,7 +43,7 @@ jobs: hit: ${{ steps.lookup.outputs.cache-hit }} hash: ${{ steps.compute.outputs.hash }} steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Compute content hash id: compute run: | @@ -84,10 +84,10 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: fetch-depth: 0 - - uses: gitleaks/gitleaks-action@dcedce43c6f43de0b836d1fe38946645c9c638dc # v2 + - uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -103,7 +103,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 12 steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: 1.3.13 @@ -113,6 +113,11 @@ jobs: key: bun-cache-${{ runner.os }}-${{ hashFiles('bun.lock') }} - run: bun install - run: bun run verify + # Guard: no bare `bun test` in workflows/scripts — bun ignores + # bunfig.toml's timeout, and hooks (beforeAll/afterAll) get the 5s + # default regardless of per-test third-arg timeouts. Runs directly + # (not via verify's CHECKS array) to avoid a package.json edit. + - run: bash scripts/check-bun-test-timeout.sh serial-tests: # *.serial.test.ts at --max-concurrency=1. Lives in its own runner so @@ -124,7 +129,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: 1.3.13 @@ -149,7 +154,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 12 steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: 1.3.13 @@ -172,7 +177,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 12 steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: 1.3.13 @@ -219,13 +224,17 @@ jobs: needs: cache-check if: needs.cache-check.outputs.hit != 'true' runs-on: ubuntu-latest - timeout-minutes: 15 + # 22, not 15: under parallel PR load the PGLite WASM cold-starts stretch a + # shard past 15 min while every test is still passing — the timeout then + # cancels the job and the test-status gate reads it as a failure. 13 runs + # died this way on 2026-07-21/22 alone. + timeout-minutes: 22 strategy: fail-fast: false matrix: shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: 1.3.13 diff --git a/.gitignore b/.gitignore index d0ad7dfad..da4b7dc43 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ -node_modules/ +# No trailing slash: a bare `node_modules/` pattern matches directories only, +# so a *symlink* named node_modules slips past it and can be committed +# (that's how the /tmp-pointing symlink in faf5cdba got in). Match any type. +node_modules bin/ .DS_Store *.log @@ -15,7 +18,7 @@ supabase/.temp/ # self-contained binaries (the bun --compile path embeds it via # `import path from 'admin/dist/index.html' with { type: 'file' }`). # Build via: cd admin && bun install && bun run build. -admin/node_modules/ +admin/node_modules .idea eval/reports/ eval/data/world-v1/world.html @@ -35,6 +38,11 @@ export/ # .context/test-shards/. Workspace-local by design — never committed. .context/ +# Local agent instruction overrides (CLAUDE.local.md / AGENTS.local.md) — personal, +# per-clone, loaded after the committed CLAUDE.md/AGENTS.md. Never committed. +CLAUDE.local.md +AGENTS.local.md + # Tier 3 PGLite snapshot fixture (built on demand by build:pglite-snapshot) test/fixtures/pglite-snapshot.tar test/fixtures/pglite-snapshot.version diff --git a/CHANGELOG.md b/CHANGELOG.md index bda13c827..0157d65aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,711 @@ complete operation catalog — both speak the verbs). Run `gbrain protocol confo to self-certify, and `gbrain protocol stats` to watch adoption. Memories your agent saves are readable by every agent connected to the brain by default; pass `visibility: "private"` for local-only facts. +## [0.42.73.2] - 2026-08-05 + +**A write that deduplication redirects onto an existing page is now checked against the write scope of whoever asked for it.** When the same content arrives under a new slug, gbrain recognises it and points the write at the page that already holds it. That redirected target is now tested against the caller's own scope — under whichever mechanism confines that caller. One of the two mechanisms was consulted at that point; both are now. + +Nothing changes for local CLI use, or for clients that hold unrestricted write access — neither was ever scope-confined. A confined caller whose write dedups onto a page **inside** its own scope keeps working exactly as before; that redirect is a feature and it is preserved, with a regression test to keep it that way. A confined caller whose write dedups onto a page **outside** its scope now gets `permission_denied`, with the remedy in the message: drop the `id:` frontmatter field, or change the content, to write a new page under your own prefix. The denial does not name the page the write resolved to. + +Recommended for any brain served over HTTP to scope-restricted clients. + +### To take advantage of v0.42.73.2 + +```bash +gbrain upgrade +``` + +Nothing to configure. Existing clients keep their scopes unchanged, and no re-registration is needed. + +### For contributors + +Reported privately by an external security researcher, who supplied a fix and a regression test with it. The version that shipped composes the two existing scope-matching rules into a single predicate rather than restating either one, so the check at the door and the check after a redirect cannot drift apart; the audit the report prompted closed the same gap on one further caller path. + +## [0.42.73.1] - 2026-08-05 + +**Removes the PR gate that v0.42.73.0 added, and reverts the v0.42.72.1 contribution-policy change it enforced.** The gate cannot function on this repository, and it caused a real incident before that was understood. + +The gate needed two things this repository does not grant it: an `ANTHROPIC_API_KEY` Actions secret for its verdict, and read-write workflow permissions to post a comment or set a label. Without them it can only skip. Worse, on its first live runs a read-only token turned every API call into a 403, the code treated that as a crash, and the check went red on an outside contributor's pull request four times with no comment explaining why. That was fixed in v0.42.73.0, but a check that runs on every pull request and can never reach a verdict does not earn its place in the repository. + +The v0.42.72.1 contribution policy is also withdrawn: the human-written intent paragraph and gbrain-in-use screenshot are no longer required on issues and pull requests. `CONTRIBUTING.md`, both issue templates, and the pull-request template return to their pre-2026-08-02 state, and issues and PRs are reviewed on their content by maintainers, as before. + +The code is preserved in git history at v0.42.73.0 and can be restored if the repository ever grants those permissions. If it is restored, the mechanical half — the intent and screenshot check, the version-first title rule, the red flags — should render to the Actions job summary instead of a comment, because that needs no token permission and no API key. + +### To take advantage of v0.42.73.1 + +```bash +gbrain upgrade +``` + +Nothing to change. Everything else v0.42.73.0 shipped — the five contributed correctness fixes, `slug_filter`, and the four dependency pins that cleared six CVEs — is unaffected and stays. + +## [0.42.73.0] - 2026-08-04 + +**Every incoming pull request now gets a verdict before anyone reads it — and five contributed fixes for silent wrong answers.** + +**The PR gate.** Open a pull request against gbrain and an automated check now posts a single verdict comment within a minute: **merge-lane**, **close-lane**, or **needs-maintainer**, with its reasons and a checklist of what a human reviewer should verify for that specific diff. It also checks mechanically that the description carries the human-written intent paragraph and the screenshot of gbrain in use that `CONTRIBUTING.md` requires, and that the title leads with its version. + +It is deliberately **advisory** — a triage signal and a reviewer checklist, not an authorization boundary. A green verdict is not permission to merge; a maintainer still decides. Pull-request code is never checked out or executed: the verdict comes from the description and the diff read through the API. Maintainer, bot, and draft pull requests are exempt from the intent-and-screenshot floor only (release automation cannot screenshot itself); they still receive the full verdict. Where the rubric can be argued with, the decision is taken away from it: a merge-lane recommendation is downgraded automatically when a diff adds a dependency, a new provider recipe, or new config keys, edits workflows, deletes a test, exceeds 40 files or 400 net source lines, or changes `src/` without touching a single test. + +**Your import output parses again.** `gbrain import --json` printed five informational lines to stdout ahead of the JSON payload, so anything parsing that output read zero imports while its own bookkeeping recorded the files as ingested — and the next run skipped them permanently. Those lines now go to stderr under `--json`; human output is byte-for-byte unchanged. + +**`sources harden --dry-run` no longer changes anything.** It reset the helper's executable bit before reaching the dry-run check, so a documented preview quietly mutated permissions. + +**Telemetry records the model that actually ran.** Two nightly-cycle phases wrote a hardcoded or unrelated model name into their verdict cache, evidence signature, and spend metering while the gateway ran whatever chat model you configured. On any brain with a non-default model, the recorded history was fiction. + +**`gbrain integrity` stops contradicting itself.** Dead-link findings were counted in the "Review queue" total but written to a different file, so `integrity review` disagreed with `integrity auto`'s own summary. They now get their own line. + +**Retype rules can address API-ingested pages.** Mapping rules could only filter on a file path, which is empty for every page written through `put_page` — so no rule could target that whole class. A new `slug_filter` filters on the slug instead, and combines with the path filter when both are given. + +Also: the `integrity` source comment no longer documents a `--dry-run` subcommand form that exits with an error. + +### To take advantage of v0.42.73.0 + +```bash +gbrain upgrade +gbrain import --json | jq . # now parses +gbrain integrity auto # dead links reported separately +``` + +Nothing to configure for the gate — it runs on pull requests to this repository. If you maintain a fork and want it, the workflow needs an `ANTHROPIC_API_KEY` secret; without one it skips loudly rather than blocking anyone. + +### For contributors + +The gate went through six rounds against two independent blind reviewers, each judging cold. The findings that changed the design most were not exploits but false positives: a code fence that swallowed the rest of a description, an explanation written as bullet points scoring zero words, a word floor stricter than the published policy, and a comment telling contributors to reopen a pull request that was never closed. Those four descriptions are now permanent regression fixtures — a gate that insults a first-time contributor is worse than no gate. Two properties are deliberate and documented rather than fixed: the mechanical floor is a floor (a determined author clears it in seconds), and a bare URL in a cited reason still autolinks. + +Contributed by @YiconZiwei (#2655), @time-attack (#3764, #3759, #3726, #3751, #3739, and the gate groundwork in #3573/#3698). + +## [0.42.72.1] - 2026-08-02 + +**Every issue and pull request now needs a human-written paragraph and a screenshot of gbrain actually being used.** + +Effective immediately, opening an issue or a PR requires two things from you personally: a paragraph you wrote yourself saying why you're opening it — what you were doing, what went wrong or what you needed, why it matters — and a screenshot of your terminal, agent session, or logs showing the real situation. Rough grammar is fine and preferred over polish. AI-generated or AI-polished intent text is not accepted; the paragraph is the human part. AI assistance for the *code* is still welcome. + +Issues and PRs missing either are closed without review, and can be reopened once both are added. Scrub private names, companies, keys, and brain contents from screenshots before attaching — a redacted screenshot is fine, a missing one is not. + +The requirement is stated in `CONTRIBUTING.md` and pre-filled in the bug-report and feature-request issue templates plus a new pull-request template, so the fields are in front of you when you open one. + +## [0.42.72.0] - 2026-08-01 + +**Per-person write isolation inside a shared source, and a guide for putting gbrain behind a multi-user agent harness.** + +Until now, `--source` was the only write boundary: a client could write anywhere inside the source it was scoped to, and keeping each person in their own folder was a convention the agent had to honor by itself. Registering a client with `--bound-slug-prefixes` now makes that boundary real. Writes outside the bound prefixes are refused by the server, on every op that can name a page. + +**Adding a binding to an existing client narrows it on purpose.** Ops that write by something other than a page slug can't be confined to a prefix, so a bound client is refused them outright rather than left with an unfenced path: `extract_entities`, `extract_facts`, `forget_fact`, `ontology_propose`, `sources_add`/`sources_remove`, and `POST /ingest`. `put_page`'s automatic fact extraction is skipped for the same reason — it writes to entity pages the caller never named. Reads are unaffected, and unbound clients behave exactly as before. The gate keys on "anything that is not a plain read", so an op added in a future release is refused to bound clients until it is explicitly fenced. + +Both prefix spellings work: the `wiki/agents/alice/*` glob that `submit_agent` bindings already use, and the plainer `emp-alice/` form. Change a binding in place with `gbrain auth rescope-client --bound-slug-prefixes ` — existing tokens pick it up on their next request, so no secret rotation is needed when someone joins or leaves a team. + +**New guide: [gbrain as the company brain for a qm deployment](docs/integrations/qm-harness.md).** qm is a multiplayer agent harness where each employee and each channel gets an isolated agent scope. The guide covers the whole path — one central `gbrain serve --http`, the thin-client binary baked into the sandbox image, one OAuth client per scope, and a roster-driven provisioning script that converges the brain to a list of people and channels. It also states plainly what the model does *not* give you: within a shared source, reads stay source-granular, so prefix isolation is a write boundary, not a privacy boundary. +gbrain upgrade # or: bun install -g gbrain@0.42.72.0 +gbrain apply-migrations --yes # required: the fence refuses writes it cannot evaluate +``` + +To fence an existing client to a folder: + +```bash +gbrain auth rescope-client --bound-slug-prefixes partners/alice-example/ +gbrain auth rescope-client --bound-slug-prefixes none # undo +``` + +Verify it took, from a client holding that credential — the first write should succeed and the second should be refused: + +```bash +gbrain put partners/alice-example/notes/test --content "mine" +gbrain put partners/bob-example/notes/test --content "not mine" +``` + +## [0.42.71.0] - 2026-08-01 + +**GBrain now publishes real releases. Every version bump from here on lands on the [Releases page](https://github.com/garrytan/gbrain/releases) with organized notes and downloadable binaries — and binary self-update finally works.** + +Until now the repo had no releases at all: `gbrain check-update` could tell you a new version existed, but `gbrain self-upgrade` downloaded from an empty releases API and failed every time, and anyone trying to follow what shipped had to read raw commit history. That's what people have been (rightly) complaining about. + +From this release forward, every version bump automatically: + +- **Tags the commit** (`v0.42.71.0`) so versions are addressable in git. +- **Publishes a GitHub Release** whose notes are that version's CHANGELOG entry — the same organized, user-facing writeup, not a commit dump. +- **Attaches compiled binaries** for macOS (arm64) and Linux (x64), so `gbrain self-upgrade` and fresh binary installs work without a toolchain. + +The pipeline is idempotent: a partial release (tag exists, assets incomplete) is repaired on the next run instead of wedging. It runs post-merge, so a flaky release build can never turn master red. Releases for today's two fix waves (v0.42.69.0 and v0.42.70.0) have been backfilled with their CHANGELOG notes so the Releases page tells the whole story of the day; binaries attach from v0.42.71.0 onward. + +### To take advantage of v0.42.71.0 + +```bash +gbrain check-update # now resolves against real releases +gbrain self-upgrade # now actually downloads a binary +``` + +Or browse https://github.com/garrytan/gbrain/releases for organized per-version notes. + +### For contributors + +`docs/RELEASING.md` gains the release-publication section; `scripts/changelog-entry.sh` extracts a version's CHANGELOG section (used for release notes — keep entries under the standard `## [X.Y.Z.W]` headers and they publish verbatim). The workflow keeps all actions SHA-pinned, tightens top-level permissions to `contents: read` with write scoped to the release job only, and env-binds all interpolations. + +Contributed by @time-attack (#3573, closing #3521). + +## [0.42.70.0] - 2026-08-01 + +**Community fix wave two: 18 contributed fixes. The headline: several things you asked gbrain to do were being quietly ignored — and now they aren't.** + +**`--brain` now actually routes.** The documented `gbrain query "X" --brain media-team` parsed the flag and then ran against your host brain anyway. It now routes to the named brain, and an unknown brain name fails loudly instead of silently answering from the wrong database. + +**`sync --dry-run` no longer touches anything.** A dry run could pull from the remote and — if your sync strategy had changed — delete indexed pages before the "dry run" early-return was reached. Previews are now read-only, full stop. + +**`apply-migrations --yes` applies.** It previously warned that your schema was behind and then printed "All migrations up to date" with exit 0. If you have wedged brains that upgrade never healed, this was why. + +**Links between your pages resolve the way you write them.** Dir-qualified wikilinks with raw Obsidian names (`[[wiki/entities/AI 3.0]]`) now resolve to the sync-slugified page; references in non-whitelisted directories are no longer silently dropped; and a scan bug that could add an edge to a *parent* page you never referenced was caught in the wave's composite review and fixed before shipping. + +**Windows and self-hosters.** Markdown files keep LF endings so frontmatter parsers stop mis-reading on Windows checkouts; the archive-crawler path gate no longer denies every real Windows path (and no longer fail-opens on NTFS case-insensitivity); a chat-synopsis tier that was hardcoded to one provider now follows your configured models; vector search asks the index for as many candidates as it was told to consider. + +**Quieter, more honest infrastructure.** `serve --http` no longer leaves an orphan holding the database lock after Ctrl-C; a minion child that fails to launch settles immediately instead of hanging its slot; doctor gains checks for content-hash duplicates, undeclared database-only pages, stale heartbeats, and a tamper-evident manifest for the skills directory; federated reads respect per-source isolation settings in two more paths; and the security docs were rewritten to describe fixes without cataloguing attack surface. +gbrain upgrade +gbrain extract --stale # re-extracts links under the fixed resolver +gbrain doctor # includes the new silent-failure checks +``` + +If your brain uses `link_resolution.global_basename` and was populated before this release, a small number of superseded `wikilink_basename` edges can linger beside their newer typed replacements after re-extraction (edge writes are append-only by design). `gbrain reconcile-links` cleans them up; they are harmless to queries that dedup on target. + +### For contributors + +The composite review of this wave (two independent max-effort review passes over the combined branch) caught two interaction defects that per-PR review could not: the ungated bare-path scanner reading inside wikilink spans, and an extraction watermark set to a date that same-day stamps would already outrun. Both were fixed in the wave with discriminating tests. One reviewed-and-approved PR was deliberately held out: it conflicts semantically with its author's own sibling PR in this wave, and choosing between their two path-resolution mechanisms is the author's call. + +Contributed by @time-attack (#3618, #3085, #3539, #3576, #3533, #3453, #3457, #3560, #3161), @daragao3 (#3619, #3536, #3517, #3578), @paul-0320 (#3613, #3564), @cvillarroel2 (#3678), @mamedov (#3624), @dialthewolff (#3550). + +## [0.42.69.0] - 2026-08-01 + +**A community fix wave: 22 contributed fixes, most of them for work your brain was quietly not doing.** + +The theme of this release is silent failure. A nightly cycle that reported `ok` while extracting nothing. An `embed` run that left a whole page unsearchable because one chunk in it failed, then exited 0. A health metric that recommended the same step forever because it counted one thing and the fix measured another. None of these looked broken from the outside, which is exactly why they lasted. + +**If you run a local or non-Anthropic model, atom extraction was doing nothing.** With a cost cap set, any model absent from the pricing tables made the first work item hard-fail, which latched a budget flag and skipped every remaining item — while the phase still reported success. Local models (`ollama`, `llama-server`) now price at $0, because local inference costs electricity rather than tokens, so their caps stay enforceable. Genuinely unpriced paid providers still skip, but loudly now instead of silently. + +**`gbrain embed` no longer lets one bad chunk darken an entire page,** and it exits non-zero when embeddings actually fail. If you have a cron wrapping `gbrain embed`, a brain holding permanently un-embeddable content will now turn that cron red. That is the intended change — it was previously green while silently incomplete. + +**Non-Latin and diacritic names now survive mention extraction.** The by-mention tokenizer matched ASCII letters and digits only, so `Đà Nẵng` shredded into one- and two-character fragments and never matched anything. Names in Vietnamese, and any script outside ASCII, are now tokenized properly. + +**Self-hosted embedding backends work.** Fixed-dimension OpenAI-compatible servers that reject an explicit `dimensions` parameter no longer get sent one when the requested width already matches the model's native width. A vector search on the embedded database also now asks the index for as many candidates as it was told to consider, instead of silently truncating the pool to the driver default. + +**Multi-source brains route correctly in two more places.** A programmatic `sync_brain` call now syncs the source it was handed rather than the global default, and entity slug resolution keeps its path separators instead of flattening `people/alice-example` into an id no page can hold. + +**Safer default on a destructive migration.** Submitting the type-unification job without an explicit `apply` now previews instead of applying. If you have that command in a runbook, add `"apply":true` — the playbooks and README were updated to show it. + +Also: interrupted imports keep their tail instead of losing progress below the next 100-file boundary; `gbrain init --help` prints its own help instead of a stub; `doctor` stops reporting Windows drive paths as missing files under WSL and bounds its embedding health probe instead of retrying a permanent auth failure three times; cycle lock-release and stamp-write failures are visible instead of swallowed; and references to a `gbrain install` command that never existed are gone from the docs. + +### To take advantage of v0.42.69.0 + +```bash +gbrain upgrade # or: bun install -g gbrain@0.42.69.0 +gbrain doctor # confirms the health metric now converges +gbrain embed --stale # exits non-zero if anything is genuinely un-embeddable +``` + +If you use a local chat model for the nightly cycle, re-run it once and check that atoms actually land: + +```bash +gbrain dream --json | jq '.phases[] | select(.name=="extract_atoms")' +``` + +If you have `gbrain jobs submit unify-types` in a runbook or script, add `"apply":true` to its `--params` or it will now preview only. + +### For contributors + +Two defects existed only in the *combination* of otherwise-sound fixes, and were caught by reviewing the composed branch rather than the individual changes: + +- `isModelPriceable` was introduced with a test asserting `llama-server` is unpriced, while a second fix in the same wave priced `llama-server` at $0. Together the assertion inverted. Reconciled by using a genuinely unpriced provider in the test and pinning the positive case: free local providers are priceable at $0, so their caps stay enforced. +- The type-unification default flipped to dry-run, but three agent-facing playbooks still presented a bare submit as the apply step. Because a second fix in the same wave also edited one of those files, each change looked self-consistent alone. Skills ship downstream via the skillpack, so this would have propagated a playbook whose apply step silently did nothing. + +One reviewed fix was deliberately held back: extending the inline subagent drain to Postgres composes badly with this wave's minion connection-recovery work, since the drain calls the same queue operations without the new recovery path and can strand a child job in a per-run queue no worker will claim. + +Contributed by @alexey-metaengage (#3652), @time-attack (#3568, #3572, #3567, #3555, #3523, #3144, #3574, #3532, #3545), @brettdavies (#3552, #3553), @mattchronicle (#3364), @rayers (#3589), @zenspam (#3699), @awilhite (#3691), @Grimnoth (#3541), @georgell-ceo (#3634), @Vyacheslav-Zakharov (#3631), @Kyzcreig (#3585), @HammerTech-Z (#3581), @cfeddersen (#3563). + +## [0.42.68.1] - 2026-07-30 + +**If you run `gbrain reindex-frontmatter` or `gbrain backfill` on the default embedded database, they now work. Until this release both failed every time, after waiting 30 seconds.** + +The embedded database allows one process at a time, and holds a lock to enforce it. These two commands opened a second connection to the same database from inside the process that already held that lock, then waited for a lock that could never be released — because the thing holding it was the waiting process itself. The wait ran its full 30 seconds and the command exited with an error naming a blocking process that was, in fact, itself. Both commands now reuse the connection that is already open. + +Nothing changes for brains on Postgres, where a second connection was always allowed. + +## To take advantage of v0.42.68.1 + +Nothing to undo — the commands failed without writing anything. Just run whichever you needed: +```bash +gbrain reindex-frontmatter +``` + +## [0.42.67.0] - 2026-07-28 + +**If you develop GBrain on Windows, the test and check commands now actually run. Until this release they were quietly doing almost nothing.** + +`bun run test`, `bun run verify`, `bun run ci:local` and `bun run test:e2e` all hand off to shell scripts, and on Windows that hand-off was broken in two separate places. The commands did not stop with an obvious error. They reported a result, so a run could look finished when barely any of the checks had actually inspected anything. On a clean Windows clone, `bun run verify` got 1 check to pass and 31 to fail. It now gets 25 to pass and 7 to fail, and none of the 7 are caused by this change. + +The first problem was line endings. Git for Windows installs with `core.autocrlf=true`, which rewrites shell scripts to Windows line endings when you clone or check out. Bash refuses to run those, so a script died on its second line before doing any work. The scripts stored in the repository were always correct; only the copy on your disk was wrong. A new `.gitattributes` pins every `.sh` file to Unix line endings at checkout, no matter how your Git is configured. + +The second problem was how the checks were started. Thirty three of them pointed straight at a `.sh` file. On macOS and Linux the shell reads the `#!/usr/bin/env bash` line at the top of the script and runs it correctly. Bun on Windows does not do that, so those commands failed the moment they were called. They now go through `bash` explicitly, the same way the other eleven were already written. + +Nothing changes for macOS and Linux. No stored file content moves, and no check behaves differently on those platforms. + +## To take advantage of v0.42.67.0 + +Only Windows contributors need to do anything, and only once. `.gitattributes` applies at checkout time, so shell scripts already sitting on your disk keep their old line endings until you refresh them. + +1. **Refresh the working copy** from the repository root: + ```bash + git rm --cached -r . -q + git reset --hard + ``` +2. **Confirm bash can read the scripts:** + ```bash + bash -n scripts/run-unit-parallel.sh + ``` + Silence means it worked. `$'\r': command not found` means step 1 did not take effect. +3. **Run the gate:** + ```bash + bun run verify + ``` + +### Itemized changes + +- New root `.gitattributes` pins `*.sh text eol=lf`, so shell scripts check out with Unix line endings regardless of the contributor's `core.autocrlf` setting. All 59 tracked `.sh` files were already stored with Unix endings, so `git add --renormalize .` reports nothing to do and no stored content changes. +- `package.json` now routes the remaining 33 `.sh` check commands through `bash`, matching the 11 that already did. Every tracked `.sh` file carries a bash shebang (52 `#!/usr/bin/env bash` and 7 `#!/bin/bash`), so the treatment is uniform across all of them. +- The five `scripts/*.ts` entries still run under bun and are untouched. +- `CONTRIBUTING.md` gains a Windows section covering the one-time working-copy refresh and the `bash scripts/.sh` convention for new checks. +- `docs/TESTING.md` records how the test commands dispatch through bash, and notes that three tree-walking checks plus `typecheck` can exceed the 120s per-check cap on Windows while passing on Linux and macOS. + +## [0.42.66.1] - 2026-07-27 + +### Fixed + +- `gbrain doctor` now treats embedding columns wider than pgvector's HNSW limit as healthy exact-scan configurations instead of prescribing an index PostgreSQL cannot build. +- Local CI now passes an empty Docker mount list correctly and compiles the embedded-WASM smoke binary from container-local storage on Docker Desktop. + +## [0.42.66.0] - 2026-07-24 + +**54 verified fixes from the community backlog: background enrichment stops wasting money on dead pages, autopilot stops killing its own healthy runs, and search respects your settings.** + +This release is the second big sweep through the open pull-request backlog, with every change reviewed and tested individually before merging. The theme is trust in the background machinery. The overnight "dream" cycle now remembers which pages produced nothing and stops re-reading them every night, meters its small-model calls against your spend caps, and keeps claim proposals from silently overwriting each other. Long consolidation runs get a 30-minute deadline instead of being killed at 10 minutes mid-work. A wedged server boot now releases its database lock instead of blocking every later command. + +Search behaves the way you configured it: the recency-decay setting now actually applies to hybrid search, a local `list_pages` call returns as many rows as you asked for, and when a listing is cut short it says so instead of looking complete. Slack conversation exports parse cleanly, with an optional AI fallback for formats the parser does not know. + +New provider recipes: DashScope reranking, OpenRouter reranking, and a claude-cli recipe for dispatching subagents through the gateway. + +## To take advantage of v0.42.66.0 + +`gbrain upgrade` should do this automatically. One schema migration ships in this release (v125, take-proposal idempotency); it is idempotent and needs no manual action. + +1. **Upgrade and verify:** + ```bash + gbrain upgrade + gbrain doctor + gbrain stats + ``` +2. **If `gbrain doctor` warns about a partial migration**, run the orchestrator manually: + ```bash + gbrain apply-migrations --yes + ``` +3. **If any step fails,** please file an issue at https://github.com/garrytan/gbrain/issues with the output of `gbrain doctor` and `~/.gbrain/upgrade-errors.jsonl` if it exists. + +### Itemized changes + +#### Dream cycle, takes, and spend control + +- Pages whose extraction yields zero claims are memoized, so the cycle stops re-spending on them every night. (#2514, #3319, contributed by @ivandebot) +- Zero-yield pages are tombstoned so `extract_atoms` stops rediscovering them. (#2144, #3304, contributed by @ChenyqThu) +- `extract_atoms` Haiku calls are metered against the cost gate. (#2371, #3329, contributed by @TheRealMrSystem) +- `extract_atoms` stamps concepts so `synthesize_concepts` has material to work with. (#2123, #3308, contributed by @ChenyqThu) +- `extract_facts` requires a live backing page, not just a non-NULL entity slug. (#2497, #3321, contributed by @javieraldape) +- Multi-claim pages keep every proposal instead of only the first (migration v125 makes the idempotency key per claim). (#3297, contributed by @rp-agent-bot) +- Superseding a take now queries the active row first. (#3275, contributed by @arisgysel-design) +- Takes keyword search matches words inside long claims via `word_similarity`. (#3267) +- Dream-generated orphan pages stay scoped to their source. (#2368, #3344, contributed by @snvtac) +- Drift detection is wired into the dream cycle, report-only for now. (#2653, #3317) + +#### Autopilot, jobs, and serve + +- Full consolidation cycles get a 30-minute timeout floor; lighter dispatches keep the interval-derived budget. (#2852, #3338, contributed by @sanchalr) +- The cron wrapper exports `~/.bun/bin` onto PATH so autopilot survives minimal environments. (#2013, #3305, contributed by @klampatech) +- Dead or cancelled jobs no longer block idempotent re-submission. (#2253, #3306, contributed by @rafaelreis-r) +- Contextual reindex jobs get a default timeout. (#2611, #3323, contributed by @spiky02plateau) +- Onboarding stops repeating the same auto-remediation within a single run. (#2854, #3342, contributed by @sanchalr) +- A wedged `gbrain serve` boot hits a readiness deadline and releases the PGLite lock. (#3335) + +#### Search, retrieval, and health + +- The recency-decay config is honored on the hybrid search path. (#2386, #3312, contributed by @rwbaker) +- `list_pages` honors explicit limits for local callers, warns on remote clamping, and threads `offset`. (#2591, #3322, contributed by @deacon-botdoctor) +- Truncated `list_pages` results say so instead of silently capping. (#2865, #3341, contributed by @paul-0320) +- Negative metrics no longer invert trajectory regression signals. (#2621, #3324, contributed by @morluto) +- Per-chunk synopsis generation in contextual retrieval is concurrency-bounded. (#2628, #3326, contributed by @spiky02plateau) +- Graph health metrics count `entity` pages. (#2639, #3330, contributed by @tylr-r) + +#### Ingestion, extraction, and links + +- Conversation parsing gains an opt-in LLM fallback for unknown formats. (#2247, #3371, contributed by @danwiggins) +- Normalized Slack markdown parses into conversations. (#3289, #3372, contributed by @danwiggins) +- Conversation backfill outcomes are durable, so completed pages skip on the next run. (#3293, #3373, contributed by @danwiggins) +- Reference-style wikilinks are recognized during extraction. (#2071, #3303, contributed by @mzkarami) +- `[[wikilink]]` frontmatter values resolve via global basename lookup. (#2406, #3313, contributed by @spiky02plateau) +- Incremental push syncs extract links. (#2850, #3337, contributed by @patentsong) +- `` reasoning tags in extractor output are handled. (#2559, #3318, contributed by @qaz8545355) +- Tiktoken special tokens no longer crash code-chunker token estimates. (#2453, #3315, contributed by @Jiglet) +- Source config stops re-wrapping into a growing JSON string scalar. (#2829, #3334, contributed by @1alessio) + +#### Providers and recipes + +- DashScope reranking recipe (DashScope serves a plural `/reranks` endpoint under its compatible API). (#2644, #3328, contributed by @YiconZiwei) +- OpenRouter reranking touchpoint. (#2164, #3302, contributed by @Hippityy) +- claude-cli recipe for native gateway-based subagent dispatch. (#2277, #3310, contributed by @brettdavies) +- Prefixed model IDs work on the openai-compatible embedding-dimensions path. (#2325, #3309, contributed by @noetherly) +- Embeddings stamp the gateway-resolved model in `content_chunks.model`, not the compiled default. (#2846, #3343, contributed by @SailorJoe6) +- Bun-on-Windows write-through EEXIST fixed, non-Anthropic `--max-cost` pricing works, dream pages excluded from enrich. (#2407, #3316, contributed by @nguyenchiviet) +- Supabase signed URLs prepend `/storage/v1`. (#2565, #3320, contributed by @danwiggins) + +#### Sources, auth, and multi-brain + +- Federated-source pages are visible to `get_page`, `list_pages`, `resolve_slugs`, and no-grant MCP callers. (#3242, #3301) +- Admin-gated rescope surface for DCR clients stuck on a default scope. (#3299) +- `whoami` exposes OAuth source grants. (#3279, #3332, contributed by @boundless-forest) +- Thin-client `--source` maps onto `source_id` for remote-routed operations. (#3086) + +#### CLI, doctor, and init + +- `gbrain doctor` stops claiming "Brain is at target" when the target is unreachable. (#2151, #3339, contributed by @brettdavies) +- Doctor gains a raw-source persistence guarantee for synthesized pages, warn-only for now. (#3300) +- Doctor timeline labels disambiguate entity coverage from the brain-score component. (#2298, #3073, contributed by @TurgutKural) +- Unknown `gbrain init` flags are rejected before migrations run. (#2201, #3307, contributed by @caioribeiroclw-pixel) +- The init soul-audit hint points at the conversational skill, not a nonexistent CLI verb. (#2486, #3314, contributed by @SeanGearin) +- `--force` retry escapes completed migration-ledger entries. (#2616, #3325, contributed by @spiky02plateau) +- PGLite data-dir lock contention gets a clear error message. (#2658, #3336, contributed by @zaycruz) +- Frontmatter validation derives slugs from the brain root, not the absolute path. (#2340, #3311, contributed by @alessioalionco) + +#### For contributors + +- Docker network isolation guidance for co-located self-hosted Postgres. (#3270, #3331) +- `CLAUDE.local.md` / `AGENTS.local.md` are gitignored. (#3290, contributed by @igbymyboy) +- The hybrid-reranker integration test isolates `GBRAIN_HOME`. (#1527, #3327, contributed by @Willisbest) +- Test-shard scripts capture the real exit code before watchdog teardown in the no-timeout fallback. (#2864, #3340, contributed by @paul-0320) + +## [0.42.65.0] - 2026-07-23 + +**A large maintenance release: 93 verified fixes and small features merged since v0.42.64.0, most of them community contributions.** + +If you use gbrain day to day, this release makes the boring parts trustworthy. Importing and syncing notes is safer: a failed pull no longer pretends everything is up to date, imported pages are read back after writing to confirm they landed, and a page with real content can no longer be silently overwritten by an empty one. Search answers get better inputs: the think command now picks excerpts that actually match your question, and results respect your federated source settings. Background enrichment (the "dream" cycle) wastes less money and retries properly when an AI provider is down. Spending caps now fail closed, so a billing hiccup can never turn into an uncapped spend. And `gbrain doctor` is quieter, with several false alarms removed and real problems (like an embedding backlog with no worker running) now flagged. + +More AI providers work out of the box, including OpenRouter prompt caching, MiniMax and Zhipu GLM recipes, Ollama Matryoshka embedding dimensions, and llama-server batch limits. + +## To take advantage of v0.42.65.0 + +`gbrain upgrade` should do this automatically. No new schema migrations ship in this release. + +1. **Upgrade and verify:** + ```bash + gbrain upgrade + gbrain doctor + gbrain stats + ``` +2. **If `gbrain doctor` reports new findings after upgrading,** that is the quieter, more accurate check set working as intended. Each finding names its fix. +3. **If any step fails,** please file an issue at https://github.com/garrytan/gbrain/issues with the output of `gbrain doctor` and `~/.gbrain/upgrade-errors.jsonl` if it exists. + +### Itemized changes + +#### Security + +- MCP source scoping for remote callers got a hardening pass, so agent-facing connections stay confined to the sources they were granted. (#2881, contributed by @spinsirr) +- Paid MCP spend accounting is now atomic and fails closed, and resolver spend is recorded before a cap error is raised, so caps cannot be raced past or undercounted. (#3203, #3204, contributed by @caterpillarC15) +- The OAuth token endpoint rate limit on the HTTP server is now configurable via env for deployments behind shared IPs. (#3114, contributed by @time-attack) +- `WWW-Authenticate` responses now carry `resource_metadata` per the MCP spec and RFC 9728, so conforming clients can discover the auth server. (#1410, contributed by @rayers) + +#### Search, retrieval, and think + +- `think` selects query-relevant excerpts instead of generic ones. (#3197, contributed by @Y0lan) +- Unqualified local CLI `search`/`query` now honors `sources.config.federated` read visibility. (#2561, #3141, contributed by @time-attack) +- Email citation metadata is projected into search results. (#2873, contributed by @amtagrwl) +- The `think` Gaps section renders once instead of twice. (#1662, contributed by @howwohmm) +- Fuzzy entity lookup threads the caller's source scope and skips soft-deleted entities. (#1508, contributed by @tim404x) +- `code-def` surfaces method, constructor, field, and struct definitions, not just top-level symbols. (#1628, contributed by @rayers) +- Briefing pages are excluded from their own Brain Pulse salience. (#1202, contributed by @rwbaker) +- Reranker calls with missing auth are classified as configuration errors before falling back. (#2059, #3139, contributed by @time-attack) + +#### Import, sync, and ingestion + +- A failed git pull with zero imports reports `partial (pull_failed)` instead of `up_to_date`. (#3068, #3253, contributed by @Masashi-Ono0611) +- Imports run a post-write read-back verification with a durable ingest-log record. (#2869, contributed by @Andredsouza1984) +- `put` refuses to overwrite a non-empty page with empty content. (#2708, contributed by @symmetric-matthew) +- `putPage` restores soft-deleted rows instead of colliding with them. (#2779, contributed by @RerankerGuo) +- Mixed-case slugs are normalized before chunk upsert, ending duplicate-chunk churn. (#430, #3143, contributed by @time-attack) +- Imports fall back to the body H1 for the title when frontmatter lacks `title:`. (#2446, #3072, contributed by @time-attack) +- YAML comments inside the frontmatter fence are no longer treated as markdown headings. (#3225, #3247, contributed by @Masashi-Ono0611) +- Write-through guards case-insensitive filesystem collisions before the atomic write. (#2831, #3119, contributed by @time-attack) +- Path-qualified wikilinks outside the known directory pattern resolve on the DB/put_page path. (#2866, contributed by @paul-0320) +- CJK slugs are supported in the slug registry and dream-cycle summary slugs. (#782, #738, #3083, contributed by @time-attack) +- Three ingest/sync/serve singleton fixes: page-type round-trip, deleted-slug embed noise, and a stateless width guard. (#3140, contributed by @time-attack) +- Sync honors the `embedding_disabled` sentinel as an implicit `--no-embed`. (#2879, contributed by @gawievanblerk) +- Verified sync head sentinels are cleared correctly. (#2734, contributed by @symmetric-matthew) +- Resumed syncs report the pinned commit they actually landed on. (#3202, contributed by @caterpillarC15) +- The expected `discover_git_root` probe failure stays off stderr. (#3232, contributed by @Masashi-Ono0611) +- `extract --stale` runs the real resolver so basename resolution reaches stale pages, and clears pre-version-bump pages. (#2576, #2717, contributed by @paul-0320; #1791, contributed by @Nazim22) +- Oversized code chunks are capped so they stay embeddable, and code-chunk metadata survives re-embeds. (#1675, contributed by @lubosxyz; #769, #1232, contributed by @rayers) + +#### Background cycle, dream, and facts + +- Path-derived dream sources are stamped, and the engine closes cleanly on autopilot shutdown. (#3178, contributed by @time-attack) +- All-provider-failed atom drains propagate so durable jobs retry instead of silently dropping work. (#3218, #3248, contributed by @Masashi-Ono0611) +- Atom extraction raises `maxTokens` and case-normalizes `atom_type` for Gemini models. (#3211, contributed by @alexey-metaengage) +- The conversation extractor gates anonymous-speaker self-attribution instead of guessing. (#3228, contributed by @asenkovskiy) +- Incremental dream extraction stamps its watermark so re-runs stop reprocessing. (#2636, #3115, contributed by @time-attack) +- `dream --dry-run --json` keeps stdout clean of embed summaries. (#394, #3109, contributed by @time-attack) +- Synthesized dream pages require a self-contained opening summary. (#2770, contributed by @Masashi-Ono0611) +- PGLite inline synth subagent drains complete, and `lint` gains `--exclude`. (#2699, #2649, #3162, contributed by @time-attack) +- Live context reads the documented "P1 Today" heading form with plain checkbox tasks, matching the daily-task-manager skill's output format. (#2186, #3124, contributed by @time-attack) +- Queued AI jobs refresh gateway config at execution time instead of using a stale snapshot. (#2125, contributed by @maxpetrusenkoagent) +- `brainstorm`/`propose_takes` honor configured models: cost preview uses the configured model, the judge reads its config key, provider probes are skipped when unneeded, and page projection is narrowed. (#3120, contributed by @time-attack) +- Backlog hardening wave: x-to-brain health check, propose_takes deadlines, capture title truncation, extract_atoms backlog handling, and pooler direct-URL routing. (#3165, contributed by @time-attack) +- `skillopt` emits `proposed.md` in no-mutate mode. (#2635, #3182, contributed by @time-attack) +- Nightly quality probe enable path and conversation-parser probe are wired up. (#2629, #2630, #3094, contributed by @time-attack) + +#### Doctor, health, and maintenance + +- New safe maintenance automation with a shared orphan-exclusion policy, so routine cleanup runs without risking linked content. (#3015, #3023, contributed by @time-attack) +- `orphan_ratio` excludes the chronicle volume under `life/events/`. (#2264, #3214, contributed by @asenkovskiy) +- `brain_score` orphan/timeline components use the orphans-audit linkable scope. (#3155, contributed by @time-attack) +- Entity timeline coverage is measured separately from whole-brain density. (#2761, contributed by @TurgutKural) +- Doctor flags embed backfills queued with no worker running. (#2696, contributed by @javieraldape) +- Two doctor false-positive/timeout fixes: the drift walk skips `node_modules`, and the bare-tweet check skips inline code and cited lines. (#1772, contributed by @sonlndv) +- A dead `llm_fallback_enabled` recommendation is dropped from conversation format coverage. (#1903, contributed by @ElliotDrel) +- Skill triggers with CRLF line endings parse on Windows. (#1149, contributed by @samporter-31) +- Onboard check names are registered in doctor categories, ending unknown-check warnings, and onboard-check remediations survive the `--apply --auto` path. (#3075, #3097, contributed by @time-attack) +- Dead slug prefixes are counted by slug. (#2697, contributed by @RerankerGuo) +- The backlinks worker defaults to check, not fix, and `check-backlinks` honors its positional directory argument. (#1853, contributed by @choomz; #3076, contributed by @time-attack) +- Calibration resolves the owner holder via config, defaulting to `self`. (#3077, contributed by @time-attack) +- Memory throttling on Linux reads `/proc/meminfo` MemAvailable. (#556, contributed by @chengzehsu) + +#### AI providers and gateway + +- OpenRouter gets family-scoped prompt caching, and query expansion works on chat-capable openai-compat recipes. (#3152, contributed by @time-attack) +- MiniMax recipe: embedding wire-shape compat fetch plus a chat touchpoint. (#1977, #3089, contributed by @time-attack) +- The Zhipu recipe gains a chat touchpoint so GLM subagents work. (#1157, #3084, contributed by @time-attack) +- Tier-configured models reach the recipe allowlist, Anthropic model lists are refreshed, tier resolutions are registered, and probe labels are honest. (#2800, contributed by @p3ob7o) +- Provider base URL config merges from the DB. (#1676, contributed by @TheLordArgus) +- The gateway falls back to the pooler when the derived direct host is unreachable. (#1641, #3088, contributed by @time-attack) +- Config-plane `voyage_api_key` folds into `VOYAGE_API_KEY` like the other hosted keys. (#3236, contributed by @Masashi-Ono0611) +- The `zeroentropyai:zerank-2` reranker has a pricing entry so the budget tracker can meter it. (#3223, #3233, contributed by @Masashi-Ono0611) +- llama-server embedding batches are capped at its 32-input request limit. (#1281, contributed by @mmekkaoui) +- Matryoshka dimensions thread through for Qwen3-Embedding on Ollama. (#1072, contributed by @mgandal) +- `init` seeds AI options from env on cold install, and `whoami` reports the stdio transport. (#3091, contributed by @time-attack) +- The `models` dispatch subcommand reads its first argument correctly. (#1428, contributed by @BenjaminDSmithy) +- Synopsis generation tail-truncates document text for small-model chat handlers. (#1427, contributed by @BenjaminDSmithy) +- The contradiction judge token cap is raised for thinking models. (#3210, contributed by @alexey-metaengage) + +#### Schema, migrations, and storage engines + +- Engine migration counts and surfaces per-page copy failures instead of silently advancing. (#3241, contributed by @Masashi-Ono0611) +- Invalid `CONCURRENTLY`-build index remnants are dropped without a DO block. (#3191, contributed by @Masashi-Ono0611) +- Unsupported large-dimension HNSW indexes are skipped instead of failing schema setup. (#1734, #3080, contributed by @time-attack) +- The v0.32.2 migration dirty-check scopes to targeted sources and surfaces failed phase detail. (#3093, contributed by @time-attack) +- Schema packs merge the full `extends` chain and `borrow_from` into the resolved manifest. (#1749, #3181, contributed by @time-attack) +- The schema-pack stats catch-all is narrowed so masked errors surface instead of fake zero-page counts. (#2466, #3133, contributed by @time-attack) +- Bundled schema-pack inspection reports the pack actually shipped in the binary, and minion subagent auth resolves through config. (#3110, contributed by @time-attack) +- PGLite `putPage` guards against zero-row RETURNING. (#1649, contributed by @alexhawkins) + +#### MCP server and CLI surface + +- `list_pages` rows include `source_id`. (#3209, contributed by @alexey-metaengage) +- Running CLI commands while `gbrain serve` (MCP) holds the brain now notifies about the conflict instead of failing confusingly. (#3243, contributed by @fdefitte) +- The OpenClaw plugin manifest entry is declared so the plugin loads. (#2551, #3185, contributed by @time-attack) + +#### For contributors + +- CI scanner roots are normalized on macOS. (#3198, contributed by @caterpillarC15) +- CI shard timeout raised to 22 minutes plus a delta-assert reporter leak test. (#3231, contributed by @time-attack) +- E2E suite hardening: flaky tests, no-op assertions, and cross-test coupling removed. (#1704, contributed by @auroracapital) +- `mechanical.test.ts` isolates `$HOME` so the E2E suite stops clobbering user config. (#434, contributed by @lloydarmbrust) +- The lint code-fence-wrap detector and fixer regex now agree. (#1597, contributed by @chungty) +- README project links for OpenClaw and Hermes are corrected. (#1961, #3179, contributed by @time-attack) +- A completed TODOS entry is dropped. (#3229, contributed by @Masashi-Ono0611) + +## [0.42.64.0] - 2026-07-20 + +### Fixed + +- Confidential OAuth clients can now revoke access tokens through the standard revocation endpoint when client secrets are stored as hashes. Invalid credentials fail closed, malformed or mixed authentication is rejected, backend failures remain retryable, and discovery metadata accurately advertises supported authentication methods. + +No schema migrations. +## [0.42.63.0] - 2026-07-20 + +**Schema commands now open the local brain you actually configured.** + +If your PGLite brain lives at a custom path, commands such as `gbrain schema stats` previously ignored that path and could inspect the default brain instead. That made a healthy configured brain look empty or report the wrong schema counts. Schema commands now use the same complete database configuration as the rest of GBrain. PostgreSQL behavior is unchanged, and no migration is required. + +### How to use it + +Upgrade, then run the schema command normally: + +```bash +gbrain upgrade +gbrain schema stats --json +``` + +The reported page and type counts now come from the `database_path` in `~/.gbrain/config.json` when the engine is PGLite. + +### Itemized changes + +#### Fixed +- **Schema CLI commands preserve configured PGLite paths.** Engine construction and connection now receive the canonical complete engine configuration, including both `database_path` and `database_url` where applicable. +- **CLI tests are isolated from ambient database URLs.** Schema subprocess tests explicitly clear inherited PostgreSQL URL variables, and a persistent-PGLite regression test proves `schema stats` reads the configured database rather than the default brain. + +## [0.42.62.0] - 2026-07-17 + +**If your brain holds more than one source, everything now lands in the right one. Link extraction, timeline extraction, background cycles, and webhook captures used to quietly file some of their output under the default source; all of those paths now carry the correct source identity. Background agent jobs got tougher too: a failed database reconnect can no longer wedge the engine, and workers recover from dropped connections instead of crash-looping. If you run the admin dashboard behind a reverse proxy, the live activity panel finally connects. Long agent conversations cost less because repeated context is reused between turns on Anthropic calls. Local LiteLLM proxies work out of the box. Nested sources scan correctly again instead of reporting zero files. And the project's automated checks now include dependency vulnerability scanning, static code-security analysis, and signed provenance for release builds. Thirty merged changes in all, the largest batch to date, each one reviewed and verified against the live codebase before landing.** + +## To take advantage of v0.42.62.0 + +`gbrain upgrade`. No new schema migrations. + +1. **Multi-source brains:** run `gbrain extract all` once (or let the next cycle do it) so previously mis-scoped link and timeline rows are regenerated under the right source. +2. **If you serve the admin dashboard behind a reverse proxy,** hard-refresh it once after upgrading; Live Activity should connect. +3. **Verify:** + ```bash + gbrain doctor + gbrain stats + ``` +4. **If any step fails,** please file an issue at https://github.com/garrytan/gbrain/issues with the output of `gbrain doctor`. + +### Itemized changes + +#### Fixed +- **Source identity threaded through write paths.** Filesystem link/timeline extraction (`src/commands/extract.ts`), the cycle extract phase, and ingest capture now stamp the resolved source id instead of defaulting to `default`, with fail-closed validation on externally supplied ids. (#1522, #1747, #1503 via #2920; absorbs #1719, contributed by @seungsu) +- **`reconnect()` is build-then-swap.** The new pool is validated before replacing the old one, so a failed rebuild restores the previous connection instead of leaving `_sql` null. (#1593 follow-up via #1906, contributed by @rayers) +- **Minion worker reconnects after promote-time connection loss** instead of crash-looping. (#1491 class via #2025, contributed by @maxpetrusenkoagent) +- **Admin Live Activity works behind reverse proxies.** The EventSource now sends credentials so strict-cookie sessions survive the proxy hop. (#912 via #1560, contributed by @flamerged) +- **Stats exclude soft-deleted pages** from visible counts on both engines; destructive-removal counts stay all-inclusive. (#2235, contributed by @xd-Neji) +- **LiteLLM recipes declare chat and expansion touchpoints,** so the subagent loop no longer swaps to Anthropic and fails without an Anthropic key. (#2207 via #2208, contributed by @brettdavies) +- **Rolling prompt-cache on the direct SDK path.** Growing conversations place rolling cache breakpoints (two, within the four-marker budget), cutting repeat-token cost on multi-turn Anthropic tool loops. (#2740 via #2771, contributed by @Masashi-Ono0611) +- **Nested sources scan again.** `sources audit` had one inverted prune check (descending into node_modules while reporting 0 files). (#2678, contributed by @ikamal97) +- **Import and sync agree on metafiles.** The import walker now skips the same structural metafiles sync skips. (#345 via #2315, contributed by @ElliotDrel) +- **Frontmatter scans respect git excludes** via a shared git-visible-files helper. (#2462, contributed by @kubi-dev) +- **Sync renames are crash-safe** (per-file failures recorded instead of aborting the run) and **zero-change syncs still bump `last_sync_at`** so freshness reporting stops lying. (#2402, contributed by @supportswift; #2335, contributed by @lost9999) +- **Facts survive one-shot CLI runs.** Facts-absorb work is enqueued as durable minion jobs instead of dying with the process exit drain; fence paths are source-scoped. (#2104, contributed by @reghar-bot) +- **Takes reads are source-scoped, `gbrain calibration` is reachable, outputs are BigInt-safe.** (#2035 and the takes slice of #2200 via #2892, takeover of #2452, contributed by @spinsirr) +- **CLI answers honestly.** `config get` reads both config planes with provenance, `sources archive` is idempotent, help text matches real subcommands, doctor recommendations name commands that exist. (#2120, #2792, #1175, #1123, #2451 via #2918) +- **PGLite init failures name plausible causes for your platform** instead of blaming a macOS-specific bug everywhere, and non-Error crashes print their message instead of `[object Object]`. (#2674 class via #2891) +- **YAML comments inside frontmatter parse.** `#` lines inside a closed fence are comments, not headings; no more false MISSING_CLOSE. (#2152 via #2153, contributed by @brettdavies) +- **Conversation facts read the raw transcript sidecar** and recognize plain `Speaker A:` lines. (#1897 via #1898, contributed by @ElliotDrel) +- **`get_timeline` exposes date-window filters** (#2604 via #2694, contributed by @RerankerGuo) and **`query` since/until filter on effective date,** not updated_at (#1520 via #1706, contributed by @mvanhorn). +- **Windows serve watchdog works** via a signal-0 liveness probe instead of a POSIX-only process listing. (#2049, contributed by @abyss-node) +- **Doctor probes route through the active engine** (no false pgvector/jsonb warnings on PGLite; #1513 via #1183, contributed by @duncanclaw) and **a disabled retrieval reflex reads as intentional** (#2459, contributed by @eloe). +- **Cross-platform installs.** The postinstall hook is a real bun script, not POSIX shell that failed on Windows. (#1486 via #1554, contributed by @Sanjays2402) +- **Agent-bound auth clients.** `auth register-client` gains the `--bound-*` flags the submit_agent gate requires. (#1945, #1971 via #1976, contributed by @mzkarami) + +#### Added +- **Security automation in the project's checks:** scheduled OSV dependency scanning, Semgrep static analysis on every PR (non-blocking initially), and build-provenance attestations wired into the release workflow. (#2182, #2142, #2272 via #2917) +- **`provider_chat_options` config passthrough** to the gateway, e.g. disabling thinking mode per provider or model. (#2577 via #2857) +- **Docs:** macOS 26.x PGLite workaround and native Postgres setup guide. (#1671, contributed by @roysaurav) + +#### Internal +- release.yml runs `verify` before building. (#2222 via #2243, contributed by @mzkarami) +- Regenerated llms bundle after the docs merge. (#2893) + +## [0.42.61.0] - 2026-07-16 + +**If gbrain's background daemon dies hard, a restart now takes over right away instead of waiting minutes for a stale lock to expire. Re-processing the same content no longer piles up near-duplicate knowledge atoms. On large brains, the takes bootstrap finally works through the whole corpus instead of re-scanning the same newest pages every run. And `gbrain schema use` can now activate the schema packs gbrain actually ships — including the install default — instead of just one hardcoded name. Cost tracking also learns the newest Claude models, so spend on them is metered instead of invisible.** + +### Itemized changes + +#### Fixed +- **Autopilot recovers immediately from a crashed daemon.** The stale-lock check verifies whether the lock-holding process is still alive instead of relying on a fixed age window — a hard-killed autopilot no longer delays restarts, and the age check alone can no longer displace a busy, live one. (#477, contributed by @vinsew) +- **Atom extraction stops minting duplicate atoms across runs.** Atom slugs are now deterministic (source-dated, canonical slugging, content-hashed suffix), so re-extracting the same content upserts instead of creating a near-duplicate under a new run-date path, and titles that truncate mid-word no longer produce trailing-dash slug variants. Pre-existing duplicates are not re-created but remain until cleaned up (an `atoms consolidate` command is tracked as a follow-up). (#2482, contributed by @joelwp) +- **Takes bootstrap works through the whole corpus.** Bootstrap runs skip pages that already have takes, so brains larger than the per-run page cap make forward progress instead of rescanning the newest slice and re-spending extraction budget. `--include-covered` restores the old behavior. (#2638, contributed by @p3ob7o) +- **`gbrain schema use` can activate the core bundled packs.** The command resolved only one hardcoded pack name; it now resolves through the bundled-pack registry, so the recommended and v2 base packs (including the install default) can be selected. (#1707, contributed by @mvanhorn) +- **Budget tracking prices Sonnet 5 and Fable 5.** The canonical chat-pricing table adds the newest Claude models at standard list rates (time-limited introductory discounts are deliberately not modeled, so early Sonnet 5 spend reads slightly conservative), removing the no-pricing blind spot in cost telemetry and budget metering. (#2799, contributed by @p3ob7o) + +#### Added +- **Inline `[Source: ..., YYYY-MM-DD]` citations become timeline entries.** Both the filesystem extract path and the auto-timeline write path recognize the citation convention gbrain’s own quality guidance recommends, with idempotent re-extraction. (#2524, contributed by @pabloglzg) +- **Schema packs extend atom-extraction page discovery.** For packs that declare the `extract_atoms` phase, the manifest’s `extractable` flag now unions with the legacy page-type list (synthesis outputs stay excluded, so concepts never feed back into atom extraction). (#2615, contributed by @p3ob7o) +- **Book-mirror two-column pages are generated as HTML tables** with top alignment instead of markdown pipe tables, which broke on multi-paragraph cells in most renderers. (#2270) + +#### Internal +- Gateway tool-schema conversion extracted into a tested helper so the regression test exercises the exact code path production uses. (#2063, contributed by @maxpetrusenkoagent) +- Reference docs synced for the v0.42.59.0 fixes (engine/testing entries). (#2798, contributed by @time-attack) + +### To take advantage of v0.42.61.0 + +`gbrain upgrade`. No new schema migrations. + +1. **Heads-up on extraction scope:** if your active schema pack declares the `extract_atoms` phase, page types the pack marks `extractable` now feed atom extraction alongside the legacy list — the first cycle after upgrading may process page types (notes, emails, slack) it previously skipped. Per-run page and budget caps still apply; check `gbrain search stats` / budget output if you watch spend closely. +2. **If takes bootstrap seemed stuck** on a large brain, re-run it — each run now covers new pages. +3. **Verify:** + ```bash + gbrain doctor + gbrain stats + ``` +4. **If any step fails,** please file an issue at https://github.com/garrytan/gbrain/issues with the output of `gbrain doctor`. + +## [0.42.60.0] - 2026-07-16 + +**Eleven verified community fixes: Windows brains no longer risk losing subdirectory pages on a full sync, agent tool loops on non-Anthropic providers survive interruption instead of dead-lettering, multi-source brains get two source-isolation gaps closed, and the search cache stops leaking results across exclude policies. Every fix was reproduced and reviewed against master before landing.** + +### Fixed +- **Windows: `sync --full` no longer deletes subdirectory pages.** A path-separator mismatch made every subdirectory page look stale during full-sync reconcile, so a routine full sync could delete them. Paths are now normalized before comparison, and a mass-delete safety valve blocks any reconcile that would remove most of a source's pages. (#2828, #2836, contributed by @1alessio) +- **Gateway tool loops on non-Anthropic providers are reliable across resume.** Tool-result turns are persisted as they happen, interrupted jobs reconcile dangling tool calls on resume instead of dead-lettering with unbalanced-transcript errors, `Date` values in tool outputs no longer crash serialization, DeepSeek reasoning-only replies are read correctly instead of as empty, and `openrouter_api_key` in config reaches the gateway. (#2820, consolidating community fixes #2062, #2065, #2257, #2274, #2336, #2487, #2491, #2572, #2614, #2617, #2806; contributed by @time-attack and the original PR authors) +- **Claude 5 models get output-token headroom.** Thinking-default models no longer have long answers silently truncated by the old 4096-token default output cap; Claude 5 chat calls now default to 32000 output tokens (16000 for `think`). Other providers keep their existing caps, so smaller-limit providers are unaffected. (#2820) +- **Bulk import survives huge fence-less files.** The markdown lexer is skipped when a page contains no code fences, removing an out-of-memory crash on large tables and notes during bulk import. (#2437, #2440, contributed by @irresi) +- **`file_list` no longer crashes on Postgres brains over MCP.** BIGINT file sizes are normalized before JSON serialization; the CLI files listing gets the same fix. (#472, contributed by @vinsew) +- **`gbrain config set auto_chronicle true` works as documented.** The Life Chronicle config keys (and `takes.bootstrap_enabled`) are registered, so the documented enable commands stop being rejected as unknown keys. (#2632, contributed by @p3ob7o) +- **Orphan reports skip generated corpus roots.** `raw/`, `atoms/`, and `skills/` no longer inflate the orphan ratio by default; `--include-pseudo` still shows everything. (#2068, contributed by @mgunnin) + +### Security +- **The search cache honors your hard-exclude policy.** Cached search results are now keyed on the effective hard-exclude/include slug-prefix policy, so a process with `GBRAIN_SEARCH_EXCLUDE` set can never be served cached rows written under a different policy — and vice versa. (#2825, #2885) +- **Take-writes are source-scoped.** When a source resolves (via `--source`, `GBRAIN_SOURCE`, or the dotfile chain), CLI take commands look pages up within that source instead of first-match-by-slug, closing a cross-source write path on brains where the same slug exists in multiple sources. Brains without a resolvable source keep the previous lookup. (#2684, #2698, contributed by @RerankerGuo) +- **Image pages land in the right source.** Imported images are stamped with the syncing source (and their auto-links stay within it) instead of always landing in `default`. (#2706, #2718, contributed by @RerankerGuo) +- **The admin bootstrap token no longer prints to a non-terminal stream.** The one-time token is withheld when its output stream is a pipe, log, or CI capture instead of an interactive terminal. (#2625, contributed by @irresi) + +### Internal +- Pinned embedding dimensions in a doctor test to eliminate a shard-order flake in CI. (#2801, contributed by @p3ob7o) + +### To take advantage of v0.42.60.0 + +`gbrain upgrade`. No new schema migrations. + +1. **Windows users with git-synced sources:** re-run `gbrain sync --full` once after upgrading — if a pre-upgrade sync deleted subdirectory pages, they re-import from the repo. +2. **Your first search after upgrading may be a cache miss** (the cache key now includes the exclude policy). Speeds return to normal as the cache refills within its TTL. +3. **If agent jobs previously dead-lettered** with unbalanced tool-call transcript errors on OpenAI-compatible providers, retry them with `gbrain jobs retry ` — resume now reconciles the transcript. +4. **Verify:** + ```bash + gbrain doctor + gbrain stats + ``` +5. **If any step fails,** please file an issue at https://github.com/garrytan/gbrain/issues with the output of `gbrain doctor`. + +## [0.42.59.0] - 2026-07-13 + +**Five community-reported fixes, each reproduced and verified before/after on both engines (PGLite + real Postgres): an upgrade wedge that locked pre-v121 brains out of migrations, two data-integrity holes in engine migration, silent deletion of facts containing pipe characters, confidently-wrong entity attribution on ambiguous names, and tightened source-scope enforcement in `think`.** + +### Fixed +- **Existing brains below schema v121 can upgrade again.** Brains created before v0.42.56.0 could get stuck in a loop where every command (including `apply-migrations`) failed with `column "event_page_id" does not exist` — the migration that adds the column could never run. The startup bootstrap now adds the forward-referenced column first; migration v121 still owns the FK and indexes. Re-running is idempotent, and already-wedged brains heal on the next command. (#2724, #2735, contributed by @time-attack) +- **`gbrain migrate --to` no longer fails on multi-source brains.** The source catalog is copied before pages, so the first page no longer dies on a foreign-key violation. Source rows migrate with full fidelity (paths, sync state, config). (#2677, #2736, contributed by @time-attack) +- **Migration resume checkpoints are target-aware.** An interrupted migration to one target no longer convinces a later migration to a *different* target that most pages are "already done" (which silently shorted the new target). A checkpoint for another destination is discarded and the run starts fresh; no connection strings or credentials are written to manifests or logs. (#2677, #2736, contributed by @time-attack) +- **Facts containing `|` characters survive reconciliation.** The facts fence rendered literal pipes escaped but re-parsed rows by splitting on every pipe, so any fact whose text contained a `|` was silently deleted from the DB on the next extract-facts cycle. Render→parse is now symmetric (pipes, backslashes, and empty cells verified round-trip). The takes fence shares the parser and gets the same fix. (#2726, #2738, contributed by @time-attack) +- **Ambiguous entity names quarantine instead of guessing.** A bare first name shared by two people, or a company name sharing a generic token (e.g. "… Capital") with another company, used to resolve confidently to the wrong entity — misattributed facts are invisible and expensive to repair. Bare names now resolve only when exactly one canonical candidate exists; low-specificity fuzzy matches fall through to the guarded holding path (a held fact is recoverable; a misattributed one isn't). Explicit slugs, full names, unique bare names, and close typos still resolve. Trade-off: heavier typos on short names may now hold instead of resolving. (#2723, #2737, contributed by @time-attack) + +### Security +- **`think` now applies the caller's source scope across all of its internal retrieval.** Hybrid page retrieval, takes keyword/vector retrieval, and graph traversal all honor scalar and federated source scope, matching the isolation the rest of the read surface already enforces. Part of the #2200 tracking work. (#2739, contributed by @time-attack) + +### To take advantage of v0.42.59.0 + +`gbrain upgrade` should do this automatically. No new schema migrations ship in this release (v121/v122 shipped with v0.42.56.0). + +1. **If your brain was stuck below schema v121** (every command printed a schema-probe warning), just upgrade and run any command — the brain heals and migrates to current on first connect. If `gbrain doctor` still complains: + ```bash + gbrain apply-migrations --yes + ``` +2. **Verify:** + ```bash + gbrain doctor + gbrain stats + ``` +3. **If a previously-resolving shorthand name now files under a holding page**, that's the new ambiguity quarantine working as intended — add an alias or use the full name/slug for entities you want bare shorthand to hit. +4. **If any step fails,** please file an issue at https://github.com/garrytan/gbrain/issues with the output of `gbrain doctor` and `~/.gbrain/upgrade-errors.jsonl` if it exists. + ## [0.42.58.0] - 2026-07-06 **gbrain now runs cleanly on the stack you already have — a local Ollama box, a self-hosted LiteLLM proxy, llama.cpp's llama-server, or gbrain running as a Claude Code MCP subprocess — instead of silently degrading or hard-failing when you're not on a raw OpenAI/Anthropic key.** A provider-agnostic plumbing pass across the AI gateway: environment handling, base-URL normalization, and embedding-dimension validation all stop tripping on the non-frontier-vendor setups that used to fail without a clear signal. diff --git a/CLAUDE.md b/CLAUDE.md index c23e645bc..e57f5214d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,6 +67,19 @@ Per-file detail is in `docs/architecture/KEY_FILES.md`. text, the cast parses it). Guarded by `scripts/check-jsonb-pattern.sh` (template grep) + `scripts/check-jsonb-params.mjs` (positional AST scanner); the real backstop is the DATABASE_URL-gated e2e parity tests, since PGLite can't surface the bug. Full rule in `docs/ENGINES.md`. +- **Engine-live paths avoid runtime dynamic `import()` for helper dependencies.** In + `src/core/pglite-engine.ts`, `src/core/postgres-engine.ts`, and + `src/core/migrate.ts`, dependencies previously reached through runtime dynamic + imports use static top-level imports. The only current dynamic-`import()` exceptions + are the four `ai/gateway.ts` lookups in both engines' + `initSchema()` and `_upsertChunksOnce()` methods; each remains lazy inside a + local `try/catch` because the gateway has a large provider/config closure and, + more importantly, eager evaluation would occur before the catch and could + turn a recoverable default/config-row fallback into a module-load failure. + Every exception carries `engine-dynamic-import-ok` on the import line. + `scripts/check-engine-dynamic-import.sh` enforces the rule. For history, use + `git log -G'await[[:space:]]+import\\('`, not `git log -S`: a dynamic-to-static + rewrite can preserve the searched token while changing its context. - **Engine parity.** `src/core/postgres-engine.ts` and `src/core/pglite-engine.ts` move in lockstep — a new method/SQL shape lands in BOTH, pinned by `test/e2e/engine-parity.test.ts`. Forward-referenced columns/indexes go in the bootstrap probe set (guarded by diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ebc68cd93..d6408cd20 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,6 +11,34 @@ bun test Requires Bun 1.0+. +### Windows + +`bun run test`, `verify`, `ci:local` and `test:e2e` all dispatch through bash, so +the shell scripts under `scripts/` must be checked out with Unix line endings. +The root `.gitattributes` pins `*.sh text eol=lf`, which overrides the +`core.autocrlf=true` that Git for Windows installs by default. A fresh clone is +correct with no extra steps. + +`.gitattributes` pins `*.md text eol=lf` for the same reason. The frontmatter +readers anchor on a `---` fence followed by a Unix line ending, so a CRLF +checkout makes a well-formed document parse as having no frontmatter. That +failure is silent: no error, the field just comes back empty. + +If you cloned before either pin existed, your working copy still has the old +Windows line endings. Bash will fail with `$'\r': command not found`, and +frontmatter will read as absent. Refresh it once, from the repository root: + +```bash +git rm --cached -r . -q +git reset --hard +bash -n scripts/run-unit-parallel.sh # silence means bash can read the scripts +git ls-files --eol -- '*.md' | grep -c w/crlf # 0 means Markdown is clean +``` + +Every `check:*` entry in `package.json` invokes its script as `bash scripts/.sh` +rather than relying on the shebang, because bun on Windows cannot exec a `.sh` +directly. Keep that prefix when you add a new shell-script check. + ## Project structure ``` @@ -163,6 +191,14 @@ host port with `GBRAIN_CI_PG_PORT=5435 bun run ci:local` if 5434 collides. Fail-closed selector: an unmapped `src/` change runs all 29 E2E files. Hand-tune narrower mappings via `scripts/e2e-test-map.ts`. +### PR-side security checks + +Besides the test gate, PRs may trigger three security workflows: Semgrep CE +SAST (every PR — **advisory/non-blocking** while the baseline is tuned, so a +Semgrep finding won't fail your PR), OSV-Scanner (only when `package.json` or +`bun.lock` change), and actionlint (only when `.github/workflows/**` change). +See `SECURITY.md` → "Automated security scanning" for details. + ## Building ```bash diff --git a/INSTALL_FOR_AGENTS.md b/INSTALL_FOR_AGENTS.md index 421d2ff09..368bafab8 100644 --- a/INSTALL_FOR_AGENTS.md +++ b/INSTALL_FOR_AGENTS.md @@ -16,6 +16,13 @@ If you fetched this file by URL without cloning yet, the companion files live at ## Step 1: Install GBrain +> **NEVER install from the npm registry.** GBrain is not distributed on npm; the npm +> package named `gbrain` is an unrelated package. Do NOT run `npm install -g gbrain` or +> `bun add -g gbrain` (note the missing `github:` prefix — that's the trap). The only +> supported sources are `github:garrytan/gbrain` and a git clone, exactly as shown below. +> If an unrelated npm install is already present, remove it first +> (`npm uninstall -g gbrain` / `bun remove -g gbrain`); `gbrain doctor` also detects this. + Default path (Bun is required — gbrain is a Bun + TypeScript runtime): ```bash diff --git a/README.md b/README.md index 7fd228c4b..2b058a27c 100644 --- a/README.md +++ b/README.md @@ -65,14 +65,24 @@ This is the difference between a search engine and a brain. Search finds the pag ## Install +> [!WARNING] +> **GBrain is NOT distributed on npm.** The npm package named `gbrain` is an unrelated +> package with no connection to this project. Do not run `npm install -g gbrain` or +> `bun add -g gbrain` — you'll get something else, and it can shadow the real binary on +> your PATH. Install and upgrade ONLY via the documented paths below +> (`bun install -g github:garrytan/gbrain`, or `git clone` + `bun install && bun link`). +> If you already ran the npm install by mistake: `npm uninstall -g gbrain` / +> `bun remove -g gbrain`, then reinstall from GitHub. `gbrain doctor` detects a +> shadowing npm install and prints the fix. + GBrain is designed to be installed and operated by an AI agent. The fastest path is to have your agent do it for you. The CLI and MCP paths below are for people who want to wire it up themselves. ### Have your agent install it (recommended) If you don't already have an AI agent platform running, start with one of these. Both are designed to read GBrain's install protocol and execute it: -- **[OpenClaw](https://github.com/openclawagents/openclaw)** — deploy [AlphaClaw on Render](https://render.com/deploy?repo=https://github.com/chrysb/alphaclaw) (one click, 8GB+ RAM) -- **[Hermes](https://github.com/openclawagents/hermes)** — deploy on [Railway](https://github.com/praveen-ks-2001/hermes-agent-template) (one click) +- **[OpenClaw](https://github.com/openclaw/openclaw)** — deploy [AlphaClaw on Render](https://render.com/deploy?repo=https://github.com/chrysb/alphaclaw) (one click, 8GB+ RAM) +- **[Hermes](https://github.com/NousResearch/hermes-agent)** — deploy on [Railway](https://github.com/praveen-ks-2001/hermes-agent-template) (one click) Then paste this into your agent: @@ -208,7 +218,7 @@ Most personal-knowledge tools force one fixed layout: their idea of "notes" + "p **gbrain doesn't have a fixed layout.** It ships with bundled schema packs and lets you author your own when none fit: - **`gbrain-base-v2`** (default as of v0.41.22) — 15-type DRY/MECE canonical taxonomy (14 canonical + `note` catch-all): `person`, `company`, `media`, `tweet`, `social-digest`, `analysis`, `atom`, `concept`, `source`, `deal`, `email`, `slack`, `writing`, `project`, `note`. Subtypes/format/origin pushed to frontmatter. The taxonomy that responds to issue #1479. -- **`gbrain-base`** (legacy, v0.41 and earlier brains) — the original 24-type layout. Stays bundled for back-compat; brains on it can upgrade via `gbrain onboard --check --explain` → `gbrain jobs submit unify-types --allow-protected --params '{"target_pack":"gbrain-base-v2"}'`. +- **`gbrain-base`** (legacy, v0.41 and earlier brains) — the original 24-type layout. Stays bundled for back-compat; brains on it can upgrade via `gbrain onboard --check --explain` → `gbrain jobs submit unify-types --allow-protected --params '{"target_pack":"gbrain-base-v2","apply":true}'` (omit `"apply":true` for a dry-run preview — that is the default). - **`gbrain-recommended`** — extends `gbrain-base` with the 13 additional directories from `docs/GBRAIN_RECOMMENDED_SCHEMA.md` (source, place, trip, conversation, personal, civic, project, etc.). Activate with `gbrain schema use gbrain-recommended`. - **Your own pack** — `gbrain schema detect` clusters your actual filesystem into proposed types, `gbrain schema suggest` runs an LLM pass over them, and `gbrain schema review-candidates --apply` promotes the ones you like. Three commands and the brain knows your shape. Authoring a successor pack (declares `migration_from:` so existing brains can opt in): see [`docs/architecture/pack-upgrade-mechanism.md`](docs/architecture/pack-upgrade-mechanism.md). @@ -260,6 +270,24 @@ The whole loop is described in [`docs/architecture/topologies.md`](docs/architec **Job queue (Minions).** BullMQ-shaped, Postgres-native job queue. Durable subagents (LLM tool loops that survive crashes via two-phase pending→done persistence), shell jobs with audit, child jobs with cascading timeouts, rate leases for outbound providers, attachments via S3/Supabase storage. Replaces "spawn subagent as fire-and-forget Promise" with something that recovers from anything. +**Non-English brains (FTS language config).** The Postgres full-text search tokenizer is configurable via `GBRAIN_FTS_LANGUAGE`. Defaults to `english`. Set it to any text-search configuration that exists in your Postgres instance: + +```bash +export GBRAIN_FTS_LANGUAGE=portuguese # uses built-in portuguese stemmer +export GBRAIN_FTS_LANGUAGE=spanish # built-in spanish stemmer +export GBRAIN_FTS_LANGUAGE=pt_br # custom config (e.g. unaccent + portuguese) +``` + +List available configs: `psql -c "SELECT cfgname FROM pg_ts_config"`. Both the **query side** (`websearch_to_tsquery`) and the **write side** (the trigger functions that populate `pages.search_vector` and `content_chunks.search_vector`) honor `GBRAIN_FTS_LANGUAGE`. On first install (or upgrade), the `configurable_fts_language` schema migration reads the env var and creates trigger functions in the configured language; subsequent inserts/updates tokenize using that setting. To change language on a brain that has already run the migration, use the dedicated CLI command: + +```bash +export GBRAIN_FTS_LANGUAGE=portuguese +gbrain reindex-search-vector --dry-run # preview row counts +gbrain reindex-search-vector --yes # recreate triggers + backfill +``` + +The command is idempotent (re-running with the same language is a no-op for vector content) and uses the same recreate-and-backfill primitives as the migration. For accent-insensitive Portuguese (`pt_br`), see [docs/guides/multi-language-fts.md](docs/guides/multi-language-fts.md) for the `unaccent` + portuguese stemmer recipe. + **43 curated skills.** Routing lives in [`skills/RESOLVER.md`](skills/RESOLVER.md). Covers signal capture, ingest (idea / media / meeting), enrichment, querying, brain ops, citation fixing, daily task management, cron scheduling, reports, voice, soul audit, skill creation, eval framework, and migrations. Skills are markdown files (tool-agnostic), packaged as a single skillpack the installer drops into your agent workspace. **Eval framework.** `gbrain eval longmemeval` runs the public [LongMemEval](https://huggingface.co/datasets/xiaowu0162/longmemeval) benchmark against your hybrid retrieval. `gbrain eval export` + `gbrain eval replay` capture real queries and replay them against code changes (set `GBRAIN_CONTRIBUTOR_MODE=1`). `gbrain eval cross-modal` cross-checks an output against the task using three different-provider frontier models. `gbrain eval retrieval-quality` runs NamedThingBench, which hard-gates the named-thing retrieval families (title-substring, alias-synonym, generic-to-named, multi-chunk-dilution) so a regression in "find the page this query names" fails CI loudly. Full methodology in [`docs/eval/SEARCH_MODE_METHODOLOGY.md`](docs/eval/SEARCH_MODE_METHODOLOGY.md). @@ -291,6 +319,8 @@ Data flowing into the brain. Each integration is a recipe — markdown + setup h ## Troubleshooting +**`gbrain init --pglite` crashes on macOS 26.x (Tahoe)?** PGLite's embedded WASM engine is incompatible with macOS 26.x on Apple Silicon. The fix is to use native Homebrew PostgreSQL + pgvector instead. Full step-by-step setup in [`docs/INSTALL.md` — Troubleshooting: PGLite crashes on macOS 26.x](docs/INSTALL.md#pglite-crashes-on-macos-26x-tahoe). + **`gbrain import` fails with `expected N dimensions, not M`?** Run `gbrain doctor`. It will print the exact `gbrain config set ...` or `gbrain retrieval-upgrade` command to repair the mismatch. You should not need to delete `~/.gbrain`. Fresh `gbrain init --pglite` auto-detects your embedding provider from API keys in your environment: set `OPENAI_API_KEY` (or `ZEROENTROPY_API_KEY` / `VOYAGE_API_KEY`) before running init, or pass `--embedding-model :` explicitly. With multiple keys set, init fires an interactive picker. In non-TTY contexts (CI, Docker) with no keys, init exits 1 with a paste-ready setup hint; pass `--no-embedding` to defer setup until runtime. See [`docs/integrations/embedding-providers.md`](docs/integrations/embedding-providers.md) for the full provider matrix and [`docs/operations/headless-install.md`](docs/operations/headless-install.md) for Docker/CI sequencing. **Hourly cron sync keeps timing out on a federated brain?** v0.41.13.0 ships diff --git a/SECURITY.md b/SECURITY.md index 60def9409..00458908a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -8,17 +8,44 @@ on GitHub. Do not open a public issue for security vulnerabilities. +## Automated security scanning + +CI runs three automated security checks alongside secret scanning (Gitleaks): + +- **Dependency vulnerabilities** — OSV-Scanner + (`.github/workflows/osv-scanner.yml`) runs weekly and on any PR that touches + `package.json` or `bun.lock`. +- **Static analysis (SAST)** — Semgrep CE (`.github/workflows/semgrep.yml`) + runs on every PR and weekly. It is currently **advisory (non-blocking)** + while the finding baseline is tuned; the graduation path to a blocking check + is documented in the workflow file. +- **Release binary provenance** — release builds + (`.github/workflows/release.yml`) attest each compiled binary with + [GitHub artifact attestations](https://docs.github.com/en/actions/security-for-github-actions/using-artifact-attestations). + Verify a downloaded release binary with: + + ```bash + gh attestation verify ./gbrain-darwin-arm64 -R garrytan/gbrain + gh attestation verify ./gbrain-linux-x64 -R garrytan/gbrain + ``` + +All security workflows use SHA-pinned actions and least-privilege permissions, +enforced structurally by actionlint on every workflow change. + ## Remote MCP Security -### ⚠️ Do NOT use open OAuth client registration for remote MCP +### Keep dynamic client registration disabled unless explicitly needed -If you deploy GBrain's MCP server behind an HTTP wrapper with OAuth 2.1 -support, **never allow unauthenticated client registration**. An attacker -who discovers your server URL can: +GBrain disables Dynamic Client Registration (DCR) by default. Keep that +default for internet-reachable deployments and pre-register trusted clients +with operator-approved scopes and source access. Enabling DCR lets network +callers create OAuth client records, so use it only when the deployment's +trust model requires self-service registration and browser approval remains +part of the authorization flow. -1. Register a new OAuth client via `POST /register` -2. Use `client_credentials` grant to obtain a bearer token -3. Access all brain data via the MCP tools +Do not enable `--enable-dcr-insecure` on an untrusted network. That option is +reserved for deployments that intentionally allow self-registered +machine-to-machine clients without browser approval. ### Recommended: `gbrain serve --http` @@ -80,12 +107,10 @@ Auth methods (`--token-endpoint-auth-method`): - `none` — public PKCE-only client (no secret minted; ChatGPT custom connector, Claude Code, Cursor) -The validator rejects unknown methods at the registration boundary, and -the same gate applies to the admin endpoint `POST /admin/api/register-client` -and the DCR `POST /register` path. Pre-v0.41.3 the CLI hard-coded -`redirect_uris = []` and `token_endpoint_auth_method = NULL`, forcing -operators to UPDATE `oauth_clients` rows by hand to make claude.ai work -without `--enable-dcr`. That footgun is gone. +The same validator applies to CLI, admin, and DCR registration paths, so +unknown authentication methods are rejected consistently. Browser-based +clients can be configured entirely through the supported CLI flags; operators +do not need to edit OAuth database rows by hand. ### DCR consent default (v0.42.55+) @@ -135,6 +160,18 @@ the PGLite schema. Local agents continue to use stdio (`gbrain serve`). Running `--http` against a PGLite-backed install fails fast with a clear error message at startup. +### Docker network isolation (self-hosted Postgres) + +OAuth and source scoping enforce isolation on the `serve --http` path only. +Raw Postgres reachability bypasses both: a container that shares Docker's +default `bridge` network with the brain's Postgres can open a direct DB +session without any token and read every source. Put the brain's Postgres on +a user-defined Docker network with nothing untrusted on it, publish its port +loopback-only (if at all), and never put `DATABASE_URL` or a Postgres +password in untrusted agent containers — those should reach the brain +exclusively via OAuth against `serve --http`. Full operator checklist: +[docs/mcp/DEPLOY.md — Co-located Docker workloads](docs/mcp/DEPLOY.md#co-located-docker-workloads-self-hosted-postgres). + ### CORS Default-deny: no `Access-Control-Allow-Origin` header is sent unless an @@ -150,16 +187,10 @@ When the request `Origin` matches the allowlist, the server echoes it back in `Access-Control-Allow-Origin` (with `Vary: Origin`). Otherwise no CORS header is sent and the browser blocks the request. -**v0.41.3:** the same allowlist now gates every OAuth endpoint (`/mcp`, -`/token`, `/authorize`, `/register`, `/revoke`). Pre-v0.41.3 these used -default-wide-open `cors()` middleware, leaking -`Access-Control-Allow-Origin: *` on every response — any web origin could -complete a token exchange from a logged-in operator's browser. The CORS -preflight handler in the legacy bearer transport was also asymmetric -(actual-request path correctly default-deny, but OPTIONS preflight leaked -`Access-Control-Allow-Methods` + `Access-Control-Allow-Headers` to every -Origin); both are now consolidated through a single allowlist-gated path. -A startup stderr WARN fires when `--bind 0.0.0.0` is set without +The same allowlist gates the complete MCP and OAuth HTTP surface. Actual +requests and browser preflight requests use one allowlist-gated policy, so +unlisted origins receive no cross-origin authorization. A startup stderr +warning fires when `--bind 0.0.0.0` is set without `GBRAIN_HTTP_CORS_ORIGIN`, surfacing the default-deny posture before the first request. diff --git a/TODOS.md b/TODOS.md index 40512efd1..acc24c374 100644 --- a/TODOS.md +++ b/TODOS.md @@ -23,23 +23,102 @@ and the scope record at `~/.gstack/projects/garrytan-gbrain/ceo-plans/2026-06-12 `src/core/verbs/entity-card.ts` open-threads assembly + a new schema table (additive — the card field already exists, so this is a quality upgrade, not a contract change). +## v0.42.67.0 follow-ups (Windows build tooling) + +Filed as follow-ups from v0.42.67.0 (`.gitattributes` LF pin for `*.sh` + +`bash` prefix on the 33 `package.json` check commands). Both items are newly +observable: before that release these checks never executed on Windows at all, +so nothing about their runtime was measurable. + +- [ ] **P2 — three guard scripts exceed the 120s `run-verify-parallel.sh` cap on Windows.** + With the dispatch fixed, `bun run verify` on Windows gets 25 passes and 7 failures, and + `check:privacy`, `check:test-names` and `check:test-isolation` are timeouts rather than + real failures (they pass on Linux and macOS well inside the cap). They walk the tree with + per-file shell loops, which is far slower under Windows process creation. Either raise the + cap for these three, or replace the per-file loop with a single `grep -r` pass. Same cap + swallows `typecheck`, though standalone `bun run typecheck` exits 0. +- [ ] **P3 — `check:wasm` cannot create its `node_modules` symlink on Windows.** + `scripts/check-wasm-embedded.sh` fails with `ln: failed to create symbolic link + '/tmp/gbrain-wasm-check.XXXX/node_modules': No such file or directory`. Unprivileged + Windows accounts cannot create symlinks without developer mode. Consider a junction, a + copy, or skipping the check with a clear message when symlink creation is unavailable. + +## community fix-wave follow-ups (filed v0.42.60.0) + +- [x] **P2 — cherry-pick #2112's uncovered doctor.ts hunk.** Fix-wave A (#2820) superseded + most of #2112 but not its `checkSubagentCapability` fix (check explicit `models.subagent` + before `models.tier.subagent`). Implemented: `checkSubagentCapability` now resolves + `models.subagent` before tier/default fallbacks and has regression coverage. + +## v0.42.59.0 follow-ups (five-fix rollup #2735–#2739) + +Filed as follow-ups from v0.42.59.0 (bootstrap probe for +`timeline_entries.event_page_id`, migrate-engine source catalog + target-aware +resume, entity-resolution quarantine, escape-aware fence cells, think gather +source scope). + +- [ ] **P2 — schema-bootstrap-coverage strip block never exercises `timeline_entries.event_page_id`.** + The guard's pre-migration-brain simulation (the strip DDL in + `test/schema-bootstrap-coverage.test.ts`) has no + `ALTER TABLE timeline_entries DROP COLUMN IF EXISTS event_page_id` (or FK drop), so the + coverage entry added for the v121 forward reference is vacuous — the probe never fires + under that harness. The real regression guard lives in `test/bootstrap.test.ts` (which + does drop → re-bootstrap → assert). Add the DROP statements to the strip block so the + coverage test genuinely exercises its own entry. +- [ ] **P2 — extract-facts reconcile still wipes-then-reinserts when the parse emitted MALFORMED warnings.** + `runExtractFacts` (`src/core/cycle/extract-facts.ts`) deletes a page's facts and + reinserts from the parsed fence even when `parseFactsFence` surfaced + `FACTS_TABLE_MALFORMED` warnings — any future parse defect becomes a deletion vector + (rows the parser failed to read get wiped with nothing to reinsert). Consider + skip-wipe-on-warnings: treat a warning-bearing parse as non-authoritative for that page + (skip the wipe, surface a warn), mirroring the empty-fence legacy-row guard's posture. +- [ ] **P3 — bare-name resolution quarantines even on an exact unique match when prefix siblings exist.** + With pages `companies/acme` + `companies/acme-labs`, a bare `"Acme"` yields two + `findPrefixCandidates` rows, so `tryUnambiguousPrefixExpansion` declines — even though + `companies/acme` is an exact `dir/token` slug match (and may be a unique exact title + match). That's an unambiguity signal being wasted. Consider promoting an exact + `dir/token` (or exact-title) hit above the sibling-count check in + `src/core/entities/resolve.ts`. +- [ ] **P2 — `scripts/run-verify-parallel.sh` no-gtimeout fallback reports the watchdog's exit code, not the check's.** + In the fallback branch, `rc=$?` is captured after `wait "$cap_pid"` (the killed + sleep-watchdog, rc=143) rather than after `wait "$pid"` (the actual check) — on a Mac + without coreutils every check false-fails with rc=143. Capture `rc` from `wait "$pid"` + first, then reap the watchdog. +- [ ] **P3 — same-target migrate resume with `--force` still skips checkpointed pages after the wipe.** + `gbrain migrate --to --force` wipes the target's pages, but the resume + manifest's `completed_slugs` filter still applies, so previously-checkpointed pages are + skipped against the now-empty target (pre-existing behavior; the v0.42.59.0 verification + warns about it). `--force` should clear the manifest when it matches the same target. + Where: `src/commands/migrate-engine.ts`. +- [ ] **P2 — think residual scope gaps.** Two spots in `src/core/think/index.ts` don't yet + inherit the caller's source scope the way the gather stage now does: + `persistCitations` resolves citation slugs with an unscoped + `SELECT id FROM pages WHERE slug = $1 LIMIT 1` (cross-source slug ambiguity can attach + saved evidence to the wrong same-slug page), and the trajectory entity-resolution scalar + is `opts.sourceId ?? 'default'` (a federated caller with `allowedSources` but no scalar + resolves entities against `default` instead of its grant). Mirror the gather-stage + precedence (federated array > scalar > default) at both sites. + ## provider-agnostic follow-ups (filed v0.42.58.0) Deferred from the provider-agnostic plumbing wave (#1249/#1250/#1292/#2271/#2209). Plan + review trail at `~/.claude/plans/system-instruction-you-are-working-keen-newell.md`. The eng-review + Codex outside-voice narrowed the wave to these deferrals: -- [ ] **P2 — Capability-aware query expansion on OpenAI-compat providers (#2372).** +- [x] **P2 — Capability-aware query expansion on OpenAI-compat providers (#2372).** Expansion only runs for recipes that declare an `expansion` touchpoint, and only the native providers (anthropic/openai/google) do. To make expansion work on litellm/openrouter/groq/together/deepseek you must ADD expansion touchpoints to those chat-capable recipes AND add a `generateObject`→`generateText` capability fallback for backends without strict structured outputs. Feature-shaped; overlaps the general OpenAI-compat proxy story (`docs/designs/COMMUNITY_IDEAS.md`). Community PR #2373 is a - starting point. Where: `src/core/ai/gateway.ts:expand`, recipe files, `types.ts` (ExpansionTouchpoint). -- [ ] **P2 — LiteLLM as a chat/expansion backend.** `litellm-proxy` declares ONLY an + starting point. Implemented by #2373 plus the DeepSeek/Groq/Together recipe wave, + LiteLLM chat/expansion support, and the OpenRouter expansion touchpoint. Where: + `src/core/ai/gateway.ts:expand`, recipe files, `types.ts` (ExpansionTouchpoint). +- [x] **P2 — LiteLLM as a chat/expansion backend.** `litellm-proxy` declares ONLY an embedding touchpoint, so `think`/chat on LiteLLM is dead. Add chat (and expansion) so a - LiteLLM proxy is a full LLM backend, not embedding-only. The general OpenAI-compat proxy story. + LiteLLM proxy is a full LLM backend, not embedding-only. Implemented by #2208. + The general OpenAI-compat proxy story. - [ ] **P3 — Per-model embedding dims metadata on `EmbeddingTouchpoint`.** `default_dims` is recipe-wide, so a recipe (ollama) can't carry different native dims per model. This wave added the modern ollama model NAMES + a `trust_custom_dims` passthrough (user supplies @@ -2247,10 +2326,25 @@ at plan time and got carved out: via `buildPerSourceBindings`. Document workaround: register source-scoped OAuth clients. -- [ ] **v0.41+: T20 — extends-chain merging in registry.ts.** - `registry.ts:167` documents the gap. Implementing full child-wins - merge cascades through every consumer of `manifest.page_types`. ~1 - day CC. +- [x] **v0.41+: T20 — extends-chain merging in registry.ts.** DONE (#1749). + `resolvePack` now merges parent → child (child-wins) for the six + ingest/query-shaping fields (`page_types`, `link_types`, + `frontmatter_links`, `enrichable_types`, `filing_rules`, `takes_kinds`) + plus `borrow_from` materialization, in `src/core/schema-pack/merge.ts`. + The cascade was transparent (consumers already read `resolved.manifest`), + not per-consumer. `phases`/`calibration_domains` deliberately excluded — + see the P3 follow-up below. + +- [ ] **P3: explicit opt-in to inherit `phases` / `calibration_domains`.** + T20 excludes these two from the child-wins merge because they gate real + cycle execution (`cycle.ts` `packDeclaresPhase`) and the manifest + contract says each pack declares its own participation explicitly — + auto-inheriting would silently make a child run cycle phases it never + requested. Multi-level lens packs (`gbrain-everything`) therefore still + re-declare them by hand. If that redeclaration becomes painful, add an + explicit manifest flag (e.g. `inherit_phases: true`) so a pack author + opts in consciously. Depends on: T20 (landed). Start in + `src/core/schema-pack/merge.ts` (`mergeInheritedManifest`). - [ ] **v0.41+: T21 — comment-preserving YAML emitter.** v0.40.7.0 emitter does NOT preserve comments. Authors who care @@ -2509,7 +2603,7 @@ contributor traps. - [ ] **v0.37.x: Adopt `resolveDefaultHeaders` for Together / Groq / other attribution-bearing recipes.** v0.37.6.0's `default_headers` / `resolveDefaultHeaders` seam is generic — any recipe whose provider benefits from app-attribution headers can opt in. Together and Groq both have rankings/analytics tied to per-app headers. Add their respective attribution headers to each recipe, similar to OR's `HTTP-Referer` + `X-OpenRouter-Title`. No type-system or gateway changes needed; just `default_headers` blocks on the existing recipes plus `_REFERER` / `_TITLE` env vars in their `auth_env.optional`. Filed during v0.37.6.0 eng review as a D4 generalization opportunity. -- [ ] **v0.37.x: Guard cli.ts `main()` so importing `buildGatewayConfig` doesn't print help.** v0.37.6.0 exported `buildGatewayConfig` from `src/cli.ts` for test access. Importing it triggers the file's top-level `main()` which prints help to stdout during tests — functionally harmless (tests pass) but noisy. Fix: wrap `main()` in `if (import.meta.main)` so it only runs when cli.ts is the entry point, not when imported. Touches one line; trivial. Filed during v0.37.6.0 implementation. +- [x] **v0.37.x: Guard cli.ts `main()` so importing `buildGatewayConfig` doesn't print help.** v0.37.6.0 exported `buildGatewayConfig` from `src/cli.ts` for test access. Importing it triggers the file's top-level `main()` which prints help to stdout during tests — functionally harmless (tests pass) but noisy. Fix: wrap `main()` in `if (import.meta.main)` so it only runs when cli.ts is the entry point, not when imported. Touches one line; trivial. Filed during v0.37.6.0 implementation. ## v0.37.4.0 pgGraph CI scaffolding follow-ups (v0.37.x+) diff --git a/admin/bun.lock b/admin/bun.lock index 9a4f43b33..18cf7221f 100644 --- a/admin/bun.lock +++ b/admin/bun.lock @@ -13,48 +13,52 @@ "@types/react-dom": "^19.1.2", "@vitejs/plugin-react": "^4.4.1", "typescript": "^5.8.3", - "vite": "^6.3.3", + "vite": "^6.4.3", }, }, }, + "overrides": { + "@babel/core": "^7.29.6", + "postcss": "^8.5.23", + }, "packages": { - "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], - "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], + "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], - "@babel/core": ["@babel/core@7.29.0", "", { "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" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], + "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@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" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], - "@babel/generator": ["@babel/generator@7.29.1", "", { "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" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + "@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], - "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "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" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], - "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], + "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], - "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], - "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], - "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], - "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], - "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], - "@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="], + "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], - "@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="], "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], - "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], - "@babel/traverse": ["@babel/traverse@7.29.0", "", { "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" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + "@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], - "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], @@ -220,7 +224,7 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="], "node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="], @@ -228,7 +232,7 @@ "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], - "postcss": ["postcss@8.5.9", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw=="], + "postcss": ["postcss@8.5.25", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw=="], "react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="], @@ -250,8 +254,36 @@ "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], - "vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="], + "vite": ["vite@6.4.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="], "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "@types/babel__core/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + + "@types/babel__core/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@types/babel__generator/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@types/babel__template/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + + "@types/babel__template/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@types/babel__traverse/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@types/babel__core/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@types/babel__core/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@types/babel__generator/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@types/babel__generator/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@types/babel__template/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@types/babel__template/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@types/babel__traverse/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@types/babel__traverse/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], } } diff --git a/admin/dist/assets/index-CviJXT-1.js b/admin/dist/assets/index-CviJXT-1.js new file mode 100644 index 000000000..674e92792 --- /dev/null +++ b/admin/dist/assets/index-CviJXT-1.js @@ -0,0 +1,56 @@ +(function(){const M=document.createElement("link").relList;if(M&&M.supports&&M.supports("modulepreload"))return;for(const N of document.querySelectorAll('link[rel="modulepreload"]'))h(N);new MutationObserver(N=>{for(const _ of N)if(_.type==="childList")for(const R of _.addedNodes)R.tagName==="LINK"&&R.rel==="modulepreload"&&h(R)}).observe(document,{childList:!0,subtree:!0});function E(N){const _={};return N.integrity&&(_.integrity=N.integrity),N.referrerPolicy&&(_.referrerPolicy=N.referrerPolicy),N.crossOrigin==="use-credentials"?_.credentials="include":N.crossOrigin==="anonymous"?_.credentials="omit":_.credentials="same-origin",_}function h(N){if(N.ep)return;N.ep=!0;const _=E(N);fetch(N.href,_)}})();function Md(o){return o&&o.__esModule&&Object.prototype.hasOwnProperty.call(o,"default")?o.default:o}var ff={exports:{}},jn={};/** + * @license React + * react-jsx-runtime.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var gd;function ey(){if(gd)return jn;gd=1;var o=Symbol.for("react.transitional.element"),M=Symbol.for("react.fragment");function E(h,N,_){var R=null;if(_!==void 0&&(R=""+_),N.key!==void 0&&(R=""+N.key),"key"in N){_={};for(var K in N)K!=="key"&&(_[K]=N[K])}else _=N;return N=_.ref,{$$typeof:o,type:h,key:R,ref:N!==void 0?N:null,props:_}}return jn.Fragment=M,jn.jsx=E,jn.jsxs=E,jn}var Sd;function ay(){return Sd||(Sd=1,ff.exports=ey()),ff.exports}var c=ay(),sf={exports:{}},k={};/** + * @license React + * react.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var pd;function ny(){if(pd)return k;pd=1;var o=Symbol.for("react.transitional.element"),M=Symbol.for("react.portal"),E=Symbol.for("react.fragment"),h=Symbol.for("react.strict_mode"),N=Symbol.for("react.profiler"),_=Symbol.for("react.consumer"),R=Symbol.for("react.context"),K=Symbol.for("react.forward_ref"),A=Symbol.for("react.suspense"),p=Symbol.for("react.memo"),B=Symbol.for("react.lazy"),D=Symbol.for("react.activity"),O=Symbol.iterator;function Q(d){return d===null||typeof d!="object"?null:(d=O&&d[O]||d["@@iterator"],typeof d=="function"?d:null)}var V={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},it=Object.assign,gt={};function ut(d,z,U){this.props=d,this.context=z,this.refs=gt,this.updater=U||V}ut.prototype.isReactComponent={},ut.prototype.setState=function(d,z){if(typeof d!="object"&&typeof d!="function"&&d!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,d,z,"setState")},ut.prototype.forceUpdate=function(d){this.updater.enqueueForceUpdate(this,d,"forceUpdate")};function w(){}w.prototype=ut.prototype;function X(d,z,U){this.props=d,this.context=z,this.refs=gt,this.updater=U||V}var Xt=X.prototype=new w;Xt.constructor=X,it(Xt,ut.prototype),Xt.isPureReactComponent=!0;var Vt=Array.isArray;function Qt(){}var at={H:null,A:null,T:null,S:null},kt=Object.prototype.hasOwnProperty;function El(d,z,U){var q=U.ref;return{$$typeof:o,type:d,key:z,ref:q!==void 0?q:null,props:U}}function Le(d,z){return El(d.type,z,d.props)}function Ol(d){return typeof d=="object"&&d!==null&&d.$$typeof===o}function $t(d){var z={"=":"=0",":":"=2"};return"$"+d.replace(/[=:]/g,function(U){return z[U]})}var Te=/\/+/g;function Ul(d,z){return typeof d=="object"&&d!==null&&d.key!=null?$t(""+d.key):z.toString(36)}function Tl(d){switch(d.status){case"fulfilled":return d.value;case"rejected":throw d.reason;default:switch(typeof d.status=="string"?d.then(Qt,Qt):(d.status="pending",d.then(function(z){d.status==="pending"&&(d.status="fulfilled",d.value=z)},function(z){d.status==="pending"&&(d.status="rejected",d.reason=z)})),d.status){case"fulfilled":return d.value;case"rejected":throw d.reason}}throw d}function x(d,z,U,q,$){var I=typeof d;(I==="undefined"||I==="boolean")&&(d=null);var ot=!1;if(d===null)ot=!0;else switch(I){case"bigint":case"string":case"number":ot=!0;break;case"object":switch(d.$$typeof){case o:case M:ot=!0;break;case B:return ot=d._init,x(ot(d._payload),z,U,q,$)}}if(ot)return $=$(d),ot=q===""?"."+Ul(d,0):q,Vt($)?(U="",ot!=null&&(U=ot.replace(Te,"$&/")+"/"),x($,z,U,"",function(Oa){return Oa})):$!=null&&(Ol($)&&($=Le($,U+($.key==null||d&&d.key===$.key?"":(""+$.key).replace(Te,"$&/")+"/")+ot)),z.push($)),1;ot=0;var Kt=q===""?".":q+":";if(Vt(d))for(var At=0;At>>1,St=x[ht];if(0>>1;htN(U,J))qN($,U)?(x[ht]=$,x[q]=J,ht=q):(x[ht]=U,x[z]=J,ht=z);else if(qN($,J))x[ht]=$,x[q]=J,ht=q;else break t}}return C}function N(x,C){var J=x.sortIndex-C.sortIndex;return J!==0?J:x.id-C.id}if(o.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var _=performance;o.unstable_now=function(){return _.now()}}else{var R=Date,K=R.now();o.unstable_now=function(){return R.now()-K}}var A=[],p=[],B=1,D=null,O=3,Q=!1,V=!1,it=!1,gt=!1,ut=typeof setTimeout=="function"?setTimeout:null,w=typeof clearTimeout=="function"?clearTimeout:null,X=typeof setImmediate<"u"?setImmediate:null;function Xt(x){for(var C=E(p);C!==null;){if(C.callback===null)h(p);else if(C.startTime<=x)h(p),C.sortIndex=C.expirationTime,M(A,C);else break;C=E(p)}}function Vt(x){if(it=!1,Xt(x),!V)if(E(A)!==null)V=!0,Qt||(Qt=!0,$t());else{var C=E(p);C!==null&&Tl(Vt,C.startTime-x)}}var Qt=!1,at=-1,kt=5,El=-1;function Le(){return gt?!0:!(o.unstable_now()-Elx&&Le());){var ht=D.callback;if(typeof ht=="function"){D.callback=null,O=D.priorityLevel;var St=ht(D.expirationTime<=x);if(x=o.unstable_now(),typeof St=="function"){D.callback=St,Xt(x),C=!0;break l}D===E(A)&&h(A),Xt(x)}else h(A);D=E(A)}if(D!==null)C=!0;else{var d=E(p);d!==null&&Tl(Vt,d.startTime-x),C=!1}}break t}finally{D=null,O=J,Q=!1}C=void 0}}finally{C?$t():Qt=!1}}}var $t;if(typeof X=="function")$t=function(){X(Ol)};else if(typeof MessageChannel<"u"){var Te=new MessageChannel,Ul=Te.port2;Te.port1.onmessage=Ol,$t=function(){Ul.postMessage(null)}}else $t=function(){ut(Ol,0)};function Tl(x,C){at=ut(function(){x(o.unstable_now())},C)}o.unstable_IdlePriority=5,o.unstable_ImmediatePriority=1,o.unstable_LowPriority=4,o.unstable_NormalPriority=3,o.unstable_Profiling=null,o.unstable_UserBlockingPriority=2,o.unstable_cancelCallback=function(x){x.callback=null},o.unstable_forceFrameRate=function(x){0>x||125ht?(x.sortIndex=J,M(p,x),E(A)===null&&x===E(p)&&(it?(w(at),at=-1):it=!0,Tl(Vt,J-ht))):(x.sortIndex=St,M(A,x),V||Q||(V=!0,Qt||(Qt=!0,$t()))),x},o.unstable_shouldYield=Le,o.unstable_wrapCallback=function(x){var C=O;return function(){var J=O;O=C;try{return x.apply(this,arguments)}finally{O=J}}}})(df)),df}var jd;function iy(){return jd||(jd=1,rf.exports=uy()),rf.exports}var hf={exports:{}},Zt={};/** + * @license React + * react-dom.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Td;function cy(){if(Td)return Zt;Td=1;var o=mf();function M(A){var p="https://react.dev/errors/"+A;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(o)}catch(M){console.error(M)}}return o(),hf.exports=cy(),hf.exports}/** + * @license React + * react-dom-client.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Ad;function sy(){if(Ad)return Tn;Ad=1;var o=iy(),M=mf(),E=fy();function h(t){var l="https://react.dev/errors/"+t;if(1St||(t.current=ht[St],ht[St]=null,St--)}function U(t,l){St++,ht[St]=t.current,t.current=l}var q=d(null),$=d(null),I=d(null),ot=d(null);function Kt(t,l){switch(U(I,l),U($,t),U(q,null),l.nodeType){case 9:case 11:t=(t=l.documentElement)&&(t=t.namespaceURI)?Xr(t):0;break;default:if(t=l.tagName,l=l.namespaceURI)l=Xr(l),t=Qr(l,t);else switch(t){case"svg":t=1;break;case"math":t=2;break;default:t=0}}z(q),U(q,t)}function At(){z(q),z($),z(I)}function Oa(t){t.memoizedState!==null&&U(ot,t);var l=q.current,e=Qr(l,t.type);l!==e&&(U($,t),U(q,e))}function zn(t){$.current===t&&(z(q),z($)),ot.current===t&&(z(ot),Sn._currentValue=J)}var Lu,yf;function ze(t){if(Lu===void 0)try{throw Error()}catch(e){var l=e.stack.trim().match(/\n( *(at )?)/);Lu=l&&l[1]||"",yf=-1)":-1n||s[a]!==v[n]){var b=` +`+s[a].replace(" at new "," at ");return t.displayName&&b.includes("")&&(b=b.replace("",t.displayName)),b}while(1<=a&&0<=n);break}}}finally{Vu=!1,Error.prepareStackTrace=e}return(e=t?t.displayName||t.name:"")?ze(e):""}function Ud(t,l){switch(t.tag){case 26:case 27:case 5:return ze(t.type);case 16:return ze("Lazy");case 13:return t.child!==l&&l!==null?ze("Suspense Fallback"):ze("Suspense");case 19:return ze("SuspenseList");case 0:case 15:return Ku(t.type,!1);case 11:return Ku(t.type.render,!1);case 1:return Ku(t.type,!0);case 31:return ze("Activity");default:return""}}function vf(t){try{var l="",e=null;do l+=Ud(t,e),e=t,t=t.return;while(t);return l}catch(a){return` +Error generating stack: `+a.message+` +`+a.stack}}var Ju=Object.prototype.hasOwnProperty,wu=o.unstable_scheduleCallback,ku=o.unstable_cancelCallback,Rd=o.unstable_shouldYield,Bd=o.unstable_requestPaint,al=o.unstable_now,Hd=o.unstable_getCurrentPriorityLevel,gf=o.unstable_ImmediatePriority,Sf=o.unstable_UserBlockingPriority,An=o.unstable_NormalPriority,qd=o.unstable_LowPriority,pf=o.unstable_IdlePriority,Yd=o.log,Gd=o.unstable_setDisableYieldValue,Na=null,nl=null;function Il(t){if(typeof Yd=="function"&&Gd(t),nl&&typeof nl.setStrictMode=="function")try{nl.setStrictMode(Na,t)}catch{}}var ul=Math.clz32?Math.clz32:Zd,Xd=Math.log,Qd=Math.LN2;function Zd(t){return t>>>=0,t===0?32:31-(Xd(t)/Qd|0)|0}var _n=256,En=262144,On=4194304;function Ae(t){var l=t&42;if(l!==0)return l;switch(t&-t){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return t&261888;case 262144:case 524288:case 1048576:case 2097152:return t&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return t&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return t}}function Nn(t,l,e){var a=t.pendingLanes;if(a===0)return 0;var n=0,u=t.suspendedLanes,i=t.pingedLanes;t=t.warmLanes;var f=a&134217727;return f!==0?(a=f&~u,a!==0?n=Ae(a):(i&=f,i!==0?n=Ae(i):e||(e=f&~t,e!==0&&(n=Ae(e))))):(f=a&~u,f!==0?n=Ae(f):i!==0?n=Ae(i):e||(e=a&~t,e!==0&&(n=Ae(e)))),n===0?0:l!==0&&l!==n&&(l&u)===0&&(u=n&-n,e=l&-l,u>=e||u===32&&(e&4194048)!==0)?l:n}function Ma(t,l){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&l)===0}function Ld(t,l){switch(t){case 1:case 2:case 4:case 8:case 64:return l+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return l+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function bf(){var t=On;return On<<=1,(On&62914560)===0&&(On=4194304),t}function $u(t){for(var l=[],e=0;31>e;e++)l.push(t);return l}function Da(t,l){t.pendingLanes|=l,l!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function Vd(t,l,e,a,n,u){var i=t.pendingLanes;t.pendingLanes=e,t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0,t.expiredLanes&=e,t.entangledLanes&=e,t.errorRecoveryDisabledLanes&=e,t.shellSuspendCounter=0;var f=t.entanglements,s=t.expirationTimes,v=t.hiddenUpdates;for(e=i&~e;0"u")return null;try{return t.activeElement||t.body}catch{return t.body}}var Wd=/[\n"\\]/g;function ml(t){return t.replace(Wd,function(l){return"\\"+l.charCodeAt(0).toString(16)+" "})}function li(t,l,e,a,n,u,i,f){t.name="",i!=null&&typeof i!="function"&&typeof i!="symbol"&&typeof i!="boolean"?t.type=i:t.removeAttribute("type"),l!=null?i==="number"?(l===0&&t.value===""||t.value!=l)&&(t.value=""+hl(l)):t.value!==""+hl(l)&&(t.value=""+hl(l)):i!=="submit"&&i!=="reset"||t.removeAttribute("value"),l!=null?ei(t,i,hl(l)):e!=null?ei(t,i,hl(e)):a!=null&&t.removeAttribute("value"),n==null&&u!=null&&(t.defaultChecked=!!u),n!=null&&(t.checked=n&&typeof n!="function"&&typeof n!="symbol"),f!=null&&typeof f!="function"&&typeof f!="symbol"&&typeof f!="boolean"?t.name=""+hl(f):t.removeAttribute("name")}function Uf(t,l,e,a,n,u,i,f){if(u!=null&&typeof u!="function"&&typeof u!="symbol"&&typeof u!="boolean"&&(t.type=u),l!=null||e!=null){if(!(u!=="submit"&&u!=="reset"||l!=null)){ti(t);return}e=e!=null?""+hl(e):"",l=l!=null?""+hl(l):e,f||l===t.value||(t.value=l),t.defaultValue=l}a=a??n,a=typeof a!="function"&&typeof a!="symbol"&&!!a,t.checked=f?t.checked:!!a,t.defaultChecked=!!a,i!=null&&typeof i!="function"&&typeof i!="symbol"&&typeof i!="boolean"&&(t.name=i),ti(t)}function ei(t,l,e){l==="number"&&Cn(t.ownerDocument)===t||t.defaultValue===""+e||(t.defaultValue=""+e)}function $e(t,l,e,a){if(t=t.options,l){l={};for(var n=0;n"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),ci=!1;if(Hl)try{var Ba={};Object.defineProperty(Ba,"passive",{get:function(){ci=!0}}),window.addEventListener("test",Ba,Ba),window.removeEventListener("test",Ba,Ba)}catch{ci=!1}var te=null,fi=null,Rn=null;function Xf(){if(Rn)return Rn;var t,l=fi,e=l.length,a,n="value"in te?te.value:te.textContent,u=n.length;for(t=0;t=Ya),Jf=" ",wf=!1;function kf(t,l){switch(t){case"keyup":return zh.indexOf(l.keyCode)!==-1;case"keydown":return l.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function $f(t){return t=t.detail,typeof t=="object"&&"data"in t?t.data:null}var Pe=!1;function _h(t,l){switch(t){case"compositionend":return $f(l);case"keypress":return l.which!==32?null:(wf=!0,Jf);case"textInput":return t=l.data,t===Jf&&wf?null:t;default:return null}}function Eh(t,l){if(Pe)return t==="compositionend"||!hi&&kf(t,l)?(t=Xf(),Rn=fi=te=null,Pe=!1,t):null;switch(t){case"paste":return null;case"keypress":if(!(l.ctrlKey||l.altKey||l.metaKey)||l.ctrlKey&&l.altKey){if(l.char&&1=l)return{node:e,offset:l-t};t=a}t:{for(;e;){if(e.nextSibling){e=e.nextSibling;break t}e=e.parentNode}e=void 0}e=as(e)}}function us(t,l){return t&&l?t===l?!0:t&&t.nodeType===3?!1:l&&l.nodeType===3?us(t,l.parentNode):"contains"in t?t.contains(l):t.compareDocumentPosition?!!(t.compareDocumentPosition(l)&16):!1:!1}function is(t){t=t!=null&&t.ownerDocument!=null&&t.ownerDocument.defaultView!=null?t.ownerDocument.defaultView:window;for(var l=Cn(t.document);l instanceof t.HTMLIFrameElement;){try{var e=typeof l.contentWindow.location.href=="string"}catch{e=!1}if(e)t=l.contentWindow;else break;l=Cn(t.document)}return l}function vi(t){var l=t&&t.nodeName&&t.nodeName.toLowerCase();return l&&(l==="input"&&(t.type==="text"||t.type==="search"||t.type==="tel"||t.type==="url"||t.type==="password")||l==="textarea"||t.contentEditable==="true")}var Bh=Hl&&"documentMode"in document&&11>=document.documentMode,ta=null,gi=null,Za=null,Si=!1;function cs(t,l,e){var a=e.window===e?e.document:e.nodeType===9?e:e.ownerDocument;Si||ta==null||ta!==Cn(a)||(a=ta,"selectionStart"in a&&vi(a)?a={start:a.selectionStart,end:a.selectionEnd}:(a=(a.ownerDocument&&a.ownerDocument.defaultView||window).getSelection(),a={anchorNode:a.anchorNode,anchorOffset:a.anchorOffset,focusNode:a.focusNode,focusOffset:a.focusOffset}),Za&&Qa(Za,a)||(Za=a,a=Eu(gi,"onSelect"),0>=i,n-=i,Nl=1<<32-ul(l)+n|e<F?(et=Y,Y=null):et=Y.sibling;var ft=g(m,Y,y[F],j);if(ft===null){Y===null&&(Y=et);break}t&&Y&&ft.alternate===null&&l(m,Y),r=u(ft,r,F),ct===null?G=ft:ct.sibling=ft,ct=ft,Y=et}if(F===y.length)return e(m,Y),nt&&Yl(m,F),G;if(Y===null){for(;FF?(et=Y,Y=null):et=Y.sibling;var je=g(m,Y,ft.value,j);if(je===null){Y===null&&(Y=et);break}t&&Y&&je.alternate===null&&l(m,Y),r=u(je,r,F),ct===null?G=je:ct.sibling=je,ct=je,Y=et}if(ft.done)return e(m,Y),nt&&Yl(m,F),G;if(Y===null){for(;!ft.done;F++,ft=y.next())ft=T(m,ft.value,j),ft!==null&&(r=u(ft,r,F),ct===null?G=ft:ct.sibling=ft,ct=ft);return nt&&Yl(m,F),G}for(Y=a(Y);!ft.done;F++,ft=y.next())ft=S(Y,m,F,ft.value,j),ft!==null&&(t&&ft.alternate!==null&&Y.delete(ft.key===null?F:ft.key),r=u(ft,r,F),ct===null?G=ft:ct.sibling=ft,ct=ft);return t&&Y.forEach(function(ly){return l(m,ly)}),nt&&Yl(m,F),G}function vt(m,r,y,j){if(typeof y=="object"&&y!==null&&y.type===it&&y.key===null&&(y=y.props.children),typeof y=="object"&&y!==null){switch(y.$$typeof){case Q:t:{for(var G=y.key;r!==null;){if(r.key===G){if(G=y.type,G===it){if(r.tag===7){e(m,r.sibling),j=n(r,y.props.children),j.return=m,m=j;break t}}else if(r.elementType===G||typeof G=="object"&&G!==null&&G.$$typeof===kt&&He(G)===r.type){e(m,r.sibling),j=n(r,y.props),ka(j,y),j.return=m,m=j;break t}e(m,r);break}else l(m,r);r=r.sibling}y.type===it?(j=De(y.props.children,m.mode,j,y.key),j.return=m,m=j):(j=Vn(y.type,y.key,y.props,null,m.mode,j),ka(j,y),j.return=m,m=j)}return i(m);case V:t:{for(G=y.key;r!==null;){if(r.key===G)if(r.tag===4&&r.stateNode.containerInfo===y.containerInfo&&r.stateNode.implementation===y.implementation){e(m,r.sibling),j=n(r,y.children||[]),j.return=m,m=j;break t}else{e(m,r);break}else l(m,r);r=r.sibling}j=Ai(y,m.mode,j),j.return=m,m=j}return i(m);case kt:return y=He(y),vt(m,r,y,j)}if(Tl(y))return H(m,r,y,j);if($t(y)){if(G=$t(y),typeof G!="function")throw Error(h(150));return y=G.call(y),Z(m,r,y,j)}if(typeof y.then=="function")return vt(m,r,Fn(y),j);if(y.$$typeof===X)return vt(m,r,wn(m,y),j);In(m,y)}return typeof y=="string"&&y!==""||typeof y=="number"||typeof y=="bigint"?(y=""+y,r!==null&&r.tag===6?(e(m,r.sibling),j=n(r,y),j.return=m,m=j):(e(m,r),j=zi(y,m.mode,j),j.return=m,m=j),i(m)):e(m,r)}return function(m,r,y,j){try{wa=0;var G=vt(m,r,y,j);return ra=null,G}catch(Y){if(Y===oa||Y===$n)throw Y;var ct=cl(29,Y,null,m.mode);return ct.lanes=j,ct.return=m,ct}finally{}}}var Ye=Ms(!0),Ds=Ms(!1),ue=!1;function qi(t){t.updateQueue={baseState:t.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function Yi(t,l){t=t.updateQueue,l.updateQueue===t&&(l.updateQueue={baseState:t.baseState,firstBaseUpdate:t.firstBaseUpdate,lastBaseUpdate:t.lastBaseUpdate,shared:t.shared,callbacks:null})}function ie(t){return{lane:t,tag:0,payload:null,callback:null,next:null}}function ce(t,l,e){var a=t.updateQueue;if(a===null)return null;if(a=a.shared,(st&2)!==0){var n=a.pending;return n===null?l.next=l:(l.next=n.next,n.next=l),a.pending=l,l=Ln(t),ms(t,null,e),l}return Zn(t,a,l,e),Ln(t)}function $a(t,l,e){if(l=l.updateQueue,l!==null&&(l=l.shared,(e&4194048)!==0)){var a=l.lanes;a&=t.pendingLanes,e|=a,l.lanes=e,jf(t,e)}}function Gi(t,l){var e=t.updateQueue,a=t.alternate;if(a!==null&&(a=a.updateQueue,e===a)){var n=null,u=null;if(e=e.firstBaseUpdate,e!==null){do{var i={lane:e.lane,tag:e.tag,payload:e.payload,callback:null,next:null};u===null?n=u=i:u=u.next=i,e=e.next}while(e!==null);u===null?n=u=l:u=u.next=l}else n=u=l;e={baseState:a.baseState,firstBaseUpdate:n,lastBaseUpdate:u,shared:a.shared,callbacks:a.callbacks},t.updateQueue=e;return}t=e.lastBaseUpdate,t===null?e.firstBaseUpdate=l:t.next=l,e.lastBaseUpdate=l}var Xi=!1;function Wa(){if(Xi){var t=sa;if(t!==null)throw t}}function Fa(t,l,e,a){Xi=!1;var n=t.updateQueue;ue=!1;var u=n.firstBaseUpdate,i=n.lastBaseUpdate,f=n.shared.pending;if(f!==null){n.shared.pending=null;var s=f,v=s.next;s.next=null,i===null?u=v:i.next=v,i=s;var b=t.alternate;b!==null&&(b=b.updateQueue,f=b.lastBaseUpdate,f!==i&&(f===null?b.firstBaseUpdate=v:f.next=v,b.lastBaseUpdate=s))}if(u!==null){var T=n.baseState;i=0,b=v=s=null,f=u;do{var g=f.lane&-536870913,S=g!==f.lane;if(S?(lt&g)===g:(a&g)===g){g!==0&&g===fa&&(Xi=!0),b!==null&&(b=b.next={lane:0,tag:f.tag,payload:f.payload,callback:null,next:null});t:{var H=t,Z=f;g=l;var vt=e;switch(Z.tag){case 1:if(H=Z.payload,typeof H=="function"){T=H.call(vt,T,g);break t}T=H;break t;case 3:H.flags=H.flags&-65537|128;case 0:if(H=Z.payload,g=typeof H=="function"?H.call(vt,T,g):H,g==null)break t;T=D({},T,g);break t;case 2:ue=!0}}g=f.callback,g!==null&&(t.flags|=64,S&&(t.flags|=8192),S=n.callbacks,S===null?n.callbacks=[g]:S.push(g))}else S={lane:g,tag:f.tag,payload:f.payload,callback:f.callback,next:null},b===null?(v=b=S,s=T):b=b.next=S,i|=g;if(f=f.next,f===null){if(f=n.shared.pending,f===null)break;S=f,f=S.next,S.next=null,n.lastBaseUpdate=S,n.shared.pending=null}}while(!0);b===null&&(s=T),n.baseState=s,n.firstBaseUpdate=v,n.lastBaseUpdate=b,u===null&&(n.shared.lanes=0),de|=i,t.lanes=i,t.memoizedState=T}}function Cs(t,l){if(typeof t!="function")throw Error(h(191,t));t.call(l)}function Us(t,l){var e=t.callbacks;if(e!==null)for(t.callbacks=null,t=0;tu?u:8;var i=x.T,f={};x.T=f,uc(t,!1,l,e);try{var s=n(),v=x.S;if(v!==null&&v(f,s),s!==null&&typeof s=="object"&&typeof s.then=="function"){var b=Vh(s,a);tn(t,l,b,dl(t))}else tn(t,l,a,dl(t))}catch(T){tn(t,l,{then:function(){},status:"rejected",reason:T},dl())}finally{C.p=u,i!==null&&f.types!==null&&(i.types=f.types),x.T=i}}function Wh(){}function ac(t,l,e,a){if(t.tag!==5)throw Error(h(476));var n=ro(t).queue;oo(t,n,l,J,e===null?Wh:function(){return ho(t),e(a)})}function ro(t){var l=t.memoizedState;if(l!==null)return l;l={memoizedState:J,baseState:J,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Zl,lastRenderedState:J},next:null};var e={};return l.next={memoizedState:e,baseState:e,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Zl,lastRenderedState:e},next:null},t.memoizedState=l,t=t.alternate,t!==null&&(t.memoizedState=l),l}function ho(t){var l=ro(t);l.next===null&&(l=t.alternate.memoizedState),tn(t,l.next.queue,{},dl())}function nc(){return qt(Sn)}function mo(){return Et().memoizedState}function yo(){return Et().memoizedState}function Fh(t){for(var l=t.return;l!==null;){switch(l.tag){case 24:case 3:var e=dl();t=ie(e);var a=ce(l,t,e);a!==null&&(el(a,l,e),$a(a,l,e)),l={cache:Ui()},t.payload=l;return}l=l.return}}function Ih(t,l,e){var a=dl();e={lane:a,revertLane:0,gesture:null,action:e,hasEagerState:!1,eagerState:null,next:null},fu(t)?go(l,e):(e=ji(t,l,e,a),e!==null&&(el(e,t,a),So(e,l,a)))}function vo(t,l,e){var a=dl();tn(t,l,e,a)}function tn(t,l,e,a){var n={lane:a,revertLane:0,gesture:null,action:e,hasEagerState:!1,eagerState:null,next:null};if(fu(t))go(l,n);else{var u=t.alternate;if(t.lanes===0&&(u===null||u.lanes===0)&&(u=l.lastRenderedReducer,u!==null))try{var i=l.lastRenderedState,f=u(i,e);if(n.hasEagerState=!0,n.eagerState=f,il(f,i))return Zn(t,l,n,0),pt===null&&Qn(),!1}catch{}finally{}if(e=ji(t,l,n,a),e!==null)return el(e,t,a),So(e,l,a),!0}return!1}function uc(t,l,e,a){if(a={lane:2,revertLane:qc(),gesture:null,action:a,hasEagerState:!1,eagerState:null,next:null},fu(t)){if(l)throw Error(h(479))}else l=ji(t,e,a,2),l!==null&&el(l,t,2)}function fu(t){var l=t.alternate;return t===W||l!==null&&l===W}function go(t,l){ha=lu=!0;var e=t.pending;e===null?l.next=l:(l.next=e.next,e.next=l),t.pending=l}function So(t,l,e){if((e&4194048)!==0){var a=l.lanes;a&=t.pendingLanes,e|=a,l.lanes=e,jf(t,e)}}var ln={readContext:qt,use:nu,useCallback:Tt,useContext:Tt,useEffect:Tt,useImperativeHandle:Tt,useLayoutEffect:Tt,useInsertionEffect:Tt,useMemo:Tt,useReducer:Tt,useRef:Tt,useState:Tt,useDebugValue:Tt,useDeferredValue:Tt,useTransition:Tt,useSyncExternalStore:Tt,useId:Tt,useHostTransitionStatus:Tt,useFormState:Tt,useActionState:Tt,useOptimistic:Tt,useMemoCache:Tt,useCacheRefresh:Tt};ln.useEffectEvent=Tt;var po={readContext:qt,use:nu,useCallback:function(t,l){return Jt().memoizedState=[t,l===void 0?null:l],t},useContext:qt,useEffect:lo,useImperativeHandle:function(t,l,e){e=e!=null?e.concat([t]):null,iu(4194308,4,uo.bind(null,l,t),e)},useLayoutEffect:function(t,l){return iu(4194308,4,t,l)},useInsertionEffect:function(t,l){iu(4,2,t,l)},useMemo:function(t,l){var e=Jt();l=l===void 0?null:l;var a=t();if(Ge){Il(!0);try{t()}finally{Il(!1)}}return e.memoizedState=[a,l],a},useReducer:function(t,l,e){var a=Jt();if(e!==void 0){var n=e(l);if(Ge){Il(!0);try{e(l)}finally{Il(!1)}}}else n=l;return a.memoizedState=a.baseState=n,t={pending:null,lanes:0,dispatch:null,lastRenderedReducer:t,lastRenderedState:n},a.queue=t,t=t.dispatch=Ih.bind(null,W,t),[a.memoizedState,t]},useRef:function(t){var l=Jt();return t={current:t},l.memoizedState=t},useState:function(t){t=Ii(t);var l=t.queue,e=vo.bind(null,W,l);return l.dispatch=e,[t.memoizedState,e]},useDebugValue:lc,useDeferredValue:function(t,l){var e=Jt();return ec(e,t,l)},useTransition:function(){var t=Ii(!1);return t=oo.bind(null,W,t.queue,!0,!1),Jt().memoizedState=t,[!1,t]},useSyncExternalStore:function(t,l,e){var a=W,n=Jt();if(nt){if(e===void 0)throw Error(h(407));e=e()}else{if(e=l(),pt===null)throw Error(h(349));(lt&127)!==0||Gs(a,l,e)}n.memoizedState=e;var u={value:e,getSnapshot:l};return n.queue=u,lo(Qs.bind(null,a,u,t),[t]),a.flags|=2048,ya(9,{destroy:void 0},Xs.bind(null,a,u,e,l),null),e},useId:function(){var t=Jt(),l=pt.identifierPrefix;if(nt){var e=Ml,a=Nl;e=(a&~(1<<32-ul(a)-1)).toString(32)+e,l="_"+l+"R_"+e,e=eu++,0<\/script>",u=u.removeChild(u.firstChild);break;case"select":u=typeof a.is=="string"?i.createElement("select",{is:a.is}):i.createElement("select"),a.multiple?u.multiple=!0:a.size&&(u.size=a.size);break;default:u=typeof a.is=="string"?i.createElement(n,{is:a.is}):i.createElement(n)}}u[Bt]=l,u[Wt]=a;t:for(i=l.child;i!==null;){if(i.tag===5||i.tag===6)u.appendChild(i.stateNode);else if(i.tag!==4&&i.tag!==27&&i.child!==null){i.child.return=i,i=i.child;continue}if(i===l)break t;for(;i.sibling===null;){if(i.return===null||i.return===l)break t;i=i.return}i.sibling.return=i.return,i=i.sibling}l.stateNode=u;t:switch(Gt(u,n,a),n){case"button":case"input":case"select":case"textarea":a=!!a.autoFocus;break t;case"img":a=!0;break t;default:a=!1}a&&Vl(l)}}return xt(l),pc(l,l.type,t===null?null:t.memoizedProps,l.pendingProps,e),null;case 6:if(t&&l.stateNode!=null)t.memoizedProps!==a&&Vl(l);else{if(typeof a!="string"&&l.stateNode===null)throw Error(h(166));if(t=I.current,ia(l)){if(t=l.stateNode,e=l.memoizedProps,a=null,n=Ht,n!==null)switch(n.tag){case 27:case 5:a=n.memoizedProps}t[Bt]=l,t=!!(t.nodeValue===e||a!==null&&a.suppressHydrationWarning===!0||Yr(t.nodeValue,e)),t||ae(l,!0)}else t=Ou(t).createTextNode(a),t[Bt]=l,l.stateNode=t}return xt(l),null;case 31:if(e=l.memoizedState,t===null||t.memoizedState!==null){if(a=ia(l),e!==null){if(t===null){if(!a)throw Error(h(318));if(t=l.memoizedState,t=t!==null?t.dehydrated:null,!t)throw Error(h(557));t[Bt]=l}else Ce(),(l.flags&128)===0&&(l.memoizedState=null),l.flags|=4;xt(l),t=!1}else e=Ni(),t!==null&&t.memoizedState!==null&&(t.memoizedState.hydrationErrors=e),t=!0;if(!t)return l.flags&256?(sl(l),l):(sl(l),null);if((l.flags&128)!==0)throw Error(h(558))}return xt(l),null;case 13:if(a=l.memoizedState,t===null||t.memoizedState!==null&&t.memoizedState.dehydrated!==null){if(n=ia(l),a!==null&&a.dehydrated!==null){if(t===null){if(!n)throw Error(h(318));if(n=l.memoizedState,n=n!==null?n.dehydrated:null,!n)throw Error(h(317));n[Bt]=l}else Ce(),(l.flags&128)===0&&(l.memoizedState=null),l.flags|=4;xt(l),n=!1}else n=Ni(),t!==null&&t.memoizedState!==null&&(t.memoizedState.hydrationErrors=n),n=!0;if(!n)return l.flags&256?(sl(l),l):(sl(l),null)}return sl(l),(l.flags&128)!==0?(l.lanes=e,l):(e=a!==null,t=t!==null&&t.memoizedState!==null,e&&(a=l.child,n=null,a.alternate!==null&&a.alternate.memoizedState!==null&&a.alternate.memoizedState.cachePool!==null&&(n=a.alternate.memoizedState.cachePool.pool),u=null,a.memoizedState!==null&&a.memoizedState.cachePool!==null&&(u=a.memoizedState.cachePool.pool),u!==n&&(a.flags|=2048)),e!==t&&e&&(l.child.flags|=8192),hu(l,l.updateQueue),xt(l),null);case 4:return At(),t===null&&Qc(l.stateNode.containerInfo),xt(l),null;case 10:return Xl(l.type),xt(l),null;case 19:if(z(_t),a=l.memoizedState,a===null)return xt(l),null;if(n=(l.flags&128)!==0,u=a.rendering,u===null)if(n)an(a,!1);else{if(zt!==0||t!==null&&(t.flags&128)!==0)for(t=l.child;t!==null;){if(u=tu(t),u!==null){for(l.flags|=128,an(a,!1),t=u.updateQueue,l.updateQueue=t,hu(l,t),l.subtreeFlags=0,t=e,e=l.child;e!==null;)ys(e,t),e=e.sibling;return U(_t,_t.current&1|2),nt&&Yl(l,a.treeForkCount),l.child}t=t.sibling}a.tail!==null&&al()>Su&&(l.flags|=128,n=!0,an(a,!1),l.lanes=4194304)}else{if(!n)if(t=tu(u),t!==null){if(l.flags|=128,n=!0,t=t.updateQueue,l.updateQueue=t,hu(l,t),an(a,!0),a.tail===null&&a.tailMode==="hidden"&&!u.alternate&&!nt)return xt(l),null}else 2*al()-a.renderingStartTime>Su&&e!==536870912&&(l.flags|=128,n=!0,an(a,!1),l.lanes=4194304);a.isBackwards?(u.sibling=l.child,l.child=u):(t=a.last,t!==null?t.sibling=u:l.child=u,a.last=u)}return a.tail!==null?(t=a.tail,a.rendering=t,a.tail=t.sibling,a.renderingStartTime=al(),t.sibling=null,e=_t.current,U(_t,n?e&1|2:e&1),nt&&Yl(l,a.treeForkCount),t):(xt(l),null);case 22:case 23:return sl(l),Zi(),a=l.memoizedState!==null,t!==null?t.memoizedState!==null!==a&&(l.flags|=8192):a&&(l.flags|=8192),a?(e&536870912)!==0&&(l.flags&128)===0&&(xt(l),l.subtreeFlags&6&&(l.flags|=8192)):xt(l),e=l.updateQueue,e!==null&&hu(l,e.retryQueue),e=null,t!==null&&t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(e=t.memoizedState.cachePool.pool),a=null,l.memoizedState!==null&&l.memoizedState.cachePool!==null&&(a=l.memoizedState.cachePool.pool),a!==e&&(l.flags|=2048),t!==null&&z(Be),null;case 24:return e=null,t!==null&&(e=t.memoizedState.cache),l.memoizedState.cache!==e&&(l.flags|=2048),Xl(Ot),xt(l),null;case 25:return null;case 30:return null}throw Error(h(156,l.tag))}function am(t,l){switch(Ei(l),l.tag){case 1:return t=l.flags,t&65536?(l.flags=t&-65537|128,l):null;case 3:return Xl(Ot),At(),t=l.flags,(t&65536)!==0&&(t&128)===0?(l.flags=t&-65537|128,l):null;case 26:case 27:case 5:return zn(l),null;case 31:if(l.memoizedState!==null){if(sl(l),l.alternate===null)throw Error(h(340));Ce()}return t=l.flags,t&65536?(l.flags=t&-65537|128,l):null;case 13:if(sl(l),t=l.memoizedState,t!==null&&t.dehydrated!==null){if(l.alternate===null)throw Error(h(340));Ce()}return t=l.flags,t&65536?(l.flags=t&-65537|128,l):null;case 19:return z(_t),null;case 4:return At(),null;case 10:return Xl(l.type),null;case 22:case 23:return sl(l),Zi(),t!==null&&z(Be),t=l.flags,t&65536?(l.flags=t&-65537|128,l):null;case 24:return Xl(Ot),null;case 25:return null;default:return null}}function Lo(t,l){switch(Ei(l),l.tag){case 3:Xl(Ot),At();break;case 26:case 27:case 5:zn(l);break;case 4:At();break;case 31:l.memoizedState!==null&&sl(l);break;case 13:sl(l);break;case 19:z(_t);break;case 10:Xl(l.type);break;case 22:case 23:sl(l),Zi(),t!==null&&z(Be);break;case 24:Xl(Ot)}}function nn(t,l){try{var e=l.updateQueue,a=e!==null?e.lastEffect:null;if(a!==null){var n=a.next;e=n;do{if((e.tag&t)===t){a=void 0;var u=e.create,i=e.inst;a=u(),i.destroy=a}e=e.next}while(e!==n)}}catch(f){dt(l,l.return,f)}}function oe(t,l,e){try{var a=l.updateQueue,n=a!==null?a.lastEffect:null;if(n!==null){var u=n.next;a=u;do{if((a.tag&t)===t){var i=a.inst,f=i.destroy;if(f!==void 0){i.destroy=void 0,n=l;var s=e,v=f;try{v()}catch(b){dt(n,s,b)}}}a=a.next}while(a!==u)}}catch(b){dt(l,l.return,b)}}function Vo(t){var l=t.updateQueue;if(l!==null){var e=t.stateNode;try{Us(l,e)}catch(a){dt(t,t.return,a)}}}function Ko(t,l,e){e.props=Xe(t.type,t.memoizedProps),e.state=t.memoizedState;try{e.componentWillUnmount()}catch(a){dt(t,l,a)}}function un(t,l){try{var e=t.ref;if(e!==null){switch(t.tag){case 26:case 27:case 5:var a=t.stateNode;break;case 30:a=t.stateNode;break;default:a=t.stateNode}typeof e=="function"?t.refCleanup=e(a):e.current=a}}catch(n){dt(t,l,n)}}function Dl(t,l){var e=t.ref,a=t.refCleanup;if(e!==null)if(typeof a=="function")try{a()}catch(n){dt(t,l,n)}finally{t.refCleanup=null,t=t.alternate,t!=null&&(t.refCleanup=null)}else if(typeof e=="function")try{e(null)}catch(n){dt(t,l,n)}else e.current=null}function Jo(t){var l=t.type,e=t.memoizedProps,a=t.stateNode;try{t:switch(l){case"button":case"input":case"select":case"textarea":e.autoFocus&&a.focus();break t;case"img":e.src?a.src=e.src:e.srcSet&&(a.srcset=e.srcSet)}}catch(n){dt(t,t.return,n)}}function bc(t,l,e){try{var a=t.stateNode;Am(a,t.type,e,l),a[Wt]=l}catch(n){dt(t,t.return,n)}}function wo(t){return t.tag===5||t.tag===3||t.tag===26||t.tag===27&&ge(t.type)||t.tag===4}function xc(t){t:for(;;){for(;t.sibling===null;){if(t.return===null||wo(t.return))return null;t=t.return}for(t.sibling.return=t.return,t=t.sibling;t.tag!==5&&t.tag!==6&&t.tag!==18;){if(t.tag===27&&ge(t.type)||t.flags&2||t.child===null||t.tag===4)continue t;t.child.return=t,t=t.child}if(!(t.flags&2))return t.stateNode}}function jc(t,l,e){var a=t.tag;if(a===5||a===6)t=t.stateNode,l?(e.nodeType===9?e.body:e.nodeName==="HTML"?e.ownerDocument.body:e).insertBefore(t,l):(l=e.nodeType===9?e.body:e.nodeName==="HTML"?e.ownerDocument.body:e,l.appendChild(t),e=e._reactRootContainer,e!=null||l.onclick!==null||(l.onclick=Bl));else if(a!==4&&(a===27&&ge(t.type)&&(e=t.stateNode,l=null),t=t.child,t!==null))for(jc(t,l,e),t=t.sibling;t!==null;)jc(t,l,e),t=t.sibling}function mu(t,l,e){var a=t.tag;if(a===5||a===6)t=t.stateNode,l?e.insertBefore(t,l):e.appendChild(t);else if(a!==4&&(a===27&&ge(t.type)&&(e=t.stateNode),t=t.child,t!==null))for(mu(t,l,e),t=t.sibling;t!==null;)mu(t,l,e),t=t.sibling}function ko(t){var l=t.stateNode,e=t.memoizedProps;try{for(var a=t.type,n=l.attributes;n.length;)l.removeAttributeNode(n[0]);Gt(l,a,e),l[Bt]=t,l[Wt]=e}catch(u){dt(t,t.return,u)}}var Kl=!1,Dt=!1,Tc=!1,$o=typeof WeakSet=="function"?WeakSet:Set,Rt=null;function nm(t,l){if(t=t.containerInfo,Vc=Bu,t=is(t),vi(t)){if("selectionStart"in t)var e={start:t.selectionStart,end:t.selectionEnd};else t:{e=(e=t.ownerDocument)&&e.defaultView||window;var a=e.getSelection&&e.getSelection();if(a&&a.rangeCount!==0){e=a.anchorNode;var n=a.anchorOffset,u=a.focusNode;a=a.focusOffset;try{e.nodeType,u.nodeType}catch{e=null;break t}var i=0,f=-1,s=-1,v=0,b=0,T=t,g=null;l:for(;;){for(var S;T!==e||n!==0&&T.nodeType!==3||(f=i+n),T!==u||a!==0&&T.nodeType!==3||(s=i+a),T.nodeType===3&&(i+=T.nodeValue.length),(S=T.firstChild)!==null;)g=T,T=S;for(;;){if(T===t)break l;if(g===e&&++v===n&&(f=i),g===u&&++b===a&&(s=i),(S=T.nextSibling)!==null)break;T=g,g=T.parentNode}T=S}e=f===-1||s===-1?null:{start:f,end:s}}else e=null}e=e||{start:0,end:0}}else e=null;for(Kc={focusedElem:t,selectionRange:e},Bu=!1,Rt=l;Rt!==null;)if(l=Rt,t=l.child,(l.subtreeFlags&1028)!==0&&t!==null)t.return=l,Rt=t;else for(;Rt!==null;){switch(l=Rt,u=l.alternate,t=l.flags,l.tag){case 0:if((t&4)!==0&&(t=l.updateQueue,t=t!==null?t.events:null,t!==null))for(e=0;e title"))),Gt(u,a,e),u[Bt]=t,Ut(u),a=u;break t;case"link":var i=ld("link","href",n).get(a+(e.href||""));if(i){for(var f=0;fvt&&(i=vt,vt=Z,Z=i);var m=ns(f,Z),r=ns(f,vt);if(m&&r&&(S.rangeCount!==1||S.anchorNode!==m.node||S.anchorOffset!==m.offset||S.focusNode!==r.node||S.focusOffset!==r.offset)){var y=T.createRange();y.setStart(m.node,m.offset),S.removeAllRanges(),Z>vt?(S.addRange(y),S.extend(r.node,r.offset)):(y.setEnd(r.node,r.offset),S.addRange(y))}}}}for(T=[],S=f;S=S.parentNode;)S.nodeType===1&&T.push({element:S,left:S.scrollLeft,top:S.scrollTop});for(typeof f.focus=="function"&&f.focus(),f=0;fe?32:e,x.T=null,e=Mc,Mc=null;var u=me,i=Wl;if(Ct=0,ba=me=null,Wl=0,(st&6)!==0)throw Error(h(331));var f=st;if(st|=4,ir(u.current),ar(u,u.current,i,e),st=f,dn(0,!1),nl&&typeof nl.onPostCommitFiberRoot=="function")try{nl.onPostCommitFiberRoot(Na,u)}catch{}return!0}finally{C.p=n,x.T=a,zr(t,l)}}function _r(t,l,e){l=vl(e,l),l=sc(t.stateNode,l,2),t=ce(t,l,2),t!==null&&(Da(t,2),Cl(t))}function dt(t,l,e){if(t.tag===3)_r(t,t,e);else for(;l!==null;){if(l.tag===3){_r(l,t,e);break}else if(l.tag===1){var a=l.stateNode;if(typeof l.type.getDerivedStateFromError=="function"||typeof a.componentDidCatch=="function"&&(he===null||!he.has(a))){t=vl(e,t),e=Eo(2),a=ce(l,e,2),a!==null&&(Oo(e,a,l,t),Da(a,2),Cl(a));break}}l=l.return}}function Rc(t,l,e){var a=t.pingCache;if(a===null){a=t.pingCache=new cm;var n=new Set;a.set(l,n)}else n=a.get(l),n===void 0&&(n=new Set,a.set(l,n));n.has(e)||(_c=!0,n.add(e),t=dm.bind(null,t,l,e),l.then(t,t))}function dm(t,l,e){var a=t.pingCache;a!==null&&a.delete(l),t.pingedLanes|=t.suspendedLanes&e,t.warmLanes&=~e,pt===t&&(lt&e)===e&&(zt===4||zt===3&&(lt&62914560)===lt&&300>al()-gu?(st&2)===0&&xa(t,0):Ec|=e,pa===lt&&(pa=0)),Cl(t)}function Er(t,l){l===0&&(l=bf()),t=Me(t,l),t!==null&&(Da(t,l),Cl(t))}function hm(t){var l=t.memoizedState,e=0;l!==null&&(e=l.retryLane),Er(t,e)}function mm(t,l){var e=0;switch(t.tag){case 31:case 13:var a=t.stateNode,n=t.memoizedState;n!==null&&(e=n.retryLane);break;case 19:a=t.stateNode;break;case 22:a=t.stateNode._retryCache;break;default:throw Error(h(314))}a!==null&&a.delete(l),Er(t,e)}function ym(t,l){return wu(t,l)}var zu=null,Ta=null,Bc=!1,Au=!1,Hc=!1,ve=0;function Cl(t){t!==Ta&&t.next===null&&(Ta===null?zu=Ta=t:Ta=Ta.next=t),Au=!0,Bc||(Bc=!0,gm())}function dn(t,l){if(!Hc&&Au){Hc=!0;do for(var e=!1,a=zu;a!==null;){if(t!==0){var n=a.pendingLanes;if(n===0)var u=0;else{var i=a.suspendedLanes,f=a.pingedLanes;u=(1<<31-ul(42|t)+1)-1,u&=n&~(i&~f),u=u&201326741?u&201326741|1:u?u|2:0}u!==0&&(e=!0,Dr(a,u))}else u=lt,u=Nn(a,a===pt?u:0,a.cancelPendingCommit!==null||a.timeoutHandle!==-1),(u&3)===0||Ma(a,u)||(e=!0,Dr(a,u));a=a.next}while(e);Hc=!1}}function vm(){Or()}function Or(){Au=Bc=!1;var t=0;ve!==0&&Em()&&(t=ve);for(var l=al(),e=null,a=zu;a!==null;){var n=a.next,u=Nr(a,l);u===0?(a.next=null,e===null?zu=n:e.next=n,n===null&&(Ta=e)):(e=a,(t!==0||(u&3)!==0)&&(Au=!0)),a=n}Ct!==0&&Ct!==5||dn(t),ve!==0&&(ve=0)}function Nr(t,l){for(var e=t.suspendedLanes,a=t.pingedLanes,n=t.expirationTimes,u=t.pendingLanes&-62914561;0f)break;var b=s.transferSize,T=s.initiatorType;b&&Gr(T)&&(s=s.responseEnd,i+=b*(s"u"?null:document;function Fr(t,l,e){var a=za;if(a&&typeof l=="string"&&l){var n=ml(l);n='link[rel="'+t+'"][href="'+n+'"]',typeof e=="string"&&(n+='[crossorigin="'+e+'"]'),Wr.has(n)||(Wr.add(n),t={rel:t,crossOrigin:e,href:l},a.querySelector(n)===null&&(l=a.createElement("link"),Gt(l,"link",t),Ut(l),a.head.appendChild(l)))}}function Hm(t){Fl.D(t),Fr("dns-prefetch",t,null)}function qm(t,l){Fl.C(t,l),Fr("preconnect",t,l)}function Ym(t,l,e){Fl.L(t,l,e);var a=za;if(a&&t&&l){var n='link[rel="preload"][as="'+ml(l)+'"]';l==="image"&&e&&e.imageSrcSet?(n+='[imagesrcset="'+ml(e.imageSrcSet)+'"]',typeof e.imageSizes=="string"&&(n+='[imagesizes="'+ml(e.imageSizes)+'"]')):n+='[href="'+ml(t)+'"]';var u=n;switch(l){case"style":u=Aa(t);break;case"script":u=_a(t)}jl.has(u)||(t=D({rel:"preload",href:l==="image"&&e&&e.imageSrcSet?void 0:t,as:l},e),jl.set(u,t),a.querySelector(n)!==null||l==="style"&&a.querySelector(vn(u))||l==="script"&&a.querySelector(gn(u))||(l=a.createElement("link"),Gt(l,"link",t),Ut(l),a.head.appendChild(l)))}}function Gm(t,l){Fl.m(t,l);var e=za;if(e&&t){var a=l&&typeof l.as=="string"?l.as:"script",n='link[rel="modulepreload"][as="'+ml(a)+'"][href="'+ml(t)+'"]',u=n;switch(a){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":u=_a(t)}if(!jl.has(u)&&(t=D({rel:"modulepreload",href:t},l),jl.set(u,t),e.querySelector(n)===null)){switch(a){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":if(e.querySelector(gn(u)))return}a=e.createElement("link"),Gt(a,"link",t),Ut(a),e.head.appendChild(a)}}}function Xm(t,l,e){Fl.S(t,l,e);var a=za;if(a&&t){var n=we(a).hoistableStyles,u=Aa(t);l=l||"default";var i=n.get(u);if(!i){var f={loading:0,preload:null};if(i=a.querySelector(vn(u)))f.loading=5;else{t=D({rel:"stylesheet",href:t,"data-precedence":l},e),(e=jl.get(u))&&Ic(t,e);var s=i=a.createElement("link");Ut(s),Gt(s,"link",t),s._p=new Promise(function(v,b){s.onload=v,s.onerror=b}),s.addEventListener("load",function(){f.loading|=1}),s.addEventListener("error",function(){f.loading|=2}),f.loading|=4,Mu(i,l,a)}i={type:"stylesheet",instance:i,count:1,state:f},n.set(u,i)}}}function Qm(t,l){Fl.X(t,l);var e=za;if(e&&t){var a=we(e).hoistableScripts,n=_a(t),u=a.get(n);u||(u=e.querySelector(gn(n)),u||(t=D({src:t,async:!0},l),(l=jl.get(n))&&Pc(t,l),u=e.createElement("script"),Ut(u),Gt(u,"link",t),e.head.appendChild(u)),u={type:"script",instance:u,count:1,state:null},a.set(n,u))}}function Zm(t,l){Fl.M(t,l);var e=za;if(e&&t){var a=we(e).hoistableScripts,n=_a(t),u=a.get(n);u||(u=e.querySelector(gn(n)),u||(t=D({src:t,async:!0,type:"module"},l),(l=jl.get(n))&&Pc(t,l),u=e.createElement("script"),Ut(u),Gt(u,"link",t),e.head.appendChild(u)),u={type:"script",instance:u,count:1,state:null},a.set(n,u))}}function Ir(t,l,e,a){var n=(n=I.current)?Nu(n):null;if(!n)throw Error(h(446));switch(t){case"meta":case"title":return null;case"style":return typeof e.precedence=="string"&&typeof e.href=="string"?(l=Aa(e.href),e=we(n).hoistableStyles,a=e.get(l),a||(a={type:"style",instance:null,count:0,state:null},e.set(l,a)),a):{type:"void",instance:null,count:0,state:null};case"link":if(e.rel==="stylesheet"&&typeof e.href=="string"&&typeof e.precedence=="string"){t=Aa(e.href);var u=we(n).hoistableStyles,i=u.get(t);if(i||(n=n.ownerDocument||n,i={type:"stylesheet",instance:null,count:0,state:{loading:0,preload:null}},u.set(t,i),(u=n.querySelector(vn(t)))&&!u._p&&(i.instance=u,i.state.loading=5),jl.has(t)||(e={rel:"preload",as:"style",href:e.href,crossOrigin:e.crossOrigin,integrity:e.integrity,media:e.media,hrefLang:e.hrefLang,referrerPolicy:e.referrerPolicy},jl.set(t,e),u||Lm(n,t,e,i.state))),l&&a===null)throw Error(h(528,""));return i}if(l&&a!==null)throw Error(h(529,""));return null;case"script":return l=e.async,e=e.src,typeof e=="string"&&l&&typeof l!="function"&&typeof l!="symbol"?(l=_a(e),e=we(n).hoistableScripts,a=e.get(l),a||(a={type:"script",instance:null,count:0,state:null},e.set(l,a)),a):{type:"void",instance:null,count:0,state:null};default:throw Error(h(444,t))}}function Aa(t){return'href="'+ml(t)+'"'}function vn(t){return'link[rel="stylesheet"]['+t+"]"}function Pr(t){return D({},t,{"data-precedence":t.precedence,precedence:null})}function Lm(t,l,e,a){t.querySelector('link[rel="preload"][as="style"]['+l+"]")?a.loading=1:(l=t.createElement("link"),a.preload=l,l.addEventListener("load",function(){return a.loading|=1}),l.addEventListener("error",function(){return a.loading|=2}),Gt(l,"link",e),Ut(l),t.head.appendChild(l))}function _a(t){return'[src="'+ml(t)+'"]'}function gn(t){return"script[async]"+t}function td(t,l,e){if(l.count++,l.instance===null)switch(l.type){case"style":var a=t.querySelector('style[data-href~="'+ml(e.href)+'"]');if(a)return l.instance=a,Ut(a),a;var n=D({},e,{"data-href":e.href,"data-precedence":e.precedence,href:null,precedence:null});return a=(t.ownerDocument||t).createElement("style"),Ut(a),Gt(a,"style",n),Mu(a,e.precedence,t),l.instance=a;case"stylesheet":n=Aa(e.href);var u=t.querySelector(vn(n));if(u)return l.state.loading|=4,l.instance=u,Ut(u),u;a=Pr(e),(n=jl.get(n))&&Ic(a,n),u=(t.ownerDocument||t).createElement("link"),Ut(u);var i=u;return i._p=new Promise(function(f,s){i.onload=f,i.onerror=s}),Gt(u,"link",a),l.state.loading|=4,Mu(u,e.precedence,t),l.instance=u;case"script":return u=_a(e.src),(n=t.querySelector(gn(u)))?(l.instance=n,Ut(n),n):(a=e,(n=jl.get(u))&&(a=D({},e),Pc(a,n)),t=t.ownerDocument||t,n=t.createElement("script"),Ut(n),Gt(n,"link",a),t.head.appendChild(n),l.instance=n);case"void":return null;default:throw Error(h(443,l.type))}else l.type==="stylesheet"&&(l.state.loading&4)===0&&(a=l.instance,l.state.loading|=4,Mu(a,e.precedence,t));return l.instance}function Mu(t,l,e){for(var a=e.querySelectorAll('link[rel="stylesheet"][data-precedence],style[data-precedence]'),n=a.length?a[a.length-1]:null,u=n,i=0;i title"):null)}function Vm(t,l,e){if(e===1||l.itemProp!=null)return!1;switch(t){case"meta":case"title":return!0;case"style":if(typeof l.precedence!="string"||typeof l.href!="string"||l.href==="")break;return!0;case"link":if(typeof l.rel!="string"||typeof l.href!="string"||l.href===""||l.onLoad||l.onError)break;switch(l.rel){case"stylesheet":return t=l.disabled,typeof l.precedence=="string"&&t==null;default:return!0}case"script":if(l.async&&typeof l.async!="function"&&typeof l.async!="symbol"&&!l.onLoad&&!l.onError&&l.src&&typeof l.src=="string")return!0}return!1}function ad(t){return!(t.type==="stylesheet"&&(t.state.loading&3)===0)}function Km(t,l,e,a){if(e.type==="stylesheet"&&(typeof a.media!="string"||matchMedia(a.media).matches!==!1)&&(e.state.loading&4)===0){if(e.instance===null){var n=Aa(a.href),u=l.querySelector(vn(n));if(u){l=u._p,l!==null&&typeof l=="object"&&typeof l.then=="function"&&(t.count++,t=Cu.bind(t),l.then(t,t)),e.state.loading|=4,e.instance=u,Ut(u);return}u=l.ownerDocument||l,a=Pr(a),(n=jl.get(n))&&Ic(a,n),u=u.createElement("link"),Ut(u);var i=u;i._p=new Promise(function(f,s){i.onload=f,i.onerror=s}),Gt(u,"link",a),e.instance=u}t.stylesheets===null&&(t.stylesheets=new Map),t.stylesheets.set(e,l),(l=e.state.preload)&&(e.state.loading&3)===0&&(t.count++,e=Cu.bind(t),l.addEventListener("load",e),l.addEventListener("error",e))}}var tf=0;function Jm(t,l){return t.stylesheets&&t.count===0&&Ru(t,t.stylesheets),0tf?50:800)+l);return t.unsuspend=e,function(){t.unsuspend=null,clearTimeout(a),clearTimeout(n)}}:null}function Cu(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)Ru(this,this.stylesheets);else if(this.unsuspend){var t=this.unsuspend;this.unsuspend=null,t()}}}var Uu=null;function Ru(t,l){t.stylesheets=null,t.unsuspend!==null&&(t.count++,Uu=new Map,l.forEach(wm,t),Uu=null,Cu.call(t))}function wm(t,l){if(!(l.state.loading&4)){var e=Uu.get(t);if(e)var a=e.get(null);else{e=new Map,Uu.set(t,e);for(var n=t.querySelectorAll("link[data-precedence],style[data-precedence]"),u=0;u"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(o)}catch(M){console.error(M)}}return o(),of.exports=sy(),of.exports}var ry=oy();const dy=Md(ry),Cd="";async function wt(o,M){const E=await fetch(`${Cd}${o}`,{...M,credentials:"same-origin",headers:{"Content-Type":"application/json",...M==null?void 0:M.headers}});if(E.status===401)throw window.location.hash="#login",new Error("Unauthorized");if(!E.ok){const h=await E.json().catch(()=>({}));throw new Error(h.error||`HTTP ${E.status}`)}return E.json()}async function hy(o){const M=await fetch(`${Cd}${o}`,{credentials:"same-origin"});if(M.status===401)throw window.location.hash="#login",new Error("Unauthorized");if(!M.ok)throw new Error(`HTTP ${M.status}`);return M.text()}const Lt={login:o=>wt("/admin/login",{method:"POST",body:JSON.stringify({token:o})}),signOutEverywhere:()=>wt("/admin/api/sign-out-everywhere",{method:"POST"}),stats:()=>wt("/admin/api/stats"),health:()=>wt("/admin/api/health-indicators"),agents:()=>wt("/admin/api/agents"),sources:()=>wt("/admin/api/sources"),requests:(o=1,M="")=>wt(`/admin/api/requests?page=${o}${M}`),apiKeys:()=>wt("/admin/api/api-keys"),createApiKey(o){return wt("/admin/api/api-keys",{method:"POST",body:JSON.stringify({name:o})})},revokeApiKey(o){return wt("/admin/api/api-keys/revoke",{method:"POST",body:JSON.stringify({name:o})})},updateClientTtl:(o,M)=>wt("/admin/api/update-client-ttl",{method:"POST",body:JSON.stringify({clientId:o,tokenTtl:M})}),rescopeClient:(o,M,E)=>wt("/admin/api/rescope-client",{method:"POST",body:JSON.stringify({clientId:o,sourceId:M,federatedRead:E})}),revokeClient:o=>wt("/admin/api/revoke-client",{method:"POST",body:JSON.stringify({clientId:o})}),calibrationProfile:o=>wt(`/admin/api/calibration/profile${o?`?holder=${encodeURIComponent(o)}`:""}`),calibrationChart:(o,M)=>hy(`/admin/api/calibration/charts/${encodeURIComponent(o)}${M?`?holder=${encodeURIComponent(M)}`:""}`),jobsWatch:()=>wt("/admin/api/jobs/watch")};function my({onLogin:o}){const[M,E]=L.useState(""),[h,N]=L.useState(""),[_,R]=L.useState(!1),K=async A=>{A.preventDefault(),N(""),R(!0);try{await Lt.login(M),E(""),o()}catch{N("Invalid token.")}finally{R(!1)}};return c.jsx("div",{className:"login-page",children:c.jsxs("div",{className:"login-box",children:[c.jsx("div",{className:"login-logo",children:"GBrain"}),c.jsxs("div",{style:{background:"rgba(136, 170, 255, 0.08)",border:"1px solid rgba(136, 170, 255, 0.2)",borderRadius:8,padding:"14px 16px",marginBottom:20,fontSize:13,lineHeight:1.5,color:"var(--text-secondary)"},children:[c.jsx("div",{style:{fontWeight:600,color:"var(--text-primary)",marginBottom:6},children:"🔒 This is a protected dashboard"}),"Ask your AI agent for the admin login link:",c.jsx("div",{style:{background:"rgba(0,0,0,0.3)",borderRadius:6,padding:"8px 12px",marginTop:8,fontFamily:"var(--font-mono)",fontSize:12,color:"#88aaff",wordBreak:"break-all"},children:'"Give me the GBrain admin login link"'}),c.jsx("div",{style:{marginTop:8,fontSize:12,color:"var(--text-muted)"},children:"Each link is single-use. Your agent generates a fresh one each time."})]}),c.jsxs("details",{style:{marginBottom:16},children:[c.jsx("summary",{style:{cursor:"pointer",fontSize:13,color:"var(--text-muted)"},children:"Or paste bootstrap token manually"}),c.jsxs("form",{onSubmit:K,style:{marginTop:12},children:[c.jsx("div",{style:{marginBottom:12},children:c.jsx("input",{type:"password",placeholder:"Admin Token",value:M,onChange:A=>E(A.target.value)})}),c.jsx("button",{className:"btn btn-primary",style:{width:"100%"},disabled:_,children:_?"Authenticating...":"Submit"}),h&&c.jsx("div",{className:"login-error",children:h})]})]})]})})}function yy(){const[o,M]=L.useState({connected_agents:0,requests_today:0,active_tokens:0}),[E,h]=L.useState({expiring_soon:0,error_rate:"0%"}),[N,_]=L.useState([]),[R,K]=L.useState("connecting"),A=L.useRef(null);L.useEffect(()=>{Lt.stats().then(M).catch(()=>{}),Lt.health().then(h).catch(()=>{});const B=new EventSource("/admin/events",{withCredentials:!0});A.current=B,B.onopen=()=>K("connected"),B.onmessage=O=>{try{const Q=JSON.parse(O.data);_(V=>[Q,...V].slice(0,50))}catch{}},B.onerror=()=>{K("disconnected"),setTimeout(()=>{K("connecting"),B.close()},3e3)};const D=setInterval(()=>{Lt.stats().then(M).catch(()=>{}),Lt.health().then(h).catch(()=>{})},3e4);return()=>{B.close(),clearInterval(D)}},[]);const p=B=>{const D=Date.now()-new Date(B).getTime();return D<6e4?`${Math.floor(D/1e3)}s ago`:D<36e5?`${Math.floor(D/6e4)} min ago`:`${Math.floor(D/36e5)}h ago`};return c.jsxs(c.Fragment,{children:[c.jsx("h1",{className:"page-title",children:"Dashboard"}),c.jsxs("div",{style:{display:"flex",gap:24},children:[c.jsxs("div",{style:{flex:1},children:[c.jsxs("div",{className:"metrics",children:[c.jsxs("div",{className:"metric",children:[c.jsx("div",{className:"metric-value",children:o.connected_agents}),c.jsx("div",{className:"metric-label",children:"Connected Agents"})]}),c.jsxs("div",{className:"metric",children:[c.jsx("div",{className:"metric-value",children:o.requests_today}),c.jsx("div",{className:"metric-label",children:"Requests Today"})]}),c.jsxs("div",{className:"metric",children:[c.jsx("div",{className:"metric-value",children:o.active_tokens}),c.jsx("div",{className:"metric-label",children:"Active Tokens"})]})]}),c.jsxs("h2",{className:"section-title",children:["Live Activity",c.jsx("span",{style:{marginLeft:8,fontSize:10,color:R==="connected"?"var(--success)":R==="connecting"?"var(--warning)":"var(--error)"},children:R==="connected"?"● connected":R==="connecting"?"● connecting...":"● disconnected"})]}),c.jsx("div",{className:"feed",children:N.length===0?c.jsx("div",{className:"feed-empty",children:R==="connected"?"No requests yet. Agents will appear when they connect.":"Connecting..."}):c.jsxs("table",{children:[c.jsx("thead",{children:c.jsxs("tr",{children:[c.jsx("th",{children:"Agent"}),c.jsx("th",{children:"Operation"}),c.jsx("th",{children:"Scopes"}),c.jsx("th",{children:"Latency"}),c.jsx("th",{children:"Status"}),c.jsx("th",{children:"Time"})]})}),c.jsx("tbody",{children:N.map((B,D)=>c.jsxs("tr",{children:[c.jsx("td",{className:"mono",children:B.agent}),c.jsx("td",{className:"mono",children:B.operation}),c.jsx("td",{children:B.scopes.split(",").map(O=>c.jsx("span",{className:`badge badge-${O.trim()}`,style:{marginRight:4},children:O.trim()},O))}),c.jsxs("td",{className:"mono",children:[B.latency_ms," ms"]}),c.jsx("td",{children:c.jsx("span",{className:`badge badge-${B.status}`,children:B.status})}),c.jsx("td",{style:{color:"var(--text-secondary)"},children:p(B.timestamp)})]},D))})]})})]}),c.jsxs("div",{style:{width:220},children:[c.jsx("h2",{className:"section-title",children:"Token Health"}),c.jsxs("div",{className:"health-panel",children:[c.jsxs("div",{className:"health-row",children:[c.jsx("span",{style:{color:"var(--warning)"},children:"Expiring Soon"}),c.jsx("span",{className:"mono",children:E.expiring_soon})]}),c.jsxs("div",{className:"health-row",children:[c.jsx("span",{style:{color:"var(--error)"},children:"Error Rate"}),c.jsx("span",{className:"mono",children:E.error_rate})]})]})]})]})]})}const Ed=["admin","agent","read","sources_admin","users_admin","write"];function vy(o){const M=Math.floor((Date.now()-o.getTime())/1e3);return M<60?"just now":M<3600?`${Math.floor(M/60)}m ago`:M<86400?`${Math.floor(M/3600)}h ago`:`${Math.floor(M/86400)}d ago`}function gy(){const[o,M]=L.useState([]),[E,h]=L.useState([]),[N,_]=L.useState(!0),[R,K]=L.useState(!1),[A,p]=L.useState(null),[B,D]=L.useState(!1),[O,Q]=L.useState(null),[V,it]=L.useState(null);L.useEffect(()=>{gt(),Lt.sources().then(h).catch(()=>{})},[]);const gt=()=>{Lt.agents().then(M).catch(()=>{})};return c.jsxs(c.Fragment,{children:[c.jsxs("div",{style:{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:24},children:[c.jsx("h1",{className:"page-title",style:{marginBottom:0},children:"Agents"}),c.jsxs("div",{style:{display:"flex",gap:8,alignItems:"center"},children:[c.jsxs("label",{style:{fontSize:13,color:"var(--text-secondary)",display:"flex",alignItems:"center",gap:6,cursor:"pointer"},children:[c.jsx("input",{type:"checkbox",checked:N,onChange:ut=>_(ut.target.checked)})," Hide revoked"]}),c.jsx("button",{className:"btn btn-secondary",onClick:()=>D(!0),children:"+ API Key"}),c.jsx("button",{className:"btn btn-primary",onClick:()=>K(!0),children:"+ OAuth Client"})]})]}),(()=>{const ut=o.filter(w=>!N||w.status!=="revoked");return o.length===0?c.jsx("div",{style:{textAlign:"center",padding:48,color:"var(--text-muted)"},children:"No agents registered. Register your first agent to get started."}):ut.length===0?c.jsx("div",{style:{textAlign:"center",padding:48,color:"var(--text-muted)"},children:'All agents are revoked. Uncheck "Hide revoked" to view them.'}):c.jsxs(c.Fragment,{children:[c.jsxs("table",{children:[c.jsx("thead",{children:c.jsxs("tr",{children:[c.jsx("th",{children:"Name"}),c.jsx("th",{children:"Type"}),c.jsx("th",{children:"Scopes"}),c.jsx("th",{children:"Sources"}),c.jsx("th",{children:"Status"}),c.jsx("th",{children:"Requests"}),c.jsx("th",{children:"Last Used"})]})}),c.jsx("tbody",{children:ut.map(w=>c.jsxs("tr",{onClick:()=>it(w),style:{cursor:"pointer"},children:[c.jsx("td",{style:{fontWeight:500},children:w.name||w.client_name}),c.jsx("td",{children:c.jsx("span",{className:`badge ${w.auth_type==="oauth"?"badge-read":"badge-write"}`,style:{fontSize:11},children:w.auth_type==="oauth"?"OAuth":"API Key"})}),c.jsx("td",{children:(w.scope||"").split(" ").filter(Boolean).map(X=>c.jsx("span",{className:`badge badge-${X}`,style:{marginRight:4},children:X},X))}),c.jsx("td",{style:{color:"var(--text-secondary)",fontSize:12},children:w.auth_type==="oauth"?`${w.source_id||"none"} · ${(w.federated_read||[]).length} readable`:"Unscoped"}),c.jsx("td",{children:c.jsx("span",{className:`badge ${w.status==="active"?"badge-success":"badge-danger"}`,children:w.status})}),c.jsxs("td",{children:[c.jsx("span",{style:{fontWeight:500},children:w.requests_today||0}),c.jsxs("span",{style:{color:"var(--text-muted)",fontSize:12},children:[" / ",w.total_requests||0]})]}),c.jsx("td",{style:{color:"var(--text-secondary)"},children:w.last_used_at?vy(new Date(w.last_used_at)):"Never"})]},w.id))})]}),c.jsxs("div",{style:{color:"var(--text-muted)",fontSize:13,marginTop:12},children:[o.filter(w=>w.status==="active").length," active / ",o.length," total"]})]})})(),R&&c.jsx(by,{onClose:()=>K(!1),onRegistered:ut=>{K(!1),p(ut),gt()}}),A&&c.jsx(xy,{credentials:A,onClose:()=>p(null)}),V&&c.jsx(Ty,{agent:V,sources:E,onClose:()=>it(null),onRevoked:gt,onRescoped:({sourceId:ut,federatedRead:w})=>{it(X=>X&&{...X,source_id:ut,federated_read:w}),gt()}},V.id),B&&c.jsx(Sy,{onClose:()=>D(!1),onCreated:ut=>{D(!1),Q(ut),gt()}}),O&&c.jsx(py,{token:O,onClose:()=>Q(null)})]})}function Sy({onClose:o,onCreated:M}){const[E,h]=L.useState(""),[N,_]=L.useState(!1),[R,K]=L.useState(""),A=async p=>{if(p.preventDefault(),!E.trim()){K("Name required");return}_(!0);try{const B=await Lt.createApiKey(E.trim());M({name:B.name,token:B.token})}catch(B){K(B instanceof Error?B.message:"Failed")}finally{_(!1)}};return c.jsx("div",{className:"modal-overlay",onClick:o,children:c.jsxs("form",{className:"modal",onClick:p=>p.stopPropagation(),onSubmit:A,children:[c.jsx("div",{className:"modal-title",children:"Create API Key"}),c.jsx("p",{style:{color:"var(--text-secondary)",fontSize:13,marginBottom:16},children:"API keys use simple bearer token auth. They grant full read+write+admin access. For scoped access, use OAuth clients instead."}),c.jsxs("div",{style:{marginBottom:16},children:[c.jsx("label",{children:"Key Name"}),c.jsx("input",{placeholder:"e.g. claude-code-local",value:E,onChange:p=>h(p.target.value),autoFocus:!0})]}),R&&c.jsx("div",{style:{color:"var(--error)",fontSize:13,marginBottom:12},children:R}),c.jsxs("div",{style:{display:"flex",gap:12,justifyContent:"flex-end"},children:[c.jsx("button",{type:"button",className:"btn btn-secondary",onClick:o,children:"Cancel"}),c.jsx("button",{type:"submit",className:"btn btn-primary",disabled:N,children:N?"Creating...":"Create Key"})]})]})})}function py({token:o,onClose:M}){const E=h=>navigator.clipboard.writeText(h);return c.jsx("div",{className:"modal-overlay",children:c.jsxs("div",{className:"modal",style:{maxWidth:560},children:[c.jsxs("div",{style:{textAlign:"center",marginBottom:16},children:[c.jsx("div",{style:{fontSize:36,color:"var(--success)",marginBottom:8},children:"✓"}),c.jsx("div",{style:{fontSize:20,fontWeight:600},children:"API Key Created"})]}),c.jsxs("div",{style:{marginBottom:12},children:[c.jsx("label",{style:{fontSize:12},children:"Name"}),c.jsx("div",{className:"code-block",children:c.jsx("span",{children:o.name})})]}),c.jsxs("div",{style:{marginBottom:12},children:[c.jsx("label",{style:{fontSize:12},children:"Bearer Token"}),c.jsxs("div",{className:"code-block",children:[c.jsx("span",{children:o.token}),c.jsx("button",{className:"copy-btn",onClick:()=>E(o.token),children:"Copy"})]})]}),c.jsxs("div",{style:{marginBottom:12},children:[c.jsx("label",{style:{fontSize:12},children:"Usage"}),c.jsxs("div",{className:"code-block",children:[c.jsx("pre",{style:{whiteSpace:"pre-wrap",margin:0,fontSize:12},children:`Authorization: Bearer ${o.token}`}),c.jsx("button",{className:"copy-btn",onClick:()=>E(`Authorization: Bearer ${o.token}`),children:"Copy"})]})]}),c.jsx("div",{className:"warning-bar",children:"Save this token now. It will not be shown again."}),c.jsx("div",{style:{display:"flex",gap:12,justifyContent:"flex-end",marginTop:20},children:c.jsx("button",{className:"btn btn-primary",onClick:M,children:"Done"})})]})})}function by({onClose:o,onRegistered:M}){const[E,h]=L.useState(""),[N,_]=L.useState(()=>Object.fromEntries(Ed.map(V=>[V,V==="read"]))),[R,K]=L.useState("86400"),[A,p]=L.useState(!1),[B,D]=L.useState(""),O=[{label:"1 hour",value:"3600"},{label:"24 hours",value:"86400"},{label:"7 days",value:"604800"},{label:"30 days",value:"2592000"},{label:"1 year",value:"31536000"},{label:"No expiry",value:"0"}],Q=async V=>{if(V.preventDefault(),!E.trim()){D("Name required");return}p(!0),D("");try{const it=Object.entries(N).filter(([,w])=>w).map(([w])=>w).join(" "),gt=await fetch("/admin/api/register-client",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:E.trim(),scopes:it,tokenTtl:R==="0"?31536e4:Number(R)})});if(!gt.ok)throw new Error("Registration failed");const ut=await gt.json();M({clientId:ut.clientId,clientSecret:ut.clientSecret,name:E.trim()})}catch(it){D(it instanceof Error?it.message:"Registration failed")}finally{p(!1)}};return c.jsx("div",{className:"modal-overlay",onClick:o,children:c.jsxs("form",{className:"modal",onClick:V=>V.stopPropagation(),onSubmit:Q,children:[c.jsx("div",{className:"modal-title",children:"Register Agent"}),c.jsxs("div",{style:{marginBottom:16},children:[c.jsx("label",{children:"Agent Name"}),c.jsx("input",{placeholder:"e.g. perplexity-production",value:E,onChange:V=>h(V.target.value),autoFocus:!0})]}),c.jsxs("div",{style:{marginBottom:16},children:[c.jsx("label",{children:"Scopes"}),c.jsx("div",{className:"checkbox-group",children:Ed.map(V=>c.jsxs("label",{className:"checkbox-label",children:[c.jsx("input",{type:"checkbox",checked:N[V],onChange:it=>_(gt=>({...gt,[V]:it.target.checked}))}),V]},V))})]}),c.jsxs("div",{style:{marginBottom:20},children:[c.jsx("label",{children:"Token Lifetime"}),c.jsx("select",{value:R,onChange:V=>K(V.target.value),style:{width:"100%",background:"var(--bg-secondary)",color:"var(--text-primary)",border:"1px solid var(--border)",borderRadius:6,padding:"6px 10px",fontSize:14},children:O.map(V=>c.jsx("option",{value:V.value,children:V.label},V.value))})]}),B&&c.jsx("div",{style:{color:"var(--error)",fontSize:13,marginBottom:12},children:B}),c.jsxs("div",{style:{display:"flex",gap:12,justifyContent:"flex-end"},children:[c.jsx("button",{type:"button",className:"btn btn-secondary",onClick:o,children:"Cancel"}),c.jsx("button",{type:"submit",className:"btn btn-primary",disabled:A,children:A?"Registering...":"Register"})]})]})})}function xy({credentials:o,onClose:M}){const E=N=>navigator.clipboard.writeText(N),h=()=>{const N=new Blob([JSON.stringify(o,null,2)],{type:"application/json"}),_=URL.createObjectURL(N),R=document.createElement("a");R.href=_,R.download=`${o.name}-credentials.json`,R.click(),URL.revokeObjectURL(_)};return c.jsx("div",{className:"modal-overlay",children:c.jsxs("div",{className:"modal",style:{maxWidth:560},children:[c.jsxs("div",{style:{textAlign:"center",marginBottom:16},children:[c.jsx("div",{style:{fontSize:36,color:"var(--success)",marginBottom:8},children:"✓"}),c.jsx("div",{style:{fontSize:20,fontWeight:600},children:"Agent Registered"})]}),c.jsxs("div",{style:{marginBottom:12},children:[c.jsx("label",{style:{fontSize:12},children:"Client ID"}),c.jsxs("div",{className:"code-block",children:[c.jsx("span",{children:o.clientId}),c.jsx("button",{className:"copy-btn",onClick:()=>E(o.clientId),children:"Copy"})]})]}),c.jsxs("div",{style:{marginBottom:12},children:[c.jsx("label",{style:{fontSize:12},children:"Client Secret"}),c.jsxs("div",{className:"code-block",children:[c.jsx("span",{children:o.clientSecret}),c.jsx("button",{className:"copy-btn",onClick:()=>E(o.clientSecret),children:"Copy"})]})]}),c.jsx("div",{className:"warning-bar",children:"Save this secret now. It will not be shown again."}),c.jsxs("div",{style:{display:"flex",gap:12,justifyContent:"flex-end",marginTop:20},children:[c.jsx("button",{className:"btn btn-secondary",onClick:h,children:"Download as JSON"}),c.jsx("button",{className:"btn btn-primary",onClick:M,children:"Done"})]})]})})}function jy({clientId:o,agent:M,sources:E,onRescoped:h}){const[N,_]=L.useState(M.source_id||"default"),[R,K]=L.useState(M.federated_read||[]),[A,p]=L.useState(!1),[B,D]=L.useState(""),[O,Q]=L.useState(!1),V=new Set(R),it=new Set(E.map(X=>X.id)),gt=R.filter(X=>!it.has(X)),ut=!it.has(N),w=async()=>{if(R.length===0){D("Select at least one readable source.");return}p(!0),D(""),Q(!1);try{const X=await Lt.rescopeClient(o,N,R);_(X.sourceId),K(X.federatedRead),Q(!0),h(X)}catch(X){D(X instanceof Error?X.message:"Failed to save source access")}finally{p(!1)}};return c.jsxs(c.Fragment,{children:[c.jsx("div",{className:"section-title",children:"Source Access"}),c.jsx("div",{style:{color:"var(--text-secondary)",fontSize:12,lineHeight:1.5,marginBottom:12},children:"The primary source is the write destination. Read access is an explicit allowlist and does not widen automatically."}),c.jsxs("div",{style:{marginBottom:14},children:[c.jsx("label",{htmlFor:"agent-write-source",children:"Primary / write source"}),c.jsxs("select",{id:"agent-write-source",value:N,onChange:X=>{_(X.target.value),Q(!1)},style:{width:"100%",background:"var(--bg-secondary)",color:"var(--text-primary)",border:"1px solid var(--border)",borderRadius:6,padding:"6px 10px",fontSize:14},children:[ut&&c.jsxs("option",{value:N,disabled:!0,children:[N," · unavailable"]}),E.map(X=>c.jsxs("option",{value:X.id,children:[X.name," (",X.id,")"]},X.id))]})]}),c.jsxs("fieldset",{style:{border:0,padding:0,margin:"0 0 14px"},children:[c.jsx("legend",{children:"Readable sources"}),c.jsxs("div",{className:"checkbox-group",style:{marginTop:6},children:[E.map(X=>c.jsxs("label",{className:"checkbox-label",children:[c.jsx("input",{type:"checkbox",checked:V.has(X.id),onChange:Xt=>{Q(!1),K(Vt=>Xt.target.checked?[...Vt,X.id]:Vt.filter(Qt=>Qt!==X.id))}}),X.name," (",X.id,")",X.federated?" · federated":" · private"]},X.id)),gt.map(X=>c.jsxs("label",{className:"checkbox-label",style:{color:"var(--warning)"},children:[c.jsx("input",{type:"checkbox",checked:!0,onChange:()=>{Q(!1),K(Xt=>Xt.filter(Vt=>Vt!==X))}}),X," · unavailable (clear to remove grant)"]},X))]})]}),(ut||gt.length>0)&&c.jsx("div",{style:{color:"var(--warning)",fontSize:13,marginBottom:10},children:"This client references unavailable or archived sources. Choose an active primary source and clear unavailable read grants before saving."}),B&&c.jsx("div",{style:{color:"var(--error)",fontSize:13,marginBottom:10},children:B}),O&&c.jsx("div",{style:{color:"var(--success)",fontSize:13,marginBottom:10},children:"Source access saved."}),c.jsx("button",{type:"button",className:"btn btn-primary",disabled:A||R.length===0||E.length===0||ut||gt.length>0,onClick:w,children:A?"Saving...":"Save Source Access"})]})}function Ty({agent:o,sources:M,onClose:E,onRevoked:h,onRescoped:N}){const[_,R]=L.useState("claude-code"),K=Q=>navigator.clipboard.writeText(Q),A=window.location.origin,p=o.id||o.client_id||"",B=o.auth_type==="oauth",D=o.name||o.client_name||"unknown",O={"claude-code":B?["The user wants to connect GBrain MCP to your context. Here's how.","","# Step 1 — read the client secret from the operator without putting it in shell history.","# read -s suppresses the echo; export keeps it scoped to this shell session.",'read -rs -p "Client Secret: " GBRAIN_CS && echo',"","# Step 2 — mint a token and register GBrain in one go.",`claude mcp add --transport http gbrain ${A}/mcp \\`,` --header "Authorization: Bearer $(curl -s -X POST ${A}/token \\`," -d 'grant_type=client_credentials' \\",` -d 'client_id=${p}' \\`,' --data-urlencode "client_secret=$GBRAIN_CS" \\',` -d 'scope=${o.scope||"read write"}' | jq -r .access_token)"`,"","# Step 3 — clear the secret from this shell.","unset GBRAIN_CS","",`The token will last ${o.token_ttl?o.token_ttl>=86400?Math.floor(o.token_ttl/86400)+" days":Math.floor(o.token_ttl/3600)+" hours":"1 hour (default)"}.`,"","─── Fallback: 2-step curl + paste ───","","If your shell doesn't support read -s, mint the token first, then paste:","",`curl -s -X POST ${A}/token \\`," -d 'grant_type=client_credentials' \\",` -d 'client_id=${p}' \\`," -d 'client_secret=PASTE_YOUR_CLIENT_SECRET_HERE' \\",` -d 'scope=${o.scope||"read write"}' | jq -r .access_token`,"",`claude mcp add --transport http gbrain ${A}/mcp \\`,' --header "Authorization: Bearer PASTE_TOKEN_FROM_ABOVE"',"","# Then run: history -d $((HISTCMD-1)) # zsh: print -s -- '' && fc -p"].join(` +`):["The user wants to connect GBrain MCP to your context. Here's how.","","Run this command in terminal to register GBrain:","",`claude mcp add --transport http gbrain ${A}/mcp \\`,' --header "Authorization: Bearer PASTE_YOUR_API_KEY_HERE"',"",`Replace PASTE_YOUR_API_KEY_HERE with the API key shown when "${D}" was created.`,"API keys never expire."].join(` +`),chatgpt:["The user wants to connect GBrain MCP to ChatGPT.","","1. Open ChatGPT > Settings > Tools & Integrations > Add MCP Server","2. Enter this URL — ChatGPT will auto-discover the OAuth configuration:","",` ${A}/.well-known/oauth-authorization-server`,"","3. When prompted for credentials:",` Client ID: ${p}`," Client Secret: (the secret from agent registration)"," Grant Type: client_credentials",` Scope: ${o.scope||"read write"}`].join(` +`),"claude-cowork":["The user wants to connect GBrain MCP to Claude.ai.","","1. Open claude.ai > Settings > Connected Apps > Add MCP Server",`2. Server URL: ${A}/mcp`,"3. When prompted for auth:",` Token endpoint: ${A}/token`,` Client ID: ${p}`," Client Secret: (the secret from agent registration)",` Scope: ${o.scope||"read write"}`,"",`Discovery URL: ${A}/.well-known/oauth-authorization-server`].join(` +`),cursor:B?["The user wants to connect GBrain MCP to Cursor.","","Cursor supports OAuth for remote MCP. Add to .cursor/mcp.json:","","{",' "mcpServers": {',' "gbrain": {',` "url": "${A}/mcp",`,' "transport": "sse"'," }"," }","}","","Cursor will auto-discover OAuth via:",`${A}/.well-known/oauth-authorization-server`,"",`When prompted: Client ID ${p}, use the secret from registration.`].join(` +`):["The user wants to connect GBrain MCP to Cursor.","","Add to .cursor/mcp.json:","","{",' "mcpServers": {',' "gbrain": {',` "url": "${A}/mcp",`,' "transport": "sse",',' "headers": {',' "Authorization": "Bearer PASTE_YOUR_API_KEY_HERE"'," }"," }"," }","}","",`Replace PASTE_YOUR_API_KEY_HERE with the API key shown when "${D}" was created.`].join(` +`),perplexity:["The user wants to connect GBrain MCP to Perplexity.","","1. Go to Settings > Connectors > Add MCP",`2. Server URL: ${A}/mcp`,`3. Client ID: ${p}`,"4. Client Secret: (the secret from agent registration)"].join(` +`),json:JSON.stringify({server_url:A+"/mcp",token_url:A+"/token",discovery_url:A+"/.well-known/oauth-authorization-server",client_id:p,client_name:D,auth_type:o.auth_type,scope:o.scope},null,2)};return c.jsxs(c.Fragment,{children:[c.jsx("div",{className:"drawer-overlay",onClick:E}),c.jsxs("div",{className:"drawer",children:[c.jsx("button",{className:"drawer-close",onClick:E,children:"✕"}),c.jsx("div",{style:{fontSize:18,fontWeight:600,marginBottom:4},children:o.name||o.client_name}),c.jsx("span",{className:`badge ${o.status==="active"?"badge-success":"badge-danger"}`,children:o.status}),c.jsx("div",{className:"section-title",children:"Details"}),c.jsxs("div",{style:{display:"grid",gridTemplateColumns:"100px 1fr",gap:"6px 12px",fontSize:13},children:[c.jsx("span",{style:{color:"var(--text-secondary)"},children:"Client ID"}),c.jsxs("span",{className:"mono",children:[(o.id||o.id||o.client_id||"").substring(0,24),"..."]}),c.jsx("span",{style:{color:"var(--text-secondary)"},children:"Scopes"}),c.jsx("span",{children:(o.scope||"").split(" ").filter(Boolean).map(Q=>c.jsx("span",{className:`badge badge-${Q}`,style:{marginRight:4},children:Q},Q))}),c.jsx("span",{style:{color:"var(--text-secondary)"},children:"Registered"}),c.jsx("span",{children:new Date(o.created_at).toLocaleDateString()}),c.jsx("span",{style:{color:"var(--text-secondary)"},children:"Token TTL"}),c.jsx("span",{children:o.token_ttl?o.token_ttl>=31536e3?"No expiry":o.token_ttl>=86400?`${Math.floor(o.token_ttl/86400)}d`:o.token_ttl>=3600?`${Math.floor(o.token_ttl/3600)}h`:`${o.token_ttl}s`:"1h (default)"})]}),B&&c.jsx(jy,{clientId:p,agent:o,sources:M,onRescoped:N}),c.jsx("div",{className:"section-title",children:"Config Export"}),c.jsxs("div",{className:"tabs",style:{flexWrap:"wrap"},children:[c.jsx("div",{className:`tab ${_==="claude-code"?"active":""}`,onClick:()=>R("claude-code"),children:"Claude Code"}),c.jsx("div",{className:`tab ${_==="chatgpt"?"active":""}`,onClick:()=>R("chatgpt"),children:"ChatGPT"}),c.jsx("div",{className:`tab ${_==="claude-cowork"?"active":""}`,onClick:()=>R("claude-cowork"),children:"Claude.ai"}),c.jsx("div",{className:`tab ${_==="cursor"?"active":""}`,onClick:()=>R("cursor"),children:"Cursor"}),c.jsx("div",{className:`tab ${_==="perplexity"?"active":""}`,onClick:()=>R("perplexity"),children:"Perplexity"}),c.jsx("div",{className:`tab ${_==="json"?"active":""}`,onClick:()=>R("json"),children:"JSON"})]}),(()=>{if(!B&&new Set(["chatgpt","claude-cowork","perplexity"]).has(_)){const V=_==="chatgpt"?"ChatGPT":_==="claude-cowork"?"Claude.ai":"Perplexity";return c.jsxs("div",{style:{background:"rgba(255, 200, 100, 0.08)",border:"1px solid rgba(255, 200, 100, 0.2)",borderRadius:8,padding:"14px 16px",marginTop:12,fontSize:13,lineHeight:1.6,color:"var(--text-secondary)"},children:[c.jsxs("div",{style:{fontWeight:600,color:"var(--text-primary)",marginBottom:6},children:[V," requires an OAuth client"]}),V," only supports OAuth 2.0 (client_credentials). API keys use raw bearer tokens, which ",V," does not accept. Register a separate OAuth client and use that to connect this AI."]})}return c.jsxs("div",{className:"code-block",children:[c.jsx("pre",{style:{whiteSpace:"pre-wrap",margin:0},children:O[_]}),c.jsx("button",{className:"copy-btn",onClick:()=>K(O[_]),children:"Copy"})]})})(),c.jsxs("div",{style:{marginTop:32},children:[o.status==="active"&&c.jsx("button",{className:"btn btn-danger",onClick:async()=>{if(confirm(`Revoke ${o.name||o.client_name}? All active tokens will be invalidated.`))try{o.auth_type==="oauth"?await Lt.revokeClient(o.id||o.client_id||""):await Lt.revokeApiKey(o.name||""),h(),E()}catch(Q){alert("Revoke failed: "+(Q instanceof Error?Q.message:"unknown error"))}},children:"Revoke Agent"}),o.status==="revoked"&&c.jsx("span",{style:{color:"var(--text-muted)",fontSize:13},children:"This agent has been revoked."})]})]})]})}function zy(){const[o,M]=L.useState({rows:[],total:0,page:1,pages:1}),[E,h]=L.useState(1),[N,_]=L.useState("all"),[R,K]=L.useState(null);L.useEffect(()=>{A(E)},[E,N]);const A=O=>{const Q=N!=="all"?`&agent=${encodeURIComponent(N)}`:"";Lt.requests(O,Q).then(M).catch(()=>{})},p=O=>{const Q=Date.now()-new Date(O).getTime();return Q<6e4?`${Math.floor(Q/1e3)}s ago`:Q<36e5?`${Math.floor(Q/6e4)} min ago`:Q<864e5?`${Math.floor(Q/36e5)}h ago`:new Date(O).toLocaleDateString()},B=O=>{if(!O)return null;const{query:Q,slug:V,partial:it,limit:gt,...ut}=O,w=[];return Q&&w.push(`"${Q}"`),V&&w.push(V),it&&w.push(`~${it}`),gt&&w.push(`limit=${gt}`),Object.keys(ut).length>0&&w.push(`+${Object.keys(ut).length} params`),w.join(" ")},D=new Map;return o.rows.forEach(O=>{O.token_name&&D.set(O.token_name,O.agent_name||O.token_name)}),c.jsxs(c.Fragment,{children:[c.jsxs("div",{style:{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:24},children:[c.jsx("h1",{className:"page-title",style:{marginBottom:0},children:"Request Log"}),c.jsxs("select",{value:N,onChange:O=>{_(O.target.value),h(1)},style:{background:"var(--bg-secondary)",color:"var(--text-primary)",border:"1px solid var(--border)",borderRadius:6,padding:"4px 8px",fontSize:13},children:[c.jsx("option",{value:"all",children:"All agents"}),[...D.entries()].map(([O,Q])=>c.jsx("option",{value:O,children:Q},O))]})]}),o.rows.length===0?c.jsx("div",{style:{textAlign:"center",padding:48,color:"var(--text-muted)"},children:"No requests yet."}):c.jsxs(c.Fragment,{children:[c.jsxs("table",{children:[c.jsx("thead",{children:c.jsxs("tr",{children:[c.jsx("th",{children:"Time"}),c.jsx("th",{children:"Agent"}),c.jsx("th",{children:"Operation"}),c.jsx("th",{children:"Params"}),c.jsx("th",{children:"Latency"}),c.jsx("th",{children:"Status"})]})}),c.jsx("tbody",{children:o.rows.map(O=>c.jsxs(Dd.Fragment,{children:[c.jsxs("tr",{onClick:()=>K(R===O.id?null:O.id),style:{cursor:"pointer"},children:[c.jsx("td",{style:{color:"var(--text-secondary)",whiteSpace:"nowrap"},children:p(O.created_at)}),c.jsx("td",{children:c.jsx("a",{style:{color:"var(--text-link, #88aaff)",cursor:"pointer",textDecoration:"none",fontWeight:500},onClick:Q=>{Q.stopPropagation(),_(O.token_name),h(1)},children:O.agent_name||O.token_name})}),c.jsx("td",{className:"mono",children:O.operation}),c.jsx("td",{style:{color:"var(--text-secondary)",fontSize:12,maxWidth:200,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"},children:B(O.params)}),c.jsxs("td",{className:"mono",children:[O.latency_ms,"ms"]}),c.jsx("td",{children:c.jsx("span",{className:`badge badge-${O.status}`,children:O.status})})]}),R===O.id&&c.jsx("tr",{children:c.jsx("td",{colSpan:6,style:{background:"var(--bg-secondary, #0f0f1a)",padding:16},children:c.jsxs("div",{style:{display:"grid",gridTemplateColumns:"100px 1fr",gap:"6px 12px",fontSize:13},children:[c.jsx("span",{style:{color:"var(--text-muted)"},children:"Time"}),c.jsx("span",{children:new Date(O.created_at).toLocaleString()}),c.jsx("span",{style:{color:"var(--text-muted)"},children:"Agent"}),c.jsx("span",{className:"mono",children:O.token_name}),c.jsx("span",{style:{color:"var(--text-muted)"},children:"Operation"}),c.jsx("span",{className:"mono",children:O.operation}),c.jsx("span",{style:{color:"var(--text-muted)"},children:"Latency"}),c.jsxs("span",{children:[O.latency_ms,"ms"]}),O.params&&c.jsxs(c.Fragment,{children:[c.jsx("span",{style:{color:"var(--text-muted)"},children:"Params"}),c.jsx("pre",{className:"mono",style:{margin:0,whiteSpace:"pre-wrap",fontSize:12},children:JSON.stringify(O.params,null,2)})]}),O.error_message&&c.jsxs(c.Fragment,{children:[c.jsx("span",{style:{color:"var(--error, #ff6b6b)"},children:"Error"}),c.jsx("span",{style:{color:"var(--error, #ff6b6b)"},children:O.error_message})]})]})})})]},O.id))})]}),c.jsxs("div",{className:"pagination",children:[c.jsxs("span",{children:["Page ",o.page," of ",o.pages," (",o.total," total)"]}),c.jsxs("div",{style:{display:"flex",gap:8},children:[c.jsx("button",{disabled:o.page<=1,onClick:()=>h(O=>O-1),children:"Previous"}),c.jsx("button",{disabled:o.page>=o.pages,onClick:()=>h(O=>O+1),children:"Next"})]})]})]})]})}function Ay({markup:o}){return c.jsx("div",{style:{width:"100%",overflow:"auto"},dangerouslySetInnerHTML:{__html:o}})}function Zu({type:o,ariaLabel:M}){const[E,h]=L.useState(""),[N,_]=L.useState("");return L.useEffect(()=>{let R=!1;return Lt.calibrationChart(o).then(K=>{R||h(K)}).catch(K=>{R||_(K.message??"fetch failed")}),()=>{R=!0}},[o]),N?c.jsxs("div",{style:{padding:16,color:"var(--error)"},role:"alert",children:[M,": ",N]}):E?c.jsx(Ay,{markup:E}):c.jsxs("div",{style:{padding:16,color:"var(--text-muted)"},children:[M," loading..."]})}function _y(){const[o,M]=L.useState(null),[E,h]=L.useState(!0),[N,_]=L.useState("");if(L.useEffect(()=>{Lt.calibrationProfile().then(A=>{M(A),h(!1)}).catch(A=>{_(A.message??"fetch failed"),h(!1)})},[]),E)return c.jsx("div",{style:{padding:24,color:"var(--text-secondary)"},children:"Loading calibration profile…"});if(N)return c.jsxs("div",{style:{padding:24,color:"var(--error)"},role:"alert",children:["Could not load calibration profile: ",N]});if(!o)return c.jsxs("div",{style:{padding:24,maxWidth:700},children:[c.jsx("h1",{style:{marginBottom:16},children:"Calibration"}),c.jsx("p",{style:{color:"var(--text-secondary)"},children:"No calibration profile yet. Builds after 5+ resolved takes."}),c.jsx("pre",{style:{background:"var(--bg-secondary)",padding:12,borderRadius:4,color:"var(--text-primary)",marginTop:12,fontFamily:"var(--font-mono)"},children:"gbrain dream --phase calibration_profile"})]});const R=new Date(o.generated_at),K=Math.floor((Date.now()-R.getTime())/(1e3*60*60*24));return c.jsxs("div",{style:{padding:32,maxWidth:720},children:[c.jsx("h1",{style:{marginBottom:8},children:"Calibration"}),c.jsxs("div",{style:{color:"var(--text-muted)",fontSize:13,marginBottom:24},children:["Holder: ",o.holder," · ","Updated ",K===0?"today":`${K}d ago`,o.published&&" · published",o.grade_completion<.9&&` · ~${Math.round(o.grade_completion*100)}% graded`,!o.voice_gate_passed&&" · voice gate fell back to template"]}),c.jsx("section",{style:{marginBottom:32},children:c.jsx(Zu,{type:"brier-trend",ariaLabel:"Brier trend"})}),c.jsxs("section",{style:{marginBottom:32},children:[c.jsx("h2",{style:{fontSize:14,color:"var(--text-secondary)",marginBottom:12,fontWeight:400},children:"Pattern statements"}),c.jsx(Zu,{type:"pattern-statements",ariaLabel:"Pattern statements"})]}),c.jsx("section",{style:{marginBottom:32},children:c.jsx(Zu,{type:"domain-bars",ariaLabel:"Per-domain accuracy"})}),c.jsx("section",{style:{marginBottom:32},children:c.jsx(Zu,{type:"abandoned-threads",ariaLabel:"Abandoned threads"})}),o.active_bias_tags.length>0&&c.jsxs("section",{style:{marginBottom:32,color:"var(--text-muted)",fontSize:13},children:["Active bias tags: ",o.active_bias_tags.join(", ")]})]})}function Ey(o){return o===0?"var(--accent-success, #2ea043)":o>=100?"var(--accent-danger, #f85149)":"var(--accent-warn, #d29922)"}function Od(o){return`$${(o/100).toFixed(2)}`}function Oy(){const[o,M]=L.useState(null),[E,h]=L.useState(null);if(L.useEffect(()=>{let _=!0,R=null;const K=async()=>{try{const A=await Lt.jobsWatch();_&&(M(A),h(null))}catch(A){_&&h(A instanceof Error?A.message:String(A))}_&&(R=setTimeout(K,1e3))};return K(),()=>{_=!1,R&&clearTimeout(R)}},[]),E)return c.jsxs("div",{style:{padding:24,color:"var(--accent-danger, #f85149)"},children:[c.jsx("h2",{children:"Jobs Watch — error"}),c.jsx("pre",{style:{whiteSpace:"pre-wrap"},children:E})]});if(!o)return c.jsx("div",{style:{padding:24,color:"var(--text-muted, #777)"},children:"Loading jobs watch…"});const N=new Date(o.ts_ms).toLocaleTimeString();return c.jsxs("div",{style:{padding:24,fontFamily:'var(--font-mono, "JetBrains Mono", monospace)'},children:[c.jsxs("h1",{style:{fontSize:18,marginBottom:4},children:["Jobs Watch",c.jsxs("span",{style:{marginLeft:12,color:"var(--text-muted, #777)",fontSize:12,fontWeight:"normal"},children:["updated ",N]})]}),c.jsxs("section",{style:{marginTop:24},children:[c.jsx("h2",{style:{fontSize:14,marginBottom:8},children:"Queue"}),c.jsxs("div",{children:["waiting=",c.jsx("b",{children:o.queue_health.waiting})," ","active=",c.jsx("b",{children:o.queue_health.active})," ","stalled=",c.jsx("b",{style:{color:o.queue_health.stalled>0?"var(--accent-warn, #d29922)":void 0},children:o.queue_health.stalled})]})]}),o.by_type.length>0&&c.jsxs("section",{style:{marginTop:24},children:[c.jsx("h2",{style:{fontSize:14,marginBottom:8},children:"By type (24h)"}),c.jsxs("table",{style:{borderCollapse:"collapse"},children:[c.jsx("thead",{children:c.jsxs("tr",{style:{color:"var(--text-muted, #777)",fontSize:12},children:[c.jsx("th",{style:{textAlign:"left",padding:"4px 12px 4px 0"},children:"name"}),c.jsx("th",{style:{textAlign:"right",padding:"4px 12px"},children:"total"}),c.jsx("th",{style:{textAlign:"right",padding:"4px 12px"},children:"done"}),c.jsx("th",{style:{textAlign:"right",padding:"4px 12px"},children:"fail"}),c.jsx("th",{style:{textAlign:"right",padding:"4px 12px"},children:"dead"})]})}),c.jsx("tbody",{children:o.by_type.slice(0,6).map(_=>c.jsxs("tr",{children:[c.jsx("td",{style:{padding:"4px 12px 4px 0"},children:_.name}),c.jsx("td",{style:{textAlign:"right",padding:"4px 12px"},children:_.total}),c.jsx("td",{style:{textAlign:"right",padding:"4px 12px"},children:_.completed}),c.jsx("td",{style:{textAlign:"right",padding:"4px 12px"},children:_.failed}),c.jsx("td",{style:{textAlign:"right",padding:"4px 12px"},children:_.dead})]},_.name))})]})]}),c.jsxs("section",{style:{marginTop:24},children:[c.jsx("h2",{style:{fontSize:14,marginBottom:8},children:"Lease pressure (1h)"}),c.jsxs("div",{style:{color:Ey(o.lease_pressure_1h)},children:[o.lease_pressure_1h," bounce",o.lease_pressure_1h===1?"":"s"]})]}),o.top_errors.length>0&&c.jsxs("section",{style:{marginTop:24},children:[c.jsx("h2",{style:{fontSize:14,marginBottom:8},children:"Top errors (24h)"}),c.jsx("table",{style:{borderCollapse:"collapse"},children:c.jsx("tbody",{children:o.top_errors.slice(0,5).map(_=>c.jsxs("tr",{children:[c.jsxs("td",{style:{textAlign:"right",padding:"4px 12px 4px 0",color:"var(--text-muted, #777)"},children:[_.count,"×"]}),c.jsx("td",{style:{padding:"4px 12px 4px 0"},children:_.cluster})]},_.cluster))})})]}),o.budget_owners.length>0&&c.jsxs("section",{style:{marginTop:24},children:[c.jsx("h2",{style:{fontSize:14,marginBottom:8},children:"Budget owners"}),c.jsxs("table",{style:{borderCollapse:"collapse"},children:[c.jsx("thead",{children:c.jsxs("tr",{style:{color:"var(--text-muted, #777)",fontSize:12},children:[c.jsx("th",{style:{textAlign:"left",padding:"4px 12px 4px 0"},children:"owner"}),c.jsx("th",{style:{textAlign:"right",padding:"4px 12px"},children:"spent"}),c.jsx("th",{style:{textAlign:"right",padding:"4px 12px"},children:"remaining"})]})}),c.jsx("tbody",{children:o.budget_owners.slice(0,5).map(_=>c.jsxs("tr",{children:[c.jsx("td",{style:{padding:"4px 12px 4px 0"},children:_.owner_id}),c.jsx("td",{style:{textAlign:"right",padding:"4px 12px"},children:Od(_.total_spent_cents)}),c.jsx("td",{style:{textAlign:"right",padding:"4px 12px"},children:Od(_.remaining_cents)})]},_.owner_id))})]})]})]})}function Nd(){const o=window.location.hash.replace("#","")||"dashboard";return["login","dashboard","agents","log","calibration","jobs"].includes(o)?o:"dashboard"}function Ny(){const[o,M]=L.useState(Nd);L.useEffect(()=>{const N=()=>M(Nd());return window.addEventListener("hashchange",N),()=>window.removeEventListener("hashchange",N)},[]);const E=N=>{window.location.hash=N,M(N)};if(o==="login")return c.jsx(my,{onLogin:()=>E("dashboard")});const h=async()=>{if(confirm("Sign out every active admin session, including other browsers and tabs? Each one will need to re-authenticate via a fresh magic link.")){try{await Lt.signOutEverywhere()}catch{}E("login")}};return c.jsxs("div",{className:"app",children:[c.jsxs("nav",{className:"sidebar",children:[c.jsx("div",{className:"sidebar-logo",children:"GBrain"}),c.jsxs("div",{className:"sidebar-nav",children:[c.jsx("a",{className:`nav-item ${o==="dashboard"?"active":""}`,onClick:()=>E("dashboard"),children:"Dashboard"}),c.jsx("a",{className:`nav-item ${o==="agents"?"active":""}`,onClick:()=>E("agents"),children:"Agents"}),c.jsx("a",{className:`nav-item ${o==="log"?"active":""}`,onClick:()=>E("log"),children:"Request Log"}),c.jsx("a",{className:`nav-item ${o==="calibration"?"active":""}`,onClick:()=>E("calibration"),children:"Calibration"}),c.jsx("a",{className:`nav-item ${o==="jobs"?"active":""}`,onClick:()=>E("jobs"),children:"Jobs Watch"})]}),c.jsx("div",{style:{marginTop:"auto",padding:"16px 12px",borderTop:"1px solid var(--border)"},children:c.jsx("button",{onClick:h,style:{background:"transparent",border:"1px solid var(--border)",color:"var(--text-secondary)",padding:"6px 10px",borderRadius:6,fontSize:12,cursor:"pointer",width:"100%"},title:"Revoke every active admin session — every browser, every tab",children:"Sign out everywhere"})})]}),c.jsxs("main",{className:"main",children:[o==="dashboard"&&c.jsx(yy,{}),o==="agents"&&c.jsx(gy,{}),o==="log"&&c.jsx(zy,{}),o==="calibration"&&c.jsx(_y,{}),o==="jobs"&&c.jsx(Oy,{})]})]})}dy.createRoot(document.getElementById("root")).render(c.jsx(Dd.StrictMode,{children:c.jsx(Ny,{})})); diff --git a/admin/dist/assets/index-DqP-zmqH.js b/admin/dist/assets/index-DqP-zmqH.js deleted file mode 100644 index 3fd01fc23..000000000 --- a/admin/dist/assets/index-DqP-zmqH.js +++ /dev/null @@ -1,56 +0,0 @@ -(function(){const D=document.createElement("link").relList;if(D&&D.supports&&D.supports("modulepreload"))return;for(const E of document.querySelectorAll('link[rel="modulepreload"]'))h(E);new MutationObserver(E=>{for(const N of E)if(N.type==="childList")for(const C of N.addedNodes)C.tagName==="LINK"&&C.rel==="modulepreload"&&h(C)}).observe(document,{childList:!0,subtree:!0});function O(E){const N={};return E.integrity&&(N.integrity=E.integrity),E.referrerPolicy&&(N.referrerPolicy=E.referrerPolicy),E.crossOrigin==="use-credentials"?N.credentials="include":E.crossOrigin==="anonymous"?N.credentials="omit":N.credentials="same-origin",N}function h(E){if(E.ep)return;E.ep=!0;const N=O(E);fetch(E.href,N)}})();function Md(o){return o&&o.__esModule&&Object.prototype.hasOwnProperty.call(o,"default")?o.default:o}var ff={exports:{}},jn={};/** - * @license React - * react-jsx-runtime.production.js - * - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */var gd;function ey(){if(gd)return jn;gd=1;var o=Symbol.for("react.transitional.element"),D=Symbol.for("react.fragment");function O(h,E,N){var C=null;if(N!==void 0&&(C=""+N),E.key!==void 0&&(C=""+E.key),"key"in E){N={};for(var Q in E)Q!=="key"&&(N[Q]=E[Q])}else N=E;return E=N.ref,{$$typeof:o,type:h,key:C,ref:E!==void 0?E:null,props:N}}return jn.Fragment=D,jn.jsx=O,jn.jsxs=O,jn}var Sd;function ay(){return Sd||(Sd=1,ff.exports=ey()),ff.exports}var c=ay(),sf={exports:{}},V={};/** - * @license React - * react.production.js - * - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */var pd;function ny(){if(pd)return V;pd=1;var o=Symbol.for("react.transitional.element"),D=Symbol.for("react.portal"),O=Symbol.for("react.fragment"),h=Symbol.for("react.strict_mode"),E=Symbol.for("react.profiler"),N=Symbol.for("react.consumer"),C=Symbol.for("react.context"),Q=Symbol.for("react.forward_ref"),_=Symbol.for("react.suspense"),b=Symbol.for("react.memo"),H=Symbol.for("react.lazy"),M=Symbol.for("react.activity"),A=Symbol.iterator;function I(d){return d===null||typeof d!="object"?null:(d=A&&d[A]||d["@@iterator"],typeof d=="function"?d:null)}var L={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},nl=Object.assign,tl={};function bl(d,z,R){this.props=d,this.context=z,this.refs=tl,this.updater=R||L}bl.prototype.isReactComponent={},bl.prototype.setState=function(d,z){if(typeof d!="object"&&typeof d!="function"&&d!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,d,z,"setState")},bl.prototype.forceUpdate=function(d){this.updater.enqueueForceUpdate(this,d,"forceUpdate")};function Ml(){}Ml.prototype=bl.prototype;function Gl(d,z,R){this.props=d,this.context=z,this.refs=tl,this.updater=R||L}var rt=Gl.prototype=new Ml;rt.constructor=Gl,nl(rt,bl.prototype),rt.isPureReactComponent=!0;var _t=Array.isArray;function Ll(){}var el={H:null,A:null,T:null,S:null},Vl=Object.prototype.hasOwnProperty;function Et(d,z,R){var q=R.ref;return{$$typeof:o,type:d,key:z,ref:q!==void 0?q:null,props:R}}function Le(d,z){return Et(d.type,z,d.props)}function Ot(d){return typeof d=="object"&&d!==null&&d.$$typeof===o}function Kl(d){var z={"=":"=0",":":"=2"};return"$"+d.replace(/[=:]/g,function(R){return z[R]})}var Te=/\/+/g;function Ut(d,z){return typeof d=="object"&&d!==null&&d.key!=null?Kl(""+d.key):z.toString(36)}function jt(d){switch(d.status){case"fulfilled":return d.value;case"rejected":throw d.reason;default:switch(typeof d.status=="string"?d.then(Ll,Ll):(d.status="pending",d.then(function(z){d.status==="pending"&&(d.status="fulfilled",d.value=z)},function(z){d.status==="pending"&&(d.status="rejected",d.reason=z)})),d.status){case"fulfilled":return d.value;case"rejected":throw d.reason}}throw d}function x(d,z,R,q,J){var $=typeof d;($==="undefined"||$==="boolean")&&(d=null);var fl=!1;if(d===null)fl=!0;else switch($){case"bigint":case"string":case"number":fl=!0;break;case"object":switch(d.$$typeof){case o:case D:fl=!0;break;case H:return fl=d._init,x(fl(d._payload),z,R,q,J)}}if(fl)return J=J(d),fl=q===""?"."+Ut(d,0):q,_t(J)?(R="",fl!=null&&(R=fl.replace(Te,"$&/")+"/"),x(J,z,R,"",function(Oa){return Oa})):J!=null&&(Ot(J)&&(J=Le(J,R+(J.key==null||d&&d.key===J.key?"":(""+J.key).replace(Te,"$&/")+"/")+fl)),z.push(J)),1;fl=0;var Ql=q===""?".":q+":";if(_t(d))for(var Tl=0;Tl>>1,yl=x[rl];if(0>>1;rlE(R,Z))qE(J,R)?(x[rl]=J,x[q]=Z,rl=q):(x[rl]=R,x[z]=Z,rl=z);else if(qE(J,Z))x[rl]=J,x[q]=Z,rl=q;else break l}}return U}function E(x,U){var Z=x.sortIndex-U.sortIndex;return Z!==0?Z:x.id-U.id}if(o.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var N=performance;o.unstable_now=function(){return N.now()}}else{var C=Date,Q=C.now();o.unstable_now=function(){return C.now()-Q}}var _=[],b=[],H=1,M=null,A=3,I=!1,L=!1,nl=!1,tl=!1,bl=typeof setTimeout=="function"?setTimeout:null,Ml=typeof clearTimeout=="function"?clearTimeout:null,Gl=typeof setImmediate<"u"?setImmediate:null;function rt(x){for(var U=O(b);U!==null;){if(U.callback===null)h(b);else if(U.startTime<=x)h(b),U.sortIndex=U.expirationTime,D(_,U);else break;U=O(b)}}function _t(x){if(nl=!1,rt(x),!L)if(O(_)!==null)L=!0,Ll||(Ll=!0,Kl());else{var U=O(b);U!==null&&jt(_t,U.startTime-x)}}var Ll=!1,el=-1,Vl=5,Et=-1;function Le(){return tl?!0:!(o.unstable_now()-Etx&&Le());){var rl=M.callback;if(typeof rl=="function"){M.callback=null,A=M.priorityLevel;var yl=rl(M.expirationTime<=x);if(x=o.unstable_now(),typeof yl=="function"){M.callback=yl,rt(x),U=!0;break t}M===O(_)&&h(_),rt(x)}else h(_);M=O(_)}if(M!==null)U=!0;else{var d=O(b);d!==null&&jt(_t,d.startTime-x),U=!1}}break l}finally{M=null,A=Z,I=!1}U=void 0}}finally{U?Kl():Ll=!1}}}var Kl;if(typeof Gl=="function")Kl=function(){Gl(Ot)};else if(typeof MessageChannel<"u"){var Te=new MessageChannel,Ut=Te.port2;Te.port1.onmessage=Ot,Kl=function(){Ut.postMessage(null)}}else Kl=function(){bl(Ot,0)};function jt(x,U){el=bl(function(){x(o.unstable_now())},U)}o.unstable_IdlePriority=5,o.unstable_ImmediatePriority=1,o.unstable_LowPriority=4,o.unstable_NormalPriority=3,o.unstable_Profiling=null,o.unstable_UserBlockingPriority=2,o.unstable_cancelCallback=function(x){x.callback=null},o.unstable_forceFrameRate=function(x){0>x||125rl?(x.sortIndex=Z,D(b,x),O(_)===null&&x===O(b)&&(nl?(Ml(el),el=-1):nl=!0,jt(_t,Z-rl))):(x.sortIndex=yl,D(_,x),L||I||(L=!0,Ll||(Ll=!0,Kl()))),x},o.unstable_shouldYield=Le,o.unstable_wrapCallback=function(x){var U=A;return function(){var Z=A;A=U;try{return x.apply(this,arguments)}finally{A=Z}}}})(df)),df}var jd;function iy(){return jd||(jd=1,rf.exports=uy()),rf.exports}var hf={exports:{}},Xl={};/** - * @license React - * react-dom.production.js - * - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */var Td;function cy(){if(Td)return Xl;Td=1;var o=mf();function D(_){var b="https://react.dev/errors/"+_;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(o)}catch(D){console.error(D)}}return o(),hf.exports=cy(),hf.exports}/** - * @license React - * react-dom-client.production.js - * - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */var Ad;function sy(){if(Ad)return Tn;Ad=1;var o=iy(),D=mf(),O=fy();function h(l){var t="https://react.dev/errors/"+l;if(1yl||(l.current=rl[yl],rl[yl]=null,yl--)}function R(l,t){yl++,rl[yl]=l.current,l.current=t}var q=d(null),J=d(null),$=d(null),fl=d(null);function Ql(l,t){switch(R($,t),R(J,l),R(q,null),t.nodeType){case 9:case 11:l=(l=t.documentElement)&&(l=l.namespaceURI)?Xr(l):0;break;default:if(l=t.tagName,t=t.namespaceURI)t=Xr(t),l=Qr(t,l);else switch(l){case"svg":l=1;break;case"math":l=2;break;default:l=0}}z(q),R(q,l)}function Tl(){z(q),z(J),z($)}function Oa(l){l.memoizedState!==null&&R(fl,l);var t=q.current,e=Qr(t,l.type);t!==e&&(R(J,l),R(q,e))}function zn(l){J.current===l&&(z(q),z(J)),fl.current===l&&(z(fl),Sn._currentValue=Z)}var Lu,yf;function ze(l){if(Lu===void 0)try{throw Error()}catch(e){var t=e.stack.trim().match(/\n( *(at )?)/);Lu=t&&t[1]||"",yf=-1)":-1n||s[a]!==v[n]){var p=` -`+s[a].replace(" at new "," at ");return l.displayName&&p.includes("")&&(p=p.replace("",l.displayName)),p}while(1<=a&&0<=n);break}}}finally{Vu=!1,Error.prepareStackTrace=e}return(e=l?l.displayName||l.name:"")?ze(e):""}function Ud(l,t){switch(l.tag){case 26:case 27:case 5:return ze(l.type);case 16:return ze("Lazy");case 13:return l.child!==t&&t!==null?ze("Suspense Fallback"):ze("Suspense");case 19:return ze("SuspenseList");case 0:case 15:return Ku(l.type,!1);case 11:return Ku(l.type.render,!1);case 1:return Ku(l.type,!0);case 31:return ze("Activity");default:return""}}function vf(l){try{var t="",e=null;do t+=Ud(l,e),e=l,l=l.return;while(l);return t}catch(a){return` -Error generating stack: `+a.message+` -`+a.stack}}var Ju=Object.prototype.hasOwnProperty,wu=o.unstable_scheduleCallback,ku=o.unstable_cancelCallback,Rd=o.unstable_shouldYield,Bd=o.unstable_requestPaint,lt=o.unstable_now,Hd=o.unstable_getCurrentPriorityLevel,gf=o.unstable_ImmediatePriority,Sf=o.unstable_UserBlockingPriority,An=o.unstable_NormalPriority,qd=o.unstable_LowPriority,pf=o.unstable_IdlePriority,Yd=o.log,Gd=o.unstable_setDisableYieldValue,Na=null,tt=null;function It(l){if(typeof Yd=="function"&&Gd(l),tt&&typeof tt.setStrictMode=="function")try{tt.setStrictMode(Na,l)}catch{}}var et=Math.clz32?Math.clz32:Zd,Xd=Math.log,Qd=Math.LN2;function Zd(l){return l>>>=0,l===0?32:31-(Xd(l)/Qd|0)|0}var _n=256,En=262144,On=4194304;function Ae(l){var t=l&42;if(t!==0)return t;switch(l&-l){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return l&261888;case 262144:case 524288:case 1048576:case 2097152:return l&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return l&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return l}}function Nn(l,t,e){var a=l.pendingLanes;if(a===0)return 0;var n=0,u=l.suspendedLanes,i=l.pingedLanes;l=l.warmLanes;var f=a&134217727;return f!==0?(a=f&~u,a!==0?n=Ae(a):(i&=f,i!==0?n=Ae(i):e||(e=f&~l,e!==0&&(n=Ae(e))))):(f=a&~u,f!==0?n=Ae(f):i!==0?n=Ae(i):e||(e=a&~l,e!==0&&(n=Ae(e)))),n===0?0:t!==0&&t!==n&&(t&u)===0&&(u=n&-n,e=t&-t,u>=e||u===32&&(e&4194048)!==0)?t:n}function Ma(l,t){return(l.pendingLanes&~(l.suspendedLanes&~l.pingedLanes)&t)===0}function Ld(l,t){switch(l){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function bf(){var l=On;return On<<=1,(On&62914560)===0&&(On=4194304),l}function $u(l){for(var t=[],e=0;31>e;e++)t.push(l);return t}function Da(l,t){l.pendingLanes|=t,t!==268435456&&(l.suspendedLanes=0,l.pingedLanes=0,l.warmLanes=0)}function Vd(l,t,e,a,n,u){var i=l.pendingLanes;l.pendingLanes=e,l.suspendedLanes=0,l.pingedLanes=0,l.warmLanes=0,l.expiredLanes&=e,l.entangledLanes&=e,l.errorRecoveryDisabledLanes&=e,l.shellSuspendCounter=0;var f=l.entanglements,s=l.expirationTimes,v=l.hiddenUpdates;for(e=i&~e;0"u")return null;try{return l.activeElement||l.body}catch{return l.body}}var Wd=/[\n"\\]/g;function ht(l){return l.replace(Wd,function(t){return"\\"+t.charCodeAt(0).toString(16)+" "})}function ti(l,t,e,a,n,u,i,f){l.name="",i!=null&&typeof i!="function"&&typeof i!="symbol"&&typeof i!="boolean"?l.type=i:l.removeAttribute("type"),t!=null?i==="number"?(t===0&&l.value===""||l.value!=t)&&(l.value=""+dt(t)):l.value!==""+dt(t)&&(l.value=""+dt(t)):i!=="submit"&&i!=="reset"||l.removeAttribute("value"),t!=null?ei(l,i,dt(t)):e!=null?ei(l,i,dt(e)):a!=null&&l.removeAttribute("value"),n==null&&u!=null&&(l.defaultChecked=!!u),n!=null&&(l.checked=n&&typeof n!="function"&&typeof n!="symbol"),f!=null&&typeof f!="function"&&typeof f!="symbol"&&typeof f!="boolean"?l.name=""+dt(f):l.removeAttribute("name")}function Uf(l,t,e,a,n,u,i,f){if(u!=null&&typeof u!="function"&&typeof u!="symbol"&&typeof u!="boolean"&&(l.type=u),t!=null||e!=null){if(!(u!=="submit"&&u!=="reset"||t!=null)){li(l);return}e=e!=null?""+dt(e):"",t=t!=null?""+dt(t):e,f||t===l.value||(l.value=t),l.defaultValue=t}a=a??n,a=typeof a!="function"&&typeof a!="symbol"&&!!a,l.checked=f?l.checked:!!a,l.defaultChecked=!!a,i!=null&&typeof i!="function"&&typeof i!="symbol"&&typeof i!="boolean"&&(l.name=i),li(l)}function ei(l,t,e){t==="number"&&Cn(l.ownerDocument)===l||l.defaultValue===""+e||(l.defaultValue=""+e)}function $e(l,t,e,a){if(l=l.options,t){t={};for(var n=0;n"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),ci=!1;if(Ht)try{var Ba={};Object.defineProperty(Ba,"passive",{get:function(){ci=!0}}),window.addEventListener("test",Ba,Ba),window.removeEventListener("test",Ba,Ba)}catch{ci=!1}var le=null,fi=null,Rn=null;function Xf(){if(Rn)return Rn;var l,t=fi,e=t.length,a,n="value"in le?le.value:le.textContent,u=n.length;for(l=0;l=Ya),Jf=" ",wf=!1;function kf(l,t){switch(l){case"keyup":return zh.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function $f(l){return l=l.detail,typeof l=="object"&&"data"in l?l.data:null}var Pe=!1;function _h(l,t){switch(l){case"compositionend":return $f(t);case"keypress":return t.which!==32?null:(wf=!0,Jf);case"textInput":return l=t.data,l===Jf&&wf?null:l;default:return null}}function Eh(l,t){if(Pe)return l==="compositionend"||!hi&&kf(l,t)?(l=Xf(),Rn=fi=le=null,Pe=!1,l):null;switch(l){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:e,offset:t-l};l=a}l:{for(;e;){if(e.nextSibling){e=e.nextSibling;break l}e=e.parentNode}e=void 0}e=as(e)}}function us(l,t){return l&&t?l===t?!0:l&&l.nodeType===3?!1:t&&t.nodeType===3?us(l,t.parentNode):"contains"in l?l.contains(t):l.compareDocumentPosition?!!(l.compareDocumentPosition(t)&16):!1:!1}function is(l){l=l!=null&&l.ownerDocument!=null&&l.ownerDocument.defaultView!=null?l.ownerDocument.defaultView:window;for(var t=Cn(l.document);t instanceof l.HTMLIFrameElement;){try{var e=typeof t.contentWindow.location.href=="string"}catch{e=!1}if(e)l=t.contentWindow;else break;t=Cn(l.document)}return t}function vi(l){var t=l&&l.nodeName&&l.nodeName.toLowerCase();return t&&(t==="input"&&(l.type==="text"||l.type==="search"||l.type==="tel"||l.type==="url"||l.type==="password")||t==="textarea"||l.contentEditable==="true")}var Bh=Ht&&"documentMode"in document&&11>=document.documentMode,la=null,gi=null,Za=null,Si=!1;function cs(l,t,e){var a=e.window===e?e.document:e.nodeType===9?e:e.ownerDocument;Si||la==null||la!==Cn(a)||(a=la,"selectionStart"in a&&vi(a)?a={start:a.selectionStart,end:a.selectionEnd}:(a=(a.ownerDocument&&a.ownerDocument.defaultView||window).getSelection(),a={anchorNode:a.anchorNode,anchorOffset:a.anchorOffset,focusNode:a.focusNode,focusOffset:a.focusOffset}),Za&&Qa(Za,a)||(Za=a,a=Eu(gi,"onSelect"),0>=i,n-=i,Nt=1<<32-et(t)+n|e<k?(ll=Y,Y=null):ll=Y.sibling;var il=g(m,Y,y[k],j);if(il===null){Y===null&&(Y=ll);break}l&&Y&&il.alternate===null&&t(m,Y),r=u(il,r,k),ul===null?G=il:ul.sibling=il,ul=il,Y=ll}if(k===y.length)return e(m,Y),al&&Yt(m,k),G;if(Y===null){for(;kk?(ll=Y,Y=null):ll=Y.sibling;var je=g(m,Y,il.value,j);if(je===null){Y===null&&(Y=ll);break}l&&Y&&je.alternate===null&&t(m,Y),r=u(je,r,k),ul===null?G=je:ul.sibling=je,ul=je,Y=ll}if(il.done)return e(m,Y),al&&Yt(m,k),G;if(Y===null){for(;!il.done;k++,il=y.next())il=T(m,il.value,j),il!==null&&(r=u(il,r,k),ul===null?G=il:ul.sibling=il,ul=il);return al&&Yt(m,k),G}for(Y=a(Y);!il.done;k++,il=y.next())il=S(Y,m,k,il.value,j),il!==null&&(l&&il.alternate!==null&&Y.delete(il.key===null?k:il.key),r=u(il,r,k),ul===null?G=il:ul.sibling=il,ul=il);return l&&Y.forEach(function(ty){return t(m,ty)}),al&&Yt(m,k),G}function ml(m,r,y,j){if(typeof y=="object"&&y!==null&&y.type===nl&&y.key===null&&(y=y.props.children),typeof y=="object"&&y!==null){switch(y.$$typeof){case I:l:{for(var G=y.key;r!==null;){if(r.key===G){if(G=y.type,G===nl){if(r.tag===7){e(m,r.sibling),j=n(r,y.props.children),j.return=m,m=j;break l}}else if(r.elementType===G||typeof G=="object"&&G!==null&&G.$$typeof===Vl&&He(G)===r.type){e(m,r.sibling),j=n(r,y.props),ka(j,y),j.return=m,m=j;break l}e(m,r);break}else t(m,r);r=r.sibling}y.type===nl?(j=De(y.props.children,m.mode,j,y.key),j.return=m,m=j):(j=Vn(y.type,y.key,y.props,null,m.mode,j),ka(j,y),j.return=m,m=j)}return i(m);case L:l:{for(G=y.key;r!==null;){if(r.key===G)if(r.tag===4&&r.stateNode.containerInfo===y.containerInfo&&r.stateNode.implementation===y.implementation){e(m,r.sibling),j=n(r,y.children||[]),j.return=m,m=j;break l}else{e(m,r);break}else t(m,r);r=r.sibling}j=Ai(y,m.mode,j),j.return=m,m=j}return i(m);case Vl:return y=He(y),ml(m,r,y,j)}if(jt(y))return B(m,r,y,j);if(Kl(y)){if(G=Kl(y),typeof G!="function")throw Error(h(150));return y=G.call(y),X(m,r,y,j)}if(typeof y.then=="function")return ml(m,r,Fn(y),j);if(y.$$typeof===Gl)return ml(m,r,wn(m,y),j);In(m,y)}return typeof y=="string"&&y!==""||typeof y=="number"||typeof y=="bigint"?(y=""+y,r!==null&&r.tag===6?(e(m,r.sibling),j=n(r,y),j.return=m,m=j):(e(m,r),j=zi(y,m.mode,j),j.return=m,m=j),i(m)):e(m,r)}return function(m,r,y,j){try{wa=0;var G=ml(m,r,y,j);return ra=null,G}catch(Y){if(Y===oa||Y===$n)throw Y;var ul=nt(29,Y,null,m.mode);return ul.lanes=j,ul.return=m,ul}finally{}}}var Ye=Ms(!0),Ds=Ms(!1),ue=!1;function qi(l){l.updateQueue={baseState:l.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function Yi(l,t){l=l.updateQueue,t.updateQueue===l&&(t.updateQueue={baseState:l.baseState,firstBaseUpdate:l.firstBaseUpdate,lastBaseUpdate:l.lastBaseUpdate,shared:l.shared,callbacks:null})}function ie(l){return{lane:l,tag:0,payload:null,callback:null,next:null}}function ce(l,t,e){var a=l.updateQueue;if(a===null)return null;if(a=a.shared,(cl&2)!==0){var n=a.pending;return n===null?t.next=t:(t.next=n.next,n.next=t),a.pending=t,t=Ln(l),ms(l,null,e),t}return Zn(l,a,t,e),Ln(l)}function $a(l,t,e){if(t=t.updateQueue,t!==null&&(t=t.shared,(e&4194048)!==0)){var a=t.lanes;a&=l.pendingLanes,e|=a,t.lanes=e,jf(l,e)}}function Gi(l,t){var e=l.updateQueue,a=l.alternate;if(a!==null&&(a=a.updateQueue,e===a)){var n=null,u=null;if(e=e.firstBaseUpdate,e!==null){do{var i={lane:e.lane,tag:e.tag,payload:e.payload,callback:null,next:null};u===null?n=u=i:u=u.next=i,e=e.next}while(e!==null);u===null?n=u=t:u=u.next=t}else n=u=t;e={baseState:a.baseState,firstBaseUpdate:n,lastBaseUpdate:u,shared:a.shared,callbacks:a.callbacks},l.updateQueue=e;return}l=e.lastBaseUpdate,l===null?e.firstBaseUpdate=t:l.next=t,e.lastBaseUpdate=t}var Xi=!1;function Wa(){if(Xi){var l=sa;if(l!==null)throw l}}function Fa(l,t,e,a){Xi=!1;var n=l.updateQueue;ue=!1;var u=n.firstBaseUpdate,i=n.lastBaseUpdate,f=n.shared.pending;if(f!==null){n.shared.pending=null;var s=f,v=s.next;s.next=null,i===null?u=v:i.next=v,i=s;var p=l.alternate;p!==null&&(p=p.updateQueue,f=p.lastBaseUpdate,f!==i&&(f===null?p.firstBaseUpdate=v:f.next=v,p.lastBaseUpdate=s))}if(u!==null){var T=n.baseState;i=0,p=v=s=null,f=u;do{var g=f.lane&-536870913,S=g!==f.lane;if(S?(P&g)===g:(a&g)===g){g!==0&&g===fa&&(Xi=!0),p!==null&&(p=p.next={lane:0,tag:f.tag,payload:f.payload,callback:null,next:null});l:{var B=l,X=f;g=t;var ml=e;switch(X.tag){case 1:if(B=X.payload,typeof B=="function"){T=B.call(ml,T,g);break l}T=B;break l;case 3:B.flags=B.flags&-65537|128;case 0:if(B=X.payload,g=typeof B=="function"?B.call(ml,T,g):B,g==null)break l;T=M({},T,g);break l;case 2:ue=!0}}g=f.callback,g!==null&&(l.flags|=64,S&&(l.flags|=8192),S=n.callbacks,S===null?n.callbacks=[g]:S.push(g))}else S={lane:g,tag:f.tag,payload:f.payload,callback:f.callback,next:null},p===null?(v=p=S,s=T):p=p.next=S,i|=g;if(f=f.next,f===null){if(f=n.shared.pending,f===null)break;S=f,f=S.next,S.next=null,n.lastBaseUpdate=S,n.shared.pending=null}}while(!0);p===null&&(s=T),n.baseState=s,n.firstBaseUpdate=v,n.lastBaseUpdate=p,u===null&&(n.shared.lanes=0),de|=i,l.lanes=i,l.memoizedState=T}}function Cs(l,t){if(typeof l!="function")throw Error(h(191,l));l.call(t)}function Us(l,t){var e=l.callbacks;if(e!==null)for(l.callbacks=null,l=0;lu?u:8;var i=x.T,f={};x.T=f,uc(l,!1,t,e);try{var s=n(),v=x.S;if(v!==null&&v(f,s),s!==null&&typeof s=="object"&&typeof s.then=="function"){var p=Vh(s,a);ln(l,t,p,st(l))}else ln(l,t,a,st(l))}catch(T){ln(l,t,{then:function(){},status:"rejected",reason:T},st())}finally{U.p=u,i!==null&&f.types!==null&&(i.types=f.types),x.T=i}}function Wh(){}function ac(l,t,e,a){if(l.tag!==5)throw Error(h(476));var n=ro(l).queue;oo(l,n,t,Z,e===null?Wh:function(){return ho(l),e(a)})}function ro(l){var t=l.memoizedState;if(t!==null)return t;t={memoizedState:Z,baseState:Z,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Zt,lastRenderedState:Z},next:null};var e={};return t.next={memoizedState:e,baseState:e,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Zt,lastRenderedState:e},next:null},l.memoizedState=t,l=l.alternate,l!==null&&(l.memoizedState=t),t}function ho(l){var t=ro(l);t.next===null&&(t=l.alternate.memoizedState),ln(l,t.next.queue,{},st())}function nc(){return Hl(Sn)}function mo(){return Al().memoizedState}function yo(){return Al().memoizedState}function Fh(l){for(var t=l.return;t!==null;){switch(t.tag){case 24:case 3:var e=st();l=ie(e);var a=ce(t,l,e);a!==null&&(Il(a,t,e),$a(a,t,e)),t={cache:Ui()},l.payload=t;return}t=t.return}}function Ih(l,t,e){var a=st();e={lane:a,revertLane:0,gesture:null,action:e,hasEagerState:!1,eagerState:null,next:null},fu(l)?go(t,e):(e=ji(l,t,e,a),e!==null&&(Il(e,l,a),So(e,t,a)))}function vo(l,t,e){var a=st();ln(l,t,e,a)}function ln(l,t,e,a){var n={lane:a,revertLane:0,gesture:null,action:e,hasEagerState:!1,eagerState:null,next:null};if(fu(l))go(t,n);else{var u=l.alternate;if(l.lanes===0&&(u===null||u.lanes===0)&&(u=t.lastRenderedReducer,u!==null))try{var i=t.lastRenderedState,f=u(i,e);if(n.hasEagerState=!0,n.eagerState=f,at(f,i))return Zn(l,t,n,0),vl===null&&Qn(),!1}catch{}finally{}if(e=ji(l,t,n,a),e!==null)return Il(e,l,a),So(e,t,a),!0}return!1}function uc(l,t,e,a){if(a={lane:2,revertLane:qc(),gesture:null,action:a,hasEagerState:!1,eagerState:null,next:null},fu(l)){if(t)throw Error(h(479))}else t=ji(l,e,a,2),t!==null&&Il(t,l,2)}function fu(l){var t=l.alternate;return l===w||t!==null&&t===w}function go(l,t){ha=tu=!0;var e=l.pending;e===null?t.next=t:(t.next=e.next,e.next=t),l.pending=t}function So(l,t,e){if((e&4194048)!==0){var a=t.lanes;a&=l.pendingLanes,e|=a,t.lanes=e,jf(l,e)}}var tn={readContext:Hl,use:nu,useCallback:xl,useContext:xl,useEffect:xl,useImperativeHandle:xl,useLayoutEffect:xl,useInsertionEffect:xl,useMemo:xl,useReducer:xl,useRef:xl,useState:xl,useDebugValue:xl,useDeferredValue:xl,useTransition:xl,useSyncExternalStore:xl,useId:xl,useHostTransitionStatus:xl,useFormState:xl,useActionState:xl,useOptimistic:xl,useMemoCache:xl,useCacheRefresh:xl};tn.useEffectEvent=xl;var po={readContext:Hl,use:nu,useCallback:function(l,t){return Zl().memoizedState=[l,t===void 0?null:t],l},useContext:Hl,useEffect:to,useImperativeHandle:function(l,t,e){e=e!=null?e.concat([l]):null,iu(4194308,4,uo.bind(null,t,l),e)},useLayoutEffect:function(l,t){return iu(4194308,4,l,t)},useInsertionEffect:function(l,t){iu(4,2,l,t)},useMemo:function(l,t){var e=Zl();t=t===void 0?null:t;var a=l();if(Ge){It(!0);try{l()}finally{It(!1)}}return e.memoizedState=[a,t],a},useReducer:function(l,t,e){var a=Zl();if(e!==void 0){var n=e(t);if(Ge){It(!0);try{e(t)}finally{It(!1)}}}else n=t;return a.memoizedState=a.baseState=n,l={pending:null,lanes:0,dispatch:null,lastRenderedReducer:l,lastRenderedState:n},a.queue=l,l=l.dispatch=Ih.bind(null,w,l),[a.memoizedState,l]},useRef:function(l){var t=Zl();return l={current:l},t.memoizedState=l},useState:function(l){l=Ii(l);var t=l.queue,e=vo.bind(null,w,t);return t.dispatch=e,[l.memoizedState,e]},useDebugValue:tc,useDeferredValue:function(l,t){var e=Zl();return ec(e,l,t)},useTransition:function(){var l=Ii(!1);return l=oo.bind(null,w,l.queue,!0,!1),Zl().memoizedState=l,[!1,l]},useSyncExternalStore:function(l,t,e){var a=w,n=Zl();if(al){if(e===void 0)throw Error(h(407));e=e()}else{if(e=t(),vl===null)throw Error(h(349));(P&127)!==0||Gs(a,t,e)}n.memoizedState=e;var u={value:e,getSnapshot:t};return n.queue=u,to(Qs.bind(null,a,u,l),[l]),a.flags|=2048,ya(9,{destroy:void 0},Xs.bind(null,a,u,e,t),null),e},useId:function(){var l=Zl(),t=vl.identifierPrefix;if(al){var e=Mt,a=Nt;e=(a&~(1<<32-et(a)-1)).toString(32)+e,t="_"+t+"R_"+e,e=eu++,0<\/script>",u=u.removeChild(u.firstChild);break;case"select":u=typeof a.is=="string"?i.createElement("select",{is:a.is}):i.createElement("select"),a.multiple?u.multiple=!0:a.size&&(u.size=a.size);break;default:u=typeof a.is=="string"?i.createElement(n,{is:a.is}):i.createElement(n)}}u[Rl]=t,u[Jl]=a;l:for(i=t.child;i!==null;){if(i.tag===5||i.tag===6)u.appendChild(i.stateNode);else if(i.tag!==4&&i.tag!==27&&i.child!==null){i.child.return=i,i=i.child;continue}if(i===t)break l;for(;i.sibling===null;){if(i.return===null||i.return===t)break l;i=i.return}i.sibling.return=i.return,i=i.sibling}t.stateNode=u;l:switch(Yl(u,n,a),n){case"button":case"input":case"select":case"textarea":a=!!a.autoFocus;break l;case"img":a=!0;break l;default:a=!1}a&&Vt(t)}}return Sl(t),pc(t,t.type,l===null?null:l.memoizedProps,t.pendingProps,e),null;case 6:if(l&&t.stateNode!=null)l.memoizedProps!==a&&Vt(t);else{if(typeof a!="string"&&t.stateNode===null)throw Error(h(166));if(l=$.current,ia(t)){if(l=t.stateNode,e=t.memoizedProps,a=null,n=Bl,n!==null)switch(n.tag){case 27:case 5:a=n.memoizedProps}l[Rl]=t,l=!!(l.nodeValue===e||a!==null&&a.suppressHydrationWarning===!0||Yr(l.nodeValue,e)),l||ae(t,!0)}else l=Ou(l).createTextNode(a),l[Rl]=t,t.stateNode=l}return Sl(t),null;case 31:if(e=t.memoizedState,l===null||l.memoizedState!==null){if(a=ia(t),e!==null){if(l===null){if(!a)throw Error(h(318));if(l=t.memoizedState,l=l!==null?l.dehydrated:null,!l)throw Error(h(557));l[Rl]=t}else Ce(),(t.flags&128)===0&&(t.memoizedState=null),t.flags|=4;Sl(t),l=!1}else e=Ni(),l!==null&&l.memoizedState!==null&&(l.memoizedState.hydrationErrors=e),l=!0;if(!l)return t.flags&256?(it(t),t):(it(t),null);if((t.flags&128)!==0)throw Error(h(558))}return Sl(t),null;case 13:if(a=t.memoizedState,l===null||l.memoizedState!==null&&l.memoizedState.dehydrated!==null){if(n=ia(t),a!==null&&a.dehydrated!==null){if(l===null){if(!n)throw Error(h(318));if(n=t.memoizedState,n=n!==null?n.dehydrated:null,!n)throw Error(h(317));n[Rl]=t}else Ce(),(t.flags&128)===0&&(t.memoizedState=null),t.flags|=4;Sl(t),n=!1}else n=Ni(),l!==null&&l.memoizedState!==null&&(l.memoizedState.hydrationErrors=n),n=!0;if(!n)return t.flags&256?(it(t),t):(it(t),null)}return it(t),(t.flags&128)!==0?(t.lanes=e,t):(e=a!==null,l=l!==null&&l.memoizedState!==null,e&&(a=t.child,n=null,a.alternate!==null&&a.alternate.memoizedState!==null&&a.alternate.memoizedState.cachePool!==null&&(n=a.alternate.memoizedState.cachePool.pool),u=null,a.memoizedState!==null&&a.memoizedState.cachePool!==null&&(u=a.memoizedState.cachePool.pool),u!==n&&(a.flags|=2048)),e!==l&&e&&(t.child.flags|=8192),hu(t,t.updateQueue),Sl(t),null);case 4:return Tl(),l===null&&Qc(t.stateNode.containerInfo),Sl(t),null;case 10:return Xt(t.type),Sl(t),null;case 19:if(z(zl),a=t.memoizedState,a===null)return Sl(t),null;if(n=(t.flags&128)!==0,u=a.rendering,u===null)if(n)an(a,!1);else{if(jl!==0||l!==null&&(l.flags&128)!==0)for(l=t.child;l!==null;){if(u=lu(l),u!==null){for(t.flags|=128,an(a,!1),l=u.updateQueue,t.updateQueue=l,hu(t,l),t.subtreeFlags=0,l=e,e=t.child;e!==null;)ys(e,l),e=e.sibling;return R(zl,zl.current&1|2),al&&Yt(t,a.treeForkCount),t.child}l=l.sibling}a.tail!==null&<()>Su&&(t.flags|=128,n=!0,an(a,!1),t.lanes=4194304)}else{if(!n)if(l=lu(u),l!==null){if(t.flags|=128,n=!0,l=l.updateQueue,t.updateQueue=l,hu(t,l),an(a,!0),a.tail===null&&a.tailMode==="hidden"&&!u.alternate&&!al)return Sl(t),null}else 2*lt()-a.renderingStartTime>Su&&e!==536870912&&(t.flags|=128,n=!0,an(a,!1),t.lanes=4194304);a.isBackwards?(u.sibling=t.child,t.child=u):(l=a.last,l!==null?l.sibling=u:t.child=u,a.last=u)}return a.tail!==null?(l=a.tail,a.rendering=l,a.tail=l.sibling,a.renderingStartTime=lt(),l.sibling=null,e=zl.current,R(zl,n?e&1|2:e&1),al&&Yt(t,a.treeForkCount),l):(Sl(t),null);case 22:case 23:return it(t),Zi(),a=t.memoizedState!==null,l!==null?l.memoizedState!==null!==a&&(t.flags|=8192):a&&(t.flags|=8192),a?(e&536870912)!==0&&(t.flags&128)===0&&(Sl(t),t.subtreeFlags&6&&(t.flags|=8192)):Sl(t),e=t.updateQueue,e!==null&&hu(t,e.retryQueue),e=null,l!==null&&l.memoizedState!==null&&l.memoizedState.cachePool!==null&&(e=l.memoizedState.cachePool.pool),a=null,t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(a=t.memoizedState.cachePool.pool),a!==e&&(t.flags|=2048),l!==null&&z(Be),null;case 24:return e=null,l!==null&&(e=l.memoizedState.cache),t.memoizedState.cache!==e&&(t.flags|=2048),Xt(_l),Sl(t),null;case 25:return null;case 30:return null}throw Error(h(156,t.tag))}function am(l,t){switch(Ei(t),t.tag){case 1:return l=t.flags,l&65536?(t.flags=l&-65537|128,t):null;case 3:return Xt(_l),Tl(),l=t.flags,(l&65536)!==0&&(l&128)===0?(t.flags=l&-65537|128,t):null;case 26:case 27:case 5:return zn(t),null;case 31:if(t.memoizedState!==null){if(it(t),t.alternate===null)throw Error(h(340));Ce()}return l=t.flags,l&65536?(t.flags=l&-65537|128,t):null;case 13:if(it(t),l=t.memoizedState,l!==null&&l.dehydrated!==null){if(t.alternate===null)throw Error(h(340));Ce()}return l=t.flags,l&65536?(t.flags=l&-65537|128,t):null;case 19:return z(zl),null;case 4:return Tl(),null;case 10:return Xt(t.type),null;case 22:case 23:return it(t),Zi(),l!==null&&z(Be),l=t.flags,l&65536?(t.flags=l&-65537|128,t):null;case 24:return Xt(_l),null;case 25:return null;default:return null}}function Lo(l,t){switch(Ei(t),t.tag){case 3:Xt(_l),Tl();break;case 26:case 27:case 5:zn(t);break;case 4:Tl();break;case 31:t.memoizedState!==null&&it(t);break;case 13:it(t);break;case 19:z(zl);break;case 10:Xt(t.type);break;case 22:case 23:it(t),Zi(),l!==null&&z(Be);break;case 24:Xt(_l)}}function nn(l,t){try{var e=t.updateQueue,a=e!==null?e.lastEffect:null;if(a!==null){var n=a.next;e=n;do{if((e.tag&l)===l){a=void 0;var u=e.create,i=e.inst;a=u(),i.destroy=a}e=e.next}while(e!==n)}}catch(f){ol(t,t.return,f)}}function oe(l,t,e){try{var a=t.updateQueue,n=a!==null?a.lastEffect:null;if(n!==null){var u=n.next;a=u;do{if((a.tag&l)===l){var i=a.inst,f=i.destroy;if(f!==void 0){i.destroy=void 0,n=t;var s=e,v=f;try{v()}catch(p){ol(n,s,p)}}}a=a.next}while(a!==u)}}catch(p){ol(t,t.return,p)}}function Vo(l){var t=l.updateQueue;if(t!==null){var e=l.stateNode;try{Us(t,e)}catch(a){ol(l,l.return,a)}}}function Ko(l,t,e){e.props=Xe(l.type,l.memoizedProps),e.state=l.memoizedState;try{e.componentWillUnmount()}catch(a){ol(l,t,a)}}function un(l,t){try{var e=l.ref;if(e!==null){switch(l.tag){case 26:case 27:case 5:var a=l.stateNode;break;case 30:a=l.stateNode;break;default:a=l.stateNode}typeof e=="function"?l.refCleanup=e(a):e.current=a}}catch(n){ol(l,t,n)}}function Dt(l,t){var e=l.ref,a=l.refCleanup;if(e!==null)if(typeof a=="function")try{a()}catch(n){ol(l,t,n)}finally{l.refCleanup=null,l=l.alternate,l!=null&&(l.refCleanup=null)}else if(typeof e=="function")try{e(null)}catch(n){ol(l,t,n)}else e.current=null}function Jo(l){var t=l.type,e=l.memoizedProps,a=l.stateNode;try{l:switch(t){case"button":case"input":case"select":case"textarea":e.autoFocus&&a.focus();break l;case"img":e.src?a.src=e.src:e.srcSet&&(a.srcset=e.srcSet)}}catch(n){ol(l,l.return,n)}}function bc(l,t,e){try{var a=l.stateNode;Am(a,l.type,e,t),a[Jl]=t}catch(n){ol(l,l.return,n)}}function wo(l){return l.tag===5||l.tag===3||l.tag===26||l.tag===27&&ge(l.type)||l.tag===4}function xc(l){l:for(;;){for(;l.sibling===null;){if(l.return===null||wo(l.return))return null;l=l.return}for(l.sibling.return=l.return,l=l.sibling;l.tag!==5&&l.tag!==6&&l.tag!==18;){if(l.tag===27&&ge(l.type)||l.flags&2||l.child===null||l.tag===4)continue l;l.child.return=l,l=l.child}if(!(l.flags&2))return l.stateNode}}function jc(l,t,e){var a=l.tag;if(a===5||a===6)l=l.stateNode,t?(e.nodeType===9?e.body:e.nodeName==="HTML"?e.ownerDocument.body:e).insertBefore(l,t):(t=e.nodeType===9?e.body:e.nodeName==="HTML"?e.ownerDocument.body:e,t.appendChild(l),e=e._reactRootContainer,e!=null||t.onclick!==null||(t.onclick=Bt));else if(a!==4&&(a===27&&ge(l.type)&&(e=l.stateNode,t=null),l=l.child,l!==null))for(jc(l,t,e),l=l.sibling;l!==null;)jc(l,t,e),l=l.sibling}function mu(l,t,e){var a=l.tag;if(a===5||a===6)l=l.stateNode,t?e.insertBefore(l,t):e.appendChild(l);else if(a!==4&&(a===27&&ge(l.type)&&(e=l.stateNode),l=l.child,l!==null))for(mu(l,t,e),l=l.sibling;l!==null;)mu(l,t,e),l=l.sibling}function ko(l){var t=l.stateNode,e=l.memoizedProps;try{for(var a=l.type,n=t.attributes;n.length;)t.removeAttributeNode(n[0]);Yl(t,a,e),t[Rl]=l,t[Jl]=e}catch(u){ol(l,l.return,u)}}var Kt=!1,Nl=!1,Tc=!1,$o=typeof WeakSet=="function"?WeakSet:Set,Ul=null;function nm(l,t){if(l=l.containerInfo,Vc=Bu,l=is(l),vi(l)){if("selectionStart"in l)var e={start:l.selectionStart,end:l.selectionEnd};else l:{e=(e=l.ownerDocument)&&e.defaultView||window;var a=e.getSelection&&e.getSelection();if(a&&a.rangeCount!==0){e=a.anchorNode;var n=a.anchorOffset,u=a.focusNode;a=a.focusOffset;try{e.nodeType,u.nodeType}catch{e=null;break l}var i=0,f=-1,s=-1,v=0,p=0,T=l,g=null;t:for(;;){for(var S;T!==e||n!==0&&T.nodeType!==3||(f=i+n),T!==u||a!==0&&T.nodeType!==3||(s=i+a),T.nodeType===3&&(i+=T.nodeValue.length),(S=T.firstChild)!==null;)g=T,T=S;for(;;){if(T===l)break t;if(g===e&&++v===n&&(f=i),g===u&&++p===a&&(s=i),(S=T.nextSibling)!==null)break;T=g,g=T.parentNode}T=S}e=f===-1||s===-1?null:{start:f,end:s}}else e=null}e=e||{start:0,end:0}}else e=null;for(Kc={focusedElem:l,selectionRange:e},Bu=!1,Ul=t;Ul!==null;)if(t=Ul,l=t.child,(t.subtreeFlags&1028)!==0&&l!==null)l.return=t,Ul=l;else for(;Ul!==null;){switch(t=Ul,u=t.alternate,l=t.flags,t.tag){case 0:if((l&4)!==0&&(l=t.updateQueue,l=l!==null?l.events:null,l!==null))for(e=0;e title"))),Yl(u,a,e),u[Rl]=l,Cl(u),a=u;break l;case"link":var i=td("link","href",n).get(a+(e.href||""));if(i){for(var f=0;fml&&(i=ml,ml=X,X=i);var m=ns(f,X),r=ns(f,ml);if(m&&r&&(S.rangeCount!==1||S.anchorNode!==m.node||S.anchorOffset!==m.offset||S.focusNode!==r.node||S.focusOffset!==r.offset)){var y=T.createRange();y.setStart(m.node,m.offset),S.removeAllRanges(),X>ml?(S.addRange(y),S.extend(r.node,r.offset)):(y.setEnd(r.node,r.offset),S.addRange(y))}}}}for(T=[],S=f;S=S.parentNode;)S.nodeType===1&&T.push({element:S,left:S.scrollLeft,top:S.scrollTop});for(typeof f.focus=="function"&&f.focus(),f=0;fe?32:e,x.T=null,e=Mc,Mc=null;var u=me,i=Wt;if(Dl=0,ba=me=null,Wt=0,(cl&6)!==0)throw Error(h(331));var f=cl;if(cl|=4,ir(u.current),ar(u,u.current,i,e),cl=f,dn(0,!1),tt&&typeof tt.onPostCommitFiberRoot=="function")try{tt.onPostCommitFiberRoot(Na,u)}catch{}return!0}finally{U.p=n,x.T=a,zr(l,t)}}function _r(l,t,e){t=yt(e,t),t=sc(l.stateNode,t,2),l=ce(l,t,2),l!==null&&(Da(l,2),Ct(l))}function ol(l,t,e){if(l.tag===3)_r(l,l,e);else for(;t!==null;){if(t.tag===3){_r(t,l,e);break}else if(t.tag===1){var a=t.stateNode;if(typeof t.type.getDerivedStateFromError=="function"||typeof a.componentDidCatch=="function"&&(he===null||!he.has(a))){l=yt(e,l),e=Eo(2),a=ce(t,e,2),a!==null&&(Oo(e,a,t,l),Da(a,2),Ct(a));break}}t=t.return}}function Rc(l,t,e){var a=l.pingCache;if(a===null){a=l.pingCache=new cm;var n=new Set;a.set(t,n)}else n=a.get(t),n===void 0&&(n=new Set,a.set(t,n));n.has(e)||(_c=!0,n.add(e),l=dm.bind(null,l,t,e),t.then(l,l))}function dm(l,t,e){var a=l.pingCache;a!==null&&a.delete(t),l.pingedLanes|=l.suspendedLanes&e,l.warmLanes&=~e,vl===l&&(P&e)===e&&(jl===4||jl===3&&(P&62914560)===P&&300>lt()-gu?(cl&2)===0&&xa(l,0):Ec|=e,pa===P&&(pa=0)),Ct(l)}function Er(l,t){t===0&&(t=bf()),l=Me(l,t),l!==null&&(Da(l,t),Ct(l))}function hm(l){var t=l.memoizedState,e=0;t!==null&&(e=t.retryLane),Er(l,e)}function mm(l,t){var e=0;switch(l.tag){case 31:case 13:var a=l.stateNode,n=l.memoizedState;n!==null&&(e=n.retryLane);break;case 19:a=l.stateNode;break;case 22:a=l.stateNode._retryCache;break;default:throw Error(h(314))}a!==null&&a.delete(t),Er(l,e)}function ym(l,t){return wu(l,t)}var zu=null,Ta=null,Bc=!1,Au=!1,Hc=!1,ve=0;function Ct(l){l!==Ta&&l.next===null&&(Ta===null?zu=Ta=l:Ta=Ta.next=l),Au=!0,Bc||(Bc=!0,gm())}function dn(l,t){if(!Hc&&Au){Hc=!0;do for(var e=!1,a=zu;a!==null;){if(l!==0){var n=a.pendingLanes;if(n===0)var u=0;else{var i=a.suspendedLanes,f=a.pingedLanes;u=(1<<31-et(42|l)+1)-1,u&=n&~(i&~f),u=u&201326741?u&201326741|1:u?u|2:0}u!==0&&(e=!0,Dr(a,u))}else u=P,u=Nn(a,a===vl?u:0,a.cancelPendingCommit!==null||a.timeoutHandle!==-1),(u&3)===0||Ma(a,u)||(e=!0,Dr(a,u));a=a.next}while(e);Hc=!1}}function vm(){Or()}function Or(){Au=Bc=!1;var l=0;ve!==0&&Em()&&(l=ve);for(var t=lt(),e=null,a=zu;a!==null;){var n=a.next,u=Nr(a,t);u===0?(a.next=null,e===null?zu=n:e.next=n,n===null&&(Ta=e)):(e=a,(l!==0||(u&3)!==0)&&(Au=!0)),a=n}Dl!==0&&Dl!==5||dn(l),ve!==0&&(ve=0)}function Nr(l,t){for(var e=l.suspendedLanes,a=l.pingedLanes,n=l.expirationTimes,u=l.pendingLanes&-62914561;0f)break;var p=s.transferSize,T=s.initiatorType;p&&Gr(T)&&(s=s.responseEnd,i+=p*(s"u"?null:document;function Fr(l,t,e){var a=za;if(a&&typeof t=="string"&&t){var n=ht(t);n='link[rel="'+l+'"][href="'+n+'"]',typeof e=="string"&&(n+='[crossorigin="'+e+'"]'),Wr.has(n)||(Wr.add(n),l={rel:l,crossOrigin:e,href:t},a.querySelector(n)===null&&(t=a.createElement("link"),Yl(t,"link",l),Cl(t),a.head.appendChild(t)))}}function Hm(l){Ft.D(l),Fr("dns-prefetch",l,null)}function qm(l,t){Ft.C(l,t),Fr("preconnect",l,t)}function Ym(l,t,e){Ft.L(l,t,e);var a=za;if(a&&l&&t){var n='link[rel="preload"][as="'+ht(t)+'"]';t==="image"&&e&&e.imageSrcSet?(n+='[imagesrcset="'+ht(e.imageSrcSet)+'"]',typeof e.imageSizes=="string"&&(n+='[imagesizes="'+ht(e.imageSizes)+'"]')):n+='[href="'+ht(l)+'"]';var u=n;switch(t){case"style":u=Aa(l);break;case"script":u=_a(l)}xt.has(u)||(l=M({rel:"preload",href:t==="image"&&e&&e.imageSrcSet?void 0:l,as:t},e),xt.set(u,l),a.querySelector(n)!==null||t==="style"&&a.querySelector(vn(u))||t==="script"&&a.querySelector(gn(u))||(t=a.createElement("link"),Yl(t,"link",l),Cl(t),a.head.appendChild(t)))}}function Gm(l,t){Ft.m(l,t);var e=za;if(e&&l){var a=t&&typeof t.as=="string"?t.as:"script",n='link[rel="modulepreload"][as="'+ht(a)+'"][href="'+ht(l)+'"]',u=n;switch(a){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":u=_a(l)}if(!xt.has(u)&&(l=M({rel:"modulepreload",href:l},t),xt.set(u,l),e.querySelector(n)===null)){switch(a){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":if(e.querySelector(gn(u)))return}a=e.createElement("link"),Yl(a,"link",l),Cl(a),e.head.appendChild(a)}}}function Xm(l,t,e){Ft.S(l,t,e);var a=za;if(a&&l){var n=we(a).hoistableStyles,u=Aa(l);t=t||"default";var i=n.get(u);if(!i){var f={loading:0,preload:null};if(i=a.querySelector(vn(u)))f.loading=5;else{l=M({rel:"stylesheet",href:l,"data-precedence":t},e),(e=xt.get(u))&&Ic(l,e);var s=i=a.createElement("link");Cl(s),Yl(s,"link",l),s._p=new Promise(function(v,p){s.onload=v,s.onerror=p}),s.addEventListener("load",function(){f.loading|=1}),s.addEventListener("error",function(){f.loading|=2}),f.loading|=4,Mu(i,t,a)}i={type:"stylesheet",instance:i,count:1,state:f},n.set(u,i)}}}function Qm(l,t){Ft.X(l,t);var e=za;if(e&&l){var a=we(e).hoistableScripts,n=_a(l),u=a.get(n);u||(u=e.querySelector(gn(n)),u||(l=M({src:l,async:!0},t),(t=xt.get(n))&&Pc(l,t),u=e.createElement("script"),Cl(u),Yl(u,"link",l),e.head.appendChild(u)),u={type:"script",instance:u,count:1,state:null},a.set(n,u))}}function Zm(l,t){Ft.M(l,t);var e=za;if(e&&l){var a=we(e).hoistableScripts,n=_a(l),u=a.get(n);u||(u=e.querySelector(gn(n)),u||(l=M({src:l,async:!0,type:"module"},t),(t=xt.get(n))&&Pc(l,t),u=e.createElement("script"),Cl(u),Yl(u,"link",l),e.head.appendChild(u)),u={type:"script",instance:u,count:1,state:null},a.set(n,u))}}function Ir(l,t,e,a){var n=(n=$.current)?Nu(n):null;if(!n)throw Error(h(446));switch(l){case"meta":case"title":return null;case"style":return typeof e.precedence=="string"&&typeof e.href=="string"?(t=Aa(e.href),e=we(n).hoistableStyles,a=e.get(t),a||(a={type:"style",instance:null,count:0,state:null},e.set(t,a)),a):{type:"void",instance:null,count:0,state:null};case"link":if(e.rel==="stylesheet"&&typeof e.href=="string"&&typeof e.precedence=="string"){l=Aa(e.href);var u=we(n).hoistableStyles,i=u.get(l);if(i||(n=n.ownerDocument||n,i={type:"stylesheet",instance:null,count:0,state:{loading:0,preload:null}},u.set(l,i),(u=n.querySelector(vn(l)))&&!u._p&&(i.instance=u,i.state.loading=5),xt.has(l)||(e={rel:"preload",as:"style",href:e.href,crossOrigin:e.crossOrigin,integrity:e.integrity,media:e.media,hrefLang:e.hrefLang,referrerPolicy:e.referrerPolicy},xt.set(l,e),u||Lm(n,l,e,i.state))),t&&a===null)throw Error(h(528,""));return i}if(t&&a!==null)throw Error(h(529,""));return null;case"script":return t=e.async,e=e.src,typeof e=="string"&&t&&typeof t!="function"&&typeof t!="symbol"?(t=_a(e),e=we(n).hoistableScripts,a=e.get(t),a||(a={type:"script",instance:null,count:0,state:null},e.set(t,a)),a):{type:"void",instance:null,count:0,state:null};default:throw Error(h(444,l))}}function Aa(l){return'href="'+ht(l)+'"'}function vn(l){return'link[rel="stylesheet"]['+l+"]"}function Pr(l){return M({},l,{"data-precedence":l.precedence,precedence:null})}function Lm(l,t,e,a){l.querySelector('link[rel="preload"][as="style"]['+t+"]")?a.loading=1:(t=l.createElement("link"),a.preload=t,t.addEventListener("load",function(){return a.loading|=1}),t.addEventListener("error",function(){return a.loading|=2}),Yl(t,"link",e),Cl(t),l.head.appendChild(t))}function _a(l){return'[src="'+ht(l)+'"]'}function gn(l){return"script[async]"+l}function ld(l,t,e){if(t.count++,t.instance===null)switch(t.type){case"style":var a=l.querySelector('style[data-href~="'+ht(e.href)+'"]');if(a)return t.instance=a,Cl(a),a;var n=M({},e,{"data-href":e.href,"data-precedence":e.precedence,href:null,precedence:null});return a=(l.ownerDocument||l).createElement("style"),Cl(a),Yl(a,"style",n),Mu(a,e.precedence,l),t.instance=a;case"stylesheet":n=Aa(e.href);var u=l.querySelector(vn(n));if(u)return t.state.loading|=4,t.instance=u,Cl(u),u;a=Pr(e),(n=xt.get(n))&&Ic(a,n),u=(l.ownerDocument||l).createElement("link"),Cl(u);var i=u;return i._p=new Promise(function(f,s){i.onload=f,i.onerror=s}),Yl(u,"link",a),t.state.loading|=4,Mu(u,e.precedence,l),t.instance=u;case"script":return u=_a(e.src),(n=l.querySelector(gn(u)))?(t.instance=n,Cl(n),n):(a=e,(n=xt.get(u))&&(a=M({},e),Pc(a,n)),l=l.ownerDocument||l,n=l.createElement("script"),Cl(n),Yl(n,"link",a),l.head.appendChild(n),t.instance=n);case"void":return null;default:throw Error(h(443,t.type))}else t.type==="stylesheet"&&(t.state.loading&4)===0&&(a=t.instance,t.state.loading|=4,Mu(a,e.precedence,l));return t.instance}function Mu(l,t,e){for(var a=e.querySelectorAll('link[rel="stylesheet"][data-precedence],style[data-precedence]'),n=a.length?a[a.length-1]:null,u=n,i=0;i title"):null)}function Vm(l,t,e){if(e===1||t.itemProp!=null)return!1;switch(l){case"meta":case"title":return!0;case"style":if(typeof t.precedence!="string"||typeof t.href!="string"||t.href==="")break;return!0;case"link":if(typeof t.rel!="string"||typeof t.href!="string"||t.href===""||t.onLoad||t.onError)break;switch(t.rel){case"stylesheet":return l=t.disabled,typeof t.precedence=="string"&&l==null;default:return!0}case"script":if(t.async&&typeof t.async!="function"&&typeof t.async!="symbol"&&!t.onLoad&&!t.onError&&t.src&&typeof t.src=="string")return!0}return!1}function ad(l){return!(l.type==="stylesheet"&&(l.state.loading&3)===0)}function Km(l,t,e,a){if(e.type==="stylesheet"&&(typeof a.media!="string"||matchMedia(a.media).matches!==!1)&&(e.state.loading&4)===0){if(e.instance===null){var n=Aa(a.href),u=t.querySelector(vn(n));if(u){t=u._p,t!==null&&typeof t=="object"&&typeof t.then=="function"&&(l.count++,l=Cu.bind(l),t.then(l,l)),e.state.loading|=4,e.instance=u,Cl(u);return}u=t.ownerDocument||t,a=Pr(a),(n=xt.get(n))&&Ic(a,n),u=u.createElement("link"),Cl(u);var i=u;i._p=new Promise(function(f,s){i.onload=f,i.onerror=s}),Yl(u,"link",a),e.instance=u}l.stylesheets===null&&(l.stylesheets=new Map),l.stylesheets.set(e,t),(t=e.state.preload)&&(e.state.loading&3)===0&&(l.count++,e=Cu.bind(l),t.addEventListener("load",e),t.addEventListener("error",e))}}var lf=0;function Jm(l,t){return l.stylesheets&&l.count===0&&Ru(l,l.stylesheets),0lf?50:800)+t);return l.unsuspend=e,function(){l.unsuspend=null,clearTimeout(a),clearTimeout(n)}}:null}function Cu(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)Ru(this,this.stylesheets);else if(this.unsuspend){var l=this.unsuspend;this.unsuspend=null,l()}}}var Uu=null;function Ru(l,t){l.stylesheets=null,l.unsuspend!==null&&(l.count++,Uu=new Map,t.forEach(wm,l),Uu=null,Cu.call(l))}function wm(l,t){if(!(t.state.loading&4)){var e=Uu.get(l);if(e)var a=e.get(null);else{e=new Map,Uu.set(l,e);for(var n=l.querySelectorAll("link[data-precedence],style[data-precedence]"),u=0;u"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(o)}catch(D){console.error(D)}}return o(),of.exports=sy(),of.exports}var ry=oy();const dy=Md(ry),Cd="";async function ot(o,D){const O=await fetch(`${Cd}${o}`,{...D,credentials:"same-origin",headers:{"Content-Type":"application/json",...D==null?void 0:D.headers}});if(O.status===401)throw window.location.hash="#login",new Error("Unauthorized");if(!O.ok){const h=await O.json().catch(()=>({}));throw new Error(h.error||`HTTP ${O.status}`)}return O.json()}async function hy(o){const D=await fetch(`${Cd}${o}`,{credentials:"same-origin"});if(D.status===401)throw window.location.hash="#login",new Error("Unauthorized");if(!D.ok)throw new Error(`HTTP ${D.status}`);return D.text()}const Pl={login:o=>ot("/admin/login",{method:"POST",body:JSON.stringify({token:o})}),signOutEverywhere:()=>ot("/admin/api/sign-out-everywhere",{method:"POST"}),stats:()=>ot("/admin/api/stats"),health:()=>ot("/admin/api/health-indicators"),agents:()=>ot("/admin/api/agents"),requests:(o=1,D="")=>ot(`/admin/api/requests?page=${o}${D}`),apiKeys:()=>ot("/admin/api/api-keys"),createApiKey:o=>ot("/admin/api/api-keys",{method:"POST",body:JSON.stringify({name:o})}),revokeApiKey:o=>ot("/admin/api/api-keys/revoke",{method:"POST",body:JSON.stringify({name:o})}),updateClientTtl:(o,D)=>ot("/admin/api/update-client-ttl",{method:"POST",body:JSON.stringify({clientId:o,tokenTtl:D})}),revokeClient:o=>ot("/admin/api/revoke-client",{method:"POST",body:JSON.stringify({clientId:o})}),calibrationProfile:o=>ot(`/admin/api/calibration/profile${o?`?holder=${encodeURIComponent(o)}`:""}`),calibrationChart:(o,D)=>hy(`/admin/api/calibration/charts/${encodeURIComponent(o)}${D?`?holder=${encodeURIComponent(D)}`:""}`),jobsWatch:()=>ot("/admin/api/jobs/watch")};function my({onLogin:o}){const[D,O]=K.useState(""),[h,E]=K.useState(""),[N,C]=K.useState(!1),Q=async _=>{_.preventDefault(),E(""),C(!0);try{await Pl.login(D),O(""),o()}catch{E("Invalid token.")}finally{C(!1)}};return c.jsx("div",{className:"login-page",children:c.jsxs("div",{className:"login-box",children:[c.jsx("div",{className:"login-logo",children:"GBrain"}),c.jsxs("div",{style:{background:"rgba(136, 170, 255, 0.08)",border:"1px solid rgba(136, 170, 255, 0.2)",borderRadius:8,padding:"14px 16px",marginBottom:20,fontSize:13,lineHeight:1.5,color:"var(--text-secondary)"},children:[c.jsx("div",{style:{fontWeight:600,color:"var(--text-primary)",marginBottom:6},children:"🔒 This is a protected dashboard"}),"Ask your AI agent for the admin login link:",c.jsx("div",{style:{background:"rgba(0,0,0,0.3)",borderRadius:6,padding:"8px 12px",marginTop:8,fontFamily:"var(--font-mono)",fontSize:12,color:"#88aaff",wordBreak:"break-all"},children:'"Give me the GBrain admin login link"'}),c.jsx("div",{style:{marginTop:8,fontSize:12,color:"var(--text-muted)"},children:"Each link is single-use. Your agent generates a fresh one each time."})]}),c.jsxs("details",{style:{marginBottom:16},children:[c.jsx("summary",{style:{cursor:"pointer",fontSize:13,color:"var(--text-muted)"},children:"Or paste bootstrap token manually"}),c.jsxs("form",{onSubmit:Q,style:{marginTop:12},children:[c.jsx("div",{style:{marginBottom:12},children:c.jsx("input",{type:"password",placeholder:"Admin Token",value:D,onChange:_=>O(_.target.value)})}),c.jsx("button",{className:"btn btn-primary",style:{width:"100%"},disabled:N,children:N?"Authenticating...":"Submit"}),h&&c.jsx("div",{className:"login-error",children:h})]})]})]})})}function yy(){const[o,D]=K.useState({connected_agents:0,requests_today:0,active_tokens:0}),[O,h]=K.useState({expiring_soon:0,error_rate:"0%"}),[E,N]=K.useState([]),[C,Q]=K.useState("connecting"),_=K.useRef(null);K.useEffect(()=>{Pl.stats().then(D).catch(()=>{}),Pl.health().then(h).catch(()=>{});const H=new EventSource("/admin/events");_.current=H,H.onopen=()=>Q("connected"),H.onmessage=A=>{try{const I=JSON.parse(A.data);N(L=>[I,...L].slice(0,50))}catch{}},H.onerror=()=>{Q("disconnected"),setTimeout(()=>{Q("connecting"),H.close()},3e3)};const M=setInterval(()=>{Pl.stats().then(D).catch(()=>{}),Pl.health().then(h).catch(()=>{})},3e4);return()=>{H.close(),clearInterval(M)}},[]);const b=H=>{const M=Date.now()-new Date(H).getTime();return M<6e4?`${Math.floor(M/1e3)}s ago`:M<36e5?`${Math.floor(M/6e4)} min ago`:`${Math.floor(M/36e5)}h ago`};return c.jsxs(c.Fragment,{children:[c.jsx("h1",{className:"page-title",children:"Dashboard"}),c.jsxs("div",{style:{display:"flex",gap:24},children:[c.jsxs("div",{style:{flex:1},children:[c.jsxs("div",{className:"metrics",children:[c.jsxs("div",{className:"metric",children:[c.jsx("div",{className:"metric-value",children:o.connected_agents}),c.jsx("div",{className:"metric-label",children:"Connected Agents"})]}),c.jsxs("div",{className:"metric",children:[c.jsx("div",{className:"metric-value",children:o.requests_today}),c.jsx("div",{className:"metric-label",children:"Requests Today"})]}),c.jsxs("div",{className:"metric",children:[c.jsx("div",{className:"metric-value",children:o.active_tokens}),c.jsx("div",{className:"metric-label",children:"Active Tokens"})]})]}),c.jsxs("h2",{className:"section-title",children:["Live Activity",c.jsx("span",{style:{marginLeft:8,fontSize:10,color:C==="connected"?"var(--success)":C==="connecting"?"var(--warning)":"var(--error)"},children:C==="connected"?"● connected":C==="connecting"?"● connecting...":"● disconnected"})]}),c.jsx("div",{className:"feed",children:E.length===0?c.jsx("div",{className:"feed-empty",children:C==="connected"?"No requests yet. Agents will appear when they connect.":"Connecting..."}):c.jsxs("table",{children:[c.jsx("thead",{children:c.jsxs("tr",{children:[c.jsx("th",{children:"Agent"}),c.jsx("th",{children:"Operation"}),c.jsx("th",{children:"Scopes"}),c.jsx("th",{children:"Latency"}),c.jsx("th",{children:"Status"}),c.jsx("th",{children:"Time"})]})}),c.jsx("tbody",{children:E.map((H,M)=>c.jsxs("tr",{children:[c.jsx("td",{className:"mono",children:H.agent}),c.jsx("td",{className:"mono",children:H.operation}),c.jsx("td",{children:H.scopes.split(",").map(A=>c.jsx("span",{className:`badge badge-${A.trim()}`,style:{marginRight:4},children:A.trim()},A))}),c.jsxs("td",{className:"mono",children:[H.latency_ms," ms"]}),c.jsx("td",{children:c.jsx("span",{className:`badge badge-${H.status}`,children:H.status})}),c.jsx("td",{style:{color:"var(--text-secondary)"},children:b(H.timestamp)})]},M))})]})})]}),c.jsxs("div",{style:{width:220},children:[c.jsx("h2",{className:"section-title",children:"Token Health"}),c.jsxs("div",{className:"health-panel",children:[c.jsxs("div",{className:"health-row",children:[c.jsx("span",{style:{color:"var(--warning)"},children:"Expiring Soon"}),c.jsx("span",{className:"mono",children:O.expiring_soon})]}),c.jsxs("div",{className:"health-row",children:[c.jsx("span",{style:{color:"var(--error)"},children:"Error Rate"}),c.jsx("span",{className:"mono",children:O.error_rate})]})]})]})]})]})}const Ed=["admin","agent","read","sources_admin","users_admin","write"];function vy(o){const D=Math.floor((Date.now()-o.getTime())/1e3);return D<60?"just now":D<3600?`${Math.floor(D/60)}m ago`:D<86400?`${Math.floor(D/3600)}h ago`:`${Math.floor(D/86400)}d ago`}function gy(){const[o,D]=K.useState([]),[O,h]=K.useState(!0),[E,N]=K.useState(!1),[C,Q]=K.useState(null),[_,b]=K.useState(!1),[H,M]=K.useState(null),[A,I]=K.useState(null);K.useEffect(()=>{L()},[]);const L=()=>{Pl.agents().then(D).catch(()=>{})};return c.jsxs(c.Fragment,{children:[c.jsxs("div",{style:{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:24},children:[c.jsx("h1",{className:"page-title",style:{marginBottom:0},children:"Agents"}),c.jsxs("div",{style:{display:"flex",gap:8,alignItems:"center"},children:[c.jsxs("label",{style:{fontSize:13,color:"var(--text-secondary)",display:"flex",alignItems:"center",gap:6,cursor:"pointer"},children:[c.jsx("input",{type:"checkbox",checked:O,onChange:nl=>h(nl.target.checked)})," Hide revoked"]}),c.jsx("button",{className:"btn btn-secondary",onClick:()=>b(!0),children:"+ API Key"}),c.jsx("button",{className:"btn btn-primary",onClick:()=>N(!0),children:"+ OAuth Client"})]})]}),(()=>{const nl=o.filter(tl=>!O||tl.status!=="revoked");return o.length===0?c.jsx("div",{style:{textAlign:"center",padding:48,color:"var(--text-muted)"},children:"No agents registered. Register your first agent to get started."}):nl.length===0?c.jsx("div",{style:{textAlign:"center",padding:48,color:"var(--text-muted)"},children:'All agents are revoked. Uncheck "Hide revoked" to view them.'}):c.jsxs(c.Fragment,{children:[c.jsxs("table",{children:[c.jsx("thead",{children:c.jsxs("tr",{children:[c.jsx("th",{children:"Name"}),c.jsx("th",{children:"Type"}),c.jsx("th",{children:"Scopes"}),c.jsx("th",{children:"Status"}),c.jsx("th",{children:"Requests"}),c.jsx("th",{children:"Last Used"})]})}),c.jsx("tbody",{children:nl.map(tl=>c.jsxs("tr",{onClick:()=>I(tl),style:{cursor:"pointer"},children:[c.jsx("td",{style:{fontWeight:500},children:tl.name||tl.client_name}),c.jsx("td",{children:c.jsx("span",{className:`badge ${tl.auth_type==="oauth"?"badge-read":"badge-write"}`,style:{fontSize:11},children:tl.auth_type==="oauth"?"OAuth":"API Key"})}),c.jsx("td",{children:(tl.scope||"").split(" ").filter(Boolean).map(bl=>c.jsx("span",{className:`badge badge-${bl}`,style:{marginRight:4},children:bl},bl))}),c.jsx("td",{children:c.jsx("span",{className:`badge ${tl.status==="active"?"badge-success":"badge-danger"}`,children:tl.status})}),c.jsxs("td",{children:[c.jsx("span",{style:{fontWeight:500},children:tl.requests_today||0}),c.jsxs("span",{style:{color:"var(--text-muted)",fontSize:12},children:[" / ",tl.total_requests||0]})]}),c.jsx("td",{style:{color:"var(--text-secondary)"},children:tl.last_used_at?vy(new Date(tl.last_used_at)):"Never"})]},tl.id))})]}),c.jsxs("div",{style:{color:"var(--text-muted)",fontSize:13,marginTop:12},children:[o.filter(tl=>tl.status==="active").length," active / ",o.length," total"]})]})})(),E&&c.jsx(by,{onClose:()=>N(!1),onRegistered:nl=>{N(!1),Q(nl),L()}}),C&&c.jsx(xy,{credentials:C,onClose:()=>Q(null)}),A&&c.jsx(jy,{agent:A,onClose:()=>I(null),onRevoked:L}),_&&c.jsx(Sy,{onClose:()=>b(!1),onCreated:nl=>{b(!1),M(nl),L()}}),H&&c.jsx(py,{token:H,onClose:()=>M(null)})]})}function Sy({onClose:o,onCreated:D}){const[O,h]=K.useState(""),[E,N]=K.useState(!1),[C,Q]=K.useState(""),_=async b=>{if(b.preventDefault(),!O.trim()){Q("Name required");return}N(!0);try{const H=await Pl.createApiKey(O.trim());D({name:H.name,token:H.token})}catch(H){Q(H instanceof Error?H.message:"Failed")}finally{N(!1)}};return c.jsx("div",{className:"modal-overlay",onClick:o,children:c.jsxs("form",{className:"modal",onClick:b=>b.stopPropagation(),onSubmit:_,children:[c.jsx("div",{className:"modal-title",children:"Create API Key"}),c.jsx("p",{style:{color:"var(--text-secondary)",fontSize:13,marginBottom:16},children:"API keys use simple bearer token auth. They grant full read+write+admin access. For scoped access, use OAuth clients instead."}),c.jsxs("div",{style:{marginBottom:16},children:[c.jsx("label",{children:"Key Name"}),c.jsx("input",{placeholder:"e.g. claude-code-local",value:O,onChange:b=>h(b.target.value),autoFocus:!0})]}),C&&c.jsx("div",{style:{color:"var(--error)",fontSize:13,marginBottom:12},children:C}),c.jsxs("div",{style:{display:"flex",gap:12,justifyContent:"flex-end"},children:[c.jsx("button",{type:"button",className:"btn btn-secondary",onClick:o,children:"Cancel"}),c.jsx("button",{type:"submit",className:"btn btn-primary",disabled:E,children:E?"Creating...":"Create Key"})]})]})})}function py({token:o,onClose:D}){const O=h=>navigator.clipboard.writeText(h);return c.jsx("div",{className:"modal-overlay",children:c.jsxs("div",{className:"modal",style:{maxWidth:560},children:[c.jsxs("div",{style:{textAlign:"center",marginBottom:16},children:[c.jsx("div",{style:{fontSize:36,color:"var(--success)",marginBottom:8},children:"✓"}),c.jsx("div",{style:{fontSize:20,fontWeight:600},children:"API Key Created"})]}),c.jsxs("div",{style:{marginBottom:12},children:[c.jsx("label",{style:{fontSize:12},children:"Name"}),c.jsx("div",{className:"code-block",children:c.jsx("span",{children:o.name})})]}),c.jsxs("div",{style:{marginBottom:12},children:[c.jsx("label",{style:{fontSize:12},children:"Bearer Token"}),c.jsxs("div",{className:"code-block",children:[c.jsx("span",{children:o.token}),c.jsx("button",{className:"copy-btn",onClick:()=>O(o.token),children:"Copy"})]})]}),c.jsxs("div",{style:{marginBottom:12},children:[c.jsx("label",{style:{fontSize:12},children:"Usage"}),c.jsxs("div",{className:"code-block",children:[c.jsx("pre",{style:{whiteSpace:"pre-wrap",margin:0,fontSize:12},children:`Authorization: Bearer ${o.token}`}),c.jsx("button",{className:"copy-btn",onClick:()=>O(`Authorization: Bearer ${o.token}`),children:"Copy"})]})]}),c.jsx("div",{className:"warning-bar",children:"Save this token now. It will not be shown again."}),c.jsx("div",{style:{display:"flex",gap:12,justifyContent:"flex-end",marginTop:20},children:c.jsx("button",{className:"btn btn-primary",onClick:D,children:"Done"})})]})})}function by({onClose:o,onRegistered:D}){const[O,h]=K.useState(""),[E,N]=K.useState(()=>Object.fromEntries(Ed.map(L=>[L,L==="read"]))),[C,Q]=K.useState("86400"),[_,b]=K.useState(!1),[H,M]=K.useState(""),A=[{label:"1 hour",value:"3600"},{label:"24 hours",value:"86400"},{label:"7 days",value:"604800"},{label:"30 days",value:"2592000"},{label:"1 year",value:"31536000"},{label:"No expiry",value:"0"}],I=async L=>{if(L.preventDefault(),!O.trim()){M("Name required");return}b(!0),M("");try{const nl=Object.entries(E).filter(([,Ml])=>Ml).map(([Ml])=>Ml).join(" "),tl=await fetch("/admin/api/register-client",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:O.trim(),scopes:nl,tokenTtl:C==="0"?31536e4:Number(C)})});if(!tl.ok)throw new Error("Registration failed");const bl=await tl.json();D({clientId:bl.clientId,clientSecret:bl.clientSecret,name:O.trim()})}catch(nl){M(nl instanceof Error?nl.message:"Registration failed")}finally{b(!1)}};return c.jsx("div",{className:"modal-overlay",onClick:o,children:c.jsxs("form",{className:"modal",onClick:L=>L.stopPropagation(),onSubmit:I,children:[c.jsx("div",{className:"modal-title",children:"Register Agent"}),c.jsxs("div",{style:{marginBottom:16},children:[c.jsx("label",{children:"Agent Name"}),c.jsx("input",{placeholder:"e.g. perplexity-production",value:O,onChange:L=>h(L.target.value),autoFocus:!0})]}),c.jsxs("div",{style:{marginBottom:16},children:[c.jsx("label",{children:"Scopes"}),c.jsx("div",{className:"checkbox-group",children:Ed.map(L=>c.jsxs("label",{className:"checkbox-label",children:[c.jsx("input",{type:"checkbox",checked:E[L],onChange:nl=>N(tl=>({...tl,[L]:nl.target.checked}))}),L]},L))})]}),c.jsxs("div",{style:{marginBottom:20},children:[c.jsx("label",{children:"Token Lifetime"}),c.jsx("select",{value:C,onChange:L=>Q(L.target.value),style:{width:"100%",background:"var(--bg-secondary)",color:"var(--text-primary)",border:"1px solid var(--border)",borderRadius:6,padding:"6px 10px",fontSize:14},children:A.map(L=>c.jsx("option",{value:L.value,children:L.label},L.value))})]}),H&&c.jsx("div",{style:{color:"var(--error)",fontSize:13,marginBottom:12},children:H}),c.jsxs("div",{style:{display:"flex",gap:12,justifyContent:"flex-end"},children:[c.jsx("button",{type:"button",className:"btn btn-secondary",onClick:o,children:"Cancel"}),c.jsx("button",{type:"submit",className:"btn btn-primary",disabled:_,children:_?"Registering...":"Register"})]})]})})}function xy({credentials:o,onClose:D}){const O=E=>navigator.clipboard.writeText(E),h=()=>{const E=new Blob([JSON.stringify(o,null,2)],{type:"application/json"}),N=URL.createObjectURL(E),C=document.createElement("a");C.href=N,C.download=`${o.name}-credentials.json`,C.click(),URL.revokeObjectURL(N)};return c.jsx("div",{className:"modal-overlay",children:c.jsxs("div",{className:"modal",style:{maxWidth:560},children:[c.jsxs("div",{style:{textAlign:"center",marginBottom:16},children:[c.jsx("div",{style:{fontSize:36,color:"var(--success)",marginBottom:8},children:"✓"}),c.jsx("div",{style:{fontSize:20,fontWeight:600},children:"Agent Registered"})]}),c.jsxs("div",{style:{marginBottom:12},children:[c.jsx("label",{style:{fontSize:12},children:"Client ID"}),c.jsxs("div",{className:"code-block",children:[c.jsx("span",{children:o.clientId}),c.jsx("button",{className:"copy-btn",onClick:()=>O(o.clientId),children:"Copy"})]})]}),c.jsxs("div",{style:{marginBottom:12},children:[c.jsx("label",{style:{fontSize:12},children:"Client Secret"}),c.jsxs("div",{className:"code-block",children:[c.jsx("span",{children:o.clientSecret}),c.jsx("button",{className:"copy-btn",onClick:()=>O(o.clientSecret),children:"Copy"})]})]}),c.jsx("div",{className:"warning-bar",children:"Save this secret now. It will not be shown again."}),c.jsxs("div",{style:{display:"flex",gap:12,justifyContent:"flex-end",marginTop:20},children:[c.jsx("button",{className:"btn btn-secondary",onClick:h,children:"Download as JSON"}),c.jsx("button",{className:"btn btn-primary",onClick:D,children:"Done"})]})]})})}function jy({agent:o,onClose:D,onRevoked:O}){const[h,E]=K.useState("claude-code"),N=M=>navigator.clipboard.writeText(M),C=window.location.origin,Q=o.id||o.client_id||"",_=o.auth_type==="oauth",b=o.name||o.client_name||"unknown",H={"claude-code":_?["The user wants to connect GBrain MCP to your context. Here's how.","","# Step 1 — read the client secret from the operator without putting it in shell history.","# read -s suppresses the echo; export keeps it scoped to this shell session.",'read -rs -p "Client Secret: " GBRAIN_CS && echo',"","# Step 2 — mint a token and register GBrain in one go.",`claude mcp add --transport http gbrain ${C}/mcp \\`,` --header "Authorization: Bearer $(curl -s -X POST ${C}/token \\`," -d 'grant_type=client_credentials' \\",` -d 'client_id=${Q}' \\`,' --data-urlencode "client_secret=$GBRAIN_CS" \\',` -d 'scope=${o.scope||"read write"}' | jq -r .access_token)"`,"","# Step 3 — clear the secret from this shell.","unset GBRAIN_CS","",`The token will last ${o.token_ttl?o.token_ttl>=86400?Math.floor(o.token_ttl/86400)+" days":Math.floor(o.token_ttl/3600)+" hours":"1 hour (default)"}.`,"","─── Fallback: 2-step curl + paste ───","","If your shell doesn't support read -s, mint the token first, then paste:","",`curl -s -X POST ${C}/token \\`," -d 'grant_type=client_credentials' \\",` -d 'client_id=${Q}' \\`," -d 'client_secret=PASTE_YOUR_CLIENT_SECRET_HERE' \\",` -d 'scope=${o.scope||"read write"}' | jq -r .access_token`,"",`claude mcp add --transport http gbrain ${C}/mcp \\`,' --header "Authorization: Bearer PASTE_TOKEN_FROM_ABOVE"',"","# Then run: history -d $((HISTCMD-1)) # zsh: print -s -- '' && fc -p"].join(` -`):["The user wants to connect GBrain MCP to your context. Here's how.","","Run this command in terminal to register GBrain:","",`claude mcp add --transport http gbrain ${C}/mcp \\`,' --header "Authorization: Bearer PASTE_YOUR_API_KEY_HERE"',"",`Replace PASTE_YOUR_API_KEY_HERE with the API key shown when "${b}" was created.`,"API keys never expire."].join(` -`),chatgpt:["The user wants to connect GBrain MCP to ChatGPT.","","1. Open ChatGPT > Settings > Tools & Integrations > Add MCP Server","2. Enter this URL — ChatGPT will auto-discover the OAuth configuration:","",` ${C}/.well-known/oauth-authorization-server`,"","3. When prompted for credentials:",` Client ID: ${Q}`," Client Secret: (the secret from agent registration)"," Grant Type: client_credentials",` Scope: ${o.scope||"read write"}`].join(` -`),"claude-cowork":["The user wants to connect GBrain MCP to Claude.ai.","","1. Open claude.ai > Settings > Connected Apps > Add MCP Server",`2. Server URL: ${C}/mcp`,"3. When prompted for auth:",` Token endpoint: ${C}/token`,` Client ID: ${Q}`," Client Secret: (the secret from agent registration)",` Scope: ${o.scope||"read write"}`,"",`Discovery URL: ${C}/.well-known/oauth-authorization-server`].join(` -`),cursor:_?["The user wants to connect GBrain MCP to Cursor.","","Cursor supports OAuth for remote MCP. Add to .cursor/mcp.json:","","{",' "mcpServers": {',' "gbrain": {',` "url": "${C}/mcp",`,' "transport": "sse"'," }"," }","}","","Cursor will auto-discover OAuth via:",`${C}/.well-known/oauth-authorization-server`,"",`When prompted: Client ID ${Q}, use the secret from registration.`].join(` -`):["The user wants to connect GBrain MCP to Cursor.","","Add to .cursor/mcp.json:","","{",' "mcpServers": {',' "gbrain": {',` "url": "${C}/mcp",`,' "transport": "sse",',' "headers": {',' "Authorization": "Bearer PASTE_YOUR_API_KEY_HERE"'," }"," }"," }","}","",`Replace PASTE_YOUR_API_KEY_HERE with the API key shown when "${b}" was created.`].join(` -`),perplexity:["The user wants to connect GBrain MCP to Perplexity.","","1. Go to Settings > Connectors > Add MCP",`2. Server URL: ${C}/mcp`,`3. Client ID: ${Q}`,"4. Client Secret: (the secret from agent registration)"].join(` -`),json:JSON.stringify({server_url:C+"/mcp",token_url:C+"/token",discovery_url:C+"/.well-known/oauth-authorization-server",client_id:Q,client_name:b,auth_type:o.auth_type,scope:o.scope},null,2)};return c.jsxs(c.Fragment,{children:[c.jsx("div",{className:"drawer-overlay",onClick:D}),c.jsxs("div",{className:"drawer",children:[c.jsx("button",{className:"drawer-close",onClick:D,children:"✕"}),c.jsx("div",{style:{fontSize:18,fontWeight:600,marginBottom:4},children:o.name||o.client_name}),c.jsx("span",{className:`badge ${o.status==="active"?"badge-success":"badge-danger"}`,children:o.status}),c.jsx("div",{className:"section-title",children:"Details"}),c.jsxs("div",{style:{display:"grid",gridTemplateColumns:"100px 1fr",gap:"6px 12px",fontSize:13},children:[c.jsx("span",{style:{color:"var(--text-secondary)"},children:"Client ID"}),c.jsxs("span",{className:"mono",children:[(o.id||o.id||o.client_id||"").substring(0,24),"..."]}),c.jsx("span",{style:{color:"var(--text-secondary)"},children:"Scopes"}),c.jsx("span",{children:(o.scope||"").split(" ").filter(Boolean).map(M=>c.jsx("span",{className:`badge badge-${M}`,style:{marginRight:4},children:M},M))}),c.jsx("span",{style:{color:"var(--text-secondary)"},children:"Registered"}),c.jsx("span",{children:new Date(o.created_at).toLocaleDateString()}),c.jsx("span",{style:{color:"var(--text-secondary)"},children:"Token TTL"}),c.jsx("span",{children:o.token_ttl?o.token_ttl>=31536e3?"No expiry":o.token_ttl>=86400?`${Math.floor(o.token_ttl/86400)}d`:o.token_ttl>=3600?`${Math.floor(o.token_ttl/3600)}h`:`${o.token_ttl}s`:"1h (default)"})]}),c.jsx("div",{className:"section-title",children:"Config Export"}),c.jsxs("div",{className:"tabs",style:{flexWrap:"wrap"},children:[c.jsx("div",{className:`tab ${h==="claude-code"?"active":""}`,onClick:()=>E("claude-code"),children:"Claude Code"}),c.jsx("div",{className:`tab ${h==="chatgpt"?"active":""}`,onClick:()=>E("chatgpt"),children:"ChatGPT"}),c.jsx("div",{className:`tab ${h==="claude-cowork"?"active":""}`,onClick:()=>E("claude-cowork"),children:"Claude.ai"}),c.jsx("div",{className:`tab ${h==="cursor"?"active":""}`,onClick:()=>E("cursor"),children:"Cursor"}),c.jsx("div",{className:`tab ${h==="perplexity"?"active":""}`,onClick:()=>E("perplexity"),children:"Perplexity"}),c.jsx("div",{className:`tab ${h==="json"?"active":""}`,onClick:()=>E("json"),children:"JSON"})]}),(()=>{if(!_&&new Set(["chatgpt","claude-cowork","perplexity"]).has(h)){const A={chatgpt:"ChatGPT","claude-cowork":"Claude.ai",perplexity:"Perplexity"}[h]||h;return c.jsxs("div",{style:{background:"rgba(255, 200, 100, 0.08)",border:"1px solid rgba(255, 200, 100, 0.2)",borderRadius:8,padding:"14px 16px",marginTop:12,fontSize:13,lineHeight:1.6,color:"var(--text-secondary)"},children:[c.jsxs("div",{style:{fontWeight:600,color:"var(--text-primary)",marginBottom:6},children:[A," requires an OAuth client"]}),A," only supports OAuth 2.0 (client_credentials). API keys use raw bearer tokens, which ",A," does not accept. Register a separate OAuth client and use that to connect this AI."]})}return c.jsxs("div",{className:"code-block",children:[c.jsx("pre",{style:{whiteSpace:"pre-wrap",margin:0},children:H[h]}),c.jsx("button",{className:"copy-btn",onClick:()=>N(H[h]),children:"Copy"})]})})(),c.jsxs("div",{style:{marginTop:32},children:[o.status==="active"&&c.jsx("button",{className:"btn btn-danger",onClick:async()=>{if(confirm(`Revoke ${o.name||o.client_name}? All active tokens will be invalidated.`))try{o.auth_type==="oauth"?await Pl.revokeClient(o.id||o.client_id||""):await Pl.revokeApiKey(o.name||""),O(),D()}catch(M){alert("Revoke failed: "+(M instanceof Error?M.message:"unknown error"))}},children:"Revoke Agent"}),o.status==="revoked"&&c.jsx("span",{style:{color:"var(--text-muted)",fontSize:13},children:"This agent has been revoked."})]})]})]})}function Ty(){const[o,D]=K.useState({rows:[],total:0,page:1,pages:1}),[O,h]=K.useState(1),[E,N]=K.useState("all"),[C,Q]=K.useState(null);K.useEffect(()=>{_(O)},[O,E]);const _=A=>{const I=E!=="all"?`&agent=${encodeURIComponent(E)}`:"";Pl.requests(A,I).then(D).catch(()=>{})},b=A=>{const I=Date.now()-new Date(A).getTime();return I<6e4?`${Math.floor(I/1e3)}s ago`:I<36e5?`${Math.floor(I/6e4)} min ago`:I<864e5?`${Math.floor(I/36e5)}h ago`:new Date(A).toLocaleDateString()},H=A=>{if(!A)return null;const{query:I,slug:L,partial:nl,limit:tl,...bl}=A,Ml=[];return I&&Ml.push(`"${I}"`),L&&Ml.push(L),nl&&Ml.push(`~${nl}`),tl&&Ml.push(`limit=${tl}`),Object.keys(bl).length>0&&Ml.push(`+${Object.keys(bl).length} params`),Ml.join(" ")},M=new Map;return o.rows.forEach(A=>{A.token_name&&M.set(A.token_name,A.agent_name||A.token_name)}),c.jsxs(c.Fragment,{children:[c.jsxs("div",{style:{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:24},children:[c.jsx("h1",{className:"page-title",style:{marginBottom:0},children:"Request Log"}),c.jsxs("select",{value:E,onChange:A=>{N(A.target.value),h(1)},style:{background:"var(--bg-secondary)",color:"var(--text-primary)",border:"1px solid var(--border)",borderRadius:6,padding:"4px 8px",fontSize:13},children:[c.jsx("option",{value:"all",children:"All agents"}),[...M.entries()].map(([A,I])=>c.jsx("option",{value:A,children:I},A))]})]}),o.rows.length===0?c.jsx("div",{style:{textAlign:"center",padding:48,color:"var(--text-muted)"},children:"No requests yet."}):c.jsxs(c.Fragment,{children:[c.jsxs("table",{children:[c.jsx("thead",{children:c.jsxs("tr",{children:[c.jsx("th",{children:"Time"}),c.jsx("th",{children:"Agent"}),c.jsx("th",{children:"Operation"}),c.jsx("th",{children:"Params"}),c.jsx("th",{children:"Latency"}),c.jsx("th",{children:"Status"})]})}),c.jsx("tbody",{children:o.rows.map(A=>c.jsxs(Dd.Fragment,{children:[c.jsxs("tr",{onClick:()=>Q(C===A.id?null:A.id),style:{cursor:"pointer"},children:[c.jsx("td",{style:{color:"var(--text-secondary)",whiteSpace:"nowrap"},children:b(A.created_at)}),c.jsx("td",{children:c.jsx("a",{style:{color:"var(--text-link, #88aaff)",cursor:"pointer",textDecoration:"none",fontWeight:500},onClick:I=>{I.stopPropagation(),N(A.token_name),h(1)},children:A.agent_name||A.token_name})}),c.jsx("td",{className:"mono",children:A.operation}),c.jsx("td",{style:{color:"var(--text-secondary)",fontSize:12,maxWidth:200,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"},children:H(A.params)}),c.jsxs("td",{className:"mono",children:[A.latency_ms,"ms"]}),c.jsx("td",{children:c.jsx("span",{className:`badge badge-${A.status}`,children:A.status})})]}),C===A.id&&c.jsx("tr",{children:c.jsx("td",{colSpan:6,style:{background:"var(--bg-secondary, #0f0f1a)",padding:16},children:c.jsxs("div",{style:{display:"grid",gridTemplateColumns:"100px 1fr",gap:"6px 12px",fontSize:13},children:[c.jsx("span",{style:{color:"var(--text-muted)"},children:"Time"}),c.jsx("span",{children:new Date(A.created_at).toLocaleString()}),c.jsx("span",{style:{color:"var(--text-muted)"},children:"Agent"}),c.jsx("span",{className:"mono",children:A.token_name}),c.jsx("span",{style:{color:"var(--text-muted)"},children:"Operation"}),c.jsx("span",{className:"mono",children:A.operation}),c.jsx("span",{style:{color:"var(--text-muted)"},children:"Latency"}),c.jsxs("span",{children:[A.latency_ms,"ms"]}),A.params&&c.jsxs(c.Fragment,{children:[c.jsx("span",{style:{color:"var(--text-muted)"},children:"Params"}),c.jsx("pre",{className:"mono",style:{margin:0,whiteSpace:"pre-wrap",fontSize:12},children:JSON.stringify(A.params,null,2)})]}),A.error_message&&c.jsxs(c.Fragment,{children:[c.jsx("span",{style:{color:"var(--error, #ff6b6b)"},children:"Error"}),c.jsx("span",{style:{color:"var(--error, #ff6b6b)"},children:A.error_message})]})]})})})]},A.id))})]}),c.jsxs("div",{className:"pagination",children:[c.jsxs("span",{children:["Page ",o.page," of ",o.pages," (",o.total," total)"]}),c.jsxs("div",{style:{display:"flex",gap:8},children:[c.jsx("button",{disabled:o.page<=1,onClick:()=>h(A=>A-1),children:"Previous"}),c.jsx("button",{disabled:o.page>=o.pages,onClick:()=>h(A=>A+1),children:"Next"})]})]})]})]})}function zy({markup:o}){return c.jsx("div",{style:{width:"100%",overflow:"auto"},dangerouslySetInnerHTML:{__html:o}})}function Zu({type:o,ariaLabel:D}){const[O,h]=K.useState(""),[E,N]=K.useState("");return K.useEffect(()=>{let C=!1;return Pl.calibrationChart(o).then(Q=>{C||h(Q)}).catch(Q=>{C||N(Q.message??"fetch failed")}),()=>{C=!0}},[o]),E?c.jsxs("div",{style:{padding:16,color:"var(--error)"},role:"alert",children:[D,": ",E]}):O?c.jsx(zy,{markup:O}):c.jsxs("div",{style:{padding:16,color:"var(--text-muted)"},children:[D," loading..."]})}function Ay(){const[o,D]=K.useState(null),[O,h]=K.useState(!0),[E,N]=K.useState("");if(K.useEffect(()=>{Pl.calibrationProfile().then(_=>{D(_),h(!1)}).catch(_=>{N(_.message??"fetch failed"),h(!1)})},[]),O)return c.jsx("div",{style:{padding:24,color:"var(--text-secondary)"},children:"Loading calibration profile…"});if(E)return c.jsxs("div",{style:{padding:24,color:"var(--error)"},role:"alert",children:["Could not load calibration profile: ",E]});if(!o)return c.jsxs("div",{style:{padding:24,maxWidth:700},children:[c.jsx("h1",{style:{marginBottom:16},children:"Calibration"}),c.jsx("p",{style:{color:"var(--text-secondary)"},children:"No calibration profile yet. Builds after 5+ resolved takes."}),c.jsx("pre",{style:{background:"var(--bg-secondary)",padding:12,borderRadius:4,color:"var(--text-primary)",marginTop:12,fontFamily:"var(--font-mono)"},children:"gbrain dream --phase calibration_profile"})]});const C=new Date(o.generated_at),Q=Math.floor((Date.now()-C.getTime())/(1e3*60*60*24));return c.jsxs("div",{style:{padding:32,maxWidth:720},children:[c.jsx("h1",{style:{marginBottom:8},children:"Calibration"}),c.jsxs("div",{style:{color:"var(--text-muted)",fontSize:13,marginBottom:24},children:["Holder: ",o.holder," · ","Updated ",Q===0?"today":`${Q}d ago`,o.published&&" · published",o.grade_completion<.9&&` · ~${Math.round(o.grade_completion*100)}% graded`,!o.voice_gate_passed&&" · voice gate fell back to template"]}),c.jsx("section",{style:{marginBottom:32},children:c.jsx(Zu,{type:"brier-trend",ariaLabel:"Brier trend"})}),c.jsxs("section",{style:{marginBottom:32},children:[c.jsx("h2",{style:{fontSize:14,color:"var(--text-secondary)",marginBottom:12,fontWeight:400},children:"Pattern statements"}),c.jsx(Zu,{type:"pattern-statements",ariaLabel:"Pattern statements"})]}),c.jsx("section",{style:{marginBottom:32},children:c.jsx(Zu,{type:"domain-bars",ariaLabel:"Per-domain accuracy"})}),c.jsx("section",{style:{marginBottom:32},children:c.jsx(Zu,{type:"abandoned-threads",ariaLabel:"Abandoned threads"})}),o.active_bias_tags.length>0&&c.jsxs("section",{style:{marginBottom:32,color:"var(--text-muted)",fontSize:13},children:["Active bias tags: ",o.active_bias_tags.join(", ")]})]})}function _y(o){return o===0?"var(--accent-success, #2ea043)":o>=100?"var(--accent-danger, #f85149)":"var(--accent-warn, #d29922)"}function Od(o){return`$${(o/100).toFixed(2)}`}function Ey(){const[o,D]=K.useState(null),[O,h]=K.useState(null);if(K.useEffect(()=>{let N=!0,C=null;const Q=async()=>{try{const _=await Pl.jobsWatch();N&&(D(_),h(null))}catch(_){N&&h(_ instanceof Error?_.message:String(_))}N&&(C=setTimeout(Q,1e3))};return Q(),()=>{N=!1,C&&clearTimeout(C)}},[]),O)return c.jsxs("div",{style:{padding:24,color:"var(--accent-danger, #f85149)"},children:[c.jsx("h2",{children:"Jobs Watch — error"}),c.jsx("pre",{style:{whiteSpace:"pre-wrap"},children:O})]});if(!o)return c.jsx("div",{style:{padding:24,color:"var(--text-muted, #777)"},children:"Loading jobs watch…"});const E=new Date(o.ts_ms).toLocaleTimeString();return c.jsxs("div",{style:{padding:24,fontFamily:'var(--font-mono, "JetBrains Mono", monospace)'},children:[c.jsxs("h1",{style:{fontSize:18,marginBottom:4},children:["Jobs Watch",c.jsxs("span",{style:{marginLeft:12,color:"var(--text-muted, #777)",fontSize:12,fontWeight:"normal"},children:["updated ",E]})]}),c.jsxs("section",{style:{marginTop:24},children:[c.jsx("h2",{style:{fontSize:14,marginBottom:8},children:"Queue"}),c.jsxs("div",{children:["waiting=",c.jsx("b",{children:o.queue_health.waiting})," ","active=",c.jsx("b",{children:o.queue_health.active})," ","stalled=",c.jsx("b",{style:{color:o.queue_health.stalled>0?"var(--accent-warn, #d29922)":void 0},children:o.queue_health.stalled})]})]}),o.by_type.length>0&&c.jsxs("section",{style:{marginTop:24},children:[c.jsx("h2",{style:{fontSize:14,marginBottom:8},children:"By type (24h)"}),c.jsxs("table",{style:{borderCollapse:"collapse"},children:[c.jsx("thead",{children:c.jsxs("tr",{style:{color:"var(--text-muted, #777)",fontSize:12},children:[c.jsx("th",{style:{textAlign:"left",padding:"4px 12px 4px 0"},children:"name"}),c.jsx("th",{style:{textAlign:"right",padding:"4px 12px"},children:"total"}),c.jsx("th",{style:{textAlign:"right",padding:"4px 12px"},children:"done"}),c.jsx("th",{style:{textAlign:"right",padding:"4px 12px"},children:"fail"}),c.jsx("th",{style:{textAlign:"right",padding:"4px 12px"},children:"dead"})]})}),c.jsx("tbody",{children:o.by_type.slice(0,6).map(N=>c.jsxs("tr",{children:[c.jsx("td",{style:{padding:"4px 12px 4px 0"},children:N.name}),c.jsx("td",{style:{textAlign:"right",padding:"4px 12px"},children:N.total}),c.jsx("td",{style:{textAlign:"right",padding:"4px 12px"},children:N.completed}),c.jsx("td",{style:{textAlign:"right",padding:"4px 12px"},children:N.failed}),c.jsx("td",{style:{textAlign:"right",padding:"4px 12px"},children:N.dead})]},N.name))})]})]}),c.jsxs("section",{style:{marginTop:24},children:[c.jsx("h2",{style:{fontSize:14,marginBottom:8},children:"Lease pressure (1h)"}),c.jsxs("div",{style:{color:_y(o.lease_pressure_1h)},children:[o.lease_pressure_1h," bounce",o.lease_pressure_1h===1?"":"s"]})]}),o.top_errors.length>0&&c.jsxs("section",{style:{marginTop:24},children:[c.jsx("h2",{style:{fontSize:14,marginBottom:8},children:"Top errors (24h)"}),c.jsx("table",{style:{borderCollapse:"collapse"},children:c.jsx("tbody",{children:o.top_errors.slice(0,5).map(N=>c.jsxs("tr",{children:[c.jsxs("td",{style:{textAlign:"right",padding:"4px 12px 4px 0",color:"var(--text-muted, #777)"},children:[N.count,"×"]}),c.jsx("td",{style:{padding:"4px 12px 4px 0"},children:N.cluster})]},N.cluster))})})]}),o.budget_owners.length>0&&c.jsxs("section",{style:{marginTop:24},children:[c.jsx("h2",{style:{fontSize:14,marginBottom:8},children:"Budget owners"}),c.jsxs("table",{style:{borderCollapse:"collapse"},children:[c.jsx("thead",{children:c.jsxs("tr",{style:{color:"var(--text-muted, #777)",fontSize:12},children:[c.jsx("th",{style:{textAlign:"left",padding:"4px 12px 4px 0"},children:"owner"}),c.jsx("th",{style:{textAlign:"right",padding:"4px 12px"},children:"spent"}),c.jsx("th",{style:{textAlign:"right",padding:"4px 12px"},children:"remaining"})]})}),c.jsx("tbody",{children:o.budget_owners.slice(0,5).map(N=>c.jsxs("tr",{children:[c.jsx("td",{style:{padding:"4px 12px 4px 0"},children:N.owner_id}),c.jsx("td",{style:{textAlign:"right",padding:"4px 12px"},children:Od(N.total_spent_cents)}),c.jsx("td",{style:{textAlign:"right",padding:"4px 12px"},children:Od(N.remaining_cents)})]},N.owner_id))})]})]})]})}function Nd(){const o=window.location.hash.replace("#","")||"dashboard";return["login","dashboard","agents","log","calibration","jobs"].includes(o)?o:"dashboard"}function Oy(){const[o,D]=K.useState(Nd);K.useEffect(()=>{const E=()=>D(Nd());return window.addEventListener("hashchange",E),()=>window.removeEventListener("hashchange",E)},[]);const O=E=>{window.location.hash=E,D(E)};if(o==="login")return c.jsx(my,{onLogin:()=>O("dashboard")});const h=async()=>{if(confirm("Sign out every active admin session, including other browsers and tabs? Each one will need to re-authenticate via a fresh magic link.")){try{await Pl.signOutEverywhere()}catch{}O("login")}};return c.jsxs("div",{className:"app",children:[c.jsxs("nav",{className:"sidebar",children:[c.jsx("div",{className:"sidebar-logo",children:"GBrain"}),c.jsxs("div",{className:"sidebar-nav",children:[c.jsx("a",{className:`nav-item ${o==="dashboard"?"active":""}`,onClick:()=>O("dashboard"),children:"Dashboard"}),c.jsx("a",{className:`nav-item ${o==="agents"?"active":""}`,onClick:()=>O("agents"),children:"Agents"}),c.jsx("a",{className:`nav-item ${o==="log"?"active":""}`,onClick:()=>O("log"),children:"Request Log"}),c.jsx("a",{className:`nav-item ${o==="calibration"?"active":""}`,onClick:()=>O("calibration"),children:"Calibration"}),c.jsx("a",{className:`nav-item ${o==="jobs"?"active":""}`,onClick:()=>O("jobs"),children:"Jobs Watch"})]}),c.jsx("div",{style:{marginTop:"auto",padding:"16px 12px",borderTop:"1px solid var(--border)"},children:c.jsx("button",{onClick:h,style:{background:"transparent",border:"1px solid var(--border)",color:"var(--text-secondary)",padding:"6px 10px",borderRadius:6,fontSize:12,cursor:"pointer",width:"100%"},title:"Revoke every active admin session — every browser, every tab",children:"Sign out everywhere"})})]}),c.jsxs("main",{className:"main",children:[o==="dashboard"&&c.jsx(yy,{}),o==="agents"&&c.jsx(gy,{}),o==="log"&&c.jsx(Ty,{}),o==="calibration"&&c.jsx(Ay,{}),o==="jobs"&&c.jsx(Ey,{})]})]})}dy.createRoot(document.getElementById("root")).render(c.jsx(Dd.StrictMode,{children:c.jsx(Oy,{})})); diff --git a/admin/dist/index.html b/admin/dist/index.html index 165106e55..b6fcad3e8 100644 --- a/admin/dist/index.html +++ b/admin/dist/index.html @@ -7,7 +7,7 @@ - + diff --git a/admin/package.json b/admin/package.json index aa1895ad3..b81271ae5 100644 --- a/admin/package.json +++ b/admin/package.json @@ -15,7 +15,11 @@ "@types/react": "^19.1.2", "@types/react-dom": "^19.1.2", "@vitejs/plugin-react": "^4.4.1", - "vite": "^6.3.3", + "vite": "^6.4.3", "typescript": "^5.8.3" + }, + "overrides": { + "@babel/core": "^7.29.6", + "postcss": "^8.5.23" } } diff --git a/admin/src/api.ts b/admin/src/api.ts index 9cf24a9ee..3c15d5859 100644 --- a/admin/src/api.ts +++ b/admin/src/api.ts @@ -39,11 +39,21 @@ export const api = { stats: () => apiFetch('/admin/api/stats'), health: () => apiFetch('/admin/api/health-indicators'), agents: () => apiFetch('/admin/api/agents'), + sources: () => apiFetch('/admin/api/sources'), requests: (page = 1, qs = '') => apiFetch(`/admin/api/requests?page=${page}${qs}`), apiKeys: () => apiFetch('/admin/api/api-keys'), - createApiKey: (name: string) => apiFetch('/admin/api/api-keys', { method: 'POST', body: JSON.stringify({ name }) }), - revokeApiKey: (name: string) => apiFetch('/admin/api/api-keys/revoke', { method: 'POST', body: JSON.stringify({ name }) }), + createApiKey(keyName: string) { + return apiFetch('/admin/api/api-keys', { method: 'POST', body: JSON.stringify({ name: keyName }) }); + }, + revokeApiKey(keyName: string) { + return apiFetch('/admin/api/api-keys/revoke', { method: 'POST', body: JSON.stringify({ name: keyName }) }); + }, updateClientTtl: (clientId: string, tokenTtl: number | null) => apiFetch('/admin/api/update-client-ttl', { method: 'POST', body: JSON.stringify({ clientId, tokenTtl }) }), + rescopeClient: (clientId: string, sourceId: string, federatedRead: string[]) => + apiFetch('/admin/api/rescope-client', { + method: 'POST', + body: JSON.stringify({ clientId, sourceId, federatedRead }), + }), revokeClient: (clientId: string) => apiFetch('/admin/api/revoke-client', { method: 'POST', body: JSON.stringify({ clientId }) }), // v0.36.1.0 (T15 / E6) — calibration endpoints. calibrationProfile: (holder?: string) => diff --git a/admin/src/pages/Agents.tsx b/admin/src/pages/Agents.tsx index c0680006c..ccde9adb1 100644 --- a/admin/src/pages/Agents.tsx +++ b/admin/src/pages/Agents.tsx @@ -18,6 +18,8 @@ interface Agent { client_name?: string; // compat grant_types: string[]; scope: string; + source_id: string | null; + federated_read: string[]; created_at: string; last_used_at: string | null; total_requests: number; @@ -26,6 +28,12 @@ interface Agent { status: 'active' | 'revoked'; } +interface Source { + id: string; + name: string; + federated: boolean; +} + interface ApiKey { id: string; name: string; @@ -36,6 +44,7 @@ interface ApiKey { export function AgentsPage() { const [agents, setAgents] = useState([]); + const [sources, setSources] = useState([]); const [hideRevoked, setHideRevoked] = useState(true); const [showRegister, setShowRegister] = useState(false); const [showCredentials, setShowCredentials] = useState<{ clientId: string; clientSecret: string; name: string } | null>(null); @@ -43,7 +52,10 @@ export function AgentsPage() { const [showApiKeyToken, setShowApiKeyToken] = useState<{ name: string; token: string } | null>(null); const [selectedAgent, setSelectedAgent] = useState(null); - useEffect(() => { loadAgents(); }, []); + useEffect(() => { + loadAgents(); + api.sources().then(setSources).catch(() => {}); + }, []); const loadAgents = () => { api.agents().then(setAgents).catch(() => {}); }; @@ -88,6 +100,7 @@ export function AgentsPage() { Name Type Scopes + Sources Status Requests Last Used @@ -108,6 +121,11 @@ export function AgentsPage() { {s} ))} + + {a.auth_type === 'oauth' + ? `${a.source_id || 'none'} · ${(a.federated_read || []).length} readable` + : 'Unscoped'} + {a.status} @@ -144,7 +162,21 @@ export function AgentsPage() { )} {selectedAgent && ( - setSelectedAgent(null)} onRevoked={loadAgents} /> + setSelectedAgent(null)} + onRevoked={loadAgents} + onRescoped={({ sourceId, federatedRead }) => { + setSelectedAgent(current => current ? { + ...current, + source_id: sourceId, + federated_read: federatedRead, + } : current); + loadAgents(); + }} + /> )} {showApiKeyCreate && ( @@ -381,7 +413,127 @@ function CredentialsModal({ credentials, onClose }: { ); } -function AgentDrawer({ agent, onClose, onRevoked }: { agent: Agent; onClose: () => void; onRevoked: () => void }) { +function SourceAccessEditor({ clientId, agent, sources, onRescoped }: { + clientId: string; + agent: Agent; + sources: Source[]; + onRescoped: (scope: { sourceId: string; federatedRead: string[] }) => void; +}) { + const [writeSource, setWriteSource] = useState(agent.source_id || 'default'); + const [readSources, setReadSources] = useState(agent.federated_read || []); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + const [saved, setSaved] = useState(false); + const readableSet = new Set(readSources); + const activeSourceIds = new Set(sources.map(source => source.id)); + const unavailableReadSources = readSources.filter(sourceId => !activeSourceIds.has(sourceId)); + const primaryUnavailable = !activeSourceIds.has(writeSource); + + const save = async () => { + if (readSources.length === 0) { + setError('Select at least one readable source.'); + return; + } + setSaving(true); + setError(''); + setSaved(false); + try { + const result = await api.rescopeClient(clientId, writeSource, readSources) as { + sourceId: string; + federatedRead: string[]; + }; + setWriteSource(result.sourceId); + setReadSources(result.federatedRead); + setSaved(true); + onRescoped(result); + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to save source access'); + } finally { + setSaving(false); + } + }; + + return ( + <> +
Source Access
+
+ The primary source is the write destination. Read access is an explicit allowlist and does not widen automatically. +
+
+ + +
+
+ Readable sources +
+ {sources.map(source => ( + + ))} + {unavailableReadSources.map(sourceId => ( + + ))} +
+
+ {(primaryUnavailable || unavailableReadSources.length > 0) && ( +
+ This client references unavailable or archived sources. Choose an active primary source and clear unavailable read grants before saving. +
+ )} + {error &&
{error}
} + {saved &&
Source access saved.
} + + + ); +} + +function AgentDrawer({ agent, sources, onClose, onRevoked, onRescoped }: { + agent: Agent; + sources: Source[]; + onClose: () => void; + onRevoked: () => void; + onRescoped: (scope: { sourceId: string; federatedRead: string[] }) => void; +}) { const [tab, setTab] = useState<'claude-code' | 'chatgpt' | 'claude-cowork' | 'perplexity' | 'cursor' | 'json'>('claude-code'); const copy = (text: string) => navigator.clipboard.writeText(text); const serverUrl = window.location.origin; @@ -553,6 +705,15 @@ function AgentDrawer({ agent, onClose, onRevoked }: { agent: Agent; onClose: () {agent.token_ttl ? (agent.token_ttl >= 31536000 ? 'No expiry' : agent.token_ttl >= 86400 ? `${Math.floor(agent.token_ttl / 86400)}d` : agent.token_ttl >= 3600 ? `${Math.floor(agent.token_ttl / 3600)}h` : `${agent.token_ttl}s`) : '1h (default)'} + {isOAuth && ( + + )} + {/* Config Export visible for both auth_type=oauth AND auth_type=api_key. Claude Code + Cursor + JSON tabs render real snippets regardless @@ -579,7 +740,11 @@ function AgentDrawer({ agent, onClose, onRevoked }: { agent: Agent; onClose: () {(() => { const oauthOnlyTabs = new Set(['chatgpt', 'claude-cowork', 'perplexity']); if (!isOAuth && oauthOnlyTabs.has(tab)) { - const clientName = { chatgpt: 'ChatGPT', 'claude-cowork': 'Claude.ai', perplexity: 'Perplexity' }[tab] || tab; + const clientName = tab === 'chatgpt' + ? 'ChatGPT' + : tab === 'claude-cowork' + ? 'Claude.ai' + : 'Perplexity'; return (
{}); api.health().then(setHealth).catch(() => {}); - const es = new EventSource('/admin/events'); + const es = new EventSource('/admin/events', { withCredentials: true }); eventSourceRef.current = es; es.onopen = () => setSseStatus('connected'); es.onmessage = (e) => { diff --git a/bun.lock b/bun.lock index 0e2ad1807..1ebc01b26 100644 --- a/bun.lock +++ b/bun.lock @@ -26,8 +26,8 @@ "express-rate-limit": "^7.5.0", "gray-matter": "^4.0.3", "heic-decode": "^2.1.0", - "js-yaml": "^3.14.2", - "marked": "^18.0.0", + "js-yaml": "^3.15.0", + "marked": "^18.0.2", "openai": "^4.0.0", "pgvector": "^0.2.0", "postgres": "^3.4.0", @@ -50,6 +50,18 @@ "trustedDependencies": [ "@electric-sql/pglite", ], + "overrides": { + "@hono/node-server": "^2.0.5", + "body-parser": "^2.3.0", + "fast-uri": "^3.1.5", + "fast-xml-builder": "^1.1.7", + "fast-xml-parser": "^5.7.0", + "form-data": "^4.0.6", + "hono": "^4.12.34", + "ip-address": "^10.3.1", + "js-yaml": "^3.15.0", + "qs": "^6.15.2", + }, "packages": { "@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.74", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.26" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Xew9rfz9WWhDSyF8rNhjT/XWOWelNfJrMlmG0Ahw210hStisRpQZ1s+7VeI9JTJOZ5y5tXqBi5kfPwYnCfyRTA=="], @@ -151,7 +163,7 @@ "@electric-sql/pglite": ["@electric-sql/pglite@0.4.3", "", {}, "sha512-ichuWTgtd4mOM1G4SpyGJa5trT03lWbMypDV0fUXUCXg5hiHqVAz/bZyV68NqmkLB7WcYmj1RMJVSp8HV/v/ZQ=="], - "@hono/node-server": ["@hono/node-server@1.19.12", "", { "peerDependencies": { "hono": "^4" } }, "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw=="], + "@hono/node-server": ["@hono/node-server@2.0.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-bjD221KPLoJTWUwso1J6fGKiTXEUFedG/s0visavY4zakFPkeGURMRNly+FhBHs7T8Dz4qHaZIMX9ZoJHSJtKA=="], "@jsquash/avif": ["@jsquash/avif@2.1.1", "", { "dependencies": { "wasm-feature-detect": "^1.2.11" } }, "sha512-LMRxd0fMgfCLtobDh0/sFYJMMiRJTNYSEEWvRDKXlAeZ08t3gI5V+1thIT0XjXJ+SVG7Zug9B0XPyx0Ti5VRNA=="], @@ -159,6 +171,8 @@ "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + "@nodable/entities": ["@nodable/entities@3.0.0", "", {}, "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw=="], + "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], "@smithy/chunked-blob-reader": ["@smithy/chunked-blob-reader@5.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-St+kVicSyayWQca+I1rGitaOEH6uKgE8IUWoYnnEX26SWdWQcL6LvMSD19Lg+vYHKdT9B2Zuu7rd3i6Wnyb/iw=="], @@ -307,11 +321,13 @@ "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + "anynum": ["anynum@1.0.1", "", {}, "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A=="], + "argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], - "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], + "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="], @@ -385,15 +401,15 @@ "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], - "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + "fast-uri": ["fast-uri@3.1.5", "", {}, "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw=="], - "fast-xml-builder": ["fast-xml-builder@1.1.4", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg=="], + "fast-xml-builder": ["fast-xml-builder@1.3.0", "", { "dependencies": { "path-expression-matcher": "^1.6.2", "xml-naming": "^0.3.0" } }, "sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ=="], - "fast-xml-parser": ["fast-xml-parser@5.5.8", "", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.2.0", "strnum": "^2.2.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ=="], + "fast-xml-parser": ["fast-xml-parser@5.10.1", "", { "dependencies": { "@nodable/entities": "^3.0.0", "fast-xml-builder": "^1.2.0", "is-unsafe": "^2.0.0", "path-expression-matcher": "^1.6.2", "strnum": "^2.4.1", "xml-naming": "^0.3.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw=="], "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], - "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], + "form-data": ["form-data@4.0.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="], "form-data-encoder": ["form-data-encoder@1.7.2", "", {}, "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="], @@ -417,11 +433,11 @@ "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], - "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], "heic-decode": ["heic-decode@2.1.0", "", { "dependencies": { "libheif-js": "^1.19.8" } }, "sha512-0fB3O3WMk38+PScbHLVp66jcNhsZ/ErtQ6u2lMYu/YxXgbBtl+oKOhGQHa4RpvE68k8IzbWkABzHnyAIjR758A=="], - "hono": ["hono@4.12.10", "", {}, "sha512-mx/p18PLy5og9ufies2GOSUqep98Td9q4i/EF6X7yJgAiIopxqdfIO3jbqsi3jRgTgw88jMDEzVKi+V2EF+27w=="], + "hono": ["hono@4.13.0", "", {}, "sha512-jhunvfHWxd7J5EFfSgH4xsYJzSe/lfqbUCxiyyeaQasUsXeEHXtzVid+7EOGByc5JnFa23SSFL3Y2RV/z1T+eQ=="], "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], @@ -431,7 +447,7 @@ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], + "ip-address": ["ip-address@10.4.0", "", {}, "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ=="], "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], @@ -439,11 +455,13 @@ "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + "is-unsafe": ["is-unsafe@2.0.0", "", {}, "sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], - "js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], + "js-yaml": ["js-yaml@3.15.0", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog=="], "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], @@ -455,7 +473,7 @@ "libheif-js": ["libheif-js@1.19.8", "", {}, "sha512-vQJWusIxO7wavpON1dusciL8Go9jsIQ+EUrckauFYAiSTjcmLAsuJh3SszLpvkwPci3JcL41ek2n+LUZGFpPIQ=="], - "marked": ["marked@18.0.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-2e7Qiv/HJSXj8rDEpgTvGKsP8yYtI9xXHKDnrftrmnrJPaFNM7VRb2YCzWaX4BP1iCJ/XPduzDJZMFoqTCcIMA=="], + "marked": ["marked@18.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-MrV5puXBfuiy6wl6DLaq3BtIJQAJToAd5zt/ZKhRfGRAuFPALE7/4Y7jnxRQoEgK/pBgurGqLyAuRgZ2xOjr6w=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], @@ -487,7 +505,7 @@ "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], - "path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="], + "path-expression-matcher": ["path-expression-matcher@1.6.2", "", {}, "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], @@ -503,7 +521,7 @@ "pure-rand": ["pure-rand@8.4.0", "", {}, "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A=="], - "qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="], + "qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="], "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], @@ -529,9 +547,9 @@ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], - "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], @@ -543,7 +561,7 @@ "strip-bom-string": ["strip-bom-string@1.0.0", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="], - "strnum": ["strnum@2.2.3", "", {}, "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg=="], + "strnum": ["strnum@2.4.1", "", { "dependencies": { "anynum": "^1.0.1" } }, "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg=="], "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], @@ -577,6 +595,8 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="], + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], @@ -595,12 +615,20 @@ "@types/bun/bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + "body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + + "body-parser/type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], + + "es-set-tostringtag/hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + "eventsource/eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], "express/cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "get-intrinsic/hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + "openai/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], "@anthropic-ai/sdk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], diff --git a/bunfig.toml b/bunfig.toml index e7a953223..3dd8ebb9a 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -13,4 +13,9 @@ timeout = 60_000 # fixtures still match the schema. v0.37's production default is ZE/1280; # tests that want the new default call configureGateway() explicitly in # their own beforeAll. -preload = ["./test/helpers/legacy-embedding-preload.ts"] +# +# #2823: redirect GBRAIN_AUDIT_DIR to a per-run scratch dir BEFORE any test +# runs, so audit-emitting code paths (content-sanity, shell-audit, etc.) +# can't leak fixture events into the operator's real ~/.gbrain/audit/. See +# test/helpers/audit-dir-preload.ts for the full rationale. +preload = ["./test/helpers/legacy-embedding-preload.ts", "./test/helpers/audit-dir-preload.ts"] diff --git a/docs/ENGINES.md b/docs/ENGINES.md index 38e35b3f7..257a6e7cd 100644 --- a/docs/ENGINES.md +++ b/docs/ENGINES.md @@ -148,6 +148,51 @@ RRF fusion, multi-query expansion, and 4-layer dedup are engine-agnostic. They o **Why not self-hosted for v0:** The brain should be infrastructure agents use, not something you maintain. Self-hosted Postgres with Docker is a welcome community PR, but v0 optimizes for zero ops. +### Opt-in RLS source-scope binding (`GBRAIN_RLS_SCOPE_BINDING`) + +Defense-in-depth layer for Postgres deployments that want the database itself +to enforce source isolation, in addition to the mandatory app-layer filters +(`sourceScopeOpts` — layer 1, always on). + +**Mechanism.** With `GBRAIN_RLS_SCOPE_BINDING=1` (or `true`), the engine's +source-scoped read methods wrap their queries in a transaction that first runs +`SELECT set_config('app.scopes', $1, true)` — the value is a bound parameter +(federated `sourceIds` CSV > scalar `sourceId` > `'*'` for unscoped internal +reads), transaction-local (equivalent to `SET LOCAL`, which itself can't take +bound params). An RLS policy can then filter rows by +`current_setting('app.scopes', true)`. + +**Default off.** With the env var unset, reads call through on the shared pool +exactly as before — no per-read transaction, no pool-slot hold (the search +methods keep the transaction they always had for their `SET LOCAL +statement_timeout`). Existing operators see zero behavior change. + +**Enabling it** (operator-managed SQL; gbrain ships no DDL for this): + +```sql +ALTER TABLE pages ENABLE ROW LEVEL SECURITY; +CREATE POLICY pages_scope_filter ON pages + USING (current_setting('app.scopes', true) = '*' + OR source_id = ANY(string_to_array(current_setting('app.scopes', true), ','))); + +-- Required: connections that don't run through the scoped read helper +-- (admin, autopilot, cycle, writes) must default to unscoped, or they +-- see zero rows once the policy exists: +ALTER ROLE SET app.scopes = '*'; + +-- If the runtime role OWNS the table, RLS is skipped for it unless forced: +ALTER TABLE pages FORCE ROW LEVEL SECURITY; +``` + +Safe to enable in either order: the env var without a policy is a no-op +setting; a policy without the env var is enforced only via the role default. + +**Honest caveat:** only read paths routed through the scoped helper carry a +per-request scope binding — unwrapped paths (writes, admin/maintenance reads) +run under the role default and are not backstopped per caller. This is layer 2; +the app-layer source filters remain layer 1 and stay mandatory. Behavioral pins +live in `test/postgres-engine-rls-scope.test.ts`. + ## PGLiteEngine (v0.7, ships) **Dependencies:** `@electric-sql/pglite` (v0.4.4+) diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 388eeeb19..9236ecf6d 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -39,10 +39,11 @@ gbrain migrate --to pglite # Postgres → PGLite (rare) For shared / large / multi-machine deployments (a team or company brain with multiple users hitting one server over HTTP MCP with OAuth scoping per user), follow the dedicated walkthrough: **[Tutorial: set up GBrain as your company brain](tutorials/company-brain.md)**. -API keys live in `~/.gbrain/config.json` (file plane) or env vars (`OPENAI_API_KEY`, `ZEROENTROPY_API_KEY`, `VOYAGE_API_KEY`, `ANTHROPIC_API_KEY`). Set via CLI: +API keys live in `~/.gbrain/config.json` (file plane) or env vars (`OPENAI_API_KEY`, `OPENROUTER_API_KEY`, `ZEROENTROPY_API_KEY`, `VOYAGE_API_KEY`, `ANTHROPIC_API_KEY`). Set via CLI: ```bash gbrain config set zeroentropy_api_key sk-... +gbrain config set openrouter_api_key sk-or-... gbrain config set anthropic_api_key sk-ant-... ``` @@ -112,3 +113,38 @@ gbrain models doctor # 1-token probe per configured model ``` If anything's yellow, `gbrain doctor` names the fix command in the message. Most issues are missing API keys or stale schema (`gbrain upgrade --force-schema`). + +## Troubleshooting + +### PGLite crashes on macOS 26.x (Tahoe) + +PGLite's embedded WASM engine is incompatible with macOS 26.x (Tahoe) on Apple Silicon. If `gbrain init --pglite` crashes during engine initialization, switch to native Homebrew PostgreSQL: + +```bash +# Install PostgreSQL + pgvector +brew install postgresql@17 +brew services start postgresql@17 +createdb gbrain + +# Build pgvector from source (required for vector search) +cd /tmp && git clone --branch v0.8.0 https://github.com/pgvector/pgvector.git +cd pgvector && make && make install +psql gbrain -c "CREATE EXTENSION IF NOT EXISTS vector;" + +# Point gbrain at your local Postgres +cat > ~/.gbrain/config.json << 'EOF' +{ + "engine": "postgres", + "database_url": "postgresql://localhost:5432/gbrain", + "schema_pack": "gbrain-base-v2" +} +EOF + +# Run migrations and verify +gbrain apply-migrations --yes +gbrain doctor +``` + +All 102 migrations run on first try. Once `gbrain doctor` shows green, the brain works identically to PGLite — same commands, same skills, same data model. The only difference is the storage backend. + +> **Note:** This workaround is temporary. When the upstream WASM runtime fix ships (likely via a Bun update), `--pglite` will work on Tahoe again. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 3cff19ddc..2168f47ad 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -362,6 +362,39 @@ done If any SHA differs from what's in the workflow files, update the pin and version comment. +## GitHub releases (binary assets + self-update) — #3521 + +`.github/workflows/release.yml` publishes a GitHub release automatically for +**every VERSION bump that lands on master** (trigger: push to master touching +`VERSION`, plus `workflow_dispatch` for a manual first run or repair). No +manual tag push is part of the ship flow — the workflow reads `VERSION` (the +single source of truth), mints tag `v` at the pushed commit, titles +the release the same, uses that version's `CHANGELOG.md` entry as the notes +(`scripts/changelog-entry.sh`; falls back to a CHANGELOG link if the entry is +missing), and attaches the compiled binaries. + +Why every bump, not selective: `gbrain check-update` resolves the latest +version from `VERSION` on master, while binary self-update +(`src/core/binary-self-update.ts`) downloads assets from `releases/latest`. +Any release that lags `VERSION` tells binary installs an upgrade exists that +self-update cannot apply. `releases/latest` must track `VERSION`. + +Invariants: + +- **Asset names are a contract.** The build matrix's `artifact:` names must + equal what `expectedAssetName()` in `src/core/binary-self-update.ts` + returns (`gbrain-darwin-arm64`, `gbrain-linux-x64` today). Adding a + platform means updating BOTH plus the version job's completeness check; + `test/release-workflow.test.ts` pins all of it. +- **Idempotent + self-repairing.** The version job skips when a release for + `v` already exists with all expected assets; a partial release + (tag but no release, or missing assets) is completed on re-run. Racing + master pushes queue via the `release` concurrency group — a skipped + intermediate version is fine, latest is what matters. +- **Historical tags are never rewritten.** Old 3-segment versions keep their + history; every new 4-segment `VERSION` mints a fresh tag. +- **Permissions stay scoped.** `contents: write` lives on the release job + only; everything else runs read-only. ## PR descriptions cover the whole branch diff --git a/docs/TESTING.md b/docs/TESTING.md index 93b33252c..e97f36a46 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -3,6 +3,8 @@ On-demand reference (see CLAUDE.md Reference map). Current behavior + invariants only. +`test/e2e/serve-http-oauth.test.ts` additionally pins confidential POST/Basic revocation, public-client SDK fallthrough, malformed/mixed authentication rejection, cross-client isolation, unknown-token opacity, metadata auth methods, no-store responses, strict post-revoke `401`, and retryable backend `503` semantics. + ### Test command tiers Seven test command tiers, each with a clear scope: @@ -17,6 +19,32 @@ Seven test command tiers, each with a clear scope: | `bun run test:e2e` | Real Postgres E2E. Requires Docker + `DATABASE_URL`. Sequential. | ~5-10min | Pre-ship; nightly. | | `bun run check:all` | The historical pre-check scripts (22, chained sequentially in package.json). Overlaps `verify` heavily but is NOT a superset — `verify`'s `CHECKS` array in `scripts/run-verify-parallel.sh` (~30 entries incl. typecheck) is the authoritative gate; `check:all` keeps a few local-only extras (trailing-newline, exports-count, no-legacy-getconnection). | ~10s | Local-only sweep for the extras. | +### Shell dispatch and Windows + +All four of `test`, `verify`, `ci:local` and `test:e2e` hand off to shell scripts +under `scripts/`, so every `check:*` entry in `package.json` invokes its script as +`bash scripts/.sh` instead of relying on the shebang — bun on Windows cannot +exec a `.sh` directly. Add a new shell-script check with that same prefix. The +`scripts/*.ts` entries run under bun and take no prefix. + +The scripts must also be on disk with Unix line endings. A strict bash (WSL, Linux +CI, macOS) rejects CRLF and dies on the script's first meaningful line; the Cygwin +bash that ships with Git for Windows tolerates it, so a green local run is not by +itself evidence that a script is CRLF-clean. +The root `.gitattributes` pins `*.sh text eol=lf`, which overrides the +`core.autocrlf=true` default that Git for Windows installs. It pins `*.md` the +same way, because the frontmatter readers anchor on a `---` fence followed by a +Unix line ending and a CRLF checkout makes a document parse as having no +frontmatter, silently. Working copies cloned +before those pins need a one-time `git rm --cached -r . -q && git reset --hard` to +pick them up; see the Windows section of `CONTRIBUTING.md`. + +Wallclock figures in the table above are from a Mac dev box. Windows is +substantially slower because each check pays full process-creation cost, and three +tree-walking checks (`check:privacy`, `check:test-names`, `check:test-isolation`) +plus `typecheck` can exceed the 120s per-check cap in `run-verify-parallel.sh` +there even though they pass on Linux and macOS. + ### CI vs local: intentionally divergent file sets - **CI matrix** (`.github/workflows/test.yml`) runs `scripts/test-shard.sh` across 10 matrix shards partitioned by weight-aware LPT bin-packing (`scripts/sharding.ts`) and INCLUDES `*.slow.test.ts` (the two outlier slow files run as dedicated jobs alongside the matrix). CI EXCLUDES `*.serial.test.ts` from the shards and runs them in a dedicated job via `bun run test:serial`, one bun process per file — keeping serial files out of the shard processes is what preserves the `mock.module` quarantine (a top-level mock in one file leaks into every other file sharing its process). `bun run verify` gets its own job too. CI is the ground truth for "did everything pass." @@ -44,6 +72,15 @@ If a shard wedges (per-shard `GBRAIN_TEST_SHARD_TIMEOUT` cap, default 600s), the - `tests/heavy/*.sh` → ops-shape shell scripts. Cost minutes per run; NOT in default `bun test`. Run via `bun run test:heavy` or scheduled nightly via `.github/workflows/heavy-tests.yml`. Examples: pg_upgrade matrix (boot legacy brain → walk to head), RSS budget gate (measure peak worker RSS vs committed baseline), read-latency-under-sync (p50/p95/p99 under concurrent writer load), sync lock regression (N concurrent syncs assert 1 winner + N-1 lock-busy + zero leaked `gbrain_cycle_locks` rows). See `tests/heavy/README.md` for when to add a script here vs `*.slow.test.ts`. Files prefixed with `_` (e.g. `tests/heavy/_build_legacy_fixtures.sh`) are helpers/libs invoked by sibling tests — the runner skips them. - `test/fuzz/*.test.ts` → property-based fuzz harness. Pure-validator targets in `pure-validators.test.ts` are guarded by `scripts/check-fuzz-purity.sh` (in `bun run verify`), which `bun build --target=bun` bundles each target and greps the resulting bundle for banned transitive imports (`node:fs`, `node:child_process`, engine modules). Anything that fails the guard moves to `mixed-validators.test.ts` (still property-tested, but no purity guarantee) or `filesystem-validators.test.ts` (fs-backed, uses temp dirs). Fuzz tests run in the default `bun test` loop because they're fast (~3s for ~12 properties × 1000 runs each). +### Skills-manifest freshness guard + +`skills/skills.lock.json` is a committed sha256 inventory of every bundled file under +`skills/` (tamper evidence, not signatures — see `src/core/skills-integrity.ts`). +Any change under `skills/` must regenerate it: `bun run scripts/generate-skills-manifest.ts`. +`scripts/check-skills-manifest-fresh.sh` (`bun run check:skills-manifest`, wired into +`bun run verify`) regenerates to a tmp file and diffs, failing CI on drift; at runtime +`gbrain doctor` reports the same drift as a warn-only `skills_manifest_integrity` check. + ### Test-isolation lint and helpers The cross-file flake class is enforced statically by `scripts/check-test-isolation.sh`, wired into `bun run verify` and `bun run check:all`. Rules (non-serial unit files only; `*.serial.test.ts` and `test/e2e/*` are skipped): @@ -187,8 +224,10 @@ Unit tests and what they cover: - `test/orphans.test.ts` — orphans command: detection, pseudo filtering, text/json/count outputs, MCP op. - `test/postgres-engine.test.ts` — `statement_timeout` scoping: `sql.begin` + `SET LOCAL` shape, source-level grep guardrail against a reintroduced bare `SET statement_timeout`. - `test/sync.test.ts` — sync logic + regression guard asserting top-level `engine.transaction` is not called. +- `test/sync-pull-failed-anchor.serial.test.ts` — #3068 regression: a failed internal `git pull` (local-path origin vs `protocol.file.allow=never`) with zero imports returns `partial`/`pull_failed` (not `up_to_date`), freezes `last_commit` + `last_sync_at`, recovers after a manual pull; fall-through import of local commits preserved. Serial: pins `GBRAIN_HOME` to a temp dir for the whole file. - `test/sync-concurrency.test.ts` — `autoConcurrency()` thresholds + PGLite-forces-serial + explicit-override clamping; `shouldRunParallel()` explicit-bypasses-floor contract; `parseWorkers()` validation rejecting `'0'`/`'-3'`/`'foo'`/`'1.5'`/trailing chars. - `test/sync-parallel.test.ts` — PGLite-routed coverage of the bookmark gate under concurrency, head-drift gate, vanished-file failure capture, PGLite-stays-serial, and the `gbrain-sync` writer-lock contract. +- `test/sync-all-missing-path.test.ts` — `sync --all --missing-path ` pure helpers: `parseMissingPathMode` (default fail, explicit values, loud rejection of bad/dangling values, never swallows a following flag) and `partitionMissingPathSources` (classification driven only by the injected pathExists predicate — no fs; null `local_path` passes through runnable; order preserved). - `test/sync-failures.test.ts` — `classifyErrorCode` regex coverage for all 12 codes against literal production message strings from `markdown.ts` and `import-file.ts`; `summarizeFailuresByCode` sort + pre-classified-honor; `recordSyncFailures` code-field persistence; `acknowledgeSyncFailures` `AcknowledgeResult` shape + backfill on legacy entries. - `test/doctor.test.ts` — doctor command; assertions that `jsonb_integrity` scans the four JSONB write sites and `markdown_body_completeness` is present. - `test/utils.test.ts` — shared SQL utilities + `tryParseEmbedding` null-return and single-warn semantics. @@ -239,8 +278,11 @@ E2E tests live in `test/e2e/` and run against real Postgres+pgvector (require `D - `test/e2e/http-transport.test.ts` — `gbrain serve --http` end-to-end against real Postgres: bearer auth round-trip, `last_used_at` SQL-level debounce, `mcp_request_log` row insertion on success and auth_failed paths, `/health` DB-down → 503 (DB-probing health check), and the dispatch round-trip with a real operation. Skips without `DATABASE_URL`. - `test/e2e/serve-http-oauth.test.ts` — real-Postgres E2E against `gbrain serve --http` with full OAuth 2.1. Spawns a subprocess server, registers a client via the CLI, mints `client_credentials` tokens, exercises the `/mcp` JSON-RPC pipeline. Real DCR `/register` HTTP-level response-shape test (asserts `typeof body.client_id_issued_at === 'number'` over the wire, RFC 7591 §3.2.1); real CLI subprocess test for `revoke-client` (registers → mints token → revokes via `execSync` → asserts token rejected at `/mcp` → asserts re-run exits 1); server fixture flips on `--enable-dcr` so `/register` is reachable. **bun execSync env-inheritance contract:** bun's `execSync` does NOT inherit env mutations done via `process.env.X = ...`, only OS-level env from before bun started. helpers.ts loads `.env.testing` and sets `DATABASE_URL` via `process.env` mutation, which is invisible to subprocesses unless `env: { ...process.env }` is passed explicitly — every subprocess call in this file passes `env: { ...process.env }`. Reference fix for the same failure mode in sibling sync/cycle/dream/claw-test E2Es. `afterAll` cleanup is guarded on `clientId` (won't throw if `beforeAll` failed before registration); cleanup errors surface to stderr without throwing so real test failures aren't masked. Also covers the trust-boundary fix: an HTTP MCP `submit_job` for `name: "shell"` MUST reject with a permission error (request handler sets `remote: true` and `submit_job`'s protected-name guard fires), and the same guard rejects subagent submission. Skips without `DATABASE_URL`. - `test/e2e/sync-parallel.test.ts` — `DATABASE_URL`-gated. 60-file Postgres sync at concurrency=4 imports all + no connection leak (probes `pg_stat_activity` before/after to confirm worker engines disconnected). 120-file serial-vs-parallel benchmark prints `SYNC_PARALLEL_BENCH N files | serial=Xms | parallel(4)=Yms | speedup=Zx`. Asserts parallel ≤ serial × 1.5 (CI-noise tolerant; not a strict speedup gate). -- `test/e2e/multi-source-bug-class.test.ts` — PGLite in-memory regression suite pinning every multi-source bug site: `listAllPageRefs` ordering by `(source_id, slug)`, `getPage` with sourceId picks the right `(source, slug)` row, `extract-takes` processes both overlapping `people/alice` rows independently, `listPages` filters correctly with `PageFilters.sourceId`, `addLinksBatch` with `from/to_source_id` targets the right rows, `validateSourceId` rejects path traversal, reverse-write disk layout uses `brainDir/.sources//.md` for non-default sources. No `DATABASE_URL` needed. Wired into `scripts/e2e-test-map.ts` so changes to extract-takes / patterns / synthesize / embed / extract / migrate-engine auto-trigger it. +- `test/e2e/multi-source-bug-class.test.ts` — PGLite in-memory regression suite pinning every multi-source bug site: `listAllPageRefs` ordering by `(source_id, slug)`, `getPage` with sourceId picks the right `(source, slug)` row, `extract-takes` processes both overlapping `people/alice` rows independently, `listPages` filters correctly with `PageFilters.sourceId`, `addLinksBatch` with `from/to_source_id` targets the right rows, `validateSourceId` rejects path traversal, reverse-write disk layout uses `brainDir/.sources//.md` for non-default sources, `copyMigrationSources` lands source metadata before overlapping-slug pages. No `DATABASE_URL` needed. Wired into `scripts/e2e-test-map.ts` so changes to extract-takes / patterns / synthesize / embed / extract / migrate-engine auto-trigger it. +- `test/e2e/migrate-engine-sources-postgres.test.ts` — `DATABASE_URL`-gated companion for `gbrain migrate --to`: migrates a PGLite brain carrying two non-default sources with overlapping slugs into real Postgres and asserts `copyMigrationSources` created every `sources` FK parent (config JSONB intact, not double-encoded) before any page write. Unit-level manifest identity (crash manifest resumes only against the SAME target; legacy engine-only manifests start fresh) is `test/migrate-engine-resume.test.ts`. +- `test/e2e/facts-fence-reconcile-postgres.test.ts` — `DATABASE_URL`-gated round-trip for the escape-aware fence parser: renders a `## Facts` fence whose cells carry literal pipes, backslashes (Windows paths), and empty cells via `renderFactsTable`, runs the wipe-and-reinsert reconcile (`runExtractFacts`) on real Postgres, and asserts every cell survives byte-identically with no column shift. - `test/e2e/source-isolation-pglite.test.ts` — PGLite in-memory regression suite pinning the source-isolation seal at two layers. Engine layer: `searchKeyword` / `searchVector` / `searchKeywordChunks` / `listPages` / `getPage` / `traverseGraph` / `traversePaths` apply `sourceId` (scalar fast path) and `sourceIds` (array path) correctly across both engines. Op-handler layer: routes through `sourceScopeOpts(ctx)` so a `read+write`-scoped OAuth client bound to `--source dept-x` cannot see rows from neighboring sources via `search`, `query`, `list_pages`, `get_page`, or `find_experts`. Covers both `ctx.sourceId` (single-source clients) and `ctx.auth.allowedSources` (federated_read clients) precedence; federated array wins over scalar wins over nothing. No `DATABASE_URL` needed. +- `test/e2e/think-source-isolation-pglite.test.ts` — PGLite in-memory suite pinning the `think` gather stage's source scope: seeds three sources with cross-source links and embedded takes, then asserts `runGather` under a federated `sourceIds` grant (and under a scalar `sourceId`) keeps every stream — hybrid retrieval, takes keyword + vector (`searchTakes`/`searchTakesVector`), and the `traversePaths` graph walk — inside the grant while still reaching authorized neighboring sources. No `DATABASE_URL` needed. - `test/e2e/skill-brain-first.test.ts` — doctor reports `skill_brain_first` check with structured issues; `--fix --dry-run` previews insertion without writing; `--fix` applies the canonical Convention callout idempotently; `brain_first: exempt` frontmatter resolves the warn; `brain_first_typo` surfaces a paste-ready hint; audit JSONL records `detected` / `resolved` / `fixed` transitions; stable brain emits 0 audit lines/run. - Tier 2 (`test/e2e/skills.test.ts`) requires OpenClaw + API keys, runs nightly in CI. - If `.env.testing` doesn't exist in this directory, check sibling worktrees: `find ../ -maxdepth 2 -name .env.testing -print -quit` and copy it here if found. diff --git a/docs/architecture/KEY_FILES.md b/docs/architecture/KEY_FILES.md index 29901b633..8d79913f6 100644 --- a/docs/architecture/KEY_FILES.md +++ b/docs/architecture/KEY_FILES.md @@ -8,7 +8,11 @@ lives in `CHANGELOG.md` + `git log` / `git blame`, NOT here. Do not append per-release `**vX.Y.Z:**` narration — CI enforces this (`scripts/check-key-files-current-state.sh`). -- `src/core/operations.ts` — Contract-first operation definitions (the foundation). Exports upload validators `validateUploadPath`, `validatePageSlug`, `validateFilename`, plus `matchesSlugAllowList(slug, prefixes)` (glob matcher: `/*` matches recursive children; bare `` matches exact only). `OperationContext.remote` is a REQUIRED field flagging untrusted callers; `OperationContext.allowedSlugPrefixes` is the trusted-workspace allow-list set by the dream cycle; `OperationContext.auth?: AuthInfo` is threaded through HTTP dispatch for scope enforcement in `serve-http.ts` before the op runs. `put_page` enforces: when `viaSubagent` and `allowedSlugPrefixes` is set, slug must match the allow-list; else the legacy `wiki/agents//...` namespace check applies. Auto-link skipped only when `remote=true && !trustedWorkspace`. Every `Operation` carries `scope?: 'read' | 'write' | 'admin'` + `localOnly?: boolean`; `sync_brain`, `file_upload`, `file_list`, `file_url` are `admin + localOnly` (rejected over HTTP). Four trust-boundary call sites (`put_page` allowlist, file_upload trust-narrowing, submit_job protected-name guard, auto-link skip) use FAIL-CLOSED semantics: `ctx.remote === false` for trusted-only sites, `ctx.remote !== false` for "untrust unless explicit-false" — anything not strictly `false` is treated as remote (closes the HTTP MCP shell-job RCE where a read+write OAuth token could submit `shell` jobs). `sourceScopeOpts(ctx)` encodes the source-scoped read precedence ladder — federated array (`ctx.auth.allowedSources`) wins over scalar (`ctx.sourceId`/`ctx.auth.sourceId`) over nothing; every read-side op handler routes through it so a source-bound OAuth client can't see neighboring sources via `search`/`query`/`list_pages`/`get_page`/`find_experts`/query's image path, plus the by-slug reads `get_tags`/`get_links`/`get_backlinks`/`get_timeline` (and `get_page`'s tag fetch, which resolves against the concrete page's own `source_id`). `linkReadScopeOpts(ctx)` is the link-read sibling for `get_links`/`get_backlinks`: a link row references three pages (from/to/origin), and the engine's federated (`sourceIds[]`) branch scopes ALL THREE while its scalar (`sourceId`) branch scopes only the near endpoint (by design — trusted internal callers like `reconcileLinks` and back-link validators call the engine with a scalar scope and need the cross-source view). For an UNTRUSTED remote caller (`ctx.remote !== false`) carrying only a scalar scope it promotes that scope to a single-element `sourceIds:[id]`, routing them through the all-endpoint branch so a far/origin slug from outside the grant isn't disclosed; a federated array passes through unchanged and trusted local CLI (`ctx.remote === false`) keeps the scalar cross-source view. `put_page`'s inline disk write-through is the shared `writePageThrough` helper (`src/core/write-through.ts`), ATOMIC via temp-sibling + rename so a crash or concurrent `gbrain sync` can't read a half-written `.md`; same helper backs `gbrain brainstorm/lsd --save`. Link provenance surface (#1941): `add_link` (`gbrain link`/`link-add`) + `remove_link` (`gbrain unlink`/`link-rm`) expose `link_source`/`link_type`; `add_link` rejects the reconciliation-managed built-ins via `MANAGED_LINK_SOURCES` (`markdown`/`frontmatter`/`mentions`/`wikilink-resolved`) and defaults omitted provenance to `'manual'` (the engine's own default stays `'markdown'` for internal callers); `list_link_sources` (`gbrain link-sources`, read) lists provenances via `sourceScopeOpts`. CLI aliases register through `cliHints.aliases` (collision-guarded in `src/cli.ts`). +- `docs/operations/conversation-parser-llm-fallback.md` — operator and maintainer contract for the default-off LLM parse fallback: exact config key, deterministic-first dispatch boundary, sampled data surface, untrusted-content prompt handling, page-date/cache-key coupling, timestamp validation, cache/checkpoint behavior, observability, limitations, and focused test commands. + +- `src/commands/serve-http.ts` confidential revoke extension — a pre-router `/revoke` handler validates the RFC 7009 body, verifies hash-only secrets for both `client_secret_post` and `client_secret_basic`, rejects mixed authentication, preserves the SDK path for public clients, and separates opaque client-auth failures from retryable/backend failures. OAuth metadata advertises both confidential methods. Pinned by `test/e2e/serve-http-oauth.test.ts`. + +- `src/core/operations.ts` — Contract-first operation definitions (the foundation). Exports upload validators `validateUploadPath`, `validatePageSlug`, `validateFilename`, plus `matchesSlugAllowList(slug, prefixes)` (glob matcher: `/*` matches recursive children; bare `` matches exact only). `OperationContext.remote` is a REQUIRED field flagging untrusted callers; `OperationContext.allowedSlugPrefixes` is the trusted-workspace allow-list set by the dream cycle; `OperationContext.auth?: AuthInfo` is threaded through HTTP dispatch for scope enforcement in `serve-http.ts` before the op runs. OAuth `whoami` exposes the authenticated `AuthInfo.sourceId` and `AuthInfo.allowedSources` grants as `source_id` and `federated_read`; absent grants serialize fail-closed as `null` and `[]`, while local, legacy, and stdio response shapes stay unchanged. `enforceSubagentSlugFence(ctx, slug, opName)` is the shared fail-closed subagent write fence: when `viaSubagent` and `allowedSlugPrefixes` is set, the slug must match the allow-list; else the legacy `wiki/agents//...` namespace check applies. Both `put_page` and `add_timeline_entry` (subagent-allowlisted) route through it. Auto-link skipped only when `remote=true && !trustedWorkspace`. `enforceClientSlugFence(ctx, slug, opName)` is the OAuth-client write fence: when `ctx.auth.boundSlugPrefixes` is present (threaded from `oauth_clients.bound_slug_prefixes` at token-verification time), every direct slug-mutating write op — `put_page`, `delete_page`, `restore_page`, `add_tag`, `remove_tag`, `add_link`/`remove_link` (`from` endpoint only; linking TO a readable page is a reference), `add_timeline_entry`, `revert_version`, `put_raw_data` — rejects out-of-prefix slugs with `permission_denied`, BEFORE each op's dry-run short-circuit. Plain-startsWith semantics matching `submit_agent`'s check for the same column (NOT the glob grammar of the subagent allow-list); empty-array binding is deny-all (fail-closed); no auth / no binding = no fence. The match rule itself lives in the exported `slugUnderBoundPrefixes(prefixes, slug)` so non-op write surfaces reuse it verbatim. It is BOUNDARY-AWARE (a prefix matches whole segments, so `emp-alice` does not admit `emp-alice-2/…`), lowercases both sides (stored slugs are lowercased by `validateSlug`, so comparing the caller's raw string let a mixed-case slug commit and only then trip the resolved-slug re-check), accepts BOTH the trailing-slash and the v85 `/*` glob spelling via `normalizeSlugPrefix` (the column predates this fence as submit_agent's binding, so one stored value must mean one span of slugs on both paths), and ignores empty-string prefixes. `assertValidSlugPrefixes` (`oauth-provider.ts`) rejects empty, whitespace-bearing, non-lowercase, and boundary-less entries at registration and rescope. `submit_agent` applies the same boundary-aware rule when validating a requested prefix against the binding, normalizes trailing-slash prefixes to the glob form `matchesSlugAllowList` expects before handing them to the child job, and collapses an EXPLICIT empty `allowed_tools`/`allowed_slug_prefixes` to the binding (the worker reads empty as "full registry" / "legacy `wiki/agents//` namespace", so `??` — which only substitutes null/undefined — left a vacuous-subset bypass). `put_page` additionally fences the RESOLVED slug when importFromContent's dedup pre-check redirects the write to a different page (same content_hash / `frontmatter.id`), since the disk write-through runs against that slug. That re-check applies whichever confinement the CALLER is under — OAuth binding and/or subagent allow-list/legacy namespace — via `slugOutsideCallerFence(ctx, slug)`, which composes `slugUnderBoundPrefixes` with the subagent fence's own match rule: the delegated `submit_agent` → subagent context carries `viaSubagent` + `allowedSlugPrefixes` but NO `auth`, so an auth-only test let a slug-bound client holding `agent` scope reach an out-of-fence page simply by delegating the write. Denials never name the resolved slug (it would be a slug-enumeration oracle). Pinned by `test/put-page-dedup-fence.test.ts`. `CLIENT_FENCED_WRITE_OPS` + `enforceBoundClientOpAllowList(auth, op)` are the fail-closed companion, applied once in `src/mcp/dispatch.ts` (the choke point both MCP transports share): a slug-bound client calling ANY `write`/`admin` op not on the allow-list gets `permission_denied`. This covers the ops that write by a key other than a slug and therefore cannot be fenced — `extract_entities`/`extract_facts` (mutate `people/*`, `companies/*`), `forget_fact` (numeric fact id, crosses sources), `ontology_propose` — and makes a write op added later denied-by-default instead of silently unfenced. `think` is on the allow-list because remote callers cannot persist from it. Pinned by `test/client-slug-fence.test.ts` and over-the-wire by `test/e2e/qm-provisioning.test.ts`. Every `Operation` carries `scope?: 'read' | 'write' | 'admin'` + `localOnly?: boolean`; `sync_brain`, `file_upload`, `file_list`, `file_url` are `admin + localOnly` (rejected over HTTP). Four trust-boundary call sites (`put_page` allowlist, file_upload trust-narrowing, submit_job protected-name guard, auto-link skip) use FAIL-CLOSED semantics: `ctx.remote === false` for trusted-only sites, `ctx.remote !== false` for "untrust unless explicit-false" — anything not strictly `false` is treated as remote (closes the HTTP MCP shell-job RCE where a read+write OAuth token could submit `shell` jobs). `sourceScopeOpts(ctx)` encodes the source-scoped read precedence ladder — federated array (`ctx.auth.allowedSources`) wins over scalar (`ctx.sourceId`/`ctx.auth.sourceId`) over nothing; every read-side op handler routes through it so a source-bound OAuth client can't see neighboring sources via `search`/`query`/`list_pages`/`get_page`/`find_experts`/query's image path, plus the by-slug reads `get_tags`/`get_links`/`get_backlinks`/`get_timeline` (and `get_page`'s tag fetch, which resolves against the concrete page's own `source_id`). `linkReadScopeOpts(ctx)` is the link-read sibling for `get_links`/`get_backlinks`: a link row references three pages (from/to/origin), and the engine's federated (`sourceIds[]`) branch scopes ALL THREE while its scalar (`sourceId`) branch scopes only the near endpoint (by design — trusted internal callers like `reconcileLinks` and back-link validators call the engine with a scalar scope and need the cross-source view). For an UNTRUSTED remote caller (`ctx.remote !== false`) carrying only a scalar scope it promotes that scope to a single-element `sourceIds:[id]`, routing them through the all-endpoint branch so a far/origin slug from outside the grant isn't disclosed; a federated array passes through unchanged and trusted local CLI (`ctx.remote === false`) keeps the scalar cross-source view. `thinkSourceScopeOpts(ctx)` maps the same precedence ladder onto `runThink`'s public options (`allowedSources`/`sourceId`) so the `think` op's gather and trajectory stages inherit the caller's source grant. `put_page`'s inline disk write-through is the shared `writePageThrough` helper (`src/core/write-through.ts`), ATOMIC via temp-sibling + rename so a crash or concurrent `gbrain sync` can't read a half-written `.md`; same helper backs `gbrain brainstorm/lsd --save`. Link provenance surface (#1941): `add_link` (`gbrain link`/`link-add`) + `remove_link` (`gbrain unlink`/`link-rm`) expose `link_source`/`link_type`; `add_link` rejects the reconciliation-managed built-ins via `MANAGED_LINK_SOURCES` (`markdown`/`frontmatter`/`mentions`/`wikilink-resolved`) and defaults omitted provenance to `'manual'` (the engine's own default stays `'markdown'` for internal callers); `list_link_sources` (`gbrain link-sources`, read) lists provenances via `sourceScopeOpts`. CLI aliases register through `cliHints.aliases` (collision-guarded in `src/cli.ts`). - `src/core/engine.ts` — Pluggable engine interface (BrainEngine). `clampSearchLimit(limit, default, cap)` takes an explicit cap so per-operation caps can be tighter than `MAX_SEARCH_LIMIT`. Exports `LinkBatchInput`/`TimelineBatchInput` for the bulk-insert API (`addLinksBatch`/`addTimelineEntriesBatch`). `readonly kind: 'postgres' | 'pglite'` discriminator lets `src/core/migrate.ts` and others branch without `instanceof` + dynamic imports. Methods: `batchLoadEmotionalInputs(slugs?)` (CTE-shaped read with per-table aggregates so page × N tags × M takes never produces N×M rows), `setEmotionalWeightBatch(rows)` (`UPDATE FROM unnest($1::text[],$2::text[],$3::real[])` composite-keyed on `(slug, source_id)`), `getRecentSalience(opts)`, `findAnomalies(opts)`. `PageFilters` has `sort?: 'updated_desc'|'updated_asc'|'created_desc'|'slug'` + `PAGE_SORT_SQL` whitelist consumed by both engines. `listAllPageRefs(): Promise>` ordered by `(source_id, slug)` — cheap cross-source enumeration replacing the `getAllSlugs()→getPage(slug)` N+1 (which silently defaulted to `source_id='default'`); parity across postgres-engine.ts + pglite-engine.ts; Pinned by `test/e2e/multi-source-bug-class.test.ts`. `SearchOpts`+`PageFilters` add `sourceIds?: string[]` (federated read axis; both engines apply `WHERE source_id = ANY($N::text[])` when set, preserve scalar `sourceId` fast path when unset); `traverseGraph(slug, depth, opts?)` and `traversePaths(slug, opts?)` accept `opts.sourceId`/`opts.sourceIds`. The by-slug read methods carry the same federated axis: `getTags`/`getLinks`/`getBacklinks` opts and `TimelineOpts` (consumed by `getTimeline`) accept `sourceIds?: string[]` taking precedence over the scalar `sourceId` (`source_id = ANY($::text[])` scoping the slug→page-id lookup); the link reads (`getLinks`/`getBacklinks`) scope ALL THREE endpoints (from/to/origin) on the federated branch while the scalar branch scopes only the near endpoint for trusted internal cross-source callers. `traverseGraph` opts has `frontierCap?: number` (per-iteration recursive-CTE cap, approx per-BFS-layer); return type `Promise` for MCP wire stability; export `TraverseGraphOpts`; Postgres uses parenthesized `LIMIT N ORDER BY (slug, id)` inside the recursive term, PGLite mirrors with positional params; Pinned by `test/regressions/v0_36_frontier_cap.test.ts`. Phantom-redirect methods: `refreshPageBody(slug, sourceId, compiled_truth, timeline, content_hash)` narrow-UPDATEs three columns + updated_at, skipping soft-deleted rows (content_hash refresh required so `gbrain sync` sees the canonical as unchanged after fence merge); `migrateFactsToCanonical(phantomSlug, canonicalSlug, sourceId)` UPDATEs `entity_slug`+`source_markdown_slug` on every active fact row keyed on the phantom, preserving embedding/validUntil/kind/status/source_session/confidence; parity at `test/phantom-redirect-engine-parity.test.ts`. `getAdjacencyBoosts(pageIds): Promise>` powers the per-query graph-signals stage — one SQL query returning inbound-link counts among top-K plus a cross-source count (links from differing `source_id`); `COALESCE(p.source_id,'default')` null safety, `HAVING >= 1`, cross-source CASE-WHEN excludes the target's own source; parity SQL across both engines; `SearchResult` gains optional `base_score`, `backlink_boost`, `salience_boost`, `recency_boost`, `exact_match_boost`, `graph_adjacency_boost`, `graph_cross_source_boost`, `session_demote_factor`, `reranker_delta` + internal staging fields; Pinned by `test/e2e/graph-signals-engine.test.ts`. Two REQUIRED methods: `deletePages(slugs, {sourceId}): Promise` (single-batch primitive returning slugs actually deleted) and `resolveSlugsByPaths(paths, {sourceId}): Promise>` (batch path→slug lookup); `sourceId` REQUIRED on both at the type level (asymmetric with single-row `deletePage` which keeps optional/'default'); both short-circuit on empty input and throw when `> DELETE_BATCH_SIZE`. Embedding-signature stale-detection quartet: `countStaleChunks(opts?)` gains optional `signature?: string` widening the stale predicate from `embedding IS NULL` to ALSO include chunks whose JOINed page `embedding_signature IS NOT NULL AND <> $signature` (NULL signature is GRANDFATHERED, never counted; omit `signature` for the legacy NULL-only count); `sumStaleChunkChars(opts?: {sourceId?, signature?}): Promise` = `SUM(LENGTH(chunk_text))` over stale chunks (same predicate + embed_skip filter + optional sourceId scope), used by `gbrain sync --all` cost preview via `estimateCostFromChars`; `setPageEmbeddingSignature(slug, {sourceId?, signature})` stamps `pages.embedding_signature` after a page's chunks (re)embed, idempotent no-op when page absent; `invalidateStaleSignatureEmbeddings({signature, sourceId?}): Promise` NULLs `embedding`+`embedded_at` on every chunk whose page signature is set AND differs, returning the count, called BEFORE `listStaleChunks` so signature-drift pages flow through the NULL-embedding keyset cursor unchanged (NULL never invalidated). Widens `findOrphanPages(opts?: {sourceId?, sourceIds?})` (candidate-side scoping only; inbound links counted from any source). Pinned by `test/sum-stale-chunk-chars.test.ts`, `test/embedding-signature-stale.test.ts`, `test/e2e/engine-parity.test.ts`. Free-text alias layer: `resolveAliases(aliasNorms, opts?): Promise>>` (READ; maps each normalized alias to declaring `(slug, source_id)` pairs, source-scoped) and `setPageAliases(slug, sourceId, aliasNorms)` (WRITE; replaces the full alias set, delete-then-insert, empty clears, idempotent on the unique triple), called by the `importFromContent` ingest projection and the `reindex --aliases` backfill; parity across both engines, Pinned by `test/search/page-aliases-engine.test.ts`. `searchVector` in both engines injects the shared `buildBestPerPagePoolCte` per-page max-pool so a page surfaces on its strongest chunk. `executeRawDirect(sql, params?, opts?)` is the lock-hot-path sibling of `executeRaw`: same single-statement contract, but routes to the direct session-mode pool when dual-pool is active (Postgres/Supabase port 5432) so a long-held lock heartbeat survives the transaction pooler's per-transaction connection recycling; PGLite delegates straight to `executeRaw` (no pooler). Both engines implement it; the Minion lock path (`claim`/`renewLock`) is the consumer. `reconnect(ctx?: {error?})` is a REQUIRED lifecycle method on BOTH engines: it recovers a dropped connection using the config captured at the last `connect()`, so callers (autopilot health probe, `batchRetry`) never `disconnect()` + bare `connect()` (which loses the config and throws `database_url undefined` forever, and opens a null-connection window). PostgresEngine rebuilds its pool with a `_reconnecting` reentrancy guard and emits pool-recovery audit; PGLite is single-writer in-process so it just restores the saved data dir for parity. - `src/core/engine-constants.ts` — single source of truth for engine batch-sizing constants. Exports `DELETE_BATCH_SIZE = 500` consumed by both engines' `deletePages` + `resolveSlugsByPaths` and by the sync delete + rename loops. Lives outside `engine.ts` (the interface module) to avoid circular-import worry — bounded per-statement work for predictable lock hold time + write amplification. - `src/core/background-work.ts` (#1762/#1745/#1775) — process background-work registry: the single owner of "drain every fire-and-forget DB-write sink before the CLI disconnects." `registerBackgroundWorkDrainer({name, order, drain(timeoutMs), abort?})` + `drainAllBackgroundWorkForCliExit({timeoutMs})` over a `Map` (idempotent registration by name; `__registerDrainerForTest` returns an unregister handle). Drains in explicit `(order, name)` order — facts FIRST (order 0) so its abort-path DB `logIngest` runs against the freshest live engine — and AWAITS `abort()` only when `drain()` reports `unfinished>0`. Best-effort per drainer: one sink's failure never blocks the others or the disconnect. FIVE sinks register at module import: `facts/queue.ts` (order 0; `abort`=`shutdown()` cancels a hung facts:absorb Haiku via internalAbort), `last-retrieved.ts` (order 1), `search/hybrid.ts` (order 2; `awaitPendingSearchCacheWrites` bounded via `Promise.race`), `eval-capture.ts` (order 3; `captureEvalCandidate` self-tracks its promise via `awaitPendingEvalCaptures`), `context/volunteer-events.ts` (order 4, #2095 — batched volunteer-event INSERTs, drained like the rest). Every cli.ts teardown site reaches it through `finishCliTeardown` (`src/core/cli-force-exit.ts`), which drains the registry before `engine.disconnect()` — closing the PGLite busy-loop where `db.close()` raced an in-flight job and pinned the single-writer lock (#1762). Exports `backgroundWorkSinkCount()` so the teardown helper computes its backstop deadline from the registered sink count. CLI-EXIT-ONLY: the facts `shutdown()` abort is permanent process state, never call in a long-lived `gbrain serve`. Companion changes: `src/core/ai/gateway.ts` `withDefaultTimeout(caller, ms)` bounds every outbound AI call (chat 300s, embed+multimodal 60s; env `GBRAIN_AI_{CHAT,EMBED,MULTIMODAL}_TIMEOUT_MS`; composed with caller signals via `AbortSignal.any`) and the teardown backstop honors an errored op's exit code so a hung disconnect can't mask failure as success (see `cli-force-exit.ts`); `src/core/postgres-engine.ts` `reconnect()` module-mode branch re-establishes via idempotent `db.connect()` + `connectionManager.setReadPool` refresh instead of `db.disconnect()` (no null window for concurrent ops; fail-loud on real connect failure — #1745); `src/core/search/hybrid.ts` `embedQueryBounded` + a shared `QueryEmbedDeadline` (6s, floored 2s per embed via `MIN_QUERY_EMBED_BUDGET_MS`; env `GBRAIN_QUERY_EMBED_TIMEOUT_MS`) bounds the cache-lookup AND inner query embeds so a stalled provider falls back to keyword instead of stalling the whole op (#1775). Incorporates + hardens PR #1763 (@ElliotDrel). Pinned by `test/core/background-work.test.ts`, `test/search/query-embed-deadline.test.ts`, `test/eval-capture-drain.test.ts`, `test/e2e/postgres-reconnect-singleton.test.ts`, `test/e2e/pglite-cli-exit.serial.test.ts`, `test/fix-wave-structural.test.ts`. @@ -19,38 +23,41 @@ per-release `**vX.Y.Z:**` narration — CI enforces this - `src/core/context-engine.ts` + `src/openclaw-context-engine.ts` — the deterministic context engine OpenClaw loads on every turn (`assemble()` injects the Live Context block, zero-LLM). `createGBrainContextEngine({workspaceDir, resolveEntities?})` accepts an OPTIONAL host-injected resolver (`ENGINE_API_VERSION` 0.2.0, additive — older hosts work unchanged; the plugin entry maps `ctx.resolveEntities`/`ctx.brainQuery` onto it). `assemble()` runs the Retrieval Reflex after the Live Context block: extracts the current turn's user text, builds prior-context text (every message EXCEPT the current turn — suppression must not see the triggering mention), passes the rolling window (`getWindowTurns`, last 12 user/assistant turns; the reflex slices to its configured `retrieval_reflex_window_turns`), and appends the pointer block. `warmReflex()` fires at construction. - `src/core/context/` — Retrieval Reflex (Layer 1, issue #1981). `entity-salience.ts`: pure, zero-LLM, precision-biased `extractCandidates(text)` (capitalized runs + `@handles`, STOPWORDS + soft COMMON_WORDS + sentence-start guard, deterministic, capped) + `extractCandidatesFromWindow(turns)` (#2095: merges per-turn extraction across the last N turns by normalizeAlias form with occurrence/newest-turn/user-mention metadata; salience-ordered — recency > frequency > user-role — so the cap drops stale assistant chatter first). `retrieval-reflex.ts`: `resolveEntitiesToPointers(engine, sourceId, candidates, opts)` — alias arm (`resolveAliases`, caught per-arm for pre-v110 brains) + exact title/slug-suffix arm (the recall fix: real slugs are namespaced `people/x` but `slugify` drops the prefix); pointers carry `source_id`/`arm`/`confidence`/`matchedNorm` (#2095 — `ARM_CONFIDENCE` alias 0.9 / title 0.8 / slug-suffix 0.6 lives next to the arm definitions; arm-2 provenance classified in JS since the combined OR can't report which predicate matched); opts: `sourceIds?` federated scope (alias arm loops per source, arm 2 uses `source_id = ANY`), `suppression?` ('slug-and-title' legacy default; 'slug-only' REQUIRED under windowing — the title rule would suppress every entity merely mentioned in a prior window turn), ambient-channel event logging is DELIVERY-side, not in-resolver — `logDeliveredReflexPointers(engine, pointers)` fires only once a block is actually handed to the consumer (serve's resolve-IPC `onDelivered` hook post-write; `buildReflexAddition` post-timeout on the direct rung), so abandoned/timed-out blocks never pollute the volunteered-vs-used stats; synopsis runs through `stripTakesFence`/`stripFactsFence` (the same privacy boundary `get_page` applies) so private facts never reach the prompt; capped at `MAX_POINTERS`. `reflex.ts`: the orchestrator + engine-aware resolver ladder (host `resolveEntities` → PGLite serve IPC → Postgres cached process-singleton → disabled), zero-candidate fast path, fail-open + timeout, heartbeat write for the doctor check, `reflexEnabled(cfg)` (file/env gate, default ON; DB-plane does NOT gate — `assemble()` is sync); windowed extraction when `windowTurns` present and `retrieval_reflex_window_turns` (default 4; 1 = exact legacy behavior) > 1 — switches suppression to slug-only; accept-side reflex-channel logging fires after the per-turn timeout admits the block (direct-Postgres rung only — IPC logs server-side at delivery; host-injected resolvers are a documented gap). `resolve-ipc.ts`: local unix-socket resolve protocol (client + server) so PGLite resolves through the single connection `gbrain serve` holds (a second opener would hit the exclusive lock; a subprocess would force-steal it past the 5-min staleness window and crash). Wired into `src/mcp/server.ts` (serve binds `/.gbrain-resolve.sock` on PGLite, cleaned up on shutdown). Doctor surface: `retrieval_reflex_health` in `src/commands/doctor.ts` (reads the heartbeat for truthful runtime status; categorized in `doctor-categories.ts`). Config: `retrieval_reflex` + `retrieval_reflex_max_pointers` + `retrieval_reflex_window_turns` in `src/core/config.ts` (env `GBRAIN_RETRIEVAL_REFLEX_WINDOW_TURNS`). `volunteer.ts` (#2095): `parseWindow` (lenient `user:`/`assistant:` prefixes, unprefixed → one user turn), `volunteerContext` (extract → resolve → +0.05 multi-turn/newest-turn boost → `min_confidence` 0.7 gate → cap 3/5; deterministic rationale strings, never raw conversation text; slug-only suppression), `volunteerUsageStats` (per-arm/channel precision from the `pages.last_retrieved_at > volunteered_at` join — APPROXIMATE: the 5-min last-retrieved throttle causes false negatives, unrelated reads false positives). `volunteer-events.ts` (#2095): `insertVolunteerEvents` (ONE multi-row parameterized INSERT), `logVolunteerEventsFireAndForget` + bounded drain registered as the `volunteer-events` background-work sink (order 4), `purgeStaleVolunteerEvents` (90-day GC, called from the dream cycle's purge phase). Policy layer ships as the `retrieval-reflex` recipe (`recipes/retrieval-reflex/`). Pinned by `test/context/entity-salience.test.ts`, `test/retrieval-reflex.test.ts`, `test/context/resolve-ipc.test.ts`, `test/doctor-retrieval-reflex.test.ts`, `test/volunteer-context.test.ts`, `test/e2e/volunteer-context-postgres.test.ts`. - `src/commands/watch.ts` — `gbrain watch` (#2095): the push transport. Reads turns from stdin as they arrive (`user:`/`assistant:` prefixes; unprefixed = user turn), keeps a rolling in-process window (`--window-turns`, default 4), calls `volunteerContext` per turn, streams pointers to stdout (`--json` for JSONL with turn attribution), logs `channel: 'watch'` events with a per-session id. Session dedupe feeds already-pushed slugs back as priorContext so the core's slug-only suppression dedupes. Blocks in the stdin iteration (interactive alive until Ctrl-C/Ctrl-D; piped ends at EOF) — deliberately NOT in DAEMON_COMMANDS; SIGINT closes the stream so teardown flows through finishCliTeardown. Per-turn resolution failures are fail-open. Registered in CLI_ONLY + CLI_ONLY_SELF_HELP + THIN_CLIENT_REFUSED_COMMANDS (thin clients use the `volunteer_context` MCP op). Pinned by `test/watch-command.test.ts`. -- `src/commands/integrations.ts` — recipe install. The resolver-row install fence is keyed by `manifest.recipe` (`gbrain::resolver-rows`), so a second `copy-into-host-repo` recipe no longer writes a block mislabeled with the first recipe's name. Pinned by `test/integrations-install.test.ts`. +- `src/commands/integrations.ts` — recipe install. The resolver-row install fence is keyed by `manifest.recipe` (`gbrain::resolver-rows`), so a second `copy-into-host-repo` recipe no longer writes a block mislabeled with the first recipe's name. Pinned by `test/integrations-install.test.ts`. Health-check DSL includes the staleness-aware `heartbeat_max_age` type (#2787): declares the sense's expected cadence (`max_age: 48h`), and `integrations doctor` FAILS when the newest heartbeat event is older — the only check type that catches a green-but-dead sense (all others are point-in-time). Not embedded-gated (reads only the local heartbeat file). Recipe frontmatter carries `output_paths` (repo-relative dirs the collector writes, e.g. calendar-to-brain → `daily/calendar/`); `getConfiguredCollectorOutputs()` surfaces them for the #2788 db_only-collision check/warning. Pinned by `test/integrations-heartbeat-max-age.test.ts`. - `src/core/audit/audit-writer.ts` — shared JSONL audit primitive consolidating the hand-rolled audit modules. Exports `createAuditWriter({kind, recordSchema})` returning `{log, readRecent}` plus shared helpers `computeIsoWeekFilename(kind, now?)` and `resolveAuditDir()` (honors `GBRAIN_AUDIT_DIR`). ISO-week file rotation; best-effort writes (stderr warn on failure, never throws); read-path scans current-week + previous-week files for boundary spans. Refactored onto it for parity: `src/core/rerank-audit.ts`, `src/core/audit-slug-fallback.ts`, `src/core/minions/handlers/shell-audit.ts`, `src/core/minions/handlers/supervisor-audit.ts`, `src/core/facts/phantom-audit.ts` (each module's public API preserved bit-for-bit). The `graph-signals-failures` audit (`logGraphSignalsFailure`) uses the same primitive. One hand-rolled audit remains at `src/core/skillpack/audit.ts`. Pinned by `test/audit/audit-writer.test.ts`. - `src/core/cli-force-exit.ts` (#2084) — single owner of one-shot CLI exit + teardown, designed as a PAIR with the `import.meta.main` seam at the bottom of `src/cli.ts`. `finishCliTeardown({engine, drainTimeoutMs?})` is teardown-ONLY (never exits on the clean path): arms a REF'D backstop (unref'd would let a hung teardown exit naturally, skipping the flush and exiting with whatever PGLite scribbled into `process.exitCode`) whose deadline is COMPUTED from the bounds it guards (`computeTeardownDeadlineMs` = sinks × drainTimeoutMs + facts-abort grace + 2 × pool-end bound + slack, floor 10s; `GBRAIN_TEARDOWN_DEADLINE_MS` env override is the incident escape hatch), drains every background-work sink, disconnects the engine (a throw is warned + swallowed — the exit code reports the OPERATION, not the cleanup), then returns. The exit VERDICT lives in a gbrain-owned channel (`setCliExitVerdict`/`currentExitCode`; mirror-writes `process.exitCode` but NEVER reads it back) because PGLite's Emscripten runtime scribbles its own status into `process.exitCode` at arbitrary points mid-run — every writer that means to set the CLI exit code (op-dispatch catch, reindex, frontmatter, transcripts, brainstorm, autopilot, doctor's FAIL verdict, extract, and cli.ts's swept inner exits — friction, claw-test, smoke-test, the no-DB eval runners, status/status-thin, whoknows-thin) calls `setCliExitVerdict`; `test/cli-exit-verdict-pin.test.ts` greps src/ so the next raw `process.exitCode =` write fails CI instead of silently reporting success on failure. The deadline arms at TEARDOWN start, never before the op handler (the pre-#2084 placement measured handler + teardown combined, so PgBouncer deployments paid a flat 10s force-exit tax on every query and any >10s op was killed mid-run with exit 0). All nine cli.ts disconnect sites route through it; the ONE process exit happens in cli.ts's `main().then/catch` via `flushThenExit(currentExitCode())`, gated by `shouldForceExitAfterMain()` (daemon list: `serve`) — the CLI never waits for Bun's event loop to drain, because `endPoolBounded` deliberately races past stuck PgBouncer sockets that would keep it alive. `flushThenExit(code)` fences stdout+stderr (`write('', cb)` raced with an unref'd guard, EPIPE-safe both sync and async) then holds a REF'D aliveness grace for non-TTY stdio before `process.exit` — Bun delivers queued pipe writes only while the process is alive (no flush API reaches `process.stdout`'s native queue; write callbacks fire on accept, not delivery), so the grace IS the flush (#1959 truncation class). Scope claim is deliberately cli.ts-only: command modules' mid-run engine lifecycles stay local (process-exit semantics inside them would be wrong) and are absorbed by the final explicit exit. Pinned by `test/cli-finish-teardown.test.ts`, `test/flush-then-exit-harness.test.ts` (real spawned-Bun pipe semantics), `test/cli-should-force-exit.test.ts`, `test/cli-pipe-truncation.test.ts` (real-CLI piped --tools-json byte-stable), `test/cli-exit-verdict-pin.test.ts`, the `#2084` describes in `test/fix-wave-structural.test.ts` + `test/e2e/pglite-cli-exit.serial.test.ts`, and `test/e2e/pgbouncer-teardown.test.ts` (CI transaction-mode pooler — the #1972/#2015/#2084 class, finally reproducible in CI). - `src/core/cli-options.ts` extension — `CliOptions` gains `explain: boolean`. `parseGlobalFlags` recognizes `--explain` anywhere in argv (stripped before command dispatch). `src/cli.ts` `formatResult` for `search` + `query` cases routes to `formatResultsExplain` from `src/core/search/explain-formatter.ts` when `CliOptions.explain` is set; falls through to the existing JSON / human formatters otherwise. - `src/commands/search.ts:gbrain search stats` extension — `graph_signals` section (enabled/source/failures_count/failures_by_reason). JSON envelope adds a `graph_signals` sibling property; `_meta.metric_glossary` adds `graph_signals.enabled` + `graph_signals.failures_by_reason`. Human output prints the section after the existing block. Reads `search.graph_signals` config first, falls back to the mode default. Pinned by `test/search/search-stats-graph-signals.test.ts`. +- `src/commands/doctor.ts` extension — silent-failure batch (#2250/#2784/#2788): `content_hash_duplicates` (single GROUP BY over `(source_id, content_hash)` with FILTER aggregates — never N² — flagging hash groups that hold BOTH a bare and a path-prefixed slug, the wrong-import-root pattern; warn carries sample pairs + the `pages delete` → `purge-deleted --older-than 0` remediation); `undeclared_db_only_pages` (per source with a local repo: markdown pages with no backing file outside every declared + derive-phase-default db_only prefix — the one check deliberately allowed to stat the repo); `db_only_collector_collision` (configured recipe `output_paths` inside a declared db_only dir — auto-gitignore means sync AND import silently skip the collector's files; same warning fires in sync's `manageGitignore` at config-write time). All warn-level, engine-parity pinned by `test/e2e/doctor-silent-death-parity.test.ts`; units in `test/doctor-silent-death-checks.test.ts`. - `src/commands/doctor.ts` extension — `graph_signals_coverage` check wired into both `runDoctor` (local) and `doctorReportRemote` (HTTP/JSON thin-client path). Reads `search.graph_signals` config first, falls back to mode default; silent `ok` when disabled. Computes inbound link coverage on the page set; warns at <10% with `gbrain extract all` fix hint; `ok` at ≥30% ("fire on most queries") and 10-29% ("fire occasionally"), each with the percentage embedded. Pinned by cases in `test/doctor.test.ts`. - `src/core/engine-factory.ts` — Engine factory with dynamic imports (`'pglite'` | `'postgres'`). -- `src/core/pglite-engine.ts` — PGLite (embedded Postgres 17.5 via WASM) implementation, all BrainEngine methods. `listLinkSources({sourceId?, sourceIds?})` returns distinct `link_source` provenances + counts (`ORDER BY count DESC, link_source ASC NULLS LAST`; scalar + federated scoped; parity with postgres-engine.ts) powering `gbrain link-sources`. `addLinksBatch`/`addTimelineEntriesBatch`/`addTakesBatch` pass the whole batch as one JSONB document via `jsonb_to_recordset(($1::jsonb)->'rows')` (bound through `executeRawJsonb` with a `{ rows }` wrapper; rows built by the shared `src/core/batch-rows.ts` helpers, NUL-stripped), and are `batchRetry`-wrapped. `connect()` wraps `PGlite.create()` in a try/catch that emits an actionable error (macOS 26.3 WASM bug #223, points at `gbrain doctor`); the lock is released on failure so the next process can retry cleanly. `searchKeyword`/`searchKeywordChunks` multiply `ts_rank` by the source-factor CASE at chunk grain; `searchVector` is a two-stage CTE — inner CTE keeps `ORDER BY cc.embedding <=> vec` so HNSW stays usable, outer SELECT re-ranks by `raw_score * source_factor`, inner LIMIT scales with offset to preserve pagination. `initSchema()` calls `applyForwardReferenceBootstrap()` BEFORE replaying SCHEMA_SQL — probes for forward-referenced state the embedded blob needs (`pages.source_id`, `links.link_source`, `links.origin_page_id`, `content_chunks.symbol_name`, `content_chunks.language`, `sources` FK target, plus `files.source_id`, `files.page_id`, `oauth_clients.source_id`, `oauth_clients.federated_read`, `sources.archived`, `sources.archived_at`, `sources.archive_expires_at`) and adds only what's missing; threads the DDL connection from `initSchema` so probes run inside the advisory-lock scope; no-op on fresh installs and modern brains (closes the upgrade-wedge bug class #239/#243/#266/#357/#366/#374/#375/#378/#395/#396/#1018/#974/#820). `getBrainScore` returns 100/100 with full breakdown (35/25/15/15/10) when `pageCount === 0` (vacuous truth — empty brain has no coverage problem); Pinned by `test/brain-score-breakdown.test.ts` empty-brain assertion + `test/doctor-report-remote.serial.test.ts`. `disconnect()` uses snapshot+early-null (snapshot `_db`/`_lock`, null instance fields BEFORE any `await` so a concurrent `connect()` can't see a partial mid-close state) wrapped in try/finally guaranteeing lock-release even if `db.close()` throws; KEEPS close-then-release order (release-then-close was rejected: it would widen the window where a sibling process connects to a still-closing brain); Pinned by `test/pglite-engine-disconnect.serial.test.ts`. `PGlite.create()` runs inside `preservingProcessExitCode` (#2084): PGLite's Emscripten runtime writes its own status into `process.exitCode` (99 at create; in-memory brains run initdb whose status lands on a later tick; the exit status at close — and assigning `undefined` cannot clear a prior value), which would otherwise silently clobber an errored op's exit 1 back to 0. The wrapper keeps the global tidy for external readers; `db.close()` stays unwrapped (its 0-write is baseline behavior test runners depend on). The CLI's exit verdict is immune either way — it lives in the gbrain-owned channel in `cli-force-exit.ts` and never reads `process.exitCode` back. Exports `classifyPgliteInitError(message): 'bunfs' | 'macos-26-3' | 'unknown'` + `buildPgliteInitErrorMessage(verdict, original)` routing the catch-block hint by failure shape (`bunfs` matches literal `$$bunfs` OR `ENOENT[\s\S]*pglite\.data` co-occurrence, surfaces a paste-ready `bun upgrade` + Node fallback; `macos-26-3` keeps the #223 link; `unknown` falls through); Pinned by `test/pglite-init-classifier.test.ts`. Implements `deletePages(slugs, {sourceId})` + `resolveSlugsByPaths(paths, {sourceId})` via `slug = ANY($1::text[])` array-param binding, caller-chunking primitive throwing when input exceeds `DELETE_BATCH_SIZE`, `deletePages` returns `RETURNING slug` rows so callers filter `pagesAffected` to confirmed deletes. Implements the embedding-signature stale-detection quartet — `sumStaleChunkChars({sourceId?, signature?})`, `setPageEmbeddingSignature(slug, {sourceId?, signature})`, `invalidateStaleSignatureEmbeddings({signature, sourceId?})`, widened `countStaleChunks({sourceId?, signature?})` (the `signature` opt widens via `JOIN pages p ... WHERE cc.embedding IS NULL OR (p.embedding_signature IS NOT NULL AND p.embedding_signature <> $signature)`, NULL grandfathered); parity SQL with postgres-engine.ts. PGLite-specific DDL (pgvector, pg_trgm, triggers). -- `src/core/pglite-lock.ts` — advisory data-dir lock so two processes never open the same embedded PGLite (single-connection WASM Postgres) at once. Atomic `mkdir` of `.gbrain-lock/` + a `lock` file carrying `{pid, acquired_at, refreshed_at}`. A held lock HEARTBEATS its `refreshed_at` every 30s (`.unref()`ed timer; informational). A waiting acquirer reaps a holder ONLY when its PID is dead — a LIVE holder is NEVER stolen, regardless of how stale its heartbeat is (#2348). The heartbeat runs on the JS event loop, which is blocked during long synchronous WASM imports/CHECKPOINTs, so a working `dream`/`embed` holder can look stale while alive; the old steal-on-stale-heartbeat grace let a second OS process open the same data dir and corrupt the catalog + pgvector extension (58P01 / `internal_load_library` / `type "vector" does not exist`), recoverable only by wipe+restore. A wedged-but-alive or PID-reused holder now makes the acquire TIME OUT with a message naming the PID (the user removes the lock explicitly) rather than risk corruption. Each holder carries an ownership token (`:`); the heartbeat and `releaseLock` verify the on-disk lock is STILL theirs before touching it. In-memory engines take no lock. Pinned by `test/pglite-lock.test.ts`. A corrupted store surfaces a `reinit-pglite` recovery hint via `classifyPgliteInitError`'s `corrupt` verdict in `pglite-engine.ts`. -- `src/core/postgres-engine.ts` — Postgres + pgvector implementation (Supabase / self-hosted). `addLinksBatch`/`addTimelineEntriesBatch`/`addTakesBatch` pass the batch as one JSONB document — `INSERT ... SELECT FROM jsonb_to_recordset(($1::jsonb)->'rows') AS v(...) JOIN pages ...` bound through `executeRawJsonb({ rows })` — which encodes arbitrary free text safely (the old `unnest(${arr}::text[])` array-literal path crashed Postgres with "malformed array literal" on calendar/Zoom context, gbrain#1861) and sidesteps the 65535-parameter cap; takes declares native recordset column types (`page_id int, weight real, active boolean, …`) so no per-element casts; all three are `batchRetry`-wrapped. `searchKeyword`/`searchVector` scope `statement_timeout` via `sql.begin` + `SET LOCAL` so the GUC dies with the transaction instead of leaking across the pooled postgres.js connection. `getEmbeddingsByChunkIds` uses `tryParseEmbedding` so one corrupt row skips+warns instead of killing the query. `searchKeyword`/`searchKeywordChunks`/`searchVector` apply source-aware ranking by inlining the source-factor CASE and `NOT (col LIKE …)` hard-exclude from `src/core/search/sql-ranking.ts`; `searchVector` is a two-stage CTE (HNSW-safe inner ORDER BY, source-boost re-rank in outer SELECT) carrying `p.source_id` inner→outer. `_savedConfig` retains the connect config; `reconnect()` tears down + recreates the pool from saved config (called by supervisor watchdog after 3 consecutive health-check failures, and by `batchRetry` on a retryable connection error). Concurrent callers share one in-flight `_reconnectPromise` (they await the single reconnect rather than racing a half-rebuilt pool); ownership re-samples through the atomic `db.connect()` token on the connect leg. `reconnect(ctx?)` accepts the triggering error and records a pool-recovery audit event (`reap_detected`/`reconnect_other`/`reconnect_succeeded`/`reconnect_failed`) for the `pool_reap_health` doctor check. `executeRaw` is a single-statement passthrough — no per-call retry (unsound for non-idempotent statements; recovery is supervisor-driven). `connect()` applies `resolveSessionTimeouts()` from `db.ts` as connection-time startup parameters (`statement_timeout`, `idle_in_transaction_session_timeout`) so orphan pgbouncer backends can't hold locks for hours. `countStaleChunks()`+`listStaleChunks()` server-side-filter on `embedding IS NULL` for `embed --stale` (eliminates ~76 MB/call client-side pull); `upsertChunks()` resets both `embedding` AND `embedded_at` to NULL when chunk_text changes without a new embedding. `initSchema()` calls `applyForwardReferenceBootstrap()` BEFORE replaying SCHEMA_SQL on the same probe set as PGLite (extended for column-only forward-reference cases: `files.source_id`, `files.page_id`, `oauth_clients.source_id`, `oauth_clients.federated_read`, `sources.archived`/`archived_at`/`archive_expires_at`); the entire probe path runs on the DDL connection threaded from `initSchema` (closing a concurrent-bootstrap race for Supabase pooler users); closes #1018/#974/#820. `disconnect()` is idempotent — `_connectionStyle` tracks whether the engine owns its pool (worker engines) or shares the module-level singleton; second call on an instance-pool engine is a no-op rather than clobbering the singleton; and a module-style engine only calls `db.disconnect()` when it owns the singleton (`_ownsModuleSingleton`, set from the `db.connect()` creation token), so a borrower probe engine's teardown leaves the cycle owner's connection intact. Pinned by `test/e2e/postgres-engine-disconnect-idempotency.test.ts` + `test/postgres-engine-singleton-ownership.test.ts`. `getBrainScore` empty-brain parity with PGLite — 100/100 with breakdown 35/25/15/15/10 when `pageCount === 0` (both engines must agree to keep `doctor-report-remote.serial.test.ts` deterministic). Implements `deletePages(slugs, {sourceId}): Promise` via `DELETE FROM pages WHERE slug = ANY($1::text[]) AND source_id = $2 RETURNING slug` (single round-trip; caller chunks); `resolveSlugsByPaths` does `SELECT slug, source_path FROM pages WHERE source_path = ANY($1::text[]) AND source_id = $2`; FK cascades through `content_chunks`/`links`/`tags`/`raw_data`/`timeline_entries`/`page_versions`, `files.page_id`+`links.origin_page_id` go SET NULL; throws when input exceeds `DELETE_BATCH_SIZE` (from `src/core/engine-constants.ts`); both short-circuit on empty input. Implements the embedding-signature stale-detection quartet (`sumStaleChunkChars`, `setPageEmbeddingSignature`, `invalidateStaleSignatureEmbeddings`, widened `countStaleChunks`, all accept optional `signature` extending "stale" to model/dims-swap drift via the `pages.embedding_signature` JOIN, NULL grandfathered; the `embedding IS NULL` server-side filter is preserved as the no-signature fast path); Pinned by `test/e2e/engine-parity.test.ts`. +- `src/core/pglite-engine.ts` — PGLite (embedded Postgres 17.5 via WASM) implementation, all BrainEngine methods. `listLinkSources({sourceId?, sourceIds?})` returns distinct `link_source` provenances + counts (`ORDER BY count DESC, link_source ASC NULLS LAST`; scalar + federated scoped; parity with postgres-engine.ts) powering `gbrain link-sources`. `addLinksBatch`/`addTimelineEntriesBatch`/`addTakesBatch` pass the whole batch as one JSONB document via `jsonb_to_recordset(($1::jsonb)->'rows')` (bound through `executeRawJsonb` with a `{ rows }` wrapper; rows built by the shared `src/core/batch-rows.ts` helpers, NUL-stripped), and are `batchRetry`-wrapped. `connect()` wraps `PGlite.create()` in a try/catch that emits an actionable error (macOS 26.3 WASM bug #223, points at `gbrain doctor`); the lock is released on failure so the next process can retry cleanly. `searchKeyword`/`searchKeywordChunks` multiply `ts_rank` by the source-factor CASE at chunk grain; `searchVector` is a two-stage CTE — inner CTE keeps `ORDER BY cc.embedding <=> vec` so HNSW stays usable, outer SELECT re-ranks by `raw_score * source_factor`, inner LIMIT scales with offset to preserve pagination. `searchTakes`/`searchTakesVector` take full `SearchOpts` and apply the standard source-scope predicates (federated `sourceIds[]` wins over scalar `sourceId`, via the joined page's `source_id`) alongside the holder allow-list — parity SQL in postgres-engine.ts; pinned by `test/e2e/think-source-isolation-pglite.test.ts`. `initSchema()` calls `applyForwardReferenceBootstrap()` BEFORE replaying SCHEMA_SQL — probes for forward-referenced state the embedded blob needs (`pages.source_id`, `links.link_source`, `links.origin_page_id`, `content_chunks.symbol_name`, `content_chunks.language`, `sources` FK target, plus `files.source_id`, `files.page_id`, `oauth_clients.source_id`, `oauth_clients.federated_read`, `sources.archived`, `sources.archived_at`, `sources.archive_expires_at`, `timeline_entries.event_page_id` — column-only, migration v121 stays the source of truth for its FK + indexes) and adds only what's missing; threads the DDL connection from `initSchema` so probes run inside the advisory-lock scope; no-op on fresh installs and modern brains (closes the upgrade-wedge bug class #239/#243/#266/#357/#366/#374/#375/#378/#395/#396/#1018/#974/#820). `getBrainScore` returns 100/100 with full breakdown (35/25/15/15/10) when `pageCount === 0` (vacuous truth — empty brain has no coverage problem); Pinned by `test/brain-score-breakdown.test.ts` empty-brain assertion + `test/doctor-report-remote.serial.test.ts`. `disconnect()` uses snapshot+early-null (snapshot `_db`/`_lock`, null instance fields BEFORE any `await` so a concurrent `connect()` can't see a partial mid-close state) wrapped in try/finally guaranteeing lock-release even if `db.close()` throws; KEEPS close-then-release order (release-then-close was rejected: it would widen the window where a sibling process connects to a still-closing brain); Pinned by `test/pglite-engine-disconnect.serial.test.ts`. `PGlite.create()` runs inside `preservingProcessExitCode` (#2084): PGLite's Emscripten runtime writes its own status into `process.exitCode` (99 at create; in-memory brains run initdb whose status lands on a later tick; the exit status at close — and assigning `undefined` cannot clear a prior value), which would otherwise silently clobber an errored op's exit 1 back to 0. The wrapper keeps the global tidy for external readers; `db.close()` stays unwrapped (its 0-write is baseline behavior test runners depend on). The CLI's exit verdict is immune either way — it lives in the gbrain-owned channel in `cli-force-exit.ts` and never reads `process.exitCode` back. Exports `classifyPgliteInitError(message): 'bunfs' | 'macos-26-3' | 'unknown'` + `buildPgliteInitErrorMessage(verdict, original)` routing the catch-block hint by failure shape (`bunfs` matches literal `$$bunfs` OR `ENOENT[\s\S]*pglite\.data` co-occurrence, surfaces a paste-ready `bun upgrade` + Node fallback; `macos-26-3` keeps the #223 link; `unknown` falls through); Pinned by `test/pglite-init-classifier.test.ts`. Implements `deletePages(slugs, {sourceId})` + `resolveSlugsByPaths(paths, {sourceId})` via `slug = ANY($1::text[])` array-param binding, caller-chunking primitive throwing when input exceeds `DELETE_BATCH_SIZE`, `deletePages` returns `RETURNING slug` rows so callers filter `pagesAffected` to confirmed deletes. Implements the embedding-signature stale-detection quartet — `sumStaleChunkChars({sourceId?, signature?})`, `setPageEmbeddingSignature(slug, {sourceId?, signature})`, `invalidateStaleSignatureEmbeddings({signature, sourceId?})`, widened `countStaleChunks({sourceId?, signature?})` (the `signature` opt widens via `JOIN pages p ... WHERE cc.embedding IS NULL OR (p.embedding_signature IS NOT NULL AND p.embedding_signature <> $signature)`, NULL grandfathered); parity SQL with postgres-engine.ts. PGLite-specific DDL (pgvector, pg_trgm, triggers). Engine-path helper dependencies (`retry`, ontology, recency decay) avoid dynamic `import()`; the only lazy dynamic imports are `ai/gateway.ts` in `initSchema` and `_upsertChunksOnce`, line-marked because their local catches preserve compiled-default and stored-config fallbacks that eager module evaluation would bypass. +- `src/core/pglite-lock.ts` — advisory data-dir lock so two processes never open the same embedded PGLite (single-connection WASM Postgres) at once. Atomic `mkdir` of `.gbrain-lock/` + a `lock` file carrying `{pid, acquired_at, refreshed_at, command, subcommand}`. A held lock HEARTBEATS its `refreshed_at` every 30s (`.unref()`ed timer; informational). A waiting acquirer reaps a holder ONLY when its PID is dead — a LIVE holder is NEVER stolen, regardless of how stale its heartbeat is (#2348). A live `gbrain serve` holder is identified from the parsed `subcommand` and reported immediately with separate CLI-retry and MCP-tool choices; other live holders keep the bounded wait. The heartbeat runs on the JS event loop, which is blocked during long synchronous WASM imports/CHECKPOINTs, so a working `dream`/`embed` holder can look stale while alive; the old steal-on-stale-heartbeat grace let a second OS process open the same data dir and corrupt the catalog + pgvector extension (58P01 / `internal_load_library` / `type "vector" does not exist`), recoverable only by wipe+restore. A wedged-but-alive or PID-reused holder is never stolen: serve-tagged holders report immediately, while other holders time out with a message naming the PID. Each holder carries an ownership token (`:`); the heartbeat and `releaseLock` verify the on-disk lock is STILL theirs before touching it. In-memory engines take no lock. There is deliberately NO same-process reentrancy or same-PID special case: a second `acquireLock` from the process that already holds the lock waits out the timeout like any other live holder (#1963 was this shape — a command double-connecting a second engine on the same data dir; the fix is to reuse the connected engine at the dispatch layer, never to soften the lock). Pinned by `test/pglite-lock.test.ts`. A corrupted store surfaces a `reinit-pglite` recovery hint via `classifyPgliteInitError`'s `corrupt` verdict in `pglite-engine.ts`. +- `src/core/postgres-engine.ts` — Postgres + pgvector implementation (Supabase / self-hosted). `addLinksBatch`/`addTimelineEntriesBatch`/`addTakesBatch` pass the batch as one JSONB document — `INSERT ... SELECT FROM jsonb_to_recordset(($1::jsonb)->'rows') AS v(...) JOIN pages ...` bound through `executeRawJsonb({ rows })` — which encodes arbitrary free text safely (the old `unnest(${arr}::text[])` array-literal path crashed Postgres with "malformed array literal" on calendar/Zoom context, gbrain#1861) and sidesteps the 65535-parameter cap; takes declares native recordset column types (`page_id int, weight real, active boolean, …`) so no per-element casts; all three are `batchRetry`-wrapped. `searchKeyword`/`searchVector` scope `statement_timeout` via `sql.begin` + `SET LOCAL` so the GUC dies with the transaction instead of leaking across the pooled postgres.js connection. `getEmbeddingsByChunkIds` uses `tryParseEmbedding` so one corrupt row skips+warns instead of killing the query. `searchKeyword`/`searchKeywordChunks`/`searchVector` apply source-aware ranking by inlining the source-factor CASE and `NOT (col LIKE …)` hard-exclude from `src/core/search/sql-ranking.ts`; `searchVector` is a two-stage CTE (HNSW-safe inner ORDER BY, source-boost re-rank in outer SELECT) carrying `p.source_id` inner→outer. `_savedConfig` retains the connect config; `reconnect()` tears down + recreates the pool from saved config (called by supervisor watchdog after 3 consecutive health-check failures, and by `batchRetry` on a retryable connection error). Concurrent callers share one in-flight `_reconnectPromise` (they await the single reconnect rather than racing a half-rebuilt pool); ownership re-samples through the atomic `db.connect()` token on the connect leg. `reconnect(ctx?)` accepts the triggering error and records a pool-recovery audit event (`reap_detected`/`reconnect_other`/`reconnect_succeeded`/`reconnect_failed`) for the `pool_reap_health` doctor check. `executeRaw` is a single-statement passthrough — no per-call retry (unsound for non-idempotent statements; recovery is supervisor-driven). `connect()` applies `resolveSessionTimeouts()` from `db.ts` as connection-time startup parameters (`statement_timeout`, `idle_in_transaction_session_timeout`) so orphan pgbouncer backends can't hold locks for hours. `countStaleChunks()`+`listStaleChunks()` server-side-filter on `embedding IS NULL` for `embed --stale` (eliminates ~76 MB/call client-side pull); `upsertChunks()` resets both `embedding` AND `embedded_at` to NULL when chunk_text changes without a new embedding. `initSchema()` calls `applyForwardReferenceBootstrap()` BEFORE replaying SCHEMA_SQL on the same probe set as PGLite (extended for column-only forward-reference cases: `files.source_id`, `files.page_id`, `oauth_clients.source_id`, `oauth_clients.federated_read`, `sources.archived`/`archived_at`/`archive_expires_at`, `timeline_entries.event_page_id`); the entire probe path runs on the DDL connection threaded from `initSchema` (closing a concurrent-bootstrap race for Supabase pooler users); closes #1018/#974/#820. `disconnect()` is idempotent — `_connectionStyle` tracks whether the engine owns its pool (worker engines) or shares the module-level singleton; second call on an instance-pool engine is a no-op rather than clobbering the singleton; and a module-style engine only calls `db.disconnect()` when it owns the singleton (`_ownsModuleSingleton`, set from the `db.connect()` creation token), so a borrower probe engine's teardown leaves the cycle owner's connection intact. Pinned by `test/e2e/postgres-engine-disconnect-idempotency.test.ts` + `test/postgres-engine-singleton-ownership.test.ts`. `getBrainScore` empty-brain parity with PGLite — 100/100 with breakdown 35/25/15/15/10 when `pageCount === 0` (both engines must agree to keep `doctor-report-remote.serial.test.ts` deterministic). Implements `deletePages(slugs, {sourceId}): Promise` via `DELETE FROM pages WHERE slug = ANY($1::text[]) AND source_id = $2 RETURNING slug` (single round-trip; caller chunks); `resolveSlugsByPaths` does `SELECT slug, source_path FROM pages WHERE source_path = ANY($1::text[]) AND source_id = $2`; FK cascades through `content_chunks`/`links`/`tags`/`raw_data`/`timeline_entries`/`page_versions`, `files.page_id`+`links.origin_page_id` go SET NULL; throws when input exceeds `DELETE_BATCH_SIZE` (from `src/core/engine-constants.ts`); both short-circuit on empty input. Implements the embedding-signature stale-detection quartet (`sumStaleChunkChars`, `setPageEmbeddingSignature`, `invalidateStaleSignatureEmbeddings`, widened `countStaleChunks`, all accept optional `signature` extending "stale" to model/dims-swap drift via the `pages.embedding_signature` JOIN, NULL grandfathered; the `embedding IS NULL` server-side filter is preserved as the no-signature fast path); Pinned by `test/e2e/engine-parity.test.ts`. Retry classifiers, ontology/recency helpers, and disconnect/pool-recovery audit writers bind statically; only the two `ai/gateway.ts` fallback lookups stay lazy and line-marked, in parity with PGLite. - `src/core/cjk.ts` — Single source of truth for CJK detection. Exports `CJK_RANGES_REGEX`, `CJK_SLUG_CHARS` (character-class fragment for embedding inside other regexes), `CJK_SENTENCE_DELIMITERS` (`。!?`), `CJK_CLAUSE_DELIMITERS` (`;:,、`), `CJK_DENSITY_THRESHOLD = 0.30`, `hasCJK(s)`, `countCJKAwareWords(s)` (30% density threshold — English docs with one Japanese term stay whitespace-tokenized; Chinese-dominant docs get char-counted), and `escapeLikePattern(s)` (escapes `%`, `_`, `\\` for `ILIKE ... ESCAPE '\\'`). BMP-only ranges (Han / Hiragana / Katakana / Hangul Syllables). Consumers: `expansion.ts`, `sync.ts:slugifySegment`, `operations.ts:validatePageSlug + validateFilename`, `chunkers/recursive.ts:countWords + DELIMITERS`, `pglite-engine.ts:searchKeyword + searchKeywordChunks`. - `src/core/audit-slug-fallback.ts` — Weekly ISO-week-rotated audit JSONL at `~/.gbrain/audit/slug-fallback-YYYY-Www.jsonl`. `logSlugFallback(slug, sourcePath)` fires when `importFromFile` falls back to a frontmatter slug because `slugifyPath` returned empty (emoji / Thai / Arabic / non-CJK exotic-script filenames). `readRecentSlugFallbacks(days)` reads the last N days for `gbrain doctor`'s `slug_fallback_audit` check. Honors `GBRAIN_AUDIT_DIR` via the shared `resolveAuditDir()`. Separate surface from `sync-failures.jsonl` — that file carries bookmark-gating semantics that info events shouldn't trigger. -- `src/core/embedding-pricing.ts` — `EMBEDDING_PRICING` map keyed `provider:model` for the post-upgrade reindex cost estimate. Sibling to `anthropic-pricing.ts`. Entries: OpenAI text-embedding-3-large ($0.13/1M), 3-small ($0.02/1M), ada-002 ($0.10/1M), Voyage 3-large ($0.18/1M), 3 ($0.06/1M). `lookupEmbeddingPrice(modelString)` returns a tagged union (`known` with price + `unknown` with provider name); `estimateCostFromChars(charCount, pricePerMTok)` uses 3.5 chars/token. Unknown providers degrade to "estimate unavailable" instead of fabricating numbers. +- `src/core/embedding-pricing.ts` — `EMBEDDING_PRICING` map keyed `provider:model` for the post-upgrade reindex cost estimate. Sibling to `anthropic-pricing.ts`; EMBEDDINGS only — chat/completion pricing lives in `model-pricing.ts` (different unit) and is never mixed in. Every entry carries its official source URL + the date it was last read. Entries: OpenAI text-embedding-3-large ($0.13/1M), 3-small ($0.02/1M), ada-002 ($0.10/1M); Voyage 4-large ($0.12/1M), 4 ($0.06/1M), 4-lite ($0.02/1M), legacy 3-large ($0.18/1M), 3 ($0.06/1M); ZeroEntropy zembed-1 ($0.05/1M), zerank-2 ($0.025/1M); Mistral mistral-embed ($0.10/1M); Perplexity pplx-embed-v1-4b ($0.03/1M), 0.6b ($0.004/1M). `voyage-4-nano` is deliberately unpriced (open-weight variant, no published hosted rate) so it degrades to "estimate unavailable" rather than a fabricated 0. `lookupEmbeddingPrice(modelString)` returns a tagged union (`known` with price + `unknown` with provider name); `estimateCostFromChars(charCount, pricePerMTok)` uses 3.5 chars/token. Unknown providers degrade to "estimate unavailable" instead of fabricating numbers. - `src/core/post-upgrade-reembed.ts` — Pure functions backing the `gbrain upgrade` chunker-bump cost prompt. `computeReembedEstimate(engine, model)` queries real SQL (`COUNT(*)` + `COALESCE(SUM(LENGTH(compiled_truth)) + SUM(LENGTH(timeline)), 0)`) on `pages WHERE chunker_version < MARKDOWN_CHUNKER_VERSION`. `formatReembedPrompt(est, graceSeconds)` is the stderr-line formatter. `runPostUpgradeReembedPrompt(engine, model, opts)` orchestrates the 10-second Ctrl-C window; TTY-only wait (non-TTY auto-proceeds for CI / cron); `GBRAIN_NO_REEMBED=1` bails with a doctor-warning marker; `GBRAIN_REEMBED_GRACE_SECONDS=0` skips the wait. - `src/commands/reindex.ts` — `gbrain reindex --markdown [--limit N] [--dry-run] [--json] [--no-embed] [--repo PATH]`. Walks `pages WHERE page_kind = 'markdown' AND chunker_version < MARKDOWN_CHUNKER_VERSION` in 100-row batches ordered by id. Rows with non-null `source_path` re-import via `importFromFile`; rows without fall back to `importFromContent`. Both paths pass `forceRechunk: true` to bypass `importFromContent`'s `content_hash` short-circuit — without it the chunker version bump never reaches pages whose source content hasn't changed, AND the stripFactsFence privacy strip never applies to pre-strip chunks. Idempotent — partial-completion re-runs pick up via id-ordered batches. Wired into `src/commands/upgrade.ts:runPostUpgrade` after `apply-migrations`. The DB-only fallback (no source file on disk) does NOT pass body-only `compiled_truth` to `importFromContent` (that path re-parses with EMPTY frontmatter and OVERWRITES the page's real frontmatter/title/timeline); it `getPage`+`getTags`, reconstructs FULL markdown via `serializeMarkdown(frontmatter, compiled_truth, timeline, {type, title, tags})`, and re-imports THAT so re-chunking a DB-only page preserves everything while bumping `chunker_version`. Pinned by `test/reindex-preserve-tags.test.ts`. - `src/commands/reindex-code.ts` — `gbrain reindex --code [--source ID] [--dry-run] [--yes] [--json] [--force] [--no-embed]`. Walks `pages WHERE type = 'code'` in 100-row batches, replays through `importCodeFile` for chunk + embed + content_hash folding. Idempotent unless `--force` bypasses the content_hash early-return. Cost-preview model field reads `getEmbeddingModelName()` from the gateway so preview reflects what the gateway will actually embed with. An informational stderr nudge inside `runReindexCode` (so dry-run + execute both surface it): when the configured embedding model isn't code-tuned (allowlist `{'voyage-code-3'}`, case-insensitive bare match), prints a recommendation to switch to `voyage:voyage-code-3`; suppress with `GBRAIN_NO_CODE_MODEL_NUDGE=1`, `--no-embed`, or `--json`. Pure `shouldNudgeCodeModel(bareName)` returns a tagged `NudgeDecision` union (takes the bare model name, emits qualified `voyage:voyage-code-3` for the paste-ready `gbrain config set` line). When `--yes` is absent and the caller is non-TTY or passed `--json`, the cost gate refuses (exit 2, no spend) via the pure exported `buildCostRefusal({json, previewMsg, preview, costUsd, model}): {stdout?, stderr?}` — JSON envelope only when `--json` is explicit, otherwise a human refusal on stderr (the spend guardrail is independent of the output format). `spend.posture=tokenmax` OR an explicit `--max-cost off`/`unlimited` makes the gate informational and proceeds (#2139); `--max-cost off` also disables the runtime BudgetTracker cap. Pinned by `test/ai/voyage-code-3-recipe.test.ts`, `test/reindex-code-nudge.serial.test.ts`, `test/reindex-code-model-source.serial.test.ts` (IRON-RULE regression for the cost-preview fix), `test/reindex-cost-refusal.test.ts`. +- `src/core/fts-language.ts` — Single source for the Postgres text-search configuration name used by FTS. `getFtsLanguage()` resolves `GBRAIN_FTS_LANGUAGE` (default `english`), validates against `/^[a-z][a-z0-9_]*$/` (tsvector config names can't be bound as parameters, so the value is interpolated into raw SQL — the allowlist regex is the injection guard; invalid values warn once and fall back to `english`), and caches on first read (`resetFtsLanguageCache()` is test-only). Consumed by both engines' `searchKeyword`/`searchKeywordChunks` (`websearch_to_tsquery` query side), the `configurable_fts_language` migration, and `reindex-search-vector` (write-side trigger functions). Pinned by `test/fts-language.serial.test.ts` + `test/fts-language-migration.serial.test.ts` (includes the `'; DROP TABLE pages; --` injection cases). +- `src/commands/reindex-search-vector.ts` — `gbrain reindex-search-vector [--dry-run] [--yes] [--json]`. Escape hatch for changing `GBRAIN_FTS_LANGUAGE` after the `configurable_fts_language` migration has run (the migration shows applied and is skipped): recreates `update_page_search_vector` + `update_chunk_search_vector` with the configured language — bodies mirror the migration's and KEEP the `SET search_path = pg_catalog, public` hardening (CREATE OR REPLACE resets proconfig) — then backfills `pages` (UPDATE-to-self re-fires the trigger) and `content_chunks` (direct vector recompute) in id-keyset batches of `BACKFILL_BATCH_SIZE` (5000) via `UPDATE … WHERE id IN (SELECT … LIMIT n) RETURNING id`, streaming phases `reindex_search_vector.pages`/`.chunks` through the shared progress reporter (stderr). Confirmation gate: `--yes`, or an interactive TTY [y/N]; `--json` does NOT bypass the gate (non-TTY without `--yes` refuses with a ConfirmationRequired envelope, exit 2). Idempotent. Pinned by `test/reindex-search-vector.serial.test.ts`. - `src/commands/sync.ts:resolveSlugByPathOrSourcePath` — Resolves a slug by `pages.source_path` first (returns the stored slug for frontmatter-fallback pages whose path doesn't derive a slug), then falls back to `resolveSlugForPath(path)`. Threaded into all 4 delete/rename call sites (`performSync`'s un-syncable cleanup at ~:531, deletes at ~:603, rename oldSlug at ~:622). Without this, emoji-only / Thai / Arabic filenames whose slug came from frontmatter would orphan on delete/rename (the delete path would compute the wrong path-derived slug). Best-effort query — pre-migration brains fall through to the legacy path. - `src/core/sources-ops.ts` — Multi-source registration + clone-lifecycle ops (`addSource`, `recloneIfMissing`, `defaultCloneDir`, `isOwnedClone`, `unownedHint`). **Reclone-ownership invariant (must-never-violate): gbrain may only delete/re-clone a clone it created, NEVER a user working tree.** `recloneIfMissing` deletes `local_path`, so it gates on `isOwnedClone(src)` and throws a `SourceOpError('unmanaged_path', ...)` BEFORE any filesystem op when ownership is unprovable — fail-closed. Ownership is proven by `config.managed_clone === true` (written by `addSource`'s `--url` path, covering default-location and `--clone-dir` clones) OR `local_path === defaultCloneDir(id)` (back-compat for pre-marker clones, via exact normalized-path equality, symlink-free). A row with `remote_url` + an unowned `local_path` (a user-registered working tree, e.g. `sources add --path`) is refused untouched; re-add with `--url` to regain auto-reclone. The reclone is EXDEV-safe: clone into a SIBLING temp of `local_path` (not the shared `clones/.tmp`, which may sit on a different mount than a `--clone-dir` target), then swap (move old aside → move new in → drop old) so `local_path` is never left missing-and-unrecoverable; on swap failure the original is restored, and if restore fails the error names the `aside` path so it's never reflexively deleted. A TOCTOU re-check re-confirms ownership immediately before the destructive move and rejects a symlink leaf swapped in after the entry check (`symlink_escape`). `unownedHint(src, state)` is the shared recovery message used by both the core error and the `gbrain sync --source` CLI error; `gbrain sources restore` special-cases `unmanaged_path` to print "DB row restored; gbrain syncs this path read-only" instead of the misleading "try sync to recover" guidance. `SourceOpErrorCode` includes `unmanaged_path`. Pinned by `test/sources-ops.test.ts`, `test/sources-resync-recovery.test.ts`. -- `src/core/utils.ts` — Shared SQL utilities extracted from postgres-engine.ts. Exports `parseEmbedding(value)` (throws on unknown input, used by migration + ingest paths where data integrity matters) and `tryParseEmbedding(value)` (returns `null` + warns once per process, used by search/rescore paths where availability matters more than strictness). `isUndefinedColumnError(err)` predicate — pattern-matches Postgres SQLSTATE 42703 / "column ... does not exist" with engine-driver shape variation tolerated; replaces bare `catch {}` blocks in `oauth-provider.ts` so genuine errors (lock timeout, network blip, permission denied) propagate while column-missing falls through to the legacy fallback. `validateSourceId(id)` throws on anything outside `^[a-z0-9_-]+$`, used by the per-source disk-layout fix in patterns.ts/synthesize.ts before any `join(brainDir, '.sources', source_id, slug+'.md')` so source_id can't traverse out of brainDir. `rowToPage` populates the required `Page.source_id` from the SELECT projection (`scripts/check-source-id-projection.sh` enforces every projection feeding `rowToPage` includes the column). +- `src/core/utils.ts` — Shared SQL utilities extracted from postgres-engine.ts. Exports `parseEmbedding(value)` (throws on unknown input, used by migration + ingest paths where data integrity matters) and `tryParseEmbedding(value)` (returns `null` + warns once per process, used by search/rescore paths where availability matters more than strictness). `isUndefinedColumnError(err)` predicate — pattern-matches Postgres SQLSTATE 42703 / "column ... does not exist" with engine-driver shape variation tolerated; replaces bare `catch {}` blocks in `oauth-provider.ts` so genuine errors (lock timeout, network blip, permission denied) propagate while column-missing falls through to the legacy fallback. `validateSourceId(id)` throws on anything outside `^[a-z0-9_-]+$`, used by the per-source disk-layout fix in patterns.ts/synthesize.ts before any `join(brainDir, '.sources', source_id, slug+'.md')` so source_id can't traverse out of brainDir. `rowToSearchResult` projects email `message_id` / `thread_id` metadata and exposes `source_subject` only when a non-empty Message-ID proves the page is an email, so generated page titles never become authoritative email subjects. `rowToPage` populates the required `Page.source_id` from the SELECT projection (`scripts/check-source-id-projection.sh` enforces every projection feeding `rowToPage` includes the column). - `src/core/db.ts` — Connection management, schema initialization. `resolveSessionTimeouts()` returns `statement_timeout` + `idle_in_transaction_session_timeout` (defaults 5min each, env-overridable via `GBRAIN_STATEMENT_TIMEOUT`/`GBRAIN_IDLE_TX_TIMEOUT`/`GBRAIN_CLIENT_CHECK_INTERVAL`). Both `connect()` (module singleton) and `PostgresEngine.connect()` (worker pool) consume the result via postgres.js's `connection` option, sending GUCs as startup parameters that survive PgBouncer transaction mode (`setSessionDefaults` kept as a back-compat no-op shim). `connect()` returns `Promise` — `true` iff THIS call created the module singleton, `false` if it joined an existing one; the decision is atomic (no `await` between the `if (sql)` null-check and the synchronous `sql = postgres(...)` assignment), so two concurrent module connects can't both claim creation. `PostgresEngine` stores the return as its `_ownsModuleSingleton` token and only the creating engine may `db.disconnect()` the singleton — a borrower probe engine (lint/doctor config-lift) no-ops its disconnect, so its teardown can't null the connection the long-lived cycle owner is still using (the dream-cycle "connect() has not been called" failure). The module `sql` is only ever nulled by `db.disconnect()` (postgres.js auto-reconnects its own internal pool and never touches our reference). `disconnect()` snapshots + nulls `sql` before awaiting the pool end so a concurrent connect can't join a pool that's already closing. The end routes through `endPoolBounded(pool)` (#1972) — a gbrain-owned `Promise.race` of `pool.end({ timeout: POOL_END_TIMEOUT_SECONDS })` against a hard timer — so a PgBouncer transaction-mode drain that never settles can't hang teardown — the #2084 contract (finishCliTeardown's computed-deadline backstop + flushThenExit's fence-and-grace exit in cli-force-exit.ts) bounds it and delivers pending stdout before exit. `connection-manager.ts` ends its direct + read pools concurrently through the same helper so the per-pool bounds don't stack. -- `src/commands/migrate-engine.ts` — Bidirectional engine migration (`gbrain migrate --to supabase/pglite`). +- `src/commands/migrate-engine.ts` — Bidirectional engine migration (`gbrain migrate --to supabase/pglite`). Copies the complete source catalog FIRST (`copyMigrationSources` — every `sources` row incl. archived rows and sync/routing metadata, `ON CONFLICT (id) DO UPDATE`, `default` ordered first) so every page write has a valid `pages.source_id` FK parent and the target preserves per-source behavior; pages copy afterward, tracked in the resume manifest by composite `(source_id, slug)` key. The resume manifest is target-aware: `migrationTargetId(config)` hashes `(engine, locator)` (`database_url` for Postgres, resolved `database_path` for PGLite) and `manifestMatchesTarget` requires `schema_version === 2` plus a matching `target_id` — a legacy engine-only manifest, or one from a DIFFERENT target of the same engine kind, starts fresh instead of skipping "completed" pages the new target never received. Pinned by `test/migrate-engine-resume.test.ts` (manifest identity) + `test/e2e/migrate-engine-sources-postgres.test.ts` (source catalog lands before overlapping-slug pages, PGLite → real Postgres). - `src/core/import-file.ts` — importFromFile + importFromContent (chunk + embed + tags). `importFromContent` and `importCodeFile` stamp `pages.embedding_signature` via `setPageEmbeddingSignature(slug, {sourceId, signature: currentEmbeddingSignature()})` when the import actually embedded (not `--no-embed`) so a model/dims swap is detectable as stale; `importCodeFile` only stamps when every chunk was freshly embedded this call (`needsEmbedIndexes.length === chunks.length`), mixed reuse-by-hash pages stay unstamped (`reindex --code --force` / `embed --stale` handle those). `importFromContent`'s tag reconciliation is ADD-ONLY: it only `addTag` (idempotent, ON CONFLICT DO NOTHING). The `tags` table has no provenance column and frontmatter tags are stripped from stored `pages.frontmatter` (markdown.ts:118), so a frontmatter-origin tag can't be distinguished from a DB-enrichment tag (auto-tag / dream synthesize / signal-detector) at re-import — deletion is unsafe (would wipe enrichment under `gbrain reindex --markdown`). Accepted trade-off: removing a tag from frontmatter no longer removes it from the DB on next sync (needs a `tag_source` provenance column). Pinned by `test/reindex-preserve-tags.test.ts` + `test/import-file.test.ts`. -- `src/core/sync.ts` — Pure sync functions (manifest parsing, filtering, slug conversion). Exported `pruneDir(name: string): boolean` is the single source of truth for descent-time directory exclusion across walkers — blocks `node_modules` (no leading dot, so naive walkers slipped through and inflated MISSING_OPEN counts via vendor packages), dot-prefix dirs, `ops/`, and `*.raw` sidecars; `isSyncable` applies it per path segment, and `walkMarkdownFiles` in `src/commands/extract.ts` + `listTextFiles` in `src/core/cycle/transcript-discovery.ts` consult it BEFORE recursing to save the IO of walking thousands of vendor files (closes #923 + #202). `manageGitignore` worktree discriminator matches the gitdir path segment (`/modules/` = submodule, `/worktrees/` = worktree, per Git's documented layout) so Conductor worktrees (first-class repos) get `.gitignore` management for storage-tiering (closes #889). The sync-failure ledger (failure store, error classifier, the shared bookmark gate, and the doctor severity rule) lives in `src/core/sync-failure-ledger.ts`; `sync.ts` re-exports `classifyErrorCode`, `summarizeFailuresByCode`, `loadSyncFailures`, `unacknowledgedSyncFailures`, `acknowledgeSyncFailures`, `recordSyncFailures`, `decideSyncFailureSeverity`, `applySyncFailureGate`, and the `SyncFailure` type for backward-compatible imports — see its entry below. +- `src/core/sync.ts` — Pure sync functions (manifest parsing, filtering, slug conversion). Exported `pruneDir(name: string): boolean` is the single source of truth for descent-time directory exclusion across walkers — blocks `node_modules` (no leading dot, so naive walkers slipped through and inflated MISSING_OPEN counts via vendor packages), `vendor`/`dist`/`build`/`venv`, dot-prefix dirs, and `*.raw` sidecars — NOT `ops/`, which is ordinary user content (#2404; the bundled daily-task-manager stores `ops/tasks` there); `isSyncable` applies it per path segment, and `walkMarkdownFiles` in `src/commands/extract.ts` + `listTextFiles` in `src/core/cycle/transcript-discovery.ts` consult it BEFORE recursing to save the IO of walking thousands of vendor files (closes #923 + #202). `manageGitignore` worktree discriminator matches the gitdir path segment (`/modules/` = submodule, `/worktrees/` = worktree, per Git's documented layout) so Conductor worktrees (first-class repos) get `.gitignore` management for storage-tiering (closes #889). The sync-failure ledger (failure store, error classifier, the shared bookmark gate, and the doctor severity rule) lives in `src/core/sync-failure-ledger.ts`; `sync.ts` re-exports `classifyErrorCode`, `summarizeFailuresByCode`, `loadSyncFailures`, `unacknowledgedSyncFailures`, `acknowledgeSyncFailures`, `recordSyncFailures`, `decideSyncFailureSeverity`, `applySyncFailureGate`, and the `SyncFailure` type for backward-compatible imports — see its entry below. - `src/core/sync-failure-ledger.ts` — the bounded auto-skip sync failure ledger (issue #1939; formerly inline "Bug 9" in `sync.ts`). A LEAF module (imports only fs/path/crypto/config) so `sync.ts` can re-export it without a circular dependency. State lives in `~/.gbrain/sync-failures.jsonl`, one JSON object per line, keyed by `(source_id, path)` with a per-key `attempts` count and a 3-state machine: `open` (fresh/blocking) → `acknowledged` (human resolved via `gbrain sync --skip-failed`) or `auto_skipped` (chronic). `classifyErrorCode(errorMsg)` regex classifier with 12 codes (`SLUG_MISMATCH`, `YAML_PARSE`, `YAML_DUPLICATE_KEY`, `MISSING_OPEN`, `MISSING_CLOSE`, `NESTED_QUOTES`, `EMPTY_FRONTMATTER`, `NULL_BYTES`, `INVALID_UTF8`, `STATEMENT_TIMEOUT`, `FILE_TOO_LARGE`, `SYMLINK_NOT_ALLOWED`) plus `UNKNOWN` (also recognizes `PAGE_JUNK_PATTERN` from the content-sanity gate); `summarizeFailuresByCode(failures)` returns sorted `[{code, count}]`; `MISSING_OPEN`/`MISSING_CLOSE`/`EMPTY_FRONTMATTER` regexes match the `markdown.ts` validator strings, `FILE_TOO_LARGE` covers `import-file.ts:199, 352, 401`, `SYMLINK_NOT_ALLOWED` covers `:347`. All mutations run under `withLedgerLock` (cross-process file lock) with an atomic rename write. The auto-skip threshold resolves via `resolveAutoSkipThreshold()` from `GBRAIN_SYNC_AUTOSKIP_AFTER` (default `DEFAULT_AUTOSKIP_AFTER = 3`; `0` disables the valve = pure fail-closed). Two pure decision functions are the unit-test surface: `decideGateAction({fileFailures, sentinels, attemptsByPath, threshold, skipFailed})` returns `hard_block | block | advance | advance_then_autoskip` (sentinels like `` ALWAYS hard-block, even with `--skip-failed`, so a history rewrite can't auto-skip; any FRESH failure with `attempts < threshold` blocks fail-closed; only when ALL failures are chronic does it `advance_then_autoskip`), and `decideSyncFailureSeverity({entries, nowMs, failHours})` returns the `sync_failures` doctor status (`ok` when zero unresolved; `fail` when ≥10 OPEN-blocking or the oldest OPEN failure has blocked the bookmark past `failHours`; otherwise `warn` — `auto_skipped`-only rows stay WARN-visible regardless of count because the bookmark already advanced). `applySyncFailureGate(input)` is the one orchestrator BOTH sync paths (incremental + full/`runImport`) call: it records/clears ledger rows, runs `decideGateAction`, then executes effects in the crash-safe order (advance the bookmark FIRST via the injected `advance()` callback, THEN auto-skip the chronic set) so a crash can never mark a file skipped while leaving sync wedged. `isSkippablePath` rejects `<…>` sentinels. Pinned by `test/sync-failure-ledger.serial.test.ts` + `test/sync-failures.test.ts`. - `src/core/storage.ts` — Pluggable storage interface (S3, Supabase Storage, local). -- `src/core/storage-config.ts` — Storage tiering: `loadStorageConfig` reads `gbrain.yml`, normalizes deprecated keys (`git_tracked`/`supabase_only`) to canonical (`db_tracked`/`db_only`) with once-per-process deprecation warning, and runs `normalizeAndValidateStorageConfig` (auto-fixes missing trailing `/`, throws `StorageConfigError` on tier overlap). Path-segment matcher: `media/x/` does NOT match `media/xerox/foo`. Uses a dedicated parser for the `gbrain.yml` shape rather than gray-matter (broken on delimiter-less YAML). +- `src/core/storage-config.ts` — Storage tiering: `loadStorageConfig` reads `gbrain.yml`, normalizes deprecated keys (`git_tracked`/`supabase_only`) to canonical (`db_tracked`/`db_only`) with once-per-process deprecation warning, and runs `normalizeAndValidateStorageConfig` (auto-fixes missing trailing `/`, throws `StorageConfigError` on tier overlap). Path-segment matcher: `media/x/` does NOT match `media/xerox/foo`. Uses a dedicated parser for the `gbrain.yml` shape rather than gray-matter (broken on delimiter-less YAML). Also carries `DERIVE_PHASE_DB_ONLY_DEFAULTS` (`life/events/`, `atoms/`, `extracts/`, `dream-cycle-summaries/`) + `effectiveDbOnlyDirs` — the engine's derive-phase output prefixes treated as implicitly-declared db_only by the `undeclared_db_only_pages` doctor check but deliberately NOT merged into `loadStorageConfig` (a global merge would auto-gitignore those dirs and silently kill ingestion for brains that file-back them, the #2788 class) — and `findDbOnlyCollisions` (pure collector-output vs db_only overlap detector shared by the `db_only_collector_collision` doctor check and sync's `manageGitignore` warning). Pinned by `test/storage-config.test.ts` + `test/doctor-silent-death-checks.test.ts`. - `src/core/disk-walk.ts` — `walkBrainRepo(repoPath)` returns `Map` from one recursive `readdirSync`. Skips dot-dirs, `node_modules`, non-`.md` files. Used by `gbrain storage status` to replace per-page `existsSync + statSync` (~400K syscalls on 200K-page brains → tens). - `src/core/git-head.ts` — local git HEAD freshness probe for `gbrain doctor`. `isSourceUnchangedSinceSync(localPath, lastCommit, opts?)` returns true iff `localPath` is a git repo whose current HEAD matches `lastCommit`; when `opts.requireCleanWorkingTree` is true also requires a clean working tree (mirrors `gbrain sync`'s force-walk gate at `sync.ts:1075` so doctor and sync agree on "is there work to do?"). `requireCleanWorkingTree` is `boolean | 'ignore-untracked'` — in `'ignore-untracked'` mode the clean probe runs `git status --porcelain --untracked-files=no` so a quiet repo with stray untracked dirs (`?? companies/`, `?? media/`) is still "unchanged" (sync's incremental path keys off the commit diff and never imports untracked files); `GitCleanProbe` gains an `ignoreUntracked?` second arg. Two probe seams (`_setGitHeadProbeForTests`, `_setGitCleanProbeForTests`) keep unit tests R2-compliant (no `mock.module`). Uses `execFileSync` with array args so shell metachars in `local_path` cannot escape to a shell (the regression test runs real `execFileSync` against `'/nonexistent/$(touch )/repo'` and asserts the sentinel is never created). Fail-open on every error (missing path, not a git repo, git not installed, timeout, NULL inputs, dirty-probe errored → false) preserving the caller's prior time-based behavior. The chunker-version-match check lives in the caller (doctor.ts) because it depends on engine state (`sources.chunker_version` vs `CHUNKER_VERSION` from `src/core/chunkers/code.ts`). Pinned by `test/core/git-head.test.ts` (incl. the shell-injection regression guard). - `src/core/source-health.ts` — per-source health metrics for `gbrain sources status` + doctor's `federation_health`. Commit-relative staleness: `newestCommitMs(localPath)` = HEAD committer time via `git log -1 --format=%ct` (fail-open null; NO working-tree mtime parsing — committed content only, robust against the porcelain-mtime bug farm); pure `lagFromContentMs(contentMs|null, lastSyncMs|null, nowMs)` = remote/column comparator (null lastSync → null; negative wall-clock → skew passthrough; `contentMs <= lastSync` → 0; else/null-content → wall-clock). `computeAllSourceMetrics(engine, sources, {probeContent?})`: LOCAL (`probeContent:true`, `gbrain sources status`) → `isSourceUnchangedSinceSync(..., {requireCleanWorkingTree:'ignore-untracked'}) ? 0 : wall-clock` (live commit-hash catches HEAD moving to an old-dated commit a timestamp compare would miss); REMOTE (default, `federation_health` on the HTTP MCP path) → `lagFromContentMs(row.newest_content_at, ...)`, NO git subprocess (trust boundary). `commitTimeMs(localPath, sha)` is the `newestCommitMs` sibling pinned to an arbitrary commit (committer time via `git show -s --format=%ct `, fail-open null, execFileSync array args) — the resumable sync stamps `newest_content_at` against its pinned target commit, not whatever HEAD raced to. Pinned by `test/source-health.test.ts`. - `src/core/git-remote.ts` — SSRF-hardened git invocations for remote-source `cloneRepo`, `pullRepo`, and `fetchRemote(repoPath, branch)` (the last added for the sync cost-estimator's fetch-first path, #2139, so a cost preview / dry-run fetches through the same hardened flags + `GIT_TERMINAL_PROMPT=0` as real sync rather than a less-protected route). Exports two distinct flag constants because `git`'s argv grammar treats them differently: `GIT_SSRF_FLAGS` (3 `-c` config flags — `protocol.allow=user`, `protocol.file.allow=never`, `http.allowRedirects=false`) is global config, spread BEFORE the subcommand verb; `GIT_SSRF_SUBCOMMAND_FLAGS = ['--no-recurse-submodules']` is subcommand-scoped, spread AFTER the verb (a combined array would spread `--no-recurse-submodules` before the verb where real git rejects it exit 129). `cloneRepo` argv: `git clone --depth=1 [--branch X] -- `. `pullRepo` argv: `git -C pull --ff-only`. Pinned by `test/git-remote.test.ts` position-anchored regression guard (`argv.indexOf('--no-recurse-submodules') > argv.indexOf(verb)`). Also exports the durability-side helpers that power `gbrain sources harden/pull`: `GIT_ENV_AUTH` (the no-prompt env minus the askpass `/bin/false` overrides, so an auth'd push/fetch can consult the repo's configured credential helper while `GIT_TERMINAL_PROMPT=0` still fails fast on a missing credential), `divergenceSafePull(repoPath, branch)` (fetch + `pull --rebase`; returns `skipped_dirty` on a dirty tree, `conflict_aborted` on a rebase conflict after `rebase --abort` so the tree is never left mid-rebase, else `up_to_date`/`advanced`), `detectDefaultBranch` (origin/HEAD → current branch → `main`), `pushProbe(repoPath, branch)` (authenticated `push --dry-run` that proves push access and classifies `auth`/`protected`/`unreachable`), and `isWorkingTreeDirty`. These auth'd paths route their `protocol.file.allow` through `GBRAIN_GIT_ALLOW_FILE_TRANSPORT` (default `never`; set `=1` for self-hosted filesystem remotes), unlike clone/pull which stay strict. -- `src/core/brain-repo-durability.ts` + `src/commands/sources-harden.ts` — brain-repo git durability. `hardenBrainRepo(opts)` makes a brain's working tree durable, idempotently: divergence-safe pull, a LOCAL untracked `.git/hooks/post-commit` auto-push safety net (never committed — a pulled commit can't rewrite executed code next to the token; installed into the active `core.hooksPath` dir and excluded via `.git/info/exclude` when that dir is tracked), a committed `scripts/brain-commit-push.sh` that refuses to exit 0 without a confirmed push (hook + helper render from ONE bash push-retry template — DRY at the TS source, not by the hook sourcing a repo-controlled script), durability rules patched into the active resolver file (`findResolverFile` → RESOLVER.md > AGENTS.md; taxonomy rendered from the bundled `_brain-filing-rules.json`), a minimal DB-free pull cron (launchd/crontab running `gbrain sources pull --path ` so it never opens the PGLite single-writer lock), and a push-probe verify (no heartbeat commit). Credential is REPO-scoped (`acceptPat` from `--pat-file`/`GBRAIN_GITHUB_PAT`, warns on loose perms; reuses an existing repo-local `credential.helper`, else a `0600` store wired via repo-local config); the token is redacted everywhere via `redactSecretsInText` and never enters the repo, remote URL, logs, or `DurabilityReport`. `unhardenBrainRepo` removes the cron/hook/credential wiring (ownership-fingerprinted) and runs before `sources remove`. CLI: `gbrain sources harden ` / `pull |--path ` / `unharden `; auto-harden fires on `sources add --url ... --pat-file` for managed clones (`--no-harden` opts out). `sources pull --path` is dispatched in `src/cli.ts` BEFORE `connectEngine` so the cron stays DB-free. CLI-only (writes executables + an OS cron + a credential helper on the host); never exposed over MCP. Tests: `test/brain-repo-durability.serial.test.ts`, `test/git-remote-durable.serial.test.ts`, `test/brain-durability-hook.serial.test.ts`, `test/durability-cron.test.ts`. +- `src/core/brain-repo-durability.ts` + `src/commands/sources-harden.ts` — brain-repo git durability. `hardenBrainRepo(opts)` makes a brain's working tree durable, idempotently: divergence-safe pull, a LOCAL untracked `.git/hooks/post-commit` auto-push safety net (never committed — a pulled commit can't rewrite executed code next to the token; installed into the active `core.hooksPath` dir and excluded via `.git/info/exclude` when that dir is tracked), a committed `scripts/brain-commit-push.sh` that refuses to exit 0 without a confirmed push and stages+commits BEFORE any pull so a dirty tree of modified pages (the write-through shape) can still be committed — the push-retry's rebase-on-reject handles a remote that advanced (#2426; hook + helper render from ONE bash push-retry template — DRY at the TS source, not by the hook sourcing a repo-controlled script), durability rules patched into the active resolver file (`findResolverFile` → RESOLVER.md > AGENTS.md; taxonomy rendered from the bundled `_brain-filing-rules.json`), a minimal DB-free pull cron (launchd/crontab running `gbrain sources pull --path ` so it never opens the PGLite single-writer lock), and a push-probe verify (no heartbeat commit). Credential is REPO-scoped (`acceptPat` from `--pat-file`/`GBRAIN_GITHUB_PAT`, warns on loose perms; reuses an existing repo-local `credential.helper`, else a `0600` store wired via repo-local config); the token is redacted everywhere via `redactSecretsInText` and never enters the repo, remote URL, logs, or `DurabilityReport`. `unhardenBrainRepo` removes the cron/hook/credential wiring (ownership-fingerprinted) and runs before `sources remove`. CLI: `gbrain sources harden ` / `pull |--path ` / `unharden `; auto-harden fires on `sources add --url ... --pat-file` for managed clones (`--no-harden` opts out). `sources pull --path` is dispatched in `src/cli.ts` BEFORE `connectEngine` so the cron stays DB-free. CLI-only (writes executables + an OS cron + a credential helper on the host); never exposed over MCP. Tests: `test/brain-repo-durability.serial.test.ts`, `test/git-remote-durable.serial.test.ts`, `test/brain-durability-hook.serial.test.ts`, `test/durability-cron.test.ts`. - `src/commands/storage.ts` — `gbrain storage status [--repo P] [--json]`. Split into pure data (`getStorageStatus`) + JSON formatter + human formatter (ASCII-only) matching the `orphans.ts` pattern. `PageCountsByTier` and `DiskUsageByTier` are distinct nominal types so swaps fail at compile time. - `gbrain.yml` (brain repo root) — Optional storage tiering config. Top-level `storage:` section with `db_tracked:` and `db_only:` array-valued keys. `gbrain sync` auto-manages `.gitignore` for `db_only` paths on successful sync (skips on dry-run, blocked-by-failures, submodule context, or `GBRAIN_NO_GITIGNORE=1`). `gbrain export --restore-only [--repo P] [--type T] [--slug-prefix S]` repopulates missing `db_only` files from the database. - `src/core/supabase-admin.ts` — Supabase admin API (project discovery, pgvector check). @@ -79,7 +86,7 @@ per-release `**vX.Y.Z:**` narration — CI enforces this - `src/core/eval/cycle-default.ts` — single source of truth for the eval cycle-count default. Exports `DEFAULT_CYCLES_TTY = 3`, `DEFAULT_CYCLES_NONTTY = 1`, `resolveCycleDefault(explicit, isTty): {cycles, usedNonTtyDefault}`, and `cycleDefaultSuffix(r)` (returns ` (non-interactive default; --cycles N for more)` only when the non-TTY default was applied, else `''`). Consumed by `eval-cross-modal.ts`, `eval-takes-quality.ts` (run + regress), and `takes-quality-eval/runner.ts` (core uses only the constant — library stays TTY-agnostic; the CLI owns the TTY=3 upgrade + banner annotation). `eval-suspected-contradictions.ts` applies the same transparency to its `$5`/`$1` budget default via a `budgetUsdExplicit` flag (the budget is overwritten in-place so explicitness can't be inferred post-hoc). Not shared with `resolveWorkersWithClamp` (different domain, no engine, no dedup). Pinned by `test/eval/cycle-default.test.ts`, `test/eval-suspected-contradictions-budget-default.test.ts`. - `src/core/cross-modal-eval/json-repair.ts` — `parseModelJSON(raw)` named export with a 4-strategy fallback chain (direct parse → fence-strip → trailing-comma + single-quote + embedded-newline repair → regex nuclear option). Adversarial input throws rather than fabricating scores — the aggregator treats a throw as "this model contributed nothing this cycle" so the gate stays correct at >=2/3 successes. - `src/core/cross-modal-eval/aggregate.ts` — pure verdict logic. Pass criterion: `(successes >= 2) AND (every dim mean >= 7) AND (every dim min across models >= 5)`. Inconclusive when <2/3 models returned parseable scores (regression guard for the v1 `Object.values({}).every(...) === true` empty-array PASS bug). -- `src/core/cross-modal-eval/runner.ts` — orchestrator. Each cycle runs `Promise.allSettled([gwChat(slotA), gwChat(slotB), gwChat(slotC)])` (bare allSettled, no rate-leases for the CLI path). Stops early on PASS or INCONCLUSIVE; runs up to 3 cycles. Default slots: `openai:gpt-4o` / `anthropic:claude-opus-4-7` / `google:gemini-1.5-pro`. `estimateCost()` exports a small per-model pricing table (drifts; refresh alongside model-family bumps). +- `src/core/cross-modal-eval/runner.ts` — orchestrator. Each cycle runs `Promise.allSettled([gwChat(slotA), gwChat(slotB), gwChat(slotC)])` (bare allSettled, no rate-leases for the CLI path). Stops early on PASS or INCONCLUSIVE; runs up to 3 cycles. Default slots: `openai:gpt-5.2` / `anthropic:claude-opus-4-7` / `deepseek:deepseek-v4-pro`. `estimateCost()` prices via the canonical model-pricing table; `test/cross-modal-default-slots.test.ts` pins recipe support, pricing coverage, and three distinct providers. - `src/core/cross-modal-eval/receipt-name.ts` — receipt filename binds (slug, SKILL.md sha-8). `findReceiptForSkill(skillPath, receiptDir)` returns `'found' | 'stale' | 'missing'`. Skillify-check surfaces the status as informational; the audit does NOT fail on missing/stale receipts. - `src/core/cross-modal-eval/receipt-write.ts` — wraps `fs.writeFileSync` with `mkdirSync({recursive:true})` ahead of every write (`gbrainPath()` does NOT auto-mkdir). - `src/commands/eval-export.ts` — streams `eval_candidates` rows as NDJSON to stdout with `schema_version: 1` prefix on every line. EPIPE-safe, progress heartbeats on stderr, stable id-desc tiebreaker so `--since` windows never dupe/miss rows. @@ -90,11 +97,11 @@ per-release `**vX.Y.Z:**` narration — CI enforces this - `src/commands/autopilot.ts` extension — tick body invokes `runNightlyQualityProbe` when `cfg.autopilot.nightly_quality_probe.enabled === true` (default OFF — opt-in to protect API spend). NO scheduler-side rate-limit check — `runNightlyQualityProbe`'s internal `shouldRunNightly` (reading the audit JSONL) is the single source of truth. Probe call wrapped in try/catch that logs via `logError` and does NOT bump `consecutiveErrors` (probe failure is informational, never crashes the loop). Default `max_usd` cap = 5. Pinned by `test/autopilot-nightly-probe-wiring.test.ts`. - `test/eval-replay-gate.test.ts` + `test/fixtures/eval-baselines/qrels-search.json` — hermetic retrieval qrels gate running in the standard PR unit-shard CI matrix (`.github/workflows/test.yml`, NOT the fixed-file E2E workflow). Uses the canonical PGLite block (test-isolation R3+R4) and the basis-vector embedding pattern from `test/e2e/search-quality.test.ts:23-28` for fully hermetic retrieval. The qrels fixture (12 queries) uses PLACEHOLDER names only (alice-example, widget-co-example, etc. — privacy rule) and embeds each query at a deterministic basis dimension so retrieval is reproducible. Each query lists `relevant_slugs[]` + `first_relevant_slug`; the test computes `top1_match_rate` (top-1 == first_relevant) and `recall@10` (fraction of relevant_slugs in top-10), asserting both meet floors (defaults `>= 0.80` and `>= 0.85`). Env-overridable floors `GBRAIN_REPLAY_GATE_TOP1_FLOOR` / `GBRAIN_REPLAY_GATE_RECALL_FLOOR` (via `withEnv()` per R1). Refresh discipline: when ranking changes intentionally move expected slugs, edit `qrels-search.json` directly with a `Why:` line in the commit body or the gate degrades to rubber-stamp. Pinned by `test/eval-replay-gate.test.ts` (incl. a privacy-grep regression guard against real-name reintroduction). - `src/core/cycle/nightly-quality-probe.ts` + `src/core/audit-quality-probe.ts` + `test/fixtures/longmemeval-nightly.jsonl` + `test/nightly-quality-probe.test.ts` — opt-in nightly cross-modal quality probe. The phase runs `gbrain eval longmemeval --by-type` against the committed 10-question placeholder fixture, pipes output through `gbrain eval cross-modal --batch --max-usd 5 --yes`, and writes one event per run to `~/.gbrain/audit/quality-probe-YYYY-Www.jsonl` (ISO-week-rotated, mirrors `audit-slug-fallback.ts`; honors `GBRAIN_AUDIT_DIR`). Default DISABLED — opt-in via `gbrain config set autopilot.nightly_quality_probe.enabled true` (prevents surprise API spend). 24h rate limit (pure `shouldRunNightly(now, recentEvents, windowMs?)`) skips with audit row `outcome: rate_limited`. Embedding-key short-circuit: longmemeval needs `gateway.embedQuery()`, so the phase exits early with `outcome: no_embedding_key` + stderr warn when no provider configured. Full DI surface via `NightlyProbeDeps` (`isEnabled`, `hasEmbeddingProvider`, `resolveMaxUsd`, `resolveRepoRoot`, `runLongMemEval`, `runCrossModalBatch`, `now`) so the unit test stubs every external effect. Cost ceiling: $5/run × 30 nights ≈ $150/month worst-case; expected ~$10.50/month. New `nightly_quality_probe_health` doctor check (`src/commands/doctor.ts`, right after `slug_fallback_audit`) reads last 7 days: SKIPPED when flag off (with enable command); OK when enabled + all PASS; WARN on any FAIL / ERROR / BUDGET_EXCEEDED with per-outcome counts. Pinned by `test/nightly-quality-probe.test.ts`. -- `src/commands/eval-trajectory.ts` + `src/commands/founder-scorecard.ts` + `src/core/trajectory.ts` — temporal trajectory + founder scorecard. `gbrain eval trajectory ` shows the chronological typed-claim history (mrr/arr/team_size/etc) with regressions auto-flagged inline; `gbrain founder scorecard ` rolls up claim_accuracy / consistency / growth_trajectory / red_flags into one JSON. Pure-function math in `trajectory.ts`: `detectRegressions(points, threshold)` walks consecutive metric-value pairs per metric (10% drop default, env override `GBRAIN_TRAJECTORY_REGRESSION_THRESHOLD`); `computeDriftScore(points)` returns `1 - mean(cosine(emb[i], emb[i-1]))` over existing embeddings (null when <3 embedded points). Backed by `BrainEngine.findTrajectory(opts)` — both Postgres and PGLite, single SQL query, deterministic `ORDER BY valid_from ASC, id ASC`. Source-scoped via the `sourceId` scalar / `sourceIds` array dual pattern; visibility-filtered for remote callers. MCP op `find_trajectory` (read scope, NOT localOnly) registered after `find_experts`. Migration v67 adds optional typed-claim columns (`claim_metric`, `claim_value`, `claim_unit`, `claim_period`) + a partial index on `(entity_slug, claim_metric, valid_from) WHERE claim_metric IS NOT NULL`. Fence widens from 10 to 14 cells when any row has typed data; renderer stays at 10 cells when none do (no churn diff on existing fences). Metric labels normalize to lowercase snake_case via `normalizeMetricLabel` (15-entry seed map). The `consolidate` cycle phase does semantic upsert keyed on `(page_id, claim, since_date)` (fixes the duplicate-takes bug where re-running the cycle after `extract_facts` cleared `consolidated_at` appended duplicates via `MAX(row_num)+1`) and writes chronological `valid_until` on each cluster's older facts. The `extract_facts` cycle phase batch-embeds via `gateway.embed()` before insert AND threads `pages.effective_date` as the `pageEffectiveDate` fallback for `valid_from` (precedence: fence-row > pageEffectiveDate > now()). The contradiction probe MUST NOT write `valid_until` — grep guard at `test/eval-contradictions/no-valid-until-write.test.ts`. Haiku extraction lives in `src/core/facts/extract.ts` (not the `extract-facts.ts` cycle phase); `pageEffectiveDate` is OPTIONAL because `fence-write.ts` callers have no Page object. Migration v89 adds a nullable `event_type TEXT` column on `facts` so the substrate carries event-shaped rows (`event_type='meeting'` / `'job_change'` / `'location_change'`) alongside metric rows. `TrajectoryPoint.event_type: string | null` projected by both engines. `TrajectoryOpts.kind?: 'metric' | 'event' | 'all'` filter (default `'all'`); `founder-scorecard` + `eval-trajectory` pass `kind: 'metric'` explicitly. Back-compat pinned by `test/regressions/v0_40_2_0-trajectory-backcompat.test.ts` (byte-identical `computeFounderScorecard` + `computeTrajectoryStats` with and without event rows); engine parity in `test/engine-parity-event-type.test.ts`. +- `src/commands/eval-trajectory.ts` + `src/commands/founder-scorecard.ts` + `src/core/trajectory.ts` — temporal trajectory + founder scorecard. `gbrain eval trajectory ` shows the chronological typed-claim history (mrr/arr/team_size/etc) with regressions auto-flagged inline; `gbrain founder scorecard ` rolls up claim_accuracy / consistency / growth_trajectory / red_flags into one JSON. Pure-function math in `trajectory.ts`: `detectRegressions(points, threshold)` walks consecutive metric-value pairs per metric (10% drop default, env override `GBRAIN_TRAJECTORY_REGRESSION_THRESHOLD`); `computeDriftScore(points)` returns `1 - mean(cosine(emb[i], emb[i-1]))` over existing embeddings (null when <3 embedded points). Backed by `BrainEngine.findTrajectory(opts)` — both Postgres and PGLite, single SQL query, deterministic `ORDER BY valid_from ASC, id ASC`. Source-scoped via the `sourceId` scalar / `sourceIds` array dual pattern; visibility-filtered for remote callers. MCP op `find_trajectory` (read scope, NOT localOnly) registered after `find_experts`. Migration v67 adds optional typed-claim columns (`claim_metric`, `claim_value`, `claim_unit`, `claim_period`) + a partial index on `(entity_slug, claim_metric, valid_from) WHERE claim_metric IS NOT NULL`. Fence widens from 10 to 14 cells when any row has typed data; renderer stays at 10 cells when none do (no churn diff on existing fences). Metric labels normalize to lowercase snake_case via `normalizeMetricLabel` (15-entry seed map). The `consolidate` cycle phase does semantic upsert keyed on `(page_id, claim, since_date)` (fixes the duplicate-takes bug where re-running the cycle after `extract_facts` cleared `consolidated_at` appended duplicates via `MAX(row_num)+1`) and writes chronological `valid_until` on each cluster's older facts. The `extract_facts` cycle phase batch-embeds via `gateway.embed()` before insert AND threads `pages.effective_date` as the `pageEffectiveDate` fallback for `valid_from` (precedence: fence-row > pageEffectiveDate > now()). The contradiction probe MUST NOT write `valid_until` — grep guard at `test/eval-contradictions/no-valid-until-write.test.ts`. Haiku extraction lives in `src/core/facts/extract.ts` (not the `extract-facts.ts` cycle phase); its output cap is config `facts.extraction_max_tokens` (default 4000), a `stopReason: 'length'` response retries once at 2× the cap, and persistent truncation warns loudly on stderr instead of silently extracting zero facts; `pageEffectiveDate` is OPTIONAL because `fence-write.ts` callers have no Page object. Migration v89 adds a nullable `event_type TEXT` column on `facts` so the substrate carries event-shaped rows (`event_type='meeting'` / `'job_change'` / `'location_change'`) alongside metric rows. `TrajectoryPoint.event_type: string | null` projected by both engines. `TrajectoryOpts.kind?: 'metric' | 'event' | 'all'` filter (default `'all'`); `founder-scorecard` + `eval-trajectory` pass `kind: 'metric'` explicitly. Back-compat pinned by `test/regressions/v0_40_2_0-trajectory-backcompat.test.ts` (byte-identical `computeFounderScorecard` + `computeTrajectoryStats` with and without event rows); engine parity in `test/engine-parity-event-type.test.ts`. - `src/core/trajectory-format.ts` — shared `formatTrajectoryBlock(points, entitySlug, opts)` consumed by both `gbrain think` (production) and the LongMemEval harness (benchmark). Groups by `(metric ?? event_type)`, per-metric cap 20, total cap 100, knowledge_update intent annotates value-change rows with `(superseded prior)`. Emits a `` XML envelope — `INJECTION_PATTERNS` in `src/core/think/sanitize.ts` escapes ``, `` open tags, and attribute injection so adversarial fact text can't break out. Pinned by `test/trajectory-format.test.ts`. - `src/core/think/intent.ts` + `src/core/think/entity-extract.ts` — pure `classifyIntent(question)` returns `'temporal' | 'knowledge_update' | 'other'` (regex-first, no LLM, `'other'` fast path short-circuits with zero SQL). `extractCandidateEntities(question, retrievedSlugs)` pulls high-precision candidates from retrieved entity-prefix slugs (`people/`, `companies/`, `organizations/`) and medium-precision noun phrases. Stop-word boundaries + leading-verb stripper handle "When did I last meet Marco" → `marco`. Both consumed by `runThink` and the LongMemEval harness so the two paths cannot drift. Pinned by `test/think-intent.test.ts` and `test/think-entity-extract.test.ts`. - `src/commands/eval-suspected-contradictions.ts` + `src/core/eval-contradictions/{judge,runner,types,date-filter,cost-tracker,cache,severity-classify,cross-source,trends,calibration,judge-errors,auto-supersession,fixture-redact}.ts` — `gbrain eval suspected-contradictions [run|trend|review]`. Probe samples top-K retrieval pairs per query (cross-slug + intra-page chunk-vs-take), date pre-filters (3-rule layered — same-paragraph-dual-date overrides separation rule), LLM judge (query-conditioned; UTF-8-safe truncation; confidence-floor double-enforcement; resolution_kind output drives paste-ready commands), persistent cache keyed on `(chunk_a_hash, chunk_b_hash, model_id, prompt_version, truncation_policy)` (prompt edits cleanly invalidate prior verdicts), Wilson 95% CI calibration on the headline percentage with `small_sample_note` when n<30, judge_errors as first-class typed counters (parse_fail/refusal/timeout/http_5xx/unknown — avoids bias from silent skip), trend writes to `eval_contradictions_runs`, source-tier breakdown reuses `DEFAULT_SOURCE_BOOSTS` prefix logic, deterministic sampling (combined_score DESC + lex tiebreaker for stable cache hit-rate). Hermetic via `judgeFn` + `searchFn` DI in the runner; never touches the real gateway in tests. Engine surface: `BrainEngine.listActiveTakesForPages` (batched), `writeContradictionsRun` + `loadContradictionsTrend`, `getContradictionCacheEntry` + `putContradictionCacheEntry` + `sweepContradictionCache`. Schema migrations v51 + v52. MCP op `find_contradictions` (read scope, NOT localOnly, NOT in subagent allowlist — user-initiated only). Doctor check surfaces high-severity findings with paste-ready resolution commands; synthesize phase pre-fetches the latest probe's top-5-by-severity findings and threads them into `buildSynthesisPrompt` as an informational block. Architecture doc: `docs/contradictions.md`. -- `src/core/think/index.ts` — `runThink` builds its internal `LLMClient` via a small adapter wrapping `gateway.chat()` from `src/core/ai/gateway.ts` (not `new Anthropic()` directly) so stdio MCP launches (Claude Desktop, Cursor) that don't inherit shell env still find a key set via `gbrain config set anthropic_api_key` (the gateway reads `~/.gbrain/config.json` AND env). Test seam: `opts.client?: ThinkLLMClient` injection works (`test/think-pipeline.serial.test.ts`, `test/think-gateway-adapter.test.ts`); `opts.stubResponse` short-circuits before any LLM call. When neither key nor client is available, the "no LLM available" stub fires with `NO_ANTHROPIC_API_KEY`. Trajectory injection (default ON): `runThink` orchestrates `classifyIntent(question)` → `extractCandidateEntities(question, retrievedSlugs)` → `findTrajectory` (5s `Promise.race` timeout per candidate, concurrency cap 3) → `formatTrajectoryBlock`. `buildThinkUserMessage` (in `src/core/think/prompt.ts`) has a `trajectory?: ThinkTrajectoryBlockOpts` slot honoring BOTH prompt orderings (calibration mode: retrieval → calibration → trajectory → question; default mode: question → retrieval → trajectory → instruction). The MCP `think` op handler extracts `sourceScopeOpts(ctx)` to scalar `sourceId` / `allowedSources` / `remote` on `RunThinkOpts` so federated-read OAuth clients can't see trajectory rows outside their source scope. Config key `think.trajectory_enabled` (default `true`). Any error in the trajectory path degrades to "no block injected" + `TRAJECTORY_INJECTION_FAILED` warning — the think call never crashes from trajectory. Production path skips `fallback_slugify` resolutions (avoid querying invented slugs); the LongMemEval harness accepts them. Pinned by `test/think-trajectory-injection.test.ts`. Debug: `GBRAIN_THINK_DEBUG=1 gbrain think "..."` prints the spliced prompt to stderr. +- `src/core/think/index.ts` — `runThink` builds its internal `LLMClient` via a small adapter wrapping `gateway.chat()` from `src/core/ai/gateway.ts` (not `new Anthropic()` directly) so stdio MCP launches (Claude Desktop, Cursor) that don't inherit shell env still find a key set via `gbrain config set anthropic_api_key` (the gateway reads `~/.gbrain/config.json` AND env). Test seam: `opts.client?: ThinkLLMClient` injection works (`test/think-pipeline.serial.test.ts`, `test/think-gateway-adapter.test.ts`); `opts.stubResponse` short-circuits before any LLM call. When neither key nor client is available, the "no LLM available" stub fires with `NO_ANTHROPIC_API_KEY`. Trajectory injection (default ON): `runThink` orchestrates `classifyIntent(question)` → `extractCandidateEntities(question, retrievedSlugs)` → `findTrajectory` (5s `Promise.race` timeout per candidate, concurrency cap 3) → `formatTrajectoryBlock`. `buildThinkUserMessage` (in `src/core/think/prompt.ts`) has a `trajectory?: ThinkTrajectoryBlockOpts` slot honoring BOTH prompt orderings (calibration mode: retrieval → calibration → trajectory → question; default mode: question → retrieval → trajectory → instruction). The MCP `think` op handler maps `sourceScopeOpts(ctx)` onto `RunThinkOpts` via `thinkSourceScopeOpts(ctx)` (operations.ts), and `runThink` threads the scope into `runGather` (`src/core/think/gather.ts`) — so every gather stream (hybrid retrieval, takes keyword + vector via the engines' scoped `searchTakes`/`searchTakesVector`, graph walk via `traversePaths`) AND trajectory resolution stay within the caller's source grant (federated `sourceIds[]` wins over scalar `sourceId`); pinned by `test/e2e/think-source-isolation-pglite.test.ts`. Config key `think.trajectory_enabled` (default `true`). Any error in the trajectory path degrades to "no block injected" + `TRAJECTORY_INJECTION_FAILED` warning — the think call never crashes from trajectory. Production path skips `fallback_slugify` resolutions (avoid querying invented slugs); the LongMemEval harness accepts them. Pinned by `test/think-trajectory-injection.test.ts`. Debug: `GBRAIN_THINK_DEBUG=1 gbrain think "..."` prints the spliced prompt to stderr. - `src/core/operations.ts` extension (orphans fix) — `findOrphanPages` (both engines) filters `p.deleted_at IS NULL` on the candidate side AND adds `JOIN pages src ON src.id = l.from_page_id WHERE src.deleted_at IS NULL` to the EXISTS subquery on the link-source side, so soft-deleted pages don't appear as orphans AND links from soft-deleted source pages don't suppress live pages from orphan results. Pinned by `test/orphans.test.ts`'s soft-delete cases. - `src/commands/eval-longmemeval.ts` + `src/eval/longmemeval/{harness,adapter,sanitize}.ts` — `gbrain eval longmemeval ` runs the public [LongMemEval](https://huggingface.co/datasets/xiaowu0162/longmemeval) benchmark against gbrain's hybrid retrieval. One in-memory PGLite per run via `createBenchmarkBrain` + `withBenchmarkBrain` (NO `EphemeralBrain` class). Between questions, `TRUNCATE` over runtime-enumerated `pg_tables` (schema-migration-safe); infrastructure tables (`sources`, `config`, `gbrain_cycle_locks`, `subagent_rate_leases`) preserved. `cli.ts` pre-dispatch bypass so `eval longmemeval` skips `connectEngine()` — the user's `~/.gbrain` brain is never opened. `--expansion` defaults OFF (deterministic, no per-query Haiku); pass `--expansion` to opt in. Default model via `resolveModel()` 6-tier chain with `models.eval.longmemeval` config key. Sanitization parity: `harness.ts` reuses `INJECTION_PATTERNS` from `src/core/think/sanitize.ts` so adding a pattern covers takes AND benchmarks. Retrieved chat content wrapped in ``; the answer-gen system prompt declares content UNTRUSTED. LLM injection seam: `runEvalLongMemEval(args, {client?: ThinkLLMClient})` lets tests stub the client without an API key. p50 25.9ms / p99 30.3ms warm reset+import+search on Apple Silicon (`test/eval-longmemeval.test.ts` perf gate). Hand the JSONL to LongMemEval's `evaluate_qa.py` to score (not bundled — needs OpenAI gpt-4o). Per-question JSONL row carries `question: string` (additive; `evaluate_qa.py` ignores unknown fields) so `gbrain eval cross-modal --batch` has the `task` text without joining; also `question_type: string` and `recall_hit?: boolean` so a `--resume-from` run rebuilds cumulative `recallByType` from the file alone. `--by-type` flag emits a `{schema_version:1, kind:"by_type_summary", recall_by_type:{...}, aggregate:{...}}` line as the FINAL line; resume-replace strips any prior summary at the tail so 5 resumed runs produce 1 summary. Empty-bucket guard: `aggregate.rate` is `null` (not NaN) when no questions had ground truth. Optional `--by-type-floor F` (0..1) exits non-zero with a stderr line per breached `question_type` (default informational). Pure `buildByTypeSummary(buckets)` + `emitByTypeSummary(path, summary)` + `seedRecallByTypeFromFile(path, bucket)` exported for unit tests. Inline Haiku extractor + trajectory routing (methodology change): `src/eval/longmemeval/extract.ts` runs `extractAndInsertClaims()` over each haystack session before retrieval, populating the benchmark brain's `facts` table inline at import. Single Haiku call per session with content-hash cache (cuts a 3-iteration run from $1.50 to $0.50 when sessions repeat). Per-question alias map (fresh per question, never leaks) collapses `"Marco"` + `"Marco Smith"` + `"marco"` to one canonical slug via first-mention-wins. Fail-open on every error path (malformed JSON, Haiku throw, insert collision, empty array → `inserted: 0`). `getCacheStats()` writes empirical hit rate to stderr. `src/eval/longmemeval/intent.ts` prefers the dataset's `question_type` label before falling back to the SHARED regex set from `src/core/think/intent.ts` — single source of truth means think and longmemeval cannot drift. `runOneQuestion` routes temporal/knowledge_update intents through shared `extractCandidateEntities` → `findTrajectory` → splice into the answer-gen prompt before the retrieved-sessions block. `--no-trajectory` bypasses BOTH extractor and intent routing (baseline default-on vs no-trajectory across 3 seeds with paired-bootstrap CI). JSON envelope adds 5 per-question fields when trajectory routing is on: `intent`, `trajectory_points`, `entity_resolved`, `resolution_source`, `methodology_note`. The `methodology_note` writes to stderr at run completion (`extractor=haiku-preprocess-full-haystack-v1`) — honest disclosure that the published number is "gbrain + Haiku-preprocess pipeline" vs "gbrain alone", NOT directly comparable to baseline LongMemEval scores without that note. Pinned by `test/longmemeval-extract.test.ts`, `test/longmemeval-intent.test.ts`, `test/longmemeval-trajectory-routing.test.ts` (end-to-end through `runEvalLongMemEval` with both clients stubbed). - `docs/eval-bench.md` — contributor guide for using captured data to benchmark retrieval changes before merging. Linked from CONTRIBUTING.md under "Running real-world eval benchmarks (touching retrieval code)". @@ -116,10 +123,10 @@ per-release `**vX.Y.Z:**` narration — CI enforces this - `src/core/search/embedding-column.ts` — single source of truth for "which `content_chunks.*` column does this query rank against?" Pure functions, no engine I/O: `loadRegistry(cfg)` walks the `embedding_columns` config (DB plane, JSON map keyed by column name with `{provider, dimensions, type}` entries), seeds the OpenAI `embedding` builtin when unset, validates everything before it lands (column-name regex, type ∈ `vector | halfvec`, dims in [1, 8192], provider format) using `Object.create(null)` + `Object.hasOwn` so a key like `constructor` rejects instead of resolving to `Object.prototype.constructor`. `resolveColumn(registry, override?, cfg)` is the boundary call: returns a frozen `ResolvedColumn` descriptor (`{name, provider, dimensions, type}`) honoring per-call override → `search_embedding_column` config → `'embedding'` default; throws `UnknownEmbeddingColumnError` with the list of registered names on miss. `isCacheSafe(resolved, cfg)` compares the full embedding SPACE (provider + dimensions + name) against cfg's default so a repointed `embedding` builtin doesn't serve OpenAI-shaped cache rows. `validateResolvedColumn(descriptor)` re-validates hand-rolled descriptors that bypass the registry (internal-SDK passthrough) so the SQL-injection escape hatch through the descriptor field is closed. Consumed by `hybridSearch`, `gateway.embedQuery(text, {embeddingModel, dimensions})`, `cosineReScore`, and the `query` MCP op (per-call `embedding_column` param). Pinned by `test/search/embedding-column.test.ts` (prototype-pollution, descriptor passthrough, env-only Postgres install, empty-brain coverage gate, cache-space comparison). - `src/core/search/rerank.ts` — the call-site abstraction. `applyReranker(query, results, opts)` slots between `dedupResults()` and `enforceTokenBudget()` in `src/core/search/hybrid.ts`. Slices `opts.topNIn` (default 30) by current RRF order, sends to `gateway.rerank()`, reorders by `relevanceScore` desc, appends the un-reranked tail unchanged (recall protection). Fail-open on every `RerankError.reason`: any error logs via `logRerankFailure` and returns the input array unchanged. Stamps `rerank_score` onto reordered items so downstream telemetry sees the new ordering signal. `topNOut: null` is the explicit "don't truncate" signal — semantically distinct from `undefined` ("fall through to mode bundle"). Test seam: `opts.rerankerFn` stubs `gateway.rerank` without the network. - `src/core/search/return-policy.ts` (default OFF) — intent-aware adaptive return-sizing. Pure, dependency-light module that trims the final ranked candidate set to an intent-driven cap instead of returning the full top-K. `entity` intent gets a tight cap; `temporal`/`event`/`general` get a recall-preserving cap. A `minKeep` failsafe (≥1) guarantees a human never gets a silent blank when candidates exist. WHY a cap, not a score-cliff detector: PrecisionMemBench instrumentation (gbrain-evals) measured the rank1→rank2 RRF gap is ~identical whether rank-1 is correct (0.602) or wrong (0.569) — mechanical decay, not a separatrix; rank-1 is right in 94% of single-answer cases, so "return a tight set" is the whole win and cliff-cutting just adds noise. Exports `AdaptiveReturnConfig`, `DEFAULT_ADAPTIVE_RETURN` (frozen: `enabled=false`, `entityMax=2`, `otherMax=6`, `minKeep=1`), `AdaptiveReturnDecision` (`{applied, intent, cap, kept, total}`), `AdaptiveReturnInput` (`boolean | Partial | undefined`), `adaptiveReturnFromConfig(cfg)`, `resolveAdaptiveReturn(perCall, fromConfig)` (defaults → config → per-call merge), `adaptiveReturnEnabled(...)` (cache-skip gate check), `applyAdaptiveReturn(results, intent, cfg)` (the trim). Config knobs (DB or file plane): `search.adaptive_return` (master switch), `search.adaptive_return_entity_max`, `search.adaptive_return_other_max`, `search.adaptive_return_min_keep` (each clamped ≥1). Wired into `hybridSearch` AFTER `applyReranker`, BEFORE the `limit` slice, and ONLY on the first page (`offset===0`) — paginating a confidence-gated set is incoherent, so paginated calls fall through to the fixed limit. Stamps the decision onto `HybridSearchMeta.adaptive_return` for `gbrain search --explain`. `hybridSearchCached` SKIPS the cache when the gate is on (a trimmed set must not be served to a gate-off lookup and vice versa). `SearchOpts.adaptiveReturn` + `HybridSearchMeta.adaptive_return` declared in `src/core/types.ts`. Agent-facing: the `query` op (`src/core/operations.ts`) exposes an `adaptive_return` boolean param whose description instructs the agent WHEN to set it (single-answer → on; breadth/exploration → off; pass `limit:1` for a hard single-answer cap), threaded into `hybridSearchCached` — end users never touch the config knob; their agent decides per query (same pattern as `salience`/`recency`). Pinned by `test/search/return-policy.test.ts` (mechanism) + `test/search/query-op-adaptive-return.test.ts` (agent surface: param exists + description teaches both directions + the never-empty contract). -- `src/core/search/autocut.ts` (default ON in reranked modes) — Weaviate-style autocut: score-discontinuity result-sizing on the cross-encoder rerank separatrix. `applyAutocut(results, scoreOf, cfg)` normalizes the reranker scores, finds the largest consecutive gap, and cuts there when it clears `jumpRatio` (default 0.20); robust to unsorted provider output (cuts on a sorted copy, keeps items in INPUT order via a score threshold), guards `top<=0`/non-finite, never returns empty, and no-ops when <2 results carry a finite `rerank_score` (covers the reranker fail-open path). WHY rerank_score and NOT RRF/cosine: gbrain measured (see `return-policy.ts`) that the RRF rank1→rank2 gap is ~flat whether rank-1 is right or wrong — not a separatrix; the cross-encoder score IS. So autocut runs ONLY where the reranker ran (the floor reaches `balanced`+`tokenmax`; `conservative` is a documented no-op). Exports `AutocutConfig`, `DEFAULT_AUTOCUT` (frozen: `enabled=true, jumpRatio=0.20, minKeep=1`), `AutocutDecision` (`{applied, signal:'rerank'|'none', cut, kept, total, gapRatio}`), `AutocutInput`, `autocutFromConfig`, `resolveAutocut`, `applyAutocut`. Cache-key integration (clean path, not the adaptive-return cache-skip hack): enable+sensitivity flow through `ModeBundle` → `ResolvedSearchKnobs` → `knobsHash` exactly like `graph_signals`. `mode.ts` adds `autocut`/`autocut_jump` (conservative false, balanced/tokenmax true@0.20) AND sets `reranker_top_n_in = searchLimit` for reranked modes (so the reranker scores the full returned set; there is no un-scored tail for autocut to wrongly drop — closes the load-bearing recall finding). `KNOBS_HASH_VERSION` is 8 (title_boost claimed 7; autocut appends 8 — one-time global cache cold-miss on upgrade). Wired into `hybridSearch` AFTER adaptive-return, BEFORE the limit slice, first page only; emits `HybridSearchMeta.autocut`. BOTH the cache-miss `finalMeta` and cache-HIT `cachedMeta` rebuilds carry `autocut`+`adaptive_return`+`mode`+`embedding_column`. Preserves alias-hop exact matches: `applyAutocut` takes an optional `preserve` predicate; hybrid passes `r => r.alias_hit === true` so a canonical page injected by `applyAliasHop` after reranking (no `rerank_score`) is never cut. Agent surface: `query` op `autocut` boolean (ceiling override — `false` forces full top-K); `SearchOpts.autocut`; `--explain` shows per-result `rerank_score`, `formatAutocutSummary` renders the decision when search meta is threaded; `gbrain search modes` attribution; metric glossary `autocut.signal`/`autocut.gap_ratio`. Config: `search.autocut`, `search.autocut_jump`. Default-ON backed by an in-repo eval gate — `test/search/autocut-eval.test.ts` (also `bun run eval:autocut`) measures precision-lift-without-recall-regression over labeled qrels fixtures with modeled cross-encoder distributions (no API key, no sibling repo; runs in CI): mean precision 0.33→0.94, recall 1.00→0.95, ZERO recall regression on enumeration queries. Env-overridable floors. Pinned by `test/search/autocut.test.ts` (pure-fn), `test/search/query-op-autocut.test.ts` (agent surface), `test/search/autocut-integration.serial.test.ts` (IRON-RULE behavioral via `rerankerFn` DI seam: cliff trims, flat doesn't, no-reranker no-ops, `autocut:false` ceiling, composes with adaptive-return), `test/search/autocut-eval.test.ts` (the precision/recall gate), and the v=8 knobsHash assertions in `test/search-mode.test.ts`. +- `src/core/search/autocut.ts` (default ON in reranked modes) — Weaviate-style autocut: score-discontinuity result-sizing on the cross-encoder rerank separatrix. `applyAutocut(results, scoreOf, cfg)` normalizes the reranker scores, finds the largest consecutive gap, and cuts there when it clears `jumpRatio` (default 0.20); robust to unsorted provider output (cuts on a sorted copy, keeps items in INPUT order via a score threshold), guards `top<=0`/non-finite, never returns empty, and no-ops when <2 results carry a finite `rerank_score` (covers the reranker fail-open path). WHY rerank_score and NOT RRF/cosine: gbrain measured (see `return-policy.ts`) that the RRF rank1→rank2 gap is ~flat whether rank-1 is right or wrong — not a separatrix; the cross-encoder score IS. So autocut runs ONLY where the reranker ran (the floor reaches `balanced`+`tokenmax`; `conservative` is a documented no-op). Exports `AutocutConfig`, `DEFAULT_AUTOCUT` (frozen: `enabled=true, jumpRatio=0.20, minKeep=1`), `AutocutDecision` (`{applied, signal:'rerank'|'none', cut, kept, total, gapRatio}`), `AutocutInput`, `autocutFromConfig`, `resolveAutocut`, `applyAutocut`. Cache-key integration (clean path, not the adaptive-return cache-skip hack): enable+sensitivity flow through `ModeBundle` → `ResolvedSearchKnobs` → `knobsHash` exactly like `graph_signals`. `mode.ts` adds `autocut`/`autocut_jump` (conservative false, balanced/tokenmax true@0.20) AND sets `reranker_top_n_in = searchLimit` for reranked modes (so the reranker scores the full returned set; there is no un-scored tail for autocut to wrongly drop — closes the load-bearing recall finding). Autocut folds into `knobsHash` as its own parts entry (`mode.ts:KNOBS_HASH_VERSION` is the single source of truth for the current hash version; every bump is a one-time global cache cold-miss on upgrade). Wired into `hybridSearch` AFTER adaptive-return, BEFORE the limit slice, first page only; emits `HybridSearchMeta.autocut`. BOTH the cache-miss `finalMeta` and cache-HIT `cachedMeta` rebuilds carry `autocut`+`adaptive_return`+`mode`+`embedding_column`. Preserves alias-hop exact matches: `applyAutocut` takes an optional `preserve` predicate; hybrid passes `r => r.alias_hit === true` so a canonical page injected by `applyAliasHop` after reranking (no `rerank_score`) is never cut. Agent surface: `query` op `autocut` boolean (ceiling override — `false` forces full top-K); `SearchOpts.autocut`; `--explain` shows per-result `rerank_score`, `formatAutocutSummary` renders the decision when search meta is threaded; `gbrain search modes` attribution; metric glossary `autocut.signal`/`autocut.gap_ratio`. Config: `search.autocut`, `search.autocut_jump`. Default-ON backed by an in-repo eval gate — `test/search/autocut-eval.test.ts` (also `bun run eval:autocut`) measures precision-lift-without-recall-regression over labeled qrels fixtures with modeled cross-encoder distributions (no API key, no sibling repo; runs in CI): mean precision 0.33→0.94, recall 1.00→0.95, ZERO recall regression on enumeration queries. Env-overridable floors. Pinned by `test/search/autocut.test.ts` (pure-fn), `test/search/query-op-autocut.test.ts` (agent surface), `test/search/autocut-integration.serial.test.ts` (IRON-RULE behavioral via `rerankerFn` DI seam: cliff trims, flat doesn't, no-reranker no-ops, `autocut:false` ceiling, composes with adaptive-return), `test/search/autocut-eval.test.ts` (the precision/recall gate), and the knobsHash assertions in `test/search-mode.test.ts`. - `src/core/ai/recipes/voyage.ts` — Voyage AI openai-compatible recipe. Declares `chars_per_token=1` + `safety_factor=0.5` so the gateway pre-splits Voyage batches at a 60K-character budget (50% of 120K-token cap with the dense-tokenizer ratio), avoiding the backfill loop where tiktoken-grounded budgeting undercounted Voyage's actual token usage. Declares `multimodal_models: ['voyage-multimodal-3']` so the gateway rejects text-only Voyage models pointed at the multimodal endpoint with a clear `AIConfigError` instead of waiting for Voyage's HTTP 400. Recipe docstring at `:7-16` names the seven hosted flexible-dim models that accept `output_dimension` (`voyage-4-large`, `voyage-4`, `voyage-4-lite`, `voyage-3-large`, `voyage-3.5`, `voyage-3.5-lite`, `voyage-code-3`) and notes `voyage-4-nano` is the open-weight variant fixed at 1024-dim that does NOT accept the parameter (negative regression assertion in `test/ai/gateway.test.ts`: `dimsProviderOptions` returns `undefined` for `voyage-4-nano`). `voyage-code-3` is the recommended embedding model for gstack per-worktree code brains (Topology 3 in `docs/architecture/topologies.md`); discoverability surfaces: decision-tree branch in `docs/integrations/embedding-providers.md`, Topology 3 "Recommended embedding model" subsection, runtime nudge from `gbrain reindex --code` against non-code-tuned models. Recipe-shape regression pinned by `test/ai/voyage-code-3-recipe.test.ts`. - `src/core/ai/recipes/anthropic.ts` — Anthropic recipe (chat + expansion touchpoints). Canonical id is `claude-sonnet-4-6` (no date suffix); a reverse alias `claude-sonnet-4-6-20250929 → claude-sonnet-4-6` keeps stale user configs working (rescues `facts.extraction_model` and `models.dream.synthesize`). Recipe-shape regression pinned by `test/anthropic-model-ids.test.ts`. -- `src/core/model-pricing.ts` — single source of truth for paid-cloud CHAT/completion model pricing (USD per 1M tokens, input | output). `CANONICAL_PRICING` is a `provider:model`-keyed table (Anthropic Opus 4.8/4.7/4.6 `$5/$25`, Sonnet 4.6 `$3/$15`, Haiku 4.5 `$1/$5` both dateless + dated, plus OpenAI / Google / Together / DeepSeek panel models). `canonicalLookup(modelId)` resolves bare (`claude-opus-4-8`), colon (`anthropic:claude-opus-4-8`), and slash (`anthropic/...`) forms — bare ids default to the `anthropic:` provider; nested OpenRouter ids (`openrouter:anthropic/...`) intentionally MISS so OpenRouter markup isn't repriced as the inner vendor. Every other chat-pricing table is a DERIVED view of this one (NOT a hand-copied duplicate), so cross-table price drift is structurally impossible. Embeddings live separately in `embedding-pricing.ts` (different unit). Pinned by `test/model-pricing.test.ts` whose drift guard asserts each derived view equals canonical and that the cross-modal panel models are all present. +- `src/core/model-pricing.ts` — single source of truth for paid-cloud CHAT/completion model pricing (USD per 1M tokens, input | output). `CANONICAL_PRICING` is a `provider:model`-keyed table (Anthropic Opus 5/4.8/4.7/4.6 `$5/$25`, Sonnet 4.6 `$3/$15`, Haiku 4.5 `$1/$5` both dateless + dated, plus OpenAI / Google / Together / DeepSeek panel models). `canonicalLookup(modelId)` resolves bare (`claude-opus-4-8`), colon (`anthropic:claude-opus-4-8`), and slash (`anthropic/...`) forms — bare ids default to the `anthropic:` provider; nested OpenRouter ids (`openrouter:anthropic/...`) intentionally MISS so OpenRouter markup isn't repriced as the inner vendor. Every other chat-pricing table is a DERIVED view of this one (NOT a hand-copied duplicate), so cross-table price drift is structurally impossible. Embeddings live separately in `embedding-pricing.ts` (different unit). Pinned by `test/model-pricing.test.ts` whose drift guard asserts each derived view equals canonical and that the cross-modal panel models are all present. - `src/core/anthropic-pricing.ts` — bare-keyed Anthropic VIEW of `model-pricing.ts` (the `anthropic:` canonical entries with the prefix stripped). Kept distinct because many callers look up by bare Claude id and because `estimateMaxCostUsd(modelId, inTokens, maxOutTokens)` carries the null-on-miss contract the dream-cycle budget gate depends on (non-Anthropic ids return null → caller warns `BUDGET_METER_NO_PRICING` once and runs unbounded). `estimateMaxCostUsd` routes bare/colon/slash ids through `splitProviderModelId`. Do NOT hand-edit prices here — the map is derived from canonical, so it cannot drift. `ANTHROPIC_PRICING` is consumed by `budget/budget-tracker.ts`, `minions/batch-projection.ts`, and `cycle/budget-meter.ts`. - `src/core/takes-quality-eval/pricing.ts` — fail-closed budget pricing for `eval takes-quality run --budget-usd N`. `MODEL_PRICING` is a curated `provider:model` allowlist (default panel + likely overrides) whose VALUES are derived from `model-pricing.ts` via `canonicalLookup`; an allowlisted id missing from canonical throws at module load. Schema is `{input_per_1m, output_per_1m}`. A model NOT on the allowlist aborts the run with an actionable error rather than guessing (distinct from `cross-modal-eval/runner.ts`, which silently estimates zero on unknown models — both now source numbers from canonical). - `src/core/budget/budget-tracker.ts` — keystone primitive for the brainstorm cost-cathedral wave. One typed error (`BudgetExhausted` with `reason: 'cost' | 'runtime' | 'no_pricing'`), one schema-stable audit JSONL at `~/.gbrain/audit/budget-YYYY-Www.jsonl`. Contracts: `record()` throws when cumulative spend exceeds cap (the cap is a real ceiling, not a suggestion); `reserve()` hard-fails with `reason: 'no_pricing'` when `maxCostUsd` is set AND the model is missing from pricing maps (warn-once preserved when cap is unset); `extractUsageFromError(err, fallback)` returns `err.usage` when the SDK provides it, else the pessimistic fallback (caller passes `maxOutputTokens`, not the optimistic pre-call estimate). `onExhausted(cb)` fires once synchronously BEFORE the throw propagates so callers can persist checkpoints. Replaces three parallel copies (inline brainstorm class, cycle/budget-meter, eval-contradictions). Adapts the old `BudgetMeter` (public shape preserved + `schema_version: 1` stamped on every dream-budget audit line). Pinned by 18 unit cases. @@ -130,16 +137,17 @@ per-release `**vX.Y.Z:**` narration — CI enforces this - `src/core/remediation-checkpoint.ts` — `doctor --remediate` checkpoint at `~/.gbrain/remediation/.json`. `plan_hash = sha256(JSON.stringify(sorted recommendation ids)).slice(0,16)`. Schema-versioned, atomic `.tmp + rename`. `gbrain doctor --remediate --resume ` (no arg picks newest matching) loads it and skips completed steps. Mismatched plan_hash refuses with a paste-ready message. Cleared on clean completion. Pinned by 13 unit cases. - `src/core/model-config.ts` — Model-string resolution (the seam every internal LLM call walks through). Four-tier system (`ModelTier = 'utility' | 'reasoning' | 'deep' | 'subagent'`) with `TIER_DEFAULTS` (utility→haiku-4-5, reasoning→sonnet-4-6, deep→opus-4-7, subagent→sonnet-4-6) and `tier?: ModelTier` on `ResolveModelOpts`. 8-step resolution chain: cliFlag → deprecated key → config key → `models.default` → `models.tier.` → env var → `TIER_DEFAULTS[tier]` → caller fallback. `isAnthropicProvider(modelString)` checks `provider:model` prefix OR `claude-` bare-id pattern (routes through `splitProviderModelId` from `src/core/model-id.ts` so slash-form ids like `anthropic/claude-sonnet-4-6` classify correctly). `enforceSubagentAnthropic()` is the layer-2 runtime guard: when `tier === 'subagent'` resolves non-Anthropic, it emits a once-per-`(source, model)` stderr warn AND falls back to `TIER_DEFAULTS.subagent` (the Anthropic Messages API tool-loop can't run on OpenAI/Gemini). `_resetDeprecationWarningsForTest()` also clears `_subagentTierWarningsEmitted`. Pinned by `test/model-config.serial.test.ts`. - `src/core/ai/model-resolver.ts` — Recipe-touchpoint validator. `assertTouchpoint(recipe, touchpoint, modelId, extendedModels?)` takes an optional 4th `extendedModels: ReadonlySet`: when the modelId is in that set the native-recipe allowlist throw is bypassed (user explicitly opted in via config, so provider rejection surfaces as `model_not_found` at HTTP call time and `gbrain models doctor` catches it earlier). Default code paths with hardcoded model strings MUST NOT pass `extendedModels` — source typos still fail fast (the fail-fast contract for chat + expand + embed stays intact). -- `src/core/ai/gateway.ts` extension — module-scoped `_extendedModels: Map>` registry feeds `assertTouchpoint`'s 4th-arg path. `reconfigureGatewayWithEngine(engine)` (async, called from `cli.ts` after `engine.connect()`, before every command except `CLI_ONLY` no-DB commands) re-resolves expansion + chat defaults through `resolveModel()` so `models.tier.*` and `models.default` overrides apply to both. `DEFAULT_CHAT_MODEL` is `anthropic:claude-sonnet-4-6`. `__setChatTransportForTests` seam mirrors `__setEmbedTransportForTests` so tests drive `chat()` with a stubbed transport. +- `src/core/ai/gateway.ts` extension — module-scoped `_extendedModels: Map>>` registry feeds `assertTouchpoint`'s extended-model path without broadening unrelated surfaces. `reconfigureGatewayWithEngine(engine)` (async, called from `cli.ts` after `engine.connect()`, before every command except `CLI_ONLY` no-DB commands) re-resolves expansion + chat defaults through `resolveModel()` so `models.tier.*` and `models.default` overrides apply to both. `registerConfigSelectedChatModel(model)` is the narrow runtime seam for a dedicated contextual-synopsis model: the ID joins the chat allowlist but remains rejected for embedding, expansion, and reranking. `DEFAULT_CHAT_MODEL` is `anthropic:claude-sonnet-4-6`. `__setChatTransportForTests` mirrors `__setEmbedTransportForTests` so tests drive `chat()` with a stubbed transport. - `src/core/minions/queue.ts` extension — `MinionQueue.add()` rejects `subagent` jobs whose `data.model` resolves via `isAnthropicProvider()` to a non-Anthropic provider. Lazy-imports `model-config.ts` to avoid pulling engine types into queue's eager-load surface. Layer 1 of the three-layer subagent provider enforcement (layers 2+3: `model-config.ts:enforceSubagentAnthropic` runtime fallback + `src/commands/doctor.ts` `subagent_provider` check). Pinned by `test/agent-cli.test.ts`. -- `src/commands/models.ts` — `gbrain models [--json]` read-only routing dashboard: prints tier defaults (`utility`/`reasoning`/`deep`/`subagent`), the resolved value for each (re-walking the resolution chain), every per-task override (11 `PER_TASK_KEYS`: `models.dream.synthesize`, `models.dream.patterns`, `models.drift`, `models.auto_think`, `models.think`, `models.subagent`, `facts.extraction_model`, `models.eval.longmemeval`, `models.expansion`, `models.chat`, `models.dream.synthesize_verdict`), the alias map, and a source-of-truth column (`default` / `config: ` / `env: `). `gbrain models doctor [--skip=] [--json]` fires a 1-token `gateway.chat()` probe against each configured chat + expansion model and classifies failures into `{model_not_found, auth, rate_limit, network, unknown}`. Wired into `cli.ts` dispatch + `CLI_ONLY` set. A zero-token `embedding_config` probe runs FIRST, before any chat/expansion probes spend money: `probeEmbeddingConfig()` reads `getEmbeddingModel()` + `getEmbeddingDimensions()` and (for Voyage flexible-dim models) checks `isValidVoyageOutputDim(dims)` against `VOYAGE_VALID_OUTPUT_DIMS`. `ProbeStatus` variant `'config'` + optional `fix?: string` on `ProbeResult` surface a paste-ready `gbrain config set ...` line in human + JSON output; touchpoint label `'embedding_config'` joins `'chat'` and `'expansion'`. +- `src/commands/models.ts` — `gbrain models [--json]` read-only routing dashboard: prints tier defaults (`utility`/`reasoning`/`deep`/`subagent`), the resolved value for each (re-walking the resolution chain), every per-task override (13 `PER_TASK_KEYS`, now including provider-neutral `models.contextual_synopsis` with legacy-key/env attribution), the alias map, and a source-of-truth column (`default` / `config: ` / `env: `). `gbrain models doctor [--skip=] [--json]` fires a 1-token `gateway.chat()` probe against each configured chat + expansion model and classifies failures into `{model_not_found, auth, rate_limit, network, unknown}`. Wired into `cli.ts` dispatch + `CLI_ONLY` set. A zero-token `embedding_config` probe runs FIRST, before any chat/expansion probes spend money: `probeEmbeddingConfig()` reads `getEmbeddingModel()` + `getEmbeddingDimensions()` and (for Voyage flexible-dim models) checks `isValidVoyageOutputDim(dims)` against `VOYAGE_VALID_OUTPUT_DIMS`. `ProbeStatus` variant `'config'` + optional `fix?: string` on `ProbeResult` surface a paste-ready `gbrain config set ...` line in human + JSON output; touchpoint label `'embedding_config'` joins `'chat'` and `'expansion'`. - `src/core/init-embed-check.ts` — embedding-key validation at `gbrain init`. `runInitEmbedCheck(opts)` runs a config-only `diagnoseEmbedding` (catches a missing key for ANY provider) plus a best-effort `liveTestEmbed` (1-token `gateway.embed(['probe'], {inputType:'query', abortSignal})`, 5s `AbortController` timeout, never throws — catches an invalid/expired key). Loud warning to stderr; init still exits 0 (`--no-embedding` is the deferred-setup escape; `--skip-embed-check` / `GBRAIN_INIT_SKIP_EMBED_CHECK=1` skip the check). Builds the effective env (`process.env` + file-plane `openai/anthropic/zeroentropy_api_key` from `loadConfigFileOnly()` + `opts.apiKey`) and configures the gateway via `buildGatewayConfig` before diagnose/probe, so the check sees the same keys AND provider base URLs runtime will (no false "missing key" for config.json-keyed users; the probe hits the right endpoint). Init-specific warning text names `--no-embedding` / `--skip-embed-check`, not the sync-flavored `--no-embed`. Wired into `initPGLite` + `initPostgres` in `src/commands/init.ts`, with the result added to the `--json` envelope as `embedding_check {ok, reason?, live_ok?}`. Pinned by `test/init-embed-check.test.ts` (hermetic via the gateway embed-transport seam + `withEnv`). - `src/core/ai/build-gateway-config.ts` — `buildGatewayConfig(c: GBrainConfig): AIGatewayConfig`, extracted from `src/cli.ts` (which re-exports it for back-compat). Lets core modules (`init-embed-check.ts`) reuse it without importing the CLI entrypoint. Single owner of folding file-plane API keys (openai/anthropic/zeroentropy) into the gateway env and threading local-server `*_BASE_URL` env vars into base_urls. `process.env` wins EXCEPT empty-string / undefined values are dropped before the merge, so an injected empty `ANTHROPIC_API_KEY=''` (Claude Code neuters subprocess LLM calls this way) can't clobber a valid config-plane key; `'0'` / `'false'` are preserved. Pinned by `test/ai/build-gateway-config.test.ts`. -- `src/commands/doctor.ts` extension — `subagent_provider` check (layer 3 of 3). Warns when `models.tier.subagent` is explicitly set non-Anthropic (message names the bad value + paste-ready fix `gbrain config set models.tier.subagent anthropic:claude-sonnet-4-6`); also warns when `models.default` would sneak `subagent` into a non-Anthropic provider via tier inheritance. OK when subagent tier resolves to Anthropic. Tests in `test/doctor.test.ts`. +- `src/commands/doctor.ts` extension — `subagent_provider` check (layer 3 of 3). Resolves subagent model config in runtime order (`models.subagent` > `models.default` > `models.tier.subagent` > built-in default) and warns when the selected model lacks native tool-loop capability (message names the bad value + paste-ready fix `gbrain config set models.tier.subagent anthropic:claude-sonnet-4-6`); also warns when `models.default` would sneak `subagent` into a non-Anthropic provider via tier inheritance. OK when subagent tier resolves to Anthropic. Tests in `test/doctor.test.ts`. - `src/core/skill-trigger-index.ts` — Shared loader that unions per-skill SKILL.md frontmatter `triggers:` with curated RESOLVER.md / AGENTS.md rows from `skillsDir` AND the parent dir (preserves the OpenClaw workspace-root layout). UNION semantics: explicit RESOLVER.md rows ADD to frontmatter triggers (don't replace). Dedup keyed on `(skillPath, trigger.trim().toLowerCase())`. Three consumers fold through this primitive — `checkResolvable`, `runRoutingEvalCli`, `mounts-cache.composeResolvers` — so fixing frontmatter reaches all of them. Exports `loadSkillTriggerIndex(skillsDir): SkillTriggerEntry[]`, `entriesToResolverContent(entries): string` (synthesizes a markdown-table resolver string for `runRoutingEval`'s string-content API), `findPrimaryResolverPath(skillsDir): string | null`, the `FRONTMATTER_SECTION` constant, and `_resetWarnedSkillsForTests`. Skip rules: non-directory entries, `_*`/`.*` prefixes, `conventions/`+`migrations/` subdirs, skills with no `SKILL.md` (deprecated `install/` graceful-skipped), no `triggers:` array, or malformed YAML (warn-once + skip). Reuses `parseSkillFrontmatter` from `src/core/skill-frontmatter.ts` (regex-based, not full YAML). Pinned by `test/skill-trigger-index.test.ts` (18 hermetic cases). CI gate `bun run check:resolver` (= `bun src/cli.ts check-resolvable --strict --skills-dir skills/`) wired into `bun run verify`. - `src/core/skill-catalog.ts` — host-repo skill catalog backing the MCP `list_skills` / `get_skill` ops. Lets a thin MCP client (Codex desktop, Claude Code, Claude Cowork, Perplexity) DISCOVER + FOLLOW the agent repo's fat-markdown skills over `gbrain serve` — a skill is prose, so "using" one = fetching its body then calling the gbrain MCP tools the server already exposes. Read-scope, NOT localOnly (defensible only via the full mitigation stack): (1) **publish gate** — `assertPublishEnabled(ctx, publishSkills)`; remote callers require `mcp.publish_skills === true`, default-OFF so an upgrade never silently grants existing read tokens host-skill read; local callers (`ctx.remote === false`) always pass. (2) **path confinement** — `assertSkillNameShape` rejects separators/`..`/null/space before any FS access; the client `name` is a manifest LOOKUP KEY (via `loadOrDeriveManifest`), never a raw path segment; `confineManifestPath` does realpath + relative-containment + `SKILL.md`-regular-file check on EVERY entry (defeats poisoned manifest.json `path`, symlink/`..` escape). (3) **frontmatter allowlist** — `GetSkillResult.frontmatter` projects a safe subset; private `writes_to` + `sources` dropped. (4) **prose-only + 256KB cap** (`MAX_SKILL_MD_BYTES`, env `GBRAIN_MAX_SKILL_MD_BYTES`), size-checked twice (statSync + UTF-8 byte length). (5) **no install_path serve for remote** — remote callers use `autoDetectSkillsDir` (no install-path tier) so a hosted gbrain with no agent repo returns `storage_error`; local callers use `autoDetectSkillsDirReadOnly`. (6) MCP rate-limiter caps call rate. Config reads honor BOTH planes: `readMcpPublishSkills` / `readMcpSkillsDir` prefer the DB plane (`engine.getConfig`) over the file plane (`ctx.config.mcp`). Tool-honesty: `crossReferenceTools(declared, ctx)` splits a skill's declared `tools:` into `usable_tools` vs `unavailable_tools`; `buildSkillCatalog`'s `instructions` envelope (`SKILL_CATALOG_INSTRUCTIONS`) carries the "these are prose, follow-then-call-tools" protocol. Skills are host-filesystem repo-global — `sourceScopeOpts(ctx)` / `ctx.brainId` deliberately do NOT apply. `buildSkillCatalog` is resilient (one malformed/escaping skill is skipped, never throws). Config keys in `src/core/config.ts`: `GBrainConfig.mcp?: { publish_skills?, skills_dir? }` + `KNOWN_CONFIG_KEYS` entries `mcp.publish_skills`/`mcp.publish_skills_prompted`/`mcp.skills_dir` + `mcp.` prefix in `KNOWN_CONFIG_KEY_PREFIXES`. `src/commands/init.ts` writes `config.mcp = { publish_skills: true, ... }` for new installs (existing config wins on re-init). `src/commands/upgrade.ts:runPostUpgrade` adds a one-time consent prompt (gated by `mcp.publish_skills_prompted`; existing installs stay OFF until owner opts in). Two ops register in `src/core/operations.ts` (`list_skills` with optional `section` filter + `cliHints:{name:'skills'}`; `get_skill` taking `name` + `cliHints:{name:'skill', positional:['name']}`) and dynamically import this module to avoid the import cycle (skill-catalog statically imports the `operations` array). Descriptions in `src/core/operations-descriptions.ts` (`LIST_SKILLS_DESCRIPTION`, `GET_SKILL_DESCRIPTION`, `SKILL_CATALOG_INSTRUCTIONS`, `SKILL_CLIENT_GUIDANCE`), pinned by `test/operations-descriptions.test.ts`. CLI: `gbrain skills` / `gbrain skill `. Pinned by `test/skill-catalog.test.ts`, `test/skill-catalog-security.test.ts` (path-confinement / poisoned-manifest / symlink-escape), `test/skill-catalog-transports.test.ts` (publish-gate + remote-vs-local) over `test/fixtures/skill-catalog/`. - `src/core/check-resolvable.ts` — Resolver validation: reachability, MECE overlap, DRY checks, structured fix objects. `CROSS_CUTTING_PATTERNS.conventions` is an array (notability gate accepts `conventions/quality.md` and `_brain-filing-rules.md`). `extractDelegationTargets()` parses `> **Convention:**`, `> **Filing rule:**`, and inline backtick references. DRY suppression is proximity-based via `DRY_PROXIMITY_LINES = 40`. `parseResolverEntries` accepts BOTH the markdown table AND a compact list format (`- **skill-name**: trigger1 | trigger2 | trigger3` or `- skill-name: trigger1 | trigger2`); shapes can mix in one file, folded by the multi-resolver merge. Skill name MUST be kebab-lowercase (regex `[a-z][a-z0-9-]+`) so prose bullets like `- **Note**:`/`- **Convention**:`/`- **TODO**:` don't false-match as skill rows. `skillPath` is ALWAYS derived as `skills//SKILL.md`: an optional `→ \`skills/path\`` (or ASCII `->`) suffix is stripped from the trigger but NOT honored as the path — two consumers (`routing-eval.ts:skillSlugFromPath`, the manifest lookup) assume the convention; use the table format for non-conventional paths. Multi-trigger rows fan out to one entry per trigger sharing the same `skillPath`; `checkResolvable` dedupes so the reachability count counts each skill once. Pinned by `test/check-resolvable.test.ts` (11 cases: bold+plain forms, Unicode+ASCII suffix strip, ellipsis filter, empty pipe segments, mixed shapes, prose-bullet rejection) + `test/check-resolvable-openclaw-compact.test.ts` (8 cases over `test/fixtures/openclaw-compact-resolver/` and `test/fixtures/openclaw-mixed-merge/`). Tutorial: `docs/guides/scaling-skills.md` (three-tier scaling: ~300-skill agent to ~4K tokens/turn from ~25K). - `src/core/repo-root.ts` — Shared `findRepoRoot(startDir?)`: walks up from `startDir` (default `process.cwd()`) looking for `skills/RESOLVER.md`. Zero-dependency, imported by `doctor.ts` and `check-resolvable.ts`; parameterized `startDir` makes tests hermetic. Read-path / write-path split: `autoDetectSkillsDir` (shared, read+write-safe) has tier-0 `$GBRAIN_SKILLS_DIR` operator override ahead of the 4-tier chain. `autoDetectSkillsDirReadOnly` wraps it with a tier-5 install-path fallback that walks up from `fileURLToPath(import.meta.url)` and gates on `isGbrainRepoRoot` so unrelated repos can't false-positive. Read-path callers (`doctor`, `check-resolvable`, `routing-eval`) use the read-only variant; write-path callers (`skillpack install`, `skillify scaffold`, `post-install-advisory`) stay on the shared function so install-from-`~` can't retarget the bundled gbrain `skills/` instead of the user's workspace. `SkillsDirSource` variants `'env_explicit'`, `'install_path'`; `AUTO_DETECT_HINT_READ_ONLY` documents the extra tier. The `--fix` safety gate in `doctor.ts` + `check-resolvable.ts` refuses auto-repair when `detected.source === 'install_path'`. +- `src/core/skills-integrity.ts` — Tamper-evidence manifest for the bundled `skills/` tree (#159); NOT a signature system. Pure functions over `node:crypto` sha256: `computeSkillsManifest(dir)` (recursive, sorted '/'-relative paths, excludes the manifest itself, skips symlinks), `renderSkillsManifest(dir)` (2-space JSON + trailing newline, deterministic), `verifySkillsManifest(dir, manifest)` → `{modified, missing, extra}`. Committed manifest lives at `skills/skills.lock.json` (`SKILLS_MANIFEST_FILENAME`); regenerate via `bun run scripts/generate-skills-manifest.ts`. Consumers: the warn-only `skills_manifest_integrity` doctor check in `src/commands/doctor.ts` (ok/skip when no manifest is present — user workspaces and compiled-binary installs are not drift) and the CI freshness guard `scripts/check-skills-manifest-fresh.sh` (`bun run check:skills-manifest`, in `bun run verify`). Pinned by `test/skills-integrity.test.ts`. - `src/commands/check-resolvable.ts` — Standalone CLI wrapper over `checkResolvable()`. Exports `parseFlags`, `resolveSkillsDir`, `DEFERRED`, `runCheckResolvable`. Exit rule: **1 on any issue (warnings OR errors)**, stricter than doctor's `ok` flag. Stable JSON envelope `{ok, skillsDir, report, autoFix, deferred, error, message}` — same shape on success and error. `--fix` runs `autoFixDryViolations` BEFORE `checkResolvable` (same ordering as doctor). `scripts/skillify-check.ts` subprocess-calls `gbrain check-resolvable --json` (cached per process) and fails loud on binary-missing. AGENTS.md workspaces resolve natively (see `src/core/resolver-filenames.ts`). `DEFERRED[]` is empty. Resolver lookup is the multi-file merge in `src/core/check-resolvable.ts` — entries collected from every `RESOLVER.md`/`AGENTS.md` across the skills dir AND its parent, deduped by `skillPath` (first occurrence wins). Uses `autoDetectSkillsDirReadOnly` so `cd ~ && gbrain check-resolvable` finds bundled skills via the install-path fallback; `--fix` carries the same install-path safety gate (refuses to write when `detected.source === 'install_path'`). - `src/core/resolver-filenames.ts` — central list of accepted routing filenames (`RESOLVER.md`, `AGENTS.md`). Shared by `findRepoRoot`, `check-resolvable`, and skillpack install so every code path walks the same fallback chain. - `src/commands/skillify.ts` + `src/core/skillify/{generator,templates}.ts` — `gbrain skillify scaffold ` creates all stubs for a new skill: SKILL.md, script, tests, routing-eval.jsonl, resolver entry, filing-rules pointer. `gbrain skillify check