mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-18 09:48:17 +00:00
Compare commits
175
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd7aaa6f58 | ||
|
|
1e2f4fb0d0 | ||
|
|
89538914c6 | ||
|
|
f15480b9d0 | ||
|
|
7b5a4b151f | ||
|
|
0a1d2d3833 | ||
|
|
dd0f1e1488 | ||
|
|
241331937a | ||
|
|
bc26c73c4d | ||
|
|
311aeb73cb | ||
|
|
cceca4c846 | ||
|
|
4904167c79 | ||
|
|
0b47afbf40 | ||
|
|
9f81e1d8b2 | ||
|
|
86555e39bb | ||
|
|
3257758492 | ||
|
|
e52957c8ba | ||
|
|
30ead196b9 | ||
|
|
9d8f0771aa | ||
|
|
a948dfd6e2 | ||
|
|
499c53a9a7 | ||
|
|
85a7c829e6 | ||
|
|
3ae6799926 | ||
|
|
c7d05e33c5 | ||
|
|
d7c13eb3d4 | ||
|
|
15b9863d13 | ||
|
|
aecb33e795 | ||
|
|
e5dee4fb78 | ||
|
|
3c26f2eaf2 | ||
|
|
615c33e5b5 | ||
|
|
40d1d4cabc | ||
|
|
d610a845a8 | ||
|
|
f87488ff36 | ||
|
|
ddb39df23a | ||
|
|
88731d8cf3 | ||
|
|
4a00c31b12 | ||
|
|
94662eb1e1 | ||
|
|
2f65ed8da6 | ||
|
|
ca1eed3e95 | ||
|
|
82fe0216ff | ||
|
|
fa313f2969 | ||
|
|
2e554500e2 | ||
|
|
630786dc65 | ||
|
|
d92f0dc86f | ||
|
|
a68379050e | ||
|
|
6d1232d5a6 | ||
|
|
aff34a428a | ||
|
|
f07e9dff11 | ||
|
|
72ec53bb49 | ||
|
|
0244104b8d | ||
|
|
48618bd4bf | ||
|
|
dcaea7fdbb | ||
|
|
c6bfab582e | ||
|
|
c5ac3efe9f | ||
|
|
0a6b697070 | ||
|
|
873587f831 | ||
|
|
78391e8b64 | ||
|
|
f08a51d9de | ||
|
|
7cbb99ffef | ||
|
|
aa5b9e6e2d | ||
|
|
8e3699156f | ||
|
|
cc1783c0e4 | ||
|
|
e77a15f0d8 | ||
|
|
522cfb032a | ||
|
|
273bd0e2be | ||
|
|
7522b90db9 | ||
|
|
42f810c406 | ||
|
|
1ca1a14f24 | ||
|
|
64c191b1d7 | ||
|
|
d49ea83db4 | ||
|
|
d71d50503d | ||
|
|
a228776b8f | ||
|
|
c0b28f104d | ||
|
|
61ef727710 | ||
|
|
532e591655 | ||
|
|
3acd511b80 | ||
|
|
25e4c0c3b1 | ||
|
|
ba27a186ec | ||
|
|
4bb313cd80 | ||
|
|
f39b059ad8 | ||
|
|
42c2d56df3 | ||
|
|
e3806cf46f | ||
|
|
a82a83dbc3 | ||
|
|
03de3246f3 | ||
|
|
37ad1d2104 | ||
|
|
aeb75dc839 | ||
|
|
298b6b01b8 | ||
|
|
d6f929bfbd | ||
|
|
9c1a4b8fce | ||
|
|
3523d8fd7e | ||
|
|
a3000d4630 | ||
|
|
64ad743d98 | ||
|
|
9ada48e600 | ||
|
|
aa05255887 | ||
|
|
241603aab8 | ||
|
|
69be8bb707 | ||
|
|
1116a95926 | ||
|
|
c8ea38421a | ||
|
|
163a83baa3 | ||
|
|
f7295e3308 | ||
|
|
f84bfb57f2 | ||
|
|
ad7114f0ad | ||
|
|
23003a2163 | ||
|
|
4c0ec60275 | ||
|
|
13d95ba0ab | ||
|
|
022a443e9b | ||
|
|
e7439828f1 | ||
|
|
5773736c63 | ||
|
|
56454c6ba8 | ||
|
|
63e79838b9 | ||
|
|
3062859420 | ||
|
|
addf03119d | ||
|
|
dba0ae7b1e | ||
|
|
7376c0266e | ||
|
|
002ac8050f | ||
|
|
f75dbb4ed6 | ||
|
|
437889c0bd | ||
|
|
335e470394 | ||
|
|
7e21e47c15 | ||
|
|
bf7d706bb4 | ||
|
|
bb69aa8b65 | ||
|
|
b4a9c7683d | ||
|
|
3c61e25503 | ||
|
|
c6dc0adf26 | ||
|
|
945fed6105 | ||
|
|
a175dd0047 | ||
|
|
2118f02fc7 | ||
|
|
a12ab5eabc | ||
|
|
e98249a624 | ||
|
|
1057bf4368 | ||
|
|
913d2d7f79 | ||
|
|
f9349ba07f | ||
|
|
e72d93fdb5 | ||
|
|
85286a556c | ||
|
|
a8a3b6df9f | ||
|
|
3aa064bcc6 | ||
|
|
6136e13997 | ||
|
|
b3b43d0f91 | ||
|
|
5b9a87f1a3 | ||
|
|
661f1f05cc | ||
|
|
3fec2123d2 | ||
|
|
176836f84d | ||
|
|
539d015cc5 | ||
|
|
91464564cd | ||
|
|
bd049d2969 | ||
|
|
784358f5fd | ||
|
|
d58bb2b0bb | ||
|
|
b252acfce3 | ||
|
|
6920744dd8 | ||
|
|
3df20f9f18 | ||
|
|
e58abd652c | ||
|
|
a104f98dca | ||
|
|
2ac6959b46 | ||
|
|
18ec732e1b | ||
|
|
fd8be831c5 | ||
|
|
9664cad329 | ||
|
|
fcc6e670f2 | ||
|
|
2a17a4dab5 | ||
|
|
ddd66e1d25 | ||
|
|
faf5cdba54 | ||
|
|
e9fa962929 | ||
|
|
0413c93e72 | ||
|
|
56aac51a08 | ||
|
|
0bbaed2e48 | ||
|
|
90d42d4ff9 | ||
|
|
1d3e45a831 | ||
|
|
3c0436e012 | ||
|
|
47f3dec0e2 | ||
|
|
31bc91690d | ||
|
|
581947f1d4 | ||
|
|
e3da0623d5 | ||
|
|
3ff3c0e2f2 | ||
|
|
64978f8a96 | ||
|
|
3d0abc3018 | ||
|
|
cc48fb7015 |
@@ -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
|
||||
@@ -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)
|
||||
@@ -88,7 +91,7 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -1,14 +1,67 @@
|
||||
name: Release
|
||||
|
||||
# Publishes a GitHub release for every VERSION bump that lands on master:
|
||||
# tag + title `v<VERSION>`, 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<VERSION> 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:
|
||||
@@ -29,9 +82,19 @@ jobs:
|
||||
with:
|
||||
bun-version: 1.3.13
|
||||
- run: bun install
|
||||
- run: bun test
|
||||
- run: bun run verify
|
||||
# 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:
|
||||
@@ -42,16 +105,35 @@ jobs:
|
||||
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@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
|
||||
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
- 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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
+5
-2
@@ -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
|
||||
|
||||
+346
@@ -2,6 +2,352 @@
|
||||
|
||||
All notable changes to GBrain will be documented in this file.
|
||||
|
||||
## [0.42.76.0] - 2026-08-08
|
||||
|
||||
**Mistyped or unsupported flags now fail loudly instead of being silently ignored — including the ones that were supposed to make a command safe.**
|
||||
|
||||
**Strict flag validation, CLI-wide.** Every gbrain command now rejects a flag it does not understand, with a clear error naming the flag and the command, before any work runs. Before, commands read their flags ad hoc and ignored the rest — so `gbrain post-upgrade --dry-run` accepted the flag, ignored it, and applied migrations for real. That class is gone: the legal flags for every command are derived from each command's own source into a generated registry, checked before dispatch, and a command may only advertise a safety flag like `--dry-run` if its code actually reads it. On commands routed through the operations contract, a trailing `--dry-run` is now a real rehearsal switch rather than a no-op. `--json` invocations get the same error as a structured payload, so scripts fail cleanly too.
|
||||
|
||||
**A word of warning (intentional breaking change):** cron jobs or scripts that pass stray, misspelled, or long-removed flags have been running on luck — the flag did nothing. Those invocations now exit with an error naming the flag. That is the point: fix the invocation once and it means what it says forever. Everything after `--` is passthrough and remains untouched.
|
||||
|
||||
**Upgrades can't wedge on forward-referenced columns anymore — as a class.** The v0.42.56.0-era startup wedge (a schema blob referencing a column that pre-existing brains didn't have yet) had two more latent instances waiting in the jobs table. Both are now probed and healed at startup, and the schema coverage guard was rewritten to cross-reference every column referenced by the embedded schema against the set of columns any migration has ever added — so a new forward reference cannot ship without its startup probe. A recovery test walks the exact journey an affected brain takes: failed upgrade, retry on the fixed binary, converge with no leftover state blocking the way.
|
||||
|
||||
**Remote agents get more, within the same fences.** The `think` operation is now available to remote MCP callers as a read-only synthesis — the local CLI can still persist results, while remote callers are forced read-only. Chunk reads now resolve through the same source-scope rules as page reads, so a federated grant that can open a page can also read that page's chunks, and a caller without the grant cannot reach chunks outside its own floor. Chunk payloads also stop carrying raw embedding vectors over the wire — noticeably smaller responses with no behavior change, since no consumer ever read them. Two internal call sites that forward caller identity now treat anything ambiguous as untrusted, matching the fail-closed rule the rest of the codebase already follows.
|
||||
|
||||
**Source-bound clients can be minted over HTTP.** The `/admin/api/register-client` endpoint now accepts `source` and `federatedRead` bindings, mirroring the CLI's `--source` / `--federated-read` flags — so an admin UI or provisioning proxy can create a client confined to a specific brain source without shelling out to the CLI. Omitting both preserves the historical default, and invalid source ids get a structured 400.
|
||||
|
||||
**`gbrain doctor` and `repair-jsonb` see further and misfire less.** The double-encoded-JSON scan now covers the subagent execution columns, and the damage test requires the stored text to actually parse as JSON before flagging it — a legitimate string value that merely starts with `[` or `{` (a log line, a code snippet) is no longer misclassified, and a repair pass can no longer corrupt it. One damaged table no longer aborts the scan of the rest.
|
||||
|
||||
### To take advantage of v0.42.76.0
|
||||
## [0.42.75.0] - 2026-08-08
|
||||
|
||||
**The "PGLite crashes on macOS 26" era is over: gbrain now repairs a torn brain in place, automatically, with your data preserved.**
|
||||
|
||||
The dreaded `RuntimeError: Aborted()` at startup — the one that made zero-config brains unusable after a macOS upgrade and pushed people onto Homebrew Postgres — was never a macOS or WASM bug. An unclean shutdown (typically the upgrade reboot) tears the write-ahead log inside the data dir, and every open after that dies replaying it. gbrain now detects that failure on any command, backs up the WAL state to a sibling directory, resets it in place (the pg_resetwal recovery Postgres has shipped for decades, ported to run against PGLite data dirs), and reopens your brain — pages, embeddings, and history intact. Transactions that never reached a checkpoint may be lost; that is the standard trade for a database that would otherwise not open at all.
|
||||
|
||||
### Added
|
||||
- **Automatic WAL repair on startup.** A torn-WAL abort self-heals on the next gbrain command: backup → in-place reset → retry, with a loud notice naming the backup and recommending `gbrain doctor`. Disable with `GBRAIN_PGLITE_WAL_REPAIR=off`.
|
||||
- **`gbrain pglite-repair`** — the deliberate version: `--dry-run` gives a read-only diagnosis of the data dir; `--yes` runs the same in-place repair manually. Refuses to operate while any live process holds the brain, and never force-removes another process's lock.
|
||||
- **`gbrain doctor` diagnoses unopenable PGLite brains.** A new `pglite_data_dir` check reads the data dir from disk when connect fails, names the right recovery rung (repair vs rebuild), inventories repair backups, and escalates when repairs keep recurring — the signal that something is still killing gbrain mid-write.
|
||||
- **Recovery guardrails throughout:** repair runs only under a cleanly-acquired lock (never after taking over another process's lock, with a quarantine window when a lock's holder couldn't be verified); a live database — including a native Postgres one — is refused by a `postmaster.pid` liveness check; repeated attempts inside one corruption episode reuse one backup instead of stacking copies (newest three episodes retained); a cooldown stops repair loops from silently eating data on machines where crashes keep recurring; and every restore path reports honestly whether your original files are back in place or waiting in the backup.
|
||||
|
||||
### Changed
|
||||
- **`gbrain reinit-pglite` works bare.** The embedding model and dimensions now default from your config file, so the rebuild rung of the recovery ladder is one command mid-outage (explicit flags still win; environment overrides are deliberately ignored so a stale shell export can't change the rebuild target).
|
||||
- **Honest error messages.** The startup-abort hint now names the real cause (torn WAL after an unclean shutdown), states exactly what auto-repair did or why it stood down, and lays out the full ladder: repair → rebuild → engine switch. The docs that claimed PGLite is "incompatible with macOS 26.x" have been rewritten (README, INSTALL, ENGINES) — thanks @roysaurav for the original native-Postgres walkthrough, which remains the engine-switch rung.
|
||||
- Message-less WASM error objects no longer surface as `[object Object]`.
|
||||
|
||||
### Fixed
|
||||
- The classifier that routes startup failures now matches the abort message PGLite actually produces (it previously fell through to a generic hint), while catalog corruption keeps routing to rebuild — WAL repair is never suggested for damage it cannot fix.
|
||||
- Lock-file reads can no longer misclassify a healthy live holder as corrupt (writes are atomic now), a holder owned by another user is treated as alive, and an in-flight acquisition is no longer mistaken for a corrupt lock.
|
||||
|
||||
Credit where due: @yang1996202-cpu (#2575), @AndreLYL (#223), and @roysaurav (#1670) for reports and diagnosis, the #223 thread contributors whose recoveries proved the root cause, and @yestheboxer, whose rejected upstream recovery PR (electric-sql/pglite#994) this port builds on.
|
||||
|
||||
### To take advantage of v0.42.75.0
|
||||
|
||||
```bash
|
||||
gbrain upgrade
|
||||
```
|
||||
|
||||
Nothing to configure. If a cron job or script starts failing with `unknown flag`, that invocation was passing a flag that did nothing — remove or fix the flag and it will not regress silently again.
|
||||
|
||||
### For contributors
|
||||
|
||||
Community fixes absorbed with credit: @colinagent (#2598 think read-scope; the upgrade-rewind e2e pattern from #2623), @guim4dev (#2016 register-client source bindings), @vinsew (#597 repair-jsonb coverage extension), @javieraldape (#2494/#2531 output-correctness class — BigInt-safe local rendering and the search `--json` regression pin land here; parts of both PRs shipped earlier from master). Thank you — superseded PRs are being closed with notes.
|
||||
|
||||
The unit-test runner is now memory-safe on machines running multiple workspaces: shard concurrency adapts to actually-available memory, and a serial rescue lane re-runs files that died to OOM or external kills before calling them failures — a red suite now means real failures, not memory pressure. The flag registry regenerates via `bun run build:flag-registry` and is pinned by freshness, drift, and consumption-evidence guards.
|
||||
If your brain currently won't open, that's it — the next command repairs it. If you'd rather look first: `gbrain pglite-repair --dry-run`.
|
||||
|
||||
## [0.42.74.0] - 2026-08-07
|
||||
|
||||
**Two fixes for agents that reach a brain over the network: takes-holder visibility now works the way you set it, and the voice recipe is safe by default.**
|
||||
|
||||
Legacy bearer tokens served over `gbrain serve --http` now honor the takes-holder allow-list you set with `gbrain auth permissions <token> set-takes-holders`. Before, that setting was read on one serving path but silently ignored on the other, so a remote agent saw only world-held takes no matter what you granted — a token you widened to see brain-held takes saw none of them, and a token you narrowed still saw the public ones. Both directions now behave as configured, an empty grant means "no takes" (not "the default set"), and the two serving paths decode and apply the grant through one shared piece of code so they cannot drift apart again. Tokens with no grant continue to fall back to public-only, so nothing widens on upgrade.
|
||||
|
||||
The bundled voice-agent recipe (`recipes/agent-voice`) ships secure by default. Its reference server now refuses cross-origin browser requests unless you name the origins in `AGENT_VOICE_CORS_ORIGIN`, gates the endpoints that spend your OpenAI key or read your brain so a stray web page can't trigger them, and listens on loopback only until you set `HOST` to expose it. The voice page you run locally is unaffected. Because this recipe is copied into your own repo at install time, `gbrain integrations install agent-voice --refresh` picks up the hardened version.
|
||||
|
||||
### To take advantage of v0.42.74.0
|
||||
|
||||
```bash
|
||||
gbrain upgrade
|
||||
```
|
||||
|
||||
Then, if you serve a brain to remote agents, set each token's takes-holder scope with `gbrain auth permissions <token> set-takes-holders world,brain` (or your desired holders). Voice-recipe operators run `gbrain integrations install agent-voice --refresh --target <your-host-repo>`, then set `AGENT_VOICE_CORS_ORIGIN` if a browser on another origin needs access and `HOST=0.0.0.0` only if the server must listen beyond loopback.
|
||||
|
||||
### For contributors
|
||||
|
||||
Both issues were reported by external security researchers who supplied fixes. Ship-stage adversarial review hardened two more spots: the two serving paths now share one permissions-decode helper (not just the allow-list parser) so a malformed double-encoded row can't make them disagree, and the hot-memory cache key encodes the allow-list collision-free so the empty-vs-absent distinction holds for every holder value. Credit @Derek95king (takes-holder threading) and @sebastiondev (voice-recipe CORS).
|
||||
|
||||
## [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 <dir> --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 <dir> --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 <id> --bound-slug-prefixes <p1,p2|none>` — 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 <client_id> --bound-slug-prefixes partners/alice-example/
|
||||
gbrain auth rescope-client <client_id> --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/<name>.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
|
||||
|
||||
@@ -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
|
||||
|
||||
+44
-4
@@ -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/<name>.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
|
||||
|
||||
```
|
||||
@@ -53,7 +81,7 @@ docs/ Architecture docs
|
||||
|
||||
```bash
|
||||
# Inner edit loop (~85s on a Mac dev box, 3700+ unit tests)
|
||||
bun run test # parallel 8-shard fan-out + serial post-pass
|
||||
bun run test # parallel 4-shard fan-out (memory-adaptive) + serial post-pass
|
||||
bun test test/markdown.test.ts # specific unit test
|
||||
|
||||
# Pre-push gate (matches what CI runs on shard 1 + typecheck)
|
||||
@@ -90,9 +118,12 @@ trailing-newline and exports-count checks.
|
||||
|
||||
### Writing tests that survive the parallel loop
|
||||
|
||||
`bun run test` shards 92+ unit-test files across 8 worker processes. Files in the
|
||||
same shard share a process, so process-global state leaks between them. Four
|
||||
lint rules (`scripts/check-test-isolation.sh`, R1-R4) enforce isolation:
|
||||
`bun run test` shards 1000+ unit-test files across up to 4 worker processes,
|
||||
capping total concurrency (shards × intra-shard files) to available memory and
|
||||
re-running OOM-killed or externally-killed files serially before calling them
|
||||
failures (see `docs/TESTING.md` for the rescue-pass details and knobs). Files
|
||||
in the same shard share a process, so process-global state leaks between them.
|
||||
Four lint rules (`scripts/check-test-isolation.sh`, R1-R4) enforce isolation:
|
||||
|
||||
| Rule | What it bans | Fix |
|
||||
|---|---|---|
|
||||
@@ -189,6 +220,15 @@ automatically appears in the CLI, MCP server, and tools-json:
|
||||
For CLI-only commands (init, upgrade, import, export, files, embed, doctor, sync):
|
||||
1. Create `src/commands/mycommand.ts`
|
||||
2. Add the case to `src/cli.ts`
|
||||
3. Regenerate the flag registry: `bun run build:flag-registry`. The CLI rejects
|
||||
unknown flags before dispatch; each CLI-only command's legal flag set is
|
||||
derived from its source into `src/core/cli-flag-registry.generated.ts`.
|
||||
`test/cli-flag-validation.test.ts` pins registry freshness, drift, and
|
||||
consumption evidence (a safety flag like `--dry-run` may only be advertised
|
||||
if the command's code actually reads it), so a stale registry fails the
|
||||
build. At runtime a missing registry entry fails open — a forgotten regen
|
||||
never bricks a command. Rerun the regen whenever you add or remove a flag
|
||||
on an existing command, too.
|
||||
|
||||
Parity tests (`test/parity.test.ts`) verify CLI/MCP/tools-json stay in sync.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -65,6 +65,16 @@ 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)
|
||||
@@ -206,7 +216,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).
|
||||
|
||||
@@ -307,7 +317,7 @@ 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).
|
||||
**PGLite crashes at startup with `RuntimeError: Aborted()` (often right after a macOS upgrade)?** Not a macOS incompatibility — the OS-upgrade reboot killed gbrain mid-write and tore the data dir's WAL. gbrain now repairs this automatically on the next command (data preserved, backup kept); if auto-repair is disabled or skipped, run `gbrain pglite-repair --dry-run` to diagnose and `gbrain pglite-repair --yes` to repair in place. Full recovery ladder (repair → rebuild → engine switch) in [`docs/ENGINES.md` — Troubleshooting: startup abort](docs/ENGINES.md#troubleshooting-startup-abort-runtimeerror-aborted) and [`docs/INSTALL.md`](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 <provider>:<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.
|
||||
|
||||
|
||||
+18
-23
@@ -34,15 +34,18 @@ 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`
|
||||
|
||||
@@ -104,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+)
|
||||
|
||||
@@ -186,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.
|
||||
|
||||
|
||||
@@ -1,5 +1,160 @@
|
||||
# TODOS
|
||||
|
||||
## Fix-wave 1 follow-ups (upgrade-wedge + trust-seam wave, 2026-08)
|
||||
|
||||
Deferred from the un-wedge-v121 hotfix wave (eng review + codex outside voice
|
||||
CLEARED; every item an explicit review decision). Waves 2–6 of the sequence are
|
||||
planned separately (provider-compat rescue is next; its original 2026-07-24
|
||||
DeepSeek-deprecation deadline has now PASSED — re-verify each cluster against
|
||||
master before starting, several fixes landed independently).
|
||||
|
||||
- [ ] **P2 — Shared strict `parseFlags` helper as the #2185 end-state (eng
|
||||
review 2B).** This wave ships the generated known-flags registry +
|
||||
pre-dispatch validator (parser and registry can drift only until the
|
||||
freshness guard fires). The structural end-state migrates commands onto one
|
||||
shared strict parser so parser == registry by construction; mechanical but
|
||||
touches 60+ command files — its own PR. Where: `src/commands/*.ts`,
|
||||
`src/cli.ts`, `scripts/generate-flag-registry.ts` (retires).
|
||||
- [ ] **P2 — `whoknows` CLI routing (surfaced by the #2035-class sweep).**
|
||||
`handleCliOnly`'s `whoknows` case (the dedicated CLI renderer with
|
||||
thin-client routing) is dead code — the command resolves via the
|
||||
`find_experts` op alias, and adding it to CLI_ONLY trips the alias-collision
|
||||
guard. Decide the intended surface alongside PR #2509 (whoknows --explain
|
||||
per-result factor breakdown) and delete whichever lane loses. Where:
|
||||
`src/cli.ts`, `src/commands/whoknows.ts`, PR #2509.
|
||||
- [ ] **P3 — #2544 second half: per-put_page `getAllSlugs` full scan.** The
|
||||
getChunks egress half shipped in this wave (explicit non-vector column
|
||||
list). The remaining Postgres-egress cost is put_page's per-call
|
||||
`getAllSlugs` table scan — needs a targeted existence probe or cached slug
|
||||
set. Where: `src/core/operations.ts` put_page path, both engines.
|
||||
- [ ] **P3 — #1558 admin-UI register form.** The `/admin/api/register-client`
|
||||
API now accepts `source` + `federatedRead` (this wave, PR #2016 absorbed);
|
||||
the admin SPA form fields + `/admin/api/sources` picker are the UI layer.
|
||||
Where: `src/commands/serve-http.ts` admin SPA blob.
|
||||
- [ ] **P3 — jsonb-integrity surfaces: batch + share (ship-review follow-up).**
|
||||
doctor's jsonbIntegrityCheck runs 2 queries per target (16 round-trips) and
|
||||
duplicates the TARGETS table with repair-jsonb (already drifted once on the
|
||||
jsonPayloadOnly predicate before being mirrored by hand). Batch the counts
|
||||
into one UNION ALL query and extract a shared targets constant
|
||||
(src/core/jsonb-integrity-targets.ts) consumed by both. Where:
|
||||
`src/commands/doctor.ts` jsonbIntegrityCheck, `src/commands/repair-jsonb.ts`.
|
||||
- [ ] **P3 — register-client HTTP-level e2e (ship-review follow-up).** The
|
||||
source/federatedRead lane is covered by unit normalizers + a structural
|
||||
route pin; a DATABASE_URL-gated serve-http e2e (register with bindings →
|
||||
assert stored client via /admin/api/agents; invalid source → 400
|
||||
invalid_source) closes the wire-level gap. Where:
|
||||
`test/e2e/serve-http-oauth.test.ts`.
|
||||
- [ ] **P3 — get_chunks `__all__` sentinel narrows to 'default' (red-team,
|
||||
Wave 3 territory).** `sourceScopeOpts` returns `{}` for a trusted local
|
||||
`--source __all__` caller (documented "spans the brain"), but both engines'
|
||||
getChunks map empty scope to the 'default' floor — the one read op where
|
||||
`{}` is reinterpreted. Fold into the Wave 3 source-federation cluster's
|
||||
`__all__` work (an explicit unscoped signal in the engine signature, or
|
||||
handler-side expansion for trusted callers). Where: `src/core/operations.ts`
|
||||
get_chunks, both engines' getChunks.
|
||||
- [ ] **P3 — #2536 wedged-migration diagnostics.** The v121 wedge aborted
|
||||
initSchema BEFORE runMigrations, so the wedged-migration diagnostics row was
|
||||
never written — operators got a bare SQL error with no remediation hint.
|
||||
Write the diagnostics row (or a stderr remediation block) from the blob-replay
|
||||
catch path too. Where: `src/core/migrate.ts`, `src/commands/apply-migrations.ts`.
|
||||
## WAL-repair wave follow-ups (#223/#1670/#2575)
|
||||
|
||||
- [ ] **P2 — gate auto-repair on an unclean-shutdown marker (adversarial F7).** The classifier
|
||||
deliberately over-matches (`RuntimeError`/`unreachable` → `wasm-abort`). If an unclean
|
||||
shutdown leaves a REPLAYABLE WAL tail (normal crash recovery would restore those committed
|
||||
txns) and the reopen then fails on a transient WASM error (OOM), auto-repair fires, layout
|
||||
validation can't tell torn from replayable, and resetWal discards the tail while the notice
|
||||
says "data preserved." Bounded today (backup always taken + restore + honest failure + repeated
|
||||
attempts capped), but a false-positive-with-successful-retry silently drops committed data.
|
||||
Fix direction (probe-verified): PGLite removes `postmaster.pid` on clean close, so gate AUTO
|
||||
repair (not the manual command) on `postmaster.pid` presence — a clean dir that aborts is not
|
||||
torn-WAL. Requires making the serial regression test stamp a `postmaster.pid` before corrupting
|
||||
(it currently clean-disconnects then corrupts, which the red-team flagged as unfaithful anyway).
|
||||
Needs a recall/precision call before landing.
|
||||
- [ ] **P3 — live non-gbrain PGLite consumer not caught by the postmaster.pid liveness guard
|
||||
(adversarial F8).** PGLite writes a sentinel `postmaster.pid` of `-42`; the liveness refusal in
|
||||
`validateWalRepairTarget` requires `pid > 0`, so it protects native Postgres dirs but not a
|
||||
non-gbrain pglite app that has the dir open (such an app writes no `.gbrain-lock`). Deliberate
|
||||
misuse of `pglite-repair --path <foreign pglite dir>` required. Option: refuse when
|
||||
postmaster.pid holds pid ≤ 0 with a very recent mtime, or document the boundary.
|
||||
- [ ] **P3 — mixed-version torn-lock read (adversarial F10 residual).** The heartbeat + initial
|
||||
lock writes are atomic (tmp+rename) now, but an OLD gbrain binary writing heartbeats IN PLACE
|
||||
while a NEW binary poll-reads can still catch a torn read → corrupt-lock verdict → a live
|
||||
holder's lock reaped → two writers (the #2348 class, version-skew-triggered). The reap marker
|
||||
quarantines repair, not the concurrent open. Cheap hardening: double-read the lock file (~50ms
|
||||
apart) before declaring it corrupt.
|
||||
|
||||
|
||||
- [ ] **P2 — graceful PGLite close on SIGTERM for the remaining long-running paths.**
|
||||
The torn-WAL genesis this wave repairs is an unclean shutdown: `src/core/process-cleanup.ts`
|
||||
releases locks on SIGTERM but never closes the PGlite handle, so `serve` / `jobs work` /
|
||||
`sync` killed mid-write (macOS-upgrade reboot, `systemctl stop`) leave the WAL torn.
|
||||
Autopilot already ships the pattern (d2fd1f29, #3178/#1872: `registerCleanup('autopilot-engine-close', ...)`
|
||||
— abort in-flight work → ≤2s bounded wait inside the 3s cleanup deadline →
|
||||
`engine.disconnect()`, double-call safe; rationale comment at autopilot.ts:438-452).
|
||||
Extend that exact pattern to the remaining long-running PGLite paths (register in
|
||||
connect()/command scope; dedupe so autopilot doesn't double-close), pinned by a serial
|
||||
lifecycle test. Interacts with #2084 exitCode containment + #1337 close ordering — read
|
||||
those comments in pglite-engine.ts first. Auto-repair makes recurrence self-healing
|
||||
meanwhile, so this is prevention, not recovery.
|
||||
- [ ] **P3 — pglite upgrade blocker tracker.** Two couplings make a "routine" pglite bump a
|
||||
breaking change: (a) pglite ≥0.5 removes the `@electric-sql/pglite/vector` export that
|
||||
`pglite-engine.ts` imports (verified against npm); (b) the pg_resetwal port
|
||||
(`src/core/pglite-resetwal.ts`) is coupled to the PG17 pg_control layout
|
||||
(`PG_CONTROL_VERSION` 1700 — guarded at runtime by `WalResetUnsupportedError`, so a
|
||||
mismatched bump makes the repair tool refuse every dir rather than corrupt, but it still
|
||||
means the repair feature silently dies). Any future pglite upgrade wave must revisit BOTH
|
||||
together and re-derive the ControlFileData offset table for the new PG major.
|
||||
|
||||
## serve --http takes-holders + agent-voice hardening follow-ups (filed v0.42.74.0)
|
||||
|
||||
Deferred from the #2529/#2477 security-fix wave (plan-eng-review + codex outside
|
||||
voice CLEARED). None block the wave.
|
||||
|
||||
- [ ] **P2 — Per-OAuth-client `takes_holders` storage (#2529 follow-up).** Legacy
|
||||
bearer tokens honor `access_tokens.permissions.takes_holders` through
|
||||
`verifyAccessToken`; OAuth clients have no equivalent column on `oauth_clients`,
|
||||
so OAuth-minted tokens fail closed to `['world']`. Needs a schema migration
|
||||
(`oauth_clients.takes_holders` JSONB or TEXT[]) + a `register-client` flag +
|
||||
the `verifyAccessToken` JOIN projection. Include surfacing the EFFECTIVE
|
||||
takes-holder scope in `whoami` output as part of this follow-up, so operators
|
||||
can self-diagnose the legacy-vs-OAuth semantic split instead of reading docs.
|
||||
Where: `src/schema.sql`, `src/core/migrate.ts`, `src/core/oauth-provider.ts`,
|
||||
`src/commands/auth.ts`, `src/core/operations.ts` (whoami).
|
||||
- [ ] **P3 — agent-voice Host-header allowlist (DNS-rebinding hardening).** The
|
||||
#2477 fix ships default-deny CORS + an Origin gate on `/session`/`/tool`, but
|
||||
the gate derives self-origin from the `Host` header, so a DNS-rebound page
|
||||
(attacker origin whose host resolves to the operator's loopback) still passes.
|
||||
Validate `Host` against `localhost`/`127.0.0.1`/operator-configured hosts and
|
||||
403 otherwise; slots beside `originAllowed()` in the router. Issue #2477
|
||||
explicitly deferred this. Where: `recipes/agent-voice/code/server.mjs`.
|
||||
- [ ] **P3 — Debounce `last_used_at` in the oauth-provider legacy path.** The
|
||||
legacy branch of `verifyAccessToken` fires an unconditional
|
||||
`UPDATE access_tokens SET last_used_at = now()` on EVERY request, while the
|
||||
legacy HTTP transport debounces the same write to once per 60s via a WHERE
|
||||
clause (`src/mcp/http-transport.ts` validateToken). Apply the same pattern —
|
||||
one fewer write per request on the `serve --http` hot path.
|
||||
Where: `src/core/oauth-provider.ts`.
|
||||
## 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
|
||||
@@ -62,17 +217,20 @@ 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
|
||||
@@ -2557,7 +2715,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 `<PROVIDER>_REFERER` / `<PROVIDER>_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+)
|
||||
|
||||
+4
-3
@@ -19,7 +19,8 @@
|
||||
},
|
||||
"overrides": {
|
||||
"@babel/core": "^7.29.6",
|
||||
"postcss": "^8.5.10",
|
||||
"nanoid": "^3.3.17",
|
||||
"postcss": "^8.5.23",
|
||||
},
|
||||
"packages": {
|
||||
"@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=="],
|
||||
@@ -224,7 +225,7 @@
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="],
|
||||
"nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="],
|
||||
|
||||
@@ -232,7 +233,7 @@
|
||||
|
||||
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||
|
||||
"postcss": ["postcss@8.5.19", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ=="],
|
||||
"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=="],
|
||||
|
||||
|
||||
+2
-1
@@ -20,6 +20,7 @@
|
||||
},
|
||||
"overrides": {
|
||||
"@babel/core": "^7.29.6",
|
||||
"postcss": "^8.5.10"
|
||||
"postcss": "^8.5.23",
|
||||
"nanoid": "^3.3.17"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"express-rate-limit": "^7.5.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"heic-decode": "^2.1.0",
|
||||
"js-yaml": "^3.15.0",
|
||||
"js-yaml": "^3.15.1",
|
||||
"marked": "^18.0.2",
|
||||
"openai": "^4.0.0",
|
||||
"pgvector": "^0.2.0",
|
||||
@@ -53,13 +53,13 @@
|
||||
"overrides": {
|
||||
"@hono/node-server": "^2.0.5",
|
||||
"body-parser": "^2.3.0",
|
||||
"fast-uri": "^3.1.4",
|
||||
"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.25",
|
||||
"ip-address": "^10.1.1",
|
||||
"js-yaml": "^3.15.0",
|
||||
"hono": "^4.12.34",
|
||||
"ip-address": "^10.3.1",
|
||||
"js-yaml": "^3.15.1",
|
||||
"qs": "^6.15.2",
|
||||
},
|
||||
"packages": {
|
||||
@@ -401,7 +401,7 @@
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-uri": ["fast-uri@3.1.4", "", {}, "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw=="],
|
||||
"fast-uri": ["fast-uri@3.1.5", "", {}, "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw=="],
|
||||
|
||||
"fast-xml-builder": ["fast-xml-builder@1.3.0", "", { "dependencies": { "path-expression-matcher": "^1.6.2", "xml-naming": "^0.3.0" } }, "sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ=="],
|
||||
|
||||
@@ -437,7 +437,7 @@
|
||||
|
||||
"heic-decode": ["heic-decode@2.1.0", "", { "dependencies": { "libheif-js": "^1.19.8" } }, "sha512-0fB3O3WMk38+PScbHLVp66jcNhsZ/ErtQ6u2lMYu/YxXgbBtl+oKOhGQHa4RpvE68k8IzbWkABzHnyAIjR758A=="],
|
||||
|
||||
"hono": ["hono@4.12.30", "", {}, "sha512-emn+JoJjrN9YTpRDS5it/UI2SO9BAE37T6I3d963RxcZ81G9A4pr2SZTEiiaiKbzx+NKRg5BZ89fCL7gCJCUog=="],
|
||||
"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=="],
|
||||
|
||||
@@ -447,7 +447,7 @@
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="],
|
||||
"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=="],
|
||||
|
||||
@@ -461,7 +461,7 @@
|
||||
|
||||
"jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="],
|
||||
|
||||
"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=="],
|
||||
"js-yaml": ["js-yaml@3.15.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag=="],
|
||||
|
||||
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
|
||||
|
||||
|
||||
@@ -221,6 +221,66 @@ live in `test/postgres-engine-rls-scope.test.ts`.
|
||||
|
||||
**Migration:** `gbrain migrate --to supabase` exports everything (pages, chunks, embeddings, links, tags, timeline) and imports into Supabase. `gbrain migrate --to pglite` goes the other direction. Bidirectional, lossless.
|
||||
|
||||
### Troubleshooting: startup abort (`RuntimeError: Aborted()`)
|
||||
|
||||
**Symptom:** every PGLite-touching command dies at startup with
|
||||
`PGLite failed to initialize its WASM runtime … Aborted(). Build with
|
||||
-sASSERTIONS for more info.` — commonly first seen right after a macOS
|
||||
upgrade.
|
||||
|
||||
**Real root cause:** corrupt WAL/checkpoint state in the data dir after an
|
||||
unclean shutdown (the OS-upgrade reboot kills gbrain mid-write and tears the
|
||||
write-ahead log; every subsequent open fails WAL replay inside WASM and
|
||||
Emscripten surfaces only the opaque abort). It is **not** a macOS/WASM
|
||||
incompatibility — the same signature reproduces across macOS versions and on
|
||||
Linux, and rebuilding the data dir on the same OS fixes it. No pglite or Bun
|
||||
version bump changes it.
|
||||
|
||||
**Recovery ladder** (top rung first):
|
||||
|
||||
1. **Auto-repair (default).** `PGLiteEngine.connect()` detects the abort,
|
||||
backs up `pg_wal/` + `pg_control` into a sibling
|
||||
`<dataDir>.wal-repair-backup-<ts>/` dir, resets the WAL in place
|
||||
(pg_resetwal semantics — data files preserved; transactions not
|
||||
checkpointed before the corruption may be lost), and retries once. On
|
||||
success it prints a loud stderr notice naming the backup and recommending
|
||||
`gbrain doctor`. Safety bounds: repair only runs under a cleanly-acquired
|
||||
data-dir lock (never after reaping another process's lock), skips for a
|
||||
cooldown window after a failed attempt
|
||||
(`GBRAIN_PGLITE_WAL_REPAIR_COOLDOWN_SECONDS`, default 3600), reuses one
|
||||
backup per corruption episode (newest 3 episodes retained), and restores
|
||||
the original files if the retry still fails. Kill-switch:
|
||||
`GBRAIN_PGLITE_WAL_REPAIR=off`.
|
||||
2. **Manual repair.** `gbrain pglite-repair --dry-run` diagnoses the data dir
|
||||
(read-only); `gbrain pglite-repair --yes` runs the same in-place WAL reset
|
||||
deliberately. Refuses when another gbrain process holds the brain (a live
|
||||
`gbrain serve` is named explicitly) and never force-removes `.gbrain-lock`.
|
||||
3. **Rebuild.** `gbrain reinit-pglite` (embedding model/dimensions default
|
||||
from your config) wipes and re-creates the brain from your brain repo, or
|
||||
manually: back up `~/.gbrain`, move `brain.pglite` aside,
|
||||
`gbrain init --pglite`, re-add sources, `gbrain sync`, `gbrain embed`.
|
||||
Required for *catalog* corruption (58P01 / pgvector load failure) — WAL
|
||||
repair cannot fix that class.
|
||||
4. **Switch engines.** `gbrain init --supabase`, or native Postgres +
|
||||
pgvector (recipe below, contributed by @roysaurav):
|
||||
|
||||
```bash
|
||||
brew install postgresql@17
|
||||
brew services start postgresql@17
|
||||
createdb gbrain
|
||||
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;"
|
||||
# ~/.gbrain/config.json: { "engine": "postgres",
|
||||
# "database_url": "postgresql://localhost:5432/gbrain" }
|
||||
gbrain apply-migrations --yes && gbrain doctor
|
||||
```
|
||||
|
||||
`gbrain doctor` runs a `pglite_data_dir` check whenever a PGLite brain fails
|
||||
to connect: it diagnoses the dir from disk, names the repair command, reports
|
||||
retained repair backups, and escalates when repairs keep recurring (that
|
||||
means the unclean-shutdown genesis is still active — see the ladder's rung 4).
|
||||
|
||||
## JSONB writes: never double-encode (the #2339 trap)
|
||||
|
||||
Writing a JS value into a `jsonb` column has exactly two correct forms. Get this
|
||||
|
||||
+15
-4
@@ -117,7 +117,20 @@ If anything's yellow, `gbrain doctor` names the fix command in the message. Most
|
||||
|
||||
### 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:
|
||||
This crash (`RuntimeError: Aborted()` at engine startup, typically first seen
|
||||
after a macOS upgrade) is **not** a macOS/WASM incompatibility. The upgrade
|
||||
reboot kills gbrain mid-write and tears the data dir's write-ahead log; every
|
||||
subsequent open then fails WAL replay. Recovery ladder:
|
||||
|
||||
1. **Auto-repair (default):** just run any gbrain command — gbrain detects the
|
||||
abort, resets the WAL in place (data preserved; a backup of the pre-repair
|
||||
state is kept next to the data dir), and continues. Then run `gbrain doctor`.
|
||||
2. **Manual repair:** `gbrain pglite-repair --dry-run` to diagnose,
|
||||
`gbrain pglite-repair --yes` to repair in place.
|
||||
3. **Rebuild:** `gbrain reinit-pglite` (wipes and re-creates the brain from
|
||||
your brain repo; embedding settings default from your config).
|
||||
4. **Switch engines** — if you prefer a server database anyway, native
|
||||
Homebrew PostgreSQL works great and supports multiple concurrent agents:
|
||||
|
||||
```bash
|
||||
# Install PostgreSQL + pgvector
|
||||
@@ -144,6 +157,4 @@ 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.
|
||||
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 (plus multi-connection support: several agents can share one Postgres brain, which PGLite's single-process lock doesn't allow).
|
||||
|
||||
@@ -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<VERSION>` 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<VERSION>` 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
|
||||
|
||||
|
||||
+39
-4
@@ -11,7 +11,7 @@ Seven test command tiers, each with a clear scope:
|
||||
|
||||
| Command | What it runs | Wallclock | When to use |
|
||||
|---|---|---|---|
|
||||
| `bun run test` | Parallel unit-test fast loop. 8-shard fan-out via `scripts/run-unit-parallel.sh`, then a serial pass over `*.serial.test.ts`. Excludes `*.slow.test.ts` and `test/e2e/*`. No pre-checks, no typecheck. | ~85s on a Mac dev box (3650+ tests) | Inner edit loop. Default. |
|
||||
| `bun run test` | Parallel unit-test fast loop. Up-to-4-shard fan-out via `scripts/run-unit-parallel.sh` (min(CPUs, 4)), then a serial pass over `*.serial.test.ts`. Excludes `*.slow.test.ts` and `test/e2e/*`. No pre-checks, no typecheck. Memory-safe by default: total concurrency (shards × intra-shard files) is capped to available memory at `GBRAIN_TEST_MEM_PER_FILE_MB` (default 1536 — a PGLite WASM instance) per concurrent file, and two phantom-failure classes are automatically re-run serially (the rescue pass): failures carrying the WASM out-of-memory signature, and shards killed externally (SIGTERM/SIGKILL well before the shard timeout — sibling workspaces' process cleanup, memory jetsam). Phantoms pass serially and the run goes green with an `oom_rescued` note; real failures fail again serially and stay red. Knobs: `GBRAIN_TEST_NO_MEM_ADAPT=1`, `GBRAIN_TEST_NO_OOM_FALLBACK=1`, `GBRAIN_TEST_MAX_CONCURRENCY` (intra-shard, default 4), `GBRAIN_TEST_SHARD_TIMEOUT` / `GBRAIN_TEST_SHARD_KILL_AFTER`, plus `--shards N` / `--max-concurrency N` / `--dry-run` script args. | ~85s on a Mac dev box (3700+ tests) | Inner edit loop. Default. |
|
||||
| `bun run verify` | CI's authoritative pre-test gate set, fanned out in parallel by `scripts/run-verify-parallel.sh`: the full `check:*` battery (~30 checks — privacy, jsonb, progress, source-id, test-isolation, wasm, …) plus `bun run typecheck`. The `CHECKS` array in that script is the single source of truth — CI literally calls `bun run verify` in a dedicated job. | ~16s (parallel; typecheck dominates) | Before pushing; before `/ship`. |
|
||||
| `bun run test:full` | `verify && bun run test && bun run test:slow && [smart e2e]`. The local equivalent of "everything CI runs." Smart e2e: runs e2e only when `DATABASE_URL` is set; else loud skip notice to stderr. | ~3-5min depending on slow + e2e | Pre-merge sanity, before opening a PR. |
|
||||
| `bun run test:slow` | Just the `*.slow.test.ts` set (intentional cold-path correctness checks). | seconds-to-minutes | When touching slow-path code. |
|
||||
@@ -19,12 +19,38 @@ 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/<name>.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."
|
||||
- **Local fast loop** (`scripts/run-unit-shard.sh` via the parallel wrapper) uses round-robin-by-index sharding and EXCLUDES `*.slow.test.ts` AND `*.serial.test.ts`. Local trades coverage for inner-loop speed; CI catches what local skips.
|
||||
|
||||
This divergence is intentional. Don't try to make them equal — the two scripts deliberately solve different problems. The regression test at `test/scripts/run-unit-shard.test.ts` pins what the local fast loop should and shouldn't include.
|
||||
This divergence is intentional. Don't try to make them equal — the two scripts deliberately solve different problems. The regression test at `test/scripts/run-unit-shard.test.ts` pins what the local fast loop should and shouldn't include; `test/scripts/run-unit-parallel.test.ts` pins the wrapper's memory-adaptive concurrency and the OOM/external-kill serial rescue pass.
|
||||
|
||||
### Failure-first logging
|
||||
|
||||
@@ -35,17 +61,26 @@ When `bun run test` finds any failure, the wrapper:
|
||||
3. Writes a one-line-per-shard summary to `.context/test-summary.txt` (`shard N/M: pass=X fail=Y skip=Z rc=W`).
|
||||
4. Exits non-zero. Empty failure log + non-zero exit = infrastructure problem (wedged shard, killed child); the banner says so.
|
||||
|
||||
If a shard wedges (per-shard `GBRAIN_TEST_SHARD_TIMEOUT` cap, default 600s), the wrapper writes `--- shard N: WEDGED after ${SHARD_TIMEOUT}s ---` to the failure log, includes the last 50 lines of the shard log, and proceeds with other shards' results.
|
||||
If a shard wedges (per-shard `GBRAIN_TEST_SHARD_TIMEOUT` cap, default 3000s; `GBRAIN_TEST_SHARD_KILL_AFTER` grace after TERM before KILL, default 30s), the wrapper writes `--- shard N: WEDGED after ${SHARD_TIMEOUT}s ---` to the failure log, includes the last 50 lines of the shard log, and proceeds with other shards' results.
|
||||
|
||||
### File taxonomy
|
||||
|
||||
- `*.test.ts` → fast loop (parallel 8-shard fan-out).
|
||||
- `*.test.ts` → fast loop (parallel up-to-4-shard fan-out, memory-adaptive).
|
||||
- `*.slow.test.ts` → run via `bun run test:slow` only (intentional cold-path tests; would dominate the fast loop's wallclock).
|
||||
- `*.serial.test.ts` → run via `bun run test:serial` after the parallel pass completes; one bun process per file (`--max-concurrency=1` within a shared process is not enough — the module registry still leaks `mock.module`). Quarantine for tests that share file-wide state and race when run alongside other files in the same `bun test` process. Several dozen files, discovered by the `*.serial.test.ts` glob — no list to maintain. Typical residents: `mock.module(...)` users (top-level mocks leak across files in a shard process, e.g. `test/embed.serial.test.ts`), env-coupled files (e.g. `test/brain-registry.serial.test.ts`), and process-lifecycle suites that assert on `process.exitCode` (e.g. `test/pglite-engine-disconnect.serial.test.ts`). **Do not put the parallelism back on a serial file unless you've fixed the contention root cause** (it just re-introduces the flake).
|
||||
- `test/e2e/*.test.ts` → real-Postgres E2E. Skipped when `DATABASE_URL` is unset.
|
||||
- `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):
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -87,6 +87,15 @@ embedding proximity. Four layers, added after the incident in
|
||||
deciding "is this page already here, safe to NOT write a duplicate?" keys off
|
||||
`create_safety`, not a raw blended score.
|
||||
|
||||
**Extraction quarantine lane (issue #160):** pages carrying the unverified
|
||||
auto-extracted markers (frontmatter `provenance: auto-extracted` +
|
||||
`status: unverified`, see `src/core/extraction-review.ts`) rank as ordinary
|
||||
content — they are skipped by the compiled-truth fusion boost and by the
|
||||
`people/`/`companies/` namespace source-boost, and every search result from
|
||||
such a page carries `unverified: true` so agents can label the provenance.
|
||||
Promote or reject them via `gbrain extraction-pending` / `gbrain
|
||||
extraction-review`.
|
||||
|
||||
The `search` MCP/CLI op is **cheap-hybrid** (vector + keyword + RRF + pool +
|
||||
title + alias, expansion off); `query` is the full-control variant. NamedThingBench
|
||||
(`gbrain eval retrieval-quality`) gates these families on every PR. Diagnose a
|
||||
|
||||
@@ -56,7 +56,8 @@ that tuple lights up the `pack_upgrade_available` onboard check.
|
||||
│ gbrain onboard --check --explain shows per-cluster narrative │
|
||||
│ User reviews; if OK, runs: │
|
||||
│ gbrain jobs submit unify-types --allow-protected \ │
|
||||
│ --params '{"target_pack":"gbrain-base-v2"}' │
|
||||
│ --params '{"target_pack":"gbrain-base-v2","apply":true}' │
|
||||
│ (omit "apply":true for a dry-run; that is the default) │
|
||||
│ (Autopilot never auto-fires this; manual_only) │
|
||||
└──────────────────────────┬─────────────────────────────────────┘
|
||||
↓
|
||||
@@ -229,7 +230,7 @@ add `GBRAIN_AUDIT_FULL=1` (v0.43+ TODO; not yet wired).
|
||||
- Per-source pack-upgrade (the handler accepts `sourceId` but
|
||||
`findPackSuccessors` doesn't yet pass it through)
|
||||
- Cross-brain federated mounts that disagree on canonical packs
|
||||
- Automatic rollback (today: manual SQL or `gbrain pages restore`)
|
||||
- Automatic rollback (today: manual SQL or `gbrain restore`)
|
||||
- LLM-assisted mapping_rules codegen from production data (`gbrain
|
||||
schema detect-mappings`; deferred to v0.43+)
|
||||
|
||||
|
||||
@@ -214,7 +214,7 @@ gbrain schema downgrade
|
||||
|
||||
1. `git revert <merge-commit>` — restores the code.
|
||||
2. `gbrain schema downgrade --to gbrain-base` — restores config.
|
||||
3. (Optional) `gbrain pages purge-deleted --older-than 0h` — drops
|
||||
3. (Optional) `gbrain purge-deleted --older-than 0h` — drops
|
||||
v0.39-typed pages that no longer have a matching type in the active
|
||||
pack.
|
||||
|
||||
|
||||
@@ -19,11 +19,13 @@ entire DB from scratch.
|
||||
|
||||
This means:
|
||||
|
||||
- **Disaster recovery is one command.** If your DB volume corrupts, if
|
||||
Postgres eats itself, if PGLite's WASM lock wedges — you don't need
|
||||
a backup. You wipe the DB, re-import from your brain repo, and the
|
||||
derived state regenerates. v0.32.3 ships `gbrain rebuild
|
||||
--confirm-destructive` as the documented one-liner.
|
||||
- **Disaster recovery is a short, boring sequence.** If your DB volume
|
||||
corrupts, if Postgres eats itself, if PGLite's WASM lock wedges — you
|
||||
don't need a backup. You wipe the derived tables (on PGLite,
|
||||
`gbrain reinit-pglite` wipes the whole embedded DB), re-import from
|
||||
your brain repo with `gbrain sync`, and `gbrain extract all`
|
||||
regenerates the derived state. See "Disaster recovery" below for the
|
||||
exact commands.
|
||||
- **Multi-machine sync is git.** Your brain is a repo. Push from one
|
||||
machine, pull from another, and the second machine's DB rebuilds on
|
||||
its next sync. No "back up the database" step.
|
||||
@@ -146,11 +148,9 @@ The promise the rule makes:
|
||||
# Snapshot what's there
|
||||
gbrain stats > /tmp/before.txt
|
||||
|
||||
# Wipe and rebuild
|
||||
gbrain rebuild --confirm-destructive # v0.32.3 — deletes derived tables
|
||||
# (pages + content_chunks survive
|
||||
# the CASCADE-safe design)
|
||||
# OR manually for v0.32.2:
|
||||
# Wipe and rebuild — delete the derived tables (pages + content_chunks
|
||||
# survive the CASCADE-safe design), then re-derive from the repo.
|
||||
# On PGLite, `gbrain reinit-pglite` wipes the whole embedded DB instead.
|
||||
psql -c 'DELETE FROM facts; DELETE FROM takes; DELETE FROM links; DELETE FROM timeline_entries;'
|
||||
gbrain sync
|
||||
gbrain extract all
|
||||
|
||||
@@ -64,7 +64,7 @@ Key files:
|
||||
thin-client routing branches. These commands bypass the operation-layer
|
||||
dispatch in cli.ts (call `engine.foo()` directly), so each gets its own
|
||||
`if (isThinClient(cfg)) { callRemoteTool(...) }` branch that maps CLI flags
|
||||
to op params. `think` is a special case: the server's `think` op
|
||||
intentionally disables `--save`/`--take` for remote callers
|
||||
(operations.ts:1103-1135 trust-boundary gate); thin-client `think` warns
|
||||
loudly when those flags are set.
|
||||
to op params. `think` is a special case: the server's `think` op is
|
||||
read-scoped for OAuth/MCP and intentionally disables `--save`/`--take` for
|
||||
remote callers in its trust-boundary gate; thin-client `think` warns loudly
|
||||
when those flags are set.
|
||||
|
||||
@@ -76,7 +76,8 @@ gbrain onboard --check --explain # per-cluster narrative dry-run
|
||||
↓
|
||||
gbrain jobs submit unify-types \ # PROTECTED + manual_only
|
||||
--allow-protected \
|
||||
--params '{"target_pack":"gbrain-base-v2"}'
|
||||
--params '{"target_pack":"gbrain-base-v2","apply":true}'
|
||||
# omit "apply":true → dry-run (default)
|
||||
↓
|
||||
Handler runs 4 phases:
|
||||
┌─────────────────────────────────────┐
|
||||
@@ -108,8 +109,8 @@ Every primitive ships with a documented rollback:
|
||||
| Operation | Rollback |
|
||||
|-----------|----------|
|
||||
| Retype | `frontmatter.legacy_type = <original>` preserved on every page (D8). One SQL UPDATE restores types: `UPDATE pages SET type = frontmatter->>'legacy_type' WHERE frontmatter ? 'legacy_type'`. |
|
||||
| Page-to-link | Source page soft-deleted with 72h TTL. `gbrain pages restore <slug>` within 72h. Link row stays harmless if source restored. |
|
||||
| Page-to-alias | Source page soft-deleted with 72h TTL. `gbrain pages restore <slug>` within 72h. Alias row stays harmless (or `DELETE FROM slug_aliases WHERE alias_slug = <slug>` to clean up). |
|
||||
| Page-to-link | Source page soft-deleted with 72h TTL. `gbrain restore <slug>` within 72h. Link row stays harmless if source restored. |
|
||||
| Page-to-alias | Source page soft-deleted with 72h TTL. `gbrain restore <slug>` within 72h. Alias row stays harmless (or `DELETE FROM slug_aliases WHERE alias_slug = <slug>` to clean up). |
|
||||
| Active-pack flip | `gbrain schema use gbrain-base` reverses the flip. |
|
||||
|
||||
## What if my brain doesn't fit?
|
||||
@@ -127,7 +128,8 @@ For brains with substantial custom types that deserve their own canonical
|
||||
2. Edit your fork to add page_types + mapping_rules covering your
|
||||
custom domain.
|
||||
3. Target your fork: `gbrain jobs submit unify-types --allow-protected
|
||||
--params '{"target_pack":"my-pack"}'`
|
||||
--params '{"target_pack":"my-pack","apply":true}'` (omit `"apply":true`
|
||||
for a dry-run preview — that is the default)
|
||||
|
||||
Your fork can also declare `migration_from: {pack: gbrain-base-v2,
|
||||
version: "1.x"}` to register itself as a successor — future agents
|
||||
|
||||
@@ -154,10 +154,11 @@ these are the densest source of real bugs in the whole backlog.
|
||||
`aliases:`), first-H1-title, and basename fallback resolution (path-equality-only gives
|
||||
~5.5% edge recall on real vaults). Master shipped global-basename (#1388); the alias/
|
||||
title fallbacks are the still-novel part.
|
||||
- **Schema-pack-aware link extraction** (#1547, @billy-armstrong) — **OPEN, high.** The
|
||||
link extractor's `DIR_PATTERN` is a frozen 16-prefix const that ignores pack-declared
|
||||
`path_prefixes`, so default-pack installs silently lose wikilinks to `person/`,
|
||||
`writing/`, `wiki/*`. Resolve prefixes from the active pack.
|
||||
- **Schema-pack-aware link extraction** (#1547, @billy-armstrong) — **RESOLVED via #2576.**
|
||||
The extractor no longer gates on the frozen `DIR_PATTERN` whitelist: any dir-shaped
|
||||
path produces a candidate and the persist paths' page-existence checks decide, so
|
||||
pack-declared directories (`person/`, `writing/`, `wiki/*`, `ops/`) link without a
|
||||
prefix registry.
|
||||
- **DB-source extraction** (#1539, @afshaker) — **OPEN, high.** The cycle's extract phase
|
||||
only walks the filesystem, so DB-resident pages (imported transcripts, remote-DB brains)
|
||||
never get links/timeline and `brain_score` is capped. Thread `source:'db'`.
|
||||
|
||||
@@ -183,6 +183,6 @@ This also means the best AI agent setups will be open source by default. Closed,
|
||||
|
||||
Software distribution reimagined: the package is a markdown file, the runtime is a sufficiently smart model, the package manager is your AI agent, and the app store is a git repo.
|
||||
|
||||
`gbrain install voice-agent`
|
||||
`gbrain skillpack scaffold voice-agent`
|
||||
|
||||
That's it.
|
||||
|
||||
@@ -31,7 +31,7 @@ receipt file from disk and re-renders it. The other modes need the brain.
|
||||
| `--budget-usd N` | unset | Abort before next call's projected cost would exceed cap. Models without a `pricing.ts` entry fail loud (codex #4). |
|
||||
| `--source db|fs` | `db` | `fs` is reserved for v0.33+. |
|
||||
| `--slug-prefix P` | unset | Filter takes to pages whose slug starts with P. |
|
||||
| `--models a,b,c` | `openai:gpt-4o,anthropic:claude-opus-4-7,google:gemini-1.5-pro` | Comma-separated panel. |
|
||||
| `--models a,b,c` | `openai:gpt-5.2,anthropic:claude-opus-4-7,google:gemini-2.0-flash` | Comma-separated panel. |
|
||||
| `--json` | off | Emit the full receipt to stdout. |
|
||||
|
||||
## Receipt JSON shape (`schema_version: 1`)
|
||||
@@ -50,7 +50,7 @@ receipt file from disk and re-renders it. The other modes need the brain.
|
||||
},
|
||||
"prompt_sha8": "abcd1234",
|
||||
"models_sha8": "abcd1234",
|
||||
"models": ["openai:gpt-4o", "anthropic:claude-opus-4-7", "google:gemini-1.5-pro"],
|
||||
"models": ["openai:gpt-5.2", "anthropic:claude-opus-4-7", "google:gemini-2.0-flash"],
|
||||
"cycles_run": 3,
|
||||
"successes_per_cycle": [3, 3, 2],
|
||||
"verdict": "pass",
|
||||
|
||||
@@ -69,7 +69,7 @@ update_brain_page(slug, new_info, source):
|
||||
page = gbrain get {slug}
|
||||
|
||||
// TIMELINE: always APPEND (never edit existing entries)
|
||||
gbrain add_timeline_entry {slug} {
|
||||
gbrain timeline-add {slug} {
|
||||
date: today,
|
||||
summary: new_info.summary,
|
||||
detail: new_info.detail,
|
||||
|
||||
@@ -46,10 +46,10 @@ on user_shares_media(url_or_file):
|
||||
|
||||
# Step 4: Extract and cross-reference entities
|
||||
for person in transcript.mentioned_people:
|
||||
gbrain add_link <slug> <person_slug>
|
||||
gbrain add_link <person_slug> <slug>
|
||||
gbrain add_timeline_entry <person_slug> \
|
||||
--entry "Discussed in {video_title}: {what_was_said}" \
|
||||
gbrain link <slug> <person_slug>
|
||||
gbrain link <person_slug> <slug>
|
||||
gbrain timeline-add <person_slug> {date} \
|
||||
"Discussed in {video_title}: {what_was_said}" \
|
||||
--source "YouTube: {url}"
|
||||
|
||||
# PATTERN 2: Social Media Bundles
|
||||
@@ -80,8 +80,8 @@ on user_shares_media(url_or_file):
|
||||
|
||||
# Extract entities and cross-reference
|
||||
for entity in bundle.mentioned_entities:
|
||||
gbrain add_link <slug> <entity_slug>
|
||||
gbrain add_link <entity_slug> <slug>
|
||||
gbrain link <slug> <entity_slug>
|
||||
gbrain link <entity_slug> <slug>
|
||||
|
||||
# PATTERN 3: PDFs and Documents
|
||||
elif media.type == "pdf" or media.type == "document":
|
||||
@@ -109,8 +109,8 @@ on user_shares_media(url_or_file):
|
||||
"""
|
||||
|
||||
for entity in document.mentioned_entities:
|
||||
gbrain add_link <slug> <entity_slug>
|
||||
gbrain add_link <entity_slug> <slug>
|
||||
gbrain link <slug> <entity_slug>
|
||||
gbrain link <entity_slug> <slug>
|
||||
|
||||
# Always sync after ingestion
|
||||
gbrain sync
|
||||
@@ -127,7 +127,7 @@ on user_shares_media(url_or_file):
|
||||
## How to Verify
|
||||
|
||||
1. Ingest a YouTube video. Run `gbrain get media/youtube/{slug}`. Confirm the page has: the agent's analysis (not just a summary), key quotes with speaker attribution, and the full diarized transcript.
|
||||
2. Run `gbrain get_links media/youtube/{slug}`. Confirm back-links exist to brain pages for every person and company mentioned in the video.
|
||||
2. Run `gbrain call get_links '{"slug": "media/youtube/{slug}"}'`. Confirm back-links exist to brain pages for every person and company mentioned in the video.
|
||||
3. Pick a person mentioned in the video. Run `gbrain get <person_slug>`. Confirm their timeline has a new entry referencing the video with specific context.
|
||||
4. Ingest a tweet. Confirm the brain page includes the thread context, linked article summaries, and entity cross-references -- not just the tweet text.
|
||||
5. Run `gbrain search "{topic_from_video}"`. Confirm the media page appears in search results (verifies the content is indexed and searchable).
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
# Embedding migration — moving a brain to another embedding provider
|
||||
|
||||
`gbrain migrate embeddings` re-embeds an entire brain onto a different
|
||||
embedding provider/model, safely and resumably. It is the forward path off a
|
||||
sunsetting provider (for example ZeroEntropy's hosted API, which shuts down
|
||||
2026-09-04 and is the shipped default for brains that never picked a model) —
|
||||
but it is provider-agnostic: any configured `provider:model` works as a
|
||||
target.
|
||||
|
||||
Also reachable as `gbrain retrieval-upgrade` (the name `doctor` and the
|
||||
README reference).
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# Preview the work + cost. Changes nothing.
|
||||
gbrain migrate embeddings --to openai:text-embedding-3-small --dry-run
|
||||
|
||||
# Run it (interactive confirm shows chunk count + $ estimate first).
|
||||
gbrain migrate embeddings --to openai:text-embedding-3-small
|
||||
|
||||
# Non-interactive (cron / scripts): --yes is required, else exit 2.
|
||||
gbrain migrate embeddings --to voyage:voyage-3-large --yes
|
||||
```
|
||||
|
||||
`--dim <N>` overrides the target width; it defaults to the provider recipe's
|
||||
declared width and is required for recipes that don't declare one (litellm,
|
||||
llama-server, and other bring-your-own-model providers).
|
||||
|
||||
## What it does, in order
|
||||
|
||||
1. **Plan.** Counts every chunk not already in the target embedding space —
|
||||
including chunks on pages with **no recorded embedding signature**
|
||||
(pages embedded before the v108 provenance stamp). Prices the re-embed
|
||||
from the pricing table; unknown providers print "estimate unavailable"
|
||||
instead of a fabricated number.
|
||||
2. **Consent gate.** Prints the plan; requires an interactive `y` or `--yes`.
|
||||
Non-TTY without `--yes` refuses with exit 2 (mirrors the `reindex-code`
|
||||
gate in [spend-controls](../operations/spend-controls.md)). Unlike the pure
|
||||
cost gates there, `spend.posture=tokenmax` does **not** bypass this one:
|
||||
posture waives the spend *ceiling*, and this gate also guards a
|
||||
destructive schema rebuild. Under `tokenmax` the dollar figure is marked
|
||||
informational and the confirmation is still asked. `--yes` is the single
|
||||
scripted bypass.
|
||||
3. **Live probe.** One tiny embed against the TARGET provider before any
|
||||
mutation — validates the API key, model id, and dimension support in a
|
||||
single call. A bad key fails here, with nothing changed.
|
||||
4. **Env-override gate.** Refuses when `GBRAIN_EMBEDDING_MODEL` /
|
||||
`GBRAIN_EMBEDDING_DIMENSIONS` would silently defeat the switch at
|
||||
runtime (the same guard `ze-switch` uses). `--ignore-env-override` for
|
||||
people running deliberate experiments.
|
||||
5. **Apply.** When the target width differs from the actual column width,
|
||||
runs the same atomic schema transition `ze-switch` uses, in one
|
||||
transaction. It rebuilds **all three dim-pinned text-embedding-space
|
||||
columns** — `content_chunks.embedding`, `query_cache.embedding`, and
|
||||
`facts.embedding` — at the new width, preserving each column's type
|
||||
(`vector` vs `halfvec`) and recreating its HNSW index. Missing any of the
|
||||
three leaves it silently broken: a narrow `query_cache.embedding` makes
|
||||
every cache write and read fail *by design* (the cache swallows errors so
|
||||
it can never break search) for a permanent 0% hit rate, and a narrow
|
||||
`facts.embedding` fails every per-fact embed write. The image/multimodal
|
||||
columns ARE deliberately untouched — they use separate models whose
|
||||
dimensions are independent of the text embedding model.
|
||||
Writes `embedding_model` + `embedding_dimensions` to BOTH config planes
|
||||
(file plane for the runtime gateway, DB plane for doctor), invalidates
|
||||
every chunk still in the old space — **including NULL-signature pages** —
|
||||
and purges the semantic query cache so stale cached results can't be
|
||||
served across the swap.
|
||||
6. **Re-embed.** The standard embed pipeline (`embed --stale --catch-up`)
|
||||
with per-source single-flight locks, rate-limit backoff, stderr progress,
|
||||
and optional DB-contention pacing (`--pace[=mode]`).
|
||||
|
||||
## What the rebuild deletes
|
||||
|
||||
The dimension change **deletes every stored embedding vector** in the brain —
|
||||
they are in the old model's space and unusable. They are not recoverable:
|
||||
going back to the previous provider means paying for a second full re-embed.
|
||||
`content_chunks` vectors are rebuilt by the re-embed pass, the query cache
|
||||
refills on the next query, and fact embeddings are rewritten on their next
|
||||
write (or a `gbrain extract` pass).
|
||||
|
||||
## Resume after a kill
|
||||
|
||||
The NULL-embedding column is the checkpoint. If the run is killed (or some
|
||||
pages fail to embed), re-run the **same command**: chunks already embedded on
|
||||
the target are never re-embedded, the schema/config steps no-op, and the run
|
||||
continues where it stopped. An in-flight marker (`embedding_migration.state`
|
||||
in DB config) records the target; it is cleared only when the backlog drains
|
||||
to zero.
|
||||
|
||||
A page whose chunks straddle two stale batches is embedded correctly but not
|
||||
stamped by the embed loop (which only stamps all-or-nothing per batch), so the
|
||||
migration runs one reconcile pass after the drain that stamps every
|
||||
fully-embedded page. Without it a large brain would report "incomplete" and the
|
||||
re-run would pay again for those pages. `--batch-size N` tunes the batch
|
||||
(default 2000).
|
||||
|
||||
`--no-embed` applies schema + config + invalidation and stops, so you can run
|
||||
the (potentially long) re-embed later or in the background:
|
||||
|
||||
```bash
|
||||
gbrain migrate embeddings --to openai:text-embedding-3-small --yes --no-embed
|
||||
gbrain embed --stale --catch-up --include-null-signature --background
|
||||
```
|
||||
|
||||
## During the migration
|
||||
|
||||
While the re-embed runs, semantic search returns degraded (lexical-arm-only)
|
||||
results for not-yet-re-embedded content. Pick a quiet window for large
|
||||
brains, or use `--pace` to keep the DB responsive.
|
||||
|
||||
## Pages without an embedding signature (#3391)
|
||||
|
||||
Pages embedded before provenance stamping have `embedding_signature IS NULL`
|
||||
and are grandfathered by the routine stale sweep (so an upgrade never
|
||||
surprise-re-embeds a whole corpus). After a provider swap that grandfather
|
||||
clause would silently leave those pages in the OLD embedding space — mixed
|
||||
vector spaces in one index, degrading retrieval with nothing in the logs.
|
||||
|
||||
- `gbrain migrate embeddings` always includes them.
|
||||
- Plain `gbrain embed --stale` warns when a model swap leaves NULL-signature
|
||||
pages behind, and `gbrain embed --stale --include-null-signature` re-embeds
|
||||
them.
|
||||
|
||||
## Reranker
|
||||
|
||||
Migrating embeddings does not touch the reranker. If
|
||||
`search.reranker.model` points at the outgoing provider, the plan prints a
|
||||
warning; disable it (`gbrain config set search.reranker.enabled false`) or
|
||||
point it at another provider.
|
||||
|
||||
## Self-hosting instead of migrating
|
||||
|
||||
If the outgoing model's weights are available (zembed-1's are Apache-2.0),
|
||||
serving them locally via `llama-server` / `ollama` / a LiteLLM proxy
|
||||
preserves your existing vectors — no re-embed at all. Point
|
||||
`embedding_model` at the local recipe and keep the same dimensions. The
|
||||
migration command is for when you'd rather move to a hosted provider.
|
||||
@@ -49,23 +49,23 @@ on enrich(entity, trigger):
|
||||
data["contacts"] = google_contacts(entity.email) # Contact data
|
||||
|
||||
# Step 5: Store raw data (auditable, re-processable)
|
||||
gbrain put_raw_data <entity_slug> \
|
||||
--data '{"sources": {"crustdata": {"fetched_at": "...", "data": {...}}, ...}}'
|
||||
gbrain call put_raw_data \
|
||||
'{"slug": "<entity_slug>", "data": {"sources": {"crustdata": {"fetched_at": "...", "data": {...}}, ...}}}'
|
||||
# Overwrite on re-enrichment, don't append
|
||||
|
||||
# Step 6: Write to brain page
|
||||
if path == "CREATE":
|
||||
gbrain put <entity_slug> --content "<compiled_truth_from_all_sources>"
|
||||
gbrain add_timeline_entry <entity_slug> --entry "Page created via enrichment"
|
||||
gbrain timeline-add <entity_slug> {date} "Page created via enrichment"
|
||||
elif path == "UPDATE":
|
||||
# Append timeline, update compiled truth ONLY if materially new
|
||||
gbrain add_timeline_entry <entity_slug> --entry "Enriched: {new_signal}"
|
||||
gbrain timeline-add <entity_slug> {date} "Enriched: {new_signal}"
|
||||
# Flag contradictions -- don't silently resolve them
|
||||
|
||||
# Step 7: Cross-reference the graph
|
||||
gbrain add_link <person_slug> <company_slug> # person -> company
|
||||
gbrain add_link <company_slug> <person_slug> # company -> person
|
||||
gbrain add_link <person_slug> <deal_slug> # person -> deal
|
||||
gbrain link <person_slug> <company_slug> # person -> company
|
||||
gbrain link <company_slug> <person_slug> # company -> person
|
||||
gbrain link <person_slug> <deal_slug> # person -> deal
|
||||
# Every entity page links to every other entity page that references it
|
||||
|
||||
# People page sections (not a LinkedIn profile -- a living portrait):
|
||||
@@ -94,8 +94,8 @@ on enrich(entity, trigger):
|
||||
## How to Verify
|
||||
|
||||
1. Enrich a Tier 1 person. Run `gbrain get <slug>` and confirm the page has Executive Summary, State, What They Believe, Contact, and Timeline sections populated from multiple sources.
|
||||
2. Run `gbrain get_raw_data <slug>`. Confirm raw API responses are stored with `sources.{provider}.fetched_at` timestamps.
|
||||
3. Run `gbrain get_links <slug>`. Confirm cross-reference links exist to the person's company page, deal pages, and related entities.
|
||||
2. Run `gbrain call get_raw_data '{"slug": "<slug>"}'`. Confirm raw API responses are stored with `sources.{provider}.fetched_at` timestamps.
|
||||
3. Run `gbrain call get_links '{"slug": "<slug>"}'`. Confirm cross-reference links exist to the person's company page, deal pages, and related entities.
|
||||
4. Check a page that was enriched AND has a user-written Assessment. Confirm the Assessment section was preserved, not overwritten by API data.
|
||||
5. Try to re-enrich the same person. Confirm the system checks the `fetched_at` timestamp and skips if less than a week old.
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ on upcoming_meeting(meeting):
|
||||
"last_interaction": page.timeline[0], # most recent
|
||||
"open_threads": page.open_threads,
|
||||
"relationship_temperature": page.relationship,
|
||||
"relevant_deals": gbrain get_links <attendee_slug>,
|
||||
"relevant_deals": gbrain call get_links '{"slug": "<attendee_slug>"}',
|
||||
}
|
||||
else:
|
||||
briefing[attendee] = "No brain page -- consider enriching"
|
||||
@@ -67,14 +67,14 @@ on inbox_cleared():
|
||||
for email in processed_emails:
|
||||
if email.contained_new_information:
|
||||
# Update the sender's brain page with new signal
|
||||
gbrain add_timeline_entry <sender_slug> \
|
||||
--entry "Email re: {subject}. Key info: {extracted_signal}" \
|
||||
gbrain timeline-add <sender_slug> {date} \
|
||||
"Email re: {subject}. Key info: {extracted_signal}" \
|
||||
--source "email from {sender} re {subject}, {date}"
|
||||
|
||||
# Update any mentioned entity pages too
|
||||
for entity in email.mentioned_entities:
|
||||
gbrain add_timeline_entry <entity_slug> \
|
||||
--entry "{what_was_said_about_them}" \
|
||||
gbrain timeline-add <entity_slug> {date} \
|
||||
"{what_was_said_about_them}" \
|
||||
--source "email from {sender}, {date}"
|
||||
|
||||
# WORKFLOW 4: Scheduling Nudges
|
||||
|
||||
@@ -32,15 +32,15 @@ on new_meeting_transcript(meeting):
|
||||
|
||||
# Step 3: Propagate to ALL entity pages (MANDATORY -- most agents skip this)
|
||||
for person in meeting.attendees + meeting.mentioned_people:
|
||||
gbrain add_timeline_entry <person_slug> \
|
||||
--entry "Met in '{meeting.title}' on {date}. Key points: ..." \
|
||||
gbrain timeline-add <person_slug> {date} \
|
||||
"Met in '{meeting.title}' on {date}. Key points: ..." \
|
||||
--source "Meeting notes '{meeting.title}', {date}"
|
||||
# Update their State section if new information surfaced
|
||||
# Update company pages for each person's company if relevant
|
||||
|
||||
for company in meeting.mentioned_companies:
|
||||
gbrain add_timeline_entry <company_slug> \
|
||||
--entry "Discussed in '{meeting.title}': {what_was_said}" \
|
||||
gbrain timeline-add <company_slug> {date} \
|
||||
"Discussed in '{meeting.title}': {what_was_said}" \
|
||||
--source "Meeting notes '{meeting.title}', {date}"
|
||||
|
||||
# Step 4: Extract action items
|
||||
@@ -49,8 +49,8 @@ on new_meeting_transcript(meeting):
|
||||
|
||||
# Step 5: Back-link everything (bidirectional graph)
|
||||
for entity in all_entities_mentioned:
|
||||
gbrain add_link <slug> <entity_slug> # meeting -> entity
|
||||
gbrain add_link <entity_slug> <slug> # entity -> meeting
|
||||
gbrain link <slug> <entity_slug> # meeting -> entity
|
||||
gbrain link <entity_slug> <slug> # entity -> meeting
|
||||
|
||||
# Step 6: Sync so new pages are immediately searchable
|
||||
gbrain sync
|
||||
@@ -73,7 +73,7 @@ on new_meeting_transcript(meeting):
|
||||
1. After ingesting a meeting, run `gbrain get meetings/{date}-{slug}`. Confirm the page has the agent's analysis above the bar and the full diarized transcript below it.
|
||||
2. For each attendee, run `gbrain get <attendee_slug>`. Check that their timeline has a new entry referencing the meeting with specific insights (not just "attended meeting").
|
||||
3. Pick a company mentioned in the meeting. Run `gbrain get <company_slug>`. Confirm a timeline entry exists referencing what was discussed about the company.
|
||||
4. Run `gbrain get_links meetings/{date}-{slug}`. Verify back-links exist to all attendee and entity pages.
|
||||
4. Run `gbrain call get_links '{"slug": "meetings/{date}-{slug}"}'`. Verify back-links exist to all attendee and entity pages.
|
||||
5. Run `gbrain search "{meeting_topic}"`. Confirm the meeting page appears in search results (verifies sync ran).
|
||||
|
||||
---
|
||||
|
||||
@@ -59,6 +59,12 @@ streaming progress to stderr. It is idempotent: re-running with the same
|
||||
language produces identical vectors. `--json` prints a machine-readable
|
||||
result envelope but still requires `--yes` (or an interactive confirm).
|
||||
|
||||
No cache purge is needed. The resolved language is part of the query-cache
|
||||
key, so rows written under the previous language are unreachable after the
|
||||
switch — searches read the retokenized index immediately instead of being
|
||||
served pre-switch results for up to `search.cache.ttl_seconds`. Switching
|
||||
back reaches the original rows rather than rebuilding them.
|
||||
|
||||
## Recipe: accent-insensitive Portuguese (`pt_br`)
|
||||
|
||||
Brazilian Portuguese content often mixes accented and unaccented spellings
|
||||
|
||||
@@ -91,7 +91,7 @@ first):
|
||||
6. The seeded `default` source.
|
||||
|
||||
So inside `~/.gstack/plans/` on a brain that pinned `gstack` to
|
||||
`~/.gstack` via `.gbrain-source`, `gbrain put-page` implicitly writes to
|
||||
`~/.gstack` via `.gbrain-source`, `gbrain put` implicitly writes to
|
||||
the `gstack` source. Outside any registered directory with no env/dotfile
|
||||
set, it writes to the default.
|
||||
|
||||
@@ -188,10 +188,10 @@ citations keep working.
|
||||
|
||||
```bash
|
||||
# Pass --source explicitly
|
||||
gbrain put-page topics/ai ... --source wiki
|
||||
gbrain put topics/ai ... --source wiki
|
||||
|
||||
# Or rely on the dotfile / env / CWD match
|
||||
cd ~/.gstack && gbrain put-page plans/multi-repo ...
|
||||
cd ~/.gstack && gbrain put plans/multi-repo ...
|
||||
# → source auto-resolves to gstack
|
||||
```
|
||||
|
||||
|
||||
@@ -20,8 +20,8 @@ on every_inbound_message(message):
|
||||
for entity in entities:
|
||||
existing = gbrain search "{entity.name}"
|
||||
if existing:
|
||||
gbrain add_timeline_entry <entity_slug> \
|
||||
--entry "{what_was_said}" \
|
||||
gbrain timeline-add <entity_slug> {date} \
|
||||
"{what_was_said}" \
|
||||
--source "User, direct message, {timestamp}"
|
||||
# else: flag for enrichment if important enough
|
||||
|
||||
@@ -64,13 +64,13 @@ on nightly_schedule("02:00"):
|
||||
# The brain COMPOUNDS overnight.
|
||||
|
||||
# 5a: Entity sweep -- find unlinked mentions
|
||||
pages = gbrain list_pages
|
||||
pages = gbrain list
|
||||
for page in pages:
|
||||
mentions = extract_entity_mentions(page.content)
|
||||
existing_links = gbrain get_links <page.slug>
|
||||
existing_links = gbrain call get_links '{"slug": "<page.slug>"}'
|
||||
for mention in mentions:
|
||||
if mention not in existing_links:
|
||||
gbrain add_link <page.slug> <mention_slug> # fix broken graph
|
||||
gbrain link <page.slug> <mention_slug> # fix broken graph
|
||||
|
||||
# 5b: Citation audit -- find facts without sources
|
||||
for page in pages:
|
||||
@@ -80,7 +80,7 @@ on nightly_schedule("02:00"):
|
||||
|
||||
# 5c: Memory consolidation -- update compiled truth from timeline
|
||||
for page in stale_pages(older_than="7d"):
|
||||
timeline = gbrain get_timeline <page.slug>
|
||||
timeline = gbrain timeline <page.slug>
|
||||
if timeline.has_new_entries_since_last_consolidation:
|
||||
# Re-synthesize compiled truth from accumulated timeline
|
||||
updated_truth = consolidate(page.compiled_truth, timeline.new_entries)
|
||||
@@ -110,11 +110,11 @@ on nightly_schedule("02:00"):
|
||||
|
||||
## How to Verify
|
||||
|
||||
1. Send a message mentioning a person with a brain page. Confirm the agent detects the entity and adds a timeline entry to their page (`gbrain get_timeline <slug>`).
|
||||
1. Send a message mentioning a person with a brain page. Confirm the agent detects the entity and adds a timeline entry to their page (`gbrain timeline <slug>`).
|
||||
2. Ask the agent about someone in the brain. Confirm it runs `gbrain search` or `gbrain get` BEFORE reaching for external APIs (check the tool call order).
|
||||
3. Write a new page with `gbrain put`, then immediately run `gbrain search` for it. Confirm it appears in results (verifies sync ran).
|
||||
4. Run `gbrain doctor`. Confirm it returns a health report with database status, page count, and any flagged issues.
|
||||
5. After a dream cycle runs, check a page that had unlinked entity mentions. Confirm new links were added (`gbrain get_links <slug>`).
|
||||
5. After a dream cycle runs, check a page that had unlinked entity mentions. Confirm new links were added (`gbrain call get_links '{"slug": "<slug>"}'`).
|
||||
|
||||
---
|
||||
*Part of the [GBrain Skillpack](../GBRAIN_SKILLPACK.md).*
|
||||
|
||||
@@ -47,8 +47,8 @@ on user_message(message):
|
||||
|
||||
# Step 3: Cross-link to everything that shaped the thinking
|
||||
for entity in idea.influences:
|
||||
gbrain add_link originals/{slug} <entity_slug>
|
||||
gbrain add_link <entity_slug> originals/{slug}
|
||||
gbrain link originals/{slug} <entity_slug>
|
||||
gbrain link <entity_slug> originals/{slug}
|
||||
|
||||
# Step 4: Sync
|
||||
gbrain sync
|
||||
@@ -79,7 +79,7 @@ on user_message(message):
|
||||
|
||||
1. Generate an original idea in conversation (e.g., "I call this the 'ambition debt' problem -- every year you delay going big, the compound interest works against you"). Confirm a new page appears at `brain/originals/ambition-debt` with `gbrain get originals/ambition-debt`.
|
||||
2. Check that the page uses the user's exact phrasing for the title and slug -- not a sanitized version.
|
||||
3. Run `gbrain get_links originals/ambition-debt`. Confirm cross-links exist to related people, meetings, or other originals.
|
||||
3. Run `gbrain call get_links '{"slug": "originals/ambition-debt"}'`. Confirm cross-links exist to related people, meetings, or other originals.
|
||||
4. Express a take on someone else's idea (e.g., "I think Thiel's contrarian question is wrong because..."). Confirm it goes to `originals/` (synthesis is original), not `concepts/`.
|
||||
5. Run `gbrain search "ambition debt"`. Confirm the originals page appears in search results and is discoverable.
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ expect it.
|
||||
| `version` | string | yes | Your plugin's semver. Informational. |
|
||||
| `plugin_version` | string | yes | Contract lock. Must equal `"gbrain-plugin-v1"` for v0.15. |
|
||||
| `subagents` | string | no | Subdir name (default `subagents`). Escape-attempts are rejected. |
|
||||
| `description` | string | no | Shown in future `gbrain plugin list`. |
|
||||
| `description` | string | no | Shown in a future plugin-listing command. |
|
||||
|
||||
## Subagent definition files
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ These require manual setup (no self-installing recipe yet):
|
||||
|-------|-------------|
|
||||
| [Credential Gateway](credential-gateway.md) | Set up ClawVisor or Hermes for Gmail, Calendar, Contacts access |
|
||||
| [Meeting & Call Webhooks](meeting-webhooks.md) | Circleback meeting transcripts + Quo/OpenPhone SMS/calls |
|
||||
| [qm Harness](qm-harness.md) | gbrain as the company brain for a qm (multi-user agent harness) deployment — central HTTP MCP, per-scope clients, roster provisioning, write fencing |
|
||||
|
||||
## How to Read a Recipe
|
||||
|
||||
@@ -69,6 +70,12 @@ health_checks: # typed DSL to verify the integration is working
|
||||
auth_user: "$TWILIO_ACCOUNT_SID"
|
||||
auth_token: "$TWILIO_AUTH_TOKEN"
|
||||
label: "Twilio account"
|
||||
- type: heartbeat_max_age # staleness gate: FAILS `integrations doctor`
|
||||
max_age: 48h # when the newest heartbeat event is older.
|
||||
label: "Data freshness" # The other types are point-in-time and stay
|
||||
# green even when a sense stops producing data.
|
||||
output_paths: # repo-relative dirs the collector writes files to;
|
||||
- daily/voice/ # lets doctor/sync warn if one lands in db_only
|
||||
setup_time: 30 min # estimated time to complete setup
|
||||
---
|
||||
|
||||
@@ -86,7 +93,8 @@ a source install, or the global install copy) are trusted. Recipes discovered at
|
||||
runtime from `$GBRAIN_RECIPES_DIR` or a cwd-local `./recipes/` are marked untrusted:
|
||||
they cannot run `command` health checks, cannot run `http` health checks (SSRF
|
||||
defense), and cannot use the deprecated string health_check form. Untrusted recipes
|
||||
can still use `env_exists` and `any_of` compositions. To ship a recipe that runs
|
||||
can still use `env_exists`, `heartbeat_max_age` (reads only the local heartbeat
|
||||
file — no exec, no network), and `any_of` compositions. To ship a recipe that runs
|
||||
live checks, contribute it upstream so it becomes package-bundled.
|
||||
|
||||
## The Deterministic Collector Pattern
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
name: gbrain
|
||||
description: Search and write the company knowledge brain. Use for any question about the org, people, projects, decisions, or history, and to persist durable knowledge beyond this scope's notebook.
|
||||
---
|
||||
|
||||
# gbrain — the company brain
|
||||
|
||||
This sandbox has the `gbrain` CLI connected (thin-client) to the org's central
|
||||
brain. It is the deep, indexed, cross-source memory: org docs, shared channel
|
||||
knowledge, and every agent's durable notes. Your scope's own notebook stays the
|
||||
fast per-turn memory; the brain is where knowledge outlives a scope and becomes
|
||||
searchable by everyone entitled to it.
|
||||
|
||||
## First-run setup (once per sandbox — skip if `gbrain remote doctor` passes)
|
||||
|
||||
Your scope's brain credentials arrive via the deployment's secret handoff
|
||||
(keychain entry or one-time secret drop named `gbrain`). Then:
|
||||
|
||||
```bash
|
||||
gbrain init --mcp-only \
|
||||
--issuer-url "https://brain.<org>.com" \
|
||||
--mcp-url "https://brain.<org>.com/mcp" \
|
||||
--oauth-client-id "<client id from the handoff>" \
|
||||
--oauth-client-secret "<client secret from the handoff>"
|
||||
gbrain whoami # must succeed before using any other command
|
||||
```
|
||||
|
||||
Pass the secret with `--oauth-client-secret`, not via `GBRAIN_REMOTE_CLIENT_SECRET`:
|
||||
an env-sourced secret is deliberately NOT written to `~/.gbrain/config.json`, so
|
||||
every later command would fail with "No client_secret available" once the
|
||||
variable is out of scope. The flag persists it to the config file on this
|
||||
sandbox's durable disk, which is what the tool's credential capture expects.
|
||||
|
||||
Do not run `gbrain remote doctor` — it needs `admin` scope, which your client
|
||||
does not have (by design). `gbrain whoami` is the read-scope health check.
|
||||
|
||||
## Reading (do this liberally)
|
||||
|
||||
```bash
|
||||
gbrain search "who decided X and why" # hybrid semantic + keyword search
|
||||
gbrain get <slug> # read one page
|
||||
gbrain query "question" --json # search tuned for agent consumption
|
||||
```
|
||||
|
||||
You can read: the shared agent-memory source, org read-only sources (wiki,
|
||||
handbook), and everything under them. Reads are isolation-enforced server-side;
|
||||
you only ever see sources your client is entitled to.
|
||||
|
||||
## Writing (durable knowledge only, under YOUR prefixes)
|
||||
|
||||
Your client is write-fenced to slug prefixes — your own namespace plus the
|
||||
channels you belong to. Writes outside them are rejected server-side.
|
||||
|
||||
```bash
|
||||
# personal durable memory (your namespace):
|
||||
gbrain put emp-<your-slug>/people/jane-example --content "..."
|
||||
|
||||
# shared channel knowledge (channels you are in):
|
||||
gbrain put chan-eng/decisions/2026-08-database-choice --content "..."
|
||||
```
|
||||
|
||||
Conventions:
|
||||
- Write conclusions and durable facts, not chat transcripts. One page per
|
||||
entity/decision/topic; update the page rather than appending near-duplicates.
|
||||
- Markdown with YAML frontmatter; the brain chunks, embeds, and links it.
|
||||
- Cross-reference liberally: `gbrain link <from> <to>` (from must be in your
|
||||
namespace; linking TO any readable page is fine).
|
||||
- When you learn something channel-relevant in personal work, mirror the
|
||||
conclusion into the channel prefix with a `(said in <where>)` provenance
|
||||
note.
|
||||
|
||||
## When to reach for the brain
|
||||
|
||||
- Any question about the org, a person, a project, a decision, or history →
|
||||
`gbrain search` FIRST, then answer.
|
||||
- You produced knowledge with value beyond this conversation → `gbrain put`.
|
||||
- Something looks wrong (auth errors, empty results you don't expect) →
|
||||
`gbrain whoami` to confirm which client and scopes you're using, and report
|
||||
its output.
|
||||
+258
@@ -0,0 +1,258 @@
|
||||
#!/usr/bin/env bash
|
||||
# provision-scopes.sh — roster-driven gbrain provisioning for a qm deployment
|
||||
# (or any multi-user agent harness with per-person + per-channel scopes).
|
||||
#
|
||||
# Reads a roster of channels + employees and converges the brain to it:
|
||||
# - ensures the shared agent-memory source exists (path-less: agents write
|
||||
# pages into it over MCP; `gbrain sync` skips it; if the brain host has
|
||||
# sync.repo_path configured, pages also write through to .sources/<id>/
|
||||
# on disk for git-backed durability)
|
||||
# - registers one OAuth client per employee, write-fenced via
|
||||
# bound_slug_prefixes to emp-<slug>/ plus chan-<c>/ for each channel
|
||||
# they are in, with federated reads over the memory source + any
|
||||
# read-only sources you pass
|
||||
# - re-running after roster edits rescopes existing clients IN PLACE
|
||||
# (client ids are remembered in the state file; secrets never rotate
|
||||
# unless you revoke + delete the state row)
|
||||
#
|
||||
# Usage:
|
||||
# provision-scopes.sh roster.tsv \
|
||||
# [--memory-source agents] [--read-sources org-wiki,handbook] \
|
||||
# [--budget-usd-per-day 5] [--state-file roster.state.tsv] \
|
||||
# [--secrets-out new-credentials.tsv] [--gbrain gbrain] [--dry-run]
|
||||
#
|
||||
# Roster format (one entry per line; '#' comments and blank lines ignored):
|
||||
# channel <slug>
|
||||
# employee <slug> [comma-separated channel slugs]
|
||||
#
|
||||
# SECURITY: --secrets-out receives client secrets for NEW registrations,
|
||||
# written exactly once (gbrain never re-shows them). Deliver each row to its
|
||||
# scope's sandbox (e.g. via the harness keychain or a one-time secret drop),
|
||||
# then delete the file.
|
||||
#
|
||||
# ponytail: sequential CLI loop, one gbrain invocation per roster row — fine
|
||||
# to hundreds of employees; batch via the admin API if that ever hurts.
|
||||
|
||||
# -f (noglob) is load-bearing, not stylistic: roster lines are word-split
|
||||
# unquoted below, so without it a line like `employee * eng` would expand
|
||||
# against the working directory and silently provision a filename as a
|
||||
# person — i.e. the wrong write fence. Nothing here needs globbing.
|
||||
set -euf -o pipefail
|
||||
|
||||
# Client secrets and the id state file are written by this script; 077 makes
|
||||
# them 0600 instead of the default 0644. Set before the first file is created.
|
||||
umask 077
|
||||
|
||||
die() { echo "ERROR: $*" >&2; exit 1; }
|
||||
|
||||
# Slugs become source ids, client names, AND slug-prefix write fences. The
|
||||
# fence list is comma-separated, so an unvalidated slug containing a comma
|
||||
# would inject an EXTRA prefix and hand the client write access to someone
|
||||
# else's namespace. Fail closed on anything that isn't plain kebab-case.
|
||||
valid_slug() {
|
||||
case "$1" in
|
||||
'') return 1 ;;
|
||||
-*|*-) return 1 ;;
|
||||
*[!a-z0-9-]*) return 1 ;;
|
||||
*) return 0 ;;
|
||||
esac
|
||||
}
|
||||
require_slug() {
|
||||
valid_slug "$2" || die "roster: invalid $1 slug '$2' (allowed: lowercase a-z, 0-9, interior hyphens)"
|
||||
}
|
||||
|
||||
ROSTER="${1:-}"
|
||||
[ -n "$ROSTER" ] && [ -f "$ROSTER" ] || die "usage: provision-scopes.sh <roster-file> [flags] (roster not found: '$ROSTER')"
|
||||
shift
|
||||
|
||||
GBRAIN="${GBRAIN:-gbrain}"
|
||||
MEMORY_SOURCE="agents"
|
||||
READ_SOURCES=""
|
||||
BUDGET="5"
|
||||
STATE_FILE=""
|
||||
SECRETS_OUT=""
|
||||
DRY_RUN=0
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--memory-source) MEMORY_SOURCE="$2"; shift 2 ;;
|
||||
--read-sources) READ_SOURCES="$2"; shift 2 ;;
|
||||
--budget-usd-per-day) BUDGET="$2"; shift 2 ;;
|
||||
--state-file) STATE_FILE="$2"; shift 2 ;;
|
||||
--secrets-out) SECRETS_OUT="$2"; shift 2 ;;
|
||||
--gbrain) GBRAIN="$2"; shift 2 ;;
|
||||
--dry-run) DRY_RUN=1; shift ;;
|
||||
*) die "unknown flag: $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
STATE_FILE="${STATE_FILE:-${ROSTER}.state.tsv}"
|
||||
SECRETS_OUT="${SECRETS_OUT:-${ROSTER}.new-credentials.tsv}"
|
||||
|
||||
# The roster usually lives in the deployment repo, so the default secrets and
|
||||
# state paths land there too — one `git add -A` from committing live
|
||||
# credentials. The STATE file matters as much as the secrets file: it maps
|
||||
# employee -> client_id, and this script feeds that id straight to
|
||||
# `rescope-client`, so whoever can write it decides which client receives a
|
||||
# given employee's write authority. Treat both as privileged infrastructure,
|
||||
# at the same trust level as the roster itself.
|
||||
for f in "$SECRETS_OUT" "$STATE_FILE"; do
|
||||
if git -C "$(dirname "$f")" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
echo "WARN: $f is inside a git work tree. Never commit it;" >&2
|
||||
echo " gitignore it, or pass --secrets-out/--state-file outside the repo." >&2
|
||||
fi
|
||||
done
|
||||
|
||||
# A group/world-writable parent directory defeats the symlink and ownership
|
||||
# checks below: anyone with write access there can swap the file between our
|
||||
# check and our append. Refuse rather than pretend the checks hold.
|
||||
for d in "$(dirname "$SECRETS_OUT")" "$(dirname "$STATE_FILE")"; do
|
||||
perms=$(ls -ld "$d" | awk '{print $1}')
|
||||
case "$perms" in
|
||||
?????w*|????????w*) die "refusing to write credentials into a group/world-writable directory: $d ($perms)" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Secure the credential sinks BEFORE anything is appended. umask only governs
|
||||
# files this script creates; a pre-existing world-readable file would receive
|
||||
# secrets first and be chmod'ed only afterwards, and a symlink planted at
|
||||
# either path would redirect them entirely.
|
||||
for f in "$SECRETS_OUT" "$STATE_FILE"; do
|
||||
[ -L "$f" ] && die "refusing to write credentials through a symlink: $f"
|
||||
if [ -e "$f" ]; then
|
||||
[ -f "$f" ] || die "refusing to write credentials to a non-regular file: $f"
|
||||
[ -O "$f" ] || die "refusing to write credentials to a file owned by another user: $f"
|
||||
else
|
||||
: > "$f"
|
||||
fi
|
||||
chmod 600 "$f"
|
||||
done
|
||||
|
||||
run() {
|
||||
if [ "$DRY_RUN" = 1 ]; then echo "DRY-RUN: $GBRAIN $*" >&2; return 0; fi
|
||||
# shellcheck disable=SC2086 — $GBRAIN may carry args ("bun run src/cli.ts")
|
||||
$GBRAIN "$@"
|
||||
}
|
||||
|
||||
state_lookup() { # state_lookup <employee-slug> -> client_id or empty
|
||||
[ -f "$STATE_FILE" ] || return 0
|
||||
awk -F'\t' -v s="$1" '$1 == s { print $2; exit }' "$STATE_FILE"
|
||||
}
|
||||
|
||||
# ── Pass 1: parse roster, collect declared channels ─────────────────────────
|
||||
CHANNELS=""
|
||||
EMPLOYEES=""
|
||||
lineno=0
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
lineno=$((lineno + 1))
|
||||
line="${line%%#*}"
|
||||
line="${line%$'\r'}" # a CRLF roster would otherwise yield 'emp-alice\r/' prefixes that fence everything out
|
||||
[ -z "${line//[[:space:]]/}" ] && continue
|
||||
# shellcheck disable=SC2086 — deliberate word split; globbing is off (set -f above)
|
||||
set -- $line
|
||||
[ "$#" -le 3 ] || die "roster line $lineno: too many fields ('$line'). Channels are ONE comma-separated field with no spaces: 'employee alice eng,product'"
|
||||
case "$1" in
|
||||
channel)
|
||||
require_slug channel "${2:-}"
|
||||
CHANNELS="$CHANNELS $2"
|
||||
;;
|
||||
employee)
|
||||
require_slug employee "${2:-}"
|
||||
case " $EMPLOYEES " in *" $2:"*) die "roster line $lineno: employee '$2' listed twice" ;; esac
|
||||
if [ -n "${3:-}" ]; then
|
||||
for c in ${3//,/ }; do require_slug "channel-reference" "$c"; done
|
||||
fi
|
||||
EMPLOYEES="$EMPLOYEES $2:${3:-}"
|
||||
;;
|
||||
*) die "roster line $lineno: unknown entry type '$1' (expected 'channel' or 'employee')" ;;
|
||||
esac
|
||||
done < "$ROSTER"
|
||||
|
||||
# ── Pass 2: ensure the shared memory source exists (path-less) ──────────────
|
||||
if out=$(run sources add "$MEMORY_SOURCE" --name "agent memory ($MEMORY_SOURCE)" 2>&1); then
|
||||
echo "source '$MEMORY_SOURCE': created"
|
||||
else
|
||||
echo "$out" | grep -q "already registered" || die "sources add failed: $out"
|
||||
echo "source '$MEMORY_SOURCE': already exists"
|
||||
fi
|
||||
|
||||
# ── Pass 3: converge one client per employee ────────────────────────────────
|
||||
FED_READ="$MEMORY_SOURCE${READ_SOURCES:+,$READ_SOURCES}"
|
||||
new_secrets=0
|
||||
|
||||
for entry in $EMPLOYEES; do
|
||||
slug="${entry%%:*}"
|
||||
chans="${entry#*:}"
|
||||
|
||||
prefixes="emp-$slug/"
|
||||
if [ -n "$chans" ]; then
|
||||
for c in ${chans//,/ }; do
|
||||
echo " $CHANNELS " | grep -q " $c " || echo "WARN: employee '$slug' references undeclared channel '$c'" >&2
|
||||
prefixes="$prefixes,chan-$c/"
|
||||
done
|
||||
fi
|
||||
|
||||
client_id="$(state_lookup "$slug")"
|
||||
if [ -n "$client_id" ]; then
|
||||
# The state file usually sits in the deployment repo, so anyone who can
|
||||
# edit it could otherwise retarget this privileged rescope at an arbitrary
|
||||
# client id (e.g. point alice's row at an admin client). Shape-check it.
|
||||
case "$client_id" in
|
||||
gbrain_cl_) die "state file: empty client id for '$slug'" ;;
|
||||
gbrain_cl_*[!a-zA-Z0-9_]*) die "state file: malformed client id for '$slug': $client_id" ;;
|
||||
gbrain_cl_*) ;;
|
||||
*) die "state file: client id for '$slug' does not look like a gbrain client: $client_id" ;;
|
||||
esac
|
||||
# --source too, so a re-run actually CONVERGES the client to the roster:
|
||||
# without it, changing --memory-source (or inheriting a state row written
|
||||
# against an older one) silently leaves the old write source in place
|
||||
# while the script reports success.
|
||||
run auth rescope-client "$client_id" --source "$MEMORY_SOURCE" \
|
||||
--federated-read "$FED_READ" --bound-slug-prefixes "$prefixes" >/dev/null
|
||||
echo "employee '$slug': rescoped $client_id [write: $prefixes]"
|
||||
elif [ "$DRY_RUN" = 1 ]; then
|
||||
echo "employee '$slug': WOULD register qm-emp-$slug [write: $prefixes] [read: $FED_READ]"
|
||||
continue
|
||||
else
|
||||
out=$(run auth register-client "qm-emp-$slug" \
|
||||
--grant-types client_credentials --scopes "read write" \
|
||||
--source "$MEMORY_SOURCE" --federated-read "$FED_READ" \
|
||||
--bound-slug-prefixes "$prefixes" --budget-usd-per-day "$BUDGET" 2>&1) \
|
||||
|| die "register-client failed for '$slug' (output withheld: it can contain a secret). Re-run the command by hand to see why."
|
||||
client_id=$(echo "$out" | sed -n 's/.*Client ID:[[:space:]]*\(gbrain_cl_[^[:space:]]*\).*/\1/p' | head -1)
|
||||
secret=$(echo "$out" | sed -n 's/.*Client Secret:[[:space:]]*\(gbrain_cs_[^[:space:]]*\).*/\1/p' | head -1)
|
||||
if [ -z "$client_id" ] || [ -z "$secret" ]; then
|
||||
# The client may well have been created — dying silently would strand a
|
||||
# live credential nobody can find. Say so WITHOUT echoing the captured
|
||||
# output: it contains the freshly minted secret, and this path ends up
|
||||
# in CI logs.
|
||||
die "could not parse client id/secret for '$slug' from register-client output (output withheld: it contains a secret). A client MAY have been created; check \`gbrain auth list\` and revoke any stray 'qm-emp-$slug'."
|
||||
fi
|
||||
printf '%s\t%s\n' "$slug" "$client_id" >> "$STATE_FILE"
|
||||
printf '%s\t%s\t%s\n' "$slug" "$client_id" "$secret" >> "$SECRETS_OUT"
|
||||
chmod 600 "$STATE_FILE" "$SECRETS_OUT" 2>/dev/null || true # umask covers new files; this covers pre-existing ones
|
||||
new_secrets=$((new_secrets + 1))
|
||||
echo "employee '$slug': registered $client_id [write: $prefixes]"
|
||||
fi
|
||||
done
|
||||
|
||||
# ── Pass 4: flag offboarded employees ───────────────────────────────────────
|
||||
# Removing someone from the roster is the highest-stakes edit there is, and
|
||||
# this script cannot safely revoke on its own (a typo'd roster would nuke live
|
||||
# credentials). Report instead, with the exact command.
|
||||
if [ -f "$STATE_FILE" ]; then
|
||||
while IFS=$'\t' read -r st_slug st_client _rest; do
|
||||
[ -n "${st_slug:-}" ] || continue
|
||||
case " $EMPLOYEES " in
|
||||
*" $st_slug:"*) ;;
|
||||
*) echo "STALE: '$st_slug' ($st_client) is no longer in the roster but its credentials still work." >&2
|
||||
echo " Revoke with: $GBRAIN auth revoke-client $st_client" >&2 ;;
|
||||
esac
|
||||
done < "$STATE_FILE"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Done. State: $STATE_FILE"
|
||||
if [ "$new_secrets" -gt 0 ]; then
|
||||
echo "$new_secrets NEW client secret(s) written to $SECRETS_OUT — deliver to each scope's sandbox, then DELETE the file."
|
||||
fi
|
||||
@@ -0,0 +1,10 @@
|
||||
# Roster for provision-scopes.sh — one line per channel / employee.
|
||||
# channel <slug>
|
||||
# employee <slug> [comma-separated channels they belong to]
|
||||
|
||||
channel eng
|
||||
channel product
|
||||
|
||||
employee alice-example eng,product
|
||||
employee bob-example eng
|
||||
employee carol-example
|
||||
|
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"id": "gbrain",
|
||||
"label": "gbrain company brain",
|
||||
"advertise": "gbrain",
|
||||
"hints": [
|
||||
"Company knowledge brain: searchable, cross-source, persistent.",
|
||||
"Search it BEFORE answering questions about the org, people, projects, decisions, or history: `gbrain search \"<question>\"`.",
|
||||
"Write durable knowledge with `gbrain put <slug> --content ...`, only under your own slug prefixes.",
|
||||
"See the gbrain skill for slug conventions and first-run setup."
|
||||
],
|
||||
"auth": {
|
||||
"check": "gbrain whoami",
|
||||
"reauth": "gbrain init --mcp-only --force --issuer-url \"$GBRAIN_ISSUER_URL\" --mcp-url \"$GBRAIN_MCP_URL\" --oauth-client-id \"$GBRAIN_CLIENT_ID\" --oauth-client-secret \"$GBRAIN_CLIENT_SECRET\"",
|
||||
"credentialPaths": [
|
||||
{ "path": ".gbrain/config.json", "kind": "file" }
|
||||
]
|
||||
},
|
||||
"install": { "binary": "gbrain" }
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
# qm (multi-user agent harness) — gbrain as the company brain
|
||||
|
||||
Connect gbrain to [qm](https://github.com/yc-software/qm) — the multiplayer
|
||||
agent harness where each employee and each channel gets an isolated agent
|
||||
scope — so every scope's agent can search and write one shared, indexed,
|
||||
isolation-enforced company brain. The same recipe fits any harness with
|
||||
per-person sandboxes that can run a CLI.
|
||||
|
||||
**Shape:** one central `gbrain serve --http` (OAuth 2.1) next to qm's core;
|
||||
the `gbrain` binary baked into qm's sandbox image as a thin client; one OAuth
|
||||
client per employee, read-fenced by source federation and write-fenced by
|
||||
`bound_slug_prefixes`. Zero qm code changes — everything lives in the qm
|
||||
*deployment directory*.
|
||||
|
||||
qm's native memory (per-scope notebook) stays as-is for fast per-turn recall.
|
||||
gbrain adds what qm doesn't have: semantic + hybrid search, cross-scope
|
||||
knowledge, entity graphs, and durable memory that outlives a scope.
|
||||
|
||||
## Topology
|
||||
|
||||
| gbrain concept | qm concept |
|
||||
|---|---|
|
||||
| one brain (one Postgres/Supabase DB) | the org |
|
||||
| source `agents` (path-less, shared) | all agent-written memory |
|
||||
| slug prefix `emp-<slug>/` in `agents` | an employee's personal scope |
|
||||
| slug prefix `chan-<slug>/` in `agents` | a channel/room scope |
|
||||
| source `org-wiki` (git-backed, read-only) | company docs |
|
||||
| OAuth client `qm-emp-<slug>` | one employee's agent identity |
|
||||
|
||||
Isolation model:
|
||||
|
||||
- **Reads** are source-granular, SQL-enforced (`federated_read`): every
|
||||
employee client reads `agents` + the read-only sources you grant.
|
||||
- **Writes** are slug-prefix-granular, server-enforced (`bound_slug_prefixes`,
|
||||
v0.42.72.0+): a client can only mutate pages under its own `emp-<slug>/`
|
||||
and its channels' `chan-<x>/` prefixes — on `put_page`, `delete_page`,
|
||||
`restore_page`, `add_tag`, `remove_tag`, `add_link`/`remove_link`,
|
||||
`add_timeline_entry`, `revert_version` and `put_raw_data`, plus the
|
||||
`POST /ingest` webhook route. Not by convention.
|
||||
- **Every op that is not a plain read is denied unless allow-listed.** Ops
|
||||
that write by a key other than a slug — `extract_entities` and
|
||||
`extract_facts` (which mutate `people/*` and `companies/*`), `forget_fact`
|
||||
(targets a fact by numeric id, across sources), `ontology_propose`, and the
|
||||
`sources_admin` pair `sources_add`/`sources_remove` — cannot be fenced by
|
||||
slug, so a bound client gets `permission_denied` at dispatch. The gate keys
|
||||
on "not a pure read", not on a list of scope strings, so a write op added
|
||||
later (or one carrying a bespoke scope) is denied until it is explicitly
|
||||
fenced and added to `CLIENT_FENCED_WRITE_OPS` (`src/core/operations.ts`).
|
||||
`think` is allow-listed because remote callers cannot persist from it;
|
||||
`submit_agent` because it enforces this same column itself.
|
||||
- **Indirect write paths are gated too, not just the ops.** `put_page`'s
|
||||
facts backstop would otherwise extract entities from the page body and
|
||||
write fact rows (and a `## Facts` fence on git-backed sources) onto
|
||||
`people/*` pages the caller never named — the same capability
|
||||
`extract_facts` is denied for, reached through an in-prefix write. It is
|
||||
skipped for bound clients. `POST /ingest` is refused outright: its handler
|
||||
bypasses the op layer *and* discards the source grant for untrusted
|
||||
payloads, so it would write into the `default` source.
|
||||
### Known limitations — read these before you rely on the fence
|
||||
|
||||
The write fence is a **write** boundary within a source. It is not a privacy
|
||||
boundary, and it does not make every side effect prefix-clean. As of
|
||||
v0.42.73.2:
|
||||
|
||||
- **The fence follows a delegated write.** When a client with `agent` scope
|
||||
hands work to a subagent via `submit_agent`, that subagent runs under its own
|
||||
slug confinement rather than the parent's OAuth binding. Both confinements are
|
||||
enforced, including on the path where deduplication redirects a write onto an
|
||||
existing page: the redirected target is checked against whichever confinement
|
||||
the calling context actually carries, so delegation does not widen what a
|
||||
client can write.
|
||||
|
||||
- **`add_link`/`remove_link` fence the `from` endpoint only.** A bound client
|
||||
can create an edge pointing AT a page it cannot write; the edge's `context`
|
||||
text surfaces in that page's backlinks and contributes to its search
|
||||
ranking. Fencing `to` would break legitimate cross-referencing into
|
||||
`org-wiki`, so this is deliberate — treat inbound-edge context as untrusted
|
||||
content, the same way you treat page bodies.
|
||||
- **Reads are source-granular, never prefix-granular.** Everyone entitled to
|
||||
a source can read every prefix in it. If a scope needs genuine read
|
||||
privacy, give it its own source.
|
||||
- **`put_page` can create one reverse graph edge outside the fence.** If a
|
||||
page body cites a code location (`src/x.ts:42`) and a code page for it
|
||||
exists *in the same source*, doc↔impl reconciliation adds an edge
|
||||
originating from that code page. It affects graph/backlink ranking, not
|
||||
page content. Unreachable in the layout above (the `agents` source is
|
||||
path-less and holds no code pages); it applies only if you point employee
|
||||
writes at a code-synced source.
|
||||
- **A few read ops are still brain-wide** and ignore the federated grant:
|
||||
`get_recent_salience`, `find_anomalies`, `find_contradictions`, and
|
||||
`sources_list`/`sources_status` (which expose source ids, paths and URLs).
|
||||
A read-scoped client can learn facts derived from sources it was not
|
||||
granted. Pre-existing, not introduced by the fence; if that matters for
|
||||
your deployment, withhold those tools at the harness layer for now.
|
||||
- **Reads touch `last_retrieved_at`** on the pages they return, including
|
||||
pages in read-only sources. Freshness/usage signals are therefore
|
||||
writable-by-reading; nothing else about the page is.
|
||||
- **`POST /ingest` writes land in the `default` source** regardless of the
|
||||
calling client's `source_id`, because the handler discards the source for
|
||||
untrusted payloads. Bound clients are refused the route outright for this
|
||||
reason; if you point a webhook integration at it, scope that brain's
|
||||
`default` source deliberately.
|
||||
- **Tradeoff to state out loud:** read isolation is per-source, so within the
|
||||
shared `agents` source every employee can *read* every prefix (including
|
||||
other employees' `emp-*/`). That matches qm's transparent-by-default,
|
||||
everything-audited posture. If you need hard read privacy for personal
|
||||
memory, give those employees their own write source instead of a prefix
|
||||
(one `sources add emp-<slug>` + `--source emp-<slug>` per client) and keep
|
||||
channel prefixes in `agents` via a second, channels-only client — at the
|
||||
cost of two credentials in that sandbox.
|
||||
|
||||
## Host setup (the machine running qm's core, or any box its sandboxes can reach)
|
||||
|
||||
```bash
|
||||
# 1. Engine: Postgres/Supabase. PGLite is single-process and cannot serve
|
||||
# many concurrent sandboxes.
|
||||
gbrain init --supabase --embedding-model voyage:voyage-4-large
|
||||
|
||||
# 2. Modes + gates (publish_* default OFF and fail as silent 403s):
|
||||
gbrain config set search.mode balanced
|
||||
gbrain config set mcp.publish_skills true
|
||||
gbrain config set mcp.publish_advisor true
|
||||
|
||||
# 3. Read-only org sources + first sync:
|
||||
gbrain sources add org-wiki --path ~/brains/org-wiki
|
||||
gbrain sync --all # cron this
|
||||
|
||||
# 4. Serve over HTTP MCP (OAuth 2.1):
|
||||
gbrain serve --http --bind 0.0.0.0 --port 3131 \
|
||||
--public-url https://brain.acme-example.com
|
||||
```
|
||||
|
||||
Never hand sandboxes `DATABASE_URL` — direct DB access bypasses OAuth, source
|
||||
federation, and the write fence entirely.
|
||||
|
||||
## Provision scopes from a roster
|
||||
|
||||
[`qm-harness-snippets/provision-scopes.sh`](qm-harness-snippets/provision-scopes.sh)
|
||||
converges the brain to a roster file
|
||||
([`roster.example.tsv`](qm-harness-snippets/roster.example.tsv)):
|
||||
|
||||
```bash
|
||||
bash provision-scopes.sh roster.tsv --read-sources org-wiki
|
||||
```
|
||||
|
||||
- Creates the path-less `agents` source (agent-written memory needs no git
|
||||
clone; if the host has `sync.repo_path` configured, pages also write
|
||||
through to `.sources/agents/` for git-backed durability).
|
||||
- Registers `qm-emp-<slug>` clients: `--scopes "read write"`,
|
||||
`--source agents`, `--federated-read agents,org-wiki`,
|
||||
`--bound-slug-prefixes emp-<slug>/,chan-<a>/,...`, per-day budget.
|
||||
- **Idempotent:** re-run after every roster edit; existing clients are
|
||||
`rescope-client`ed in place (channel joins/leaves update the write fence
|
||||
without rotating secrets).
|
||||
- New client secrets land once in `<roster>.new-credentials.tsv` — deliver
|
||||
each row to its scope (qm keychain / one-time secret drop), then delete
|
||||
the file.
|
||||
|
||||
## qm deployment directory
|
||||
|
||||
In the org's qm deployment repo (the directory `qm init` produced):
|
||||
|
||||
1. **Tool:** copy [`qm-harness-snippets/tool.json`](qm-harness-snippets/tool.json)
|
||||
to `sandbox/tools/gbrain/tool.json` and drop the compiled `gbrain` binary
|
||||
beside it (`bun build --compile --outfile gbrain src/cli.ts`, built for
|
||||
the sandbox image's OS/arch). `auth.credentialPaths` marks
|
||||
`~/.gbrain/config.json` as the scope's resident credential file;
|
||||
`auth.check` wires `gbrain whoami` into qm's connector status (read-scope;
|
||||
see the note below on why `remote doctor` cannot be used here).
|
||||
2. **Skill:** copy [`qm-harness-snippets/SKILL.md`](qm-harness-snippets/SKILL.md)
|
||||
to `sandbox/skills/gbrain/SKILL.md` (edit slug conventions to taste).
|
||||
3. Ship it: `qm sandbox build && qm sandbox publish && qm up`.
|
||||
|
||||
Per scope, one-time (agent- or operator-run, credentials from the handoff):
|
||||
|
||||
```bash
|
||||
gbrain init --mcp-only \
|
||||
--issuer-url https://brain.acme-example.com \
|
||||
--mcp-url https://brain.acme-example.com/mcp \
|
||||
--oauth-client-id gbrain_cl_... --oauth-client-secret gbrain_cs_...
|
||||
gbrain whoami # must succeed
|
||||
```
|
||||
|
||||
Use `--oauth-client-secret`, not `GBRAIN_REMOTE_CLIENT_SECRET`: an env-sourced
|
||||
secret is deliberately not written to `~/.gbrain/config.json`
|
||||
(`src/commands/init.ts`), so with the env var alone every later command fails
|
||||
once it leaves scope — and qm's `sandbox.secretEnv` is org-wide, so there is no
|
||||
per-scope env to keep it in. With the flag, the credential lands in the config
|
||||
file on the scope's durable disk and this runs once per scope, ever.
|
||||
|
||||
`gbrain remote doctor` is **not** the health check here: `run_doctor` is an
|
||||
`admin`-scope op and these clients are `read write` on purpose. `gbrain whoami`
|
||||
is read-scope and reports the client's identity, source, and grants.
|
||||
|
||||
## Verify isolation before rollout
|
||||
|
||||
From two differently-scoped sandboxes (or two thin-client configs):
|
||||
|
||||
```bash
|
||||
# alice-example (bound to emp-alice-example/, chan-eng/):
|
||||
gbrain put emp-alice-example/notes/test --content "mine" # OK
|
||||
gbrain put chan-eng/notes/test --content "shared" # OK
|
||||
gbrain put emp-bob-example/notes/test --content "not mine" # permission_denied
|
||||
gbrain put chan-product/notes/test --content "not my channel" # permission_denied
|
||||
gbrain search "test" # sees agents + org-wiki only
|
||||
```
|
||||
|
||||
## Cost + operations
|
||||
|
||||
- `search.mode balanced` (12K token budget, relational retrieval on) is the
|
||||
right default for a startup fleet; see `docs/guides/search-modes.md` for
|
||||
the cost matrix before changing it.
|
||||
- Budgets: `--budget-usd-per-day` is recorded on the client but only enforced
|
||||
on the `submit_agent` path (`src/core/minions/budget-meter.ts`), which these
|
||||
`read write` clients cannot reach — so it does **not** cap spend from
|
||||
ordinary `search`/`put_page` traffic. Treat runaway-agent containment as an
|
||||
open item: watch the admin SPA (`/admin`) and `gbrain search stats`, and cap
|
||||
at the model/harness layer.
|
||||
- Backfills on a live brain: `gbrain embed --stale --pace` (see Pace Mode in
|
||||
CLAUDE.md / `docs/operations/spend-controls.md`).
|
||||
|
||||
## Deliberately deferred
|
||||
|
||||
- **qm `MemoryService` decorator** (mirror notebook captures into gbrain,
|
||||
fan `recall` out and merge, `volunteer_context` push): needs a qm code
|
||||
change; today's integration is agent-initiated via the CLI + skill.
|
||||
- **MCP-native attach:** qm pins `strictMcpConfig` with only its in-process
|
||||
server, so gbrain's MCP-discovered brain-resident skillpacks don't reach
|
||||
qm agents; the sandbox skill above covers it.
|
||||
- **Read-side prefix fencing** (hard privacy for `emp-*/` inside a shared
|
||||
source) — tracked upstream; the roster layout is forward-compatible with
|
||||
it.
|
||||
+1
-1
@@ -250,7 +250,7 @@ All 30 GBrain operations are available remotely, including `sync_brain` and
|
||||
directory where `gbrain serve` was launched. Symlinks, `..` traversal, and absolute
|
||||
paths outside cwd are rejected. Page slugs and filenames are allowlist-validated
|
||||
(alphanumeric + hyphens; no control chars, RTL overrides, or backslashes). Local
|
||||
CLI callers (`gbrain file upload ...`) keep unrestricted filesystem access since
|
||||
CLI callers (`gbrain files upload ...`) keep unrestricted filesystem access since
|
||||
the user owns the machine.
|
||||
|
||||
## Deployment Options
|
||||
|
||||
@@ -49,6 +49,7 @@ The USD-limit knobs accept `off`, `unlimited`, or `none` (case-insensitive) to m
|
||||
| Backfill per-job budget | `embed.backfill_max_usd` | `10` | caps the job's tracker | `off` (`0` → default) | uncapped (still ledgered) |
|
||||
| Backfill cooldown | `embed.backfill_cooldown_min` | `10` | skips re-submission inside window | — (latency knob, not spend) | **not** bypassed |
|
||||
| `reindex-code` cost gate | — (preview before re-embed) | — | TTY prompt / non-TTY refuse + exit 2 | `--max-cost off` | informational |
|
||||
| `migrate embeddings` consent gate | — (plan + estimate before provider migration) | — | TTY y/N prompt / non-TTY refuse + exit 2 | `--yes` | estimate marked informational, but **still prompts** (guards a destructive schema rebuild, not just spend) |
|
||||
| `enrich` / `onboard --auto` | `--max-usd` (per-call) | — | refuse without a cap (non-TTY) | `--max-usd off` | runs uncapped (still ledgered) |
|
||||
|
||||
### Sync inline-embed cost gate
|
||||
|
||||
@@ -140,6 +140,9 @@ Stable phase names shipped in v0.15.2:
|
||||
- `import.files`
|
||||
- `sync.deletes`, `sync.renames`, `sync.imports`
|
||||
- `migrate.copy_pages`, `migrate.copy_links`
|
||||
- `migrate.reembed` (the re-embed pass of `gbrain migrate embeddings`; total is the
|
||||
stale-chunk backlog at the start of the pass, so it can grow slightly if a
|
||||
writer adds chunks mid-run)
|
||||
- `repair_jsonb.run`, `repair_jsonb.<table>.<column>`
|
||||
- `backlinks.scan`
|
||||
- `lint.pages`
|
||||
|
||||
@@ -51,6 +51,18 @@ When storage configuration is present, `gbrain sync` automatically manages `.git
|
||||
- Skipped when the repo is a git submodule (`.git` is a file, not a directory) — submodule .gitignore changes don't survive parent updates. A warning explains.
|
||||
- Skipped entirely when `GBRAIN_NO_GITIGNORE=1` is set (escape hatch for shared-repo setups where a maintainer wants gbrain to leave .gitignore alone).
|
||||
- Failures (write permission denied, etc.) are caught and logged, never crash sync.
|
||||
- Warns when a configured collector's declared output dir (recipe `output_paths`
|
||||
frontmatter) sits inside a `db_only` path: gitignored files never appear in the
|
||||
git-walking sync diff, and `gbrain import` honors `.gitignore` too — the
|
||||
collector would run green while nothing reaches the DB. The
|
||||
`db_only_collector_collision` doctor check surfaces the same trap.
|
||||
|
||||
Related doctor coverage: `undeclared_db_only_pages` warns about DB pages with no
|
||||
backing file that sit outside every declared `db_only` path. The engine's own
|
||||
derive-phase output prefixes (`life/events/`, `atoms/`, `extracts/`,
|
||||
`dream-cycle-summaries/`) count as implicitly declared for that check, so healthy
|
||||
brains stay quiet without adding them to `gbrain.yml`. They are NOT auto-added to
|
||||
`.gitignore` — only explicitly declared `db_only` dirs are.
|
||||
|
||||
Example `.gitignore` addition:
|
||||
|
||||
|
||||
@@ -0,0 +1,690 @@
|
||||
# Engine Dynamic-Import Reconciliation Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Reconstruct the missing engine-path static-import hardening, preserve the four load-bearing lazy gateway fallbacks, and prevent unreviewed dynamic imports from returning.
|
||||
|
||||
**Architecture:** Make the 13 safe engine/migration import statements static and leave only four line-marked `ai/gateway.ts` imports inside their existing soft-failure `try/catch` boundaries. Enforce that current state with a repository-anchored Bash wrapper delegating to a fail-closed TypeScript AST scanner, a hermetic Bun regression test, package/verify wiring, and current-state architecture documentation.
|
||||
|
||||
**Tech Stack:** TypeScript compiler API, Bun test runner, Bash, Git, generated llms documentation bundles.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Reconstruct directly on branch `claude/kind-meitner-330c90`, based on investigated `origin/master` commit `6136e139972a5449630b4f47f5ed7b4cbe5b811b` plus design commit `d7f52d8c`.
|
||||
- Do not merge or cherry-pick `48ada48f`, `248bfe55`, `ef4cf7a8`, or either historical branch wholesale.
|
||||
- Do not modify `VERSION`, `CHANGELOG.md`, `TODOS.md`, or release metadata; this is a no-version-bump reconciliation.
|
||||
- Keep all four `await import('./ai/gateway.ts')` calls lazy: PGLite and Postgres `initSchema`, plus both `_upsertChunksOnce` methods.
|
||||
- Every allowed lazy gateway line must carry `engine-dynamic-import-ok`; there is no file-level exemption.
|
||||
- Preserve the stronger gateway rationale: the static closure is large, and eager module evaluation would occur outside the local `try/catch`, potentially converting a recoverable configuration/import failure into a module-load-time hard failure.
|
||||
- Describe the hoists as engine-path hardening. Do not claim every dynamic import deterministically causes a Windows crash; system-wide commit exhaustion confounded prior measurements.
|
||||
- Keep shared PGLite/Postgres behavior in parity.
|
||||
- Invoke repository shell scripts through `bash` in `package.json`.
|
||||
- Capture complete test/check output to workspace-local `.context/*.txt` files before inspecting it; never pipe a test command directly through `head` or `tail`.
|
||||
- Use `git log -G`, not `git log -S`, for any additional dynamic-to-static import history work.
|
||||
- Keep every implementation and verification commit local. Do not push, create a PR, comment upstream, or otherwise publish without explicit user approval after local completion.
|
||||
- Before editing any affected function, run GBrain `code_blast` and `code_callers` for that symbol and inspect any disambiguation candidates.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
- Create `scripts/check-engine-dynamic-import.sh` — repository-anchored Bash wrapper for default and explicit input routing.
|
||||
- Create `scripts/check-engine-dynamic-import.ts` — TypeScript AST policy scanner for runtime `import()` expressions, parse/read failures, and exact-line comment-trivia opt-outs.
|
||||
- Create `test/scripts/check-engine-dynamic-import.test.ts` — 22 hermetic adversarial, CRLF, fail-closed, real-tree, and wiring tests.
|
||||
- Modify `src/core/pglite-engine.ts` — hoist three safe import statements and mark two deliberate gateway imports.
|
||||
- Modify `src/core/postgres-engine.ts` — hoist eight safe import statements and mark two deliberate gateway imports.
|
||||
- Modify `src/core/migrate.ts` — hoist two safe migration helper import statements.
|
||||
- Modify `package.json` — expose `check:engine-dynamic-import` and append it to `check:all` through `bash`.
|
||||
- Modify `scripts/run-verify-parallel.sh` — add the package check to the authoritative verify dispatcher.
|
||||
- Modify `CLAUDE.md` — add the cross-cutting current-state invariant.
|
||||
- Modify `docs/architecture/KEY_FILES.md` — update current-state entries for the three engine-path files.
|
||||
- Regenerate `llms.txt` and `llms-full.txt` — required derived bundles after CLAUDE/reference documentation changes.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Establish and enforce the source invariant
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/check-engine-dynamic-import.sh`
|
||||
- Create: `scripts/check-engine-dynamic-import.ts`
|
||||
- Create: `test/scripts/check-engine-dynamic-import.test.ts`
|
||||
- Modify: `src/core/pglite-engine.ts`
|
||||
- Modify: `src/core/postgres-engine.ts`
|
||||
- Modify: `src/core/migrate.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: shell positional arguments `FILE...`; without arguments, the guard scans the three repository files.
|
||||
- Produces: `scripts/check-engine-dynamic-import.sh [FILE...]`, exit `0` when every runtime dynamic import is allowed and exit `1` after reporting every `file:line:text` violation plus every read/parse error on stderr.
|
||||
- Produces: one line-level opt-out token, `engine-dynamic-import-ok`, accepted only in real comment trivia on the same physical line as the deliberately lazy import.
|
||||
- Fails closed on missing/unreadable inputs, TypeScript parse diagnostics, and scanner/process failures; comments, strings, templates, regex literals, and type-position `import(...)` syntax are not runtime imports.
|
||||
|
||||
- [ ] **Step 1: Record call-graph blast radius before touching functions**
|
||||
|
||||
First call `sources_list` and select the source whose registered path is this gbrain checkout. Then run `code_blast` and `code_callers` for these qualified symbols with that exact `source_id`, following `did_you_mean`/`candidates` when a method name is ambiguous:
|
||||
|
||||
```text
|
||||
src/core/pglite-engine.ts::PGLiteEngine.initSchema
|
||||
src/core/pglite-engine.ts::PGLiteEngine.batchRetry
|
||||
src/core/pglite-engine.ts::PGLiteEngine._upsertChunksOnce
|
||||
src/core/pglite-engine.ts::PGLiteEngine.mergeOntologyFact
|
||||
src/core/pglite-engine.ts::PGLiteEngine.getRecentSalience
|
||||
src/core/postgres-engine.ts::PostgresEngine.disconnect
|
||||
src/core/postgres-engine.ts::PostgresEngine.initSchema
|
||||
src/core/postgres-engine.ts::PostgresEngine.batchRetry
|
||||
src/core/postgres-engine.ts::PostgresEngine._upsertChunksOnce
|
||||
src/core/postgres-engine.ts::PostgresEngine.mergeOntologyFact
|
||||
src/core/postgres-engine.ts::PostgresEngine.reconnect
|
||||
src/core/postgres-engine.ts::PostgresEngine.getRecentSalience
|
||||
src/core/migrate.ts::runMigrationSQLWithRetry
|
||||
src/core/migrate.ts::runMigrations
|
||||
```
|
||||
|
||||
Use `depth: 5`, `max_nodes: 200`, and `limit: 100`. Expected: no caller requires a signature or behavior change; the patch only changes module binding time and retains all local fallback/error handling.
|
||||
|
||||
- [ ] **Step 2: Write the failing guard regression test**
|
||||
|
||||
Create `test/scripts/check-engine-dynamic-import.test.ts` as a hermetic subprocess suite. The completed 22-test surface covers:
|
||||
|
||||
- unmarked runtime `import()` rejection, including bare and trivia-separated forms;
|
||||
- same-line markers in real line or multiline block-comment trivia;
|
||||
- rejection of markers on prior lines or inside strings, templates, and module paths;
|
||||
- comments and comment-like delimiters inside strings, templates, and regex literals;
|
||||
- live code after same-line or multiline block comments close;
|
||||
- CRLF input and complete multi-file violation aggregation;
|
||||
- missing/readable mixed inputs and TypeScript parse diagnostics;
|
||||
- default repository anchoring when invoked from a foreign Git repository;
|
||||
- the reconciled three-file source scan plus package/parallel-verifier wiring.
|
||||
|
||||
Use the TypeScript parser rather than a partial lexical reimplementation. On Windows, set the test default to 30 seconds because each case launches Git Bash and Bun, whose startup can exceed Bun's 5-second per-test default.
|
||||
|
||||
- [ ] **Step 3: Run the test to prove the pre-implementation red state**
|
||||
|
||||
```bash
|
||||
bun test test/scripts/check-engine-dynamic-import.test.ts > .context/engine-dynamic-import-red.txt 2>&1; rc=$?; printf 'EXIT=%s\n' "$rc"; exit 0
|
||||
```
|
||||
|
||||
Expected: non-zero Bun result captured inside the log. At minimum, the `exists` assertion fails because `scripts/check-engine-dynamic-import.sh` does not exist. Read `.context/engine-dynamic-import-red.txt`; do not infer the result from a truncated pipeline.
|
||||
|
||||
- [ ] **Step 4: Add the CRLF-safe, fail-closed guard**
|
||||
|
||||
Create `scripts/check-engine-dynamic-import.sh` as a thin LF-terminated wrapper. Resolve its own directory first; when no explicit files are passed, anchor the repository with `git -C "$SCRIPT_DIR/.."` and scan the two engines plus `migrate.ts`. Delegate with `exec bun "$SCRIPT_DIR/check-engine-dynamic-import.ts" "${FILES[@]}"` so scanner failures propagate.
|
||||
|
||||
Create `scripts/check-engine-dynamic-import.ts` using the TypeScript compiler API:
|
||||
|
||||
- read every requested file and aggregate read failures;
|
||||
- parse as TypeScript and aggregate parse diagnostics;
|
||||
- walk the AST for `CallExpression`s whose expression is `ImportKeyword`;
|
||||
- locate all marker occurrences in the full source and use `ts.getTokenAtPosition` to admit only occurrences outside AST tokens (real comment trivia), recording their physical source lines;
|
||||
- require each runtime import's line to have an admitted marker or report its original `file:line:text`;
|
||||
- print every read/parse error and every violation before exiting nonzero.
|
||||
|
||||
This preserves CRLF line accounting, ignores comment/literal/type-only false positives, catches every legal runtime `import()` shape the TypeScript parser recognizes, rejects marker spoofing, and fails closed.
|
||||
|
||||
- [ ] **Step 5: Run the guard test to prove the source-tree midpoint is still red**
|
||||
|
||||
```bash
|
||||
bun test test/scripts/check-engine-dynamic-import.test.ts > .context/engine-dynamic-import-midpoint.txt 2>&1; rc=$?; printf 'EXIT=%s\n' "$rc"; exit 0
|
||||
```
|
||||
|
||||
Expected: the synthetic violation, marker, comments, and CRLF cases pass. The default repository scan fails and reports all 17 current imports: 13 unmarked safe candidates plus the four not-yet-marked gateway calls.
|
||||
|
||||
- [ ] **Step 6: Hoist the three safe PGLite import statements**
|
||||
|
||||
Replace the existing `retry.ts` import and add the ontology/recency imports near the top of `src/core/pglite-engine.ts`:
|
||||
|
||||
```ts
|
||||
// Engine-path imports stay static unless a call site carries an explicit
|
||||
// engine-dynamic-import-ok justification. The gateway is the only current
|
||||
// exception because its local try/catch preserves a soft fallback.
|
||||
import {
|
||||
withRetry,
|
||||
BULK_RETRY_OPTS,
|
||||
resolveBulkRetryOpts,
|
||||
computeNextDelay,
|
||||
isRetryableConnError,
|
||||
type BatchAuditSite,
|
||||
} from './retry.ts';
|
||||
import {
|
||||
valueHash,
|
||||
normalizeDimension,
|
||||
isNovelDimension,
|
||||
} from './chronicle/ontology.ts';
|
||||
import {
|
||||
resolveRecencyDecayMap,
|
||||
DEFAULT_FALLBACK,
|
||||
} from './search/recency-decay.ts';
|
||||
```
|
||||
|
||||
Delete only these three in-method destructuring imports, leaving their uses unchanged:
|
||||
|
||||
```ts
|
||||
const { isRetryableConnError } = await import('./retry.ts');
|
||||
const { valueHash, normalizeDimension, isNovelDimension } = await import('./chronicle/ontology.ts');
|
||||
const { resolveRecencyDecayMap, DEFAULT_FALLBACK } = await import('./search/recency-decay.ts');
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Mark both PGLite gateway soft-failure boundaries**
|
||||
|
||||
In `PGLiteEngine.initSchema`, preserve the `try/catch` and accessors, changing only the rationale and import line:
|
||||
|
||||
```ts
|
||||
try {
|
||||
// Keep the gateway lazy: its static closure is large, and evaluation inside
|
||||
// this try/catch preserves the unconfigured-gateway default fallback.
|
||||
const gw = await import('./ai/gateway.ts'); // engine-dynamic-import-ok
|
||||
// Both accessors THROW when the gateway is unconfigured (they never
|
||||
// return falsy), so the catch below is the only fallback path (#3461).
|
||||
dims = gw.getEmbeddingDimensions();
|
||||
model = gw.getEmbeddingModel();
|
||||
} catch { /* gateway not configured — use defaults */ }
|
||||
```
|
||||
|
||||
In `PGLiteEngine._upsertChunksOnce`, preserve the config-row and compile-time fallback chain:
|
||||
|
||||
```ts
|
||||
try {
|
||||
// Keep the gateway lazy so module-load failure remains inside this soft
|
||||
// fallback boundary; eager evaluation would bypass the config-row fallback.
|
||||
const gw = await import('./ai/gateway.ts'); // engine-dynamic-import-ok
|
||||
resolvedModel = gw.getEmbeddingModel();
|
||||
} catch {
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Hoist the eight safe Postgres import statements**
|
||||
|
||||
Replace the existing `retry.ts` import and add these imports near the top of `src/core/postgres-engine.ts`:
|
||||
|
||||
```ts
|
||||
// Engine-path imports stay static unless a call site carries an explicit
|
||||
// engine-dynamic-import-ok justification. The gateway is the only current
|
||||
// exception because its local try/catch preserves a soft fallback.
|
||||
import {
|
||||
withRetry,
|
||||
BULK_RETRY_OPTS,
|
||||
resolveBulkRetryOpts,
|
||||
computeNextDelay,
|
||||
isRetryableConnError,
|
||||
type BatchAuditSite,
|
||||
} from './retry.ts';
|
||||
import { isConnectionEndedError } from './retry-matcher.ts';
|
||||
import {
|
||||
valueHash,
|
||||
normalizeDimension,
|
||||
isNovelDimension,
|
||||
} from './chronicle/ontology.ts';
|
||||
import {
|
||||
resolveRecencyDecayMap,
|
||||
DEFAULT_FALLBACK,
|
||||
} from './search/recency-decay.ts';
|
||||
import { logDbDisconnect } from './audit/db-disconnect-audit.ts';
|
||||
import { logPoolRecovery } from './audit/pool-recovery-audit.ts';
|
||||
```
|
||||
|
||||
Delete the eight safe dynamic-import statements while keeping their surrounding `try/catch` blocks and calls unchanged:
|
||||
|
||||
```ts
|
||||
const { logDbDisconnect } = await import('./audit/db-disconnect-audit.ts');
|
||||
const { isRetryableConnError } = await import('./retry.ts');
|
||||
const { valueHash, normalizeDimension, isNovelDimension } = await import('./chronicle/ontology.ts');
|
||||
const { isConnectionEndedError } = await import('./retry-matcher.ts');
|
||||
const { logPoolRecovery } = await import('./audit/pool-recovery-audit.ts');
|
||||
const { logPoolRecovery } = await import('./audit/pool-recovery-audit.ts');
|
||||
const { logPoolRecovery } = await import('./audit/pool-recovery-audit.ts');
|
||||
const { resolveRecencyDecayMap, DEFAULT_FALLBACK } = await import('./search/recency-decay.ts');
|
||||
```
|
||||
|
||||
Update the stale `batchRetry` comment from “Lazy-import to avoid a circular dep concern” to current truth:
|
||||
|
||||
```ts
|
||||
// retry.ts is already in this module's static graph through withRetry, so
|
||||
// classifying the exhausted error does not need a second runtime import.
|
||||
```
|
||||
|
||||
- [ ] **Step 9: Mark both Postgres gateway soft-failure boundaries**
|
||||
|
||||
In `PostgresEngine.initSchema`, mirror the PGLite rationale and preserve behavior:
|
||||
|
||||
```ts
|
||||
try {
|
||||
// Keep the gateway lazy: its static closure is large, and evaluation inside
|
||||
// this try/catch preserves the unconfigured-gateway default fallback.
|
||||
const gw = await import('./ai/gateway.ts'); // engine-dynamic-import-ok
|
||||
// Both accessors THROW when the gateway is unconfigured (they never
|
||||
// return falsy), so the catch below is the only fallback path (#3461).
|
||||
dims = gw.getEmbeddingDimensions();
|
||||
model = gw.getEmbeddingModel();
|
||||
} catch { /* gateway not yet configured — use defaults */ }
|
||||
```
|
||||
|
||||
In `PostgresEngine._upsertChunksOnce`, preserve the DB-config fallback:
|
||||
|
||||
```ts
|
||||
try {
|
||||
// Keep the gateway lazy so module-load failure remains inside this soft
|
||||
// fallback boundary; eager evaluation would bypass the config-row fallback.
|
||||
const gw = await import('./ai/gateway.ts'); // engine-dynamic-import-ok
|
||||
resolvedModel = gw.getEmbeddingModel();
|
||||
} catch {
|
||||
```
|
||||
|
||||
- [ ] **Step 10: Hoist the two migration helper import statements**
|
||||
|
||||
Add these static imports at the top of `src/core/migrate.ts`:
|
||||
|
||||
```ts
|
||||
// runMigrations executes while an initialized engine is live. Keep its helper
|
||||
// modules in the static graph rather than importing them from async handlers.
|
||||
import {
|
||||
isStatementTimeoutError,
|
||||
isRetryableConnError,
|
||||
} from './retry-matcher.ts';
|
||||
import { repairTimelineDedupIndex } from './timeline-dedup-repair.ts';
|
||||
```
|
||||
|
||||
Delete only these two local destructuring imports:
|
||||
|
||||
```ts
|
||||
const { isStatementTimeoutError, isRetryableConnError } = await import('./retry-matcher.ts');
|
||||
const { repairTimelineDedupIndex } = await import('./timeline-dedup-repair.ts');
|
||||
```
|
||||
|
||||
- [ ] **Step 11: Run the complete guard test and direct guard**
|
||||
|
||||
```bash
|
||||
bun test test/scripts/check-engine-dynamic-import.test.ts > .context/engine-dynamic-import-green.txt 2>&1; rc=$?; printf 'EXIT=%s\n' "$rc"; exit "$rc"
|
||||
```
|
||||
|
||||
Expected: exit `0`; the full guard regression suite passes.
|
||||
|
||||
```bash
|
||||
bash scripts/check-engine-dynamic-import.sh > .context/engine-dynamic-import-guard.txt 2>&1; rc=$?; printf 'EXIT=%s\n' "$rc"; exit "$rc"
|
||||
```
|
||||
|
||||
Expected: exit `0`; output contains `check-engine-dynamic-import: ok (3 file(s) scanned)`.
|
||||
|
||||
- [ ] **Step 12: Prove the guard leaves exactly four marked dynamic imports**
|
||||
|
||||
```bash
|
||||
git grep -n -F "import('./ai/gateway.ts'); // engine-dynamic-import-ok" -- src/core/pglite-engine.ts src/core/postgres-engine.ts src/core/migrate.ts > .context/engine-dynamic-import-sites.txt; rc=$?; printf 'EXIT=%s\n' "$rc"; exit "$rc"
|
||||
```
|
||||
|
||||
Expected: exactly four lines, all importing `./ai/gateway.ts` and all carrying `engine-dynamic-import-ok`; no match in `src/core/migrate.ts`.
|
||||
|
||||
- [ ] **Step 13: Run focused behavior tests**
|
||||
|
||||
```bash
|
||||
bun test test/chronicle-ontology.test.ts test/chronicle-ontology-ops.test.ts test/recency-decay.test.ts test/core/retry.test.ts test/retry-matcher.test.ts test/audit/pool-recovery-audit.test.ts test/migrate-retry.test.ts test/timeline-dedup-repair.test.ts > .context/engine-dynamic-import-focused.txt 2>&1; rc=$?; printf 'EXIT=%s\n' "$rc"; exit "$rc"
|
||||
```
|
||||
|
||||
Expected: exit `0`. If Windows resource pressure aborts the process, record the exact exit code and rerun the failing file alone; do not relabel an infrastructure abort as a source pass.
|
||||
|
||||
- [ ] **Step 14: Commit the source invariant locally**
|
||||
|
||||
```bash
|
||||
git add scripts/check-engine-dynamic-import.sh scripts/check-engine-dynamic-import.ts test/scripts/check-engine-dynamic-import.test.ts src/core/pglite-engine.ts src/core/postgres-engine.ts src/core/migrate.ts
|
||||
```
|
||||
|
||||
```bash
|
||||
git commit -m "fix(engine): reconcile dynamic import hardening"
|
||||
```
|
||||
|
||||
Expected: one local commit; no version or release files staged.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Wire the guard into repository checks
|
||||
|
||||
**Files:**
|
||||
- Modify: `test/scripts/check-engine-dynamic-import.test.ts`
|
||||
- Modify: `package.json`
|
||||
- Modify: `scripts/run-verify-parallel.sh`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `scripts/check-engine-dynamic-import.sh` from Task 1.
|
||||
- Produces: package script `check:engine-dynamic-import` and verify dry-list entry of the same name.
|
||||
|
||||
- [ ] **Step 1: Add failing wiring assertions**
|
||||
|
||||
Add these imports/constants to `test/scripts/check-engine-dynamic-import.test.ts`:
|
||||
|
||||
```ts
|
||||
const PACKAGE_JSON = resolve(REPO_ROOT, 'package.json');
|
||||
```
|
||||
|
||||
Append this test block:
|
||||
|
||||
```ts
|
||||
describe('engine dynamic-import guard wiring', () => {
|
||||
it('is invoked through bash by check:all', () => {
|
||||
const pkg = JSON.parse(readFileSync(PACKAGE_JSON, 'utf8')) as {
|
||||
scripts: Record<string, string>;
|
||||
};
|
||||
expect(pkg.scripts['check:engine-dynamic-import']).toBe(
|
||||
'bash scripts/check-engine-dynamic-import.sh',
|
||||
);
|
||||
expect(pkg.scripts['check:all']).toContain(
|
||||
'bash scripts/check-engine-dynamic-import.sh',
|
||||
);
|
||||
});
|
||||
|
||||
it('is listed by the authoritative verify dispatcher', () => {
|
||||
const result = spawnSync(BASH, [VERIFY_DISPATCHER, '--dry-list'], {
|
||||
cwd: REPO_ROOT,
|
||||
encoding: 'utf8',
|
||||
timeout: 30_000,
|
||||
});
|
||||
expect(result.status).toBe(0);
|
||||
expect(new Set((result.stdout ?? '').trim().split('\n'))).toContain(
|
||||
'check:engine-dynamic-import',
|
||||
);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test and verify both wiring assertions fail**
|
||||
|
||||
```bash
|
||||
bun test test/scripts/check-engine-dynamic-import.test.ts > .context/engine-dynamic-import-wiring-red.txt 2>&1; rc=$?; printf 'EXIT=%s\n' "$rc"; exit 0
|
||||
```
|
||||
|
||||
Expected: non-zero Bun result. The source guard tests remain green; package-script and verify-list assertions fail because the wiring is absent.
|
||||
|
||||
- [ ] **Step 3: Add the package scripts**
|
||||
|
||||
In `package.json`, add this script alongside the other `check:*` entries:
|
||||
|
||||
```json
|
||||
"check:engine-dynamic-import": "bash scripts/check-engine-dynamic-import.sh"
|
||||
```
|
||||
|
||||
Append the guard to the existing `check:all` chain, preserving every existing check:
|
||||
|
||||
```text
|
||||
&& bash scripts/check-engine-dynamic-import.sh
|
||||
```
|
||||
|
||||
Do not rewrite any existing shell entry without its `bash` prefix.
|
||||
|
||||
- [ ] **Step 4: Add the authoritative verify entry**
|
||||
|
||||
In `scripts/run-verify-parallel.sh`, add this stable `CHECKS` entry near the other source-shape guards:
|
||||
|
||||
```bash
|
||||
"check:engine-dynamic-import"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the regression test and package check**
|
||||
|
||||
```bash
|
||||
bun test test/scripts/check-engine-dynamic-import.test.ts > .context/engine-dynamic-import-wiring-green.txt 2>&1; rc=$?; printf 'EXIT=%s\n' "$rc"; exit "$rc"
|
||||
```
|
||||
|
||||
Expected: exit `0`; the full guard regression suite passes.
|
||||
|
||||
```bash
|
||||
bun run check:engine-dynamic-import > .context/engine-dynamic-import-package-check.txt 2>&1; rc=$?; printf 'EXIT=%s\n' "$rc"; exit "$rc"
|
||||
```
|
||||
|
||||
Expected: exit `0` and three files scanned.
|
||||
|
||||
- [ ] **Step 6: Commit the wiring locally**
|
||||
|
||||
```bash
|
||||
git add package.json scripts/run-verify-parallel.sh test/scripts/check-engine-dynamic-import.test.ts
|
||||
```
|
||||
|
||||
```bash
|
||||
git commit -m "test(engine): guard dynamic import policy"
|
||||
```
|
||||
|
||||
Expected: one local commit with the guard wiring and its regression assertions.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Document the current-state invariant
|
||||
|
||||
**Files:**
|
||||
- Modify: `CLAUDE.md`
|
||||
- Modify: `docs/architecture/KEY_FILES.md`
|
||||
- Regenerate: `llms.txt`
|
||||
- Regenerate: `llms-full.txt`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the four-marked-import source state and the `check:engine-dynamic-import` package surface.
|
||||
- Produces: current-state contributor guidance and fresh generated documentation bundles.
|
||||
|
||||
- [ ] **Step 1: Add the cross-cutting invariant to `CLAUDE.md`**
|
||||
|
||||
Add this bullet under “Cross-cutting invariants” near the other language/filesystem guards:
|
||||
|
||||
```md
|
||||
- **Engine-live paths use static imports by default.** In
|
||||
`src/core/pglite-engine.ts`, `src/core/postgres-engine.ts`, and
|
||||
`src/core/migrate.ts`, helper modules are top-level imports. The only current
|
||||
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.
|
||||
```
|
||||
|
||||
Do not add release tags, Windows-crash certainty, or historical branch names.
|
||||
|
||||
- [ ] **Step 2: Update the PGLite current-state entry in `KEY_FILES.md`**
|
||||
|
||||
Append this current-state sentence to the existing `src/core/pglite-engine.ts` entry, preserving the entry as one bullet:
|
||||
|
||||
```md
|
||||
Engine-path helper dependencies (`retry`, ontology, recency decay) bind statically; the only lazy 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.
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update the Postgres current-state entry in `KEY_FILES.md`**
|
||||
|
||||
Append this sentence to the existing `src/core/postgres-engine.ts` entry:
|
||||
|
||||
```md
|
||||
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.
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update the migration current-state entry in `KEY_FILES.md`**
|
||||
|
||||
Append this sentence to the canonical `src/core/migrate.ts` entry (the broad runner entry, not the older v95-specific index note):
|
||||
|
||||
```md
|
||||
`retry-matcher.ts` and `timeline-dedup-repair.ts` are static dependencies because `runMigrations()` executes from live engine initialization; the engine dynamic-import guard scans this file with both engine implementations.
|
||||
```
|
||||
|
||||
Keep all three entries current-state only: no `v0.42.x`, branch, commit, “previously,” or “was/now” narration.
|
||||
|
||||
- [ ] **Step 5: Regenerate the llms bundles**
|
||||
|
||||
```bash
|
||||
bun run build:llms > .context/engine-dynamic-import-build-llms.txt 2>&1; rc=$?; printf 'EXIT=%s\n' "$rc"; exit "$rc"
|
||||
```
|
||||
|
||||
Expected: exit `0`; `llms.txt` and/or `llms-full.txt` update according to their configured linked/inlined status. Byte-identical output for a linked source is acceptable; the freshness test is authoritative.
|
||||
|
||||
- [ ] **Step 6: Run documentation freshness checks**
|
||||
|
||||
```bash
|
||||
bun test test/build-llms.test.ts > .context/engine-dynamic-import-llms-test.txt 2>&1; rc=$?; printf 'EXIT=%s\n' "$rc"; exit "$rc"
|
||||
```
|
||||
|
||||
Expected: exit `0`.
|
||||
|
||||
```bash
|
||||
bun run check:doc-history > .context/engine-dynamic-import-doc-history.txt 2>&1; rc=$?; printf 'EXIT=%s\n' "$rc"; exit "$rc"
|
||||
```
|
||||
|
||||
Expected: exit `0`; no release-history marker is introduced into current-state reference docs.
|
||||
|
||||
- [ ] **Step 7: Confirm prohibited release files remain untouched**
|
||||
|
||||
```bash
|
||||
git diff --name-only d7f52d8c..HEAD -- VERSION CHANGELOG.md TODOS.md
|
||||
```
|
||||
|
||||
Expected: no output.
|
||||
|
||||
- [ ] **Step 8: Commit documentation and generated bundles locally**
|
||||
|
||||
```bash
|
||||
git add CLAUDE.md docs/architecture/KEY_FILES.md llms.txt llms-full.txt
|
||||
```
|
||||
|
||||
```bash
|
||||
git commit -m "docs(engine): record static import invariant"
|
||||
```
|
||||
|
||||
Expected: one local documentation commit. If one generated bundle is byte-identical, Git simply omits it.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Verify and review the complete local reconciliation
|
||||
|
||||
**Files:**
|
||||
- Verify all files changed since `d7f52d8c`.
|
||||
- Do not create or modify release/publication metadata.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Tasks 1–3.
|
||||
- Produces: full local verification evidence and an implementation diff ready for user review, not publication.
|
||||
|
||||
- [ ] **Step 1: Run the regression test and direct guard again**
|
||||
|
||||
```bash
|
||||
bun test test/scripts/check-engine-dynamic-import.test.ts > .context/engine-dynamic-import-final-test.txt 2>&1; rc=$?; printf 'EXIT=%s\n' "$rc"; exit "$rc"
|
||||
```
|
||||
|
||||
Expected: exit `0`; the full guard regression suite passes.
|
||||
|
||||
```bash
|
||||
bash scripts/check-engine-dynamic-import.sh > .context/engine-dynamic-import-final-guard.txt 2>&1; rc=$?; printf 'EXIT=%s\n' "$rc"; exit "$rc"
|
||||
```
|
||||
|
||||
Expected: exit `0`; three files scanned.
|
||||
|
||||
- [ ] **Step 2: Run TypeScript checking**
|
||||
|
||||
```bash
|
||||
bun run typecheck > .context/engine-dynamic-import-typecheck.txt 2>&1; rc=$?; printf 'EXIT=%s\n' "$rc"; exit "$rc"
|
||||
```
|
||||
|
||||
Expected: exit `0`. Report exact diagnostics if the branch or current Windows environment has a pre-existing failure.
|
||||
|
||||
- [ ] **Step 3: Run the authoritative verify dispatcher**
|
||||
|
||||
```bash
|
||||
bun run verify > .context/engine-dynamic-import-verify.txt 2>&1; rc=$?; printf 'EXIT=%s\n' "$rc"; exit "$rc"
|
||||
```
|
||||
|
||||
Expected: exit `0`, including `check:engine-dynamic-import`. On Windows, classify any per-check timeout from the complete log instead of treating the aggregate result as a source regression without evidence.
|
||||
|
||||
- [ ] **Step 4: Re-run focused tests as an ownership check**
|
||||
|
||||
```bash
|
||||
bun test test/chronicle-ontology.test.ts test/chronicle-ontology-ops.test.ts test/recency-decay.test.ts test/core/retry.test.ts test/retry-matcher.test.ts test/audit/pool-recovery-audit.test.ts test/migrate-retry.test.ts test/timeline-dedup-repair.test.ts > .context/engine-dynamic-import-final-focused.txt 2>&1; rc=$?; printf 'EXIT=%s\n' "$rc"; exit "$rc"
|
||||
```
|
||||
|
||||
Expected: exit `0`; record any infrastructure abort separately and rerun only the named file before classifying it.
|
||||
|
||||
- [ ] **Step 5: Run the llms freshness test after all documentation settles**
|
||||
|
||||
```bash
|
||||
bun test test/build-llms.test.ts > .context/engine-dynamic-import-final-llms.txt 2>&1; rc=$?; printf 'EXIT=%s\n' "$rc"; exit "$rc"
|
||||
```
|
||||
|
||||
Expected: exit `0`.
|
||||
|
||||
- [ ] **Step 6: Run whitespace and scope checks**
|
||||
|
||||
```bash
|
||||
git diff --check d7f52d8c..HEAD
|
||||
```
|
||||
|
||||
Expected: exit `0`, no output.
|
||||
|
||||
```bash
|
||||
git diff --name-only d7f52d8c..HEAD
|
||||
```
|
||||
|
||||
Expected files only:
|
||||
|
||||
```text
|
||||
CLAUDE.md
|
||||
docs/architecture/KEY_FILES.md
|
||||
docs/superpowers/plans/2026-07-28-engine-dynamic-import-reconciliation.md
|
||||
llms-full.txt
|
||||
llms.txt
|
||||
package.json
|
||||
scripts/check-engine-dynamic-import.sh
|
||||
scripts/check-engine-dynamic-import.ts
|
||||
scripts/run-verify-parallel.sh
|
||||
src/core/migrate.ts
|
||||
src/core/pglite-engine.ts
|
||||
src/core/postgres-engine.ts
|
||||
test/scripts/check-engine-dynamic-import.test.ts
|
||||
```
|
||||
|
||||
Either generated llms file may be absent if regeneration proves it byte-identical. `VERSION`, `CHANGELOG.md`, and `TODOS.md` must be absent.
|
||||
|
||||
- [ ] **Step 7: Review the exact implementation diff**
|
||||
|
||||
```bash
|
||||
git diff --stat d7f52d8c..HEAD && git diff d7f52d8c..HEAD -- src/core/pglite-engine.ts src/core/postgres-engine.ts src/core/migrate.ts scripts/check-engine-dynamic-import.sh test/scripts/check-engine-dynamic-import.test.ts package.json scripts/run-verify-parallel.sh CLAUDE.md docs/architecture/KEY_FILES.md
|
||||
```
|
||||
|
||||
Expected review findings:
|
||||
|
||||
- Exactly 13 safe `await import(...)` statements are removed.
|
||||
- Exactly four `ai/gateway.ts` imports remain, all marked on the same line.
|
||||
- All four gateway imports remain inside their original local `try/catch` fallback boundaries.
|
||||
- No accessor logic, fallback ordering, SQL, public signature, or engine parity behavior changes.
|
||||
- The parser-backed guard reports all violations plus read/parse failures, preserves CRLF line accounting, ignores comments/literals/type-only syntax, detects every runtime `import()` call expression, and accepts opt-outs only from real comment trivia on the same physical line.
|
||||
- The package script invokes the shell guard through Bash; `check:all` invokes that shell guard directly, and the parallel verify dispatcher invokes the package check.
|
||||
- Documentation is current-state and makes no deterministic Windows-crash claim.
|
||||
|
||||
**Observed Windows verification classification:** The authoritative aggregate completed with 25 of 33 checks passing. Individual reruns showed `check:test-names` and `typecheck` green; privacy/isolation exceeded Windows timing budgets; WASM failed in unrelated temporary-symlink setup; eval-glossary was CRLF/LF drift; resolver/brain-first findings predated and did not intersect this branch. The focused aggregate produced 103 pass / 5 fail: three setup-hook timeouts reproduced at the untouched base, and the known `migrate-retry` polling failure reproduced there. Its additional race-status assertion did not reproduce at base, so it remains an unresolved timing-sensitive limitation in untouched code—not evidence of an in-scope defect and not claimed as conclusively pre-existing.
|
||||
|
||||
- [ ] **Step 8: Commit the approved plan document locally**
|
||||
|
||||
The plan is an approved, tracked execution artifact and must not be left as an uncommitted file after implementation:
|
||||
|
||||
```bash
|
||||
git add docs/superpowers/plans/2026-07-28-engine-dynamic-import-reconciliation.md
|
||||
```
|
||||
|
||||
```bash
|
||||
git commit -m "docs: plan engine dynamic-import reconciliation"
|
||||
```
|
||||
|
||||
Expected: one local plan commit; no release metadata staged.
|
||||
|
||||
- [ ] **Step 9: Inspect final status without publishing**
|
||||
|
||||
```bash
|
||||
git status --short --branch
|
||||
```
|
||||
|
||||
Expected: branch `claude/kind-meitner-330c90` with a clean working tree. No push, PR, upstream comment, or other external side effect.
|
||||
|
||||
- [ ] **Step 10: Capture the completed milestone to memory**
|
||||
|
||||
Before writing, search MemPalace wing `gbrain` for this exact reconciliation to avoid duplication. Add a verbatim drawer recording exact base/head commits, the 13 hoists, four gateway opt-outs and rationale, guard/test/docs files, every verification command with exit code, and any environment-owned failures. Add a GBrain project timeline entry only if there is an existing relevant gbrain project page; do not create duplicate release metadata.
|
||||
|
||||
- [ ] **Step 11: Report the local result and ask separately before publication**
|
||||
|
||||
Report:
|
||||
|
||||
- exact local commits;
|
||||
- changed files;
|
||||
- test/check exit codes;
|
||||
- any blocked or pre-existing failures;
|
||||
- confirmation that release files were untouched;
|
||||
- confirmation that nothing was pushed or published.
|
||||
|
||||
Do not run any publication command. Wait for explicit user approval before any push, PR, or upstream interaction.
|
||||
@@ -0,0 +1,175 @@
|
||||
# Scalar-source Backlink Validation Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make backlink validation compare exact `(source_id, slug)` endpoint identities while preserving existing scalar, unscoped, and federated link-read semantics.
|
||||
|
||||
**Architecture:** Enrich every engine link-read row with the source identity of its joined from, to, and visible origin pages. Pass the validated page's scalar or federated scope into validator context; the backlink validator scopes its initial read consistently, groups targets by exact identity, and accepts only an exact reverse endpoint pair. SQL predicates remain unchanged, so trusted scalar cross-source visibility and federated all-endpoint containment remain intact.
|
||||
|
||||
**Tech Stack:** TypeScript, Bun test, PGLite, PostgreSQL/postgres.js.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Use strict red-before-green TDD with duplicate slugs across sources.
|
||||
- Preserve unscoped historical reads, scalar near-endpoint scoping, scalar explicit cross-source visibility, federated all-endpoint containment, and `sourceIds` precedence.
|
||||
- Keep PostgreSQL and PGLite projections in parity.
|
||||
- Do not change schema or conditional-write conflict semantics.
|
||||
- Keep deployment, restart, migration, and push actions outside the implementation tasks; a separately authorized release workflow may perform them after verification.
|
||||
- Capture full test output to files before inspecting it.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Pin the backlink false-negative in PGLite
|
||||
|
||||
**Files:**
|
||||
- Modify: `test/writer.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `backLinkValidator.validate(PageValidationContext)` and source-qualified `putPage`/`addLink`.
|
||||
- Produces: regressions for wrong-source reverse rejection, exact reverse acceptance, cross-source pair acceptance, and exact target deduplication.
|
||||
|
||||
- [ ] **Step 1: Add the minimal failing duplicate-slug regression**
|
||||
|
||||
Create `default` and `team-x` copies of the origin and target, add `(team-x, origin) -> (team-x, target)` plus the wrong reverse `(team-x, target) -> (default, origin)`, validate with `sourceId: 'team-x'`, and require one warning.
|
||||
|
||||
- [ ] **Step 2: Run the focused test and verify RED**
|
||||
|
||||
```bash
|
||||
bun test test/writer.test.ts -t "wrong-source reverse" > "$TEMP/backlink-red.txt" 2>&1
|
||||
```
|
||||
|
||||
Expected: assertion failure because current slug-only validation returns zero findings.
|
||||
|
||||
- [ ] **Step 3: Add the remaining behavioral regressions after the first red is recorded**
|
||||
|
||||
Add tests proving that the exact reverse clears the warning, a legitimate cross-source forward/reverse pair passes, and two destinations sharing one slug but differing by source are validated independently.
|
||||
|
||||
### Task 2: Expose exact endpoint identity from both engines
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/core/types.ts:1204-1229`
|
||||
- Modify: `src/core/postgres-engine.ts:3021-3124`
|
||||
- Modify: `src/core/pglite-engine.ts:2941-3037`
|
||||
- Modify: `test/get-page-federated-scope.test.ts:187-246,289-306`
|
||||
- Modify: `test/e2e/multi-source-bug-class.test.ts:184-205`
|
||||
- Modify: `test/e2e/engine-parity.test.ts:813-875`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `Link.from_source_id: string`, `Link.to_source_id: string`, and `Link.origin_source_id?: string | null`.
|
||||
- Preserves: `getLinks(slug, { sourceId?, sourceIds? })` and `getBacklinks(...)` filtering semantics.
|
||||
|
||||
- [ ] **Step 1: Add engine-contract assertions before implementation**
|
||||
|
||||
Assert scalar cross-source rows expose `beta -> default`, federated rows expose only in-grant endpoint IDs, `sourceIds` still beats scalar `sourceId`, and an out-of-grant origin has both `origin_slug` and `origin_source_id` null.
|
||||
|
||||
- [ ] **Step 2: Run the focused contract tests and verify RED**
|
||||
|
||||
```bash
|
||||
bun test test/get-page-federated-scope.test.ts test/e2e/multi-source-bug-class.test.ts > "$TEMP/link-identity-red.txt" 2>&1
|
||||
```
|
||||
|
||||
Expected: source-ID assertions fail because fields are absent.
|
||||
|
||||
- [ ] **Step 3: Extend `Link` and project IDs without changing predicates**
|
||||
|
||||
Use this additive contract:
|
||||
|
||||
```ts
|
||||
export interface Link {
|
||||
from_slug: string;
|
||||
from_source_id: string;
|
||||
to_slug: string;
|
||||
to_source_id: string;
|
||||
link_type: string;
|
||||
context: string;
|
||||
link_source?: string | null;
|
||||
origin_slug?: string | null;
|
||||
origin_source_id?: string | null;
|
||||
origin_field?: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
In all six branches per engine, project:
|
||||
|
||||
```sql
|
||||
f.source_id AS from_source_id,
|
||||
t.source_id AS to_source_id,
|
||||
o.source_id AS origin_source_id
|
||||
```
|
||||
|
||||
Keep every `WHERE` and grant-aware origin `LEFT JOIN` unchanged.
|
||||
|
||||
- [ ] **Step 4: Re-run contract tests and verify GREEN**
|
||||
|
||||
Use the same command and require all focused tests to pass.
|
||||
|
||||
### Task 3: Validate exact reverse identities and propagate scope
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/core/output/writer.ts:89-96,240-318`
|
||||
- Modify: `src/core/output/post-write.ts:36-41,73-118`
|
||||
- Modify: `src/core/output/validators/back-link.ts:24-47`
|
||||
- Modify: `src/core/operations.ts:1227-1246`
|
||||
- Modify: `test/post-write-lint.test.ts:67-130`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: optional `PageValidationContext.sourceId` and `sourceIds`, with `sourceIds` taking precedence.
|
||||
- `runPostWriteLint(..., opts)` accepts the same optional scope and loads the validated page through it.
|
||||
|
||||
- [ ] **Step 1: Add a post-write nested-read regression and verify RED**
|
||||
|
||||
Validate a non-default page with a wrong-source reverse via `runPostWriteLint(..., { force: true, noLog: true, sourceId: 'team-x' })`; require a backlink warning.
|
||||
|
||||
- [ ] **Step 2: Implement minimal scope propagation**
|
||||
|
||||
Add `sourceId?`/`sourceIds?` to validation context and lint options. Load pages using `sourceIds` when non-empty, otherwise scalar `sourceId`. Pass the same scope into nested validators. In the put-page success hook, call lint with the already-resolved write source ID.
|
||||
|
||||
- [ ] **Step 3: Implement exact backlink matching**
|
||||
|
||||
Initial outbound reads use the validation scope. Deduplicate rows by all four endpoint identity fields so every distinct expected origin remains represented even when targets share a source-qualified identity. Read each target using the federated grant when present, otherwise the target's exact scalar source. Accept only a row matching all four endpoint fields of the expected reverse.
|
||||
|
||||
- [ ] **Step 4: Run writer and post-write tests and verify GREEN**
|
||||
|
||||
```bash
|
||||
bun test test/writer.test.ts test/post-write-lint.test.ts > "$TEMP/backlink-green.txt" 2>&1
|
||||
```
|
||||
|
||||
Expected: all tests pass, including the recorded false-negative.
|
||||
|
||||
### Task 4: Verify PostgreSQL/PGLite parity and final scope
|
||||
|
||||
**Files:**
|
||||
- Modify: `test/e2e/engine-parity.test.ts:813-875`
|
||||
- Verify: all files above
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: exact endpoint fields and unchanged filtering semantics.
|
||||
- Produces: parity evidence for scalar cross-source and federated reads.
|
||||
|
||||
- [ ] **Step 1: Compare complete endpoint tuples across engines**
|
||||
|
||||
Compare sorted tuples containing `from_source_id`, `from_slug`, `to_source_id`, `to_slug`, `origin_source_id`, and `origin_slug` for scalar and federated fixtures.
|
||||
|
||||
- [ ] **Step 2: Run focused PGLite/source-isolation tests**
|
||||
|
||||
```bash
|
||||
bun test test/writer.test.ts test/post-write-lint.test.ts test/get-page-federated-scope.test.ts test/e2e/multi-source-bug-class.test.ts > "$TEMP/backlink-focused.txt" 2>&1
|
||||
```
|
||||
|
||||
Expected: exit 0.
|
||||
|
||||
- [ ] **Step 3: Run PostgreSQL parity when the test database is available**
|
||||
|
||||
```bash
|
||||
bun test test/e2e/engine-parity.test.ts -t "federated sourceIds" --timeout=300000 > "$TEMP/backlink-parity.txt" 2>&1
|
||||
```
|
||||
|
||||
Expected: exit 0; if the configured test database is unavailable, report the exact environmental blocker rather than claiming parity execution.
|
||||
|
||||
- [ ] **Step 4: Typecheck and inspect the final diff**
|
||||
|
||||
```bash
|
||||
bun run typecheck > "$TEMP/backlink-typecheck.txt" 2>&1
|
||||
```
|
||||
|
||||
Expected: exit 0. Then run `git diff --check` and confirm no version, schema, migration, deployment, or conditional-write files changed.
|
||||
@@ -0,0 +1,142 @@
|
||||
# Engine dynamic-import reconciliation design
|
||||
|
||||
**Date:** 2026-07-28
|
||||
|
||||
## Goal
|
||||
|
||||
Reconcile the overlapping engine dynamic-import changes from:
|
||||
|
||||
- `claude/hungry-edison-8bb1cd` at release commits `48ada48f` and `248bfe55`
|
||||
- `claude/elegant-gates-e5275e` at `ef4cf7a8`
|
||||
|
||||
onto a fresh branch from current `origin/master`, without merging or cherry-picking either lineage wholesale and without adding a release/version bump.
|
||||
|
||||
## Established state
|
||||
|
||||
At investigation time:
|
||||
|
||||
- `origin/master` was `6136e139972a5449630b4f47f5ed7b4cbe5b811b`, version `0.42.67.0`.
|
||||
- Upstream PR #3511 was still open, so trunk did not contain its two `chronicle/ontology.ts` hoists.
|
||||
- Neither source branch was an ancestor of trunk.
|
||||
- Trunk contained 17 dynamic imports in the three engine-path files:
|
||||
- 13 safe-hoist candidates: two ontology imports, nine engine helper/audit imports, and two migration imports.
|
||||
- Four `ai/gateway.ts` imports, all inside `try/catch` fallback paths.
|
||||
- `git log -G` showed the separate ontology, helper, migration, and gateway histories. `git log -S` is not suitable for this dynamic-to-static replacement because the relevant token can remain present while its context changes.
|
||||
- The guard from `ef4cf7a8` passed against that commit but failed against trunk. It also knew about only two gateway opt-outs because two `_upsertChunksOnce` gateway lookups landed later in trunk.
|
||||
|
||||
## Selected approach
|
||||
|
||||
Reconstruct the intended current state directly on fresh `origin/master`.
|
||||
|
||||
Do not merge or cherry-pick either old lineage. Selectively reproduce the desired source changes, adapt the guard to the current four gateway call sites, and write current-state documentation. This avoids importing stale release metadata, stale TODO claims, and unrelated lineage changes.
|
||||
|
||||
## Source changes
|
||||
|
||||
### Safe static imports
|
||||
|
||||
Hoist all 13 safe candidates:
|
||||
|
||||
- `src/core/pglite-engine.ts`
|
||||
- `valueHash`, `normalizeDimension`, `isNovelDimension` from `chronicle/ontology.ts`
|
||||
- `isRetryableConnError` through the existing `retry.ts` import
|
||||
- `resolveRecencyDecayMap`, `DEFAULT_FALLBACK` from `search/recency-decay.ts`
|
||||
- `src/core/postgres-engine.ts`
|
||||
- the same ontology, retry, and recency helpers
|
||||
- `isConnectionEndedError` from `retry-matcher.ts`
|
||||
- `logDbDisconnect` from `audit/db-disconnect-audit.ts`
|
||||
- `logPoolRecovery` from `audit/pool-recovery-audit.ts`
|
||||
- `src/core/migrate.ts`
|
||||
- `isStatementTimeoutError`, `isRetryableConnError` from `retry-matcher.ts`
|
||||
- `repairTimelineDedupIndex` from `timeline-dedup-repair.ts`
|
||||
|
||||
The implementation must keep the two engines in parity where the behavior is shared. Comments should describe current invariants, not repeat an unproven causal claim that these hoists fix the Windows test-runner crash.
|
||||
|
||||
### Deliberately lazy gateway imports
|
||||
|
||||
Keep all four `await import('./ai/gateway.ts')` call sites lazy:
|
||||
|
||||
- PGLite `initSchema`
|
||||
- PGLite `_upsertChunksOnce`
|
||||
- Postgres `initSchema`
|
||||
- Postgres `_upsertChunksOnce`
|
||||
|
||||
Each line receives the explicit `engine-dynamic-import-ok` marker and a concise nearby rationale.
|
||||
|
||||
The rationale has two parts:
|
||||
|
||||
1. The gateway's static closure includes the AI SDK, provider packages, and validation/config machinery, so eager loading would tax engine startup paths that do not otherwise need it.
|
||||
2. More importantly, each lookup is inside a `try/catch` that preserves a soft fallback (compiled defaults or the brain's stored embedding-model config). Hoisting the module would evaluate it before that catch can run and could convert a recoverable configuration/import failure into a module-load-time hard failure.
|
||||
|
||||
The guard must not allow unmarked gateway imports or a broad file-level exemption.
|
||||
|
||||
## Guard and wiring
|
||||
|
||||
Add `scripts/check-engine-dynamic-import.sh`, adapted from `ef4cf7a8`, with these properties:
|
||||
|
||||
- Default scan set:
|
||||
- `src/core/pglite-engine.ts`
|
||||
- `src/core/postgres-engine.ts`
|
||||
- `src/core/migrate.ts`
|
||||
- Normalize trailing CR before matching so CRLF checkouts cannot bypass the check.
|
||||
- Ignore comment-only lines.
|
||||
- Ignore only lines carrying `engine-dynamic-import-ok`.
|
||||
- Report every unmarked `await import(` with file and line.
|
||||
- Explain that contributors should prefer a static import and must justify a real opt-out.
|
||||
- Avoid asserting that every dynamic import deterministically crashes Windows; the measured evidence supports treating the pattern as an engine-path hardening invariant, while box-level commit exhaustion remained a confound in prior runs.
|
||||
|
||||
Wire it into:
|
||||
|
||||
- `package.json` as `check:engine-dynamic-import`
|
||||
- `package.json` `check:all`
|
||||
- `scripts/run-verify-parallel.sh`
|
||||
|
||||
Follow trunk's current rule that package scripts invoke repository shell scripts through `bash`.
|
||||
|
||||
## Regression coverage
|
||||
|
||||
Add an automated test for the guard. It must cover:
|
||||
|
||||
- A real dynamic import produces exit 1 and is reported.
|
||||
- A line carrying `engine-dynamic-import-ok` is allowed.
|
||||
- Line comments and block-comment lines do not produce findings.
|
||||
- The same violation is caught with CRLF input.
|
||||
- The default repository scan passes after the source reconciliation.
|
||||
|
||||
Use a temporary fixture rather than mutating tracked source files. Keep assertions path-portable.
|
||||
|
||||
The pre-fix red demonstration is the exact guard from `ef4cf7a8` run against current trunk: it exits 1 and reports the existing unmarked imports. The post-fix guard and test must pass.
|
||||
|
||||
## Documentation policy
|
||||
|
||||
Preserve current behavior, not either old release narrative:
|
||||
|
||||
- Do not modify `VERSION` or add a release `CHANGELOG.md` entry.
|
||||
- Do not copy old version headings or completed release TODO blocks.
|
||||
- Do not retain the old TODO claiming that extracting gateway accessors is necessarily the fix; the lazy imports are deliberately protected by their local soft-failure boundaries.
|
||||
- Add the cross-cutting no-unmarked-dynamic-import invariant to `CLAUDE.md`.
|
||||
- Update the current-state entries for `src/core/pglite-engine.ts`, `src/core/postgres-engine.ts`, and `src/core/migrate.ts` in `docs/architecture/KEY_FILES.md` where needed.
|
||||
- Regenerate `llms.txt` and `llms-full.txt` after the documentation edits.
|
||||
- Add a TODO only if implementation uncovers a real unresolved action.
|
||||
|
||||
Public documentation must use generic language and must not overstate the historical Windows crash causality.
|
||||
|
||||
## Verification
|
||||
|
||||
Capture full output to files before inspecting summaries. Run, at minimum:
|
||||
|
||||
1. The guard regression test.
|
||||
2. `bash scripts/check-engine-dynamic-import.sh`.
|
||||
3. Focused tests that exercise the touched engine, migration, retry, audit, and recency modules.
|
||||
4. `bun run typecheck`.
|
||||
5. `bun run verify`.
|
||||
6. `bun run build:llms` followed by `bun test test/build-llms.test.ts`.
|
||||
7. `git diff --check` and a final clean-status/diff review.
|
||||
|
||||
If platform contention or existing Windows suite defects block a broad test, report the exact command, exit code, and ownership classification rather than declaring success from a partial run.
|
||||
|
||||
## Git and publication boundary
|
||||
|
||||
- Work on `claude/kind-meitner-330c90`, reset locally to the exact investigated `origin/master` base.
|
||||
- Preserve the previous worktree tip under `claude/kind-meitner-330c90-pre-reconcile`.
|
||||
- Keep implementation and verification commits local.
|
||||
- Do not push, create a PR, comment upstream, or otherwise publish without explicit user approval after the local result is complete.
|
||||
@@ -0,0 +1,184 @@
|
||||
# Scalar-source backlink validation design
|
||||
|
||||
## Problem
|
||||
|
||||
A page identity in a multi-source brain is `(source_id, slug)`, but the back-link validator currently reasons only about `slug`.
|
||||
|
||||
For an outbound edge:
|
||||
|
||||
```text
|
||||
(source-a, concepts/origin) -> (source-a, people/target)
|
||||
```
|
||||
|
||||
the validator accepts any reverse row whose bare slugs are:
|
||||
|
||||
```text
|
||||
people/target -> concepts/origin
|
||||
```
|
||||
|
||||
That can incorrectly accept a row ending at `(default, concepts/origin)` instead of `(source-a, concepts/origin)`.
|
||||
|
||||
The bug is not that scalar `getLinks(slug, { sourceId })` permits cross-source destinations. That behavior is intentional: scalar scope qualifies the near/from endpoint while trusted local callers retain visibility into explicit cross-source edges. The gap is that a returned `Link` does not carry the source identity of either endpoint, so callers cannot distinguish same-slug pages.
|
||||
|
||||
## Reproduction and evidence
|
||||
|
||||
A deterministic PGLite reproduction creates duplicate `concepts/a` and `people/b` pages in `default` and `team-x`, then adds:
|
||||
|
||||
```text
|
||||
(team-x, concepts/a) -> (team-x, people/b)
|
||||
(team-x, people/b) -> (default, concepts/a)
|
||||
```
|
||||
|
||||
The second edge is not a valid reverse of the first. Nevertheless:
|
||||
|
||||
```ts
|
||||
await engine.getLinks('people/b', { sourceId: 'team-x' })
|
||||
```
|
||||
|
||||
returns the second row, and the current validator accepts it because `to_slug === 'concepts/a'`.
|
||||
|
||||
Both engines implement the same scalar rule: filter `f.slug` and `f.source_id`, join the actual destination by `to_page_id`, and do not filter `t.source_id`. Federated `sourceIds` is a separate branch that constrains all visible endpoints and takes precedence over scalar scope.
|
||||
|
||||
## Goals
|
||||
|
||||
1. Validate back-links by exact source-qualified endpoint identity.
|
||||
2. Preserve explicit cross-source links for trusted scalar reads.
|
||||
3. Preserve federated all-endpoint containment and `sourceIds` precedence.
|
||||
4. Keep PostgreSQL and PGLite behavior identical.
|
||||
5. Add strict red-before-green regressions using duplicate slugs across sources.
|
||||
6. Avoid schema migrations and production operational changes.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Changing scalar link reads to same-source-only reads.
|
||||
- Weakening or widening federated reads.
|
||||
- Changing link write identity or database schema.
|
||||
- Refactoring the atomic conditional-write branch.
|
||||
- Coupling deployment, restart, or migration mechanics to the backlink code change. Release operations are handled separately after verification.
|
||||
|
||||
## Chosen approach
|
||||
|
||||
Extend the engine `Link` result with endpoint source identities and use those fields in the validator.
|
||||
|
||||
```ts
|
||||
interface Link {
|
||||
from_slug: string;
|
||||
from_source_id: string;
|
||||
to_slug: string;
|
||||
to_source_id: string;
|
||||
// existing fields
|
||||
origin_slug?: string | null;
|
||||
origin_source_id?: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
All `getLinks` and `getBacklinks` query branches in PostgreSQL and PGLite will project the source IDs from the pages already joined as `f`, `t`, and `o`. No filtering behavior changes.
|
||||
|
||||
This approach is preferred over a dedicated `hasExactLink` method because it keeps source identity attached to the link data everywhere, avoids duplicate engine SQL and per-edge existence queries, and matches existing source-qualified link-write and batch-row contracts.
|
||||
|
||||
Validator-only raw SQL is rejected because validators should consume the `BrainEngine` contract rather than bypass it with engine-specific schema knowledge.
|
||||
|
||||
## Engine semantics
|
||||
|
||||
The existing three read modes remain unchanged.
|
||||
|
||||
### Unscoped
|
||||
|
||||
`getLinks(slug)` returns rows from all same-slug from-pages across sources. Each row identifies the actual source of both endpoints.
|
||||
|
||||
### Scalar source
|
||||
|
||||
`getLinks(slug, { sourceId })` matches exactly `(sourceId, slug)` on the from side. A destination may belong to another source, and `to_source_id` reveals that exact identity.
|
||||
|
||||
The corresponding scalar `getBacklinks` rule continues to match the exact destination/to-page identity while allowing a cross-source referrer.
|
||||
|
||||
### Federated sources
|
||||
|
||||
`getLinks(slug, { sourceIds })` continues to constrain from and to endpoints to the grant. The origin join continues to redact an out-of-grant origin. `sourceIds` continues to take precedence over scalar `sourceId`.
|
||||
|
||||
Adding source IDs to returned in-grant endpoints does not disclose anything new: the existing result already discloses those pages' slugs and edges. An out-of-grant endpoint remains absent.
|
||||
|
||||
## Validator algorithm
|
||||
|
||||
The validator receives the source scope associated with the page being validated.
|
||||
|
||||
For every outbound edge:
|
||||
|
||||
```text
|
||||
(from_source_id, from_slug) -> (to_source_id, to_slug)
|
||||
```
|
||||
|
||||
it requires a reverse row:
|
||||
|
||||
```text
|
||||
(to_source_id, to_slug) -> (from_source_id, from_slug)
|
||||
```
|
||||
|
||||
Duplicate edge rows are deduplicated by the full endpoint pair `(from_source_id, from_slug, to_source_id, to_slug)`, not by bare target slug. This preserves separate reverse requirements when multiple same-slug origin pages point to one exact target.
|
||||
|
||||
For each target:
|
||||
|
||||
1. Read target outbound links using the target's exact scalar source when validation is scalar-scoped.
|
||||
2. Under federated validation, retain the caller's `sourceIds` grant rather than converting it to scalar scope.
|
||||
3. Accept only a returned row whose `from_source_id`, `from_slug`, `to_source_id`, and `to_slug` exactly match the expected reverse identity.
|
||||
4. Emit the existing warning when no exact reverse exists.
|
||||
|
||||
This preserves legitimate cross-source pairs. For example:
|
||||
|
||||
```text
|
||||
(source-a, concepts/origin) -> (source-b, people/target)
|
||||
(source-b, people/target) -> (source-a, concepts/origin)
|
||||
```
|
||||
|
||||
is valid.
|
||||
|
||||
## Validation context propagation
|
||||
|
||||
`PageValidationContext` must carry the relevant scalar or federated source scope. The writer and post-write lint paths must load the page with that scope and pass the same scope to nested validator reads.
|
||||
|
||||
This change is scoped to source routing needed by validation. It does not modify conditional-write revision or conflict semantics and must not be applied to the atomic conditional-write branch.
|
||||
|
||||
## Testing strategy
|
||||
|
||||
### PGLite strict-TDD regression
|
||||
|
||||
Add duplicate pages across `default` and a second source, then prove before the production fix that:
|
||||
|
||||
1. A forward edge in the second source plus a wrong-source reverse produces a warning.
|
||||
2. Adding the exact reverse removes the warning.
|
||||
3. A legitimate cross-source forward/reverse pair passes.
|
||||
4. Two same-slug destination pages are not collapsed into one target identity.
|
||||
|
||||
The first assertion must fail against the pre-fix implementation.
|
||||
|
||||
### Engine contract tests
|
||||
|
||||
For PGLite and PostgreSQL:
|
||||
|
||||
1. Assert link rows expose exact from/to source IDs.
|
||||
2. Assert scalar reads still return explicit cross-source destinations.
|
||||
3. Assert federated reads still exclude out-of-grant endpoints.
|
||||
4. Assert `sourceIds` still takes precedence over scalar `sourceId`.
|
||||
5. Assert origin source identity is null when the origin is redacted by the federated branch.
|
||||
|
||||
### Parity and focused verification
|
||||
|
||||
Run:
|
||||
|
||||
- the focused backlink validator test;
|
||||
- source-isolation and federated link tests;
|
||||
- the Postgres/PGLite parity fixture with a test database;
|
||||
- related writer/post-write tests;
|
||||
- `bun run typecheck`.
|
||||
|
||||
Capture complete command output to files before inspecting summaries. Do not use production databases or restart the live service.
|
||||
|
||||
## Compatibility
|
||||
|
||||
The `Link` change is additive at runtime. Existing consumers that read only slug or provenance fields continue to work. TypeScript object literals typed as complete `Link` values may need source fields; if compatibility pressure is high, the source fields can initially be optional in the public type while engine implementations and validator tests require their presence. The preferred contract is required endpoint source IDs because every persisted link always has both pages and therefore both source IDs.
|
||||
|
||||
No schema migration is required because source IDs already live on the joined `pages` rows.
|
||||
|
||||
## Operational constraints
|
||||
|
||||
The implementation phase does not deploy, restart GBrain, run production migrations, or alter the atomic conditional-write branch. Release, migration, and restart operations are a separate verified workflow and do not change this design's engine or validator semantics.
|
||||
@@ -13,7 +13,7 @@ Step-by-step walkthroughs that take you from zero to a working outcome. Concrete
|
||||
|
||||
These are the next tutorials on the roadmap. Open an issue if one of them is the one you need most; that's how we'll prioritize.
|
||||
|
||||
- **Set up GBrain for VC dealflow** — the operator's recipe. People pages for founders, companies with typed Facts fence carrying ARR / team-size / runway across dates, meetings auto-ingested, deal pages linking everything. Shows `gbrain whoknows`, `gbrain find_trajectory`, and `gbrain founder scorecard` on real workflows.
|
||||
- **Set up GBrain for VC dealflow** — the operator's recipe. People pages for founders, companies with typed Facts fence carrying ARR / team-size / runway across dates, meetings auto-ingested, deal pages linking everything. Shows `gbrain whoknows`, `gbrain find-trajectory`, and `gbrain founder scorecard` on real workflows.
|
||||
|
||||
- **Migrate your existing vault into GBrain** — for Notion / Obsidian / Roam users with a vault that doesn't match GBrain's default layout. Walks through `gbrain schema detect` → `suggest` → `review-candidates` so the brain learns your shape instead of forcing you to learn its.
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ There are two ways to scope teammates' access. They suit different deployment sh
|
||||
|
||||
**Model A: separate sources with OAuth scoping (recommended for true multi-user with different AI clients).** What this tutorial walks you through. Each teammate gets their own OAuth client, which carries `--source` + `--federated-read` flags. The brain refuses cross-source reads at the SQL layer; isolation is database-enforced. Each teammate can run their own MCP-aware client (Claude Code, Cursor, their own OpenClaw, etc.) and the scoping holds.
|
||||
|
||||
**Model B: one source, directory-based per-person scoping (simpler for one-agent-serves-everyone setups).** The shape I actually run in production: a single source called `default`, with a `partners/<slug>/` convention inside it (e.g. `partners/alice-example/`, `partners/bob-example/`). Each partner gets their own subdirectory holding their personal pages: `partners/alice-example/USER.md`, `partners/alice-example/concepts/`, `partners/alice-example/sources/`, etc. There's no OAuth-enforced isolation; the agent itself enforces "Alice's writes go to her partners/ subdir." This is the right model when ONE agent (yours) serves everyone over Telegram or a single shared interface. It's simpler ops, no per-user OAuth, but the scoping is convention-only.
|
||||
**Model B: one source, directory-based per-person scoping (simpler for one-agent-serves-everyone setups).** The shape I actually run in production: a single source called `default`, with a `partners/<slug>/` convention inside it (e.g. `partners/alice-example/`, `partners/bob-example/`). Each partner gets their own subdirectory holding their personal pages: `partners/alice-example/USER.md`, `partners/alice-example/concepts/`, `partners/alice-example/sources/`, etc. This is the right model when ONE agent (yours) serves everyone over Telegram or a single shared interface. It's simpler ops, no per-user OAuth. **Write scoping within the shared source can be server-enforced:** register each per-person client with `--bound-slug-prefixes partners/alice-example/` and every slug-mutating write outside that prefix is rejected with `permission_denied` (v0.42.72.0+). Without the binding, the scoping is convention-only (the agent polices itself). Read scoping stays source-granular in both models — within a shared source, everyone entitled to the source can read every folder.
|
||||
|
||||
For most company-brain installs (10+ teammates each with their own AI client), Model A is the right starting point. If you're running the fat-agent-serves-everyone pattern from the personal-brain tutorial, Model B is genuinely simpler. You can also mix: separate sources for the obviously-different ones (customer notes vs internal-only) AND a `partners/<slug>/` convention inside the shared source for per-person workspace.
|
||||
|
||||
@@ -210,7 +210,7 @@ Each `register-client` command prints a `client_id` and a `client_secret`. Save
|
||||
A note on the flags:
|
||||
|
||||
- `--scopes read,write` lets the client query the brain and write new pages. You can omit `write` for read-only clients (executive summaries, dashboards). The `admin` scope is needed for operational commands like `gbrain remote doctor` and is usually reserved for your own admin client.
|
||||
- `--source` controls write authority. A client can only write to one source. Within that source, your folder convention from Part 3 keeps each person's writes in their own subfolder.
|
||||
- `--source` controls write authority. A client can only write to one source. Within that source, your folder convention from Part 3 keeps each person's writes in their own subfolder — and you can make that server-enforced with `--bound-slug-prefixes alice-example/` (v0.42.72.0+): every slug-mutating write op (put_page, delete_page, tags, links, timeline, revert, raw data) outside the bound prefixes is rejected with `permission_denied`. Update the binding later with `gbrain auth rescope-client <id> --bound-slug-prefixes <p1,p2|none>`. **Adding a binding to an existing client narrows it in ways you should expect:** ops that write by something other than a slug (`extract_entities`, `extract_facts`, `forget_fact`, `ontology_propose`, `sources_add`/`sources_remove`) and `POST /ingest` become unavailable to that client, and `put_page`'s automatic fact extraction is skipped — all because none of them can be confined to a prefix. Reads are unaffected. See [the qm-harness guide](../integrations/qm-harness.md) for the full model.
|
||||
- `--federated-read` controls read scope. A client can read from one or more sources.
|
||||
|
||||
### Verify the scoping actually scopes
|
||||
@@ -554,7 +554,7 @@ What to do next:
|
||||
|
||||
- **Wire ingestion** from external systems (Granola, Linear, Slack) using the [ingestion source contract](../skillpack-anatomy.md). Most companies want their meetings auto-ingested so the brain stays current without anyone typing notes.
|
||||
- **Set up team-specific dashboards** through the admin UI. Each team lead can have their own view of brain health and activity.
|
||||
- **Explore the rest of the brain layer.** `gbrain whoknows` (find the expert on a topic), `gbrain find_trajectory` (how a metric changed over time), `gbrain founder scorecard` (especially useful for VC and ops teams), the contradiction-detection cycle that surfaces conflicts between different people's notes.
|
||||
- **Explore the rest of the brain layer.** `gbrain whoknows` (find the expert on a topic), `gbrain find-trajectory` (how a metric changed over time), `gbrain founder scorecard` (especially useful for VC and ops teams), the contradiction-detection cycle that surfaces conflicts between different people's notes.
|
||||
|
||||
If you're building in this space (which YC has flagged as the [company-brain category in its Request for Startups](https://www.ycombinator.com/rfs#company-brain)), you might as well build on this. Everything described above is open source, MIT licensed, and what I run in production behind my own AI agents.
|
||||
|
||||
|
||||
@@ -115,21 +115,21 @@ You can use the same keys across multiple agents.
|
||||
|
||||
## Step 6: Install GBrain
|
||||
|
||||
Once OpenClaw is running:
|
||||
Once OpenClaw is running, installation is two commands — one in the brain repo, one in the agent workspace:
|
||||
|
||||
```bash
|
||||
gbrain install
|
||||
# In the BRAIN repo (the git repo that holds your markdown pages):
|
||||
gbrain init --supabase
|
||||
|
||||
# In the AGENT WORKSPACE repo (where OpenClaw runs):
|
||||
gbrain skillpack scaffold --all
|
||||
```
|
||||
|
||||
This installs:
|
||||
`gbrain init --supabase` walks a short wizard that asks for your Supabase connection string and creates the schema. You'll get that connection string in Step 7 — read 7a and 7b first so you paste the right one (the transaction pooler, not the direct connection). If you'd rather try things locally before paying for a database, `gbrain init --pglite` gives you a zero-config embedded engine instead; you can migrate to Supabase later with `gbrain migrate --to supabase`.
|
||||
|
||||
- About 60 skills
|
||||
- About 9 skill packs
|
||||
- Default brain structure
|
||||
- MCP server configuration
|
||||
- Supabase connection (for embeddings and search)
|
||||
`gbrain skillpack scaffold --all` copies the ~43 bundled skills into your agent workspace as first-class files you can edit freely. (The old managed-install model was retired in v0.36.0.0; see `docs/INSTALL.md` if you're upgrading from an older release.)
|
||||
|
||||
GBrain populates the brain repo with its default directory structure, skill files, and configuration. From this point, the agent has working memory and access to every skill.
|
||||
From this point, the agent has working memory and access to every skill.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ gbrain schema sync --apply
|
||||
The sync backfills `page.type = 'meeting'` on all 4000 pages in 1000-row batches. Now:
|
||||
|
||||
- `gbrain whoknows "Q3 roadmap discussion"` routes through the meeting type, ranking by `expert_routing` signal (attendees, recency, salience) instead of raw text.
|
||||
- `gbrain extract-facts` runs on every meeting page automatically (because `extractable: true`), pulling typed facts like `attended_by=alice-example`, `date=2026-05-23`.
|
||||
- The `extract_facts` cycle runs on every meeting page automatically (because `extractable: true`), pulling typed facts like `attended_by=alice-example`, `date=2026-05-23`.
|
||||
- The downstream `think` skill can now answer "what did we decide about pricing in the last three roadmap meetings" by querying the meeting graph instead of grep'ing 4000 files.
|
||||
|
||||
One command. 4000 pages went from invisible to queryable. The content didn't change. The structure did.
|
||||
@@ -62,7 +62,7 @@ gbrain schema add-link-type led-by --page-type deal --target-type inves
|
||||
gbrain schema sync --apply
|
||||
```
|
||||
|
||||
Now `gbrain whoknows "Series A SaaS"` routes through `investor` and `portco` types specifically, not the noisy general type set. `gbrain graph-query alice-example --type intro-from --depth 2` walks two hops of intros to surface "Alice introduced you to Bob who introduced you to Charlie." `gbrain extract-facts` starts producing typed claims from the fence in your deal pages: `(deals/acme-seed, raise=2000000, valuation=15000000, lead=widget-vc, closed_at=2026-05-23)`.
|
||||
Now `gbrain whoknows "Series A SaaS"` routes through `investor` and `portco` types specifically, not the noisy general type set. `gbrain graph-query alice-example --type intro-from --depth 2` walks two hops of intros to surface "Alice introduced you to Bob who introduced you to Charlie." The `extract_facts` cycle starts producing typed claims from the fence in your deal pages: `(deals/acme-seed, raise=2000000, valuation=15000000, lead=widget-vc, closed_at=2026-05-23)`.
|
||||
|
||||
The CRM you've been promising yourself you'll set up next quarter? You just shipped it in 4 commands. It's downstream of your notes, not parallel to them.
|
||||
|
||||
@@ -143,7 +143,7 @@ Re-run the same `whoknows` query. Top-3 should shift, because the new type is no
|
||||
|
||||
Three things gbrain does that generic note systems can't:
|
||||
|
||||
**1. The brain knows the difference between a person and an idea.** Page-type matters at query time. `gbrain whoknows` only considers `expert_routing: true` types. `gbrain extract-facts` only runs on `extractable: true` types. `gbrain graph-query` walks declared link verbs. None of that works on a flat tag system because tags don't have semantics — they're labels. Types are first-class citizens with rules attached.
|
||||
**1. The brain knows the difference between a person and an idea.** Page-type matters at query time. `gbrain whoknows` only considers `expert_routing: true` types. The `extract_facts` cycle only runs on `extractable: true` types. `gbrain graph-query` walks declared link verbs. None of that works on a flat tag system because tags don't have semantics — they're labels. Types are first-class citizens with rules attached.
|
||||
|
||||
**2. Untyped content is invisible content.** If your meetings are typed as `note`, expert routing skips them, facts extraction ignores them, link inference doesn't fire. They exist on disk and they're indexed for text search, but the structural surfaces (whoknows, find_experts, recall, think) treat them as second-class. Adding a type isn't cosmetic; it's structural promotion.
|
||||
|
||||
|
||||
+96
-6
@@ -216,6 +216,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
|
||||
@@ -1006,6 +1019,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
|
||||
@@ -1559,6 +1579,16 @@ 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)
|
||||
@@ -1700,7 +1730,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).
|
||||
|
||||
@@ -1801,7 +1831,7 @@ 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).
|
||||
**PGLite crashes at startup with `RuntimeError: Aborted()` (often right after a macOS upgrade)?** Not a macOS incompatibility — the OS-upgrade reboot killed gbrain mid-write and tore the data dir's WAL. gbrain now repairs this automatically on the next command (data preserved, backup kept); if auto-repair is disabled or skipped, run `gbrain pglite-repair --dry-run` to diagnose and `gbrain pglite-repair --yes` to repair in place. Full recovery ladder (repair → rebuild → engine switch) in [`docs/ENGINES.md` — Troubleshooting: startup abort](docs/ENGINES.md#troubleshooting-startup-abort-runtimeerror-aborted) and [`docs/INSTALL.md`](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 <provider>:<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.
|
||||
|
||||
@@ -2186,6 +2216,66 @@ live in `test/postgres-engine-rls-scope.test.ts`.
|
||||
|
||||
**Migration:** `gbrain migrate --to supabase` exports everything (pages, chunks, embeddings, links, tags, timeline) and imports into Supabase. `gbrain migrate --to pglite` goes the other direction. Bidirectional, lossless.
|
||||
|
||||
### Troubleshooting: startup abort (`RuntimeError: Aborted()`)
|
||||
|
||||
**Symptom:** every PGLite-touching command dies at startup with
|
||||
`PGLite failed to initialize its WASM runtime … Aborted(). Build with
|
||||
-sASSERTIONS for more info.` — commonly first seen right after a macOS
|
||||
upgrade.
|
||||
|
||||
**Real root cause:** corrupt WAL/checkpoint state in the data dir after an
|
||||
unclean shutdown (the OS-upgrade reboot kills gbrain mid-write and tears the
|
||||
write-ahead log; every subsequent open fails WAL replay inside WASM and
|
||||
Emscripten surfaces only the opaque abort). It is **not** a macOS/WASM
|
||||
incompatibility — the same signature reproduces across macOS versions and on
|
||||
Linux, and rebuilding the data dir on the same OS fixes it. No pglite or Bun
|
||||
version bump changes it.
|
||||
|
||||
**Recovery ladder** (top rung first):
|
||||
|
||||
1. **Auto-repair (default).** `PGLiteEngine.connect()` detects the abort,
|
||||
backs up `pg_wal/` + `pg_control` into a sibling
|
||||
`<dataDir>.wal-repair-backup-<ts>/` dir, resets the WAL in place
|
||||
(pg_resetwal semantics — data files preserved; transactions not
|
||||
checkpointed before the corruption may be lost), and retries once. On
|
||||
success it prints a loud stderr notice naming the backup and recommending
|
||||
`gbrain doctor`. Safety bounds: repair only runs under a cleanly-acquired
|
||||
data-dir lock (never after reaping another process's lock), skips for a
|
||||
cooldown window after a failed attempt
|
||||
(`GBRAIN_PGLITE_WAL_REPAIR_COOLDOWN_SECONDS`, default 3600), reuses one
|
||||
backup per corruption episode (newest 3 episodes retained), and restores
|
||||
the original files if the retry still fails. Kill-switch:
|
||||
`GBRAIN_PGLITE_WAL_REPAIR=off`.
|
||||
2. **Manual repair.** `gbrain pglite-repair --dry-run` diagnoses the data dir
|
||||
(read-only); `gbrain pglite-repair --yes` runs the same in-place WAL reset
|
||||
deliberately. Refuses when another gbrain process holds the brain (a live
|
||||
`gbrain serve` is named explicitly) and never force-removes `.gbrain-lock`.
|
||||
3. **Rebuild.** `gbrain reinit-pglite` (embedding model/dimensions default
|
||||
from your config) wipes and re-creates the brain from your brain repo, or
|
||||
manually: back up `~/.gbrain`, move `brain.pglite` aside,
|
||||
`gbrain init --pglite`, re-add sources, `gbrain sync`, `gbrain embed`.
|
||||
Required for *catalog* corruption (58P01 / pgvector load failure) — WAL
|
||||
repair cannot fix that class.
|
||||
4. **Switch engines.** `gbrain init --supabase`, or native Postgres +
|
||||
pgvector (recipe below, contributed by @roysaurav):
|
||||
|
||||
```bash
|
||||
brew install postgresql@17
|
||||
brew services start postgresql@17
|
||||
createdb gbrain
|
||||
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;"
|
||||
# ~/.gbrain/config.json: { "engine": "postgres",
|
||||
# "database_url": "postgresql://localhost:5432/gbrain" }
|
||||
gbrain apply-migrations --yes && gbrain doctor
|
||||
```
|
||||
|
||||
`gbrain doctor` runs a `pglite_data_dir` check whenever a PGLite brain fails
|
||||
to connect: it diagnoses the dir from disk, names the repair command, reports
|
||||
retained repair backups, and escalates when repairs keep recurring (that
|
||||
means the unclean-shutdown genesis is still active — see the ladder's rung 4).
|
||||
|
||||
## JSONB writes: never double-encode (the #2339 trap)
|
||||
|
||||
Writing a JS value into a `jsonb` column has exactly two correct forms. Get this
|
||||
@@ -2316,7 +2406,7 @@ gbrain schema sync --apply
|
||||
The sync backfills `page.type = 'meeting'` on all 4000 pages in 1000-row batches. Now:
|
||||
|
||||
- `gbrain whoknows "Q3 roadmap discussion"` routes through the meeting type, ranking by `expert_routing` signal (attendees, recency, salience) instead of raw text.
|
||||
- `gbrain extract-facts` runs on every meeting page automatically (because `extractable: true`), pulling typed facts like `attended_by=alice-example`, `date=2026-05-23`.
|
||||
- The `extract_facts` cycle runs on every meeting page automatically (because `extractable: true`), pulling typed facts like `attended_by=alice-example`, `date=2026-05-23`.
|
||||
- The downstream `think` skill can now answer "what did we decide about pricing in the last three roadmap meetings" by querying the meeting graph instead of grep'ing 4000 files.
|
||||
|
||||
One command. 4000 pages went from invisible to queryable. The content didn't change. The structure did.
|
||||
@@ -2346,7 +2436,7 @@ gbrain schema add-link-type led-by --page-type deal --target-type inves
|
||||
gbrain schema sync --apply
|
||||
```
|
||||
|
||||
Now `gbrain whoknows "Series A SaaS"` routes through `investor` and `portco` types specifically, not the noisy general type set. `gbrain graph-query alice-example --type intro-from --depth 2` walks two hops of intros to surface "Alice introduced you to Bob who introduced you to Charlie." `gbrain extract-facts` starts producing typed claims from the fence in your deal pages: `(deals/acme-seed, raise=2000000, valuation=15000000, lead=widget-vc, closed_at=2026-05-23)`.
|
||||
Now `gbrain whoknows "Series A SaaS"` routes through `investor` and `portco` types specifically, not the noisy general type set. `gbrain graph-query alice-example --type intro-from --depth 2` walks two hops of intros to surface "Alice introduced you to Bob who introduced you to Charlie." The `extract_facts` cycle starts producing typed claims from the fence in your deal pages: `(deals/acme-seed, raise=2000000, valuation=15000000, lead=widget-vc, closed_at=2026-05-23)`.
|
||||
|
||||
The CRM you've been promising yourself you'll set up next quarter? You just shipped it in 4 commands. It's downstream of your notes, not parallel to them.
|
||||
|
||||
@@ -2427,7 +2517,7 @@ Re-run the same `whoknows` query. Top-3 should shift, because the new type is no
|
||||
|
||||
Three things gbrain does that generic note systems can't:
|
||||
|
||||
**1. The brain knows the difference between a person and an idea.** Page-type matters at query time. `gbrain whoknows` only considers `expert_routing: true` types. `gbrain extract-facts` only runs on `extractable: true` types. `gbrain graph-query` walks declared link verbs. None of that works on a flat tag system because tags don't have semantics — they're labels. Types are first-class citizens with rules attached.
|
||||
**1. The brain knows the difference between a person and an idea.** Page-type matters at query time. `gbrain whoknows` only considers `expert_routing: true` types. The `extract_facts` cycle only runs on `extractable: true` types. `gbrain graph-query` walks declared link verbs. None of that works on a flat tag system because tags don't have semantics — they're labels. Types are first-class citizens with rules attached.
|
||||
|
||||
**2. Untyped content is invisible content.** If your meetings are typed as `note`, expert routing skips them, facts extraction ignores them, link inference doesn't fire. They exist on disk and they're indexed for text search, but the structural surfaces (whoknows, find_experts, recall, think) treat them as second-class. Adding a type isn't cosmetic; it's structural promotion.
|
||||
|
||||
@@ -3897,7 +3987,7 @@ All 30 GBrain operations are available remotely, including `sync_brain` and
|
||||
directory where `gbrain serve` was launched. Symlinks, `..` traversal, and absolute
|
||||
paths outside cwd are rejected. Page slugs and filenames are allowlist-validated
|
||||
(alphanumeric + hyphens; no control chars, RTL overrides, or backslashes). Local
|
||||
CLI callers (`gbrain file upload ...`) keep unrestricted filesystem access since
|
||||
CLI callers (`gbrain files upload ...`) keep unrestricted filesystem access since
|
||||
the user owns the machine.
|
||||
|
||||
## Deployment Options
|
||||
|
||||
+42
-38
@@ -37,25 +37,27 @@
|
||||
"build:admin-embedded": "bun run scripts/build-admin-embedded.ts",
|
||||
"build:schema": "bash scripts/build-schema.sh",
|
||||
"build:llms": "bun run scripts/build-llms.ts",
|
||||
"build:flag-registry": "bun run scripts/generate-flag-registry.ts",
|
||||
"build:pglite-snapshot": "bun run scripts/build-pglite-snapshot.ts",
|
||||
"test": "bash scripts/run-unit-parallel.sh",
|
||||
"eval:autocut": "bun test test/search/autocut-eval.test.ts",
|
||||
"test:full": "bun run verify && bash scripts/run-unit-parallel.sh && bun run test:slow && ([ -n \"$DATABASE_URL\" ] && bash scripts/run-e2e.sh || echo '[test:full] skipped E2E (no DATABASE_URL); run docker-compose -f docker-compose.ci.yml up + bun run test:e2e to include' 1>&2)",
|
||||
"verify": "bash scripts/run-verify-parallel.sh",
|
||||
"check:source-config-leak": "scripts/check-source-config-leak.sh",
|
||||
"check:no-pii-agent-voice": "scripts/check-no-pii-in-agent-voice.sh",
|
||||
"check:synthetic-corpus-privacy": "scripts/check-synthetic-corpus-privacy.sh",
|
||||
"check:system-of-record": "scripts/check-system-of-record.sh",
|
||||
"check:admin-scope-drift": "scripts/check-admin-scope-drift.sh",
|
||||
"check:cli-exec": "scripts/check-cli-executable.sh",
|
||||
"check:all": "scripts/check-privacy.sh && scripts/check-proposal-pii.sh && scripts/check-test-real-names.sh && scripts/check-jsonb-pattern.sh && scripts/check-source-id-projection.sh && scripts/check-source-config-leak.sh && scripts/check-progress-to-stdout.sh && scripts/check-no-legacy-getconnection.sh && scripts/check-test-isolation.sh && scripts/check-trailing-newline.sh && scripts/check-wasm-embedded.sh && scripts/check-exports-count.sh && scripts/check-admin-build.sh && scripts/check-admin-scope-drift.sh && scripts/check-cli-executable.sh && scripts/check-skill-brain-first.sh && scripts/check-operations-filter-bypass.sh && scripts/check-gateway-routed-no-direct-anthropic.sh && scripts/check-worker-pool-atomicity.sh && scripts/check-key-files-current-state.sh && scripts/check-no-double-retry.sh && scripts/check-batch-audit-site.sh",
|
||||
"check:gateway-routed": "scripts/check-gateway-routed-no-direct-anthropic.sh",
|
||||
"check:worker-pool-atomicity": "scripts/check-worker-pool-atomicity.sh",
|
||||
"check:doc-history": "scripts/check-key-files-current-state.sh",
|
||||
"check:source-config-leak": "bash scripts/check-source-config-leak.sh",
|
||||
"check:no-pii-agent-voice": "bash scripts/check-no-pii-in-agent-voice.sh",
|
||||
"check:synthetic-corpus-privacy": "bash scripts/check-synthetic-corpus-privacy.sh",
|
||||
"check:system-of-record": "bash scripts/check-system-of-record.sh",
|
||||
"check:admin-scope-drift": "bash scripts/check-admin-scope-drift.sh",
|
||||
"check:cli-exec": "bash scripts/check-cli-executable.sh",
|
||||
"check:engine-dynamic-import": "bash scripts/check-engine-dynamic-import.sh",
|
||||
"check:all": "bash scripts/check-privacy.sh && bash scripts/check-proposal-pii.sh && bash scripts/check-test-real-names.sh && bash scripts/check-jsonb-pattern.sh && bash scripts/check-source-id-projection.sh && bash scripts/check-source-config-leak.sh && bash scripts/check-progress-to-stdout.sh && bash scripts/check-no-tracked-symlinks.sh && bash scripts/check-no-legacy-getconnection.sh && bash scripts/check-test-isolation.sh && bash scripts/check-trailing-newline.sh && bash scripts/check-wasm-embedded.sh && bash scripts/check-exports-count.sh && bash scripts/check-admin-build.sh && bash scripts/check-admin-scope-drift.sh && bash scripts/check-cli-executable.sh && bash scripts/check-skill-brain-first.sh && bash scripts/check-operations-filter-bypass.sh && bash scripts/check-gateway-routed-no-direct-anthropic.sh && bash scripts/check-worker-pool-atomicity.sh && bash scripts/check-key-files-current-state.sh && bash scripts/check-no-double-retry.sh && bash scripts/check-batch-audit-site.sh && bash scripts/check-engine-dynamic-import.sh",
|
||||
"check:gateway-routed": "bash scripts/check-gateway-routed-no-direct-anthropic.sh",
|
||||
"check:worker-pool-atomicity": "bash scripts/check-worker-pool-atomicity.sh",
|
||||
"check:doc-history": "bash scripts/check-key-files-current-state.sh",
|
||||
"check:resolver": "bun src/cli.ts check-resolvable --strict --skills-dir skills/",
|
||||
"check:skill-brain-first": "scripts/check-skill-brain-first.sh",
|
||||
"check:wasm": "scripts/check-wasm-embedded.sh",
|
||||
"check:newlines": "scripts/check-trailing-newline.sh",
|
||||
"check:skill-brain-first": "bash scripts/check-skill-brain-first.sh",
|
||||
"check:wasm": "bash scripts/check-wasm-embedded.sh",
|
||||
"check:newlines": "bash scripts/check-trailing-newline.sh",
|
||||
"test:e2e": "bash scripts/run-e2e.sh",
|
||||
"test:slow": "bash scripts/run-slow-tests.sh",
|
||||
"test:heavy": "bash scripts/run-heavy.sh",
|
||||
@@ -65,26 +67,28 @@
|
||||
"ci:local:diff": "bash scripts/ci-local.sh --diff",
|
||||
"ci:select-e2e": "bun run scripts/select-e2e.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"check:jsonb": "scripts/check-jsonb-pattern.sh",
|
||||
"check:search-path": "scripts/check-search-path.sh",
|
||||
"check:no-double-retry": "scripts/check-no-double-retry.sh",
|
||||
"check:batch-audit-site": "scripts/check-batch-audit-site.sh",
|
||||
"check:worker-lock-renewal-shape": "scripts/check-worker-lock-renewal-shape.sh",
|
||||
"check:source-id-projection": "scripts/check-source-id-projection.sh",
|
||||
"check:privacy": "scripts/check-privacy.sh",
|
||||
"check:proposal-pii": "scripts/check-proposal-pii.sh",
|
||||
"check:eval-glossary": "scripts/check-eval-glossary-fresh.sh",
|
||||
"check:test-names": "scripts/check-test-real-names.sh",
|
||||
"check:progress": "scripts/check-progress-to-stdout.sh",
|
||||
"check:exports-count": "scripts/check-exports-count.sh",
|
||||
"check:admin-build": "scripts/check-admin-build.sh",
|
||||
"check:admin-embedded": "scripts/check-admin-embedded.sh",
|
||||
"check:test-isolation": "scripts/check-test-isolation.sh",
|
||||
"check:fuzz-purity": "scripts/check-fuzz-purity.sh",
|
||||
"check:operations-filter-bypass": "scripts/check-operations-filter-bypass.sh",
|
||||
"check:fixture-privacy": "scripts/check-fixture-privacy.sh",
|
||||
"check:jsonb": "bash scripts/check-jsonb-pattern.sh",
|
||||
"check:search-path": "bash scripts/check-search-path.sh",
|
||||
"check:no-double-retry": "bash scripts/check-no-double-retry.sh",
|
||||
"check:batch-audit-site": "bash scripts/check-batch-audit-site.sh",
|
||||
"check:worker-lock-renewal-shape": "bash scripts/check-worker-lock-renewal-shape.sh",
|
||||
"check:source-id-projection": "bash scripts/check-source-id-projection.sh",
|
||||
"check:privacy": "bash scripts/check-privacy.sh",
|
||||
"check:proposal-pii": "bash scripts/check-proposal-pii.sh",
|
||||
"check:eval-glossary": "bash scripts/check-eval-glossary-fresh.sh",
|
||||
"check:skills-manifest": "bash scripts/check-skills-manifest-fresh.sh",
|
||||
"check:test-names": "bash scripts/check-test-real-names.sh",
|
||||
"check:progress": "bash scripts/check-progress-to-stdout.sh",
|
||||
"check:no-tracked-symlinks": "bash scripts/check-no-tracked-symlinks.sh",
|
||||
"check:exports-count": "bash scripts/check-exports-count.sh",
|
||||
"check:admin-build": "bash scripts/check-admin-build.sh",
|
||||
"check:admin-embedded": "bash scripts/check-admin-embedded.sh",
|
||||
"check:test-isolation": "bash scripts/check-test-isolation.sh",
|
||||
"check:fuzz-purity": "bash scripts/check-fuzz-purity.sh",
|
||||
"check:operations-filter-bypass": "bash scripts/check-operations-filter-bypass.sh",
|
||||
"check:fixture-privacy": "bash scripts/check-fixture-privacy.sh",
|
||||
"check:conversation-parser": "bun src/cli.ts eval conversation-parser test/fixtures/conversation-formats/all.jsonl --no-llm",
|
||||
"check:source-scope-onboard": "scripts/check-source-scope-onboard.sh",
|
||||
"check:source-scope-onboard": "bash scripts/check-source-scope-onboard.sh",
|
||||
"postinstall": "bun run scripts/postinstall.ts",
|
||||
"prepublish:clawhub": "bun run build:all",
|
||||
"publish:clawhub": "clawhub package publish . --family bundle-plugin"
|
||||
@@ -119,7 +123,7 @@
|
||||
"express-rate-limit": "^7.5.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"heic-decode": "^2.1.0",
|
||||
"js-yaml": "^3.15.0",
|
||||
"js-yaml": "^3.15.1",
|
||||
"marked": "^18.0.2",
|
||||
"openai": "^4.0.0",
|
||||
"pgvector": "^0.2.0",
|
||||
@@ -145,17 +149,17 @@
|
||||
"bun": ">=1.3.10"
|
||||
},
|
||||
"license": "MIT",
|
||||
"version": "0.42.66.1",
|
||||
"version": "0.42.76.0",
|
||||
"overrides": {
|
||||
"@hono/node-server": "^2.0.5",
|
||||
"fast-uri": "^3.1.4",
|
||||
"fast-uri": "^3.1.5",
|
||||
"body-parser": "^2.3.0",
|
||||
"fast-xml-builder": "^1.1.7",
|
||||
"fast-xml-parser": "^5.7.0",
|
||||
"form-data": "^4.0.6",
|
||||
"hono": "^4.12.25",
|
||||
"ip-address": "^10.1.1",
|
||||
"hono": "^4.12.34",
|
||||
"ip-address": "^10.3.1",
|
||||
"qs": "^6.15.2",
|
||||
"js-yaml": "^3.15.0"
|
||||
"js-yaml": "^3.15.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: agent-voice
|
||||
name: Voice Personas (Mars + Venus)
|
||||
version: 0.1.0
|
||||
version: 0.1.1
|
||||
description: WebRTC-first voice agent reference (Mars + Venus personas, optional Twilio adapter). Skillpack-as-reference paradigm — the install-time agent COPIES code into your host agent repo where it becomes user-owned and mutable, NOT a runtime gbrain dependency.
|
||||
category: voice
|
||||
install_kind: copy-into-host-repo
|
||||
@@ -132,7 +132,9 @@ Reference code ships intentionally minimal. Before public deployment:
|
||||
|
||||
- **Twilio signature validation** on `/voice` — currently absent; add `X-Twilio-Signature` header validation.
|
||||
- **Rate limiting** on `/session` and `/tool` — currently absent.
|
||||
- **CORS allowlist** — currently `*`; restrict to your deployed origins.
|
||||
- **CORS allowlist** — default-deny out of the box: no `Access-Control-Allow-Origin` header is emitted unless the request's Origin exactly matches `AGENT_VOICE_CORS_ORIGIN` (comma-separated origins, e.g. `AGENT_VOICE_CORS_ORIGIN=https://your.app,https://staging.your.app`). The served `/call` page is same-origin and needs no configuration. `/session` and `/tool` additionally reject cross-origin browser requests (403) unless allowlisted — CORS headers alone can't stop a no-preflight "simple" POST from executing.
|
||||
- **Bind address** — the server listens on `127.0.0.1` by default; set `HOST=0.0.0.0` for containers or direct LAN exposure (and prefer a tunnel for anything public).
|
||||
- **Host-header allowlist** — not shipped; the origin gate derives self-origin from the `Host` header, so DNS rebinding is not covered. Add a `Host` allowlist before exposing beyond loopback.
|
||||
- **Auth on /tool** — voice-side tool calls currently trust the in-process connection; if you expose `/tool` publicly, gate it behind a session token.
|
||||
- **HTTPS** — required for browser mic access in production. Use ngrok / Caddy / Cloudflare Tunnel.
|
||||
- **Twilio fallback URL** — `/fallback` is a TwiML stub; wire to your operator's cell for crash recovery.
|
||||
|
||||
@@ -19,16 +19,26 @@
|
||||
* against `lib/twilio-bridge.mjs` (port-ready stubs included).
|
||||
*
|
||||
* Configuration via env:
|
||||
* PORT default 8765
|
||||
* OPENAI_API_KEY required for /session
|
||||
* OPENAI_REALTIME_MODEL default 'gpt-4o-realtime-preview'
|
||||
* DEFAULT_PERSONA default 'venus' (one of 'mars' | 'venus')
|
||||
* BRAIN_ROOT passed through to context-builder
|
||||
* TIMEZONE passed through to context-builder
|
||||
* PORT default 8765
|
||||
* HOST default '127.0.0.1' (loopback-only; set 0.0.0.0
|
||||
* for containers / direct LAN exposure)
|
||||
* OPENAI_API_KEY required for /session
|
||||
* OPENAI_REALTIME_MODEL default 'gpt-4o-realtime-preview'
|
||||
* DEFAULT_PERSONA default 'venus' (one of 'mars' | 'venus')
|
||||
* AGENT_VOICE_CORS_ORIGIN comma-separated exact origins allowed via CORS
|
||||
* (default unset = default-deny; the served /call
|
||||
* page is same-origin and needs nothing)
|
||||
* BRAIN_ROOT passed through to context-builder
|
||||
* TIMEZONE passed through to context-builder
|
||||
*
|
||||
* Security posture: this is reference code. It does NOT ship hardening for
|
||||
* production deployment (no rate limiting, no Twilio signature validation,
|
||||
* no CORS allowlist). Operators add those at install time per the recipe's
|
||||
* Security posture: CORS is default-deny (exact-origin allowlist via
|
||||
* AGENT_VOICE_CORS_ORIGIN), the side-effectful POSTs (/session, /tool) are
|
||||
* gated on the Origin header (CORS headers gate response READS, not request
|
||||
* SENDS — a cross-origin "simple" POST skips preflight, so without the gate
|
||||
* an attacker page could blind-fire /session and burn the OpenAI key), and
|
||||
* the listener binds loopback by default. Still reference code: rate
|
||||
* limiting, Twilio signature validation, and a Host-header allowlist
|
||||
* (DNS-rebinding hardening) are operator-added per the recipe's
|
||||
* "production checklist."
|
||||
*/
|
||||
|
||||
@@ -43,10 +53,60 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const PUBLIC_DIR = join(__dirname, 'public');
|
||||
|
||||
const PORT = parseInt(process.env.PORT || '8765', 10);
|
||||
// Loopback by default (mirrors `gbrain serve --http --bind 127.0.0.1`).
|
||||
// Tunnels (ngrok / Caddy / Cloudflare) target localhost, so the documented
|
||||
// flows keep working; container/LAN deployments set HOST=0.0.0.0.
|
||||
const HOST = process.env.HOST || '127.0.0.1';
|
||||
const DEFAULT_PERSONA = (process.env.DEFAULT_PERSONA || 'venus').toLowerCase();
|
||||
const OPENAI_REALTIME_MODEL = process.env.OPENAI_REALTIME_MODEL || 'gpt-4o-realtime-preview';
|
||||
const OPENAI_REALTIME_URL = 'https://api.openai.com/v1/realtime/calls';
|
||||
|
||||
// ── CORS + origin gate (default-deny) ─────────────────────────────────
|
||||
// Mirrors the GBRAIN_HTTP_CORS_ORIGIN pattern in gbrain's own HTTP
|
||||
// transport: exact-origin allowlist, no header emitted otherwise, and
|
||||
// Access-Control-Allow-Credentials is never set.
|
||||
function parseCorsAllowlist() {
|
||||
const v = process.env.AGENT_VOICE_CORS_ORIGIN;
|
||||
if (!v) return null;
|
||||
const entries = v.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
return entries.length > 0 ? new Set(entries) : null;
|
||||
}
|
||||
const CORS_ALLOWLIST = parseCorsAllowlist();
|
||||
|
||||
function applyCors(req, res) {
|
||||
const origin = req.headers.origin;
|
||||
if (!(CORS_ALLOWLIST && origin && CORS_ALLOWLIST.has(origin))) return;
|
||||
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||
res.setHeader('Vary', 'Origin');
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||
}
|
||||
}
|
||||
|
||||
// CORS headers gate response READS, not request SENDS: a cross-origin
|
||||
// "simple" POST (e.g. text/plain) skips preflight entirely, executes
|
||||
// server-side, and only the response is withheld from the attacker's JS.
|
||||
// For endpoints with side effects (/session spends OPENAI_API_KEY, /tool
|
||||
// dispatches brain reads) that isn't enough — reject disallowed Origins
|
||||
// BEFORE doing any work. Requests without an Origin header (curl, Twilio
|
||||
// webhooks, native apps) pass; browser requests pass only when same-origin
|
||||
// (Origin host matches the Host header — covers the served /call page,
|
||||
// including through a tunnel) or explicitly allowlisted. Known limit: a
|
||||
// DNS-rebound page's Origin host matches the rebound Host header, so this
|
||||
// does not defend against DNS rebinding (Host-header allowlist is the
|
||||
// production-checklist follow-up).
|
||||
function originAllowed(req) {
|
||||
const origin = req.headers.origin;
|
||||
if (!origin) return true;
|
||||
if (CORS_ALLOWLIST && CORS_ALLOWLIST.has(origin)) return true;
|
||||
try {
|
||||
return new URL(origin).host === req.headers.host;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const MIME = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'application/javascript; charset=utf-8',
|
||||
@@ -223,10 +283,9 @@ function handleVoiceTwiml(req, res) {
|
||||
const server = createServer(async (req, res) => {
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
|
||||
// CORS: allow same-origin only by default. Operators relax in production.
|
||||
res.setHeader('Access-Control-Allow-Origin', req.headers.origin || '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||
// CORS: default-deny. Headers are emitted only for allowlisted origins
|
||||
// (AGENT_VOICE_CORS_ORIGIN); a 204 without CORS headers is browser-blocked.
|
||||
applyCors(req, res);
|
||||
if (req.method === 'OPTIONS') return send(res, 204, '');
|
||||
|
||||
try {
|
||||
@@ -239,11 +298,13 @@ const server = createServer(async (req, res) => {
|
||||
if (url.pathname === '/directory') {
|
||||
return serveStatic(res, 'directory.html');
|
||||
}
|
||||
if (url.pathname === '/session') {
|
||||
return handleSession(req, res);
|
||||
}
|
||||
if (url.pathname === '/tool') {
|
||||
return handleTool(req, res);
|
||||
if (url.pathname === '/session' || url.pathname === '/tool') {
|
||||
// Origin gate BEFORE any body read / upstream fetch / tool dispatch —
|
||||
// blocks blind cross-origin "simple" POSTs that CORS headers can't.
|
||||
if (!originAllowed(req)) {
|
||||
return sendJson(res, 403, { error: 'origin not allowed' });
|
||||
}
|
||||
return url.pathname === '/session' ? handleSession(req, res) : handleTool(req, res);
|
||||
}
|
||||
if (url.pathname === '/voice') {
|
||||
return handleVoiceTwiml(req, res);
|
||||
@@ -266,11 +327,16 @@ const server = createServer(async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
server.listen(PORT, HOST, () => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[agent-voice] listening on http://localhost:${PORT}`);
|
||||
console.log(`[agent-voice] listening on http://${HOST}:${PORT} (bind: ${HOST}${HOST === '127.0.0.1' ? ' — set HOST=0.0.0.0 to expose beyond loopback' : ''})`);
|
||||
console.log(`[agent-voice] default persona: ${DEFAULT_PERSONA}`);
|
||||
console.log(`[agent-voice] read-only tools: ${getEffectiveAllowlist().join(', ')}`);
|
||||
if (!CORS_ALLOWLIST) {
|
||||
console.log('[agent-voice] CORS: default-deny. Set AGENT_VOICE_CORS_ORIGIN=https://your.app to allow cross-origin browser clients.');
|
||||
} else {
|
||||
console.log(`[agent-voice] CORS allowlist: ${[...CORS_ALLOWLIST].join(', ')}`);
|
||||
}
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => server.close(() => process.exit(0)));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"recipe": "agent-voice",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"install_kind": "copy-into-host-repo",
|
||||
"description": "src → target mapping consumed by `gbrain integrations install agent-voice`. The install command reads this manifest, computes SHA-256 of each source file at copy time, writes the per-file hash to <host>/services/voice-agent/.gbrain-source.json so --refresh can do three-way classification (unchanged-identical / unchanged-stale / locally-modified).",
|
||||
"target_root_relative_to_host_repo": "services/voice-agent",
|
||||
|
||||
@@ -15,8 +15,14 @@ OPENAI_API_KEY=sk-... # required (OpenAI Realtime API)
|
||||
DEFAULT_PERSONA=venus # optional (one of: venus, mars)
|
||||
BRAIN_ROOT=/path/to/your/brain # optional (enables live context)
|
||||
TIMEZONE=US/Pacific # optional
|
||||
HOST=0.0.0.0 # optional — binds 127.0.0.1 (loopback) by default; set only to expose beyond localhost
|
||||
AGENT_VOICE_CORS_ORIGIN=https://your.app # optional — CORS is default-deny; list exact origins (comma-separated) only if a browser on another origin needs access
|
||||
```
|
||||
|
||||
The two security env vars ship safe by default: the server listens on loopback
|
||||
only and refuses cross-origin browser requests. The local `/call` flow below
|
||||
needs neither. See the recipe's production checklist before exposing publicly.
|
||||
|
||||
Optional for inbound Twilio:
|
||||
```bash
|
||||
TWILIO_ACCOUNT_SID=AC...
|
||||
@@ -56,7 +62,7 @@ If any prompt-shape test fails, the privacy guard has caught a name you'd want t
|
||||
```bash
|
||||
cd <target-repo>/services/voice-agent
|
||||
bun run start # or `npm start`
|
||||
# → listening on http://localhost:8765
|
||||
# → listening on http://127.0.0.1:8765 (bind: 127.0.0.1 — set HOST=0.0.0.0 to expose beyond loopback)
|
||||
```
|
||||
|
||||
Open `http://localhost:8765/call` in a browser, click Connect, grant mic permission. You should be talking to Venus (or Mars if you set `DEFAULT_PERSONA=mars`).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: calendar-to-brain
|
||||
name: Calendar-to-Brain
|
||||
version: 0.7.0
|
||||
version: 0.8.0
|
||||
description: Google Calendar events become searchable brain pages. Daily files with attendees, locations, and meeting prep context.
|
||||
category: sense
|
||||
requires: [credential-gateway]
|
||||
@@ -28,6 +28,11 @@ health_checks:
|
||||
- type: env_exists
|
||||
name: GOOGLE_CLIENT_ID
|
||||
label: "Google OAuth"
|
||||
- type: heartbeat_max_age
|
||||
max_age: 48h
|
||||
label: "Calendar data freshness"
|
||||
output_paths:
|
||||
- daily/calendar/
|
||||
setup_time: 20 min
|
||||
cost_estimate: "$0 (both options are free)"
|
||||
---
|
||||
|
||||
+15
-7
@@ -1,12 +1,12 @@
|
||||
---
|
||||
id: x-to-brain
|
||||
name: X-to-Brain
|
||||
version: 0.8.2
|
||||
version: 0.8.3
|
||||
description: Twitter timeline, mentions, and keyword monitoring flow into brain pages. Tracks deletions, engagement velocity, OCR on images, and real-time alerts.
|
||||
category: sense
|
||||
requires: []
|
||||
secrets:
|
||||
- name: X_BEARER_TOKEN
|
||||
- name: X_API_BEARER_TOKEN
|
||||
description: X API v2 Bearer token (Basic tier minimum, $200/mo for full archive search)
|
||||
where: https://developer.x.com/en/portal/dashboard — create a project + app, copy the Bearer Token from "Keys and tokens"
|
||||
- name: X_HANDLE
|
||||
@@ -16,7 +16,7 @@ health_checks:
|
||||
- type: http
|
||||
url: "https://api.x.com/2/users/by/username/$X_HANDLE"
|
||||
auth: bearer
|
||||
auth_token: "$X_BEARER_TOKEN"
|
||||
auth_token: "$X_API_BEARER_TOKEN"
|
||||
label: "X API"
|
||||
setup_time: 15 min
|
||||
cost_estimate: "$0-200/mo (Free tier: 1 app, read-only. Basic: $200/mo for search + higher limits)"
|
||||
@@ -118,11 +118,11 @@ Tell the user:
|
||||
Note: Free tier gives read-only access with low limits. Basic tier ($200/mo)
|
||||
gives search/recent endpoint and higher limits. Pro tier gets full archive search."
|
||||
|
||||
Set both `X_BEARER_TOKEN` and `X_HANDLE` in the environment. Validate immediately
|
||||
Set both `X_API_BEARER_TOKEN` and `X_HANDLE` in the environment. Validate immediately
|
||||
(app-only bearer tokens cannot call `/users/me` — that endpoint requires
|
||||
user-context OAuth — so validation uses the by-username lookup):
|
||||
```bash
|
||||
curl -sf -H "Authorization: Bearer $X_BEARER_TOKEN" \
|
||||
curl -sf -H "Authorization: Bearer $X_API_BEARER_TOKEN" \
|
||||
"https://api.x.com/2/users/by/username/$X_HANDLE" \
|
||||
&& echo "PASS: X API connected" \
|
||||
|| echo "FAIL: X API token invalid"
|
||||
@@ -138,7 +138,7 @@ starting with 'AAA...', (3) if you just created the app, the token is valid imme
|
||||
|
||||
```bash
|
||||
# Look up the user's X user ID from their handle
|
||||
curl -sf -H "Authorization: Bearer $X_BEARER_TOKEN" \
|
||||
curl -sf -H "Authorization: Bearer $X_API_BEARER_TOKEN" \
|
||||
"https://api.x.com/2/users/by/username/$X_HANDLE" | grep -o '"id":"[^"]*"'
|
||||
```
|
||||
|
||||
@@ -210,7 +210,7 @@ The agent should review collected data 2-3x daily and run enrichment.
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.gbrain/integrations/x-to-brain
|
||||
echo '{"ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","event":"setup_complete","source_version":"0.8.2","status":"ok","details":{"user_id":"X_USER_ID"}}' >> ~/.gbrain/integrations/x-to-brain/heartbeat.jsonl
|
||||
echo '{"ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","event":"setup_complete","source_version":"0.8.3","status":"ok","details":{"user_id":"X_USER_ID"}}' >> ~/.gbrain/integrations/x-to-brain/heartbeat.jsonl
|
||||
```
|
||||
|
||||
## Production Patterns (v0.8.1)
|
||||
@@ -438,6 +438,14 @@ Free tier works for personal monitoring. Basic tier needed for keyword search.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Upgrading from recipe v0.8.2 or earlier (token shows [missing] after upgrade):**
|
||||
- Older versions of this recipe named the token `X_BEARER_TOKEN`. The canonical
|
||||
name is `X_API_BEARER_TOKEN` — the name the built-in `x_handle_to_tweet`
|
||||
resolver reads. Rename the variable wherever you set it (shell profile, cron
|
||||
environment, `.env`) — same value, new name. A collector installed under the
|
||||
old name keeps running either way; the rename is what makes the integrations
|
||||
dashboard and the resolver see the token.
|
||||
|
||||
**API returns 403:**
|
||||
- Check your app has the right access level (Read or Read+Write)
|
||||
- Free tier apps can only use basic endpoints
|
||||
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
# Print the CHANGELOG.md body for one version (Keep-a-Changelog format),
|
||||
# without its `## [X.Y.Z.W] - date` header line. Used by
|
||||
# .github/workflows/release.yml as the GitHub release notes; tested by
|
||||
# test/release-workflow.test.ts.
|
||||
#
|
||||
# Usage: changelog-entry.sh <version> [changelog-file]
|
||||
# Exits 1 when the version has no entry (caller falls back to a link stub).
|
||||
set -euo pipefail
|
||||
|
||||
ver="${1:?usage: changelog-entry.sh <version> [changelog-file]}"
|
||||
file="${2:-CHANGELOG.md}"
|
||||
|
||||
# Exact-string prefix match on "## [<ver>]" — no regex, so dots in the
|
||||
# version can't glob and a 3-segment version can't match a 4-segment header.
|
||||
awk -v ver="$ver" '
|
||||
index($0, "## [" ver "]") == 1 { found = 1; next }
|
||||
found && /^## \[/ { exit }
|
||||
found { print }
|
||||
END { exit found ? 0 : 1 }
|
||||
' "$file"
|
||||
Executable
+41
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
# CI guard: every `bun test` invocation in workflows and runner scripts must
|
||||
# pass an explicit --timeout.
|
||||
#
|
||||
# Why: bun ignores bunfig.toml's `timeout` key (verified on 1.3.14), so a bare
|
||||
# `bun test` gets the 5000ms default for BOTH tests and beforeAll/beforeEach/
|
||||
# afterAll/afterEach hooks. Hooks do NOT inherit a test's third-arg timeout —
|
||||
# a file whose tests all declare `}, 30_000)` still has a 5s hook budget, and
|
||||
# slow setup (Postgres connect + migrations, PGLite cold start) flakes on
|
||||
# loaded CI runners with the signature `(unnamed) [5001ms] ... hook timed out`
|
||||
# (the #3545 jsonb-parity failure). The CLI --timeout flag is the one measured
|
||||
# mechanism that raises the hook budget uniformly; per-hook second-arg
|
||||
# timeouts work too but don't scale to ~400 slow hooks.
|
||||
#
|
||||
# Usage: scripts/check-bun-test-timeout.sh
|
||||
# Exit: 0 when clean, 1 when a bare `bun test` invocation is found.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
# Match executable `bun test` invocations. Exclude comment lines (#, //, *)
|
||||
# and lines that already carry --timeout anywhere.
|
||||
# Scope: workflows + runner scripts (the surfaces CI executes). package.json
|
||||
# script bodies route through scripts/ already; editing it is out of scope here.
|
||||
violations="$(grep -rnE '\bbun test\b' .github/workflows scripts 2>/dev/null \
|
||||
| grep -v -- '--timeout' \
|
||||
| grep -vE ':[[:space:]]*(#|//|\*)' \
|
||||
| grep -v 'check-bun-test-timeout' \
|
||||
|| true)"
|
||||
|
||||
if [ -n "$violations" ]; then
|
||||
echo "FAIL: bare 'bun test' without --timeout (5s default kills slow setup hooks):" >&2
|
||||
echo "$violations" >&2
|
||||
echo "" >&2
|
||||
echo "Add --timeout=60000 (see scripts/run-unit-shard.sh for the convention)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "OK: every bun test invocation passes an explicit --timeout."
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
# Engine-live paths use static imports by default. A line-level
|
||||
# `engine-dynamic-import-ok` marker is required for a justified lazy import.
|
||||
#
|
||||
# Historical Windows runs associated imports on these paths with abrupt Bun
|
||||
# test-process exits, but system-wide commit exhaustion remained a confound.
|
||||
# This guard therefore enforces a reviewed engine-path hardening invariant; it
|
||||
# does not claim every dynamic import deterministically crashes Windows.
|
||||
#
|
||||
# Usage:
|
||||
# bash scripts/check-engine-dynamic-import.sh
|
||||
# bash scripts/check-engine-dynamic-import.sh FILE [FILE...]
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" || exit 1
|
||||
|
||||
if [ "$#" -gt 0 ]; then
|
||||
FILES=("$@")
|
||||
else
|
||||
ROOT="$(git -C "$SCRIPT_DIR/.." rev-parse --show-toplevel 2>/dev/null || true)"
|
||||
[ -n "$ROOT" ] || ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
cd "$ROOT" || exit 1
|
||||
FILES=(
|
||||
src/core/pglite-engine.ts
|
||||
src/core/postgres-engine.ts
|
||||
src/core/migrate.ts
|
||||
)
|
||||
fi
|
||||
|
||||
exec bun "$SCRIPT_DIR/check-engine-dynamic-import.ts" "${FILES[@]}"
|
||||
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import ts from 'typescript';
|
||||
|
||||
const MARKER = 'engine-dynamic-import-ok';
|
||||
const MARKER_TOKEN_CHAR = /[\p{ID_Continue}$-]/u;
|
||||
const files = process.argv.slice(2);
|
||||
const violations: string[] = [];
|
||||
const readErrors: string[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
let sourceText: string;
|
||||
try {
|
||||
sourceText = await readFile(file, 'utf8');
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
readErrors.push(`ERROR: cannot read input file ${file}: ${detail}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourceFile = ts.createSourceFile(
|
||||
file,
|
||||
sourceText,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
ts.ScriptKind.TS,
|
||||
);
|
||||
const lines = sourceText.split(/\r?\n/);
|
||||
const markerLines = new Set<number>();
|
||||
|
||||
if (sourceFile.parseDiagnostics.length > 0) {
|
||||
const diagnostics = sourceFile.parseDiagnostics
|
||||
.map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, ' '))
|
||||
.join('; ');
|
||||
readErrors.push(`ERROR: cannot parse input file ${file}: ${diagnostics}`);
|
||||
}
|
||||
|
||||
for (let markerPos = sourceText.indexOf(MARKER); markerPos >= 0; markerPos = sourceText.indexOf(MARKER, markerPos + MARKER.length)) {
|
||||
const before = Array.from(sourceText.slice(0, markerPos)).at(-1);
|
||||
const after = Array.from(sourceText.slice(markerPos + MARKER.length))[0];
|
||||
const standaloneMarker = (!before || !MARKER_TOKEN_CHAR.test(before))
|
||||
&& (!after || !MARKER_TOKEN_CHAR.test(after));
|
||||
const token = ts.getTokenAtPosition(sourceFile, markerPos);
|
||||
const insideToken = token.getStart(sourceFile) <= markerPos && markerPos < token.end;
|
||||
if (standaloneMarker && !insideToken) {
|
||||
markerLines.add(sourceFile.getLineAndCharacterOfPosition(markerPos).line);
|
||||
}
|
||||
}
|
||||
|
||||
function visit(node: ts.Node): void {
|
||||
if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) {
|
||||
const { line } = sourceFile.getLineAndCharacterOfPosition(node.expression.getStart(sourceFile));
|
||||
const sourceLine = lines[line] ?? '';
|
||||
if (!markerLines.has(line)) {
|
||||
violations.push(` ${file}:${line + 1}:${sourceLine}`);
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
|
||||
visit(sourceFile);
|
||||
}
|
||||
|
||||
for (const error of readErrors) console.error(error);
|
||||
|
||||
if (violations.length > 0) {
|
||||
console.error('ERROR: unreviewed dynamic import on an engine-live path:');
|
||||
console.error();
|
||||
console.error(violations.join('\n'));
|
||||
console.error();
|
||||
console.error('Prefer a static top-level import. If lazy loading is load-bearing,');
|
||||
console.error("append 'engine-dynamic-import-ok' to that exact line and document");
|
||||
console.error('the startup or soft-failure boundary that requires it.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (readErrors.length > 0) process.exit(1);
|
||||
|
||||
console.log(`check-engine-dynamic-import: ok (${files.length} file(s) scanned)`);
|
||||
Executable
+67
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
# CI guard: fail if any symlink is tracked in git.
|
||||
#
|
||||
# A symlink committed from a build sandbox points at a path that exists on
|
||||
# exactly one machine. Everywhere else the checkout produces a dangling
|
||||
# link, and anything that opens it fails. That is not hypothetical: commit
|
||||
# faf5cdba landed `node_modules -> /tmp/fleet/repo/node_modules`, which made
|
||||
# `bun install` abort with `ENOENT: could not open the "node_modules"
|
||||
# directory` on every fresh clone, and took `gbrain upgrade`'s bun-link path
|
||||
# down with it (the auto-upgrade runs `bun install`, so the printed manual
|
||||
# fallback failed the same way).
|
||||
#
|
||||
# .gitignore alone does not prevent this. A `node_modules/` pattern with a
|
||||
# trailing slash matches directories ONLY, so a symlink of the same name is
|
||||
# never ignored. Dropping the slash closes that hole, but `git add -f` still
|
||||
# walks straight past it. This guard is the backstop.
|
||||
#
|
||||
# The repo has no legitimate tracked symlinks, so the allowlist starts
|
||||
# empty. If you ever need one, add its exact repo-relative path to ALLOWLIST
|
||||
# below and explain why — a relative link that resolves inside the repo is
|
||||
# defensible; an absolute one almost never is.
|
||||
#
|
||||
# Usage: scripts/check-no-tracked-symlinks.sh
|
||||
# Exit: 0 when clean, 1 when a tracked symlink is found.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
# Paths permitted to be tracked symlinks. Empty by design.
|
||||
ALLOWLIST=()
|
||||
|
||||
# Git records symlinks with mode 120000. Field 4 of `ls-files -s` is the path
|
||||
# (tab-separated from the stage number), so cut on the tab to keep paths with
|
||||
# spaces intact.
|
||||
found="$(git ls-files -s | awk '$1 == "120000"' | cut -f2- || true)"
|
||||
|
||||
if [ -n "$found" ]; then
|
||||
filtered="$found"
|
||||
for f in "${ALLOWLIST[@]:-}"; do
|
||||
[ -z "$f" ] && continue
|
||||
filtered="$(echo "$filtered" | grep -vxF "$f" || true)"
|
||||
done
|
||||
|
||||
if [ -n "$filtered" ]; then
|
||||
echo "ERROR: symlink(s) tracked in git:"
|
||||
echo
|
||||
while IFS= read -r path; do
|
||||
[ -z "$path" ] && continue
|
||||
target="$(git cat-file blob ":$path" 2>/dev/null || echo '<unreadable>')"
|
||||
echo " $path -> $target"
|
||||
done <<< "$filtered"
|
||||
echo
|
||||
echo "A committed symlink resolves on the machine that created it and"
|
||||
echo "nowhere else. Untrack it:"
|
||||
echo
|
||||
echo " git rm --cached <path>"
|
||||
echo
|
||||
echo "If the path is build output (node_modules, dist, bin), also confirm"
|
||||
echo "it is covered by .gitignore WITHOUT a trailing slash — a trailing"
|
||||
echo "slash matches directories only and lets the symlink through."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "check-no-tracked-symlinks: OK (no tracked symlinks)"
|
||||
Executable
+41
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
# CI guard for skills/skills.lock.json freshness (#159).
|
||||
#
|
||||
# Mirrors scripts/check-eval-glossary-fresh.sh: regenerate the manifest into
|
||||
# a tmp file, diff against the committed version, fail the build if they
|
||||
# drift. Tamper-evidence, not a signature system — the point is that any
|
||||
# change under skills/ ships with an explicit manifest diff.
|
||||
#
|
||||
# Run: bash scripts/check-skills-manifest-fresh.sh
|
||||
# Wired through `bun run verify` (scripts/run-verify-parallel.sh) so PRs that
|
||||
# edit skills/ without regenerating the manifest are caught before review.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
COMMITTED="$REPO_ROOT/skills/skills.lock.json"
|
||||
TMP="$(mktemp)"
|
||||
trap 'rm -f "$TMP"' EXIT
|
||||
|
||||
if [ ! -f "$COMMITTED" ]; then
|
||||
echo "ERROR: $COMMITTED not found." >&2
|
||||
echo "Run: bun run scripts/generate-skills-manifest.ts" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
# Render directly via bun + a one-liner that exposes the module function.
|
||||
bun -e "import { renderSkillsManifest } from './src/core/skills-integrity.ts'; process.stdout.write(renderSkillsManifest('skills'));" > "$TMP"
|
||||
|
||||
if ! diff -q "$COMMITTED" "$TMP" >/dev/null 2>&1; then
|
||||
echo "ERROR: skills/skills.lock.json is stale." >&2
|
||||
echo "" >&2
|
||||
echo "Diff between committed and freshly-generated:" >&2
|
||||
echo "" >&2
|
||||
diff -u "$COMMITTED" "$TMP" >&2 || true
|
||||
echo "" >&2
|
||||
echo "To regenerate: bun run scripts/generate-skills-manifest.ts" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✓ skills/skills.lock.json is fresh"
|
||||
+11
-1
@@ -46,7 +46,15 @@ export const E2E_TEST_MAP: Record<string, string[]> = {
|
||||
"test/e2e/multi-source-bug-class.test.ts",
|
||||
"test/e2e/synthesize-bigint-job-id-postgres.test.ts",
|
||||
],
|
||||
"src/commands/embed.ts": ["test/e2e/multi-source-bug-class.test.ts"],
|
||||
"src/commands/embed.ts": [
|
||||
"test/e2e/multi-source-bug-class.test.ts",
|
||||
// #3391: the NULL-signature stale predicates differ per engine.
|
||||
"test/e2e/migrate-embeddings-postgres.test.ts",
|
||||
],
|
||||
// #3390: runSchemaTransition's DDL path + the stale predicates behave
|
||||
// differently on real pgvector than on PGLite.
|
||||
"src/core/embedding-migration.ts": ["test/e2e/migrate-embeddings-postgres.test.ts"],
|
||||
"src/core/retrieval-upgrade-planner.ts": ["test/e2e/migrate-embeddings-postgres.test.ts"],
|
||||
"src/commands/extract.ts": ["test/e2e/multi-source-bug-class.test.ts"],
|
||||
"src/commands/migrate-engine.ts": ["test/e2e/multi-source-bug-class.test.ts"],
|
||||
// Any minions queue/worker/handler change exercises all minion E2E.
|
||||
@@ -64,6 +72,8 @@ export const E2E_TEST_MAP: Record<string, string[]> = {
|
||||
"test/e2e/jsonb-roundtrip.test.ts",
|
||||
"test/e2e/engine-parity.test.ts",
|
||||
"test/e2e/schema-drift.test.ts",
|
||||
// #3391: includeNullSignature stale predicates (engine parity).
|
||||
"test/e2e/migrate-embeddings-postgres.test.ts",
|
||||
],
|
||||
// PGLite bootstrap path + parity guard.
|
||||
"src/core/pglite-engine.ts": [
|
||||
|
||||
@@ -0,0 +1,981 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Import an envelope-v0 file (a JSON serialization of AI chat history; format
|
||||
* spec: github.com/memvelope/memvelope) into a brain repo as one Markdown page
|
||||
* per conversation, which `gbrain sync` ingests.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/envelope-to-gbrain.mjs <envelope.mve.json> [outDir]
|
||||
*
|
||||
* Zero dependencies. Deterministic. No network. It does NOT call gbrain — it
|
||||
* only writes Markdown files.
|
||||
*
|
||||
* All-or-nothing. Both integrity checks below run BEFORE the first write, so a
|
||||
* refused import leaves no partial output behind to be mistaken for a whole one.
|
||||
*
|
||||
* 1. Declared counts. envelope-v0 requires `meta.conversation_count` and
|
||||
* `meta.message_count`: the envelope states its own totals. Each is judged
|
||||
* on its own. One that disagrees with what the file actually contains, or
|
||||
* that is present but is not a non-negative integer, refuses the import
|
||||
* (exit 2) — a mismatch means the envelope is truncated, hand-edited, or
|
||||
* from a broken converter, and nothing here can tell which part is
|
||||
* missing. A count that is simply absent cannot be checked against
|
||||
* anything; the envelope imports, and stderr names the field whose half of
|
||||
* the check was skipped.
|
||||
* 2. Existing target files. A file already occupying a target filename is
|
||||
* only overwritten when it is safe: byte-identical content (a re-import),
|
||||
* or a page this importer wrote from the SAME conversation id (a refreshed
|
||||
* export legitimately updating its own page). Anything else — a foreign
|
||||
* file, or one of our pages whose conversation id cannot be matched — is a
|
||||
* conflict, and the import is refused (exit 2).
|
||||
*
|
||||
* Output layout:
|
||||
* - One page per conversation, filename = date + conversation id, so shared
|
||||
* titles cannot collide. The filename is that PAIR: a duplicated id whose
|
||||
* two copies carry different `created_at` DATES lands on two files and
|
||||
* nothing collides. A DUPLICATE ID IS NOT THE ONLY WAY TO REACH ONE
|
||||
* FILENAME, though: both halves are slugged — lowercased, every
|
||||
* non-alphanumeric run collapsed to a single `-`, leading and trailing `-`
|
||||
* stripped, and only THEN truncated at 60 characters — so two DISTINCT ids
|
||||
* can map to one name whenever their SLUGS agree on the first 60
|
||||
* characters. Say it on the slug and not on the id, because the raw ids
|
||||
* predict nothing in either direction: `AbC-123` and `abc-123` differ at
|
||||
* character 1 and collide, `x_y` and `x-y` differ at character 2 and
|
||||
* collide, while sixty `-` followed by `a` and sixty `-` followed by `b`
|
||||
* agree on all of their first 60 characters and do NOT collide (the strip
|
||||
* leaves `a` and `b`). This is the same class check 2 already names below —
|
||||
* "truncation at 60 chars, or characters that slug away" — except that
|
||||
* check 2 REFUSES it at exit 2 while the tiebreak below resolves it,
|
||||
* discarding one of two UNRELATED conversations while stderr calls the id
|
||||
* "not unique" and asks for a deduplication that cannot be performed. Real
|
||||
* ChatGPT and Claude exports carry lowercase UUIDs, which `slug` passes
|
||||
* through unchanged, so this is hand-authored-envelope territory rather
|
||||
* than producer output — but that is an observation about vendor data, not
|
||||
* a guarantee: the converter copies `raw.uuid` / `raw.conversation_id`
|
||||
* verbatim and validates nothing, and nothing here distinguishes the two
|
||||
* while resolving a collision.
|
||||
* When two conversations do map to one filename, the copy with the later
|
||||
* `updated_at` is kept and the other is discarded. That rule needs an
|
||||
* orderable `updated_at` on BOTH copies naming two different INSTANTS;
|
||||
* equal instants — which includes two different STRINGS that name one
|
||||
* instant, such as `09:00:00Z` and `14:30:00+05:30` — or a value that is
|
||||
* missing or unreadable on EITHER side, fall back to array order — the
|
||||
* later copy in `conversations[]` wins, as it always did. Either way
|
||||
* stderr names both values and which copy went, and stdout reports
|
||||
* DISTINCT files written, not write calls.
|
||||
* - `id` is `string | null` in envelope-v0 and a converter must not synthesize
|
||||
* one, so null is a conforming shape, not malformed input. Such a
|
||||
* conversation falls back to a POSITIONAL filename (`conv-N`) — a function
|
||||
* of array position, not of identity. That is precisely why check 2 refuses
|
||||
* to overwrite an id-less page: two unrelated exports both put their first
|
||||
* conversation at `conv-1`, and nothing in either file can distinguish
|
||||
* "this conversation, updated" from "a different conversation entirely".
|
||||
* BE PLAIN THAT THE TWO CHECKS DISAGREE HERE: within ONE envelope that name
|
||||
* is not refused but resolved — a positional `conv-1` and any real id that
|
||||
* SLUGS to `conv-1` share a filename, the `updated_at` tiebreak above picks
|
||||
* between them, and stderr reports a duplicate id where one of the two
|
||||
* conversations has no id at all (with the id-less copy second, it prints
|
||||
* `conversation id null is not unique`). It is the same conflation check 2
|
||||
* exists to forbid — two unrelated conversations resolved against one
|
||||
* positional name — though here it is loud and evidence-bearing, with
|
||||
* `updated_at` present on both copies and three stderr lines, rather than
|
||||
* the evidence-free overwrite check 2 refuses at exit 2. Note also that
|
||||
* `id: null` is not the only way INTO the positional namespace: `slug`
|
||||
* falls back to `conv-N` for any id that slugs to empty (`"___"`), and such
|
||||
* a page records that non-null id in frontmatter, so check 2 sees it as an
|
||||
* identity mismatch rather than as an id-less page. None of these are
|
||||
* producer-reachable — a vendor id of `conv-1` or `___` is not — but
|
||||
* `id: null` is.
|
||||
* - Frontmatter: `type: conversation` (keeps pages eligible for
|
||||
* conversation-facts extraction and chronicle behavior after sync), the
|
||||
* source provider, the conversation id, `origin: memvelope/envelope-v0`,
|
||||
* and the `messages:` array described below.
|
||||
* - Page `date` is the first 10 chars of the conversation's ISO-8601
|
||||
* `created_at`.
|
||||
*
|
||||
* THE BODY IS WRITTEN FOR GBRAIN'S OWN CONVERSATION PARSER.
|
||||
*
|
||||
* Every page here declares `type: conversation`, which is what opens the gate to
|
||||
* conversation-facts extraction, chronicle eligibility, and the
|
||||
* conversation_format_coverage check.
|
||||
*
|
||||
* ELIGIBLE IS NOT AUTOMATIC, and the difference is the whole reason to say this
|
||||
* out loud. The path that accepts these pages is `gbrain
|
||||
* extract-conversation-facts` (src/commands/extract-conversation-facts.ts) — a
|
||||
* command somebody starts, whether by hand, as a background job, or through a
|
||||
* `doctor` remediation. Its autopilot wrapper is the
|
||||
* `conversation_facts_backfill` cycle phase, and that phase is opt-in and OFF
|
||||
* by default (`cycle.conversation_facts_backfill.enabled`, default false —
|
||||
* src/core/cycle/conversation-facts-backfill.ts). A plain `gbrain sync` does
|
||||
* not start either one.
|
||||
*
|
||||
* What the type buys is ADMISSION to that command, not a trigger for it, and it
|
||||
* is one admission among several: `ALLOWED_TYPES` there is `conversation`,
|
||||
* `meeting`, `slack`, `email`, `imessage`, `imessage-daily`, and the command
|
||||
* defaults to the whole list. A page typed outside that set is ineligible
|
||||
* rather than merely un-run — which is the reason to declare `conversation`
|
||||
* here — but `conversation` is not privileged within it.
|
||||
*
|
||||
* Sync's own generic facts backstop is a SEPARATE gate and it does not accept
|
||||
* these pages on the type at all: `conversation` is absent from `ELIGIBLE_TYPES`
|
||||
* in src/core/facts/eligibility.ts. It has a slug escape hatch ORed with the
|
||||
* type test — `RESCUE_SLUG_PREFIXES = ['meetings/', 'personal/', 'daily/']` —
|
||||
* so a page written into an outDir that syncs under one of those prefixes IS
|
||||
* picked up by a plain sync, provided its body clears the 80-character
|
||||
* `MIN_BODY_CHARS` floor. The default outDir (`./brain/conversations`) is not
|
||||
* one of them, so on the default path nothing extracts facts from these pages
|
||||
* until the command above runs.
|
||||
*
|
||||
* Until 2026-08-02 the body then presented a
|
||||
* turn header — `**Assistant** (2025-11-02T14:22:51.000Z · m2):` — matching NONE
|
||||
* of the 17 built-in patterns in `src/core/conversation-parser/builtins.ts`
|
||||
* (`gbrain conversation-parser list-builtins` counts them). The
|
||||
* extractor parsed zero messages, incremented `pages_skipped`, and said nothing:
|
||||
* pages stored and searchable, no facts ever extracted from any of them.
|
||||
*
|
||||
* The header is now the one shape that parser reads:
|
||||
*
|
||||
* **Me** (2025-11-02 14:22):
|
||||
*
|
||||
* message text, on the following lines
|
||||
*
|
||||
* matching the `imessage-slack` built-in. That pattern's regex accepts
|
||||
* `YYYY-MM-DD` plus `H:MM` and an OPTIONAL AM/PM — a full RFC 3339 timestamp
|
||||
* does not match it (the `T` alone is enough to miss), and neither does anything
|
||||
* appended after the time. So the header can carry a wall clock and nothing
|
||||
* else, and per-message identity has to live in frontmatter:
|
||||
*
|
||||
* messages:
|
||||
* - id: "m1"
|
||||
* ts: "2025-11-02T14:22:51.000Z"
|
||||
* - id: "m2"
|
||||
* ts: "2025-11-02T14:24:03.000Z"
|
||||
*
|
||||
* TO READ IDENTITY BACK, a consumer parses the page's YAML frontmatter and
|
||||
* indexes `messages` BY POSITION: `messages[i]` is the i-th turn of the body, in
|
||||
* body order. There is no id in the body to join on. Both fields are copied from
|
||||
* the envelope verbatim — `id` is the message id, `ts` the original RFC 3339
|
||||
* timestamp (or `null`, which envelope-v0 permits). The body header is derived
|
||||
* FROM `ts` and is lossier than it by construction: minute resolution, UTC, and
|
||||
* a fallback whenever `ts` is null OR is a string this script will not read a
|
||||
* clock out of — a date with no time, a basic-format `20251102T142251Z`, an
|
||||
* impossible `2025-02-30`, anything non-string. `ts` is the record; the header
|
||||
* is the anchor, and only the record is lossless.
|
||||
*
|
||||
* Worth being plain about how much the array rescues: for a CONFORMING envelope
|
||||
* `id` is positional by spec (`m1`, `m2`, … restarting per conversation), so it
|
||||
* is derivable from the index and carries no information the position does not.
|
||||
* `ts` is the genuinely new value here. The `id` is recorded anyway because the
|
||||
* spec is what makes it derivable, and a non-conforming or future producer is
|
||||
* not bound by it.
|
||||
*
|
||||
* Every value TAKEN FROM THE ENVELOPE is JSON-encoded, so every timestamp
|
||||
* envelope-v0 can carry — `string | null` — is QUOTED or the bare `null`. (The
|
||||
* handful of fixed keys this script writes itself — `type: conversation`,
|
||||
* `origin:`, an absent `date: null`, an empty `messages: []` — are literals
|
||||
* under its own control, not envelope data.) Unquoted, an RFC 3339
|
||||
* scalar is read by js-yaml as a JS `Date`: microseconds truncate, a `+05:30`
|
||||
* offset is normalised away, the lexical form changes — and gbrain's own
|
||||
* `coerceFrontmatterString` (src/core/markdown.ts) slices a Date to its first
|
||||
* 10 characters, losing the time of day entirely. It is sticky, too: a Date
|
||||
* re-serializes unquoted and stays a Date on every later round trip.
|
||||
* `test/envelope-to-gbrain.test.ts` fails if a timestamp is ever emitted
|
||||
* unquoted, and carries a sentinel proving that guard fires.
|
||||
*
|
||||
* The precise claim, because "everything is quoted" would be false: JSON
|
||||
* quotes STRINGS. A non-conforming envelope whose `ts` is a number emits
|
||||
* `ts: 1762093371000` — unquoted, and a YAML integer rather than a Date, so it
|
||||
* is lossless and carries no `Date` hazard, but it is not a quoted scalar
|
||||
* either. Same for a non-string `id`, and a missing `id` (the schema requires
|
||||
* one) emits `id: null`, which is indistinguishable from a legitimate
|
||||
* `ts: null`. Coercing non-conforming types is deliberately not attempted here;
|
||||
* the behavior is pinned by test so it cannot drift unnoticed.
|
||||
*
|
||||
* The array survives a gbrain rewrite SEMANTICALLY, not textually.
|
||||
* `serializeMarkdown` re-emits `id: "m1"` as `id: m1` and `ts: "…"` as
|
||||
* `ts: '…'` — values and order identical, quoting style not. Anything that
|
||||
* reads this page by parsing YAML is fine; anything that reads it by scanning
|
||||
* lines must not assume double quotes.
|
||||
*
|
||||
* 24-hour, not 12-hour-with-AM/PM. Both match `imessage-slack`, and both were
|
||||
* measured to reconstruct all 24 hours exactly, so the tie is broken elsewhere:
|
||||
* 24-hour is a substring of the envelope's own `ts` (no hour arithmetic, so the
|
||||
* 12/0 boundary cannot be got wrong), it sorts chronologically within a day
|
||||
* where 12-hour does not, and it needs no AM/PM marker to disambiguate. The
|
||||
* pattern's `time_format: '12h_ampm'` declaration is not a constraint here.
|
||||
* Outside builtins.ts it is read in exactly one place — `list-builtins` prints
|
||||
* it (src/commands/conversation-parser.ts) — and never by the parser: parse.ts
|
||||
* converts off the CAPTURED AM/PM group, which is optional and absent for a
|
||||
* 24-hour clock, so `to24h(hour, undefined)` returns the hour unchanged.
|
||||
*
|
||||
* The stdout receipt reports MESSAGES as well as pages. Counting only pages hid
|
||||
* every message-level loss by construction: a conversation that arrives with
|
||||
* one turn instead of forty still writes exactly one page.
|
||||
*
|
||||
* Exit codes: 0 success · 1 usage or unrecognized format · 2 refused import
|
||||
* (declared-count mismatch, or a target file that must not be overwritten).
|
||||
*
|
||||
* Known limits:
|
||||
* - Check 2 is check-then-write, not atomic. Two imports running
|
||||
* SIMULTANEOUSLY into one directory can both pass the check before either
|
||||
* writes, and one then clobbers the other. Measured 2026-08-02 over three
|
||||
* independent sets of 40 trials of two concurrent conflicting imports: 19,
|
||||
* 22, and 24 refused out of 40 — roughly half, and it is a race, so expect
|
||||
* the number to move. Against the previous script the same experiment
|
||||
* refused 0 of 40. Closing it needs a lock file, which is a larger change
|
||||
* than this guard. Sequential runs are what this CLI is for, and are what
|
||||
* check 2 covers.
|
||||
* - An id-less conversation cannot be REFRESHED in place. A changed re-import
|
||||
* of an `id: null` export is refused rather than applied, because nothing
|
||||
* in either file distinguishes it from a different conversation at the same
|
||||
* array position. Import it into a fresh directory. This is a deliberate
|
||||
* trade: the same ambiguity, resolved the other way, is what silently
|
||||
* destroyed the earlier import.
|
||||
* - Identity is matched on the conversation id alone, while the filename is
|
||||
* date + id. A conversation whose `created_at` changes between exports
|
||||
* therefore lands on a NEW filename and orphans its earlier page rather
|
||||
* than updating it — duplication, not loss, and true of this script before
|
||||
* these guards existed too.
|
||||
* - THE `updated_at` TIEBREAK IS WITHIN ONE ENVELOPE. It decides which of two
|
||||
* copies in the SAME file survives, and it has no effect across runs: check
|
||||
* 2 treats any existing page carrying this conversation's id as this
|
||||
* export's own page to refresh, so importing an OLDER export after a newer
|
||||
* one replaces the newer page at exit 0 with no warning at all. The
|
||||
* identical stale/fresh pair is decided one way inside an envelope and the
|
||||
* other way across two of them. Both rules are deliberate — the cross-run
|
||||
* one is what makes a re-import able to update its own page — but the
|
||||
* asymmetry is real and the cross-run direction is the silent one.
|
||||
* - A file carrying this importer's own frontmatter shape is treated as this
|
||||
* importer's page. There is no signature, so a hand-written lookalike is
|
||||
* indistinguishable from the real thing.
|
||||
* - A PARSER-LEGIBLE PAGE IS NOT THE SAME AS AN EXTRACTED ONE. parse.ts
|
||||
* accepts a page only when at least 5% of its non-blank lines anchor a turn
|
||||
* (SCORING_MIN_ACCEPTANCE), so a conversation of very long turns still
|
||||
* lands on `no_match` and still extracts nothing. Measured 2026-08-02 on
|
||||
* envelopes built by the reference converter, two turns per side: 25
|
||||
* paragraphs per assistant turn parses (density 0.070, 4 of 4 messages), 40
|
||||
* paragraphs does not (0.046, 0 messages). The exact crossover, measured
|
||||
* line by line: 18 non-blank lines per turn parses (0.0526), 19 does not
|
||||
* (0.0499) — the H1 counts in the denominator too. So turns averaging more
|
||||
* than ~18 non-blank lines of prose fall below the floor. That threshold
|
||||
* lives in gbrain's parser, not here — this script cannot raise it, and
|
||||
* long-form assistant answers sit close to it.
|
||||
* - ★ A PASTED TRANSCRIPT CAN REPLACE THE WHOLE CONVERSATION, and nothing
|
||||
* reports it. This is the sharpest limit here and it is not fixable from
|
||||
* this script.
|
||||
*
|
||||
* A message whose own text contains lines shaped like SOME OTHER export
|
||||
* format — anyone who has pasted a Slack, Discord, Telegram or IRC snippet
|
||||
* into a chat — puts those lines in the body too. parse.ts picks ONE
|
||||
* pattern per page, scored on the first 10 body lines
|
||||
* (SCORING_HEAD_LINES), and only re-scores against the full body when that
|
||||
* head score falls under 0.3. So the pasted block only has to win the head
|
||||
* window; the length of the real conversation is irrelevant. Measured, with
|
||||
* four `**[09:0N] Colleague N:**` lines quoted inside message 1:
|
||||
*
|
||||
* real turns pasted lines winner frontmatter / body turns
|
||||
* 2 2 imessage-slack 2 / 2
|
||||
* 2 3 telegram-bracket 2 / 3
|
||||
* 4 4 telegram-bracket 4 / 4 <- counts AGREE
|
||||
* 40 4 telegram-bracket 40 / 4
|
||||
*
|
||||
* In the last row all forty real turns are gone and four fabricated
|
||||
* speakers at fabricated times reach the fact extractor in their place —
|
||||
* at exit 0, with `phase: regex_match`, so `pages_skipped` stays 0 and
|
||||
* `gbrain doctor` reports `conversation_format_coverage` OK.
|
||||
*
|
||||
* Comparing `frontmatter.messages.length` against the parsed turn count
|
||||
* catches three of those four rows and NOT the 4/4 one, where the counts
|
||||
* agree while every speaker and timestamp is fabricated. Count is a
|
||||
* smoke alarm, not a proof. Closing this needs a change in parse.ts —
|
||||
* fenced-code awareness, or per-pattern scoring that does not let a
|
||||
* ten-line window speak for the page.
|
||||
* - Neither does the parser respect fenced code blocks: a turn header inside
|
||||
* ``` ``` ``` still anchors a turn. `inferTitleFromBody` in markdown.ts
|
||||
* tracks fences; parse.ts does not.
|
||||
* - `messages[i]` is positional. The body carries no id to join on, so an
|
||||
* edit that inserts or removes a turn in the body without editing the
|
||||
* frontmatter silently re-points every id after it — and so does any of the
|
||||
* parser behavior above.
|
||||
* - THE DATE IS THE UTC CALENDAR DAY, NOT THE USER'S. The page `date` and
|
||||
* every turn header are read off a timestamp already normalised to UTC, so
|
||||
* a conversation held in the evening west of Greenwich files on the
|
||||
* FOLLOWING day, and one held in the early morning east of it files on the
|
||||
* PREVIOUS day. Measured here, importing under TZ=America/Los_Angeles a
|
||||
* conversation that happened at 19:30 on Sunday 2 November 2025 in
|
||||
* California:
|
||||
*
|
||||
* created_at "2025-11-03T03:30:00.000Z"
|
||||
* -> date: "2025-11-03" (a Monday)
|
||||
* -> **Me** (2025-11-03 03:30):
|
||||
*
|
||||
* Anything that groups, windows or reports these pages by day inherits that
|
||||
* shift. The importing machine's own zone changes nothing — the output
|
||||
* above is identical under every TZ, deliberately.
|
||||
*
|
||||
* NOT FIXABLE HERE, and the reason is not neglect: the offset is not in the
|
||||
* file this script reads. envelope-v0 renders every timestamp as
|
||||
* `YYYY-MM-DDTHH:mm:ss.sssZ` — "always UTC, always the `Z` designator"
|
||||
* (SPEC.md rule 3) — so an offset a source export DID carry is normalised
|
||||
* away before the envelope reaches this script. Where a source carries no
|
||||
* designator at all, that same rule explains why it is read as UTC rather
|
||||
* than guessed: "The source's true offset is unknowable, and UTC is the
|
||||
* only machine-independent choice."
|
||||
*
|
||||
* Recovering the user's own day would therefore take a FORMAT change — a
|
||||
* conversation-level offset envelope-v0 does not have — and on the evidence
|
||||
* available there would usually be nothing to put in it. Across the
|
||||
* reference converter's own input corpus, 13 conversations carry 26
|
||||
* conversation-level timestamps: 20 are bare unix-epoch numbers (ChatGPT
|
||||
* `create_time`/`update_time`, which cannot express a zone at all), 4 end
|
||||
* in `Z`, 2 carry no designator, and NONE carries a numeric offset. What is
|
||||
* NOT available is guessing from the IMPORTING machine's zone: that would
|
||||
* make one envelope produce different pages on two laptops, which is the
|
||||
* determinism this script is built on.
|
||||
* - MINUTE RESOLUTION IS THE CEILING, and it is the parser's, not this
|
||||
* format's. Every branch of `buildIso` in parse.ts hardcodes `:00` seconds,
|
||||
* and no built-in pattern captures a seconds group — `signal-export`
|
||||
* matches seconds in its regex and still discards them. So two turns in the
|
||||
* same minute come back with identical timestamps: `claude-basic` m1
|
||||
* (15:02:00) and m2 (15:02:31) both parse to `2026-06-14T15:02:00Z`.
|
||||
* Anything downstream that orders or windows on the PARSED timestamp sees a
|
||||
* tie. The frontmatter `ts` keeps full resolution and is the only place on
|
||||
* the page that has it.
|
||||
*
|
||||
* Memory: the whole envelope is held in memory (no streaming); envelopes are
|
||||
* far smaller than the vendor exports they serialize.
|
||||
*
|
||||
* Verify:
|
||||
* node scripts/envelope-to-gbrain.mjs test/fixtures/memvelope/sample.mve.json /tmp/out
|
||||
* -> expect "wrote 1 markdown page(s) (4 message(s))"
|
||||
* bun test test/envelope-to-gbrain.test.ts
|
||||
*
|
||||
* STATUS:
|
||||
* - 2026-07-03, pre-guard behavior, live-verified against gbrain v0.42.56.0:
|
||||
* the sample fixture -> 1 page; a real 662MB Claude export -> 353
|
||||
* conversations = 353 distinct pages (no collisions), searchable after sync
|
||||
* with provenance and message-id citations intact.
|
||||
* - 2026-08-02, the two guards above: verified against all 12 golden fixtures
|
||||
* from the memvelope reference converter and against fresh envelopes
|
||||
* produced by running that converter over synthetic ChatGPT and Claude
|
||||
* exports. All 19 import at exit 0 with zero stderr bytes, and 18 of them
|
||||
* reproduce every message text byte-verbatim. The exception is the
|
||||
* lone-surrogate golden fixture, where an unpaired `U+D800` becomes
|
||||
* `U+FFFD` on UTF-8 write — behavior of `writeFileSync`, unchanged by these
|
||||
* guards and identical on the previous script. Neither guard has been run
|
||||
* against a full-size real export.
|
||||
* - 2026-08-02, the parser-legible format above. Measured on two throwaway
|
||||
* HOME-redirected PGLite brains fed the SAME 13 conversations, one written
|
||||
* the old way and one the new:
|
||||
* `gbrain conversation-parser scan <slug>`, run once per page (it takes
|
||||
* one slug; there is no aggregate form):
|
||||
* 13/13 pages `no_match`, 0 messages
|
||||
* -> 13/13 `imessage-slack`, 33 messages
|
||||
* `gbrain extract-conversation-facts --dry-run`
|
||||
* "Skipped 13 page(s)" (pages_skipped)
|
||||
* -> 0 skipped; every page segments and
|
||||
* reaches the extractor
|
||||
* `gbrain doctor` conversation_format_coverage
|
||||
* warn: "13/13 ... match NO built-in
|
||||
* pattern"
|
||||
* -> ok: "13 pages: imessage-slack=13"
|
||||
* Over all 12 golden fixtures from the reference converter: exit 0, zero
|
||||
* stderr bytes, 33/33 messages parsed, and every `id`/`ts` recovered from
|
||||
* frontmatter byte-identical to the envelope. Every message text is on disk
|
||||
* verbatim except the lone-surrogate fixture noted above; the parser's own
|
||||
* output additionally collapses blank lines WITHIN a message, so a
|
||||
* multi-paragraph turn comes back joined by single newlines.
|
||||
* Not verified: any full-size real export, and any brain with a chat model
|
||||
* configured — the extractor was reached but its LLM call could not run.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const EXIT_REFUSED = 2;
|
||||
|
||||
const [, , envelopePath, outDir = './brain/conversations'] = process.argv;
|
||||
if (!envelopePath) {
|
||||
console.error('usage: node envelope-to-gbrain.mjs <envelope.mve.json> [outDir]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const env = JSON.parse(readFileSync(envelopePath, 'utf8'));
|
||||
if (env.memvelope !== 'envelope-v0') {
|
||||
console.error(`not an envelope-v0 file (memvelope field = ${JSON.stringify(env.memvelope)})`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const slug = (s, fallback) =>
|
||||
(String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || fallback).slice(0, 60);
|
||||
|
||||
/** A frontmatter value, emitted as JSON.
|
||||
*
|
||||
* JSON is valid YAML flow syntax, so this is total for any JSON-serializable
|
||||
* value — and, for the thing that matters here, a string always comes out
|
||||
* QUOTED. An unquoted RFC 3339 scalar is read back as a JS `Date`, which is
|
||||
* lossy (microseconds truncated, offset normalised away) and sticky (it
|
||||
* re-serializes unquoted, so it stays a Date on every later round trip). */
|
||||
const yamlJson = (v) => JSON.stringify(v === undefined ? null : v);
|
||||
|
||||
/** The date a turn header is allowed to carry: exactly `YYYY-MM-DD`. */
|
||||
const HEADER_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
/** What `deriveDateContext()` in gbrain's conversation parser falls back to when
|
||||
* a page carries no date at all. Reusing it means a dateless conversation's
|
||||
* headers introduce no value gbrain would not have chosen for itself. */
|
||||
const EPOCH_DATE = '1970-01-01';
|
||||
|
||||
/** The RFC 3339 shapes this script will read a wall clock out of.
|
||||
*
|
||||
* Deliberately NOT `new Date(string)`: for a date-time with no zone
|
||||
* designator, ECMAScript parses local time, so the same envelope would import
|
||||
* differently on two machines and this script claims to be deterministic.
|
||||
* Groups: 1=Y 2=M 3=D 4=hh 5=mm, then an optional offset 6=sign 7=hh 8=mm. */
|
||||
const TS_SHAPE =
|
||||
/^(\d{4})-(\d{2})-(\d{2})[Tt ](\d{2}):(\d{2})(?::\d{2}(?:\.\d+)?)?(?:[Zz]|([+-])(\d{2}):?(\d{2}))?$/;
|
||||
|
||||
/**
|
||||
* The `YYYY-MM-DD HH:MM` a turn header carries, or null when the message's `ts`
|
||||
* cannot supply one.
|
||||
*
|
||||
* 24-hour, and UTC. `imessage-slack` — the pattern these headers are written
|
||||
* for — declares `timezone_policy: 'inline_utc'`, i.e. gbrain reads the inline
|
||||
* clock AS UTC. So a `+05:30` timestamp must be shifted before it is written;
|
||||
* emitting the local wall clock would record every fact 5.5 hours off. A `Z`
|
||||
* timestamp, or one with no designator at all, is already taken as UTC and its
|
||||
* digits are copied straight across, after the calendar check below — no
|
||||
* arithmetic on the common path, so no hour can be shifted by a conversion.
|
||||
*/
|
||||
function headerClock(ts) {
|
||||
if (typeof ts !== 'string') return null;
|
||||
const m = TS_SHAPE.exec(ts.trim());
|
||||
if (m === null) return null;
|
||||
const [, year, month, day, hour, minute, sign, offsetHour, offsetMinute] = m;
|
||||
const [y, mo, d, h, mi] = [year, month, day, hour, minute].map(Number);
|
||||
// The regex counts digits; it does not know a calendar. Without this it
|
||||
// accepts `2025-99-99T99:99` — and `imessage-slack` MATCHES a header built
|
||||
// from those digits, so gbrain stores an instant no calendar contains.
|
||||
// `2025-02-30` is worse: it yields a VALID Date silently shifted to March 2.
|
||||
// `created_at` is already validated before it reaches a header (see
|
||||
// `pageDate`); the per-message clock is the same untrusted surface and is
|
||||
// used far more often. Numbers only — no string parsing, so no
|
||||
// engine-dependent interpretation of the input; and the UTC setters rather
|
||||
// than `Date.UTC`, which applies MakeFullYear and would read a four-digit
|
||||
// year of `0050` as 1950.
|
||||
if (h > 23 || mi > 59) return null;
|
||||
const utc = new Date(0);
|
||||
utc.setUTCFullYear(y, mo - 1, d);
|
||||
utc.setUTCHours(h, mi, 0, 0);
|
||||
// A date that does not survive its own round trip was never a date: month 99
|
||||
// and February 30 both roll, and the roll is what this catches.
|
||||
if (utc.getUTCFullYear() !== y || utc.getUTCMonth() !== mo - 1 || utc.getUTCDate() !== d) {
|
||||
return null;
|
||||
}
|
||||
// No offset: the digits are already UTC by this script's policy, so they are
|
||||
// copied across rather than reformatted. This is the common path, and it does
|
||||
// no arithmetic at all.
|
||||
if (sign === undefined) return `${year}-${month}-${day} ${hour}:${minute}`;
|
||||
const [oh, om] = [offsetHour, offsetMinute].map(Number);
|
||||
if (oh > 23 || om > 59) return null;
|
||||
utc.setUTCMinutes(utc.getUTCMinutes() - (oh * 60 + om) * (sign === '-' ? -1 : 1));
|
||||
const pad = (n, width = 2) => String(n).padStart(width, '0');
|
||||
// The year is padded to four digits like every other field: the pattern's
|
||||
// regex requires `\d{4}`, so an unpadded `49` would emit a header that does
|
||||
// not parse at all — a turn silently merged into its neighbour.
|
||||
return `${pad(utc.getUTCFullYear(), 4)}-${pad(utc.getUTCMonth() + 1)}-${pad(utc.getUTCDate())} ${pad(utc.getUTCHours())}:${pad(utc.getUTCMinutes())}`;
|
||||
}
|
||||
|
||||
/** The RFC 3339 shapes a CONVERSATION-level `updated_at` is ordered by.
|
||||
*
|
||||
* Deliberately a second regex rather than `TS_SHAPE`: that one exists to build
|
||||
* a turn header, whose resolution is the minute, so it discards seconds. Two
|
||||
* exports of one conversation are routinely closer together than that, and the
|
||||
* reference converter emits milliseconds always (SPEC.md rule 3 renders every
|
||||
* timestamp as `YYYY-MM-DDTHH:mm:ss.sssZ`), so seconds and fraction are
|
||||
* captured here.
|
||||
* Groups: 1=Y 2=M 3=D 4=hh 5=mm 6=ss 7=.fff, then an offset 8=sign 9=hh 10=mm. */
|
||||
const UPDATED_AT_SHAPE =
|
||||
/^(\d{4})-(\d{2})-(\d{2})[Tt ](\d{2}):(\d{2})(?::(\d{2})(\.\d+)?)?(?:[Zz]|([+-])(\d{2}):?(\d{2}))?$/;
|
||||
|
||||
/**
|
||||
* The instant `updated_at` names, in epoch milliseconds, or null when the value
|
||||
* is not one this script will order by.
|
||||
*
|
||||
* A NUMBER, not a string comparison: `2026-06-09T02:00+05:30` sorts above
|
||||
* `2026-06-08T23:00Z` lexically and is two and a half hours EARLIER as an
|
||||
* instant. And not `new Date(string)`: a date-time with no zone designator is
|
||||
* parsed as LOCAL time by ECMAScript, so the same pair of envelopes would
|
||||
* resolve differently on two machines, which this script promises not to do. No
|
||||
* designator means UTC here, matching both `headerClock` and the spec, whose
|
||||
* reasoning is that the source's true offset is unknowable.
|
||||
*
|
||||
* Same calendar discipline as `headerClock`: the regex counts digits, so
|
||||
* `2026-02-30` reaches it as a well-formed string that `Date` silently rolls to
|
||||
* March 2. A value that does not survive its own round trip is not a date, and
|
||||
* an envelope is third-party input.
|
||||
*/
|
||||
function updatedAtInstant(value) {
|
||||
if (typeof value !== 'string') return null;
|
||||
const m = UPDATED_AT_SHAPE.exec(value.trim());
|
||||
if (m === null) return null;
|
||||
const [, year, month, day, hour, minute, second, fraction, sign, offsetHour, offsetMinute] = m;
|
||||
const [y, mo, d, h, mi] = [year, month, day, hour, minute].map(Number);
|
||||
if (h > 23 || mi > 59) return null;
|
||||
// 60 is a leap second, which RFC 3339 permits and which names a real instant.
|
||||
// It is added AFTER the calendar check below, since `23:59:60` legitimately
|
||||
// rolls the date and that roll must not be read as an impossible date.
|
||||
const s = second === undefined ? 0 : Number(second);
|
||||
if (s > 60) return null;
|
||||
const utc = new Date(0);
|
||||
utc.setUTCFullYear(y, mo - 1, d);
|
||||
utc.setUTCHours(h, mi, 0, 0);
|
||||
if (utc.getUTCFullYear() !== y || utc.getUTCMonth() !== mo - 1 || utc.getUTCDate() !== d) {
|
||||
return null;
|
||||
}
|
||||
// Kept as a number rather than pushed back through `Date`, so a
|
||||
// sub-millisecond fraction still participates in the comparison — down to
|
||||
// whatever a double has left at epoch scale, which is roughly a microsecond
|
||||
// in this century. The reference producer emits exactly three fractional
|
||||
// digits (SPEC.md rule 3), so nothing it can write reaches that floor.
|
||||
let ms = utc.getTime() + s * 1000 + (fraction === undefined ? 0 : Number(fraction) * 1000);
|
||||
if (sign !== undefined) {
|
||||
const [oh, om] = [offsetHour, offsetMinute].map(Number);
|
||||
if (oh > 23 || om > 59) return null;
|
||||
ms -= (oh * 60 + om) * 60000 * (sign === '-' ? -1 : 1);
|
||||
}
|
||||
return ms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of two conversations sharing one target filename is kept.
|
||||
*
|
||||
* `later` is the one further along `conversations[]`; before this existed it
|
||||
* simply won, and that is the defect. A merged re-export is the mainstream
|
||||
* path — the memvelope CLI's own USAGE tells users to pass every downloaded
|
||||
* file at once, the spec forbids the converter from re-sorting them, and folder
|
||||
* expansion sorts by FILENAME. Every automatic duplicate-namer a browser or OS
|
||||
* applies to a second download of `conversations.json` inserts a character that
|
||||
* sorts below `.` (` (1)`, `(1)`, `-1`, ` 2`), so the RE-EXPORT sorts first and
|
||||
* the ORIGINAL sorts last. Array order was therefore not arbitrary: it was
|
||||
* deterministically wrong, and it kept the stale copy every time.
|
||||
*
|
||||
* `updated_at` is what decides instead. It is a required conversation key in
|
||||
* envelope-v0, both vendor paths of the reference converter populate it
|
||||
* (ChatGPT `update_time`, Claude `updated_at`), and nothing here read it.
|
||||
*
|
||||
* THE FALLBACK, and why it is array order rather than a cleverer guess:
|
||||
*
|
||||
* - Equal instants. Nothing distinguishes the two copies, so the rule that
|
||||
* was there before decides. Changing it would only trade one arbitrary
|
||||
* answer for another, and this one is already pinned by test.
|
||||
* - Comparable on only ONE side — absent (the spec allows `null` with no
|
||||
* fallback), non-string, or a string this script will not order by. Both
|
||||
* copies came out of ONE converter run, so the asymmetry is the vendor's:
|
||||
* one source export carried the field for this conversation and the other
|
||||
* did not. Nothing in that fact says which export is newer — a vendor may
|
||||
* have started emitting the field or stopped — so preferring the copy that
|
||||
* HAS a timestamp is a guess dressed as a rule. The tiebreak is applied
|
||||
* only when BOTH copies carry an orderable `updated_at`.
|
||||
*
|
||||
* BE PLAIN ABOUT WHAT THAT COSTS. On that slice this rule changes nothing:
|
||||
* array order decides, and array order is the same deterministically-wrong
|
||||
* answer described above, so the stale copy still wins. `updated_at: null`
|
||||
* is producer-reachable — `converter.js` ends both normalizers'
|
||||
* `updated_at` with `|| null` — though it occurs in 0 of the 13
|
||||
* conversations in the reference corpus. What this rule buys on that slice
|
||||
* is only that the outcome is LOUD: stderr prints both values and says
|
||||
* array order decided.
|
||||
* - `created_at` is deliberately not a secondary key. It is when the
|
||||
* conversation began, which is identical in both copies of a re-export and
|
||||
* says nothing about which export is newer.
|
||||
* - THE REDUCTION IS PAIRWISE, folded over `conversations[]` in order. With
|
||||
* every copy orderable that is a true maximum. With three or more copies
|
||||
* where one is NOT orderable, the fold loses transitivity and the freshest
|
||||
* copy overall can still be discarded: `[later, absent, earlier]` keeps
|
||||
* `earlier`, because neither comparison had evidence on both sides. That is
|
||||
* the fallback above doing exactly what it says rather than a separate
|
||||
* defect, and it is what the previous script did too.
|
||||
*
|
||||
* Either way a collision is a collision: one copy is discarded, and stderr says
|
||||
* which, why, and with what values.
|
||||
*/
|
||||
function keepsLaterInArray(earlier, later) {
|
||||
const a = updatedAtInstant(earlier);
|
||||
const b = updatedAtInstant(later);
|
||||
if (a === null || b === null || a === b) return { keepLater: true, byUpdatedAt: false };
|
||||
return { keepLater: b > a, byUpdatedAt: true };
|
||||
}
|
||||
|
||||
/** The file's contents, or null if it does not exist. Any other error is the
|
||||
* caller's problem to fail on — an unreadable target must never be silently
|
||||
* treated as an absent one, because "absent" is the answer that permits a
|
||||
* write. */
|
||||
function readIfPresent(path) {
|
||||
try {
|
||||
return readFileSync(path, 'utf8');
|
||||
} catch (err) {
|
||||
if (err && err.code === 'ENOENT') return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** The conversation identity recorded in a page this importer previously wrote,
|
||||
* or null if the file is not recognizably one of ours.
|
||||
*
|
||||
* A deliberate line scan rather than a YAML parse: this script has no
|
||||
* dependencies, and anything it cannot confidently recognize must fall
|
||||
* through to "foreign" — the answer that refuses the overwrite. `{ id: null }`
|
||||
* means "ours, but written from a conversation that carried no id", which is
|
||||
* a different thing from "not ours" and must not be collapsed into it.
|
||||
*
|
||||
* The id scalar is accepted in every shape a YAML round trip produces, not
|
||||
* only the JSON this importer writes. gbrain rewrites pages it holds —
|
||||
* `export --dir`, the DB-only restore path, and put_page write-through all
|
||||
* re-emit frontmatter through gray-matter, which writes a UUID as a plain
|
||||
* unquoted scalar and an all-digit or boolean-looking id single-quoted.
|
||||
* Recognizing only our own JSON meant every one of those rewrites turned the
|
||||
* page "foreign" and a later refresh refused the whole envelope, advising
|
||||
* the user to delete gbrain's own copy. */
|
||||
function idScalar(rawValue) {
|
||||
if (rawValue.startsWith('"')) {
|
||||
// Our own emitted shape (JSON is valid YAML flow syntax).
|
||||
try {
|
||||
const value = JSON.parse(rawValue);
|
||||
return typeof value === 'string' ? value : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (rawValue.startsWith("'")) {
|
||||
// YAML single-quoted: the only escape is a doubled quote.
|
||||
if (rawValue.length < 2 || !rawValue.endsWith("'")) return null;
|
||||
const body = rawValue.slice(1, -1).replace(/''/g, '\u0000');
|
||||
if (body.includes("'")) return null;
|
||||
const value = body.replace(/\u0000/g, "'");
|
||||
// An empty id is not a shape this importer ever writes; stay foreign,
|
||||
// exactly as the JSON-only reader did.
|
||||
return value === '' ? null : value;
|
||||
}
|
||||
// Plain scalar. `null`/`~`/empty are YAML null, not a string id — and this
|
||||
// importer never writes the key for a null id, so that shape stays foreign.
|
||||
if (rawValue === '' || rawValue === 'null' || rawValue === '~') return null;
|
||||
return rawValue;
|
||||
}
|
||||
|
||||
function existingPageIdentity(raw) {
|
||||
// A page we wrote can pick up cosmetic byte changes without ceasing to be
|
||||
// ours: a git checkout with core.autocrlf, a cross-platform sync, an editor
|
||||
// that adds a BOM. Refusing to recognize those made a whole envelope
|
||||
// unimportable over a line ending, so normalize them away before the scan.
|
||||
const text = raw.replace(/^/, '').replace(/\r\n/g, '\n');
|
||||
if (!text.startsWith('---\n')) return null;
|
||||
const end = text.indexOf('\n---\n', 3);
|
||||
if (end === -1) return null;
|
||||
const ID_KEY = 'memvelope_conversation_id: ';
|
||||
let ours = false;
|
||||
let id = null;
|
||||
for (const line of text.slice(4, end).split('\n')) {
|
||||
if (line === 'origin: memvelope/envelope-v0') {
|
||||
ours = true;
|
||||
} else if (line.startsWith(ID_KEY)) {
|
||||
const value = idScalar(line.slice(ID_KEY.length));
|
||||
if (value === null) return null;
|
||||
id = value;
|
||||
}
|
||||
}
|
||||
return ours ? { id } : null;
|
||||
}
|
||||
|
||||
const conversations = env.conversations || [];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Check 1 — the envelope's own declared counts, before anything is written.
|
||||
//
|
||||
// Each count is judged on its own. Treating "either field exists" as "the
|
||||
// envelope is checkable" gave a half-declared envelope a half check and total
|
||||
// silence, which is the very defect this guard exists to close.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** How a declared count is to be read: a usable number, absent, or present but
|
||||
* not a count at all. The third case must not collapse into the second —
|
||||
* saying "declares no count" about a file that declares a broken one is a
|
||||
* false statement, and it would be printed over a real truncation. */
|
||||
function readDeclaredCount(value) {
|
||||
if (value === undefined) return { state: 'absent' };
|
||||
if (Number.isInteger(value) && value >= 0) return { state: 'declared', value };
|
||||
return { state: 'malformed' };
|
||||
}
|
||||
|
||||
const actualConversations = conversations.length;
|
||||
const actualMessages = conversations.reduce((sum, c) => sum + (c.messages || []).length, 0);
|
||||
const counts = [
|
||||
{ field: 'meta.conversation_count', raw: env.meta?.conversation_count, actual: actualConversations },
|
||||
{ field: 'meta.message_count', raw: env.meta?.message_count, actual: actualMessages },
|
||||
].map((c) => ({ ...c, ...readDeclaredCount(c.raw) }));
|
||||
|
||||
const malformed = counts.filter((c) => c.state === 'malformed');
|
||||
if (malformed.length) {
|
||||
// envelope-v0 types both counts as non-negative integers. A count that is
|
||||
// present but is not one cannot be compared, and an envelope this malformed
|
||||
// is not a file to trust with an unchecked import.
|
||||
console.error('refusing to import: the envelope declares a count that is not a non-negative integer.');
|
||||
for (const c of malformed) console.error(` ${c.field} = ${JSON.stringify(c.raw)}`);
|
||||
console.error('Nothing was written. Re-export, or correct the declared counts if the contents are known-good.');
|
||||
process.exit(EXIT_REFUSED);
|
||||
}
|
||||
|
||||
const mismatched = counts.filter((c) => c.state === 'declared' && c.value !== c.actual);
|
||||
if (mismatched.length) {
|
||||
// Fail closed. The counts are the envelope's own statement of what it holds,
|
||||
// and they disagree with what it holds — so the file is not what it claims,
|
||||
// and nothing here can tell which conversations or turns went missing. A
|
||||
// partial import that exits 0 is how an archive silently becomes a fragment.
|
||||
console.error("refusing to import: the envelope's declared counts disagree with its contents.");
|
||||
// Print both counts, not only the failing one: seeing which half agrees is
|
||||
// what tells a truncated download apart from a broken converter.
|
||||
for (const c of counts) {
|
||||
const declared = c.state === 'declared' ? c.value : 'not declared';
|
||||
console.error(` ${c.field} declared ${declared}, envelope contains ${c.actual}`);
|
||||
}
|
||||
console.error('This envelope is truncated, hand-edited, or from a broken converter. Nothing was written. Re-export, or correct the declared counts if the contents are known-good.');
|
||||
process.exit(EXIT_REFUSED);
|
||||
}
|
||||
|
||||
const absent = counts.filter((c) => c.state === 'absent');
|
||||
if (absent.length) {
|
||||
// envelope-v0 requires both fields, so this file is already non-conforming.
|
||||
// Import it anyway — hand-authored envelopes are useful — but never let an
|
||||
// unchecked import look identical to a checked one on the way past. Naming
|
||||
// the missing field matters: with one count present, only half the envelope
|
||||
// was verified, and the receipt alone cannot show which half.
|
||||
console.warn(
|
||||
`warning: envelope declares no ${absent.map((c) => c.field).join(' and no ')} (envelope-v0 requires both) — integrity check skipped for ${absent.length === 2 ? 'conversations and messages' : absent[0].field.replace('meta.', '').replace('_count', 's')}; a truncated envelope would import silently.`,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render every page in memory first. Rendering has no side effects, so the
|
||||
// conflict check below can see the complete set of target files — including the
|
||||
// final content of any filename an envelope writes more than once — while the
|
||||
// output directory is still untouched.
|
||||
// ---------------------------------------------------------------------------
|
||||
const pages = new Map();
|
||||
let collisions = 0;
|
||||
for (const [i, c] of conversations.entries()) {
|
||||
const date = (c.created_at || '').slice(0, 10);
|
||||
// Name the file by the conversation's own id, so two conversations that share
|
||||
// a date and title can never silently overwrite each other. The KEY IS THE
|
||||
// PAIR: date and id together name the file, and the date is not merely a
|
||||
// human/chronological prefix — a conversation whose `created_at` changes
|
||||
// between exports lands on a new filename, which is the "orphans its earlier
|
||||
// page" limit in the header. Positional fallback keeps names unique and
|
||||
// deterministic when an envelope omits an id.
|
||||
// One predicate for "this conversation carries its own id", shared by the
|
||||
// filename, the frontmatter below, and the conflict check further down.
|
||||
// Keeping it in a single place is what stops them disagreeing about whether
|
||||
// an id exists.
|
||||
const hasId = typeof c.id === 'string' && c.id.trim() !== '';
|
||||
const convId = hasId ? c.id.trim() : `conv-${i + 1}`;
|
||||
// `date` is third-party, exactly like `convId`, so it gets the same slug()
|
||||
// treatment. Interpolating it raw let a `created_at` of `../…` resolve the
|
||||
// join below outside outDir and write there.
|
||||
const name = `${slug(date, '0000-00-00')}-${slug(convId, `conv-${i + 1}`)}.md`;
|
||||
const messages = c.messages || [];
|
||||
// The date a turn header falls back to when its own message carries no usable
|
||||
// `ts`. `date` is third-party and only length-limited, so it is validated
|
||||
// rather than trusted: a `created_at` of "1\nowner: z" slices to ten
|
||||
// characters that include a newline, and interpolating that into a header
|
||||
// would break the turn it is supposed to anchor.
|
||||
const pageDate = HEADER_DATE.test(date) ? date : EPOCH_DATE;
|
||||
// ONE fallback for an absent title, shared by the frontmatter and the H1.
|
||||
// They used to disagree — "Untitled conversation" above, "Conversation" in
|
||||
// the body — which is two different names for the same missing thing, and
|
||||
// `parseMarkdown` prefers the body's H1 when frontmatter has no title.
|
||||
const title = c.title || 'Untitled conversation';
|
||||
// gbrain reads YAML frontmatter + markdown body; keep provenance in frontmatter.
|
||||
// Emit `type: conversation` so gbrain stores these as conversation pages rather
|
||||
// than defaulting to the generic `concept`. gbrain is open-typed — it takes an
|
||||
// explicit frontmatter `type` verbatim — and its conversation-aware features
|
||||
// (conversation-facts extraction, the conversation_format_coverage check,
|
||||
// chronicle eligibility) key off `type == 'conversation'`.
|
||||
const front = [
|
||||
'---',
|
||||
'type: conversation',
|
||||
// Every interpolated value below is quoted. An envelope is a third-party
|
||||
// file, so any string carrying a newline would otherwise close its scalar
|
||||
// and inject arbitrary frontmatter keys into the page gbrain ingests — or
|
||||
// duplicate an existing key, which makes the parse throw and silently
|
||||
// strips every provenance field from the page.
|
||||
`title: ${JSON.stringify(title)}`,
|
||||
// `date` is the first 10 chars of the envelope's `created_at`; 10 is plenty
|
||||
// to smuggle a newline plus a short key. Absent stays an unquoted YAML null.
|
||||
`date: ${date ? JSON.stringify(date) : 'null'}`,
|
||||
`source: ${JSON.stringify(env.meta?.source_provider || 'unknown')}`,
|
||||
// Omit the key entirely when the envelope carries no id, rather than
|
||||
// emitting the literal `undefined` or a synthesized `conv-N` — the positional
|
||||
// fallback names the file, but it is not a memvelope conversation id and
|
||||
// must not be recorded as one.
|
||||
// The id VERBATIM, not the trimmed form used for the filename. The spec has
|
||||
// converters copy ids exactly, and recording the trimmed one made two ids
|
||||
// differing only by surrounding whitespace indistinguishable on disk — so
|
||||
// the conflict check below read them as one conversation and let the second
|
||||
// import destroy the first.
|
||||
...(hasId ? [`memvelope_conversation_id: ${JSON.stringify(c.id)}`] : []),
|
||||
'origin: memvelope/envelope-v0',
|
||||
// Per-message identity. It cannot ride in the turn header: the only header
|
||||
// shape gbrain's parser reads carries `YYYY-MM-DD HH:MM` and nothing else,
|
||||
// so a message id and a full RFC 3339 timestamp have to live here or be
|
||||
// thrown away. Order is the body's order, so `messages[i]` is the i-th turn.
|
||||
//
|
||||
// An array of maps, deliberately. Not a map keyed by id: order IS the
|
||||
// join — `messages[i]` is body turn i — and a mapping discards it. (The
|
||||
// duplicate-id argument belongs to CONVERSATION ids, which the spec says
|
||||
// consumers must tolerate; message ids are positional per spec and unique
|
||||
// within their conversation.) And not one packed string per message, which
|
||||
// asks a consumer to split on a space and breaks the moment an id has one.
|
||||
//
|
||||
// An explicit `[]` rather than an omitted key: omission is
|
||||
// indistinguishable from a page written before this format existed, and a
|
||||
// consumer reading identity back needs to tell those apart.
|
||||
...(messages.length === 0
|
||||
? ['messages: []']
|
||||
: [
|
||||
'messages:',
|
||||
...messages.flatMap((m) => [` - id: ${yamlJson(m.id)}`, ` ts: ${yamlJson(m.ts)}`]),
|
||||
]),
|
||||
'---',
|
||||
'',
|
||||
].join('\n');
|
||||
const body = messages
|
||||
.map((m) => {
|
||||
// The message's OWN date, not the conversation's: `imessage-slack` is an
|
||||
// inline-date pattern precisely so a conversation spanning midnight lands
|
||||
// its turns on the right days.
|
||||
const clock = headerClock(m.ts) || `${pageDate} 00:00`;
|
||||
return `**${m.role === 'user' ? 'Me' : 'Assistant'}** (${clock}):\n\n${m.text}`;
|
||||
})
|
||||
// No `---` rule between turns. A horizontal rule is a non-blank line that
|
||||
// matches no pattern, so the parser appends it to the preceding message:
|
||||
// every extracted message text ended `...\n---`. It also diluted the
|
||||
// match-density score the parser's acceptance floor is computed from.
|
||||
.join('\n\n');
|
||||
const rendered = {
|
||||
// The H1 is the ONLY place a third-party string reaches the body, and the
|
||||
// body is now parsed. A title carrying a newline used to look merely
|
||||
// untidy; since the turn headers became legible it manufactures a TURN, and
|
||||
// one that lands ahead of every real one — so `messages[0]` in frontmatter
|
||||
// names content the user never sent and every id after it is off by one.
|
||||
// The heading is flattened to a single line for that reason; the verbatim
|
||||
// title, newlines and all, is still recorded in the frontmatter above.
|
||||
content: front + `# ${title.replace(/\s*[\r\n]+\s*/g, ' ')}\n\n` + body + '\n',
|
||||
messageCount: messages.length,
|
||||
// The conversation's OWN id, verbatim, or null. Never the positional
|
||||
// fallback: that is a filename, not an identity, and treating it as one is
|
||||
// the whole bug. Verbatim rather than trimmed for the same reason — see the
|
||||
// frontmatter note above.
|
||||
conversationId: hasId ? c.id : null,
|
||||
// Carried only to break a filename collision. It is NOT written to the
|
||||
// page — no frontmatter key holds it, by design and separately tracked.
|
||||
updatedAt: c.updated_at,
|
||||
};
|
||||
const earlier = pages.get(name);
|
||||
if (earlier === undefined) {
|
||||
pages.set(name, rendered);
|
||||
continue;
|
||||
}
|
||||
// Never lose a page silently: two conversations mapping to the same filename
|
||||
// (an envelope carrying duplicate ids — which the spec permits, since merging
|
||||
// never deduplicates — or DISTINCT ids that slug alike, which the header
|
||||
// describes and which this warning's wording does not cover) means one of
|
||||
// them is discarded. Warn loudly rather than overwrite in silence, and report
|
||||
// the count of DISTINCT files written — not the number of write calls, which
|
||||
// is what hid the old title-collision bug.
|
||||
collisions += 1;
|
||||
const { keepLater, byUpdatedAt } = keepsLaterInArray(earlier.updatedAt, rendered.updatedAt);
|
||||
// Name the decision AND its inputs. "Overwriting the earlier page" was the
|
||||
// whole message before, and it would now be false half the time — the reader
|
||||
// has to be able to check which copy survived rather than assume the old
|
||||
// rule still applies.
|
||||
const verdict = byUpdatedAt
|
||||
? `keeping the copy whose updated_at is later (${JSON.stringify(keepLater ? rendered.updatedAt : earlier.updatedAt)}) over ${JSON.stringify(keepLater ? earlier.updatedAt : rendered.updatedAt)}`
|
||||
: `updated_at cannot order these two copies (${JSON.stringify(earlier.updatedAt ?? null)} and ${JSON.stringify(rendered.updatedAt ?? null)}), so array order decides — overwriting the earlier page`;
|
||||
console.warn(`warning: filename collision on "${name}" — conversation id ${JSON.stringify(c.id)} is not unique; ${verdict}.`);
|
||||
if (keepLater) pages.set(name, rendered);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Check 2 — target files that already exist and were not written by this run.
|
||||
// `pages` is per-process and the default outDir is a fixed literal, so without
|
||||
// this a second import into the same directory clobbered the first in silence.
|
||||
// Only the exact target filenames are examined: unrelated markdown sitting in
|
||||
// the output directory is none of this script's business.
|
||||
// ---------------------------------------------------------------------------
|
||||
const conflicts = [];
|
||||
for (const [name, page] of pages) {
|
||||
const existing = readIfPresent(join(outDir, name));
|
||||
// Absent, or already exactly what we are about to write (a re-import of the
|
||||
// same envelope). Rewriting identical bytes changes nothing.
|
||||
if (existing === null || existing === page.content) continue;
|
||||
const identity = existingPageIdentity(existing);
|
||||
if (identity === null) {
|
||||
// Say what is true — the file was not recognized. Asserting that this
|
||||
// importer did not write it is a claim this code is in no position to make,
|
||||
// and it is wrong for any page of ours that has been edited since.
|
||||
conflicts.push(` ${name} — already exists and could not be recognized as a page written by this importer.`);
|
||||
} else if (identity.id === null) {
|
||||
// Ours, but written from an id-less conversation, so its filename encodes
|
||||
// array position rather than identity. An update and a wholly different
|
||||
// conversation are indistinguishable here; guessing either way risks
|
||||
// destroying an import.
|
||||
conflicts.push(` ${name} — written by this importer from a conversation with no id, so it cannot be matched to this envelope's conversation. Refusing to guess.`);
|
||||
} else if (identity.id !== page.conversationId) {
|
||||
// Distinct ids that slug to one filename (truncation at 60 chars, or
|
||||
// characters that slug away). Rare, but silently fatal if permitted.
|
||||
conflicts.push(` ${name} — holds conversation ${JSON.stringify(identity.id)}, but this envelope maps ${JSON.stringify(page.conversationId)} to the same filename.`);
|
||||
}
|
||||
// Otherwise: same conversation id, different content — a refreshed export
|
||||
// updating its own page. That is exactly what re-importing is for.
|
||||
}
|
||||
|
||||
if (conflicts.length) {
|
||||
console.error(`refusing to import: ${conflicts.length} target file(s) in ${outDir} would be overwritten with different content.`);
|
||||
for (const line of conflicts) console.error(line);
|
||||
console.error('Nothing was written. Import into a different output directory, or delete the listed file(s) if they are stale.');
|
||||
process.exit(EXIT_REFUSED);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Write. Everything above has already passed, so this loop cannot refuse.
|
||||
// ---------------------------------------------------------------------------
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
let messagesWritten = 0;
|
||||
for (const [name, page] of pages) {
|
||||
writeFileSync(join(outDir, name), page.content);
|
||||
messagesWritten += page.messageCount;
|
||||
}
|
||||
|
||||
console.log(`wrote ${pages.size} markdown page(s) (${messagesWritten} message(s)) to ${outDir} — point gbrain's sync at this directory.`);
|
||||
if (collisions) {
|
||||
// "Overwritten" would now be false whenever the tiebreak kept the earlier
|
||||
// copy: that copy is never rewritten and the later one is never written at
|
||||
// all. "Discarded" is true in both directions, and the per-collision lines
|
||||
// above already say which copy went.
|
||||
console.warn(`warning: ${collisions} filename collision(s) — ${collisions} page(s) discarded. Deduplicate conversation ids in the envelope to avoid data loss.`);
|
||||
}
|
||||
if (messagesWritten !== actualMessages) {
|
||||
// The page count alone cannot show this: a discarded copy leaves the same one
|
||||
// file on disk, so only the message tally reveals the turns that went with it.
|
||||
//
|
||||
// "Discarded", not "overwritten", for the same reason as the summary line
|
||||
// above: since the tiebreak can keep the EARLIER copy, the losing copy is
|
||||
// sometimes never written at any point.
|
||||
//
|
||||
// Worded as a fact about the discarded copies, not as an announcement of
|
||||
// loss. Duplicate ids are conforming input — the spec has merging never
|
||||
// deduplicate — so converting an old export together with a newer one, which
|
||||
// is what the memvelope CLI tells users to do, lands here routinely with the
|
||||
// surviving page already holding every unique turn. An alarm that cries wolf
|
||||
// on the mainstream path teaches its reader to ignore the one that matters.
|
||||
console.warn(`warning: the discarded page(s) carried ${actualMessages - messagesWritten} message(s) that are not on disk (${actualMessages} read, ${messagesWritten} written). If they were earlier copies of the same conversation, the surviving page may already contain those turns; if not, this is real loss.`);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* #2185 — known-flags registry generator for CLI_ONLY commands.
|
||||
*
|
||||
* gbrain's CLI_ONLY commands read flags ad hoc (`args.includes('--force')`,
|
||||
* per-command parseFlags helpers), so there is no parser to make strict. The
|
||||
* pre-dispatch validator in src/cli.ts needs to know each command's legal
|
||||
* flags; this script derives them from the source instead of a hand-typed
|
||||
* list that would rot.
|
||||
*
|
||||
* How: parse handleCliOnly's top-level `case 'X': {` blocks out of src/cli.ts,
|
||||
* collect every `import('./commands/Y.ts')` inside each block, then scan the
|
||||
* case-block text plus each imported module (plus one level of that module's
|
||||
* ./relative same-directory imports) for `--flag` string literals — including
|
||||
* help text, which deliberately over-includes: accepting a flag the handler
|
||||
* ignores is the pre-#2185 status quo for that flag, while missing a real
|
||||
* flag would break working invocations on upgrade.
|
||||
*
|
||||
* Output: src/core/cli-flag-registry.generated.ts (committed; freshness is
|
||||
* pinned by test/cli-flag-validation.test.ts the same way build:llms pins the
|
||||
* llms bundles). Regenerate: bun run build:flag-registry
|
||||
*
|
||||
* Hand-tuning lane: EXTRA_FLAGS below, for flags that live deeper than the
|
||||
* one-level scan (add with a comment naming the deep module).
|
||||
*/
|
||||
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||
import { dirname, resolve as resolvePath, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const ROOT = resolvePath(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
/** Flags that live deeper than the one-level module scan. Keep commented. */
|
||||
const EXTRA_FLAGS: Record<string, string[]> = {
|
||||
// embed's pace knobs resolve inside src/core/pace-mode.ts (two levels deep).
|
||||
embed: ['--pace', '--pace-max-concurrency'],
|
||||
// sync shares the same pace surface via env/config plus CLI passthrough.
|
||||
sync: ['--pace', '--pace-max-concurrency'],
|
||||
};
|
||||
|
||||
/** Universal helper flags every command may see (parsed or short-circuited upstream). */
|
||||
const UNIVERSAL_FLAGS = ['--help', '--json', '--brain', '--source'];
|
||||
|
||||
const FLAG_RE = /--[a-z0-9][a-z0-9-]*/g;
|
||||
|
||||
function flagsInText(text: string): Set<string> {
|
||||
const out = new Set<string>();
|
||||
for (const m of text.matchAll(FLAG_RE)) {
|
||||
// Template-literal prefixes (`--bound-${key}` scans as `--bound-`) are
|
||||
// not real flags — a trailing hyphen would make the validator accept
|
||||
// every typo sharing the prefix.
|
||||
if (!m[0].endsWith('-')) out.add(m[0]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** One level of ./relative imports (static or dynamic) from a module's source. */
|
||||
function relativeImports(src: string, fromDir: string): string[] {
|
||||
const paths = new Set<string>();
|
||||
for (const m of src.matchAll(/from\s+'(\.\.?\/[^']+\.ts)'/g)) paths.add(m[1]);
|
||||
for (const m of src.matchAll(/import\('(\.\.?\/[^']+\.ts)'\)/g)) paths.add(m[1]);
|
||||
return [...paths]
|
||||
.map(p => resolvePath(fromDir, p))
|
||||
.filter(p => existsSync(p));
|
||||
}
|
||||
|
||||
export function buildFlagRegistry(): Record<string, string[]> {
|
||||
const cliSource = readFileSync(join(ROOT, 'src/cli.ts'), 'utf-8');
|
||||
|
||||
// CLI_ONLY membership (the single source of truth in src/cli.ts). Strip
|
||||
// line comments first — the set literal carries commentary whose quoted
|
||||
// words ('Unknown command', 'pages') must not parse as members.
|
||||
const onlyMatch = cliSource.match(/const CLI_ONLY = new Set(?:<string>)?\(\[([\s\S]*?)\]\)/);
|
||||
if (!onlyMatch) throw new Error('CLI_ONLY set not found in src/cli.ts');
|
||||
const onlyBody = onlyMatch[1].replace(/\/\/[^\n]*/g, '');
|
||||
const commands = [...onlyBody.matchAll(/'([^']+)'/g)].map(m => m[1]);
|
||||
|
||||
// handleCliOnly body — bounded at the function's closing brace (column 0).
|
||||
// Unbounded, the LAST case block absorbed every --flag literal in the rest
|
||||
// of cli.ts (printHelp's full flag surface included), handing whichever
|
||||
// command sits last in the switch a ~100-flag junk allowlist that made
|
||||
// strict validation a no-op for it.
|
||||
const fnStart = cliSource.indexOf('async function handleCliOnly');
|
||||
if (fnStart < 0) throw new Error('handleCliOnly not found in src/cli.ts');
|
||||
const fnTail = cliSource.slice(fnStart);
|
||||
const fnEndRel = fnTail.search(/\n\}\n/);
|
||||
const fnSrc = fnEndRel > 0 ? fnTail.slice(0, fnEndRel) : fnTail;
|
||||
|
||||
// handleCliOnly dispatches through TWO styles: an `if (command === 'X')`
|
||||
// chain (DB-free commands like init/auth/schema) and a switch with
|
||||
// `case 'X':` labels. Segment on BOTH marker kinds; the text between a
|
||||
// marker and the next marker belongs to that label.
|
||||
const markRe = /(?:^\s*if \(command === '([a-z0-9-]+)'\)|^ case '([a-z0-9-]+)':)/gm;
|
||||
const marks: Array<{ label: string; start: number }> = [];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = markRe.exec(fnSrc)) !== null) {
|
||||
marks.push({ label: (m[1] ?? m[2])!, start: m.index });
|
||||
}
|
||||
|
||||
const blocks = new Map<string, string>();
|
||||
for (let i = 0; i < marks.length; i++) {
|
||||
const end = i + 1 < marks.length ? marks[i + 1].start : fnSrc.length;
|
||||
const body = fnSrc.slice(marks[i].start, end);
|
||||
// Fall-through labels share the following block.
|
||||
blocks.set(marks[i].label, (blocks.get(marks[i].label) ?? '') + body);
|
||||
}
|
||||
|
||||
// Safety flags carry destructive-bypass semantics: allowlisting one that
|
||||
// the handler never reads recreates the #2185 repro (`post-upgrade
|
||||
// --dry-run` accepted, ignored, migrations run for real). Presence isn't
|
||||
// enough — upgrade.ts prints a HINT naming another command's --dry-run,
|
||||
// which is depth-0 text for post-upgrade. These flags are only legal with
|
||||
// CONSUMPTION evidence in the command's own code: the flag as a TIGHT-QUOTED
|
||||
// standalone literal (`includes('--dry-run')`, `has('--dry-run')`,
|
||||
// `=== '--dry-run'`). Prose bleed embeds the flag inside a longer string, so
|
||||
// it never has quotes on both sides of the bare flag.
|
||||
const SAFETY_FLAGS = new Set(['--dry-run']);
|
||||
const consumes = (text: string, flag: string): boolean =>
|
||||
new RegExp(`['"\`]${flag}['"\`]`).test(text);
|
||||
|
||||
const registry: Record<string, string[]> = {};
|
||||
for (const command of commands) {
|
||||
const block = blocks.get(command) ?? '';
|
||||
const flags = new Set<string>(UNIVERSAL_FLAGS);
|
||||
const depthZero = new Set<string>();
|
||||
let depthZeroText = block;
|
||||
for (const f of flagsInText(block)) { flags.add(f); depthZero.add(f); }
|
||||
|
||||
// Modules imported inside the case block, plus one level of each module's
|
||||
// own ./relative imports.
|
||||
const commandModules = [...block.matchAll(/import\('(\.\/[^']+\.ts)'\)/g)]
|
||||
.map(mm => resolvePath(join(ROOT, 'src'), mm[1]))
|
||||
.filter(p => existsSync(p));
|
||||
for (const modPath of commandModules) {
|
||||
const modSrc = readFileSync(modPath, 'utf-8');
|
||||
depthZeroText += modSrc;
|
||||
for (const f of flagsInText(modSrc)) { flags.add(f); depthZero.add(f); }
|
||||
for (const dep of relativeImports(modSrc, dirname(modPath))) {
|
||||
for (const f of flagsInText(readFileSync(dep, 'utf-8'))) flags.add(f);
|
||||
}
|
||||
}
|
||||
|
||||
for (const f of EXTRA_FLAGS[command] ?? []) { flags.add(f); depthZero.add(f); }
|
||||
for (const f of SAFETY_FLAGS) {
|
||||
if (flags.has(f) && !consumes(depthZeroText, f)) flags.delete(f);
|
||||
}
|
||||
registry[command] = [...flags].sort();
|
||||
}
|
||||
return registry;
|
||||
}
|
||||
|
||||
export function renderRegistryModule(registry: Record<string, string[]>): string {
|
||||
const entries = Object.keys(registry)
|
||||
.sort()
|
||||
.map(cmd => ` '${cmd}': [${registry[cmd].map(f => `'${f}'`).join(', ')}],`)
|
||||
.join('\n');
|
||||
return `// AUTO-GENERATED by scripts/generate-flag-registry.ts — do not edit by hand.
|
||||
// Regenerate: bun run build:flag-registry
|
||||
// Freshness + drift pinned by test/cli-flag-validation.test.ts (#2185).
|
||||
//
|
||||
// Per-command legal flags for CLI_ONLY commands, derived from each command's
|
||||
// source (case block + imported modules + one level of relative imports +
|
||||
// scripts/generate-flag-registry.ts EXTRA_FLAGS). Deliberately over-inclusive
|
||||
// (help-text mentions count): accepting an ignored flag is the pre-#2185
|
||||
// status quo; missing a real one breaks working invocations.
|
||||
export const CLI_FLAG_REGISTRY: Record<string, readonly string[]> = {
|
||||
${entries}
|
||||
};
|
||||
`;
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const registry = buildFlagRegistry();
|
||||
const outPath = join(ROOT, 'src/core/cli-flag-registry.generated.ts');
|
||||
writeFileSync(outPath, renderRegistryModule(registry));
|
||||
const n = Object.keys(registry).length;
|
||||
const total = Object.values(registry).reduce((a, v) => a + v.length, 0);
|
||||
console.log(`wrote ${outPath} (${n} commands, ${total} flag entries)`);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Regenerates skills/skills.lock.json — the tamper-evidence manifest mapping
|
||||
* every bundled file under skills/ to its sha256 (#159). Not a signature
|
||||
* system: it turns silent skill edits into explicit diffs. `gbrain doctor`
|
||||
* warns (never fails) on drift; scripts/check-skills-manifest-fresh.sh keeps
|
||||
* the committed manifest in sync in CI.
|
||||
*
|
||||
* Run after any change under skills/:
|
||||
* bun run scripts/generate-skills-manifest.ts
|
||||
*/
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
SKILLS_MANIFEST_FILENAME,
|
||||
renderSkillsManifest,
|
||||
} from '../src/core/skills-integrity.ts';
|
||||
|
||||
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const skillsDir = join(repoRoot, 'skills');
|
||||
const outPath = join(skillsDir, SKILLS_MANIFEST_FILENAME);
|
||||
writeFileSync(outPath, renderSkillsManifest(skillsDir));
|
||||
console.log(`Wrote ${outPath}`);
|
||||
+3
-2
@@ -162,8 +162,9 @@ for f in "${files[@]}"; do
|
||||
if [ -n "${DATABASE_URL:-}" ]; then
|
||||
psql "$DATABASE_URL" -At -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE pid != pg_backend_pid() AND datname = current_database()" >/dev/null 2>&1 || true
|
||||
fi
|
||||
# Hard outer timeout (180s per file). bun's --timeout is per-test; if a
|
||||
# PGLite WASM call hangs in beforeAll/afterAll, --timeout never fires and
|
||||
# Hard outer timeout (180s per file). bun's --timeout covers tests AND
|
||||
# hooks (measured on 1.3.14), but it's timer-based: a PGLite WASM call
|
||||
# that blocks the event loop synchronously never lets the timer fire and
|
||||
# the file wedges indefinitely. gtimeout/timeout SIGKILLs the file so the
|
||||
# suite advances. gtimeout (macOS via coreutils) preferred; timeout (Linux)
|
||||
# fallback; bare bun (no outer cap) if neither is installed.
|
||||
|
||||
+302
-10
@@ -13,8 +13,29 @@
|
||||
#
|
||||
# Env overrides:
|
||||
# SHARDS=N same as --shards
|
||||
# GBRAIN_TEST_SHARD_TIMEOUT per-shard wallclock cap, seconds (default 600)
|
||||
# GBRAIN_TEST_SHARD_TIMEOUT per-shard wallclock cap, seconds (default 3000)
|
||||
# GBRAIN_TEST_SHARD_KILL_AFTER grace after TERM before KILL (default 30)
|
||||
# GBRAIN_TEST_MAX_CONCURRENCY passed through to bun test (default 4)
|
||||
# GBRAIN_TEST_MEM_PER_FILE_MB memory budget per concurrent test file used by
|
||||
# the adaptive sizing below (default 1536 — a
|
||||
# PGLite WASM instance reserves ~1-1.5GB)
|
||||
# GBRAIN_TEST_NO_MEM_ADAPT=1 disable memory-aware concurrency reduction
|
||||
# GBRAIN_TEST_NO_OOM_FALLBACK=1 disable the serial OOM-rescue pass
|
||||
#
|
||||
# Memory safety (two layers; both default-on):
|
||||
# 1. ADAPTIVE SIZING — before spawning, total concurrency (shards ×
|
||||
# intra-shard --max-concurrency) is capped to what available memory can
|
||||
# hold at GBRAIN_TEST_MEM_PER_FILE_MB per concurrent file. Concurrent
|
||||
# Conductor workspaces running their own suites shrink the budget
|
||||
# automatically instead of OOMing each other.
|
||||
# 2. SERIAL PHANTOM RESCUE — two phantom classes are re-run serially
|
||||
# (--max-concurrency 1) after the parallel pass: (a) failures whose
|
||||
# shard log carries the PGLite WASM out-of-memory signature, and
|
||||
# (b) shards killed EXTERNALLY (SIGTERM/SIGKILL well before the shard
|
||||
# timeout — sibling Conductor workspaces' process cleanup, macOS memory
|
||||
# jetsam). Phantoms pass serially and the run goes green with an
|
||||
# oom_rescued note; real failures fail again and stay red. Plain
|
||||
# assertion failures never match either signature.
|
||||
#
|
||||
# Output files (workspace-local; falls back to /tmp if .context/ unwritable):
|
||||
# .context/test-failures.log failure blocks (cleared at start)
|
||||
@@ -37,6 +58,35 @@ detect_cpus() {
|
||||
echo 4
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Available-memory detection (MB). macOS: vm_stat free + inactive +
|
||||
# speculative + purgeable pages (inactive/purgeable are reclaimable on
|
||||
# pressure, which is exactly the scenario we size for). Linux: MemAvailable.
|
||||
# Unknown platform → 0, and the caller skips adaptation entirely.
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
detect_available_mem_mb() {
|
||||
if command -v vm_stat >/dev/null 2>&1; then
|
||||
vm_stat 2>/dev/null | awk '
|
||||
/page size of/ { psize = $8 }
|
||||
/Pages free/ { free = $NF }
|
||||
/Pages inactive/ { inactive = $NF }
|
||||
/Pages speculative/ { spec = $NF }
|
||||
/Pages purgeable/ { purge = $NF }
|
||||
END {
|
||||
gsub(/\./, "", free); gsub(/\./, "", inactive)
|
||||
gsub(/\./, "", spec); gsub(/\./, "", purge)
|
||||
if (psize == 0) psize = 16384
|
||||
printf "%d\n", (free + inactive + spec + purge) * psize / 1048576
|
||||
}'
|
||||
return
|
||||
fi
|
||||
if [ -r /proc/meminfo ]; then
|
||||
awk '/MemAvailable/ { printf "%d\n", $2 / 1024; found = 1 } END { if (!found) print 0 }' /proc/meminfo
|
||||
return
|
||||
fi
|
||||
echo 0
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Argument parsing. --shards N override wins over $SHARDS; both are clamped.
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
@@ -78,7 +128,59 @@ INTRA_CONC="${MAX_CONCURRENCY_OVERRIDE:-${GBRAIN_TEST_MAX_CONCURRENCY:-4}}"
|
||||
# had completed in 968s. 1500s cap gives ~55% headroom over observed
|
||||
# 4-shard wallclock; real hangs still hit it. Override via
|
||||
# GBRAIN_TEST_SHARD_TIMEOUT=N.
|
||||
SHARD_TIMEOUT="${GBRAIN_TEST_SHARD_TIMEOUT:-1500}"
|
||||
# v0.42.74 sizing: 1500 -> 3000. The suite roughly tripled since the 1500s
|
||||
# cap was set (June: ~3900 tests, 92-migration PGLite replay; now: 11k+
|
||||
# tests, 120-migration replay per PGLite init). At 4 shards, two shards were
|
||||
# killed at 1500s while making steady per-test progress. 3000s keeps the
|
||||
# same ~55%-headroom doctrine over observed wallclock; real hangs still die.
|
||||
SHARD_TIMEOUT="${GBRAIN_TEST_SHARD_TIMEOUT:-3000}"
|
||||
SHARD_KILL_AFTER="${GBRAIN_TEST_SHARD_KILL_AFTER:-30}"
|
||||
if ! printf '%s' "$SHARD_KILL_AFTER" | grep -qE '^[0-9]+$' || [ "$SHARD_KILL_AFTER" -lt 1 ]; then
|
||||
echo "ERROR: invalid shard kill-after: $SHARD_KILL_AFTER" >&2; exit 2
|
||||
fi
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Memory-aware concurrency (layer 1). Total concurrent test files =
|
||||
# N shards × INTRA_CONC; each concurrent file can hold a PGLite WASM
|
||||
# instance (~1-1.5GB reserved). 4×4 = 16 concurrent instances OOM'd on a
|
||||
# 128GB machine when other Conductor workspaces ran their suites at the
|
||||
# same time — every PGLite connect across every shard failed at once
|
||||
# ("Out of memory" at PGlite.create). Cap total concurrency to what's
|
||||
# actually available, keeping a 4GB reserve for the OS + bun itself.
|
||||
# Applies to explicit --shards overrides too (an operator who wants an
|
||||
# over-committed run sets GBRAIN_TEST_NO_MEM_ADAPT=1).
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
MEM_PER_FILE_MB="${GBRAIN_TEST_MEM_PER_FILE_MB:-1536}"
|
||||
MEM_NOTE=""
|
||||
if [ "${GBRAIN_TEST_NO_MEM_ADAPT:-0}" != "1" ]; then
|
||||
AVAIL_MB=$(detect_available_mem_mb)
|
||||
if [ "${AVAIL_MB:-0}" -gt 0 ] 2>/dev/null; then
|
||||
BUDGET_MB=$((AVAIL_MB - 4096))
|
||||
[ "$BUDGET_MB" -lt "$MEM_PER_FILE_MB" ] && BUDGET_MB="$MEM_PER_FILE_MB"
|
||||
MAX_TOTAL=$((BUDGET_MB / MEM_PER_FILE_MB))
|
||||
[ "$MAX_TOTAL" -lt 1 ] && MAX_TOTAL=1
|
||||
ORIG_N="$N"; ORIG_INTRA="$INTRA_CONC"
|
||||
# Shed shards before intra-shard concurrency: fewer bun processes frees
|
||||
# more than narrower ones (each process carries its own heap + WASM).
|
||||
while [ $((N * INTRA_CONC)) -gt "$MAX_TOTAL" ]; do
|
||||
if [ "$N" -gt 1 ]; then N=$((N - 1))
|
||||
elif [ "$INTRA_CONC" -gt 1 ]; then INTRA_CONC=$((INTRA_CONC - 1))
|
||||
else break
|
||||
fi
|
||||
done
|
||||
if [ "$N" != "$ORIG_N" ] || [ "$INTRA_CONC" != "$ORIG_INTRA" ]; then
|
||||
# Fewer shards → more files per shard → each shard legitimately runs
|
||||
# longer. Scale the per-shard cap by the shed ratio so adaptation
|
||||
# doesn't convert memory safety into false WEDGED verdicts.
|
||||
if [ "$N" -lt "$ORIG_N" ]; then
|
||||
SHARD_TIMEOUT=$((SHARD_TIMEOUT * ORIG_N / N))
|
||||
fi
|
||||
MEM_NOTE=" | mem-adapted ${ORIG_N}x${ORIG_INTRA}→${N}x${INTRA_CONC} (avail=${AVAIL_MB}MB, ${MEM_PER_FILE_MB}MB/file, timeout→${SHARD_TIMEOUT}s)"
|
||||
else
|
||||
MEM_NOTE=" | mem-ok (avail=${AVAIL_MB}MB)"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Output directories. Prefer workspace-local .context/, fall back to /tmp.
|
||||
@@ -95,7 +197,7 @@ else
|
||||
mkdir -p "$LOG_DIR" || { echo "ERROR: cannot create log dir" >&2; exit 2; }
|
||||
fi
|
||||
# Clear from prior run.
|
||||
rm -f "$LOG_DIR"/shard-*.log "$LOG_DIR"/shard-*.exit "$LOG_DIR"/shard-*.wedged 2>/dev/null
|
||||
rm -f "$LOG_DIR"/shard-*.log "$LOG_DIR"/shard-*.exit "$LOG_DIR"/shard-*.wedged "$LOG_DIR"/shard-*.start "$LOG_DIR"/shard-*.end 2>/dev/null
|
||||
: > "$FAILURES_LOG"
|
||||
: > "$SUMMARY_FILE"
|
||||
|
||||
@@ -109,7 +211,7 @@ elif command -v timeout >/dev/null 2>&1; then TIMEOUT_BIN="timeout"
|
||||
fi
|
||||
|
||||
START_TS=$(date +%s)
|
||||
echo "[unit-parallel] N=$N shards | --max-concurrency=$INTRA_CONC | timeout=${SHARD_TIMEOUT}s | logs=$LOG_DIR" >&2
|
||||
echo "[unit-parallel] N=$N shards | --max-concurrency=$INTRA_CONC | timeout=${SHARD_TIMEOUT}s | kill-after=${SHARD_KILL_AFTER}s | logs=$LOG_DIR${MEM_NOTE}" >&2
|
||||
|
||||
if [ "$DRY_RUN" = "1" ]; then
|
||||
echo "[unit-parallel] dry-run: would spawn $N shards with the above settings."
|
||||
@@ -128,8 +230,9 @@ SHARD_PIDS=()
|
||||
for i in $(seq 1 "$N"); do
|
||||
(
|
||||
SHARD_LOG="$LOG_DIR/shard-$i.log"
|
||||
date +%s > "$LOG_DIR/shard-$i.start"
|
||||
if [ -n "$TIMEOUT_BIN" ]; then
|
||||
"$TIMEOUT_BIN" "${SHARD_TIMEOUT}s" \
|
||||
"$TIMEOUT_BIN" --signal=TERM --kill-after="${SHARD_KILL_AFTER}s" "${SHARD_TIMEOUT}s" \
|
||||
env SHARD="$i/$N" \
|
||||
bash scripts/run-unit-shard.sh --max-concurrency="$INTRA_CONC" \
|
||||
> "$SHARD_LOG" 2>&1
|
||||
@@ -140,7 +243,7 @@ for i in $(seq 1 "$N"); do
|
||||
> "$SHARD_LOG" 2>&1 &
|
||||
pid=$!
|
||||
( sleep "$SHARD_TIMEOUT" && kill -TERM "$pid" 2>/dev/null && \
|
||||
sleep 5 && kill -KILL "$pid" 2>/dev/null ) &
|
||||
sleep "$SHARD_KILL_AFTER" && kill -KILL "$pid" 2>/dev/null ) &
|
||||
cap_pid=$!
|
||||
wait "$pid" 2>/dev/null
|
||||
# Capture the shard's exit code from ITS `wait`, before any watchdog
|
||||
@@ -157,8 +260,9 @@ for i in $(seq 1 "$N"); do
|
||||
kill "$cap_pid" 2>/dev/null
|
||||
wait "$cap_pid" 2>/dev/null
|
||||
fi
|
||||
date +%s > "$LOG_DIR/shard-$i.end"
|
||||
echo "$rc" > "$LOG_DIR/shard-$i.exit"
|
||||
[ "$rc" = "124" ] && echo "WEDGED" > "$LOG_DIR/shard-$i.wedged"
|
||||
{ [ "$rc" = "124" ] || [ "$rc" = "137" ]; } && echo "WEDGED" > "$LOG_DIR/shard-$i.wedged"
|
||||
) &
|
||||
SHARD_PIDS+=($!)
|
||||
done
|
||||
@@ -311,6 +415,40 @@ TOTAL_FAILURES=0
|
||||
TOTAL_PASS=0
|
||||
TOTAL_SKIP=0
|
||||
TOTAL_RC=0
|
||||
|
||||
# Layer 2 state (serial OOM rescue). A shard whose log carries the WASM
|
||||
# out-of-memory signature gets its failing files queued for a serial re-run;
|
||||
# NON_OOM_FAIL records that at least one failure exists that the rescue lane
|
||||
# must NOT absolve (plain assertion failures, wedges without the signature).
|
||||
OOM_RE='Out of memory|WebAssembly\.Memory|RuntimeError: [Aa]borted|Aborted\(\)'
|
||||
OOM_RESCUE_LIST="$LOG_DIR/oom-rescue-files.txt"
|
||||
: > "$OOM_RESCUE_LIST"
|
||||
NON_OOM_FAIL=0
|
||||
# Set when any shard was killed externally — killed-midrun shards leave lock/
|
||||
# state residue that can poison the LATER serial pass, so serial failures are
|
||||
# only rescue-eligible under this flag (or their own OOM signature). A flaky
|
||||
# serial test in an otherwise-clean run must stay red.
|
||||
EXTERNAL_KILL_ANY=0
|
||||
|
||||
# failing_files_in_log: attribute each `(fail)` block to the test file whose
|
||||
# `path.test.ts:` header most recently preceded it in bun's output. Under
|
||||
# GITHUB_ACTIONS the shard wraps each file section as `::group::path.test.ts:`
|
||||
# — strip that prefix or the rescue pass feeds bun literal `::group::...`
|
||||
# non-paths that match zero test files (CI-only; local runs have no groups).
|
||||
failing_files_in_log() {
|
||||
local file="$1"
|
||||
[ -f "$file" ] || return 0
|
||||
awk '
|
||||
/^(::group::)?[^ ].*\.test\.ts:$/ {
|
||||
current = $0
|
||||
sub(/^::group::/, "", current)
|
||||
current = substr(current, 1, length(current) - 1)
|
||||
next
|
||||
}
|
||||
/^\(fail\) / && current != "" { print current }
|
||||
' "$file" | sort -u
|
||||
}
|
||||
|
||||
for i in $(seq 1 "$N"); do
|
||||
SHARD_LOG="$LOG_DIR/shard-$i.log"
|
||||
EXIT_FILE="$LOG_DIR/shard-$i.exit"
|
||||
@@ -325,17 +463,74 @@ for i in $(seq 1 "$N"); do
|
||||
TOTAL_FAILURES=$((TOTAL_FAILURES + fail_count))
|
||||
TOTAL_SKIP=$((TOTAL_SKIP + skip_count))
|
||||
|
||||
shard_oom=0
|
||||
if [ "$rc" != "0" ] && [ "${GBRAIN_TEST_NO_OOM_FALLBACK:-0}" != "1" ] \
|
||||
&& [ -f "$SHARD_LOG" ] && grep -qE "$OOM_RE" "$SHARD_LOG"; then
|
||||
shard_oom=1
|
||||
fi
|
||||
|
||||
# External-kill detection: rc 143 (SIGTERM) / 137 (SIGKILL) with the shard
|
||||
# dying before 80% of the shard timeout means something OUTSIDE the runner
|
||||
# killed it — sibling Conductor workspaces' process cleanup and macOS
|
||||
# memory jetsam both present exactly this way (observed: 3 shards TERM'd +
|
||||
# 1 KILL'd at ~700s under a 3000s cap, all mid-progress). A REAL wedge is
|
||||
# killed BY the runner at ~SHARD_TIMEOUT and stays red. Externally-killed
|
||||
# shards are phantoms: queue for the serial rescue lane like OOM.
|
||||
shard_external_kill=0
|
||||
if [ "$shard_oom" = "0" ] && [ "${GBRAIN_TEST_NO_OOM_FALLBACK:-0}" != "1" ] \
|
||||
&& { [ "$rc" = "143" ] || [ "$rc" = "137" ]; }; then
|
||||
s_start=$(cat "$LOG_DIR/shard-$i.start" 2>/dev/null) || s_start=""
|
||||
s_end=$(cat "$LOG_DIR/shard-$i.end" 2>/dev/null) || s_end=""
|
||||
if [ -n "$s_start" ] && [ -n "$s_end" ]; then
|
||||
s_elapsed=$((s_end - s_start))
|
||||
if [ "$s_elapsed" -lt $((SHARD_TIMEOUT * 80 / 100)) ]; then
|
||||
shard_external_kill=1
|
||||
EXTERNAL_KILL_ANY=1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -f "$WEDGED_FILE" ]; then
|
||||
TOTAL_RC=1
|
||||
if [ "$shard_external_kill" = "1" ]; then
|
||||
SHARD="$i/$N" bash scripts/run-unit-shard.sh --dry-run-list 2>/dev/null >> "$OOM_RESCUE_LIST"
|
||||
echo "shard $i/$N: KILLED externally after ${s_elapsed}s (rc=$rc, well before ${SHARD_TIMEOUT}s cap — queued for serial rescue)" >> "$SUMMARY_FILE"
|
||||
elif [ "$shard_oom" = "1" ]; then
|
||||
# Wedged UNDER memory pressure: we can't attribute failures, so queue
|
||||
# the shard's entire file list for the serial rescue pass.
|
||||
SHARD="$i/$N" bash scripts/run-unit-shard.sh --dry-run-list 2>/dev/null >> "$OOM_RESCUE_LIST"
|
||||
echo "shard $i/$N: WEDGED after ${SHARD_TIMEOUT}s (rc=$rc, OOM signature — queued for serial rescue)" >> "$SUMMARY_FILE"
|
||||
else
|
||||
NON_OOM_FAIL=1
|
||||
echo "shard $i/$N: WEDGED after ${SHARD_TIMEOUT}s (rc=$rc)" >> "$SUMMARY_FILE"
|
||||
fi
|
||||
{
|
||||
echo "--- shard $i: WEDGED after ${SHARD_TIMEOUT}s ---"
|
||||
[ -f "$SHARD_LOG" ] && tail -50 "$SHARD_LOG"
|
||||
echo ""
|
||||
} >> "$FAILURES_LOG"
|
||||
echo "shard $i/$N: WEDGED after ${SHARD_TIMEOUT}s (rc=$rc)" >> "$SUMMARY_FILE"
|
||||
continue
|
||||
fi
|
||||
|
||||
if [ "$rc" != "0" ]; then
|
||||
if [ "$shard_oom" = "1" ]; then
|
||||
# One scan, reused for both the queue append and the emptiness check.
|
||||
shard_failing_files=$(failing_files_in_log "$SHARD_LOG")
|
||||
if [ -n "$shard_failing_files" ]; then
|
||||
printf '%s\n' "$shard_failing_files" >> "$OOM_RESCUE_LIST"
|
||||
else
|
||||
# OOM signature but no attributable files (e.g. bun died before any
|
||||
# file header) → rescue the whole shard.
|
||||
SHARD="$i/$N" bash scripts/run-unit-shard.sh --dry-run-list 2>/dev/null >> "$OOM_RESCUE_LIST"
|
||||
fi
|
||||
elif [ "$shard_external_kill" = "1" ]; then
|
||||
SHARD="$i/$N" bash scripts/run-unit-shard.sh --dry-run-list 2>/dev/null >> "$OOM_RESCUE_LIST"
|
||||
echo "shard $i/$N: KILLED externally after ${s_elapsed}s (rc=$rc — queued for serial rescue)" >> "$SUMMARY_FILE"
|
||||
else
|
||||
NON_OOM_FAIL=1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "shard $i/$N: pass=$pass_count fail=$fail_count skip=$skip_count rc=$rc" >> "$SUMMARY_FILE"
|
||||
|
||||
if [ "$rc" != "0" ]; then
|
||||
@@ -390,6 +585,17 @@ if [ "$SERIAL_FILES_COUNT" -gt 0 ]; then
|
||||
cat "$LOG_DIR/serial.log"
|
||||
if [ "$SERIAL_RC" != "0" ]; then
|
||||
TOTAL_RC=1
|
||||
if [ "${GBRAIN_TEST_NO_OOM_FALLBACK:-0}" != "1" ] \
|
||||
&& { grep -qE "$OOM_RE" "$LOG_DIR/serial.log" || [ "$EXTERNAL_KILL_ANY" = "1" ]; }; then
|
||||
# Serial failures are rescue-eligible ONLY with their own OOM signature
|
||||
# or when an externally-killed shard ran earlier in this invocation
|
||||
# (killed-midrun shards leave lock/state residue that poisons the serial
|
||||
# pass). A merely-OOM'd sibling shard is NOT grounds — a flaky serial
|
||||
# test must stay red rather than get silently absolved.
|
||||
failing_files_in_log "$LOG_DIR/serial.log" >> "$OOM_RESCUE_LIST"
|
||||
else
|
||||
NON_OOM_FAIL=1
|
||||
fi
|
||||
s_fail=$(bun_summary_count "fail" "$LOG_DIR/serial.log")
|
||||
TOTAL_FAILURES=$((TOTAL_FAILURES + s_fail))
|
||||
if [ "$s_fail" -gt 0 ]; then
|
||||
@@ -415,6 +621,92 @@ if [ "$SERIAL_FILES_COUNT" -gt 0 ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Layer 2: serial OOM rescue. Re-run every file that failed inside an
|
||||
# OOM-signature shard, one at a time (1 shard, --max-concurrency 1), after
|
||||
# the parallel fan-out has fully drained. Phantom failures (the WASM ran out
|
||||
# of memory because 16 instances were up at once) pass here and the run goes
|
||||
# green with an oom_rescued note; real failures fail again and stay red.
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
OOM_RESCUED=0
|
||||
OOM_RESCUE_NOTE=""
|
||||
sort -u "$OOM_RESCUE_LIST" -o "$OOM_RESCUE_LIST" 2>/dev/null
|
||||
# grep -c exits 1 on zero matches — assign in two steps so an empty rescue
|
||||
# list yields a single "0" (the grep_count double-output bug, same class).
|
||||
RESCUE_COUNT=$(grep -c . "$OOM_RESCUE_LIST" 2>/dev/null) || RESCUE_COUNT=0
|
||||
if [ "$TOTAL_RC" != "0" ] && [ "${RESCUE_COUNT:-0}" -gt 0 ]; then
|
||||
echo "════════════ OOM rescue pass ($RESCUE_COUNT files, serial) ════════════"
|
||||
echo "[unit-parallel] OOM signature detected — re-running $RESCUE_COUNT failing file(s) at --max-concurrency 1" >&2
|
||||
RESCUE_LOG="$LOG_DIR/oom-rescue.log"
|
||||
# 60s-per-file floor with the shard cap as a minimum, and 2x the shard cap
|
||||
# as a CEILING: a wedged shard queueing its whole file list must not turn
|
||||
# `bun run test` into an unbounded multi-hour serial re-run — hitting the
|
||||
# ceiling reads as a red rescue, not silence.
|
||||
RESCUE_TIMEOUT=$((RESCUE_COUNT * 60))
|
||||
[ "$RESCUE_TIMEOUT" -lt "$SHARD_TIMEOUT" ] && RESCUE_TIMEOUT="$SHARD_TIMEOUT"
|
||||
[ "$RESCUE_TIMEOUT" -gt $((SHARD_TIMEOUT * 2)) ] && RESCUE_TIMEOUT=$((SHARD_TIMEOUT * 2))
|
||||
# Split the queue: *.serial.test.ts files require one bun PROCESS per file
|
||||
# (run-serial-tests.sh's isolation contract — top-level mock.module leaks
|
||||
# across files in a shared registry); the remainder batches in one process.
|
||||
# Both lanes mirror the shard invocation's --timeout=60000 — bun's default
|
||||
# 5s per-test timeout would re-fail PGLite phantoms (120-migration replay)
|
||||
# and mislabel them 'confirmed real'.
|
||||
grep -v '\.serial\.test\.ts$' "$OOM_RESCUE_LIST" > "$LOG_DIR/oom-rescue-batch.txt" || true
|
||||
grep '\.serial\.test\.ts$' "$OOM_RESCUE_LIST" > "$LOG_DIR/oom-rescue-serial.txt" || true
|
||||
RESCUE_RC=0
|
||||
: > "$RESCUE_LOG"
|
||||
run_rescue() { # $1 = per-invocation timeout seconds; rest = test-file args
|
||||
local t="$1"; shift
|
||||
if [ -n "$TIMEOUT_BIN" ]; then
|
||||
"$TIMEOUT_BIN" --signal=TERM --kill-after="${SHARD_KILL_AFTER}s" "${t}s" \
|
||||
bun test --max-concurrency 1 --timeout=60000 "$@" >> "$RESCUE_LOG" 2>&1
|
||||
else
|
||||
bun test --max-concurrency 1 --timeout=60000 "$@" >> "$RESCUE_LOG" 2>&1
|
||||
fi
|
||||
}
|
||||
if [ -s "$LOG_DIR/oom-rescue-batch.txt" ]; then
|
||||
# shellcheck disable=SC2046
|
||||
run_rescue "$RESCUE_TIMEOUT" $(cat "$LOG_DIR/oom-rescue-batch.txt") || RESCUE_RC=1
|
||||
fi
|
||||
if [ -s "$LOG_DIR/oom-rescue-serial.txt" ]; then
|
||||
while IFS= read -r serial_file; do
|
||||
[ -n "$serial_file" ] || continue
|
||||
run_rescue 300 "$serial_file" || RESCUE_RC=1
|
||||
done < "$LOG_DIR/oom-rescue-serial.txt"
|
||||
fi
|
||||
cat "$RESCUE_LOG"
|
||||
r_pass=$(bun_summary_count "pass" "$RESCUE_LOG")
|
||||
r_fail=$(bun_summary_count "fail" "$RESCUE_LOG")
|
||||
if [ "$RESCUE_RC" = "0" ] && [ "$NON_OOM_FAIL" = "0" ]; then
|
||||
# Every failure in the run was OOM-phantom and every rescued file passed
|
||||
# serially: the run is green. Adjust the headline numbers so they reflect
|
||||
# the rescue verdict, and mark the earlier failure blocks superseded.
|
||||
TOTAL_RC=0
|
||||
OOM_RESCUED=1
|
||||
# Do NOT fold r_pass into TOTAL_PASS — the failing shard's own summary
|
||||
# already counted the rescued files' passing tests, so folding would
|
||||
# double-count. Rescue results ride in the note instead.
|
||||
TOTAL_FAILURES=0
|
||||
OOM_RESCUE_NOTE=" | oom_rescued=${RESCUE_COUNT}files(${r_pass}p serial)"
|
||||
{
|
||||
echo "--- OOM rescue: all $RESCUE_COUNT file(s) passed serially (${r_pass} tests) ---"
|
||||
echo "--- failure blocks above were WASM out-of-memory phantoms, superseded ---"
|
||||
} >> "$FAILURES_LOG"
|
||||
echo "oom-rescue: $RESCUE_COUNT files pass=$r_pass rc=0 (phantom OOM failures superseded)" >> "$SUMMARY_FILE"
|
||||
else
|
||||
# Real failures confirmed serially (or a non-OOM failure exists anyway).
|
||||
OOM_RESCUE_NOTE=" | oom_rescue_failed=${r_fail}real"
|
||||
awk '
|
||||
/^\(fail\) / { in_block=1; print "--- oom-rescue (serial, confirmed real): " $0; next }
|
||||
in_block {
|
||||
if (/^\(pass\)/ || /^\(skip\)/ || /^[[:space:]]*$/ || /__bun_test_summary__/) { in_block=0; print ""; next }
|
||||
print $0
|
||||
}
|
||||
' "$RESCUE_LOG" >> "$FAILURES_LOG"
|
||||
echo "oom-rescue: $RESCUE_COUNT files pass=$r_pass fail=$r_fail rc=$RESCUE_RC (real failures confirmed)" >> "$SUMMARY_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
END_TS=$(date +%s)
|
||||
ELAPSED=$((END_TS - START_TS))
|
||||
|
||||
@@ -431,10 +723,10 @@ if [ "$TOTAL_RC" != "0" ]; then
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
tail -30 "$FAILURES_LOG"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "[unit-parallel] elapsed=${ELAPSED}s | pass=$TOTAL_PASS fail=$TOTAL_FAILURES skip=$TOTAL_SKIP"
|
||||
echo "[unit-parallel] elapsed=${ELAPSED}s | pass=$TOTAL_PASS fail=$TOTAL_FAILURES skip=$TOTAL_SKIP${OOM_RESCUE_NOTE}"
|
||||
} >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[unit-parallel] elapsed=${ELAPSED}s | pass=$TOTAL_PASS fail=$TOTAL_FAILURES skip=$TOTAL_SKIP" >&2
|
||||
echo "[unit-parallel] elapsed=${ELAPSED}s | pass=$TOTAL_PASS fail=$TOTAL_FAILURES skip=$TOTAL_SKIP${OOM_RESCUE_NOTE}" >&2
|
||||
exit 0
|
||||
|
||||
@@ -42,6 +42,7 @@ CHECKS=(
|
||||
"check:source-id-projection"
|
||||
"check:source-config-leak"
|
||||
"check:progress"
|
||||
"check:no-tracked-symlinks"
|
||||
"check:test-isolation"
|
||||
"check:wasm"
|
||||
"check:admin-build"
|
||||
@@ -49,6 +50,7 @@ CHECKS=(
|
||||
"check:cli-exec"
|
||||
"check:system-of-record"
|
||||
"check:eval-glossary"
|
||||
"check:skills-manifest"
|
||||
"check:no-pii-agent-voice"
|
||||
"check:synthetic-corpus-privacy"
|
||||
"check:skill-brain-first"
|
||||
@@ -63,6 +65,7 @@ CHECKS=(
|
||||
"check:source-scope-onboard"
|
||||
"check:no-double-retry"
|
||||
"check:batch-audit-site"
|
||||
"check:engine-dynamic-import"
|
||||
"check:worker-lock-renewal-shape"
|
||||
"typecheck"
|
||||
)
|
||||
|
||||
@@ -248,7 +248,7 @@ before submission.
|
||||
After the brain page is written, render to PDF using `skills/brain-pdf`:
|
||||
|
||||
```bash
|
||||
gbrain put_page # already done by the CLI; nothing to add here
|
||||
gbrain put # already done by the CLI; nothing to add here
|
||||
# Then invoke brain-pdf:
|
||||
# (see skills/brain-pdf/SKILL.md for the make-pdf invocation)
|
||||
```
|
||||
|
||||
@@ -73,13 +73,13 @@ stock worker auto-loads on startup) registers handlers before `start()`.
|
||||
Users who set `minion_mode: off` in `~/.gbrain/preferences.json` keep
|
||||
using `agentTurn`. Respect that. No auto-rewrite.
|
||||
|
||||
## Forward note (v0.12.0)
|
||||
## Forward note
|
||||
|
||||
GBrain v0.12.0 ships `gbrain cron`: a scheduler loop inside
|
||||
`gbrain jobs work` that owns cron expressions natively — no more
|
||||
handing off to host schedulers. Until v0.12.0 lands, the host
|
||||
scheduler keeps firing on schedule; v0.11.1 only replaces the execution
|
||||
layer (what the cron trigger *does*), not the scheduling layer.
|
||||
A native scheduler loop inside `gbrain jobs work` (owning cron
|
||||
expressions directly, with no host-scheduler hand-off) has been on the
|
||||
roadmap since v0.11.1 but has not shipped. The host scheduler keeps
|
||||
firing on schedule; this convention only replaces the execution layer
|
||||
(what the cron trigger *does*), not the scheduling layer.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -125,7 +125,8 @@ v0.41.22 ships **gbrain-base-v2** as the declared successor to
|
||||
gbrain-base@1.x — collapses 94 noisy types to 15 canonical via
|
||||
declarative mapping_rules. Run via `gbrain onboard --check --explain`
|
||||
(preview) → `gbrain jobs submit unify-types --allow-protected --params
|
||||
'{"target_pack":"gbrain-base-v2"}'` (apply). See
|
||||
'{"target_pack":"gbrain-base-v2","apply":true}'` (apply — `apply`
|
||||
defaults to false, so a bare submit is a dry run). See
|
||||
`skills/schema-unify/SKILL.md` for the full playbook.
|
||||
|
||||
Authoring a successor pack: declare
|
||||
|
||||
@@ -54,8 +54,8 @@ Ask the user what they want to track. Either:
|
||||
- Define a custom recipe with: source queries, classification rules, extraction schema,
|
||||
tracker page path, tracker format
|
||||
|
||||
Recipes are YAML files at `~/.gbrain/recipes/{name}.yaml`. Use `gbrain research init`
|
||||
to scaffold a new one.
|
||||
Recipes are YAML files at `~/.gbrain/recipes/{name}.yaml`. Scaffold a new one by
|
||||
copying a built-in recipe file and editing its fields.
|
||||
|
||||
### Phase 2: Search Sources
|
||||
|
||||
|
||||
@@ -201,7 +201,7 @@ Use the brain page template. MUST include:
|
||||
|
||||
### 4b. Entity pages (people, companies)
|
||||
For each entity mentioned:
|
||||
- Check if a brain page exists (`gbrain search "<name>"` or `gbrain get_page people/<slug>`).
|
||||
- Check if a brain page exists (`gbrain search "<name>"` or `gbrain get people/<slug>`).
|
||||
- If exists: update State, append Timeline entry citing this research.
|
||||
- If not: create with enrichment.
|
||||
|
||||
|
||||
@@ -112,7 +112,7 @@ gbrain query "<topic keywords>"
|
||||
# -d '{"model": "sonar-pro", "messages": [{"role":"user","content":"..."}]}'
|
||||
|
||||
# 4. Write the structured research page via put_page:
|
||||
gbrain put_page research/<slug> # via the put_page operation
|
||||
gbrain put research/<slug> # via the put_page operation
|
||||
|
||||
# 5. Cross-link entities mentioned (people, companies) per Iron Law.
|
||||
```
|
||||
|
||||
@@ -11,7 +11,7 @@ tools:
|
||||
- gbrain schema active
|
||||
- gbrain schema use
|
||||
- gbrain schema stats
|
||||
- gbrain pages restore
|
||||
- gbrain restore
|
||||
- mcp:run_onboard
|
||||
triggers:
|
||||
- "unify my types"
|
||||
@@ -90,9 +90,13 @@ The handler is PROTECTED (manual_only per D17) — autopilot will never auto-fir
|
||||
```bash
|
||||
gbrain jobs submit unify-types \
|
||||
--allow-protected \
|
||||
--params '{"target_pack":"gbrain-base-v2"}'
|
||||
--params '{"target_pack":"gbrain-base-v2","apply":true}'
|
||||
```
|
||||
|
||||
`apply` defaults to **false** (dry-run) per the handler contract, so
|
||||
`"apply":true` is required here or the job reports success having retyped
|
||||
nothing and left the active pack unflipped. Omit it to preview.
|
||||
|
||||
Watch progress per phase:
|
||||
|
||||
```bash
|
||||
@@ -143,7 +147,7 @@ WHERE source_id = 'default' AND frontmatter->>'legacy_type' IS NOT NULL;
|
||||
Page-to-alias and page-to-link source pages soft-delete with 72h TTL. Restore within that window:
|
||||
|
||||
```bash
|
||||
gbrain pages restore <slug>
|
||||
gbrain restore <slug>
|
||||
```
|
||||
|
||||
Revert the active pack flip:
|
||||
@@ -197,7 +201,7 @@ Outputs:
|
||||
- Active pack flipped to `gbrain-base-v2` atomically at end of successful run.
|
||||
|
||||
Side effects:
|
||||
- Source pages soft-deleted with 72h restore TTL (`gbrain pages restore <slug>`).
|
||||
- Source pages soft-deleted with 72h restore TTL (`gbrain restore <slug>`).
|
||||
- One-time cache invalidation on KNOBS_HASH_VERSION bump (5→6); self-healing in `cache.ttl_seconds`.
|
||||
- Query-time `--type X` alias-expands via `expandTypeFilter` (D14 back-compat).
|
||||
|
||||
@@ -212,7 +216,7 @@ DON'T:
|
||||
- Submit `unify-types` directly via the MCP `submit_job` op without `--allow-protected`. PROTECTED handlers require trusted local callers; remote MCP rejection is the intentional trust boundary.
|
||||
- Edit `mapping_rules` in `gbrain-base-v2.yaml` to skip clusters you don't trust. Fork the pack instead (`gbrain schema fork`) so the source-of-truth migration stays consistent across brains.
|
||||
- Run `unify-types` from inside an autopilot tick. The check is `manual_only` per D17 — autopilot deliberately never auto-fires it because pack upgrades are one-time consenting taxonomy decisions.
|
||||
- Hard-delete soft-deleted source pages before the 72h restore window. Use `gbrain pages restore <slug>` first if rollback is needed.
|
||||
- Hard-delete soft-deleted source pages before the 72h restore window. Use `gbrain restore <slug>` first if rollback is needed.
|
||||
- Assume `frontmatter.legacy_type` survives every roundtrip. The marker is canonical for the immediate post-migration window; downstream re-imports may overwrite it.
|
||||
|
||||
## Output Format
|
||||
|
||||
@@ -139,9 +139,9 @@ edits writes a new receipt).
|
||||
|
||||
| Slot | Default | Provider |
|
||||
|------|---------|----------|
|
||||
| A | `openai:gpt-4o` | OpenAI |
|
||||
| A | `openai:gpt-5.2` | OpenAI |
|
||||
| B | `anthropic:claude-opus-4-7` | Anthropic |
|
||||
| C | `google:gemini-1.5-pro` | Google |
|
||||
| C | `deepseek:deepseek-v4-pro` | DeepSeek |
|
||||
|
||||
**These MUST be frontier models from DIFFERENT providers.** Using a single
|
||||
provider's family or budget models defeats the purpose — different families
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
{
|
||||
"RESOLVER.md": "ad9f85d0f953ad0ec80c70091cb31c7227a89e4a08c924454be1d61d4df5b65c",
|
||||
"_AGENT_README.md": "3dd82df125ceb87bdbc1ad16be4306fb3ca6e3a491c70122f6c7724a862f0b21",
|
||||
"_brain-filing-rules.json": "cf850df6a7425464c6d63b3ace71991cc93497fa0cc8cd21acd31883e17939c6",
|
||||
"_brain-filing-rules.md": "2d2d75b7c76081c56f41b2c0a5a978c355ce957300f9b0a5575dc4079ef1f877",
|
||||
"_friction-protocol.md": "1b6e7cfa58725a6a5dc2dc787242141bc33f5fde524540d85b14ec22266140f7",
|
||||
"_output-rules.md": "239022bd9003b4d45870fea2ddb177a132f0684d898ed5075dc5dcb90cc56ab7",
|
||||
"academic-verify/SKILL.md": "1c19e27e75249d869da428ce8d060075feef8fbbfe146af58b305d11a260ebbc",
|
||||
"academic-verify/routing-eval.jsonl": "90d894a9829d9936e6ac7a6507e4de67ad26e46a1fe13b7a34e7dec1c0d887dd",
|
||||
"archive-crawler/SKILL.md": "10c529797e1cba75da5bdb13bdd3609ae5f962f7dcc429c3b85d51c0032d9a26",
|
||||
"archive-crawler/routing-eval.jsonl": "a90c607d69737adf58771d1b986c5a7d9d11f5303df7de4c7def74aa2c86a2f5",
|
||||
"article-enrichment/SKILL.md": "fcdbce0f250aa2c38b86299dae510b8dc7f1e1cd854961cb8520c61642309549",
|
||||
"article-enrichment/routing-eval.jsonl": "408fa8cc80caf1a2208afeb049e5ada69beb378f22d6b7114828201a4657e6e3",
|
||||
"ask-user/SKILL.md": "a40f484721e548a3a14d4b33a4636111d92f99619ecc4e4ea54c3da3a15f8331",
|
||||
"book-mirror/SKILL.md": "e8b8cc7a6eba4ecd302a840446b48c0e1aecc245e8f240e233c738daa8dff78a",
|
||||
"book-mirror/routing-eval.jsonl": "79fd23642cfa37b1255a799907e71bb2904585cf79dd6a596e3a2f019787e54c",
|
||||
"brain-ops/SKILL.md": "40553ad3bf0f27fc8363b69bec3ef89ae0ea0d9290de8d1efbaa0c532c2a9590",
|
||||
"brain-pdf/SKILL.md": "13c3e3162763a4503685db0a10663475d3687c4874b5f04d539af83a990f643e",
|
||||
"brain-pdf/routing-eval.jsonl": "119e4fa113ea45783cee4499e63a729fdeecb4d9a45d47497754b4f5b21d0734",
|
||||
"brain-taxonomist/SKILL.md": "dea4557b540868ec2c56bf43ee7f63c5d03a22d4047cd0dfbeaf19adef334f60",
|
||||
"brain-taxonomist/routing-eval.jsonl": "8b485b3d735aace60be703854f0f2e9d97c52d52564efdaf7334a0c39e8d20ae",
|
||||
"briefing/SKILL.md": "a661804c3eb2ce5bd4eb6f3e7106283046913945d9dba0b967bdc8483a58dd66",
|
||||
"capture/SKILL.md": "98568ac96331f57397ea072749641d9748b1ce31e8b09d512b8db25c8fcda65f",
|
||||
"citation-fixer/SKILL.md": "abdadbf0740a529b9c4f86f05bba416417624503fdcbc6054402d5546afd08b4",
|
||||
"citation-fixer/routing-eval.jsonl": "52b23b71e66fdc18aee67d0576099b0c83997d648cf4ecf8fe7753b91b6c9c53",
|
||||
"cold-start/SKILL.md": "a2c42dd7c4eceb7d3ce6449a414b417d55195aa445e723e6c798d90906cbf4e6",
|
||||
"concept-synthesis/SKILL.md": "2bc060ae6d706c4e8e7d784cbe3e577b85e211a21c68cba094a1754d3f34436b",
|
||||
"concept-synthesis/routing-eval.jsonl": "51d1da894158503ce18b892a34edd203f40732e79ac1c0e85141fd37e0b9922f",
|
||||
"conventions/brain-first.md": "29d020470d0168f8f0b29dde0350a485a9b0472f7ac9962e34948f4897455590",
|
||||
"conventions/brain-routing.md": "a8035f7dbadff0ea68b8babb8314b3d044cafbed8242dce5b931fa08b028fc45",
|
||||
"conventions/calibration.md": "eda7ca76f80c8a17ae546110484389f805c5b21fc0a57f951bbe8b6abba26e03",
|
||||
"conventions/cron-via-minions.md": "badb1cd6cd825d6f1ac0b6b28cc47e5d80facc783a3e59a14146ae901ee0f933",
|
||||
"conventions/cross-modal.yaml": "c012c3d72614a87b1ee698173dce2a0fb0d057a54df7aab87993c4b07fff6280",
|
||||
"conventions/model-routing.md": "fb7ae8746a578500d6789b68ff40049037aa4d337b65b42f7c1745ae7080c2db",
|
||||
"conventions/quality.md": "8aa681001114689d34268ccadaf0e2ff07b8f68aa5987c093a8c4a7a744f12a6",
|
||||
"conventions/salience-and-recency.md": "62b0b303bf48bef10adcf08d3b51f23d3b1f1187b3850476a4b5532c2ee88926",
|
||||
"conventions/schema-evolution.md": "b5cdd5a17e43b4f2d0546ebf8c421cc0378cbcf74cb2fe97199e115ccb05f9c2",
|
||||
"conventions/search-modes.md": "2a920225d1c95ea978fb1c77c5170f6a598377ab86fc0024b70962d5a84d54d0",
|
||||
"conventions/subagent-routing.md": "59afd362ff0cbaf3a63586e97c53f68feb837a258a2bd43f9177af4e57d2b20e",
|
||||
"conventions/test-before-bulk.md": "5073e5b93d570445f72f3c10ed3e6ec10c1fee7574ad73c2e2695993c850eca5",
|
||||
"cron-scheduler/SKILL.md": "e3f9745c4f8e2dacba5b055f408b1ffe541b450aee6bbfe4773f7b424d90e04c",
|
||||
"cross-modal-review/SKILL.md": "685233b1afd477e96697562c502233eea22eda5db8df116b81dd2fa78f01f80c",
|
||||
"daily-task-manager/SKILL.md": "e616f74a6befffc7bb64c0e29b2d2caa37c51bc470bca96df70101c772429d83",
|
||||
"daily-task-prep/SKILL.md": "9fe89f85fae139adac25c3bdc6f23bbf64239f3738a9e679c447e686175516f0",
|
||||
"data-research/SKILL.md": "990ccec01a23d3e46c7b5abbd2650ac5507480b50f7ec4de8b466863b3b61cb6",
|
||||
"eiirp/SKILL.md": "9d42d6a5f61bba30cb47400db58927c501cd89e5cc3241b5218d7962d1a68804",
|
||||
"eiirp/routing-eval.jsonl": "416459ff68da2e5f5eb216a0c24368c8eebdf4edbbc540a28fe730ceeb4c9700",
|
||||
"enrich/SKILL.md": "9988168348f6c3391d3aeec6621f9c99c8d75d1e1bfdd6c44d48e4c1067ab775",
|
||||
"frontmatter-guard/SKILL.md": "5142ab53f5428ebc084ded78f1fb7eb4bfa386d276ee034bf4d57273256570c6",
|
||||
"frontmatter-guard/routing-eval.jsonl": "243c28b04b557bac5360318c0f47bb6f1dd56d2f720aad3763b93fc0b2ea081e",
|
||||
"functional-area-resolver/SKILL.md": "52df04bc4f8e678f931c3b2078b2126524e6d2d72676ad46b6b710d13271b46c",
|
||||
"functional-area-resolver/routing-eval.jsonl": "f80674d915acdfe229046737a5b171da834be15ac6524b5a3fd18048e9b37028",
|
||||
"gbrain-advisor/SKILL.md": "c15c7a88bee2c96733d718a168dd9afcb123b7b6b2c0014c5260d37c72e8736a",
|
||||
"gbrain-upgrade/SKILL.md": "7cd05f43027fa20d56ed651f696288f9e4814b7bdf7b396f132fde7c96dac620",
|
||||
"idea-ingest/SKILL.md": "e118bc32d5044a4a6fba4fed1911b8be50210ade14957e6cfe8e7ed26ff95298",
|
||||
"idea-lineage/SKILL.md": "bbf37781d93b71ddc7909ecc5ab635872c874fb8591995dbf88b45ffeac6b1de",
|
||||
"idea-lineage/routing-eval.jsonl": "ee2e00704b9accb7dd58bb8f126a3bc04a2c40be499180fa505dbf6d5061cd41",
|
||||
"ingest/SKILL.md": "dc40ecc0072806fb8c7bb6ab9cf1f103842e05653eb55d67632d7e3ffc4dd7d2",
|
||||
"install/SKILL.md": "881bd0a422f34c6df4642aae66c51e2a4cc18ad5ca6d0b52d44b4de93512a3c4",
|
||||
"maintain/SKILL.md": "e80cf5bb170c979b773a67a18e0d880c086e0b983db9a576a207074b0112c4f8",
|
||||
"manifest.json": "52b970cfc3ed340ee4f25323b6fadeb5d125741b02d1d2dc2f85c39f0c701258",
|
||||
"media-ingest/SKILL.md": "33db12830ed31a4ff4a6a58c4f126bf2596ee28d54cee0027680c83fee648a20",
|
||||
"meeting-ingestion/SKILL.md": "7767334c63ff3bd8e60cd4d7cd1d1b44f0d6b0a7e0ac411529a1d59cc0a7781b",
|
||||
"migrate/SKILL.md": "442c117cfe50026a142ff4f934e489c56ee226d454b639d20bba3b111a86d8fd",
|
||||
"migrations/.gitkeep": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"migrations/v0.10.3.md": "1e12db6fdb88d21df479659e236bad111a041a205d47aa0894afde2f04903124",
|
||||
"migrations/v0.11.0.md": "ac45c6144eeee5033f2b24308a5063ceced2be7831b04e48154aa0b6f8ff55d8",
|
||||
"migrations/v0.12.0.md": "f30b452caed5ce2679cbd60dc2d0b9e60449bab20c3a3e0c895e6cd167644f44",
|
||||
"migrations/v0.12.1.md": "a461d8fcd6b19dabe3a0ce849a54e3c577667ae2af41870c6f456c6195e76064",
|
||||
"migrations/v0.13.0.md": "3bf61eb1da933e6c3c35d67d0ea6d9dada5d5f878bbd14020616d4e1fe48feb2",
|
||||
"migrations/v0.14.0.md": "1025395f033b91480451aad6a98dc10f2c18bfacd1b1a4e2a9fb4dc99f8fb571",
|
||||
"migrations/v0.15.2.md": "468a58e8a9f0f5c5867e0ed8eb9dd1bd60dbc8d66111647997ebd8d9d2119915",
|
||||
"migrations/v0.17.0.md": "761dda3b6090bfe20c9b4354aff5248d5a9b6a79a24702ecc2b29873d4f5b413",
|
||||
"migrations/v0.18.0.md": "94efc1d571070600a78aca13c2735866a987e6a8f188210b7062f81b54839986",
|
||||
"migrations/v0.19.0.md": "c47584e8306680f21ddc7f21a4a89f2184da522acb177f0dd73a09f1cd77ae57",
|
||||
"migrations/v0.21.0.md": "83569ce6fb2843f81964833affe5eae72c94a2983eba265c836aa05be7876957",
|
||||
"migrations/v0.22.14.md": "8898c109716fbc48c058a44c606f50fb7c02be599541d367380aa2e63bffea9e",
|
||||
"migrations/v0.22.4.md": "5f9b2ed86cb64d92416448e744ca6c4984b89728895cd4e363f9c76a860074d9",
|
||||
"migrations/v0.23.0.md": "f0363274e5071fa35d0c60e3981c4caee0494c43adb254d65acf6b743d4762d5",
|
||||
"migrations/v0.25.1.md": "981ef49abadf19a891cb68b655b9f35ee67d9f4ac232ceb29d83dfc3b1c9982e",
|
||||
"migrations/v0.27.1.md": "719dd4ebcc7ac81413ee79af20f80de5141577e3860e5049a49fc8f6d09ddf89",
|
||||
"migrations/v0.28.0.md": "21929a392af2e1f454272e80aade6c58c5f6ccd7253d24344d08c6b53bcdf4f9",
|
||||
"migrations/v0.29.1.md": "7b85373a62a8500ee8c4238ffc9878318b44ee62543adb8b2462049c6c0cd1f8",
|
||||
"migrations/v0.32.2.md": "101623fa48f946242d7edc8f126967822563c3fb30a6ad50d029894f5fe3499d",
|
||||
"migrations/v0.32.6.md": "e9438a910afe8a1f7861c5222fd7db04a0f8d8b8283778476b07b7c3d539d3f0",
|
||||
"migrations/v0.33.0.md": "11710cb11d6eb7dc3ea54b764e3c4a25f8679cf76590acd330f97bfa1c684945",
|
||||
"migrations/v0.33.3.0.md": "188a03ca86a97a9aa697cbbc83cc8ca37843fab24db2bd82f1383c400173d5bd",
|
||||
"migrations/v0.34.0.0.md": "d421c5ecff0765ac1de3592d3175734db7df52e8658ec101567779c7c56c2db2",
|
||||
"migrations/v0.35.0.0.md": "0fc21dc0b098f87fff1ac79a669b00a3d69ab1510ebfc5eac4a66f5c6d783809",
|
||||
"migrations/v0.35.7.0.md": "c6d4454bd39e2aa243b3b3d9bc72fe5a4fd25d097be7be2bb14b25604b5c2cc5",
|
||||
"migrations/v0.36.2.0.md": "1b59328240ae19c5e7e8d3eafda245809cca1fea27146607334dbee53cbeb270",
|
||||
"migrations/v0.36.5.0.md": "a01a722202dfc3c799693596750c8bee611fe4dafe3cb662f6b4cd0b635cb429",
|
||||
"migrations/v0.40.3.0.md": "5f500f8c543c2b6f41778b0bd3beedada68f7284f7933ad8b769322b433a8fe9",
|
||||
"migrations/v0.40.5.md": "b9837d52a030517698dfb31c439f562cde60a1015ae488dab09be2c16ff182e5",
|
||||
"migrations/v0.41.11.0.md": "5c6873ab969d14def4a450d792f070f1259d08b3aca43bc7825d0a9114b2b36b",
|
||||
"migrations/v0.5.0.md": "5e0dabc451595295c4d971e19bcb33c258a127223d25859d8321cb7e1ce60711",
|
||||
"migrations/v0.7.0.md": "97c2740445a10b1c5c7123c17dbd625fa27a94095b85d27c2b278da756c4c59a",
|
||||
"migrations/v0.8.0.md": "1919ff8b8f3680612ff888e7cfcc0d86ece5d5304ae19af4497bdf40b050561a",
|
||||
"migrations/v0.8.1.md": "fad7341cfb5e02545fb8a23221d12ab395fc3d8db15d1d8ee8a18844aea6563a",
|
||||
"migrations/v0.9.0.md": "773fab0a8d7f330576265a3f510c1f318f47789b6136c46d43e08121acbc20eb",
|
||||
"migrations/v0.9.1.md": "75761bad6c0ad37b69ec8197c6a678bb6a1484f9a76e4b70f2d1e86dc80102b3",
|
||||
"minion-orchestrator/SKILL.md": "669e23f485561cf6fef16d445dfb8a6d05f76127d0ebf9feb17534af8843df5f",
|
||||
"perplexity-research/SKILL.md": "c25f5c471cbe3c6e0f975d8397e8382b00a85f8aa75302231d53c52855369e97",
|
||||
"perplexity-research/routing-eval.jsonl": "f1a40d87e710d5d2acd602a372d83f46c95da022b6e635228fffeaacb3bb2b27",
|
||||
"publish/SKILL.md": "e06b609db780a3cc93a1755a87b30ff08ffdc0fdbc834c1422b2ad2489b57497",
|
||||
"query/SKILL.md": "e155a08049984c524b838988ba456d16ccedf162442160f6ba66bfd97cd5208e",
|
||||
"query/routing-eval.jsonl": "74f5a91e52fabc54e0e9403fa17db87ee26bb7ebb8ae8005148c51142abc62fe",
|
||||
"repo-architecture/SKILL.md": "4ec2b8f45d168aaa55f17ecd1ed404ab04217a75c2317f0710c71705846f5394",
|
||||
"reports/SKILL.md": "5dc190a0c3a2ee518254e8b596418dbe19ff389ea5ec8c8d30fcb0dfef4d0ed5",
|
||||
"schema-author/SKILL.md": "4da9a472c966f8e4fb43d97a3a3608c67ec3d43da6b8c626ee0c30a4e70da26f",
|
||||
"schema-unify/SKILL.md": "e1d50a54a6ff29434d38a841a572e815d236a8167d459609ae697e548770f500",
|
||||
"setup/SKILL.md": "4a47a5f6ee99ac8a2649a304abb261fd34810cf6185fd7d6a53e1b6c4cbc0764",
|
||||
"signal-detector/SKILL.md": "64e4547f5a8624c53d875001b423d240ec73ee9fd026a96c7b799d287c5fb6e4",
|
||||
"skill-creator/SKILL.md": "4a11f8935d4214b21b4664a5c0c03149733731020ec0d5d09dc9fd8c40bd92f6",
|
||||
"skill-optimizer/SKILL.md": "ba3028c7351dec3e644a7114e08e59cae7c60dc367dc4fd10560265a162baa90",
|
||||
"skill-optimizer/routing-eval.jsonl": "48f7fc04414b194ee8674577c3e58e03ebd7f74d836766bd16cb5abfd4effb76",
|
||||
"skill-optimizer/skillopt-benchmark.jsonl": "5552457d6eaa32486b79796d12fcbe2c078b0c53d7fbdaf582f0fd1a17e9a381",
|
||||
"skillify/SKILL.md": "a154e43409458136e1e1cea084aa973e4cf46cd8450c6be8e9891a4a3ddf6146",
|
||||
"skillpack-check/SKILL.md": "3f347ec8b498530a662be212d05f4cd06b205bce5795c2231b6a4cecef149ea0",
|
||||
"skillpack-harvest/SKILL.md": "3c4c591b33f03a5ccf11ca0ddde56b54fba541efef0d590b6d435687733182d7",
|
||||
"skillpack-harvest/routing-eval.jsonl": "cb4783288e95af3132b32ecb40a54cffc095f57b36240f96a25c2d2adf5e68c6",
|
||||
"smoke-test/SKILL.md": "f2f2172d41e63e288095451132a0c56848ccc34101d265b330b6cf00e5769f5d",
|
||||
"soul-audit/SKILL.md": "7f162dddcc511e97a24db3a46136295fcbda76023019ad8994546744d7b8eb0a",
|
||||
"strategic-reading/SKILL.md": "5be656c39c830153ec7c2f328dc8bdeac05c1412b01b3415de6b5b008926e7a2",
|
||||
"strategic-reading/routing-eval.jsonl": "eb0fc239c93aac53cf7d856190967bb65eaf970b4fe8bd0aad1c87495fbb8ccd",
|
||||
"testing/SKILL.md": "f1846ba7c35076d910744a6b867c9ee850c1895f75a104a319c9b7d18b1d5e90",
|
||||
"voice-note-ingest/SKILL.md": "145c02e636430abba5648e026aef77606d6ba2594285fbfee4f4dd4253cf833a",
|
||||
"voice-note-ingest/routing-eval.jsonl": "374aaec16fbde336d1e376edce89e51adc4ecaa93c13fb4a69b967ba299b8742",
|
||||
"webhook-transforms/SKILL.md": "b774293297af4d513c7efa92a79cf65b8438a714adafc16802b492613e679d17"
|
||||
}
|
||||
@@ -43,8 +43,9 @@ The Analysis section can interpret; the transcript section is sacred.
|
||||
|
||||
The user sends an audio or voice message via any channel (Telegram, voice
|
||||
memo upload, openclaw audio attachment). The host agent typically provides
|
||||
the transcript text. If not, transcribe via `gbrain transcription` (Groq
|
||||
Whisper by default; OpenAI fallback for audio > 25MB segmented via ffmpeg).
|
||||
the transcript text. If not, transcribe it with your host's transcription
|
||||
tool (Groq Whisper is fast and cheap; OpenAI Whisper works too — segment
|
||||
audio > 25MB via ffmpeg first).
|
||||
|
||||
## The pipeline
|
||||
|
||||
@@ -52,8 +53,9 @@ Whisper by default; OpenAI fallback for audio > 25MB segmented via ffmpeg).
|
||||
1. STORE → Upload original audio to gbrain storage backend
|
||||
(S3 / Supabase Storage / local — pluggable per
|
||||
src/core/storage.ts).
|
||||
2. TRANSCRIBE → Use the agent-provided transcript verbatim, OR call
|
||||
gbrain transcription if no transcript was supplied.
|
||||
2. TRANSCRIBE → Use the agent-provided transcript verbatim, OR
|
||||
transcribe the audio yourself (see "When to invoke")
|
||||
if no transcript was supplied.
|
||||
3. ROUTE → Apply the decision tree (below) to find the right
|
||||
destination directory.
|
||||
4. WRITE → Create / update the destination brain page; preserve the
|
||||
|
||||
+480
-34
@@ -9,7 +9,7 @@ installSigchldHandler();
|
||||
import { installSignalHandlers as installCleanupSignalHandlers } from './core/process-cleanup.ts';
|
||||
installCleanupSignalHandlers();
|
||||
|
||||
import { readFileSync, existsSync, unlinkSync } from 'fs';
|
||||
import { readFileSync, existsSync, unlinkSync, fstatSync } from 'fs';
|
||||
import { spawn } from 'child_process';
|
||||
import {
|
||||
readUpdateCache,
|
||||
@@ -33,6 +33,7 @@ import { parseGlobalFlags, setCliOptions, getCliOptions } from './core/cli-optio
|
||||
import type { CliOptions } from './core/cli-options.ts';
|
||||
import { callRemoteTool, RemoteMcpError, unpackToolResult } from './core/mcp-client.ts';
|
||||
import { maybePromptForUpgrade } from './core/thin-client-upgrade-prompt.ts';
|
||||
import { CLI_FLAG_REGISTRY } from './core/cli-flag-registry.generated.ts';
|
||||
import { VERSION } from './version.ts';
|
||||
|
||||
// Build CLI name -> operation lookup
|
||||
@@ -54,13 +55,29 @@ export function bigintToStringReplacer(_key: string, value: unknown): unknown {
|
||||
return typeof value === 'bigint' ? value.toString() : value;
|
||||
}
|
||||
|
||||
// ENG-2 renderer parity: round-trip a local-engine op's return value so
|
||||
// renderers see the same shape the routed path produces. Bigint-safe via
|
||||
// bigintToStringReplacer. Exported for tests (same import-safety contract as
|
||||
// cliAliases/formatResult). (#2450)
|
||||
export function normalizeLocalResult(rawResult: unknown): unknown {
|
||||
return JSON.parse(JSON.stringify(rawResult, bigintToStringReplacer));
|
||||
}
|
||||
|
||||
// CLI-only commands that bypass the operation layer
|
||||
export const CLI_ONLY = new Set(['init', 'reinit-pglite', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', 'extract-conversation-facts', 'enrich', 'features', 'autopilot', 'graph-query', 'jobs', 'agent', 'apply-migrations', 'skillpack-check', 'skillpack', 'resolvers', 'integrity', 'repair-jsonb', 'orphans', 'maintain', 'sources', 'mounts', 'dream', 'check-resolvable', 'routing-eval', 'skillify', 'smoke-test', 'providers', 'storage', 'repos', 'code-def', 'code-refs', 'reindex', 'reindex-code', 'reindex-frontmatter', 'code-callers', 'code-callees', 'reconcile-links', 'frontmatter', 'auth', 'friction', 'claw-test', 'book-mirror', 'takes', 'think', 'salience', 'anomalies', 'calibration', 'transcripts', 'models', 'remote', 'recall', 'forget', 'edges-backfill', 'cache', 'ze-switch', 'founder', 'brainstorm', 'lsd', 'schema', 'capture', 'onboard', 'conversation-parser', 'status', 'connect', 'skillopt', 'quarantine', 'self-upgrade', 'advisor', 'watch', 'reindex-search-vector']);
|
||||
export const CLI_ONLY = new Set(['init', 'reinit-pglite', 'pglite-repair', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', 'extract-conversation-facts', 'enrich', 'features', 'autopilot', 'graph-query', 'jobs', 'agent', 'apply-migrations', 'skillpack-check', 'skillpack', 'resolvers', 'integrity', 'repair-jsonb', 'orphans', 'maintain', 'sources', 'mounts', 'dream', 'check-resolvable', 'routing-eval', 'skillify', 'smoke-test', 'providers', 'storage', 'repos', 'code-def', 'code-refs', 'reindex', 'reindex-code', 'reindex-frontmatter', 'code-callers', 'code-callees', 'reconcile-links', 'frontmatter', 'auth', 'friction', 'claw-test', 'book-mirror', 'takes', 'think', 'salience', 'anomalies', 'calibration', 'transcripts', 'models', 'remote', 'recall', 'forget', 'edges-backfill', 'cache', 'ze-switch', 'retrieval-upgrade', 'founder', 'brainstorm', 'lsd', 'schema', 'capture', 'onboard', 'conversation-parser', 'status', 'connect', 'skillopt', 'quarantine', 'self-upgrade', 'advisor', 'watch', 'reindex-search-vector', 'pages', 'bench', 'backfill',
|
||||
// v0.42.58 (#2035 class, caught by the handleCliOnly reachability sweep):
|
||||
// full handler at `case 'notability-eval'` but never dispatchable.
|
||||
'notability-eval']);
|
||||
// CLI-only commands whose handlers print their own --help text. These are
|
||||
// excluded from the generic short-circuit so detailed per-command and
|
||||
// per-subcommand usage stays reachable.
|
||||
const CLI_ONLY_SELF_HELP = new Set([
|
||||
'upgrade', 'post-upgrade', 'check-update',
|
||||
// #3502 sweep: pages + bench print their own usage (pages.ts printHelp,
|
||||
// bench-publish.ts printHelp). Both were documented but undispatchable —
|
||||
// `pages` had a live handleCliOnly case but was missing from CLI_ONLY
|
||||
// (the #2035 calibration bug class); `bench` was never wired at all.
|
||||
'pages', 'bench',
|
||||
'embed', 'config',
|
||||
'skillpack', 'skillpack-check',
|
||||
'integrations', 'friction',
|
||||
@@ -88,11 +105,17 @@ const CLI_ONLY_SELF_HELP = new Set([
|
||||
// short-circuit fired before runSync could print its own usage block.
|
||||
// Adding `sync` here routes `gbrain sync --help` into runSync.
|
||||
'sync',
|
||||
// #3834: extract ships detailed help for its mode-specific flags. Keep the
|
||||
// generic CLI-only stub from hiding that contract.
|
||||
'extract',
|
||||
// v0.37 fix wave (deferred TODO, shipped): reinit-pglite has its
|
||||
// own --help in runReinitPglite. Routing through SELF_HELP avoids
|
||||
// the generic short-circuit so the destructive-action warning text
|
||||
// reaches the user.
|
||||
'reinit-pglite',
|
||||
// WAL-repair wave: pglite-repair ships its own --help with the
|
||||
// dry-run/repair semantics + the un-checkpointed-tail caveat.
|
||||
'pglite-repair',
|
||||
// v0.40.6.0 Schema Cathedral v3 — `gbrain schema --help` should hit
|
||||
// schema.ts printHelp() with the full 22+ verb taxonomy, not the
|
||||
// generic short-circuit's one-line stub.
|
||||
@@ -107,6 +130,15 @@ const CLI_ONLY_SELF_HELP = new Set([
|
||||
// `gbrain connect --help` prints its own usage (flags + examples) from
|
||||
// runConnect; route around the generic one-line short-circuit.
|
||||
'connect',
|
||||
// `gbrain init --help` prints its own usage from runInit; route around the
|
||||
// generic one-line short-circuit (matches `connect`). Without this, `init`
|
||||
// is in CLI_ONLY but not CLI_ONLY_SELF_HELP, so the dispatcher's generic
|
||||
// short-circuit fires and the printInitHelp() guard in init.ts is dead code.
|
||||
'init',
|
||||
// #3390 — `gbrain migrate embeddings --help` / `gbrain retrieval-upgrade
|
||||
// --help` print the migration flags from runMigrateEmbeddings. `migrate`
|
||||
// (engine transfer) keeps its own dispatch too.
|
||||
'migrate', 'retrieval-upgrade',
|
||||
]);
|
||||
|
||||
// v114 (#1941): alias -> operation lookup, kept separate from `cliOps` so
|
||||
@@ -308,6 +340,30 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
// #2185: strict unknown-flag validation — pre-dispatch, pre-engine. A flag
|
||||
// no handler consults (the repro: `init --migrate-only --dry-run` applying
|
||||
// REAL migrations while the user asked for a rehearsal) fails loud here
|
||||
// instead of silently doing the destructive thing. Runs after the --help
|
||||
// short-circuit so `gbrain x --help` never errors; runs before any dispatch
|
||||
// or engine connect so the error is instant and side-effect-free.
|
||||
{
|
||||
const unknown = validateCommandFlags(command, subArgs);
|
||||
if (unknown) {
|
||||
// Message contract shared with init.ts's in-handler check (which this
|
||||
// pre-dispatch validator now reaches first): lowercase 'unknown flag'
|
||||
// on stderr; --json callers get the structured error on stdout with
|
||||
// reason 'invalid_flag' (pinned by test/init-migrate-only.test.ts).
|
||||
const message = `unknown flag ${unknown} for 'gbrain ${command}'`;
|
||||
// Both --json spellings get the structured envelope (--json=false opts out).
|
||||
if (subArgs.some(a => a === '--json' || (a.startsWith('--json=') && a !== '--json=false'))) {
|
||||
process.stdout.write(JSON.stringify({ status: 'error', reason: 'invalid_flag', message }) + '\n');
|
||||
}
|
||||
console.error(`gbrain ${command}: ${message}`);
|
||||
console.error(`Run: gbrain ${command} --help`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// DB-free durability pull (v0.42.44 D2): the harden cron calls
|
||||
// `gbrain sources pull --path <dir>` every ~30 min. It must NOT open PGLite
|
||||
// (a live long-lived session holds the single-writer lock), so handle it
|
||||
@@ -340,6 +396,11 @@ async function main() {
|
||||
// them out of the engine try/catch is safe and unlocks routing.
|
||||
const params = parseOpArgs(op, subArgs);
|
||||
|
||||
// #3513: stdin fill moved out of parseOpArgs so a non-TTY stdin with no
|
||||
// piped input can't block the parse forever — the bounded read leaves the
|
||||
// param unset on timeout and the required-param check below fails fast.
|
||||
await applyStdinParam(op, params);
|
||||
|
||||
// v0.27.1 (`gbrain query --image <path>`): swap the `image` param from
|
||||
// a filesystem path into base64 bytes + mime. The op accepts base64; the
|
||||
// CLI accepts a path. Helper is exported so tests can exercise the
|
||||
@@ -385,6 +446,18 @@ async function main() {
|
||||
if (op.localOnly) {
|
||||
refuseThinClient(command, cfgPre!.remote_mcp!.mcp_url);
|
||||
}
|
||||
// A thin client has no local mounts — an explicit --brain cannot be
|
||||
// honored and must not be silently dropped (same loud-beats-silent rule
|
||||
// as applyThinClientSourceScope's --source refusal). Ambient tiers
|
||||
// (GBRAIN_BRAIN_ID / .gbrain-mount) are ignored here, matching the
|
||||
// source axis's ambient-with-nowhere-to-send behavior.
|
||||
if (cliOpts.brain) {
|
||||
console.error(
|
||||
'--brain is not supported on a thin-client install: the remote server is a single brain. ' +
|
||||
'Remove the flag, or run from a machine with local mounts (gbrain mounts list).',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
// #2098: the local path resolves --source / GBRAIN_SOURCE / .gbrain-source
|
||||
// inside makeContext (ctx.sourceId), which this route never reaches — so
|
||||
// scope must be mapped onto the op's source_id wire param before the call.
|
||||
@@ -475,8 +548,8 @@ async function main() {
|
||||
// path's return value so renderers see the same shape they'd see on the
|
||||
// routed path. Date → ISO string; bigint → string (postgres.js shape);
|
||||
// Buffer → object. Microsecond-cost; eliminates a whole drift bug class.
|
||||
const result = JSON.parse(JSON.stringify(rawResult, bigintToStringReplacer));
|
||||
const output = formatResult(op.name, result);
|
||||
const result = normalizeLocalResult(rawResult);
|
||||
const output = formatResult(op.name, result, params);
|
||||
if (output) process.stdout.write(output);
|
||||
} catch (e: unknown) {
|
||||
// v0.42.20.0 (codex D4): on error, set exitCode + return so the `finally`
|
||||
@@ -559,7 +632,7 @@ async function runThinClientRouted(
|
||||
signal: sigintController.signal,
|
||||
});
|
||||
const result = unpackToolResult(raw);
|
||||
const output = formatResult(op.name, result);
|
||||
const output = formatResult(op.name, result, params);
|
||||
if (output) process.stdout.write(output);
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof RemoteMcpError) {
|
||||
@@ -777,6 +850,29 @@ export function parseOpArgs(op: Operation, args: string[]): Record<string, unkno
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (arg.startsWith('--')) {
|
||||
// #2185: `--key=value` inline form. Pre-fix this parsed as junk key
|
||||
// 'key=value' and consumed the NEXT token as its value, corrupting
|
||||
// positional parsing. Recognized here so the strict-flag validator and
|
||||
// the parser agree on the idiom.
|
||||
const eq = arg.indexOf('=');
|
||||
if (eq > 2) {
|
||||
const key = arg.slice(2, eq).replace(/-/g, '_');
|
||||
// CLI-local booleans: `--json=<v>` / `--dry-run=<v>` must parse as
|
||||
// booleans, not fall through to the junk-key path (which would
|
||||
// consume the NEXT token as a value and corrupt positional parsing).
|
||||
if (key === 'json' || key === 'dry_run') {
|
||||
params[key] = arg.slice(eq + 1) !== 'false';
|
||||
continue;
|
||||
}
|
||||
const def = op.params[key];
|
||||
if (def) {
|
||||
const raw = arg.slice(eq + 1);
|
||||
params[key] = def.type === 'boolean' ? raw !== 'false'
|
||||
: def.type === 'number' ? Number(raw)
|
||||
: raw;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (arg.startsWith('--no-')) {
|
||||
const positiveKey = arg.slice(5).replace(/-/g, '_');
|
||||
const positiveDef = op.params[positiveKey];
|
||||
@@ -789,6 +885,14 @@ export function parseOpArgs(op: Operation, args: string[]): Record<string, unkno
|
||||
const paramDef = op.params[key];
|
||||
if (paramDef?.type === 'boolean') {
|
||||
params[key] = true;
|
||||
} else if (key === 'json' || key === 'dry_run') {
|
||||
// CLI-local booleans, intentionally NOT on the operation contract
|
||||
// exposed over MCP/tools: json is the formatter flag; dry_run feeds
|
||||
// makeContext's ctx.dryRun. Both must never consume a value token —
|
||||
// pre-fix, `gbrain delete x --dry-run` (trailing) set NOTHING, so
|
||||
// ctx.dryRun stayed false and the REAL delete ran despite the
|
||||
// rehearsal request (the resurrected #2185 class the red team caught).
|
||||
params[key] = true;
|
||||
} else if (i + 1 < args.length) {
|
||||
params[key] = args[++i];
|
||||
if (paramDef?.type === 'number') params[key] = Number(params[key]);
|
||||
@@ -800,18 +904,99 @@ export function parseOpArgs(op: Operation, args: string[]): Record<string, unkno
|
||||
}
|
||||
}
|
||||
|
||||
// Read stdin for content params
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* #3513: read stdin into an op's stdin-capable param without ever blocking
|
||||
* forever. The old inline `readFileSync(0)` in parseOpArgs assumed non-TTY
|
||||
* implies piped content; a non-TTY stdin with NO input (CI step, cron job,
|
||||
* agent harness holding an unwritten pipe open) blocked the read until kill.
|
||||
*
|
||||
* Strategy by fd kind (fstat):
|
||||
* - TTY: skip, as before (interactive input is not an op-param source).
|
||||
* - regular file / /dev/null / anything not a pipe or socket: readFileSync
|
||||
* returns without blocking (`gbrain put x < file`, `< /dev/null` → '').
|
||||
* - FIFO/socket: stream-read with a deadline on the FIRST byte only. A real
|
||||
* pipe (`echo foo | gbrain put x`, heredocs) delivers its first byte
|
||||
* within milliseconds; once any data arrives the deadline is lifted and
|
||||
* we read to EOF like readFileSync did (slow producers stay supported).
|
||||
* An empty-but-closed pipe (`: | gbrain put x`) EOFs immediately → ''.
|
||||
* A pipe that never delivers a byte times out → param stays unset, so
|
||||
* the existing required-param usage error fires (fail fast, exit 1).
|
||||
*
|
||||
* GBRAIN_STDIN_TIMEOUT_MS overrides the first-byte deadline (default 5000).
|
||||
* Exported for tests; called by the op dispatch right after parseOpArgs.
|
||||
*/
|
||||
export async function applyStdinParam(
|
||||
op: Operation,
|
||||
params: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
// Branch shape (stdin hint + missing param + `!process.stdin.isTTY` gate +
|
||||
// 5MB cap) is pinned by the R4 regression test for PR #1325's Windows fix
|
||||
// (test/cycle/regression-pr-wave-r1-r2-r4.test.ts) — keep the spelling.
|
||||
if (op.cliHints?.stdin && !params[op.cliHints.stdin] && !process.stdin.isTTY) {
|
||||
const stdinContent = readFileSync(0, 'utf-8');
|
||||
const content = await readStdinBounded();
|
||||
if (content === null) return; // no input arrived — let the required-param check fail fast
|
||||
const MAX_STDIN = 5_000_000; // 5MB
|
||||
if (Buffer.byteLength(stdinContent, 'utf-8') > MAX_STDIN) {
|
||||
if (Buffer.byteLength(content, 'utf-8') > MAX_STDIN) {
|
||||
console.error(`Error: stdin content exceeds ${MAX_STDIN} bytes. Split into smaller inputs.`);
|
||||
process.exit(1);
|
||||
}
|
||||
params[op.cliHints.stdin] = stdinContent;
|
||||
params[op.cliHints.stdin] = content;
|
||||
}
|
||||
}
|
||||
|
||||
return params;
|
||||
/** First-byte deadline for pipe/socket stdin (#3513). Env-overridable escape hatch. */
|
||||
function stdinFirstByteTimeoutMs(): number {
|
||||
const n = Number(process.env.GBRAIN_STDIN_TIMEOUT_MS);
|
||||
return Number.isFinite(n) && n > 0 ? n : 5000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the full stdin content, '' for a readable-but-empty stdin, or
|
||||
* null when stdin is a pipe/socket that never delivered a byte within the
|
||||
* first-byte deadline (or the fd is closed/unreadable).
|
||||
*/
|
||||
export async function readStdinBounded(): Promise<string | null> {
|
||||
let isPipeOrSocket: boolean;
|
||||
try {
|
||||
const st = fstatSync(0);
|
||||
isPipeOrSocket = st.isFIFO() || st.isSocket();
|
||||
} catch {
|
||||
return null; // closed/invalid fd — treat as no input
|
||||
}
|
||||
if (!isPipeOrSocket) {
|
||||
// Regular file redirect, /dev/null, etc. — read returns without blocking.
|
||||
try {
|
||||
return readFileSync(0, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return await new Promise<string | null>((resolve) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let gotData = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (!gotData) {
|
||||
process.stdin.destroy();
|
||||
resolve(null);
|
||||
}
|
||||
}, stdinFirstByteTimeoutMs());
|
||||
const finish = () => {
|
||||
clearTimeout(timer);
|
||||
resolve(Buffer.concat(chunks).toString('utf-8'));
|
||||
};
|
||||
process.stdin.on('data', (c: Buffer) => {
|
||||
if (!gotData) {
|
||||
gotData = true;
|
||||
clearTimeout(timer); // deadline applies to the FIRST byte only
|
||||
}
|
||||
chunks.push(c);
|
||||
});
|
||||
process.stdin.once('end', finish);
|
||||
process.stdin.once('error', finish);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -868,7 +1053,114 @@ export function applyThinClientSourceScope(
|
||||
params.source_id = resolved;
|
||||
}
|
||||
|
||||
async function makeContext(engine: BrainEngine, params: Record<string, unknown>): Promise<OperationContext> {
|
||||
// Exported for tests (same import-safety contract as applyThinClientSourceScope).
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// #2185 — strict unknown-flag validation (pre-dispatch, pre-engine).
|
||||
// A flag no handler consults must fail loud instead of silently doing the
|
||||
// destructive thing (`init --migrate-only --dry-run` applied REAL migrations
|
||||
// while the user asked for a rehearsal). Two lanes:
|
||||
// - op commands: legal flags derive from the operation contract
|
||||
// (op.params) + the CLI-local formatter flags, mirroring parseOpArgs's
|
||||
// traversal so values that begin with '--' are never misread.
|
||||
// - CLI_ONLY commands: legal flags come from the generated
|
||||
// CLI_FLAG_REGISTRY (scripts/generate-flag-registry.ts scans each
|
||||
// command's source; freshness + coverage pinned by
|
||||
// test/cli-flag-validation.test.ts).
|
||||
// Everything after a literal `--` is passthrough and never validated.
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// Exempt by contract, not oversight:
|
||||
// - call: the generic op invoker — arbitrary --param names are its interface.
|
||||
// - config: `config set <key> <value>` values are arbitrary strings.
|
||||
// - jobs submit: job payloads carry handler-defined params (shell lane incl.).
|
||||
function flagValidationExempt(command: string, subArgs: string[]): boolean {
|
||||
return command === 'call' || command === 'config'
|
||||
|| (command === 'jobs' && subArgs[0] === 'submit');
|
||||
}
|
||||
|
||||
/** Returns the first unknown flag (e.g. '--dry-run') or null when clean. */
|
||||
export function validateCommandFlags(command: string, subArgs: string[]): string | null {
|
||||
if (flagValidationExempt(command, subArgs)) return null;
|
||||
// Lane order MUST mirror dispatch order (CLI_ONLY first): commands that are
|
||||
// BOTH an op and a CLI_ONLY member (think, salience, anomalies) dispatch to
|
||||
// handleCliOnly, whose handlers parse flags the op contract doesn't declare
|
||||
// (`salience --kind`, `think --with-calibration`) — validating those
|
||||
// against op.params rejected documented invocations.
|
||||
if (CLI_ONLY.has(command)) {
|
||||
const legal = CLI_FLAG_REGISTRY[command];
|
||||
// Registry drift fails OPEN at runtime (never brick a command); the
|
||||
// drift-guard test fails the build instead.
|
||||
if (!legal) return null;
|
||||
return findUnknownFlag(subArgs, new Set(legal));
|
||||
}
|
||||
const op = cliOps.get(command) ?? cliAliases.get(command);
|
||||
if (op) return findUnknownOpFlag(op, subArgs);
|
||||
return null; // unknown command — the dispatcher's own error handles it
|
||||
}
|
||||
|
||||
/** CLI_ONLY lane: token scan against the generated legal set. */
|
||||
export function findUnknownFlag(args: string[], legal: ReadonlySet<string>): string | null {
|
||||
for (const a of args) {
|
||||
if (a === '--') break;
|
||||
const m = /^--([a-z0-9][a-z0-9-]*)(?:=.*)?$/i.exec(a);
|
||||
if (!m) continue;
|
||||
// Casing typo = unknown flag: every handler in the repo is
|
||||
// case-sensitive-lowercase, so `--MIGRATE-ONLY` passing validation would
|
||||
// just be silently ignored downstream — the exact class this validator
|
||||
// exists to kill.
|
||||
if (/[A-Z]/.test(m[1])) return `--${m[1]}`;
|
||||
const name = `--${m[1]}`;
|
||||
if (legal.has(name)) continue;
|
||||
// --no-<flag> negation of a known flag is legal.
|
||||
if (name.startsWith('--no-') && legal.has(`--${name.slice(5)}`)) continue;
|
||||
return name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Op lane: mirrors parseOpArgs so flag VALUES starting with '--' are skipped. */
|
||||
export function findUnknownOpFlag(op: Operation, args: string[]): string | null {
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
if (a === '--') break;
|
||||
const m = /^--([a-z0-9][a-z0-9-]*)(?:=(.*))?$/i.exec(a);
|
||||
if (!m) continue;
|
||||
// Casing typo = unknown flag (see findUnknownFlag).
|
||||
if (/[A-Z]/.test(m[1])) return `--${m[1]}`;
|
||||
const rawKey = m[1];
|
||||
// CLI-local flags consumed OUTSIDE the op contract (never wire params):
|
||||
// json/explain — formatter flags; help — short-circuits pre-dispatch;
|
||||
// source — makeContext's 6-tier source resolution (deleted before wire);
|
||||
// dry-run — makeContext's ctx.dryRun projection.
|
||||
// Pre-fix, rejecting these broke documented invocations
|
||||
// (`gbrain search "x" --source y`, `gbrain put x --dry-run`).
|
||||
if (rawKey === 'json') continue;
|
||||
if ((rawKey === 'explain' || rawKey === 'help') && m[2] === undefined) continue;
|
||||
if (rawKey === 'source' || rawKey === 'dry-run') {
|
||||
// Non-boolean-style CLI-locals consume the next token as their value
|
||||
// in parseOpArgs (source does; dry-run is boolean-read) — mirror the
|
||||
// parser: source consumes a value when not inline-`=`.
|
||||
if (rawKey === 'source' && m[2] === undefined) i++;
|
||||
continue;
|
||||
}
|
||||
if (rawKey.startsWith('no-')) {
|
||||
const positive = rawKey.slice(3).replace(/-/g, '_');
|
||||
if (op.params[positive]?.type === 'boolean') continue;
|
||||
}
|
||||
const key = rawKey.replace(/-/g, '_');
|
||||
const paramDef = op.params[key];
|
||||
if (paramDef) {
|
||||
// Non-boolean flags consume the next token as their value unless
|
||||
// provided inline via `=` — exactly like parseOpArgs.
|
||||
if (paramDef.type !== 'boolean' && m[2] === undefined) i++;
|
||||
continue;
|
||||
}
|
||||
return `--${rawKey}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function makeContext(engine: BrainEngine, params: Record<string, unknown>): Promise<OperationContext> {
|
||||
// v0.31.8 (D11): resolve sourceId via the canonical 6-tier chain. Honors
|
||||
// --source / GBRAIN_SOURCE / .gbrain-source / path-match / brain default /
|
||||
// 'default'. Wrapped in try/catch so a doctor / single-source brain that
|
||||
@@ -880,16 +1172,21 @@ async function makeContext(engine: BrainEngine, params: Record<string, unknown>)
|
||||
// trusted local boundary) and consumed by federatedSearchScope in
|
||||
// operations.ts, which additionally gates on ctx.remote === false.
|
||||
let localFederated: string[] | undefined;
|
||||
// params.source is set when a CLI flag was parsed for the op (rare; most
|
||||
// CLI ops don't take --source). Falls through to env/dotfile/path-match.
|
||||
const explicit = (params.source as string | undefined) ?? null;
|
||||
try {
|
||||
const { resolveSourceWithTier, localFederatedSourceIds } = await import('./core/source-resolver.ts');
|
||||
// params.source is set when a CLI flag was parsed for the op (rare; most
|
||||
// CLI ops don't take --source). Falls through to env/dotfile/path-match.
|
||||
const explicit = (params.source as string | undefined) ?? null;
|
||||
const resolved = await resolveSourceWithTier(engine, explicit);
|
||||
sourceId = resolved.source_id;
|
||||
localFederated = await localFederatedSourceIds(engine, resolved.source_id, resolved.tier);
|
||||
} catch {
|
||||
// Source resolution failed (e.g. sources table doesn't exist on a fresh
|
||||
} catch (err) {
|
||||
// #1712: an EXPLICIT --source that fails to resolve (invalid id, or a
|
||||
// source that doesn't exist) must error loudly — the blanket swallow
|
||||
// turned `--source __all__` and typos into a silent `default` scope,
|
||||
// which is how three bug reports became debugging sessions.
|
||||
if (explicit) throw err;
|
||||
// Ambient resolution failed (e.g. sources table doesn't exist on a fresh
|
||||
// pre-init brain). Leave sourceId unset; engine read methods fall through
|
||||
// to the cross-source view (D16 back-compat path).
|
||||
sourceId = undefined;
|
||||
@@ -908,12 +1205,20 @@ async function makeContext(engine: BrainEngine, params: Record<string, unknown>)
|
||||
// table). Matches dispatch.ts's auto-fill so the contract holds across
|
||||
// every transport.
|
||||
sourceId: sourceId ?? 'default',
|
||||
// Brain axis: the id connectEngine resolved for this process. Module
|
||||
// state, NEVER params — caller-supplied params.brain must not select a
|
||||
// brain (that would be an untrusted-caller cross-brain hole over MCP).
|
||||
brainId: activeBrainId,
|
||||
...(localFederated ? { localFederatedSourceIds: localFederated } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// Exported for tests (same import-safety contract as cliAliases/printOpHelp).
|
||||
export function formatResult(opName: string, result: unknown): string {
|
||||
export function formatResult(
|
||||
opName: string,
|
||||
result: unknown,
|
||||
params: Record<string, unknown> = {},
|
||||
): string {
|
||||
switch (opName) {
|
||||
case 'volunteer_context': {
|
||||
const r = result as any;
|
||||
@@ -952,6 +1257,7 @@ export function formatResult(opName: string, result: unknown): string {
|
||||
case 'search':
|
||||
case 'query': {
|
||||
const results = result as any[];
|
||||
if (params.json === true) return JSON.stringify(results, null, 2) + '\n';
|
||||
if (results.length === 0) return 'No results.\n';
|
||||
// v0.40.4 — --explain switches to per-stage attribution formatter.
|
||||
// Reads CliOptions.explain via the module-level singleton.
|
||||
@@ -1038,7 +1344,9 @@ export function formatResult(opName: string, result: unknown): string {
|
||||
).join('\n') + '\n';
|
||||
}
|
||||
default:
|
||||
return JSON.stringify(result, null, 2) + '\n';
|
||||
// bigintToStringReplacer keeps this fallback renderer crash-proof even
|
||||
// if a future caller hands it a not-yet-normalized result. (#2450)
|
||||
return JSON.stringify(result, bigintToStringReplacer, 2) + '\n';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1055,7 +1363,7 @@ export function formatResult(opName: string, result: unknown): string {
|
||||
* `runRemoteDoctor` for thin-client installs.
|
||||
*/
|
||||
const THIN_CLIENT_REFUSED_COMMANDS = new Set([
|
||||
'sync', 'embed', 'extract', 'extract-conversation-facts', 'enrich', 'migrate', 'apply-migrations',
|
||||
'sync', 'embed', 'extract', 'extract-conversation-facts', 'enrich', 'migrate', 'retrieval-upgrade', 'apply-migrations',
|
||||
'repair-jsonb', 'orphans', 'integrity', 'serve',
|
||||
// v0.43 (#2095): watch streams against a LOCAL engine; thin clients get
|
||||
// the volunteer_context MCP op instead.
|
||||
@@ -1102,6 +1410,7 @@ const THIN_CLIENT_REFUSE_HINTS: Record<string, string> = {
|
||||
'extract-conversation-facts': 'extract-conversation-facts runs on the host (requires local engine + chat gateway). Run on the host machine.',
|
||||
enrich: 'enrich runs on the host (requires local engine + chat gateway for grounded synthesis). Run on the host machine.',
|
||||
migrate: "migrate runs on the host's local engine. Run on the host machine.",
|
||||
'retrieval-upgrade': "retrieval-upgrade (embedding migration) rebuilds the host brain's schema + re-embeds. Run on the host machine.",
|
||||
'apply-migrations': 'schema migrations run on the host. SSH and run there.',
|
||||
'repair-jsonb': 'repair-jsonb operates on the local DB only.',
|
||||
integrity: 'integrity scans local files. Run on the host machine.',
|
||||
@@ -1168,6 +1477,20 @@ async function handleCliOnly(command: string, args: string[]) {
|
||||
await runInit(args);
|
||||
return;
|
||||
}
|
||||
if (command === 'bench') {
|
||||
// #3502 sweep: `gbrain bench publish` was documented (docs/eval-bench.md,
|
||||
// KEY_FILES.md, and eval-gate's own --help text) but never dispatched —
|
||||
// the promised-but-unwired class retrieval-upgrade (#3390) fixed before.
|
||||
// Pure file-in/file-out (NDJSON → baseline); no DB, no engine.
|
||||
if (args[0] === 'publish') {
|
||||
const { runBenchPublish } = await import('./commands/bench-publish.ts');
|
||||
await runBenchPublish(args.slice(1));
|
||||
return;
|
||||
}
|
||||
console.error('Usage: gbrain bench publish --from <captured.ndjson> --to <X.baseline.ndjson> [flags]');
|
||||
console.error('Run `gbrain bench publish --help` for the full flag list.');
|
||||
process.exit(args[0] === '--help' || args[0] === '-h' ? 0 : 2);
|
||||
}
|
||||
// v0.37 fix wave (deferred TODO, shipped): one-command wipe-and-reinit.
|
||||
// Spawns its own engine internally so no pre-bound engine needed.
|
||||
if (command === 'reinit-pglite') {
|
||||
@@ -1175,6 +1498,13 @@ async function handleCliOnly(command: string, args: string[]) {
|
||||
await runReinitPglite(args);
|
||||
return;
|
||||
}
|
||||
// WAL-repair wave (#223/#1670/#2575): in-place torn-WAL recovery. Never
|
||||
// connects an engine — the whole point is that the DB won't open.
|
||||
if (command === 'pglite-repair') {
|
||||
const { runPgliteRepair } = await import('./commands/pglite-repair.ts');
|
||||
setCliExitVerdict(await runPgliteRepair(args));
|
||||
return;
|
||||
}
|
||||
if (command === 'auth') {
|
||||
const { runAuth } = await import('./commands/auth.ts');
|
||||
await runAuth(args);
|
||||
@@ -1580,6 +1910,14 @@ async function handleCliOnly(command: string, args: string[]) {
|
||||
return;
|
||||
}
|
||||
|
||||
// #3834: extract help is engine-independent and must work on a fresh
|
||||
// install before a brain has been configured.
|
||||
if (command === 'extract' && (args.includes('--help') || args.includes('-h'))) {
|
||||
const { runExtract } = await import('./commands/extract.ts');
|
||||
await runExtract(null as never, args);
|
||||
return;
|
||||
}
|
||||
|
||||
// v0.39.3.0 WARN-5: same pattern for `capture --help`. CLI_ONLY_SELF_HELP
|
||||
// now includes 'capture' so the generic short-circuit at :101 stays out
|
||||
// of the way, but the dispatch case at :1229 still needs an engine. The
|
||||
@@ -1610,14 +1948,12 @@ async function handleCliOnly(command: string, args: string[]) {
|
||||
// Per-command default: search 30s, sources list 10s. User --timeout=Ns wins.
|
||||
// Other commands (import, embed, doctor, etc.) keep their existing
|
||||
// unbounded connect — destructive / long-running commands shouldn't get
|
||||
// a default kill switch.
|
||||
const readOnlyDefaultTimeoutMs =
|
||||
command === 'search' ? 30_000 :
|
||||
command === 'sources' && (args[0] === 'list' || args[0] === undefined) ? 10_000 :
|
||||
null;
|
||||
// a default kill switch. The gate below is per-command (#3013): only the
|
||||
// commands dispatchReadOnlyCommand handles may enter this path — a
|
||||
// user-supplied --timeout on a write command must never reroute it here.
|
||||
const cliOptsResolved = getCliOptions();
|
||||
const userTimeoutMs = cliOptsResolved.timeoutMs;
|
||||
const readOnlyTimeoutMs = userTimeoutMs ?? readOnlyDefaultTimeoutMs;
|
||||
const readOnlyTimeoutMs = resolveReadOnlyDispatchTimeoutMs(command, args, userTimeoutMs);
|
||||
|
||||
if (readOnlyTimeoutMs !== null) {
|
||||
const { withTimeout, OperationTimeoutError } = await import('./core/timeout.ts');
|
||||
@@ -1732,7 +2068,14 @@ async function handleCliOnly(command: string, args: string[]) {
|
||||
}
|
||||
case 'embed': {
|
||||
const { runEmbed } = await import('./commands/embed.ts');
|
||||
await runEmbed(engine, args);
|
||||
// #3037: mirror the `import` case above — the CLI was discarding the
|
||||
// result, so a run where every chunk failed to embed still exited 0
|
||||
// and cron/CI/health gates read total silence as success. Surface
|
||||
// non-zero on failures > 0. (undefined = backgrounded via --background.)
|
||||
const embedResult = await runEmbed(engine, args);
|
||||
if (embedResult && embedResult.failures > 0) {
|
||||
setCliExitVerdict(1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'serve': {
|
||||
@@ -1752,10 +2095,33 @@ async function handleCliOnly(command: string, args: string[]) {
|
||||
}
|
||||
// doctor is handled before connectEngine() above
|
||||
case 'migrate': {
|
||||
// #3390: `gbrain migrate embeddings --to <provider:model>` — the
|
||||
// provider-agnostic embedding migration. Everything else stays the
|
||||
// engine-transfer path (`migrate --to <supabase|pglite>`).
|
||||
if (args[0] === 'embeddings') {
|
||||
const { runMigrateEmbeddings } = await import('./commands/migrate-embeddings.ts');
|
||||
await runMigrateEmbeddings(engine, args.slice(1));
|
||||
break;
|
||||
}
|
||||
if (args.includes('--help') || args.includes('-h')) {
|
||||
console.log('Usage: gbrain migrate --to <supabase|pglite> [--url <url>] [--path <path>] [--force]');
|
||||
console.log(' gbrain migrate embeddings --to <provider:model> [--dim N] [--dry-run] [--yes]');
|
||||
console.log('');
|
||||
console.log('The first form transfers the brain between engines; the second re-embeds');
|
||||
console.log('onto a different embedding provider (run `gbrain migrate embeddings --help`).');
|
||||
break;
|
||||
}
|
||||
const { runMigrateEngine } = await import('./commands/migrate-engine.ts');
|
||||
await runMigrateEngine(engine, args);
|
||||
break;
|
||||
}
|
||||
case 'retrieval-upgrade': {
|
||||
// The command README.md + doctor.ts promised since v0.36 but never
|
||||
// dispatched. Alias for `migrate embeddings` (#3390).
|
||||
const { runMigrateEmbeddings } = await import('./commands/migrate-embeddings.ts');
|
||||
await runMigrateEmbeddings(engine, args);
|
||||
break;
|
||||
}
|
||||
case 'eval': {
|
||||
// v0.32 EXP-5: `eval takes-quality {run,trend,regress}` requires a
|
||||
// brain (samples takes from DB / reads runs table). `replay` was
|
||||
@@ -2133,16 +2499,24 @@ async function handleCliOnly(command: string, args: string[]) {
|
||||
//
|
||||
// v0.30.1: still works; canonical entrypoint is now `gbrain backfill
|
||||
// effective_date`. This command stays as a thin alias for back-compat.
|
||||
//
|
||||
// #1963: pass the already-connected engine. The command used to build
|
||||
// + connect its OWN engine here, which self-deadlocked on the PGLite
|
||||
// data-dir lock (this process already holds it via connectEngine
|
||||
// above) — 30s spin, then exit 1, on every PGLite invocation.
|
||||
const { reindexFrontmatterCli } = await import('./commands/reindex-frontmatter.ts');
|
||||
await reindexFrontmatterCli(args);
|
||||
return; // reindexFrontmatterCli handles its own engine lifecycle
|
||||
await reindexFrontmatterCli(engine, args);
|
||||
break;
|
||||
}
|
||||
case 'backfill': {
|
||||
// v0.30.1: first-class generic backfill command. Subcommand dispatch
|
||||
// is inside runBackfillCommand (kind | list | --help).
|
||||
// #1963: same double-connect class as reindex-frontmatter — reuse the
|
||||
// connected engine instead of building a second one on the same
|
||||
// PGLite data dir.
|
||||
const { runBackfillCommand } = await import('./commands/backfill.ts');
|
||||
await runBackfillCommand(args);
|
||||
return;
|
||||
await runBackfillCommand(engine, args);
|
||||
break;
|
||||
}
|
||||
case 'code-callers': {
|
||||
// v0.20.0 Cathedral II Layer 10 (C4): "who calls <symbol>?"
|
||||
@@ -2185,6 +2559,28 @@ async function handleCliOnly(command: string, args: string[]) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #3013: decide whether an invocation enters the read-only connect+dispatch
|
||||
* timeout path, and with what wallclock. Returns null for every command
|
||||
* dispatchReadOnlyCommand can't handle. The gate used to be "a timeout is
|
||||
* present" — so a user-supplied --timeout on a write command (`sync`,
|
||||
* `embed`, `import`, ...) hijacked dispatch into the read-only path, which
|
||||
* threw and exited 1 before any work ran. Pure; exported for the
|
||||
* regression test.
|
||||
*/
|
||||
export function resolveReadOnlyDispatchTimeoutMs(
|
||||
command: string,
|
||||
subArgs: string[],
|
||||
userTimeoutMs: number | null,
|
||||
): number | null {
|
||||
if (command !== 'search' && command !== 'sources') return null;
|
||||
const defaultMs =
|
||||
command === 'search' ? 30_000 :
|
||||
(subArgs[0] === 'list' || subArgs[0] === undefined) ? 10_000 :
|
||||
null;
|
||||
return userTimeoutMs ?? defaultMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.41.6.0 D3: dispatch helper for the read-only commands that take a
|
||||
* default wallclock timeout (`gbrain search`, `gbrain sources list`).
|
||||
@@ -2220,7 +2616,52 @@ async function dispatchReadOnlyCommand(engine: BrainEngine, command: string, arg
|
||||
import { buildGatewayConfig } from './core/ai/build-gateway-config.ts';
|
||||
export { buildGatewayConfig };
|
||||
|
||||
/**
|
||||
* Which brain this process's engine targets. Set by connectEngine after brain
|
||||
* resolution; read by makeContext so ctx.brainId carries the audit id. Never
|
||||
* derived from op params — an untrusted caller must not be able to name a
|
||||
* brain (same fail-closed shape as the #3524 remote source sentinel).
|
||||
*/
|
||||
let activeBrainId: string = 'host';
|
||||
|
||||
/**
|
||||
* Connect to a mounted brain (brain axis, non-host). Routes through
|
||||
* BrainRegistry so:
|
||||
* - an unknown/disabled mount id throws UnknownBrainError. Fail-closed:
|
||||
* the pre-fix CLI silently fell back to the host brain, returning
|
||||
* confident wrong answers (mirror of #3524's explicit --source decision);
|
||||
* - postgres mounts get a per-instance pool, never the db.ts singleton;
|
||||
* - NO migrations run against the mount — schema is the publisher's job
|
||||
* (same decision as BrainRegistry.initMountBrain). Write access control
|
||||
* is the mount's own DB credential grants: a read-only role rejects
|
||||
* writes at the database; gbrain does not re-implement that client-side.
|
||||
* The AI gateway still configures from the HOST config (the caller's API
|
||||
* keys + model tiers) — embedding/expansion spend stays the caller's, and
|
||||
* a mount's DB-plane model config is never merged into the caller's gateway.
|
||||
*/
|
||||
async function connectMountEngine(brainId: string): Promise<BrainEngine> {
|
||||
const config = loadConfig();
|
||||
if (config) {
|
||||
const { configureGateway } = await import('./core/ai/gateway.ts');
|
||||
configureGateway(buildGatewayConfig(config));
|
||||
}
|
||||
const { loadRegistry } = await import('./core/brain-registry.ts');
|
||||
const handle = await loadRegistry().getBrain(brainId);
|
||||
activeBrainId = brainId;
|
||||
return handle.engine;
|
||||
}
|
||||
|
||||
async function connectEngine(opts?: { probeOnly?: boolean }): Promise<BrainEngine> {
|
||||
// Brain axis: resolve WHICH DATABASE this invocation targets before touching
|
||||
// the host engine. --brain (global flag) / GBRAIN_BRAIN_ID / .gbrain-mount /
|
||||
// mount-path-prefix resolve via the canonical 6-tier chain — the mirror of
|
||||
// the source axis in makeContext. connectEngine is the single choke point
|
||||
// every local CLI command routes through (shared ops, CLI-only commands,
|
||||
// and the search-dashboard path), so routing lands here once.
|
||||
const { resolveBrainId } = await import('./core/brain-resolver.ts');
|
||||
const brainId = resolveBrainId(getCliOptions().brain);
|
||||
if (brainId !== 'host') return connectMountEngine(brainId);
|
||||
|
||||
const config = loadConfig();
|
||||
if (!config) {
|
||||
console.error('No brain configured. Run: gbrain init');
|
||||
@@ -2352,6 +2793,7 @@ USAGE
|
||||
SETUP
|
||||
init [--pglite|--supabase|--url] Create brain (PGLite default, no server)
|
||||
migrate --to <supabase|pglite> Transfer brain between engines
|
||||
migrate embeddings --to <p:model> Re-embed onto another embedding provider
|
||||
upgrade Self-update
|
||||
check-update [--json] Check for new versions
|
||||
doctor [--json] [--fast] Health check (resolver, skills, pgvector, RLS, embeddings)
|
||||
@@ -2412,13 +2854,17 @@ TIMELINE
|
||||
|
||||
TOOLS
|
||||
extract <links|timeline|all> Extract links/timeline (idempotent)
|
||||
[--source fs|db] fs (default) walks .md files; db iterates engine pages
|
||||
[--dir <brain>] brain dir for fs source
|
||||
[--type T] [--since DATE] filters (db source)
|
||||
[--dry-run] [--json]
|
||||
[--source fs|db] [--source-id ID] [--dir <brain>]
|
||||
[--type T] [--since DATE] [--include-frontmatter]
|
||||
[--workers N|--concurrency N] [--dry-run] [--json]
|
||||
extract links --by-mention [--ner] --source db
|
||||
extract timeline --from-meetings [--infer-dates] --source db
|
||||
extract --stale [--source-id ID] [--catch-up] [--dry-run] [--json]
|
||||
extract --explain <kind> [--json] Full details: gbrain extract --help
|
||||
publish <page.md> [--password] Shareable HTML (strips private data, optional AES-256)
|
||||
check-backlinks <check|fix> [dir] Find/fix missing back-links across brain
|
||||
lint <dir|file> [--fix] Catch LLM artifacts, placeholder dates, bad frontmatter
|
||||
backfill <kind|list> v0.30.1: run a registered backfill (effective-date, ...)
|
||||
orphans [--json] [--count] Find pages with no inbound wikilinks
|
||||
salience [--days N] [--kind P] v0.29: pages ranked by emotional + activity salience
|
||||
anomalies [--since D] [--sigma N] v0.29: cohort-based statistical anomalies (tag, type)
|
||||
|
||||
@@ -108,7 +108,7 @@ Flags:
|
||||
|
||||
Exit codes:
|
||||
0 Success (including "nothing to do").
|
||||
1 An orchestrator failed.
|
||||
1 An orchestrator failed, or schema migrations are pending (re-run with --yes).
|
||||
2 Invalid arguments.
|
||||
`);
|
||||
}
|
||||
@@ -260,6 +260,41 @@ function printDryRun(plan: Plan, installed: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #1530: schema-drift pre-flight resolution. When the schema version is
|
||||
* behind, `--yes`/`--non-interactive` runs the schema migrations right there
|
||||
* (the engine is already connected); interactive runs warn and return true so
|
||||
* the caller exits non-zero instead of claiming "All migrations up to date".
|
||||
* All output goes to stderr (migrations never print to stdout).
|
||||
*
|
||||
* Returns true when the schema is STILL behind after this call.
|
||||
*/
|
||||
async function resolveSchemaBehind(opts: {
|
||||
schemaVer: number;
|
||||
latest: number;
|
||||
autoApply: boolean;
|
||||
run: () => Promise<{ applied: number; current: number }>;
|
||||
}): Promise<boolean> {
|
||||
const { schemaVer, latest, autoApply, run } = opts;
|
||||
if (schemaVer >= latest) return false;
|
||||
if (autoApply) {
|
||||
console.error(`Schema version ${schemaVer} is behind latest ${latest}; running schema migrations...`);
|
||||
try {
|
||||
const result = await run();
|
||||
console.error(`Applied ${result.applied} schema migration(s); now at v${result.current}.`);
|
||||
return false;
|
||||
} catch (err) {
|
||||
console.error(`Schema migration failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
console.warn(
|
||||
`\n⚠️ Schema version ${schemaVer} is behind latest ${latest}.\n` +
|
||||
` Run \`gbrain apply-migrations --yes\` to apply now, or \`gbrain init --migrate-only\`.\n`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
function orchestratorOptsFrom(cli: ApplyMigrationsArgs): OrchestratorOpts {
|
||||
return {
|
||||
yes: cli.yes || cli.nonInteractive,
|
||||
@@ -355,10 +390,13 @@ export async function runApplyMigrations(args: string[]): Promise<void> {
|
||||
if (cli.forceAll) return; // both surfaces flushed
|
||||
}
|
||||
|
||||
// Pre-flight: warn if schema migrations (migrate.ts) are behind.
|
||||
// apply-migrations runs orchestrator migrations only; schema migrations
|
||||
// run via connectEngine() / initSchema(). Users often expect this CLI
|
||||
// to handle everything (Issue 1 from v0.18.0 field report).
|
||||
// Pre-flight: detect schema migrations (migrate.ts) being behind.
|
||||
// apply-migrations historically ran orchestrator migrations only; schema
|
||||
// migrations run via connectEngine() / initSchema(). Users expect this CLI
|
||||
// to handle everything (Issue 1 from v0.18.0 field report; #1530). With
|
||||
// --yes/--non-interactive we apply them here; otherwise we warn and make
|
||||
// sure the run does NOT report "All migrations up to date" with exit 0.
|
||||
let schemaBehind = false;
|
||||
try {
|
||||
const { LATEST_VERSION } = await import('../core/migrate.ts');
|
||||
const { loadConfig: lc, toEngineConfig } = await import('../core/config.ts');
|
||||
@@ -378,14 +416,16 @@ export async function runApplyMigrations(args: string[]): Promise<void> {
|
||||
await eng.connect(toEngineConfig(cfg));
|
||||
const verStr = await eng.getConfig('version');
|
||||
const schemaVer = parseInt(verStr || '1', 10);
|
||||
const { runMigrations } = await import('../core/migrate.ts');
|
||||
schemaBehind = await resolveSchemaBehind({
|
||||
schemaVer,
|
||||
latest: LATEST_VERSION,
|
||||
// --list and --dry-run are read-only surfaces: never mutate schema
|
||||
// even when combined with --yes/--non-interactive.
|
||||
autoApply: (cli.yes || cli.nonInteractive) && !cli.dryRun && !cli.list,
|
||||
run: () => runMigrations(eng),
|
||||
});
|
||||
await eng.disconnect();
|
||||
if (schemaVer < LATEST_VERSION) {
|
||||
console.warn(
|
||||
`\n⚠️ Schema version ${schemaVer} is behind latest ${LATEST_VERSION}.\n` +
|
||||
` Schema migrations run automatically on next connectEngine() / initSchema().\n` +
|
||||
` To run them now: gbrain init --migrate-only\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -420,6 +460,13 @@ export async function runApplyMigrations(args: string[]): Promise<void> {
|
||||
|
||||
const toRun: Migration[] = [...plan.partial, ...plan.pending];
|
||||
if (toRun.length === 0) {
|
||||
if (schemaBehind) {
|
||||
console.error(
|
||||
'Orchestrator migrations are up to date, but schema migrations are behind. ' +
|
||||
'Run `gbrain apply-migrations --yes` (or `--force-schema`) to apply them.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('All migrations up to date.');
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -511,4 +558,5 @@ export const __testing = {
|
||||
buildPlan,
|
||||
indexCompleted,
|
||||
statusForVersion,
|
||||
resolveSchemaBehind,
|
||||
};
|
||||
|
||||
+25
-6
@@ -524,13 +524,17 @@ async function registerClient(name: string, args: string[]) {
|
||||
* /admin/api/rescope-client endpoint.
|
||||
*/
|
||||
async function rescopeClient(clientId: string, args: string[]) {
|
||||
const usage = 'Usage: auth rescope-client <client_id> [--source SOURCE] [--federated-read SRC1,SRC2,...]';
|
||||
const usage = 'Usage: auth rescope-client <client_id> [--source SOURCE] [--federated-read SRC1,SRC2,...] [--bound-slug-prefixes P1,P2|none]';
|
||||
if (!clientId) {
|
||||
console.error(usage);
|
||||
process.exit(1);
|
||||
}
|
||||
let sourceId: string | undefined;
|
||||
let federatedRead: string[] | undefined;
|
||||
// v0.42.72.0: tri-state — undefined = untouched, null = clear ('none'),
|
||||
// array = replace. Lets roster churn (channel joins/leaves) update the
|
||||
// write fence in place instead of register+rotate.
|
||||
let boundSlugPrefixes: string[] | null | undefined;
|
||||
for (let i = 0; i < args.length; i += 2) {
|
||||
const flag = args[i];
|
||||
const value = args[i + 1];
|
||||
@@ -542,14 +546,18 @@ async function rescopeClient(clientId: string, args: string[]) {
|
||||
if (flag === '--source') sourceId = value;
|
||||
else if (flag === '--federated-read') {
|
||||
federatedRead = value.split(',').map(s => s.trim()).filter(Boolean);
|
||||
} else if (flag === '--bound-slug-prefixes') {
|
||||
boundSlugPrefixes = value === 'none'
|
||||
? null
|
||||
: value.split(',').map(s => s.trim()).filter(Boolean);
|
||||
} else {
|
||||
console.error(`Error: Unknown flag: ${flag}`);
|
||||
console.error(usage);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
if (sourceId === undefined && federatedRead === undefined) {
|
||||
console.error('Error: pass --source and/or --federated-read');
|
||||
if (sourceId === undefined && federatedRead === undefined && boundSlugPrefixes === undefined) {
|
||||
console.error('Error: pass --source, --federated-read, and/or --bound-slug-prefixes');
|
||||
console.error(usage);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -557,10 +565,13 @@ async function rescopeClient(clientId: string, args: string[]) {
|
||||
await withConfiguredSql(async (sql) => {
|
||||
const { GBrainOAuthProvider } = await import('../core/oauth-provider.ts');
|
||||
const provider = new GBrainOAuthProvider({ sql });
|
||||
const result = await provider.rescopeClient(clientId, { sourceId, federatedRead });
|
||||
const result = await provider.rescopeClient(clientId, { sourceId, federatedRead, boundSlugPrefixes });
|
||||
console.log(`OAuth client rescoped: "${result.clientName}" (${result.clientId})\n`);
|
||||
console.log(` Write source: ${result.sourceId}`);
|
||||
console.log(` Federated reads: ${result.federatedRead.join(', ') || '<none>'}`);
|
||||
if (result.boundSlugPrefixes !== undefined) {
|
||||
console.log(` Bound slug prefixes: ${result.boundSlugPrefixes?.join(', ') ?? '<none — full-source write authority>'}`);
|
||||
}
|
||||
console.log('\nTakes effect on the client\'s next request (existing tokens included).');
|
||||
});
|
||||
} catch (e: any) {
|
||||
@@ -645,14 +656,22 @@ Usage:
|
||||
--bound-tools <tool1,tool2> Bind submit_agent to an allow-list of tools
|
||||
--bound-source <id> Bind submit_agent jobs to a source id
|
||||
--bound-brain <id> Bind submit_agent jobs to a brain id
|
||||
--bound-slug-prefixes <prefix1,prefix2> Bind submit_agent writes to slug prefixes
|
||||
--bound-slug-prefixes <prefix1,prefix2> Fence ALL direct slug writes (put_page, delete_page,
|
||||
tags, links, timeline, revert, raw data) AND
|
||||
submit_agent to these prefixes. Each MUST end with
|
||||
'/' or '/*' — a boundary-less 'emp-alice' would also
|
||||
name 'emp-alice-2/...'. Ops that write by something
|
||||
other than a slug (extract_*, forget_fact,
|
||||
ontology_propose, sources_*) and POST /ingest become
|
||||
unavailable to a bound client. Omit = full-source writes.
|
||||
--bound-max-concurrent <n> Bound submit_agent concurrency (default: 1)
|
||||
--budget-usd-per-day <usd> Bound submit_agent daily spend cap
|
||||
gbrain auth rescope-client <client_id> [options] Change an existing client's source scope (e.g. a DCR
|
||||
client stuck on the 'default' source). Only the flags
|
||||
you pass change; the other axis is left as-is.
|
||||
you pass change; the other axes are left as-is.
|
||||
--source <id> New write source
|
||||
--federated-read <id1,id2,...> New read-scope source list
|
||||
--bound-slug-prefixes <p1,p2|none> Replace the slug-prefix write fence ('none' clears it)
|
||||
gbrain auth revoke-client <client_id> Hard-delete an OAuth 2.1 client (cascades to tokens + codes)
|
||||
gbrain auth test <url> --token <token> Smoke-test a remote MCP server
|
||||
`);
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
import type { BrainEngine, SourceRow } from '../core/engine.ts';
|
||||
import type { MinionQueue } from '../core/minions/queue.ts';
|
||||
import { NON_GLOBAL_PHASES, GLOBAL_PHASES, LAST_GLOBAL_AT_KEY } from '../core/cycle.ts';
|
||||
import { sourceConfigHasRemoteUrl } from '../core/sources-load.ts';
|
||||
|
||||
const FULL_CYCLE_FLOOR_MIN = 60;
|
||||
|
||||
@@ -430,13 +431,13 @@ export async function dispatchPerSource(
|
||||
const dispatched: string[] = [];
|
||||
for (const src of dispatch) {
|
||||
try {
|
||||
const remoteUrl = typeof src.config?.remote_url === 'string' ? src.config.remote_url : null;
|
||||
const shouldPull = sourceConfigHasRemoteUrl(src.config);
|
||||
const job = await queue.add(
|
||||
'autopilot-cycle',
|
||||
{
|
||||
repoPath: opts.repoPath,
|
||||
source_id: src.id,
|
||||
pull: !!remoteUrl,
|
||||
pull: shouldPull,
|
||||
// #2194 fix #3 (cycle split): per-source cycles run ONLY source-scoped
|
||||
// (+ mixed) phases. The brain-wide global phases (embed, orphans,
|
||||
// purge, …) run once in autopilot-global-maintenance, not N times
|
||||
@@ -465,11 +466,11 @@ export async function dispatchPerSource(
|
||||
job_id: job.id,
|
||||
mode: 'per_source',
|
||||
source_id: src.id,
|
||||
pull: !!remoteUrl,
|
||||
pull: shouldPull,
|
||||
slot: opts.slot,
|
||||
}));
|
||||
} else {
|
||||
log(`[dispatch] job #${job.id} autopilot-cycle source=${src.id}${remoteUrl ? ' pull=yes' : ''}`);
|
||||
log(`[dispatch] job #${job.id} autopilot-cycle source=${src.id}${shouldPull ? ' pull=yes' : ''}`);
|
||||
}
|
||||
} catch (e) {
|
||||
// Per-source submit failure does NOT abort the tick (codex E1 F1
|
||||
|
||||
@@ -1,9 +1,42 @@
|
||||
import { defaultTimeoutMsFor } from '../core/minions/handler-timeouts.ts';
|
||||
|
||||
// #2781: the full-cycle floor used to be a literal `1_800_000` that merely
|
||||
// HAPPENED to match the 'autopilot-cycle' / 'autopilot-global-maintenance'
|
||||
// handler anchors (`HANDLER_DEFAULT_TIMEOUT_MS`, #1737) instead of being
|
||||
// derived from them. A duplicated literal can silently drift from the
|
||||
// handler default it's supposed to track — which is exactly the bug class
|
||||
// #2781 reported (an explicit `timeout_ms` stamp permanently overrides the
|
||||
// handler default per `queue.ts`'s `opts?.timeout_ms ?? defaultTimeoutMsFor`,
|
||||
// so a stale/lower literal here would starve a phase the handler default
|
||||
// was sized for). Deriving the floor from `defaultTimeoutMsFor` for both
|
||||
// full-cycle job names keeps the stamp coupled to its anchor by construction.
|
||||
// Fail fast (not `?? 0`) if either handler ever loses its entry in
|
||||
// HANDLER_DEFAULT_TIMEOUT_MS — silently falling back to "no floor" would
|
||||
// reintroduce #2781 rather than surface the drift.
|
||||
function requireHandlerAnchorMs(jobName: string): number {
|
||||
const ms = defaultTimeoutMsFor(jobName);
|
||||
if (ms === null) {
|
||||
throw new Error(
|
||||
`resolveAutopilotDispatchTimeoutMs: '${jobName}' has no entry in HANDLER_DEFAULT_TIMEOUT_MS ` +
|
||||
'(handler-timeouts.ts) — the full-cycle timeout floor can no longer be derived from it. ' +
|
||||
'See #2781: a missing/removed anchor here silently reintroduces the interval-derived stamp ' +
|
||||
'permanently overriding the handler default.',
|
||||
);
|
||||
}
|
||||
return ms;
|
||||
}
|
||||
|
||||
const FULL_CYCLE_TIMEOUT_FLOOR_MS = Math.max(
|
||||
requireHandlerAnchorMs('autopilot-cycle'),
|
||||
requireHandlerAnchorMs('autopilot-global-maintenance'),
|
||||
);
|
||||
|
||||
export function resolveAutopilotDispatchTimeoutMs(
|
||||
baseIntervalSeconds: number,
|
||||
fullCycle: boolean,
|
||||
): number {
|
||||
const intervalDerivedTimeoutMs = Math.max(baseIntervalSeconds * 2 * 1000, 300_000);
|
||||
return fullCycle
|
||||
? Math.max(intervalDerivedTimeoutMs, 1_800_000)
|
||||
? Math.max(intervalDerivedTimeoutMs, FULL_CYCLE_TIMEOUT_FLOOR_MS)
|
||||
: intervalDerivedTimeoutMs;
|
||||
}
|
||||
|
||||
@@ -739,7 +739,7 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
|
||||
try {
|
||||
const { isFederatedV2Enabled } = await import('../core/feature-flags.ts');
|
||||
if (await isFederatedV2Enabled(engine)) {
|
||||
const { loadAllSources } = await import('../core/sources-load.ts');
|
||||
const { loadAllSources, sourceConfigHasRemoteUrl } = await import('../core/sources-load.ts');
|
||||
const sources = await loadAllSources(engine);
|
||||
const intervalMs = baseInterval * 1000;
|
||||
const now = Date.now();
|
||||
@@ -754,6 +754,7 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
|
||||
{
|
||||
sourceId: src.id,
|
||||
repoPath: src.local_path,
|
||||
pull: sourceConfigHasRemoteUrl(src.config),
|
||||
auto_embed_backfill: true,
|
||||
embed_reason: 'autopilot_freshness',
|
||||
},
|
||||
@@ -981,12 +982,21 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
|
||||
// can't shrink throughput (codex #9/D5). autopilot-cycle jobs run on
|
||||
// the 'default' queue, so that's the concurrency we compare against.
|
||||
const fanoutMax = await resolveEffectiveFanoutMax(engine, 'default');
|
||||
// #2781: both 'autopilot-cycle' (per-source) and 'autopilot-global-
|
||||
// maintenance' carry a 30-min handler anchor (handler-timeouts.ts)
|
||||
// because a full cycle can outlive short daemon intervals — unlike
|
||||
// the lighter interval-derived `timeoutMs` above (sync/freshness,
|
||||
// extract-atoms-drain, targeted small-plan steps), which have no
|
||||
// such anchor and are meant to stay interval-derived. Naming this
|
||||
// separately (rather than reusing the outer `timeoutMs`) avoids
|
||||
// the #2781 bug class: dispatchGlobalMaintenance previously reused
|
||||
// the outer non-full-cycle `timeoutMs` by shorthand, silently
|
||||
// dropping its own handler anchor.
|
||||
const fullCycleTimeoutMs = resolveAutopilotDispatchTimeoutMs(baseInterval, true);
|
||||
const result = await dispatchPerSource(engine, queue, {
|
||||
repoPath,
|
||||
slot,
|
||||
// Full cycles can outlive short daemon intervals. Keep lighter dispatches
|
||||
// interval-derived while giving per-source consolidation enough time.
|
||||
timeoutMs: resolveAutopilotDispatchTimeoutMs(baseInterval, true),
|
||||
timeoutMs: fullCycleTimeoutMs,
|
||||
fanoutMax,
|
||||
jsonMode,
|
||||
});
|
||||
@@ -997,7 +1007,7 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
|
||||
// the per-source path (legacy single-source still runs everything).
|
||||
if (!result.legacy_fallback) {
|
||||
try {
|
||||
await dispatchGlobalMaintenance(engine, queue, { repoPath, slot, timeoutMs, jsonMode });
|
||||
await dispatchGlobalMaintenance(engine, queue, { repoPath, slot, timeoutMs: fullCycleTimeoutMs, jsonMode });
|
||||
} catch (e) {
|
||||
if (jsonMode) process.stderr.write(JSON.stringify({ event: 'global_maintenance_dispatch_failed', error: e instanceof Error ? e.message : String(e) }) + '\n');
|
||||
}
|
||||
|
||||
@@ -16,10 +16,10 @@
|
||||
* always reserving 1 connection for HNSW + heartbeat + doctor probes.
|
||||
*/
|
||||
|
||||
import type { BrainEngine } from '../core/engine.ts';
|
||||
import { resolveDirectPoolSize } from '../core/connection-manager.ts';
|
||||
import { listBackfills, getBackfill } from '../core/backfill-registry.ts';
|
||||
import { runBackfill, clearBackfillCheckpoint } from '../core/backfill-base.ts';
|
||||
import { loadConfig, toEngineConfig } from '../core/config.ts';
|
||||
|
||||
interface BackfillArgs {
|
||||
kind?: string;
|
||||
@@ -114,7 +114,14 @@ function clampConcurrency(requested: number | undefined): { effective: number; w
|
||||
return { effective: requested };
|
||||
}
|
||||
|
||||
export async function runBackfillCommand(args: string[]): Promise<void> {
|
||||
/**
|
||||
* #1963 (same class as reindex-frontmatter): takes the ALREADY-CONNECTED
|
||||
* engine from cli.ts's dispatch. Building a second engine here deadlocked on
|
||||
* the PGLite data-dir lock (cli.ts's `connectEngine()` already holds it in
|
||||
* this same process) — every `gbrain backfill <kind>` on PGLite timed out
|
||||
* after 30s. Engine lifecycle belongs to cli.ts's connect + teardown.
|
||||
*/
|
||||
export async function runBackfillCommand(engine: BrainEngine, args: string[]): Promise<void> {
|
||||
const cli = parseArgs(args);
|
||||
if (cli.help) { printHelp(); return; }
|
||||
|
||||
@@ -144,20 +151,10 @@ export async function runBackfillCommand(args: string[]): Promise<void> {
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const config = loadConfig();
|
||||
if (!config) {
|
||||
console.error('No brain configured. Run: gbrain init');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// X5 admission control — clamp concurrency to direct-pool capacity.
|
||||
const { effective: concurrency, warning } = clampConcurrency(cli.concurrency);
|
||||
if (warning) console.warn(warning);
|
||||
|
||||
const { createEngine } = await import('../core/engine-factory.ts');
|
||||
const engine = await createEngine(toEngineConfig(config));
|
||||
await engine.connect(toEngineConfig(config));
|
||||
|
||||
if (cli.fresh) {
|
||||
await clearBackfillCheckpoint(engine, reg.spec.name);
|
||||
console.log(`Cleared checkpoint for backfill.${reg.spec.name}`);
|
||||
@@ -192,7 +189,6 @@ export async function runBackfillCommand(args: string[]): Promise<void> {
|
||||
if (result.cappedByMaxRows) console.log(` ⚠️ Capped by --max-rows; more remain.`);
|
||||
if (result.cappedByErrors) console.log(` ⚠️ Capped by --max-errors at ${result.errors}.`);
|
||||
|
||||
await engine.disconnect();
|
||||
if (result.cappedByErrors) process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
semverGt,
|
||||
semverLte,
|
||||
} from '../core/semver.ts';
|
||||
import { writeUpdateCache, type UpdateMarker } from '../core/self-upgrade.ts';
|
||||
import { readUpdateCache, writeUpdateCache, type UpdateMarker } from '../core/self-upgrade.ts';
|
||||
|
||||
/** Best-effort cache write — a read-only ~/.gbrain must never make the check throw. */
|
||||
function safeWriteCache(marker: UpdateMarker): void {
|
||||
@@ -45,26 +45,56 @@ function upgradeCommandForMethod(method: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** Where the latest version is resolved from. The release train's source of
|
||||
* truth is the `VERSION` file on master — same trusted host `fetchChangelog`
|
||||
* already uses. GitHub releases are published from it per VERSION bump
|
||||
* (`.github/workflows/release.yml`, #3521) and carry the binary assets, but
|
||||
* this check deliberately does NOT read `releases/latest`: it was a permanent
|
||||
* 404 before releases existed (#3520) and can still lag master. An npm
|
||||
* fallback was rejected: the `gbrain` package on npm is an unrelated GPU
|
||||
* library (#505), so it would produce false upgrade prompts pointing at a
|
||||
* stranger's package. */
|
||||
const VERSION_SOURCE_URL = 'https://raw.githubusercontent.com/garrytan/gbrain/master/VERSION';
|
||||
const RELEASE_NOTES_URL = 'https://github.com/garrytan/gbrain/blob/master/CHANGELOG.md';
|
||||
|
||||
/** Extract a version from the raw VERSION file body: first line, optional `v`
|
||||
* prefix, optional `-suffix` channel tag (`0.31.1.1-fixwave` compares as its
|
||||
* numeric base — fail-safe: a suffix-only bump never prompts). Body is bounded
|
||||
* before parsing so a malformed/huge response can't blow up the check. */
|
||||
export function parseVersionFileBody(body: string): string | null {
|
||||
const firstLine = body.slice(0, 256).trim().split('\n')[0].trim();
|
||||
const m = firstLine.match(/^v?(\d+\.\d+\.\d+(?:\.\d+)?)(?:[-+][0-9A-Za-z.-]+)?$/);
|
||||
return m && isValidVersionString(m[1]) ? m[1] : null;
|
||||
}
|
||||
|
||||
export type LatestReleaseResult =
|
||||
| { ok: true; tag: string; published_at: string; url: string }
|
||||
| { ok: false; reason: 'network_error' | 'no_releases' };
|
||||
|
||||
/**
|
||||
* Fetch the latest GitHub release. Exported (v0.42) so the self-upgrade refresh
|
||||
* path and tests can reuse it. 5s timeout (was 10s) — this runs on the detached
|
||||
* refresh, never the hot path, but a tight bound keeps the refresh cheap.
|
||||
* Resolve the latest published gbrain version (from VERSION on master — see
|
||||
* VERSION_SOURCE_URL). Exported (v0.42) so the self-upgrade refresh path and
|
||||
* tests can reuse it. 5s timeout — this runs on the detached refresh, never the
|
||||
* hot path. Failures are discriminated: `network_error` (offline/timeout) vs
|
||||
* `no_releases` (endpoint answered but no usable version).
|
||||
*/
|
||||
export async function fetchLatestRelease(): Promise<{ tag: string; published_at: string; url: string } | null> {
|
||||
export async function fetchLatestRelease(): Promise<LatestReleaseResult> {
|
||||
let res: Response;
|
||||
try {
|
||||
const res = await fetch('https://api.github.com/repos/garrytan/gbrain/releases/latest', {
|
||||
res = await fetch(VERSION_SOURCE_URL, {
|
||||
headers: { 'User-Agent': `gbrain/${VERSION}` },
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json() as any;
|
||||
return {
|
||||
tag: data.tag_name || '',
|
||||
published_at: data.published_at || '',
|
||||
url: data.html_url || '',
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
return { ok: false, reason: 'network_error' };
|
||||
}
|
||||
try {
|
||||
if (!res.ok) return { ok: false, reason: 'no_releases' };
|
||||
const tag = parseVersionFileBody(await res.text());
|
||||
if (!tag) return { ok: false, reason: 'no_releases' };
|
||||
return { ok: true, tag, published_at: '', url: RELEASE_NOTES_URL };
|
||||
} catch {
|
||||
return { ok: false, reason: 'network_error' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,17 +148,33 @@ export function extractChangelogBetween(changelog: string, from: string, to: str
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the latest release and write the self-upgrade cache (the marker line
|
||||
* read by the CLI startup hook). Fail-open: on any network failure we cache
|
||||
* `UP_TO_DATE <current>` so the TTL prevents hammering GitHub on every
|
||||
* invocation. Returns the resolved marker for callers that want it. This is the
|
||||
* function the detached single-flight refresh (`gbrain check-update
|
||||
* --refresh-cache`) invokes.
|
||||
* A failed check must NEVER write `up_to_date` — that was #486: the fetch
|
||||
* failed permanently (dead releases API) and every user was told "you're
|
||||
* current" forever. Instead, re-write the last-known-good marker (bumping its
|
||||
* mtime so the cache TTL still throttles retries and a network blip can't
|
||||
* erase a pending upgrade_available notice). No prior marker → write nothing;
|
||||
* the next invocation retries.
|
||||
*/
|
||||
function preserveCacheOnFailedCheck(): void {
|
||||
try {
|
||||
const prior = readUpdateCache();
|
||||
if (prior) safeWriteCache(prior.marker);
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the latest version and write the self-upgrade cache (the marker line
|
||||
* read by the CLI startup hook). On fetch failure the last-known-good marker is
|
||||
* preserved (see preserveCacheOnFailedCheck) — never a fabricated `up_to_date`.
|
||||
* This is the function the detached single-flight refresh (`gbrain
|
||||
* check-update --refresh-cache`) invokes.
|
||||
*/
|
||||
export async function refreshUpdateCache(): Promise<void> {
|
||||
const release = await fetchLatestRelease();
|
||||
if (!release) {
|
||||
safeWriteCache({ kind: 'up_to_date', current: VERSION });
|
||||
if (!release.ok) {
|
||||
preserveCacheOnFailedCheck();
|
||||
return;
|
||||
}
|
||||
const latestVersion = release.tag.replace(/^v/, '');
|
||||
@@ -166,9 +212,8 @@ export async function runCheckUpdate(args: string[]) {
|
||||
|
||||
const release = await fetchLatestRelease();
|
||||
|
||||
if (!release) {
|
||||
// Warm the cache fail-open so the startup hook doesn't re-fetch every call.
|
||||
safeWriteCache({ kind: 'up_to_date', current: VERSION });
|
||||
if (!release.ok) {
|
||||
preserveCacheOnFailedCheck();
|
||||
if (json) {
|
||||
console.log(JSON.stringify({
|
||||
current_version: VERSION,
|
||||
@@ -179,10 +224,12 @@ export async function runCheckUpdate(args: string[]) {
|
||||
release_url: '',
|
||||
changelog_diff: '',
|
||||
published_at: '',
|
||||
error: 'no_releases',
|
||||
error: release.reason,
|
||||
}, null, 2));
|
||||
} else if (release.reason === 'network_error') {
|
||||
console.log(`GBrain ${VERSION} — could not check for updates (network unavailable).`);
|
||||
} else {
|
||||
console.log(`GBrain ${VERSION} — could not check for updates (no releases found or network unavailable).`);
|
||||
console.log(`GBrain ${VERSION} — could not determine the latest published version.`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -46,7 +46,14 @@ export async function runConfig(engine: BrainEngine, args: string[]) {
|
||||
}
|
||||
console.log('GBrain config:');
|
||||
for (const [k, v] of Object.entries(config)) {
|
||||
const display = typeof v === 'string' ? redactConfigValue(k, v) : v;
|
||||
// #575: objects interpolated into the template literal printed
|
||||
// `[object Object]` — render them as JSON instead. Sensitive keys
|
||||
// stay redacted whether the value is a string or an object.
|
||||
const display = typeof v === 'string'
|
||||
? redactConfigValue(k, v)
|
||||
: v !== null && typeof v === 'object'
|
||||
? (isSensitiveConfigKey(k) ? '***' : JSON.stringify(v))
|
||||
: v;
|
||||
console.log(` ${k}: ${display}`);
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* #1835 — storage_path resolution for the doctor `image_assets` check.
|
||||
*
|
||||
* `files.storage_path` rows written by a Windows gbrain install carry Windows
|
||||
* drive paths (`D:/foo/img.jpg`, `D:\foo\img.jpg`). On POSIX,
|
||||
* `path.isAbsolute()` is false for those, so the old code joined them onto the
|
||||
* repo root and produced a path that can never exist — a false-positive
|
||||
* "missing from disk, restore from git" WARN under WSL and macOS.
|
||||
*
|
||||
* Policy:
|
||||
* - win32: drive paths are absolute; stat them as-is.
|
||||
* - WSL (linux + "microsoft" in /proc/version): translate `D:/x` to
|
||||
* `<automount root>/d/x` (automount root read from /etc/wsl.conf
|
||||
* `[automount] root`, default `/mnt`) and stat that.
|
||||
* - any other POSIX host (macOS, plain Linux): the path is unresolvable on
|
||||
* this platform — report it as foreign so the caller SKIPS the stat
|
||||
* instead of inventing a path that will never exist.
|
||||
*
|
||||
* Kept in its own module (not doctor.ts) so the pure tests don't pull the
|
||||
* 7k-line doctor dep graph, and so open PRs rewriting the image_assets block
|
||||
* (e.g. a `resolveImageAssetPath` helper) can adopt it with a one-line call.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join, posix, win32 } from 'node:path';
|
||||
|
||||
const WINDOWS_DRIVE_RE = /^([A-Za-z]):[\\/](.*)$/;
|
||||
|
||||
export interface AssetPathResolution {
|
||||
/** Absolute path to stat, or null when the path is unresolvable here. */
|
||||
abs: string | null;
|
||||
/** True when storage_path is a Windows drive path this host cannot stat. */
|
||||
foreign: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a files.storage_path to a stat-able absolute path.
|
||||
* `opts.platform` / `opts.wslMountRoot` exist for tests; production callers
|
||||
* pass neither (process.platform + detected WSL automount root).
|
||||
* `wslMountRoot: null` means "not under WSL".
|
||||
*/
|
||||
export function resolveAssetPath(
|
||||
storagePath: string,
|
||||
repoRoot: string,
|
||||
opts: { platform?: NodeJS.Platform; wslMountRoot?: string | null } = {},
|
||||
): AssetPathResolution {
|
||||
const platform = opts.platform ?? process.platform;
|
||||
if (platform !== 'win32') {
|
||||
const m = WINDOWS_DRIVE_RE.exec(storagePath);
|
||||
if (m) {
|
||||
const root = opts.wslMountRoot !== undefined ? opts.wslMountRoot : detectWslMountRoot();
|
||||
if (root === null) return { abs: null, foreign: true };
|
||||
const abs = `${root.replace(/\/+$/, '')}/${m[1].toLowerCase()}/${m[2].replace(/\\/g, '/')}`;
|
||||
return { abs, foreign: false };
|
||||
}
|
||||
}
|
||||
// Platform-appropriate absoluteness (not the host's) so injected-platform
|
||||
// tests behave identically everywhere; in production platform === host.
|
||||
const isAbs = platform === 'win32' ? win32.isAbsolute(storagePath) : posix.isAbsolute(storagePath);
|
||||
return {
|
||||
abs: isAbs ? storagePath : join(repoRoot, storagePath),
|
||||
foreign: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the `[automount] root` value from /etc/wsl.conf content.
|
||||
* Defaults to `/mnt` (WSL's own default) when absent/unparseable.
|
||||
*/
|
||||
export function parseWslAutomountRoot(conf: string): string {
|
||||
let inAutomount = false;
|
||||
for (const raw of conf.split(/\r?\n/)) {
|
||||
const line = raw.replace(/[#;].*$/, '').trim();
|
||||
if (line.startsWith('[')) {
|
||||
inAutomount = /^\[automount\]$/i.test(line);
|
||||
continue;
|
||||
}
|
||||
if (!inAutomount) continue;
|
||||
const m = /^root\s*=\s*"?([^"]+?)"?\s*$/.exec(line);
|
||||
if (m) return m[1];
|
||||
}
|
||||
return '/mnt';
|
||||
}
|
||||
|
||||
let cachedWslMountRoot: string | null | undefined;
|
||||
|
||||
/**
|
||||
* Detect the WSL Windows-drive automount root. Returns null when not running
|
||||
* under WSL (including macOS and plain Linux). Memoized per process.
|
||||
*/
|
||||
export function detectWslMountRoot(): string | null {
|
||||
if (cachedWslMountRoot === undefined) cachedWslMountRoot = computeWslMountRoot();
|
||||
return cachedWslMountRoot;
|
||||
}
|
||||
|
||||
function computeWslMountRoot(): string | null {
|
||||
if (process.platform !== 'linux') return null;
|
||||
try {
|
||||
// The standard WSL tell: kernel version string names Microsoft.
|
||||
if (!/microsoft/i.test(readFileSync('/proc/version', 'utf8'))) return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return parseWslAutomountRoot(readFileSync('/etc/wsl.conf', 'utf8'));
|
||||
} catch {
|
||||
return '/mnt'; // WSL default when wsl.conf is absent.
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user