mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 06:23:01 +00:00
* fix(ai): drop empty-string env values before merge so they can't clobber config keys (#1249) Claude Code injects ANTHROPIC_API_KEY='' to neuter subprocess LLM calls; an unconditional process.env spread let that empty string override a valid config.json key, breaking every gateway op with NO_ANTHROPIC_API_KEY. Filter '' / undefined before the merge; '0' and 'false' are preserved. * fix(ai): normalize native provider base URLs + replace embedding guard with a dims-presence check (#1250, #1292) #1250: createAnthropic/createOpenAI were called with no baseURL, so an env-injected bare host (e.g. ANTHROPIC_BASE_URL without /v1) 404'd. Add a shared resolveNativeBaseUrl and pass a normalized baseURL at all anthropic + openai native sites (google deferred until its suffix is verified). #1292/D6: the user_provided_model_unset guard was structurally unreachable as a no-model check (parseModelId throws on a bare provider) and only ever false-positived for litellm:<model>, silently disabling vector search. Replace it with a real dims-presence check for user-provided/zero-default recipes and delete the dead branch in both consumers. Also stop configureGateway from fabricating a default embedding_dimensions, so 'no dims set' stays honest. * fix(ai): trust user-declared embedding dims for local recipes + litellm /v1 hint (#2271, #2209) #2271: a new trust_custom_dims flag adds a passthrough tier so ollama / llama-server / litellm accept a user-supplied --embedding-dimensions instead of being hard-rejected. Fail-closed for fixed-dim providers (openai/voyage/ zeroentropy) and excludes openrouter (declares dims_options). Register modern ollama embed model names. #2209: litellm setup_hint now states the /v1 path convention and the docs pointer is corrected to docs/integrations/embedding-providers.md. * docs+test(ai): KEY_FILES current-state for provider-agnostic gateway + embed-preflight dims-unset test (#1249, #1250, #1292) * fix(ai): point user_provided_dims_unset remediation at 'gbrain init' (config set rejects it) + coverage Pre-landing adversarial review (P1): the new dims-unset guard told users to run 'gbrain config set embedding_dimensions <N>', which config.ts hard-rejects (it's a schema-sizing field). Both consumer messages now point at 'gbrain init --embedding-dimensions'. Adds: pgvector-cap-still-fires regression for the trust_custom_dims passthrough, and a configureGateway backfill-invariant test. * chore: bump version and changelog (v0.42.57.0) Provider-agnostic plumbing wave: #1249 empty-env clobber, #1250 native baseURL normalization, #1292 embedding dims-presence guard, #2271 trust_custom_dims passthrough, #2209 litellm /v1 hint. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: sync embedding-providers guide for provider-agnostic gateway wave (v0.42.57.0) Post-ship doc drift fix for the v0.42.57.0 AI-gateway wave: - LiteLLM section now names the /v1 base-URL convention (#2209). - Ollama section lists the newly-registered modern embedders qwen3-embed-8b + snowflake-arctic-embed-l-v2, and notes dims-trust for local recipes (#2271). - llama-server section notes gbrain trusts the user-declared dimension (#2271). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: post-ship doc sweep for v0.42.57.0 provider-agnostic gateway wave - KEY_FILES.md types.ts entry: document EmbeddingTouchpoint.trust_custom_dims (#2271 passthrough tier, runs after dims_options + Matryoshka allowlists) - ENGINES.md: embedding design-choice note now names the provider-agnostic gateway delegation instead of the stale OpenAI-only parenthetical - embedding-providers.md: drop an exact-duplicate doctor-8c paragraph - llms-full.txt regenerated (ENGINES.md is inlined in the bundle) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: apply codex doc-review findings for v0.42.57.0 (base-URL env note, litellm multimodal) - embedding-providers.md OpenAI section: document OPENAI_BASE_URL / ANTHROPIC_BASE_URL bare-host /v1 normalization (#1250 user-facing surface) - TL;DR table: litellm multimodal is backend-permitting (recipe declares supports_multimodal: true, routed via the openai-compat multimodal path), not "no" Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: pin engine-find-trajectory schema to 1536 + stop gateway-state leaks across shard files CI shard 5 failed 7 findTrajectory tests with 'expected 1280 dimensions, not 1536': engine-find-trajectory hardcodes 1536-d vectors but sizes its schema from AMBIENT gateway state in beforeAll — which runs before the legacy-embedding-preload's per-test 1536 restore. A preceding file that ends with a dimensionless configureGateway (facts-extract-silent-no-op) or a bare resetGateway poisons the next fresh initSchema down to 1280-d columns. The new test files in this PR reshuffled shard bin-packing and exposed the trap. - engine-find-trajectory: pin OpenAI/1536 explicitly before initSchema (the pattern bunfig's preload documents) — deterministic regardless of neighbors - facts-extract-silent-no-op, diagnose-embedding-dims, embed-preflight: restore the legacy 1536 pin in afterAll instead of ending reset/dimensionless Reproduced: synthetic dimensionless-gateway file + old victim = the exact 7 CI failures; with the pin = 0. Verified in-process pair runs both orders. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
268 lines
14 KiB
Markdown
268 lines
14 KiB
Markdown
# Pluggable Engine Architecture
|
|
|
|
## The idea
|
|
|
|
Every GBrain operation goes through `BrainEngine`. The engine is the contract between "what the brain can do" and "how it's stored." Swap the engine, keep everything else.
|
|
|
|
v0 shipped `PostgresEngine` backed by Supabase. v0.7 adds `PGLiteEngine` -- embedded Postgres 17.5 via WASM (@electric-sql/pglite), zero-config default. The interface is designed so a `DuckDBEngine`, `TursoEngine`, or any custom backend could slot in without touching the CLI, MCP server, skills, or any consumer code.
|
|
|
|
## Why this matters
|
|
|
|
Different users have different constraints:
|
|
|
|
| User | Needs | Best engine |
|
|
|------|-------|-------------|
|
|
| Getting started | Zero-config, no accounts, no server | PGLiteEngine (default since v0.7) |
|
|
| Power user (you) | World-class search, 7K+ pages, zero-ops | PostgresEngine + Supabase |
|
|
| Open source hacker | Single file, no server, git-friendly | PGLiteEngine |
|
|
| Team/enterprise | Multi-user, RLS, audit trail | PostgresEngine + self-hosted |
|
|
| Researcher | Analytics, bulk exports, embeddings | DuckDBEngine (someday) |
|
|
| Edge/mobile | Offline-first, sync later | PGLiteEngine + sync (someday) |
|
|
|
|
The engine interface means we don't have to choose. PGLite is the zero-friction default. Supabase is the production scale path. `gbrain migrate --to supabase/pglite` moves between them.
|
|
|
|
## The interface
|
|
|
|
```typescript
|
|
// src/core/engine.ts
|
|
|
|
export interface BrainEngine {
|
|
// Lifecycle
|
|
connect(config: EngineConfig): Promise<void>;
|
|
disconnect(): Promise<void>;
|
|
initSchema(): Promise<void>;
|
|
transaction<T>(fn: (engine: BrainEngine) => Promise<T>): Promise<T>;
|
|
|
|
// Pages CRUD
|
|
getPage(slug: string): Promise<Page | null>;
|
|
putPage(slug: string, page: PageInput): Promise<Page>;
|
|
deletePage(slug: string): Promise<void>;
|
|
listPages(filters: PageFilters): Promise<Page[]>;
|
|
|
|
// Search
|
|
searchKeyword(query: string, opts?: SearchOpts): Promise<SearchResult[]>;
|
|
searchVector(embedding: Float32Array, opts?: SearchOpts): Promise<SearchResult[]>;
|
|
|
|
// Chunks
|
|
upsertChunks(slug: string, chunks: ChunkInput[]): Promise<void>;
|
|
getChunks(slug: string): Promise<Chunk[]>;
|
|
|
|
// Links
|
|
addLink(from: string, to: string, context?: string, linkType?: string): Promise<void>;
|
|
removeLink(from: string, to: string): Promise<void>;
|
|
getLinks(slug: string): Promise<Link[]>;
|
|
getBacklinks(slug: string): Promise<Link[]>;
|
|
traverseGraph(slug: string, depth?: number): Promise<GraphNode[]>;
|
|
|
|
// Tags
|
|
addTag(slug: string, tag: string): Promise<void>;
|
|
removeTag(slug: string, tag: string): Promise<void>;
|
|
getTags(slug: string): Promise<string[]>;
|
|
|
|
// Timeline
|
|
addTimelineEntry(slug: string, entry: TimelineInput): Promise<void>;
|
|
getTimeline(slug: string, opts?: TimelineOpts): Promise<TimelineEntry[]>;
|
|
|
|
// Raw data
|
|
putRawData(slug: string, source: string, data: object): Promise<void>;
|
|
getRawData(slug: string, source?: string): Promise<RawData[]>;
|
|
|
|
// Versions
|
|
createVersion(slug: string): Promise<PageVersion>;
|
|
getVersions(slug: string): Promise<PageVersion[]>;
|
|
revertToVersion(slug: string, versionId: number): Promise<void>;
|
|
|
|
// Stats + health
|
|
getStats(): Promise<BrainStats>;
|
|
getHealth(): Promise<BrainHealth>;
|
|
|
|
// Ingest log
|
|
logIngest(entry: IngestLogInput): Promise<void>;
|
|
getIngestLog(opts?: IngestLogOpts): Promise<IngestLogEntry[]>;
|
|
|
|
// Config
|
|
getConfig(key: string): Promise<string | null>;
|
|
setConfig(key: string, value: string): Promise<void>;
|
|
|
|
// Migration + advanced (added v0.7)
|
|
runMigration(sql: string): Promise<void>;
|
|
getChunksWithEmbeddings(slug: string): Promise<ChunkWithEmbedding[]>;
|
|
}
|
|
```
|
|
|
|
### Key design choices
|
|
|
|
**Slug-based API, not ID-based.** Every method takes slugs, not numeric IDs. The engine resolves slugs to IDs internally. This keeps the interface portable... slugs are strings, IDs are database-specific.
|
|
|
|
**Embedding is NOT in the engine.** The engine stores embeddings and searches by vector, but it doesn't generate embeddings. `src/core/embedding.ts` handles that (a thin delegation to the provider-agnostic AI gateway in `src/core/ai/gateway.ts`). This is intentional: embedding is an external API call (OpenAI, Voyage, a local Ollama — whichever provider you configured), not a storage concern. All engines share the same embedding service.
|
|
|
|
**Chunking is NOT in the engine.** Same logic. `src/core/chunkers/` handles chunking. The engine stores and retrieves chunks. All engines share the same chunkers.
|
|
|
|
**Search returns `SearchResult[]`, not raw rows.** The engine is responsible for its own search implementation (tsvector vs FTS5, pgvector vs sqlite-vss) but must return a uniform result type. RRF fusion and dedup happen above the engine, in `src/core/search/hybrid.ts`.
|
|
|
|
**`traverseGraph` exists but is engine-specific.** Postgres uses recursive CTEs. SQLite would use a loop with depth tracking. The interface is the same: give me a slug and max depth, return the graph.
|
|
|
|
## How search works across engines
|
|
|
|
```
|
|
+-------------------+
|
|
| hybrid.ts |
|
|
| (RRF fusion + |
|
|
| dedup, shared) |
|
|
+--------+----------+
|
|
|
|
|
+------------+------------+
|
|
| |
|
|
+--------v--------+ +--------v--------+
|
|
| engine.search | | engine.search |
|
|
| Keyword() | | Vector() |
|
|
+-----------------+ +-----------------+
|
|
| |
|
|
+-----------+-----------+ +---------+---------+
|
|
| | | |
|
|
+-------v-------+ +-------v---+ +-------v---+ +----v--------+
|
|
| Postgres: | | PGLite: | | Postgres: | | PGLite: |
|
|
| tsvector + | | tsvector +| | pgvector | | pgvector |
|
|
| ts_rank + | | ts_rank | | HNSW | | HNSW |
|
|
| websearch_to_ | | (same SQL)| | cosine | | cosine |
|
|
| tsquery | | | | | | (same SQL) |
|
|
+---------------+ +-----------+ +-----------+ +-------------+
|
|
```
|
|
|
|
RRF fusion, multi-query expansion, and 4-layer dedup are engine-agnostic. They operate on `SearchResult[]` arrays. Only the raw keyword and vector searches are engine-specific.
|
|
|
|
## PostgresEngine (v0, ships)
|
|
|
|
**Dependencies:** `postgres` (porsager/postgres), `pgvector`
|
|
|
|
**Postgres-specific features used:**
|
|
- `tsvector` + `GIN` index for full-text search with `ts_rank` weighting
|
|
- `pgvector` HNSW index for cosine similarity vector search
|
|
- `pg_trgm` + `GIN` for fuzzy slug resolution
|
|
- Recursive CTEs for graph traversal
|
|
- Trigger-based search_vector (spans pages + timeline_entries)
|
|
- JSONB for frontmatter with GIN index
|
|
- Connection pooling via Supabase Supavisor (port 6543)
|
|
|
|
**Hosting:** Supabase Pro ($25/mo). Zero-ops. Managed Postgres with pgvector built in.
|
|
|
|
**Why not self-hosted for v0:** The brain should be infrastructure agents use, not something you maintain. Self-hosted Postgres with Docker is a welcome community PR, but v0 optimizes for zero ops.
|
|
|
|
## PGLiteEngine (v0.7, ships)
|
|
|
|
**Dependencies:** `@electric-sql/pglite` (v0.4.4+)
|
|
|
|
**What it is:** Embedded Postgres 17.5 compiled to WASM via ElectricSQL's PGLite. Runs in-process, no server, no Docker, no accounts. Same SQL as PostgresEngine -- not a separate dialect. All 37 BrainEngine methods implemented.
|
|
|
|
**PGLite-specific details:**
|
|
- Uses `pglite-schema.ts` for DDL (pgvector extension, pg_trgm, triggers, indexes)
|
|
- Parameterized queries throughout (shared utilities in `src/core/utils.ts`)
|
|
- `hybridSearch` keyword-only fallback when `OPENAI_API_KEY` is not set
|
|
- Data stored at `~/.gbrain/brain.db` (configurable)
|
|
- pgvector HNSW index for cosine similarity vector search (same as Postgres)
|
|
- tsvector + ts_rank for full-text search (same as Postgres)
|
|
- pg_trgm for fuzzy slug resolution (same as Postgres)
|
|
|
|
**When to use PGLite vs Postgres:**
|
|
|
|
| Factor | PGLite | PostgresEngine + Supabase |
|
|
|--------|--------|--------------------------|
|
|
| Setup | `gbrain init` (zero-config) | Account + connection string |
|
|
| Scale | Good for < 1,000 files | Production-proven at 10K+ |
|
|
| Multi-device | Single machine only | Any device via remote MCP |
|
|
| Cost | Free | Supabase Pro ($25/mo) |
|
|
| Concurrency | Single process | Connection pooling |
|
|
| Backups | Manual (file copy) | Managed by Supabase |
|
|
|
|
**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.
|
|
|
|
## JSONB writes: never double-encode (the #2339 trap)
|
|
|
|
Writing a JS value into a `jsonb` column has exactly two correct forms. Get this
|
|
wrong and the write succeeds on PGLite but stores a **jsonb string scalar** on
|
|
real Postgres — `col ->> 'k'` returns NULL, `jsonb_array_elements` throws, and a
|
|
`jsonb_typeof = 'array'` CHECK rejects the row (this aborted every sync in #2339).
|
|
|
|
| Form | Verdict |
|
|
|---|---|
|
|
| Template tag: `` sql`... ${sql.json(obj)}` `` (postgres-engine only) | ✅ native jsonb serialization |
|
|
| Positional raw call, raw object: `executeRawJsonb(engine, sql, scalars, [obj])` | ✅ object reaches the wire as jsonb |
|
|
| Positional raw call, stringified: `executeRaw(\`... $N::text::jsonb\`, [JSON.stringify(x)])` | ✅ binds as text, the cast parses it |
|
|
| Positional raw call, BARE cast: `executeRaw(\`... $N::jsonb\`, [JSON.stringify(x)])` | ❌ **double-encodes** under postgres.js `.unsafe()` |
|
|
| Template literal interpolation: `` `... ${JSON.stringify(x)}::jsonb` `` | ❌ double-encodes |
|
|
|
|
**Why:** postgres.js `.unsafe(sql, params)` (the path behind `executeRaw` /
|
|
`executeRawDirect`) binds a JS **string** as a text param. A bare `$N::jsonb`
|
|
cast then wraps that already-JSON string into a jsonb scalar string instead of
|
|
parsing it. Casting through `$N::text::jsonb` forces a text→jsonb parse.
|
|
**PGLite's `db.query` parses text→jsonb natively, so it hides the bug** — which is
|
|
why a regression only shows up on Postgres (and why the parity test must run there).
|
|
|
|
**Two CI guards enforce this, both wired into `scripts/check-jsonb-pattern.sh`:**
|
|
- the template-tag grep (`${JSON.stringify(x)}::jsonb`), and
|
|
- `scripts/check-jsonb-params.mjs`, an AST-lite scanner for the positional
|
|
`$N::jsonb` + `JSON.stringify` form the grep misses. Sanctioned escapes:
|
|
`$N::text::jsonb`, `$N::text[]`, `executeRawJsonb`, `sql.json`, or an inline
|
|
`jsonb-guard-ok` comment.
|
|
|
|
The real backstop is `test/e2e/op-checkpoint-jsonb-parity.test.ts` +
|
|
`test/e2e/jsonb-roundtrip.test.ts`, which round-trip writes through real Postgres
|
|
and assert `jsonb_typeof` — the assertion PGLite cannot make.
|
|
|
|
## Adding a new engine
|
|
|
|
1. Create `src/core/<name>-engine.ts` implementing `BrainEngine`
|
|
2. Add to engine factory in `src/core/engine-factory.ts`:
|
|
```typescript
|
|
export function createEngine(type: string): BrainEngine {
|
|
switch (type) {
|
|
case 'pglite': return new PGLiteEngine();
|
|
case 'postgres': return new PostgresEngine();
|
|
case 'myengine': return new MyEngine();
|
|
default: throw new Error(`Unknown engine: ${type}`);
|
|
}
|
|
}
|
|
```
|
|
The factory uses dynamic imports so engines are only loaded when selected.
|
|
3. Store engine type in `~/.gbrain/config.json`: `{ "engine": "myengine", ... }`
|
|
4. Add tests. The test suite should be engine-agnostic where possible... same test cases, different engine constructor.
|
|
5. Document in this file + add a design doc in `docs/`
|
|
|
|
### What you DON'T need to touch
|
|
|
|
- `src/cli.ts` (dispatches to engine, doesn't know which one)
|
|
- `src/mcp/server.ts` (same)
|
|
- `src/core/chunkers/*` (shared across engines)
|
|
- `src/core/embedding.ts` (shared across engines)
|
|
- `src/core/search/hybrid.ts`, `expansion.ts`, `dedup.ts` (shared, operate on SearchResult[])
|
|
- `skills/*` (fat markdown, engine-agnostic)
|
|
|
|
### What you DO need to implement
|
|
|
|
Every method in `BrainEngine`. The full interface. No optional methods, no feature flags. If your engine can't do vector search (e.g., a pure-text engine), implement `searchVector` to return `[]` and document the limitation.
|
|
|
|
## Capability matrix
|
|
|
|
| Capability | PostgresEngine | PGLiteEngine | Notes |
|
|
|-----------|---------------|-------------|-------|
|
|
| CRUD | Full | Full | Same SQL |
|
|
| Keyword search | tsvector + ts_rank | tsvector + ts_rank | Identical (real Postgres) |
|
|
| Vector search | pgvector HNSW | pgvector HNSW | Identical (real Postgres) |
|
|
| Fuzzy slug | pg_trgm | pg_trgm | Identical (real Postgres) |
|
|
| Graph traversal | Recursive CTE | Recursive CTE | Same SQL |
|
|
| Transactions | Full ACID | Full ACID | Both support this |
|
|
| JSONB queries | GIN index | GIN index | Identical |
|
|
| Concurrent access | Connection pooling | Single process | PGLite limitation |
|
|
| Hosting | Supabase, self-hosted, Docker | Local file | |
|
|
| Migration methods | runMigration, getChunksWithEmbeddings | Same | Added v0.7 |
|
|
|
|
## Future engine ideas
|
|
|
|
**TursoEngine.** libSQL (SQLite fork) with embedded replicas and HTTP edge access. Would give SQLite's simplicity with cloud sync. Interesting for mobile/edge use cases.
|
|
|
|
**DuckDBEngine.** Analytical workloads. Bulk exports, embedding analysis, brain-wide statistics. Not for OLTP. Could be a secondary engine for analytics alongside Postgres for operations.
|
|
|
|
**Custom/Remote.** The interface is clean enough that someone could build an engine backed by any storage: Firestore, DynamoDB, a REST API, even a flat file system. The interface doesn't assume SQL.
|
|
|
|
Note: The original SQLite engine plan (`docs/SQLITE_ENGINE.md`) was superseded by PGLite. PGLite uses the same SQL as Postgres, eliminating the need for a separate SQLite dialect with FTS5/sqlite-vss translation.
|