mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 08:53:22 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd9522f724 |
@@ -1,5 +1,23 @@
|
||||
# ZeroEntropy — zembed-1 + zerank-2
|
||||
|
||||
> **Hosted API shutdown: 2026-09-04.** ZeroEntropy announced (2026-07-24)
|
||||
> that its hosted endpoints — `/models/embed` and `/models/rerank` — shut
|
||||
> down on that date. A brain still embedding through the hosted API loses
|
||||
> semantic retrieval entirely on that date: query embedding uses the same
|
||||
> endpoint, so **existing vectors become unqueryable**, not just new
|
||||
> content. Two fixes, either works:
|
||||
>
|
||||
> 1. **Self-host the same model** — zembed-1 weights are Apache-2.0. Serve
|
||||
> them via `llama-server` or Ollama and point the config at the local
|
||||
> endpoint. Keeps every existing vector; no re-embed at all.
|
||||
> 2. **Migrate to another provider** — `gbrain migrate embeddings --to
|
||||
> <provider:model> --dim <N> --dry-run` (resumable; see
|
||||
> [the migration guide](../guides/embedding-migration.md)). `gbrain
|
||||
> doctor` (check `provider_sunset`) prints this command with your
|
||||
> brain's actual `--dim` filled in.
|
||||
>
|
||||
> The hosted setup below remains accurate until the shutdown date.
|
||||
|
||||
[ZeroEntropy](https://zeroentropy.dev) ships two specialized small models
|
||||
for retrieval pipelines:
|
||||
|
||||
|
||||
@@ -195,7 +195,7 @@ per-release `**vX.Y.Z:**` narration — CI enforces this
|
||||
- `src/commands/embed.ts` — `gbrain embed [--stale|--all] [--slugs ...]`. `--stale` starts with `engine.countStaleChunks()` (single SELECT count(*) WHERE embedding IS NULL, ~50 bytes wire) so a fully-embedded brain short-circuits with no further reads. When stale chunks exist, `engine.listStaleChunks()` returns just the chunks needing embeddings (slug + chunk_index + chunk_text + metadata, no `vector(1536)` payload); caller groups by slug, embeds, re-upserts via `upsertChunks`. All `console.log`/`console.error` call sites use `slog`/`serr` from `src/core/console-prefix.ts` so when `runEmbedCore` runs inside a per-source `withSourcePrefix` scope (installed by the `gbrain sync --all` worker pool) every line carries the `[<source-id>] ` prefix; standalone callers see identical output because slog/serr fall through to bare console fns outside the wrap. Every embed-write path stamps `pages.embedding_signature` via `engine.setPageEmbeddingSignature(slug, {sourceId, signature: currentEmbeddingSignature()})` so a later model/dims swap is detectable as stale. The per-slug path (`embedPage`, used by `gbrain embed <slug>` AND sync's post-import embed step) and the full-re-embed path (`embedAll`) stamp unconditionally per page. The stale path (`embedAllStale`) first calls `invalidateStaleSignatureEmbeddings` on a live run so signature-drifted pages flow through the NULL cursor, then stamps each page — but ONLY when EVERY chunk was stale this pass (a partially-stale page keeps preserved chunks of unknown provenance, so it stays unstamped rather than falsely marked current; `embed --all` fully re-embeds + stamps those). dry-run never mutates: it counts signature-drift via the widened `countStaleChunks({signature})` predicate without NULLing anything. `--include-null-signature` (#3391) lifts the NULL-signature grandfather clause: threads `includeNullSignature: true` into the invalidation + counts so pages that predate the v108 stamp re-embed too after a model swap (both engines' `countStaleChunks`/`sumStaleChunkChars`/`invalidateStaleSignatureEmbeddings` accept the flag; predicate becomes `sig IS NULL OR sig <> current`). Without the flag, a live stale run that just invalidated drifted rows probes for left-behind NULL-signature chunks and emits a loud stderr warning naming the count + the fix — mixed embedding spaces in one index are never silent. Pinned by `test/embedding-migration.test.ts` + `test/e2e/migrate-embeddings-postgres.test.ts`.
|
||||
- `src/core/retrieval-upgrade-planner.ts` — `runSchemaTransition(engine, targetDim)` (exported) is the ONE atomic dimension-transition path, shared by `ze-switch` and `gbrain migrate embeddings`. In a single transaction it rebuilds ALL THREE dim-pinned text-embedding-space columns at `targetDim` — `content_chunks.embedding`, `query_cache.embedding`, `facts.embedding` — preserving each column's declared type (`vector` vs `halfvec`, probed from `information_schema`) and recreating its HNSW index with the matching opclass, gated on `hnswIndexExpected` (above the per-type dim ceiling pgvector refuses the index and exact scans remain the path). query_cache + facts are created at brain-birth width by `migrate.ts` and NO migration ever ALTERs them, so omitting either leaves it silently broken: a narrow `query_cache.embedding` makes every `store()`/`lookup()` fail inside the cache's own error-swallowing (permanent 0% hit rate), and a narrow `facts.embedding` fails every per-fact embed write (the doctor check that would warn is skipped on PGLite, the default engine). `content_chunks.embedding_image` / `embedding_multimodal` are the deliberate exception — separate multimodal models, dimensions independent of the text model. Pinned by `test/embedding-migration.test.ts` (all three widths + a real INSERT at the new width into each) and `test/e2e/migrate-embeddings-postgres.test.ts`.
|
||||
- `src/core/embedding-migration.ts` — provider-agnostic embedding migration core (#3390): `planEmbeddingMigration` (workload counts via the widened stale predicates with the TARGET signature + `includeNullSignature`, so a mid-migration re-plan counts only what remains; cost via `embedding-pricing.ts`; `null_signature_chunks` split out for #3391 visibility; reranker-on-outgoing-provider warning), `applyEmbeddingMigration` (env-override gate BEFORE any mutation → in-flight state marker `embedding_migration.state` → `runSchemaTransition` when the ACTUAL column width differs from target → DB-plane `embedding_model`/`embedding_dimensions` → `persistConfig` callback for the file plane → `invalidateStaleSignatureEmbeddings({includeNullSignature: true})` → `SemanticQueryCache.clear()`), `completeEmbeddingMigration` (clears the marker + stamps `embedding_migration.completed`; call only at zero backlog), `resolveMigrationTarget` (validates `provider:model` via `resolveRecipe`, dims via `embeddingDimsForModel` or explicit `--dim`), `migrationSignature` (matches `currentEmbeddingSignature()` shape). Engine-pure; every step idempotent under crash + re-run — the NULL-embedding column is the checkpoint. Reuses `runSchemaTransition` (now exported from `retrieval-upgrade-planner.ts`) so ze-switch and the migration share ONE dimension-transition path. `reconcilePageSignatures(engine, plan)` runs after the re-embed drain and BEFORE the completion probe: it stamps the target signature on every page that has zero NULL-embedding chunks, covering pages whose chunks straddle a `listStaleChunks` batch boundary (the embed loop only stamps when `stale.length === existing.length`, so a split page is embedded correctly but never stamped — without the reconcile a >1-batch brain reports "incomplete" and the re-run re-invalidates and re-pays for those pages). Sound only because apply() invalidated everything not already in the target space; pages with a remaining NULL chunk stay unstamped so a real embed failure still surfaces. Invalidation is ordered BEFORE the config writes so a crash on a same-dim swap leaves rows merely stale (empty results) rather than new-space queries scored against old-space vectors (silently wrong). Pinned by `test/embedding-migration.test.ts` (PGLite) + `test/e2e/migrate-embeddings-postgres.test.ts` (real pgvector).
|
||||
- `src/commands/migrate-embeddings.ts` — `gbrain migrate embeddings --to <provider:model> [--dim N] [--dry-run] [--yes] [--json] [--no-embed] [--pace[=mode]] [--ignore-env-override]` (alias: `gbrain retrieval-upgrade`, the command README/doctor promised since v0.36). Flow: plan → render (stderr when `--json` so stdout stays JSON-clean) → consent gate (TTY y/N prompt or `--yes`; non-TTY without `--yes` refuses exit 2, mirroring the reindex-code cost gate) → live probe (one embed against the TARGET model/dims BEFORE any mutation — bad key/model/dim fails with nothing changed) → `applyEmbeddingMigration` with `persistEmbeddingFileConfig` (writes `~/.gbrain/config.json` + reconfigures the in-process gateway — the gateway reads file/env, NOT the DB plane) → `runEmbedCore({stale, catchUp, singleFlight, includeNullSignature, pace})` → drain check → `completeEmbeddingMigration` or exit 1 with the resume hint (re-run the same command). Also surfaced as the `migrate_embeddings` op (scope admin, localOnly, hidden cliHints; handler hard-refuses `ctx.remote !== false` and returns `needs_confirmation` + plan without `yes: true`). Pinned by `test/migrate-embeddings-flow.serial.test.ts` (full lifecycle incl. interrupted-run resume on PGLite).
|
||||
- `src/commands/migrate-embeddings.ts` — `gbrain migrate embeddings --to <provider:model> [--dim N] [--dry-run] [--yes] [--json] [--no-embed] [--pace[=mode]] [--ignore-env-override]` (alias: `gbrain retrieval-upgrade`, the command README/doctor promised since v0.36). Flow: plan → render (stderr when `--json` so stdout stays JSON-clean) → consent gate (TTY y/N prompt or `--yes`; non-TTY without `--yes` refuses exit 2, mirroring the reindex-code cost gate) → live probe (one embed against the TARGET model/dims BEFORE any mutation — bad key/model/dim fails with nothing changed) → `applyEmbeddingMigration` with `persistEmbeddingFileConfig` (writes `~/.gbrain/config.json` + reconfigures the in-process gateway — the gateway reads file/env, NOT the DB plane) → `runEmbedCore({stale, catchUp, singleFlight, includeNullSignature, pace})` → drain check → `completeEmbeddingMigration` or exit 1 with the resume hint (re-run the same command). When the re-embed was skipped because another backfill holds the per-source single-flight lock (`EmbedResult.lock_skipped` — the state a hard-killed run leaves behind for up to the 60-min lock TTL), the exit-1 message says so instead of misreporting embed failures, since an immediate re-run cannot resume until the lock expires. Also surfaced as the `migrate_embeddings` op (scope admin, localOnly, hidden cliHints; handler hard-refuses `ctx.remote !== false` and returns `needs_confirmation` + plan without `yes: true`). Pinned by `test/migrate-embeddings-flow.serial.test.ts` (full lifecycle incl. interrupted-run resume on PGLite) + `test/provider-sunset-doctor.serial.test.ts` (lock-skip visibility). Discovery surfaces for brains pinned to a sunsetting provider: the `provider_sunset` doctor check (every run; paste-ready command with the ACTUAL column width via `readContentChunksEmbeddingDim`, warn before / fail after the date, self-host escape hatch given equal billing) and the one-shot `gbrain upgrade` banner (`ze_sunset_notice_shown`); the shutdown date lives once as `ZEROENTROPY_SUNSET_DATE` in `src/core/ai/defaults.ts`.
|
||||
- `src/core/conversation-parser/` — 17-pattern built-in chat-format registry + opt-in LLM polish/fallback. Modules: `types.ts` (PatternEntry + ParseResult + DateContext + CaptureMap + TimezonePolicy), `builtins.ts` (17 hand-vetted patterns sourced from public format docs — iMessage/Slack, Telegram bracket + text-export, bold-paren-time, bold-time-dash, bold-name-no-time, Discord classic + export, WhatsApp ISO + US, Signal, Matrix/Element, IRC classic + weechat, Teams export; module-load validation runs every `test_positive[]` + `test_negative[]` sample at startup so a typo in any built-in regex makes gbrain refuse to start; `DEFAULT_SPEAKER_CLEAN` exported as a module-level default), `parse.ts` (orchestrator with pattern-priority scoring across the first 10 lines + date derivation chain `explicit > frontmatter.date > effective_date > '1970-01-01'` + multi-line continuation + timezone warning), `llm-base.ts` (shared `runLlmCall<T>` with content-hash cache in-process + DB-persistent via migration v97 + 4-strategy JSON repair + Anthropic-key probe; polish and fallback are thin wrappers), `llm-polish.ts` (opt-IN; headroom guard skips when tracker within $0.10 of cap; pure `applyPolish` for merge/drop/edit ops), `llm-fallback.ts` (opt-IN; NO regex inference + NO persistence), `eval.ts` (`scoreFixture` + `aggregateScores` + `parseFixtureJsonl` for the fixture-corpus CI gate), `nightly-probe.ts` (DI-stubbed; mode-gated default tokenmax=ON, conservative/balanced opt-in; adversarial false-positive detection). Pattern `bold-name-no-time` (regex `/^\*\*(?!\[)(.+?):\*\*\s*(.*)$/`, ordered after the time-bearing bold patterns) parses `**Speaker:** text` with NO per-line timestamp (Circleback/Granola/Zoom), anchoring every message at `T00:00:00Z` of the frontmatter date (line order preserves sequence, same no-time convention as `irc-classic`); the `(?!\[)` lookahead rejects telegram-bracket `**[18:37] Name:**`; non-shadow is the colon-INSIDE-bold regex (NOT declaration order — `parse.ts` scores every candidate independently, order is only the tie-break). Because `**Label:** text` is a common prose idiom, the pattern sets optional `PatternEntry.score_full_body: true` so `parse.ts` recomputes the winner's acceptance score over the FULL body before the `SCORING_MIN_ACCEPTANCE` floor, keeping a bold-label notes page at `no_match`. Pattern `bold-paren-time` parses `**Speaker** (HH:MM): text` and `(HH:MM:SS)` (date_source: frontmatter). Fallback gates: `SCORING_HEAD_TRIGGER_THRESHOLD = 0.3` triggers a full-body re-score when the head pass scores below that; `SCORING_MIN_ACCEPTANCE = 0.05` blocks essay false-positives. Exported `scorePatternFull(body, entry)`; private `getNonBlankLines(body, headCap?)` + `scoreFromLines(lines, entry)` DRY the quick_reject+regex loop. CLI surfaces at `src/commands/eval-conversation-parser.ts` (`gbrain eval conversation-parser <fixture.jsonl>` exit 0/1/2, wired into `bun run verify` via `check:conversation-parser`) and `src/commands/conversation-parser.ts` (`scan <slug>` debug, `list-builtins`, `validate <file>`). Doctor checks: `conversation_format_coverage`, `progressive_batch_audit_health`, `conversation_parser_probe_health`. Pinned by `test/conversation-parser/{parse,llm-base,llm-fallback,llm-polish,nightly-probe}.test.ts` + the 27-case baseline at `test/extract-conversation-facts.test.ts` (back-compat invariant). Migration v97 (`conversation_parser_llm_cache_table`). Fixtures at `test/fixtures/conversation-formats/{imessage,imessage-time-only-12h,telegram-bracket,whatsapp-iso,whatsapp-us,signal-export,irc-classic,irc-weechat,matrix-element,teams-export,all,adversarial,bold-name-no-time,bold-time-dash}.jsonl` with `scripts/check-fixture-privacy.sh` banning real-name leaks. Maintainer guidance: [conversation parser patterns](conversation-parser-patterns.md).
|
||||
- `src/core/progressive-batch/` — shared ramp-up + cost-cap + verification primitive (trial 10 → ramp 100 → ramp 500 → full, with verification at each stage), with verifier+policy injection (callers describe HOW TO MEASURE SUCCESS, not WHEN TO WAIT FOR CTRL-C). Modules: `types.ts` (Stage, StageVerdict, AbortReason, discriminated `Verifier` union `OutputCountVerifier | IdempotentMutationVerifier | NoopVerifier`, Policy, StageReport), `orchestrator.ts` (`runProgressiveBatch(items, verifier, policy, runner)` — reads `getCurrentBudgetTracker()` ahead of `Policy.maxCostUsd` fail-closed; null both ways triggers `abort_cost_cap reason='no_budget_safety_net'`), `audit.ts` (ISO-week JSONL at `~/.gbrain/audit/progressive-batch-YYYY-Www.jsonl` via the shared `audit-writer` primitive), `stage-report.ts` (ASCII formatter for the default `Policy.onStageReport`). Env knobs: `GBRAIN_PROGRESSIVE_BATCH_DISABLED=1`, `GBRAIN_PROGRESSIVE_BATCH_AUTO=1` (skip Ctrl-C grace), `GBRAIN_PROGRESSIVE_BATCH_STAGES=10,100,500`. Sites that "jump straight to full" stay that way by default; ramp is opt-in per-site via `Policy.interactiveAbortMs > 0`. Pinned by `test/progressive-batch/orchestrator.test.ts` (35 cases, every verdict path).
|
||||
- `src/commands/extract-conversation-facts.ts` + `src/core/cycle/conversation-facts-backfill.ts` — bulk fact extraction for long-form conversation pages. Walks conversation/meeting/slack/email/imessage/imessage-daily pages, splits them into time-windowed segments (30-min gap or 30-msg cap), prepends a topical/temporal header, and uses the strict `extractFactsFromTurnWithOutcome()` path so provider and output failures remain retryable instead of becoming successful empty pages. Invariants: **strict per-source core** (`runExtractConversationFactsCore({sourceId, ...})` always takes one sourceId; CLI + cycle phase each do their own multi-source iteration because `PHASE_SCOPE='source'` is taxonomy-only); **bounded two-phase enumeration** (paginated `listPages({type, sourceId, limit:10})`; per-page body cap `MAX_PAGE_BODY_BYTES=25MB`); **page-global `row_num` accumulator** (the facts unique index is `(source_id, source_markdown_slug, row_num)`); **versioned snapshot-bound outcomes** (`cli:extract-conversation-facts:terminal:v2` for complete pages and a separate `non-extractable:v2` source for recognized pages with no eligible segment); **operation checkpoints are scheduling hints only** and never suppress a replay without a matching v2 outcome; **optional `opts.budgetTracker?`** is used as-is, while an absent tracker is created with `maxCostUsd`; **body reads cover compiled truth, timeline, and configured raw-transcript sidecars**; **`facts.extraction_enabled` kill-switch** with `--override-disabled`; **`--types LIST` allowlist** (`conversation,meeting,slack,email,imessage,imessage-daily`); and **`--background` via `maybeBackground`**. The companion `conversation_facts_backfill` cycle phase is default-off, iterates every source, and enforces per-source plus brain-wide cost and wall-time caps. Migration v94 provides the partial facts index used by outcome lookups. `computeConversationFactsBacklogCheck` reports fresh completed, scanned-not-extractable, and unfinished counts separately, warning when more than 10 eligible pages lack a fresh v2 outcome. `sources audit` exposes `facts_backfill_estimate: {pages, est_segments, est_cost_usd, types}`. Pinned by `test/extract-conversation-facts.test.ts` and `test/doctor-conversation-facts-backlog.test.ts`.
|
||||
|
||||
@@ -27,6 +27,31 @@ gbrain migrate embeddings --to voyage:voyage-3-large --yes
|
||||
declared width and is required for recipes that don't declare one (litellm,
|
||||
llama-server, and other bring-your-own-model providers).
|
||||
|
||||
**Pick `--dim` = your brain's current column width when the target supports
|
||||
it.** A different width triggers the destructive schema transition (column +
|
||||
index rebuild across all three dim-pinned tables); the same width skips it
|
||||
entirely. `gbrain doctor` (check `provider_sunset`, for providers with an
|
||||
announced shutdown) prints the paste-ready command with your actual width
|
||||
already filled in — it reads the real `vector(N)` column, not the config
|
||||
value, which can drift.
|
||||
|
||||
## How affected brains find out (provider sunsets)
|
||||
|
||||
Two surfaces flag a brain whose embedding model (or reranker) is on a
|
||||
provider with an announced hosted-API shutdown, such as ZeroEntropy
|
||||
(2026-09-04):
|
||||
|
||||
- **`gbrain doctor`** — the `provider_sunset` check warns on every run until
|
||||
the brain is off the provider (and fails after the shutdown date, when
|
||||
retrieval is already down). The message carries the paste-ready migration
|
||||
command with the brain's actual `--dim`.
|
||||
- **`gbrain upgrade`** — a one-shot banner (gated by
|
||||
`ze_sunset_notice_shown`) with the same two fixes.
|
||||
|
||||
Both state the full consequence: after the shutdown, **existing vectors
|
||||
become unqueryable** — query embedding uses the same endpoint as ingestion —
|
||||
not just new content.
|
||||
|
||||
## What it does, in order
|
||||
|
||||
1. **Plan.** Counts every chunk not already in the target embedding space —
|
||||
@@ -88,6 +113,12 @@ 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.
|
||||
|
||||
One caveat after a HARD kill (SIGKILL, crash, power loss — not Ctrl-C): the
|
||||
run's per-source single-flight embed lock is left behind, and an immediate
|
||||
re-run skips the re-embed and reports the migration as paused. The command
|
||||
says so explicitly (`lock_skipped` in `--json`); the lock expires on its own
|
||||
after at most 60 minutes, then the same re-run resumes normally.
|
||||
|
||||
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
|
||||
|
||||
@@ -23,7 +23,7 @@ The resolved provider + dimensions get persisted to `~/.gbrain/config.json` atom
|
||||
|
||||
| Provider | env vars | default dims | cost ($/1M tokens) | local? | multimodal? |
|
||||
|---|---|---|---|---|---|
|
||||
| `zeroentropyai` | `ZEROENTROPY_API_KEY` | 2560 (Matryoshka to 1280/640/320/...) | 0.05 | no | no |
|
||||
| `zeroentropyai` (hosted API **shuts down 2026-09-04** — see note below) | `ZEROENTROPY_API_KEY` | 2560 (Matryoshka to 1280/640/320/...) | 0.05 | no | no |
|
||||
| `openai` | `OPENAI_API_KEY` | 1536 | 0.13 | no | no |
|
||||
| `openrouter` | `OPENROUTER_API_KEY` | 1536 | 0.02 | no | model-dependent |
|
||||
| `voyage` | `VOYAGE_API_KEY` | 1024 | 0.18 | no | yes (`voyage-multimodal-3`) |
|
||||
@@ -42,6 +42,8 @@ The resolved provider + dimensions get persisted to `~/.gbrain/config.json` atom
|
||||
|
||||
**Note on local providers.** Ollama and llama-server have no required API key, so they don't show up in env-detection auto-pick. Pick them explicitly with `--embedding-model ollama:<model>` to avoid silently routing to a daemon that may not be running.
|
||||
|
||||
**Note on the ZeroEntropy hosted API.** ZeroEntropy announced (2026-07-24) that its hosted endpoints shut down on **2026-09-04**. A brain still embedding through the hosted API loses semantic retrieval entirely on that date — query embedding uses the same endpoint, so existing vectors become unqueryable, not just new content. Either self-host the Apache-2.0 zembed-1 weights via llama-server/Ollama (keeps every existing vector, no re-embed), or migrate with `gbrain migrate embeddings` — see [the migration guide](../guides/embedding-migration.md). `gbrain doctor` (check `provider_sunset`) flags affected brains and prints the paste-ready command with the brain's actual `--dim` filled in.
|
||||
|
||||
## If first import fails
|
||||
|
||||
If `gbrain import` fails with `expected N dimensions, not M`, run `gbrain doctor`. The output will print the exact `gbrain config set ...` or `gbrain retrieval-upgrade` command to repair the mismatch. **You should not need to delete `~/.gbrain`.** The bug-class that historically forced `rm -rf` recoveries is closed as of v0.37.
|
||||
|
||||
@@ -2337,6 +2337,84 @@ export async function checkZeEmbeddingHealth(engine: BrainEngine): Promise<Check
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* provider_sunset doctor check (#3390 follow-up).
|
||||
*
|
||||
* Detects a brain whose EFFECTIVE embedding model (gateway-resolved, which is
|
||||
* how default-config brains land on the shipped default) is on a provider
|
||||
* with an announced hosted-API shutdown, and prints a paste-ready migration
|
||||
* command with the brain's ACTUAL `content_chunks.embedding` column width
|
||||
* filled in — not the config value, which can drift. Keeping the current
|
||||
* width avoids a needless dimension transition + index rebuild when the
|
||||
* target supports it.
|
||||
*
|
||||
* Unlike the one-shot upgrade banner (`ze_sunset_notice_shown`), this fires
|
||||
* on every `gbrain doctor` run until the brain is off the provider —
|
||||
* warn before the shutdown date, fail after it (retrieval is down by then).
|
||||
* No network call; one catalog query for the column width.
|
||||
*/
|
||||
export async function checkProviderSunset(engine: BrainEngine): Promise<Check> {
|
||||
const name = 'provider_sunset';
|
||||
try {
|
||||
const { DEFAULT_EMBEDDING_MODEL, ZEROENTROPY_SUNSET_DATE } = await import('../core/ai/defaults.ts');
|
||||
// Effective model: gateway when configured (file/env plane, the runtime
|
||||
// truth); the shipped default otherwise — an unset-config brain resolves
|
||||
// to the default at runtime, so it is just as affected.
|
||||
let model = DEFAULT_EMBEDDING_MODEL;
|
||||
let reranker: string | undefined;
|
||||
try {
|
||||
const { getEmbeddingModel, getRerankerModel } = await import('../core/ai/gateway.ts');
|
||||
model = getEmbeddingModel();
|
||||
reranker = getRerankerModel();
|
||||
} catch {
|
||||
// Gateway unconfigured — runtime resolves the shipped default.
|
||||
}
|
||||
const onSunsetEmbedding = model.startsWith('zeroentropyai:');
|
||||
const onSunsetReranker = !!reranker?.startsWith('zeroentropyai:');
|
||||
if (!onSunsetEmbedding && !onSunsetReranker) {
|
||||
return {
|
||||
name,
|
||||
status: 'ok',
|
||||
message: `No configured provider has an announced shutdown (embedding: ${model}).`,
|
||||
};
|
||||
}
|
||||
const past = Date.now() >= Date.parse(`${ZEROENTROPY_SUNSET_DATE}T00:00:00Z`);
|
||||
const parts: string[] = [];
|
||||
if (onSunsetEmbedding) {
|
||||
let dims: number | null = null;
|
||||
try {
|
||||
const { readContentChunksEmbeddingDim } = await import('../core/embedding-dim-check.ts');
|
||||
dims = (await readContentChunksEmbeddingDim(engine)).dims;
|
||||
} catch {
|
||||
// Column probe failed (fresh/odd brain) — omit --dim from the hint.
|
||||
}
|
||||
const dimFlag = dims ? ` --dim ${dims}` : '';
|
||||
parts.push(
|
||||
past
|
||||
? `embedding_model="${model}": the hosted API shut down on ${ZEROENTROPY_SUNSET_DATE} — semantic retrieval is offline (queries can no longer be embedded against your existing vectors).`
|
||||
: `embedding_model="${model}": the hosted API shuts down on ${ZEROENTROPY_SUNSET_DATE}. On that date semantic retrieval stops entirely — existing vectors become unqueryable (query embedding uses the same endpoint), not just new content.`,
|
||||
);
|
||||
parts.push(
|
||||
`Two fixes, either works: ` +
|
||||
`[1] self-host the same model — zembed-1 weights are Apache-2.0; serve them via llama-server or Ollama and point the config at the local endpoint. Keeps every existing vector, no re-embed (docs/guides/embedding-migration.md, "Self-hosting instead of migrating"). ` +
|
||||
`[2] migrate to another provider (resumable; preview cost first): ` +
|
||||
`gbrain migrate embeddings --to <provider:model>${dimFlag} --dry-run` +
|
||||
(dims ? ` — keep --dim ${dims} (this brain's actual index width) to avoid a needless schema rebuild when the target supports it.` : ''),
|
||||
);
|
||||
}
|
||||
if (onSunsetReranker) {
|
||||
parts.push(
|
||||
`The reranker (${reranker}) is on the same provider; after the shutdown search falls back to unreranked ordering. ` +
|
||||
`Fix: gbrain config set search.reranker.enabled false, or point search.reranker.model at another provider.`,
|
||||
);
|
||||
}
|
||||
return { name, status: past ? 'fail' : 'warn', message: parts.join(' ') };
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return { name, status: 'warn', message: `Could not check provider sunset status: ${msg}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.36.0.0 (A5): embedding_width_consistency doctor check.
|
||||
*
|
||||
@@ -7666,6 +7744,11 @@ export async function buildChecks(
|
||||
// v0.36.0.0 (A5): ZE embedding key health + schema/config width consistency.
|
||||
progress.heartbeat('ze_embedding_health');
|
||||
checks.push(await checkZeEmbeddingHealth(engine));
|
||||
// provider_sunset — brain pinned to a provider with an announced
|
||||
// hosted-API shutdown; paste-ready migration hint with the actual
|
||||
// column width. Warn before the date, fail after.
|
||||
progress.heartbeat('provider_sunset');
|
||||
checks.push(await checkProviderSunset(engine));
|
||||
progress.heartbeat('embedding_width_consistency');
|
||||
checks.push(await checkEmbeddingWidthConsistency(engine));
|
||||
// v0.41.15.0 (T6, codex #19/#20) — facts.embedding column drift
|
||||
|
||||
@@ -148,6 +148,14 @@ export interface EmbedResult {
|
||||
pages_processed: number;
|
||||
/** True if this run was a dry-run. */
|
||||
dryRun: boolean;
|
||||
/**
|
||||
* Set when a single-flight run did NO work because another backfill holds
|
||||
* the per-source embed lock. A hard-killed (SIGKILL/crash) run leaves its
|
||||
* lock behind for up to EMBED_BACKFILL_LOCK_TTL_MIN — callers that promise
|
||||
* "re-run to resume" (migrate embeddings) use this to say so instead of
|
||||
* misreporting embed failures.
|
||||
*/
|
||||
lock_skipped?: boolean;
|
||||
/**
|
||||
* E1 (paced-backfill): end-of-run pacing telemetry. Present ONLY when pacing
|
||||
* was active (enabled bundle). The number the operator could not get from an
|
||||
@@ -318,6 +326,7 @@ export async function runEmbedCore(engine: BrainEngine, opts: EmbedOpts): Promis
|
||||
try { await h.release(); } catch { /* best-effort */ }
|
||||
}
|
||||
serr(` [embed] another backfill is already running for source "${sid}"; skipping (single-flight).`);
|
||||
result.lock_skipped = true;
|
||||
return result;
|
||||
}
|
||||
sfLocks.push(lock);
|
||||
|
||||
@@ -389,7 +389,20 @@ export async function runMigrateEmbeddings(
|
||||
exit(0);
|
||||
} else {
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify({ status: 'incomplete', plan, embedded: embedResult.embedded, remaining }, null, 2));
|
||||
console.log(JSON.stringify({
|
||||
status: 'incomplete', plan, embedded: embedResult.embedded, remaining,
|
||||
...(embedResult.lock_skipped && { lock_skipped: true }),
|
||||
}, null, 2));
|
||||
} else if (embedResult.lock_skipped) {
|
||||
// E2E-observed failure mode: a hard-killed (SIGKILL/crash) migration
|
||||
// leaves its single-flight embed lock behind, and every immediate
|
||||
// re-run "resumes" without embedding anything. Say so — "re-run to
|
||||
// resume" would be a lie until the lock expires.
|
||||
const { EMBED_BACKFILL_LOCK_TTL_MIN } = await import('../core/embed-backfill-lock.ts');
|
||||
serr(`Migration paused: ${remaining} chunk(s) still stale, and the re-embed was SKIPPED because`);
|
||||
serr('another embed backfill holds the per-source lock. If that is a live run (check');
|
||||
serr('`gbrain jobs list`), let it finish. If a previous migration was killed hard, its lock');
|
||||
serr(`expires after at most ${EMBED_BACKFILL_LOCK_TTL_MIN} minutes — re-run the same command then.`);
|
||||
} else {
|
||||
serr(`Migration incomplete: ${remaining} chunk(s) still stale (embed failures or an interrupted run).`);
|
||||
serr('Re-run the same command to resume — completed chunks are never re-embedded.');
|
||||
|
||||
+31
-10
@@ -472,19 +472,29 @@ export async function runPostUpgrade(args: string[] = []): Promise<void> {
|
||||
// `ze_sunset_notice_shown` (same pattern as the search-mode banner).
|
||||
try {
|
||||
const shown = await engine.getConfig('ze_sunset_notice_shown');
|
||||
const { DEFAULT_EMBEDDING_MODEL } = await import('../core/ai/defaults.ts');
|
||||
const { DEFAULT_EMBEDDING_MODEL, ZEROENTROPY_SUNSET_DATE } = await import('../core/ai/defaults.ts');
|
||||
const effectiveModel = cfgSchema.embedding_model ?? DEFAULT_EMBEDDING_MODEL;
|
||||
const rerankerModel = await engine.getConfig('search.reranker.model');
|
||||
const onZeEmbedding = effectiveModel.startsWith('zeroentropyai:');
|
||||
const onZeReranker = !!rerankerModel?.startsWith('zeroentropyai:');
|
||||
if (shown !== 'true' && (onZeEmbedding || onZeReranker)) {
|
||||
// Paste-ready --dim from the ACTUAL column width (config can
|
||||
// drift): keeping the current width avoids a needless dimension
|
||||
// transition + index rebuild when the target supports it.
|
||||
let colDims: number | null = null;
|
||||
try {
|
||||
const { readContentChunksEmbeddingDim } = await import('../core/embedding-dim-check.ts');
|
||||
colDims = (await readContentChunksEmbeddingDim(engine)).dims;
|
||||
} catch { /* fresh brain — omit --dim */ }
|
||||
const dimFlag = colDims ? ` --dim ${colDims}` : '';
|
||||
console.log('');
|
||||
console.log('═══════════════════════════════════════════════════════════════');
|
||||
console.log('[gbrain] ACTION REQUIRED: ZeroEntropy hosted API sunsets 2026-09-04.');
|
||||
console.log(`[gbrain] ACTION REQUIRED: ZeroEntropy hosted API sunsets ${ZEROENTROPY_SUNSET_DATE}.`);
|
||||
if (onZeEmbedding) {
|
||||
console.log(`[gbrain] This brain embeds with ${effectiveModel}. After the sunset,`);
|
||||
console.log('[gbrain] semantic retrieval STOPS WORKING (queries can no longer be');
|
||||
console.log('[gbrain] embedded against your existing vectors).');
|
||||
console.log('[gbrain] semantic retrieval STOPS WORKING entirely — your EXISTING');
|
||||
console.log('[gbrain] vectors become unqueryable (queries embed through the same');
|
||||
console.log('[gbrain] endpoint), not just new content.');
|
||||
}
|
||||
if (onZeReranker) {
|
||||
console.log(`[gbrain] The reranker (${rerankerModel}) also sunsets; search falls`);
|
||||
@@ -492,17 +502,28 @@ export async function runPostUpgrade(args: string[] = []): Promise<void> {
|
||||
}
|
||||
console.log('═══════════════════════════════════════════════════════════════');
|
||||
console.log('');
|
||||
console.log('Migrate before the sunset (resumable; preview cost first):');
|
||||
console.log(' gbrain migrate embeddings --to <provider:model> --dry-run');
|
||||
console.log(' gbrain migrate embeddings --to <provider:model>');
|
||||
console.log('Two fixes, either works:');
|
||||
console.log('');
|
||||
console.log('Self-hosting zembed-1 (weights are Apache-2.0) via llama-server /');
|
||||
console.log('ollama also works and preserves your existing vectors — point');
|
||||
console.log('embedding at the local endpoint instead of migrating.');
|
||||
console.log('[1] Self-host the same model — zembed-1 weights are Apache-2.0. Serve');
|
||||
console.log(' them via llama-server or Ollama and point the config at the local');
|
||||
console.log(' endpoint. Keeps every existing vector; NO re-embed at all. See');
|
||||
console.log(' docs/guides/embedding-migration.md ("Self-hosting instead of migrating").');
|
||||
console.log('');
|
||||
console.log('[2] Migrate to another provider (resumable; preview cost first):');
|
||||
console.log(` gbrain migrate embeddings --to <provider:model>${dimFlag} --dry-run`);
|
||||
console.log(` gbrain migrate embeddings --to <provider:model>${dimFlag}`);
|
||||
if (colDims) {
|
||||
console.log(` (--dim ${colDims} is this brain's current index width — keep it to`);
|
||||
console.log(' avoid a needless schema rebuild when the target supports it.)');
|
||||
}
|
||||
if (onZeReranker) {
|
||||
console.log('');
|
||||
console.log('Reranker: gbrain config set search.reranker.enabled false (or pick another).');
|
||||
}
|
||||
console.log('');
|
||||
console.log(`\`gbrain doctor\` will keep flagging this until the brain is off the`);
|
||||
console.log('provider (check name: provider_sunset).');
|
||||
console.log('');
|
||||
await engine.setConfig('ze_sunset_notice_shown', 'true');
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -19,3 +19,14 @@
|
||||
// steps: {2560, 1280, 640, 320, 160, 80, 40} — see ai/dims.ts.
|
||||
export const DEFAULT_EMBEDDING_MODEL = 'zeroentropyai:zembed-1';
|
||||
export const DEFAULT_EMBEDDING_DIMENSIONS = 1280;
|
||||
|
||||
/**
|
||||
* ZeroEntropy announced (2026-07-24) that its hosted API — including
|
||||
* /models/embed and /models/rerank — shuts down on this date. Query
|
||||
* embedding uses the same endpoint as ingestion, so a brain still on a
|
||||
* `zeroentropyai:*` embedding model loses semantic retrieval ENTIRELY on
|
||||
* that date (existing vectors become unqueryable, not just new content).
|
||||
* Single source of truth for the upgrade banner + the `provider_sunset`
|
||||
* doctor check. Self-hosting the Apache-2.0 zembed-1 weights is unaffected.
|
||||
*/
|
||||
export const ZEROENTROPY_SUNSET_DATE = '2026-09-04';
|
||||
|
||||
@@ -151,6 +151,7 @@ export const OPS_CHECK_NAMES: ReadonlySet<string> = new Set([
|
||||
'pgvector',
|
||||
'pool_budget',
|
||||
'progressive_batch_audit_health',
|
||||
'provider_sunset',
|
||||
'queue_health',
|
||||
'reranker_health',
|
||||
'rls',
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* provider_sunset doctor check + embed lock-skip visibility (#3390 follow-up).
|
||||
*
|
||||
* A brain whose effective embedding model is on a provider with an announced
|
||||
* hosted-API shutdown must be flagged on EVERY `gbrain doctor` run (the
|
||||
* upgrade banner is one-shot), with a paste-ready `gbrain migrate embeddings`
|
||||
* command whose `--dim` is the brain's ACTUAL column width — not the config
|
||||
* value, which can drift. Getting `--dim` wrong forces a needless dimension
|
||||
* transition + index rebuild.
|
||||
*
|
||||
* Also pins `EmbedResult.lock_skipped`: a single-flight embed run that did no
|
||||
* work because another backfill holds the per-source lock says so, instead of
|
||||
* letting `migrate embeddings` misreport "embed failures — re-run to resume"
|
||||
* (a hard-killed run leaves its lock behind for up to the lock TTL, so the
|
||||
* immediate re-run would no-op).
|
||||
*
|
||||
* The first test goes through the `buildChecks` orchestrator (exists on
|
||||
* master) so the assertion is behavioral: master's doctor simply never
|
||||
* surfaces the sunset, and the test fails on the missing check — not on a
|
||||
* missing export.
|
||||
*
|
||||
* `.serial.test.ts`: configures the process-global gateway + GBRAIN_HOME for
|
||||
* its whole lifecycle.
|
||||
*/
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import { configureGateway, resetGateway } from '../src/core/ai/gateway.ts';
|
||||
import { buildChecks } from '../src/commands/doctor.ts';
|
||||
import { runEmbedCore } from '../src/commands/embed.ts';
|
||||
import { tryAcquireDbLock, type DbLockHandle } from '../src/core/db-lock.ts';
|
||||
import { embedBackfillLockId } from '../src/core/embed-backfill-lock.ts';
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
let tmpHome: string;
|
||||
let savedHome: string | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
savedHome = process.env.GBRAIN_HOME;
|
||||
tmpHome = mkdtempSync(join(tmpdir(), 'gbrain-sunset-'));
|
||||
process.env.GBRAIN_HOME = tmpHome;
|
||||
// Gateway BEFORE initSchema — the schema sizes the embedding column from
|
||||
// the configured dims (same order as migrate-embeddings-flow.serial).
|
||||
configureGateway({
|
||||
embedding_model: 'zeroentropyai:zembed-1',
|
||||
embedding_dimensions: 1280,
|
||||
env: { OPENAI_API_KEY: 'sk-test-fake', ZEROENTROPY_API_KEY: 'ze-test-fake' },
|
||||
});
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({ embedding_dimensions: 1280 } as never);
|
||||
await engine.initSchema(); // content_chunks.embedding at the shipped-default 1280 width
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
resetGateway();
|
||||
await engine.disconnect();
|
||||
if (savedHome !== undefined) process.env.GBRAIN_HOME = savedHome;
|
||||
else delete process.env.GBRAIN_HOME;
|
||||
rmSync(tmpHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** The check may legitimately be warn (pre-date) or fail (post-date). */
|
||||
const FLAGGED = ['warn', 'fail'];
|
||||
|
||||
describe('provider_sunset — doctor flags brains pinned to a sunsetting provider', () => {
|
||||
test('buildChecks surfaces the sunset for a zembed-1 brain, with the actual --dim filled in', async () => {
|
||||
configureGateway({
|
||||
embedding_model: 'zeroentropyai:zembed-1',
|
||||
embedding_dimensions: 1280,
|
||||
env: { OPENAI_API_KEY: 'sk-test-fake', ZEROENTROPY_API_KEY: 'ze-test-fake' },
|
||||
});
|
||||
const checks = await buildChecks(engine, []);
|
||||
const sunset = checks.find((c) => c.name === 'provider_sunset');
|
||||
expect(sunset, 'doctor never surfaced the provider sunset').toBeDefined();
|
||||
expect(FLAGGED).toContain(sunset!.status);
|
||||
// The date and BOTH consequences must be stated plainly.
|
||||
expect(sunset!.message).toContain('2026-09-04');
|
||||
expect(sunset!.message.toLowerCase()).toContain('existing vectors');
|
||||
// Paste-ready migration command with the brain's ACTUAL column width.
|
||||
expect(sunset!.message).toContain('gbrain migrate embeddings --to <provider:model> --dim 1280');
|
||||
// The self-host escape hatch gets equal billing (no migration at all).
|
||||
expect(sunset!.message).toContain('Apache-2.0');
|
||||
});
|
||||
|
||||
test('the ACTUAL column width wins over drifted config', async () => {
|
||||
const { checkProviderSunset } = await import('../src/commands/doctor.ts');
|
||||
// Simulate config/schema drift: column rebuilt at 640, config still 1280.
|
||||
await engine.executeRaw(`ALTER TABLE content_chunks DROP COLUMN IF EXISTS embedding`);
|
||||
await engine.executeRaw(`ALTER TABLE content_chunks ADD COLUMN embedding vector(640)`);
|
||||
try {
|
||||
configureGateway({
|
||||
embedding_model: 'zeroentropyai:zembed-1',
|
||||
embedding_dimensions: 1280,
|
||||
env: { OPENAI_API_KEY: 'sk-test-fake', ZEROENTROPY_API_KEY: 'ze-test-fake' },
|
||||
});
|
||||
const check = await checkProviderSunset(engine);
|
||||
expect(FLAGGED).toContain(check.status);
|
||||
expect(check.message).toContain('--dim 640');
|
||||
expect(check.message).not.toContain('--dim 1280');
|
||||
} finally {
|
||||
await engine.executeRaw(`ALTER TABLE content_chunks DROP COLUMN IF EXISTS embedding`);
|
||||
await engine.executeRaw(`ALTER TABLE content_chunks ADD COLUMN embedding vector(1280)`);
|
||||
}
|
||||
});
|
||||
|
||||
test('non-sunsetting provider → ok', async () => {
|
||||
const { checkProviderSunset } = await import('../src/commands/doctor.ts');
|
||||
configureGateway({
|
||||
embedding_model: 'openai:text-embedding-3-small',
|
||||
embedding_dimensions: 1536,
|
||||
env: { OPENAI_API_KEY: 'sk-test-fake', ZEROENTROPY_API_KEY: 'ze-test-fake' },
|
||||
});
|
||||
const check = await checkProviderSunset(engine);
|
||||
expect(check.status).toBe('ok');
|
||||
});
|
||||
|
||||
test('reranker on the sunsetting provider is flagged even when embeddings are elsewhere', async () => {
|
||||
const { checkProviderSunset } = await import('../src/commands/doctor.ts');
|
||||
configureGateway({
|
||||
embedding_model: 'openai:text-embedding-3-small',
|
||||
embedding_dimensions: 1536,
|
||||
reranker_model: 'zeroentropyai:zerank-2',
|
||||
env: { OPENAI_API_KEY: 'sk-test-fake', ZEROENTROPY_API_KEY: 'ze-test-fake' },
|
||||
});
|
||||
const check = await checkProviderSunset(engine);
|
||||
expect(FLAGGED).toContain(check.status);
|
||||
expect(check.message).toContain('zerank-2');
|
||||
expect(check.message).toContain('search.reranker');
|
||||
// Embeddings are safe — no migration command in this message.
|
||||
expect(check.message).not.toContain('migrate embeddings');
|
||||
});
|
||||
});
|
||||
|
||||
describe('EmbedResult.lock_skipped — single-flight bail is observable', () => {
|
||||
test('a held per-source lock makes runEmbedCore report lock_skipped', async () => {
|
||||
// Dims match the column (1280) so the embed dim preflight passes; the
|
||||
// fake key satisfies the credential preflight (no embed call happens —
|
||||
// the lock bail fires before any work).
|
||||
configureGateway({
|
||||
embedding_model: 'openai:text-embedding-3-small',
|
||||
embedding_dimensions: 1280,
|
||||
env: { OPENAI_API_KEY: 'sk-test-fake', ZEROENTROPY_API_KEY: 'ze-test-fake' },
|
||||
});
|
||||
const sources = await engine.listAllSources();
|
||||
const ids = sources.length > 0 ? sources.map((s) => s.id) : ['default'];
|
||||
const locks: DbLockHandle[] = [];
|
||||
for (const sid of ids) {
|
||||
const lock = await tryAcquireDbLock(engine, embedBackfillLockId(sid), 60);
|
||||
expect(lock).not.toBeNull();
|
||||
locks.push(lock!);
|
||||
}
|
||||
try {
|
||||
const result = await runEmbedCore(engine, { stale: true, singleFlight: true, quiet: true });
|
||||
expect(result.embedded).toBe(0);
|
||||
// Behavioral pin: master returns the same zero-work result WITHOUT the
|
||||
// flag, so `migrate embeddings` can't tell "lock held" from "embed
|
||||
// failures" and prints a resume hint that a re-run cannot honor.
|
||||
expect(result.lock_skipped).toBe(true);
|
||||
} finally {
|
||||
for (const l of locks) await l.release();
|
||||
}
|
||||
});
|
||||
|
||||
test('no lock contention → lock_skipped is not set', async () => {
|
||||
const result = await runEmbedCore(engine, { stale: true, singleFlight: true, quiet: true });
|
||||
expect(result.lock_skipped).toBeFalsy();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user