* feat(migrate): provider-agnostic embedding migration service — the path off ZeroEntropy (#3390) - gbrain migrate embeddings --to <provider:model> (alias: retrieval-upgrade): plan + cost preflight, consent gate (--yes / TTY confirm / non-TTY exit 2), live probe against the target provider before any mutation, env-override gate, schema dimension transition via the shared runSchemaTransition, dual-plane config write, NULL-signature-inclusive invalidation, query-cache purge, resumable re-embed through the standard embed pipeline (single-flight locks, backoff, pacing, stderr progress). Killed runs resume by re-running the same command; the NULL-embedding column is the checkpoint. - #3391 root-cause fix (both engines): countStaleChunks / sumStaleChunkChars / invalidateStaleSignatureEmbeddings accept includeNullSignature to lift the v108 grandfather clause; embed --stale warns loudly when a model swap leaves NULL-signature pages in the old embedding space, and --include-null-signature re-embeds them. Default sweep behavior unchanged. - knobs_hash v=12 → v=13 (prov=default legacy callers must not be served pre-migration cache rows). - migrate_embeddings op: scope admin, localOnly, hidden cliHints, hard remote refusal, needs_confirmation without yes=true. - One-shot post-upgrade ZE-sunset banner (ze_sunset_notice_shown) for brains resolving to a zeroentropyai:* embedding model or reranker. - doctor's dimension-mismatch repair hint now names the real command. - Docs: docs/guides/embedding-migration.md, KEY_FILES entries, spend-controls gate row. Tests: PGLite unit + full-lifecycle flow (interrupted-run resume), real-Postgres e2e (pgvector DDL path + #3391 predicate parity). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): satisfy check:test-isolation + bump the remaining knobs_hash pins - test/migrate-embeddings-flow.test.ts → .serial.test.ts: the file holds a temp GBRAIN_HOME + an installed fake embed transport for its whole lifecycle (beforeAll → afterAll), which withEnv() can't wrap. This also fixes the CI shard-pollution failure in test/ai/recipes-existing-regression.test.ts (that file passes solo on both master and this branch; the flow test's configureGateway + provider-key deletion was leaking into it inside the same shard process). - test/embedding-migration.test.ts: env-override case now uses withEnv(). - Bump the three remaining KNOBS_HASH_VERSION pins to 13 (cross-modal-phase1, search-alias-resolved-boost, search/knobs-hash-reranker). - Docs + llms bundles follow the test rename. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(test): wire the new Postgres e2e into the smart e2e selector map Changes to embed.ts / embedding-migration.ts / retrieval-upgrade-planner.ts / postgres-engine.ts now trigger test/e2e/migrate-embeddings-postgres.test.ts — the #3391 stale predicates and runSchemaTransition's DDL path behave differently on real pgvector than on PGLite, so the smart selector has to know. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(migrate): consult spend.posture in the embedding-migration consent gate The brief asked the gate to honor spend.posture; it previously didn't read it at all. Now it does — but deliberately does NOT bypass on tokenmax: posture waives the spend CEILING, and this gate also guards a destructive schema rebuild (existing vectors dropped, retrieval degraded until the re-embed finishes). Under tokenmax the dollar figure is marked informational on stderr and the confirmation is still asked; --yes stays the single scripted bypass. Pinned by a new case in the flow test so a later refactor can't quietly turn posture into a bypass. Guide + spend-controls table updated to match. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * wip: blocker fixes --------- Co-authored-by: Garry Tan <garrytan@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
6.8 KiB
Progress events
Canonical reference for the JSONL progress stream that gbrain writes to
stderr when a bulk command runs with --progress-json. Stable from
v0.15.2. Additive changes only; no renames or removals without a major
version bump.
Most humans won't read this page. Agents parsing progress will.
When do I get these events?
Any of these commands stream events when --progress-json is set:
gbrain doctor(DB checks, JSONB integrity, markdown body completeness, integrity sample)gbrain orphansgbrain embedgbrain files syncgbrain exportgbrain extract [links|timeline|all](fs or db source)gbrain importgbrain syncgbrain migrate --to …gbrain repair-jsonbgbrain check-backlinksgbrain lintgbrain integrity autogbrain evalgbrain apply-migrations(the orchestrator + every child command)
Non-bulk commands (stats, graph-query, get, put, etc.) don't emit
events — they return in under a second.
Channel
- Progress events:
stderr, one JSON object per line,\n-terminated. - Data results (
--jsonpayloads from each command):stdout. - Final human summaries:
stdout.
Agents can safely capture stdout for their result parsing and read stderr separately for progress.
Flags
| Flag | Behavior |
|---|---|
| (none) | Auto. TTY: \r-rewriting single line. Non-TTY: plain line-per-event on stderr. |
--progress-json |
Force JSON-lines mode on stderr (this doc). |
--quiet |
Suppress progress entirely. Warnings and final output still print. |
--progress-interval=<ms> |
Override the minimum interval between tick emits (default 1000). |
Global flags: parsed by src/core/cli-options.ts before command dispatch,
so gbrain --progress-json doctor works the same as
gbrain doctor --progress-json (the latter also works — per-command
parsers see the flag via the shared CliOptions singleton).
Event types
Every event is a single-line JSON object with these common fields:
| Field | Type | Notes |
|---|---|---|
event |
string | One of: start, tick, heartbeat, finish, abort. |
phase |
string | Machine-stable snake_case, dot-separated. See "Phase names" below. |
ts |
ISO 8601 UTC string | Event emission time. |
elapsed_ms |
number | Ms since the phase started. Present on tick/heartbeat/finish/abort. |
start
Emitted when a phase begins.
{"event":"start","phase":"doctor.db_checks","ts":"2026-04-20T12:34:56.789Z"}
{"event":"start","phase":"import.files","total":52000,"ts":"2026-04-20T12:34:56.789Z"}
Optional fields:
total— the total item count if known at start.
tick
Emitted periodically during iteration. Time- and item-gated: the reporter
won't emit more often than minIntervalMs (default 1000) and
minItems (default max(10, ceil(total/100))).
{"event":"tick","phase":"orphans.scan","done":15000,"total":52000,"pct":28.8,"elapsed_ms":4200,"eta_ms":10300,"ts":"..."}
Fields:
done— items completed in this phase.total— total items, if known. Omitted when the scan doesn't have a total up front (e.g. a streaming iterator).pct—done/total * 100, one decimal. Omitted whentotalis unknown.eta_ms— projected ms untildone === total, from the observed rate. Omitted whentotalis unknown.note— optional string with the current item (e.g. a slug or filename).
heartbeat
Emitted for long-running single operations that don't iterate
(e.g. SELECT against a 50K-row table). No done, no total — just a
signal that work is still happening.
{"event":"heartbeat","phase":"doctor.markdown_body_completeness","note":"scanning pages for truncation…","elapsed_ms":1000,"ts":"..."}
finish
Emitted when a phase completes normally.
{"event":"finish","phase":"import.files","done":52000,"total":52000,"elapsed_ms":187000,"ts":"..."}
abort
Emitted by a single process-level SIGINT/SIGTERM handler that tracks every
live phase. After abort, no further events emit for that phase.
{"event":"abort","phase":"doctor.markdown_body_completeness","reason":"SIGINT","elapsed_ms":5300,"ts":"..."}
Phase names
Phases use snake_case.dot.path naming. A fresh reporter starts at the
root; child() composition appends to the parent's current phase, so a
sync that calls import emits sync.import.<file>, not import.<file>.
Stable phase names shipped in v0.15.2:
doctor.db_checks(umbrella for all DB-side doctor checks)orphans.scanembed.pagesextract.links_fs,extract.timeline_fs,extract.links_db,extract.timeline_dbimport.filessync.deletes,sync.renames,sync.importsmigrate.copy_pages,migrate.copy_linksmigrate.reembed(the re-embed pass ofgbrain 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.scanlint.pagesintegrity.autoeval.single,eval.abexport.pagesfiles.sync
Sub-phases exposed via child():
sync.import.files— nested inside a syncapply_migrations.v0_12_2.jsonb_repair— nested inside the orchestrator
Subprocess inheritance
When a parent CLI spawns gbrain … child processes (mostly in
src/commands/migrations/*), global flags (--quiet, --progress-json,
--progress-interval) are propagated to the child's argv via the
childGlobalFlags() helper in src/core/cli-options.ts. Child stderr
passes straight through stdio: 'inherit' so the event stream is one
merged JSONL feed on the parent's stderr.
One exception: the orchestrator phase in migrations/v0_12_2.ts that
captures child stdout (repair-jsonb --dry-run --json for verification)
does not pass --progress-json to avoid any risk of stdout pollution
breaking the orchestrator's JSON.parse. Its stdio is explicit:
['ignore', 'pipe', 'inherit'] so stderr still flows through.
Minion jobs
gbrain jobs work (the Minion worker daemon) keeps progress in the DB,
not on stderr. Each Minion handler that runs a bulk core (embed, sync,
extract, import, backlinks) calls job.updateProgress({done, total, …}) per iteration. Agents read per-job progress via the
get_job_progress MCP operation or gbrain jobs get <id>.
The jobs work daemon itself emits coarse one-line-per-job stderr output
for liveness only. Per-page detail lives in the DB.
Compatibility
- Added: only. A new event type, a new field, a new phase name — all safe. Agents must ignore unknown fields and unknown event types.
- Removed/renamed: never without a major version bump.
- Schema changes: announced in
CHANGELOG.mdand inskills/migrations/v<next>.md.
If your agent depends on this schema and something surprises you, open an issue with the event you received and what you expected.