mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
+5








86962b242b
Collector branch superseding the gateway tool-loop duplicate cluster and adjacent provider fixes. Re-implemented from the best of each PR (deduped by content, not file-overlap); every fix carries test coverage. Gateway tool-loop resume (supersedes #1934 #2062 #2065 #2112 #2274 #2487 #2336 #2257 #2499 #2491, test #2063): - toolLoop now persists the tool-result user turn per round (onToolResultTurn), so a resumed subagent job reloads a balanced transcript instead of dangling assistant tool-calls that non-Anthropic providers reject with AI_MissingToolResultsError. - runSubagentViaGateway reconciles an already-corrupted transcript on resume: it heals every dangling assistant tool-call turn (not just the tail) from the settled subagent_tool_executions rows, re-dispatching idempotent-pending tools and throwing on non-idempotent, mirroring the legacy Anthropic path. Terminal early-return for a transcript that already reached end_turn. - repairToolPairing() is a last-resort normalization at the chat() boundary (from #2336): back-fills error stubs for any assistant tool-call still unanswered (partial turns, provider-duplicated/dropped IDs on local models, length-truncated batches). No-op on balanced input. - toModelMessages is Date-safe (Postgres timestamptz -> ISO via a JSON round trip at the SDK boundary, never a ::jsonb cast; degrades bigint/circular to a string instead of throwing) and drops non-string text blocks reasoning models emit that AI SDK v6 rejects (#2488). Adjacent provider fixes: - Model-aware default max output tokens: thinking-by-default Claude 5 models get headroom (gateway 32000, think 16000) while everything else stays 4096/4000, so DeepSeek/OpenAI subagents don't exceed provider caps (#2614 #2806). - DeepSeek: promote reasoning_content into content when content is empty, via a fail-open recipe fetch shim (#2617). - OpenRouter: map openrouter_api_key (config + env) into OPENROUTER_API_KEY through buildGatewayConfig; register agent.use_gateway_loop, zeroentropy and openrouter keys in KNOWN_CONFIG_KEYS so `config set` accepts them (#2572, config key from #2112). Preserves JSONB (no JSON.stringify into ::jsonb), engine parity, source isolation, and trust-boundary invariants. Verified with the gbrain-pr-test-env consumer matrix (clawlancer/Postgres, gstack + hivemindos/PGLite) baseline-FAIL -> candidate-PASS on the same repro, plus bun run verify (31/31) and 439 targeted unit + e2e tests. Co-authored-by: Sinabina <sinabina@Sinabinas-MacBook-Pro-4.local> Co-authored-by: thomaskong119 <thomaskong119@hotmail.com> Co-authored-by: maxpetrusenkoagent <max.petrusenko.agent@gmail.com> Co-authored-by: brettdavies <brettdavies@users.noreply.github.com> Co-authored-by: ivandebot <187176982+ivandebot@users.noreply.github.com> Co-authored-by: Rafael Reis <rafael.reis@contabilizei.com.br> Co-authored-by: fbal23 <fbal.public@gmail.com> Co-authored-by: javieraldape <javieraldape@users.noreply.github.com> Co-authored-by: David Carolan <david@joyrestart.com> Co-authored-by: Masashi-Ono0611 <masashi.ono.0611@gmail.com> Co-authored-by: spiky02plateau <155588579+spiky02plateau@users.noreply.github.com> Co-authored-by: psam-717 <mphilannorbah@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1104 lines
48 KiB
TypeScript
1104 lines
48 KiB
TypeScript
import { readFileSync, writeFileSync, mkdirSync, chmodSync, existsSync } from 'fs';
|
||
import { isAbsolute, join } from 'path';
|
||
import { homedir } from 'os';
|
||
import type { EngineConfig, EmbeddingColumnConfig } from './types.ts';
|
||
|
||
/**
|
||
* Where is the active DB URL coming from? Pure introspection, no connection
|
||
* attempt. Used by `gbrain doctor --fast` so the user gets a precise message
|
||
* instead of the misleading "No database configured" when GBRAIN_DATABASE_URL
|
||
* (or DATABASE_URL) is actually set.
|
||
*
|
||
* Precedence matches loadConfig(): env vars win over config-file URL. Returns
|
||
* null only when NO source provides a URL at all.
|
||
*/
|
||
export type DbUrlSource =
|
||
| 'env:GBRAIN_DATABASE_URL'
|
||
| 'env:DATABASE_URL'
|
||
| 'config-file'
|
||
| 'config-file-path' // PGLite: config file present, no URL but database_path set
|
||
| null;
|
||
|
||
// Internal aliases retained for backwards compatibility with the existing call
|
||
// sites below. They forward to the exported configDir()/configPath() so
|
||
// GBRAIN_HOME is honored uniformly. Lazy: never call homedir() at module scope.
|
||
function getConfigDir() { return configDir(); }
|
||
function getConfigPath() { return configPath(); }
|
||
|
||
export interface GBrainConfig {
|
||
engine: 'postgres' | 'pglite';
|
||
database_url?: string;
|
||
database_path?: string;
|
||
openai_api_key?: string;
|
||
anthropic_api_key?: string;
|
||
/**
|
||
* ZeroEntropy API key. v0.37 fix wave (CDX2-5+6): ZE became the default
|
||
* embedding + reranker provider in v0.36 but lacked a file-plane config
|
||
* slot. `gbrain config set zeroentropy_api_key X` wrote DB plane,
|
||
* `loadConfig` only merged OpenAI/Anthropic, and `buildGatewayConfig`
|
||
* at cli.ts:1401 only mapped those two — so the key never reached the
|
||
* embed pipeline. Now wired through: file plane → loadConfig env
|
||
* merge → buildGatewayConfig env dict → recipe reads ZEROENTROPY_API_KEY.
|
||
*/
|
||
zeroentropy_api_key?: string;
|
||
/**
|
||
* OpenRouter API key. File-plane slot so `gbrain config set
|
||
* openrouter_api_key X` (or config.json) reaches the openrouter recipe:
|
||
* file plane → loadConfig env merge → buildGatewayConfig env dict → recipe
|
||
* reads OPENROUTER_API_KEY.
|
||
*/
|
||
openrouter_api_key?: string;
|
||
/** AI gateway config (v0.14+). v0.36+ default: "zeroentropyai:zembed-1" / 1280 / "anthropic:claude-haiku-4-5-20251001". */
|
||
embedding_model?: string;
|
||
embedding_dimensions?: number;
|
||
/**
|
||
* v0.37 (D9): user opted into deferred-setup mode at init time via
|
||
* `gbrain init --no-embedding`. When true, embed callsites and `gbrain
|
||
* import` refuse with a `gbrain config set embedding_model <id>` hint
|
||
* rather than proceeding with a default that may not match a real key.
|
||
* Mutually exclusive with `embedding_model` being set — init writes one
|
||
* or the other, never both.
|
||
*/
|
||
embedding_disabled?: boolean;
|
||
expansion_model?: string;
|
||
/**
|
||
* Default chat model for `gateway.chat()` callers (v0.27+).
|
||
* Default: "anthropic:claude-sonnet-4-6" (dateless per Anthropic's v0.31.12+ model-ID format).
|
||
*/
|
||
chat_model?: string;
|
||
/**
|
||
* Optional silent-refusal fallback chain for `chatWithFallback()` (v0.27+).
|
||
* Each entry is a "provider:modelId" string. Blocked from critic/judge/
|
||
* synthesize flows in their respective handlers (per D13 review decision).
|
||
*/
|
||
chat_fallback_chain?: string[];
|
||
/** Optional base URL overrides for openai-compatible providers (keyed by recipe id). */
|
||
provider_base_urls?: Record<string, string>;
|
||
/**
|
||
* Optional storage backend config (S3/Supabase/local). Shape matches
|
||
* `StorageConfig` in `./storage.ts`. Typed as `unknown` here to avoid
|
||
* a cyclic import; callers pass this through `createStorage()` which
|
||
* validates the shape at runtime.
|
||
*/
|
||
storage?: unknown;
|
||
/**
|
||
* v0.25.0 — session capture settings. Read via file-plane `loadConfig()`
|
||
* at process boot (NOT `gbrain config set` which writes the DB plane —
|
||
* those are different stores). Edit `~/.gbrain/config.json` directly.
|
||
* All fields default to ON — capture and scrubbing both opt-out.
|
||
*/
|
||
/**
|
||
* v0.41 — autopilot daemon configuration. Currently houses the nightly
|
||
* quality probe feature flag (default OFF — opt-in to protect API spend
|
||
* on fresh installs). Flag is gated INSIDE the autopilot tick body;
|
||
* absence means "do not run nightly probe."
|
||
*/
|
||
autopilot?: {
|
||
nightly_quality_probe?: {
|
||
/** Enable the nightly probe in the autopilot loop. Defaults to false. */
|
||
enabled?: boolean;
|
||
/**
|
||
* Cost cap (USD) per probe invocation. Defaults to 5.
|
||
* Worst case: 5 × 30 nights ≈ $150/month per brain.
|
||
*/
|
||
max_usd?: number;
|
||
};
|
||
/**
|
||
* v0.42.x (#1685 GAP D) — extract_atoms backlog auto-drain. Default ON so a
|
||
* pack-gated silent backlog never piles up unseen; daily-spend-capped so the
|
||
* Haiku spend stays bounded. Read via the DB plane (`engine.getConfig`) at
|
||
* each autopilot tick. Disable with `gbrain config set autopilot.auto_drain.enabled false`.
|
||
*/
|
||
auto_drain?: {
|
||
/** Master switch. Default true. */
|
||
enabled?: boolean;
|
||
/** Per-drain wallclock budget in seconds. Default 120. */
|
||
window_seconds?: number;
|
||
/** Backlog must exceed this to trigger a drain. Default 25. */
|
||
threshold?: number;
|
||
/** Daily spend cap (USD); bounds drains/day = floor(cap / ~$0.30). Default 2.0. */
|
||
max_usd_per_day?: number;
|
||
};
|
||
};
|
||
eval?: {
|
||
/** false disables capture entirely. Defaults to true. */
|
||
capture?: boolean;
|
||
/** false disables PII scrubbing before insert. Defaults to true. */
|
||
scrub_pii?: boolean;
|
||
};
|
||
|
||
/**
|
||
* v0.42 — self-upgrade settings (file plane; read on the hot path before any
|
||
* DB connect, so it must live here, not the DB plane). `mode` is the only
|
||
* knob most users touch: `notify` (default — emit a marker + 4-option prompt),
|
||
* `auto` (silent quiet-hours/idle upgrade; opt-in), `off` (never check).
|
||
* The rest are state the self-upgrade machinery manages.
|
||
*/
|
||
self_upgrade?: {
|
||
mode?: 'auto' | 'notify' | 'off';
|
||
/** Set true once the upgrade-time consent prompt has been shown. */
|
||
mode_prompted?: boolean;
|
||
/** Quiet-hours window for the autopilot silent channel. */
|
||
quiet_hours?: { start?: number; end?: number; tz?: string };
|
||
/** Versions that failed a prior auto-upgrade; never auto-retried. */
|
||
failed_versions?: string[];
|
||
/** Pre-swap breadcrumb so a crash-on-launch version is attributable. */
|
||
attempting_version?: string;
|
||
/** Epoch ms of the last auto-channel check (24h throttle). */
|
||
last_check_ts?: number;
|
||
last_applied_version?: string;
|
||
};
|
||
|
||
/**
|
||
* v0.27.1 — multimodal ingestion flags. Default off; opt-in.
|
||
*
|
||
* Unlike `embedding_model` / `embedding_dimensions` (which size the
|
||
* schema and must be set before initSchema), these flags only affect
|
||
* runtime behavior. They live in the DB plane primarily — `gbrain config
|
||
* set embedding_multimodal true` flips the gate without touching the file.
|
||
* loadConfigWithEngine() merges DB config on top of file/env. Env vars
|
||
* still win as the operator escape hatch.
|
||
*/
|
||
embedding_multimodal?: boolean;
|
||
/** Model override for multimodal embeddings (e.g. "voyage:voyage-multimodal-3"). */
|
||
embedding_multimodal_model?: string;
|
||
|
||
/**
|
||
* v0.42 (#1981) — Retrieval Reflex. The context engine's deterministic
|
||
* per-turn entity-pointer injection. Default ON (absent key = enabled).
|
||
*
|
||
* IMPORTANT: this is a FILE-PLANE / env gate only. The context engine reads
|
||
* it via the synchronous `loadConfig()` during `assemble()`, which never
|
||
* touches the DB. So `gbrain config set retrieval_reflex false` (DB plane)
|
||
* does NOT disable the reflex — set it in `~/.gbrain/config.json` or via
|
||
* `GBRAIN_RETRIEVAL_REFLEX=false`.
|
||
*/
|
||
retrieval_reflex?: boolean;
|
||
/** Max pointers injected per turn (default 3). File-plane only. */
|
||
retrieval_reflex_max_pointers?: number;
|
||
/**
|
||
* v0.43 (#2095) — how many recent turns the reflex extracts entities from
|
||
* (default 4). 1 reproduces the legacy current-turn-only behavior (and the
|
||
* legacy slug+title suppression). File-plane / env
|
||
* (GBRAIN_RETRIEVAL_REFLEX_WINDOW_TURNS) only — same plane as the other
|
||
* reflex knobs.
|
||
*/
|
||
retrieval_reflex_window_turns?: number;
|
||
embedding_image_ocr?: boolean;
|
||
embedding_image_ocr_model?: string;
|
||
|
||
/**
|
||
* v0.36 — embedding-column registry (D7). Maps a content_chunks column
|
||
* name to its provider + dimensions + pgvector type. Both keys live in
|
||
* the DB plane (`gbrain config set ...`) so users can flip without
|
||
* editing files. Resolver merges this with `BUILTIN_EMBEDDING_COLUMNS`
|
||
* (which derive their provider from `embedding_model` /
|
||
* `embedding_multimodal_model`).
|
||
*
|
||
* Validation lives in `src/core/search/embedding-column.ts` per D12 —
|
||
* keys must match `/^[a-z_][a-z0-9_]*$/`, type in {vector, halfvec},
|
||
* dimensions 1..8192, provider parseable as `provider:model`.
|
||
*/
|
||
embedding_columns?: Record<string, EmbeddingColumnConfig>;
|
||
/**
|
||
* v0.36 — name of the column hybridSearch uses by default. Per-call
|
||
* `SearchOpts.embeddingColumn` overrides this; absent => 'embedding'.
|
||
* Validated against the merged `embedding_columns` registry at config-
|
||
* set time and on hybridSearch entry.
|
||
*/
|
||
search_embedding_column?: string;
|
||
|
||
/**
|
||
* v0.41 content-sanity tunables. Read via file/env/DB plane (D1: lint
|
||
* lifts to DB config when reachable). Resolution order:
|
||
* env > file > DB > defaults from `src/core/content-sanity.ts`.
|
||
*
|
||
* Both lint AND ingest go through the same effective resolution so a
|
||
* `gbrain config set content_sanity.bytes_block N` flips both surfaces
|
||
* uniformly. CI without `~/.gbrain/` falls through to env/defaults.
|
||
*/
|
||
content_sanity?: {
|
||
/** Stderr warn + lint `huge-page` rule fires above this (UTF-8 bytes
|
||
* of compiled_truth + timeline). Default: 50_000. Env override:
|
||
* `GBRAIN_PAGE_WARN_BYTES`. */
|
||
bytes_warn?: number;
|
||
/** Soft-block: page writes with `frontmatter.embed_skip` set but
|
||
* embedder skips on next sweep. Default: 500_000. Env override:
|
||
* `GBRAIN_PAGE_BLOCK_BYTES`. */
|
||
bytes_block?: number;
|
||
/** Master switch for the built-in junk-pattern set. Default: true.
|
||
* Env override: `GBRAIN_NO_JUNK_PATTERNS=1` flips to false. */
|
||
junk_patterns_enabled?: boolean;
|
||
/** Master kill-switch for all sanity checks. When true, ingest emits
|
||
* loud stderr per page but lets everything through. Default: false.
|
||
* Env override: `GBRAIN_NO_SANITY=1` flips to true. */
|
||
disabled?: boolean;
|
||
/** Disposition for high-confidence junk (Cloudflare/CAPTCHA pattern or
|
||
* operator literal). `quarantine` (default) = page lands hidden +
|
||
* reviewable; `reject` = hard-block (throw → sync-failure). Issue #1699.
|
||
* No env override (a destructive flip belongs in explicit config). */
|
||
junk_disposition?: 'quarantine' | 'reject';
|
||
/** Max markup:total ratio before the fuzzy markup-heavy FLAG fires
|
||
* (page stays searchable, agent warned). Default: 0.85. Env override:
|
||
* `GBRAIN_MAX_MARKUP_RATIO`. */
|
||
max_markup_ratio?: number;
|
||
/** Master switch for the prose/markup pass. Default: true. When false,
|
||
* no markup-heavy flagging happens (patterns + oversize still apply). */
|
||
prose_check_enabled?: boolean;
|
||
};
|
||
|
||
/**
|
||
* v0.41.2.1 — dream cycle config (synthesize + patterns phases).
|
||
* Read-precedence per key: file > DB > defaults. There are no
|
||
* `GBRAIN_DREAM_*` env vars; do not add an env layer without first
|
||
* extending `loadConfig()` to read them.
|
||
*
|
||
* Existing consumers (synthesize.ts, patterns.ts) read these keys
|
||
* directly via `engine.getConfig()`, so they already see DB-plane
|
||
* values. The structured shape here exists so consumers that read
|
||
* the merged config object (e.g. extract-atoms.ts) see the values
|
||
* uniformly without per-call-site `engine.getConfig()` fallbacks.
|
||
*
|
||
* Closes PR #1416's "silent dream.* config misses on DB-plane writes"
|
||
* for the merged-config code path.
|
||
*/
|
||
dream?: {
|
||
synthesize?: {
|
||
session_corpus_dir?: string;
|
||
meeting_transcripts_dir?: string;
|
||
verdict_model?: string;
|
||
max_prompt_tokens?: number;
|
||
max_chunks_per_transcript?: number;
|
||
};
|
||
patterns?: {
|
||
lookback_days?: number;
|
||
min_evidence?: number;
|
||
};
|
||
};
|
||
|
||
/**
|
||
* Thin-client mode (multi-topology v1). When set, this install does NOT
|
||
* have a local DB; it talks to a remote `gbrain serve --http` over MCP.
|
||
* The CLI dispatch guard in `src/cli.ts` checks for this field BEFORE
|
||
* `connectEngine` and refuses any DB-bound subcommand. The `engine` field
|
||
* above is still populated (default-inferred) but never used.
|
||
*
|
||
* Two URLs because OAuth discovery + `/token` live at the issuer root,
|
||
* while tool dispatch lives at `/mcp`. They compose from a common base
|
||
* in the typical setup but the config keeps them explicit so reverse-proxy
|
||
* topologies work.
|
||
*
|
||
* `oauth_client_secret` can also be supplied via the
|
||
* `GBRAIN_REMOTE_CLIENT_SECRET` env var (preferred for headless agents);
|
||
* env-var value wins when both are present.
|
||
*/
|
||
remote_mcp?: {
|
||
issuer_url: string;
|
||
mcp_url: string;
|
||
oauth_client_id: string;
|
||
oauth_client_secret?: string;
|
||
};
|
||
|
||
/**
|
||
* v0.38 — active schema pack name (D13 tier 6 in the 7-tier resolution
|
||
* chain). The pack drives type inference, alias closure for search,
|
||
* link-verb regexes, expert-routing flags, and enrichment dispatch.
|
||
* Default: `gbrain-base` (reproduces pre-v0.38 hardcoded behavior).
|
||
*
|
||
* Resolution priority (highest → lowest, per D13):
|
||
* 1. Per-call SearchOpts.schema_pack (CLI-only; rejected for remote callers)
|
||
* 2. GBRAIN_SCHEMA_PACK env var
|
||
* 3. Per-source DB config `schema_pack.source.<id>`
|
||
* 4. Brain-wide DB config `schema_pack`
|
||
* 5. gbrain.yml `schema:` section
|
||
* 6. THIS field (~/.gbrain/config.json)
|
||
* 7. Default 'gbrain-base'
|
||
*
|
||
* `gbrain config set schema_pack <name>` writes the DB plane (tier 4);
|
||
* editing this file directly writes tier 6. Env var (tier 2) is the
|
||
* operator escape hatch.
|
||
*/
|
||
schema_pack?: string;
|
||
|
||
/**
|
||
* PR1 — MCP skill-catalog publishing. Lets a thin MCP client (Codex desktop,
|
||
* Claude Code, Perplexity) discover and follow this agent repo's skills over
|
||
* `gbrain serve`. See `src/core/skill-catalog.ts` for the trust-boundary memo.
|
||
*/
|
||
mcp?: {
|
||
/**
|
||
* Gate for `list_skills` / `get_skill` over a REMOTE transport. Runtime
|
||
* default is OFF (absent key → OFF) so an upgrade never silently grants
|
||
* existing read tokens host-skill read. `gbrain init` writes `true` for new
|
||
* installs; the upgrade migration prompts existing owners to enable it.
|
||
* Local CLI callers (`ctx.remote === false`) bypass the gate entirely.
|
||
*/
|
||
publish_skills?: boolean;
|
||
/**
|
||
* Gate for the `advisor` op over a REMOTE transport (#2180). Separate from
|
||
* `publish_skills` because the advisor exposes operational diagnostics
|
||
* (version drift, stalled jobs, embedding-key presence), not prose skills.
|
||
* Default OFF; local CLI callers bypass. The MCP advisor is read-only.
|
||
*/
|
||
publish_advisor?: boolean;
|
||
/**
|
||
* Explicit skills-dir override. Wins over autodetect — makes which skills
|
||
* get published deterministic across laptop / daemon / container launches.
|
||
* When unset, the ops autodetect (remote callers exclude the install-path
|
||
* tier so a hosted gbrain never serves its own bundled dev skills).
|
||
*/
|
||
skills_dir?: string;
|
||
};
|
||
}
|
||
|
||
/**
|
||
* True when this install is configured as a thin client of a remote
|
||
* `gbrain serve --http`. Single source of truth for the "is this a
|
||
* thin-client install?" check used by the CLI dispatch guard, doctor
|
||
* branch, and remote subcommands.
|
||
*/
|
||
export function isThinClient(config: GBrainConfig | null): boolean {
|
||
return !!config?.remote_mcp;
|
||
}
|
||
|
||
/**
|
||
* Load config with credential precedence: env vars > config file.
|
||
* Plugin config is handled by the plugin runtime injecting env vars.
|
||
*/
|
||
// v0.36.x #1086: translate legacy `provider` + `model` config shape (seen in
|
||
// pre-v0.32 docs and some community templates) to the canonical
|
||
// `embedding_model: "<provider>:<model>"`. Without this translation, sync
|
||
// and embed silently fell through to the hardcoded OpenAI default, blocking
|
||
// Voyage / Cohere / Mistral users from using their configured provider.
|
||
function migrateLegacyEmbeddingConfig(raw: Record<string, unknown>): Record<string, unknown> {
|
||
if (raw.embedding_model !== undefined) return raw;
|
||
const provider = typeof raw.provider === 'string' ? raw.provider : undefined;
|
||
const model = typeof raw.model === 'string' ? raw.model : undefined;
|
||
if (!provider || !model) return raw;
|
||
// Strip the legacy keys to avoid downstream confusion. Emit a one-line
|
||
// stderr nudge so the operator updates their config to the canonical shape.
|
||
const rest = { ...raw };
|
||
delete rest.provider;
|
||
delete rest.model;
|
||
rest.embedding_model = `${provider}:${model}`;
|
||
console.warn(
|
||
`[config] legacy "provider" + "model" detected; using "${rest.embedding_model}".` +
|
||
` Rewrite ~/.gbrain/config.json to: "embedding_model": "${rest.embedding_model}".`,
|
||
);
|
||
return rest;
|
||
}
|
||
|
||
/**
|
||
* File-only config loader. Reads ~/.gbrain/config.json and applies the
|
||
* legacy embedding-config migration shim. Does NOT merge env vars, does
|
||
* NOT infer engine kind from DATABASE_URL.
|
||
*
|
||
* Used by `gbrain init`'s config-merge path (B.4) where loading
|
||
* `loadConfig()` would poison the saved file with transient env state
|
||
* (e.g. a CI run with DATABASE_URL set writes a Postgres config.json
|
||
* for a PGLite brain). Read-path callers should keep using `loadConfig()`
|
||
* because env vars are the canonical operator escape hatch at runtime.
|
||
*
|
||
* v0.37 fix wave (CDX-5 from round 1). Pinned by test/config-file-only-loader.test.ts.
|
||
*/
|
||
export function loadConfigFileOnly(): GBrainConfig | null {
|
||
try {
|
||
const raw = readFileSync(getConfigPath(), 'utf-8');
|
||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||
return migrateLegacyEmbeddingConfig(parsed) as unknown as GBrainConfig;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* #427 guard — DATABASE_URL hijack via Bun's cwd .env auto-load.
|
||
*
|
||
* Bun merges `.env` files from the process cwd into process.env before any
|
||
* user code runs. For a globally-installed tool that is a footgun: running
|
||
* gbrain inside any checkout whose `.env` defines DATABASE_URL (Next.js,
|
||
* Hono, Supabase, most web apps) silently retargets the brain at that app's
|
||
* database. Reads hit the wrong DB; `apply-migrations` can write gbrain's
|
||
* schema — including its DDL event trigger — into a production app database
|
||
* (see the v0.42.8 report on #427).
|
||
*
|
||
* Bun gives no way to ask which vars came from a .env file (the merge
|
||
* happens before module load), so we re-parse the .env files Bun auto-loads
|
||
* from cwd and treat DATABASE_URL as "not operator-provided" when its value
|
||
* matches one of them. Deliberate overrides still work two ways:
|
||
* - GBRAIN_DATABASE_URL: namespaced to this tool, never auto-ignored;
|
||
* - exporting DATABASE_URL in the shell: exported vars win over .env in
|
||
* Bun, and a deliberate export that happens to EQUAL the cwd .env value
|
||
* would have selected the same database anyway — ignoring it changes
|
||
* the outcome only by honoring the brain config, which is the safe
|
||
* reading of ambiguous intent.
|
||
*
|
||
* The file list is a superset of Bun's auto-load set across NODE_ENV values
|
||
* so the guard doesn't depend on replicating Bun's exact selection logic.
|
||
*/
|
||
const CWD_DOTENV_FILES = ['.env', '.env.local', '.env.development', '.env.production', '.env.test'];
|
||
|
||
/**
|
||
* All values assigned to `key` across the .env files in `dir`. Collecting
|
||
* every assignment (rather than emulating override order) keeps the guard
|
||
* independent of dotenv precedence rules — a match against ANY assignment
|
||
* means the value is file-origin. Exported for tests.
|
||
*/
|
||
export function dotenvValuesForKey(key: string, dir: string = process.cwd()): Set<string> {
|
||
const values = new Set<string>();
|
||
const assignment = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/;
|
||
for (const name of CWD_DOTENV_FILES) {
|
||
let content: string;
|
||
try {
|
||
content = readFileSync(join(dir, name), 'utf-8');
|
||
} catch {
|
||
continue; // missing/unreadable file — nothing to guard against
|
||
}
|
||
for (const rawLine of content.split('\n')) {
|
||
const line = rawLine.trim();
|
||
if (!line || line.startsWith('#')) continue;
|
||
const m = line.match(assignment);
|
||
if (!m || m[1] !== key) continue;
|
||
let v = m[2].trim();
|
||
if ((v.startsWith('"') && v.endsWith('"') && v.length >= 2) ||
|
||
(v.startsWith("'") && v.endsWith("'") && v.length >= 2)) {
|
||
v = v.slice(1, -1);
|
||
} else {
|
||
const hash = v.indexOf(' #');
|
||
if (hash !== -1) v = v.slice(0, hash).trim();
|
||
}
|
||
if (v) values.add(v);
|
||
}
|
||
}
|
||
return values;
|
||
}
|
||
|
||
let warnedCwdEnvDbUrlIgnored = false;
|
||
|
||
/**
|
||
* The env-provided DB URL gbrain should honor, with the #427 guard applied:
|
||
* a DATABASE_URL whose value matches an assignment in a cwd .env file is
|
||
* treated as belonging to the project in cwd, not to gbrain, and ignored
|
||
* with a one-time stderr notice. GBRAIN_DATABASE_URL is always honored.
|
||
* `dir` is injectable for tests; callers use the default.
|
||
*/
|
||
export function effectiveEnvDatabaseUrl(dir: string = process.cwd()): string | undefined {
|
||
if (process.env.GBRAIN_DATABASE_URL) return process.env.GBRAIN_DATABASE_URL;
|
||
const url = process.env.DATABASE_URL;
|
||
if (!url) return undefined;
|
||
if (dotenvValuesForKey('DATABASE_URL', dir).has(url)) {
|
||
if (!warnedCwdEnvDbUrlIgnored) {
|
||
warnedCwdEnvDbUrlIgnored = true;
|
||
console.warn(
|
||
'[config] Ignoring DATABASE_URL auto-loaded by Bun from a .env file in the current ' +
|
||
'directory — it belongs to the project here, not to gbrain. Using the engine from ' +
|
||
'~/.gbrain/config.json instead. To point gbrain at that database deliberately, set ' +
|
||
'GBRAIN_DATABASE_URL.',
|
||
);
|
||
}
|
||
return undefined;
|
||
}
|
||
return url;
|
||
}
|
||
|
||
export function loadConfig(): GBrainConfig | null {
|
||
let fileConfig: GBrainConfig | null = null;
|
||
try {
|
||
const raw = readFileSync(getConfigPath(), 'utf-8');
|
||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||
fileConfig = migrateLegacyEmbeddingConfig(parsed) as unknown as GBrainConfig;
|
||
} catch { /* no config file */ }
|
||
|
||
// Try env vars (cwd-.env-origin DATABASE_URL excluded — see #427 guard above)
|
||
const dbUrl = effectiveEnvDatabaseUrl();
|
||
|
||
if (!fileConfig && !dbUrl) return null;
|
||
|
||
// Infer engine type. A DATABASE_URL-style env var is always a Postgres
|
||
// connection target and must override a file-backed PGLite engine
|
||
// selection; otherwise direct-script / operator paths can silently hit
|
||
// the local PGLite brain while claiming to use the env URL. The PGLite
|
||
// database_path is also cleared when dbUrl is set so toEngineConfig
|
||
// doesn't pass a stale path through alongside the URL.
|
||
const inferredEngine: 'postgres' | 'pglite' = dbUrl
|
||
? 'postgres'
|
||
: fileConfig?.engine || (fileConfig?.database_path ? 'pglite' : 'postgres');
|
||
|
||
// Merge: env vars override config file. READ only — never mutate process.env.
|
||
const merged = {
|
||
...fileConfig,
|
||
engine: inferredEngine,
|
||
...(dbUrl ? { database_url: dbUrl } : {}),
|
||
...(dbUrl ? { database_path: undefined } : {}),
|
||
...(process.env.OPENAI_API_KEY ? { openai_api_key: process.env.OPENAI_API_KEY } : {}),
|
||
...(process.env.ANTHROPIC_API_KEY ? { anthropic_api_key: process.env.ANTHROPIC_API_KEY } : {}),
|
||
...(process.env.ZEROENTROPY_API_KEY ? { zeroentropy_api_key: process.env.ZEROENTROPY_API_KEY } : {}),
|
||
...(process.env.OPENROUTER_API_KEY ? { openrouter_api_key: process.env.OPENROUTER_API_KEY } : {}),
|
||
...(process.env.GBRAIN_EMBEDDING_MODEL ? { embedding_model: process.env.GBRAIN_EMBEDDING_MODEL } : {}),
|
||
...(process.env.GBRAIN_EMBEDDING_DIMENSIONS ? { embedding_dimensions: parseInt(process.env.GBRAIN_EMBEDDING_DIMENSIONS, 10) } : {}),
|
||
...(process.env.GBRAIN_EXPANSION_MODEL ? { expansion_model: process.env.GBRAIN_EXPANSION_MODEL } : {}),
|
||
...(process.env.GBRAIN_CHAT_MODEL ? { chat_model: process.env.GBRAIN_CHAT_MODEL } : {}),
|
||
...(process.env.GBRAIN_CHAT_FALLBACK_CHAIN
|
||
? { chat_fallback_chain: process.env.GBRAIN_CHAT_FALLBACK_CHAIN.split(',').map(s => s.trim()).filter(Boolean) }
|
||
: {}),
|
||
...(process.env.GBRAIN_EMBEDDING_MULTIMODAL
|
||
? { embedding_multimodal: process.env.GBRAIN_EMBEDDING_MULTIMODAL === 'true' }
|
||
: {}),
|
||
...(process.env.GBRAIN_EMBEDDING_IMAGE_OCR
|
||
? { embedding_image_ocr: process.env.GBRAIN_EMBEDDING_IMAGE_OCR === 'true' }
|
||
: {}),
|
||
...(process.env.GBRAIN_EMBEDDING_MULTIMODAL_MODEL
|
||
? { embedding_multimodal_model: process.env.GBRAIN_EMBEDDING_MULTIMODAL_MODEL }
|
||
: {}),
|
||
...(process.env.GBRAIN_EMBEDDING_IMAGE_OCR_MODEL
|
||
? { embedding_image_ocr_model: process.env.GBRAIN_EMBEDDING_IMAGE_OCR_MODEL }
|
||
: {}),
|
||
...(process.env.GBRAIN_RETRIEVAL_REFLEX
|
||
? { retrieval_reflex: !(process.env.GBRAIN_RETRIEVAL_REFLEX === 'false' || process.env.GBRAIN_RETRIEVAL_REFLEX === '0') }
|
||
: {}),
|
||
...(process.env.GBRAIN_RETRIEVAL_REFLEX_WINDOW_TURNS &&
|
||
Number.isFinite(Number(process.env.GBRAIN_RETRIEVAL_REFLEX_WINDOW_TURNS))
|
||
? { retrieval_reflex_window_turns: Number(process.env.GBRAIN_RETRIEVAL_REFLEX_WINDOW_TURNS) }
|
||
: {}),
|
||
...(process.env.GBRAIN_REMOTE_CLIENT_SECRET && fileConfig?.remote_mcp
|
||
? { remote_mcp: { ...fileConfig.remote_mcp, oauth_client_secret: process.env.GBRAIN_REMOTE_CLIENT_SECRET } }
|
||
: {}),
|
||
};
|
||
|
||
// v0.41 content-sanity env overrides. Built up as a sparse object so
|
||
// env presence wins over file/DB only for the specific keys set,
|
||
// matching the precedence pattern used elsewhere in loadConfig.
|
||
// The env vars use natural names (GBRAIN_NO_SANITY=1 is more
|
||
// operator-friendly than GBRAIN_CONTENT_SANITY_DISABLED=true).
|
||
const envContentSanity: GBrainConfig['content_sanity'] = {};
|
||
if (process.env.GBRAIN_PAGE_WARN_BYTES) {
|
||
const n = parseInt(process.env.GBRAIN_PAGE_WARN_BYTES, 10);
|
||
if (Number.isFinite(n) && n > 0) envContentSanity.bytes_warn = n;
|
||
}
|
||
if (process.env.GBRAIN_PAGE_BLOCK_BYTES) {
|
||
const n = parseInt(process.env.GBRAIN_PAGE_BLOCK_BYTES, 10);
|
||
if (Number.isFinite(n) && n > 0) envContentSanity.bytes_block = n;
|
||
}
|
||
if (process.env.GBRAIN_NO_JUNK_PATTERNS === '1') {
|
||
envContentSanity.junk_patterns_enabled = false;
|
||
}
|
||
if (process.env.GBRAIN_NO_SANITY === '1') {
|
||
envContentSanity.disabled = true;
|
||
}
|
||
if (process.env.GBRAIN_MAX_MARKUP_RATIO) {
|
||
const n = parseFloat(process.env.GBRAIN_MAX_MARKUP_RATIO);
|
||
if (Number.isFinite(n) && n > 0 && n <= 1) envContentSanity.max_markup_ratio = n;
|
||
}
|
||
// Only attach the field when at least one env var was set, so the
|
||
// sparse-merge semantics elsewhere in loadConfigWithEngine work
|
||
// (env presence => "this key already has a value, don't read DB").
|
||
if (Object.keys(envContentSanity).length > 0) {
|
||
(merged as GBrainConfig).content_sanity = {
|
||
...(fileConfig?.content_sanity ?? {}),
|
||
...envContentSanity,
|
||
};
|
||
}
|
||
|
||
return merged as GBrainConfig;
|
||
}
|
||
|
||
/**
|
||
* v0.27.1 — async config loader that overlays DB-plane config on top of the
|
||
* file/env config. Used by `gbrain` CLI's connectEngine() AFTER engine.connect()
|
||
* so flags written via `gbrain config set` actually take effect. Unlike the
|
||
* sync loadConfig(), this needs an engine handle to read the config table.
|
||
*
|
||
* Precedence: env > file > DB > defaults. Env stays the operator escape hatch;
|
||
* file is the durable per-machine config; DB is the user-mutable runtime knob.
|
||
*
|
||
* Today only the v0.27.1 multimodal flags participate in DB-merge. Existing
|
||
* fields (embedding_model, etc.) keep their file/env-only loading because they
|
||
* size the schema and must be stable across engine connect.
|
||
*/
|
||
export async function loadConfigWithEngine(
|
||
engine: { getConfig(key: string): Promise<string | null | undefined> },
|
||
base?: GBrainConfig | null,
|
||
): Promise<GBrainConfig | null> {
|
||
// Codex /ship finding #3: when there's no file config AND no env DB URL,
|
||
// loadConfig() returns null and the DB merge would be skipped — env-only
|
||
// installs (engine wired via direct SDK pass) wouldn't see DB-plane
|
||
// overrides like `embedding_columns` / `search_embedding_column` set via
|
||
// `gbrain config set`. Since we have a live engine here, synthesize a
|
||
// minimal base config so the DB-plane merge still runs. The synthesized
|
||
// config has no auth or model fields; DB-plane keys overlay correctly
|
||
// and downstream callers either find them or fall through to defaults.
|
||
// Also applies when callers pass an explicit null for `base`.
|
||
const fileConfig: GBrainConfig =
|
||
(base !== undefined ? base : loadConfig()) ??
|
||
({ engine: 'postgres' } as GBrainConfig);
|
||
|
||
// DB-plane reads. Quiet failures — if the config table doesn't exist yet
|
||
// (pre-v36 brain mid-migration), treat as null and let file/env defaults
|
||
// win. The migration runner reads file/env directly anyway.
|
||
async function dbBool(key: string): Promise<boolean | undefined> {
|
||
try {
|
||
const v = await engine.getConfig(key);
|
||
if (v === undefined || v === null || v === '') return undefined;
|
||
return v === 'true';
|
||
} catch {
|
||
return undefined;
|
||
}
|
||
}
|
||
async function dbStr(key: string): Promise<string | undefined> {
|
||
try {
|
||
const v = await engine.getConfig(key);
|
||
if (v === undefined || v === null || v === '') return undefined;
|
||
return v;
|
||
} catch {
|
||
return undefined;
|
||
}
|
||
}
|
||
|
||
const dbMultimodal = await dbBool('embedding_multimodal');
|
||
const dbMultimodalModel = await dbStr('embedding_multimodal_model');
|
||
const dbOcr = await dbBool('embedding_image_ocr');
|
||
const dbOcrModel = await dbStr('embedding_image_ocr_model');
|
||
// v0.36 (D7) — embedding-column registry merge. Stored as JSON string in
|
||
// the config table. Parse + shape-check here; full registry validation
|
||
// (regex on keys, type/dim/provider field shapes) runs in the resolver at
|
||
// first use so a malformed DB row doesn't kill engine connect.
|
||
const dbEmbeddingColumns = await dbStr('embedding_columns');
|
||
const dbSearchEmbeddingColumn = await dbStr('search_embedding_column');
|
||
|
||
// DB applies only when env did NOT win. Env presence is detected by the
|
||
// sync loadConfig() already setting the field. For each flag, prefer the
|
||
// existing fileConfig value when defined; otherwise fall through to DB.
|
||
const merged: GBrainConfig = { ...fileConfig };
|
||
if (merged.embedding_multimodal === undefined && dbMultimodal !== undefined) {
|
||
merged.embedding_multimodal = dbMultimodal;
|
||
}
|
||
if (merged.embedding_multimodal_model === undefined && dbMultimodalModel !== undefined) {
|
||
merged.embedding_multimodal_model = dbMultimodalModel;
|
||
}
|
||
if (merged.embedding_image_ocr === undefined && dbOcr !== undefined) {
|
||
merged.embedding_image_ocr = dbOcr;
|
||
}
|
||
if (merged.embedding_image_ocr_model === undefined && dbOcrModel !== undefined) {
|
||
merged.embedding_image_ocr_model = dbOcrModel;
|
||
}
|
||
if (merged.embedding_columns === undefined && dbEmbeddingColumns !== undefined) {
|
||
try {
|
||
const parsed = JSON.parse(dbEmbeddingColumns);
|
||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||
merged.embedding_columns = parsed as Record<string, EmbeddingColumnConfig>;
|
||
} else {
|
||
console.warn('[gbrain] config: embedding_columns DB value is not a JSON object; ignoring');
|
||
}
|
||
} catch (err) {
|
||
console.warn(`[gbrain] config: embedding_columns DB value is not valid JSON; ignoring (${(err as Error).message})`);
|
||
}
|
||
}
|
||
if (merged.search_embedding_column === undefined && dbSearchEmbeddingColumn !== undefined) {
|
||
merged.search_embedding_column = dbSearchEmbeddingColumn;
|
||
}
|
||
|
||
// v0.41 content-sanity DB-plane merge (D1: lint lifts to read these
|
||
// when reachable). Per-key sparse-merge: env/file wins per individual
|
||
// key; DB fills the gaps. The container object is constructed only if
|
||
// at least one source provides a value, mirroring the env-merge logic
|
||
// in loadConfig().
|
||
async function dbInt(key: string): Promise<number | undefined> {
|
||
const v = await dbStr(key);
|
||
if (v === undefined) return undefined;
|
||
const n = parseInt(v, 10);
|
||
return Number.isFinite(n) && n > 0 ? n : undefined;
|
||
}
|
||
const dbWarnBytes = await dbInt('content_sanity.bytes_warn');
|
||
const dbBlockBytes = await dbInt('content_sanity.bytes_block');
|
||
const dbJunkEnabled = await dbBool('content_sanity.junk_patterns_enabled');
|
||
const dbSanityDisabled = await dbBool('content_sanity.disabled');
|
||
const dbJunkDisposition = await dbStr('content_sanity.junk_disposition');
|
||
const dbMaxMarkupRatioStr = await dbStr('content_sanity.max_markup_ratio');
|
||
const dbProseCheckEnabled = await dbBool('content_sanity.prose_check_enabled');
|
||
|
||
const existingCS = merged.content_sanity ?? {};
|
||
const mergedCS: NonNullable<GBrainConfig['content_sanity']> = { ...existingCS };
|
||
if (mergedCS.bytes_warn === undefined && dbWarnBytes !== undefined) {
|
||
mergedCS.bytes_warn = dbWarnBytes;
|
||
}
|
||
if (mergedCS.bytes_block === undefined && dbBlockBytes !== undefined) {
|
||
mergedCS.bytes_block = dbBlockBytes;
|
||
}
|
||
if (mergedCS.junk_patterns_enabled === undefined && dbJunkEnabled !== undefined) {
|
||
mergedCS.junk_patterns_enabled = dbJunkEnabled;
|
||
}
|
||
if (mergedCS.disabled === undefined && dbSanityDisabled !== undefined) {
|
||
mergedCS.disabled = dbSanityDisabled;
|
||
}
|
||
if (
|
||
mergedCS.junk_disposition === undefined &&
|
||
(dbJunkDisposition === 'quarantine' || dbJunkDisposition === 'reject')
|
||
) {
|
||
mergedCS.junk_disposition = dbJunkDisposition;
|
||
}
|
||
if (mergedCS.max_markup_ratio === undefined && dbMaxMarkupRatioStr !== undefined) {
|
||
const n = parseFloat(dbMaxMarkupRatioStr);
|
||
if (Number.isFinite(n) && n > 0 && n <= 1) mergedCS.max_markup_ratio = n;
|
||
}
|
||
if (mergedCS.prose_check_enabled === undefined && dbProseCheckEnabled !== undefined) {
|
||
mergedCS.prose_check_enabled = dbProseCheckEnabled;
|
||
}
|
||
if (Object.keys(mergedCS).length > 0) {
|
||
merged.content_sanity = mergedCS;
|
||
}
|
||
|
||
// v0.41.2.1 — dream.* DB-plane merge. Precedence is file > DB > defaults
|
||
// per key (NO env layer; see GBrainConfig.dream JSDoc). Without this,
|
||
// `extract-atoms.ts` and any other consumer that reads the merged config
|
||
// (vs calling `engine.getConfig()` directly) silently misses dream.*
|
||
// config set via `gbrain config set`.
|
||
const dbSessionCorpusDir = await dbStr('dream.synthesize.session_corpus_dir');
|
||
const dbMeetingTranscriptsDir = await dbStr('dream.synthesize.meeting_transcripts_dir');
|
||
const dbVerdictModel = await dbStr('dream.synthesize.verdict_model');
|
||
const dbMaxPromptTokens = await dbInt('dream.synthesize.max_prompt_tokens');
|
||
const dbMaxChunksPerTranscript = await dbInt('dream.synthesize.max_chunks_per_transcript');
|
||
const dbLookbackDays = await dbInt('dream.patterns.lookback_days');
|
||
const dbMinEvidence = await dbInt('dream.patterns.min_evidence');
|
||
|
||
const existingDream = merged.dream ?? {};
|
||
const existingSynth = existingDream.synthesize ?? {};
|
||
const existingPatterns = existingDream.patterns ?? {};
|
||
const mergedSynth: NonNullable<NonNullable<GBrainConfig['dream']>['synthesize']> = { ...existingSynth };
|
||
const mergedPatterns: NonNullable<NonNullable<GBrainConfig['dream']>['patterns']> = { ...existingPatterns };
|
||
|
||
if (mergedSynth.session_corpus_dir === undefined && dbSessionCorpusDir !== undefined) {
|
||
mergedSynth.session_corpus_dir = dbSessionCorpusDir;
|
||
}
|
||
if (mergedSynth.meeting_transcripts_dir === undefined && dbMeetingTranscriptsDir !== undefined) {
|
||
mergedSynth.meeting_transcripts_dir = dbMeetingTranscriptsDir;
|
||
}
|
||
if (mergedSynth.verdict_model === undefined && dbVerdictModel !== undefined) {
|
||
mergedSynth.verdict_model = dbVerdictModel;
|
||
}
|
||
if (mergedSynth.max_prompt_tokens === undefined && dbMaxPromptTokens !== undefined) {
|
||
mergedSynth.max_prompt_tokens = dbMaxPromptTokens;
|
||
}
|
||
if (mergedSynth.max_chunks_per_transcript === undefined && dbMaxChunksPerTranscript !== undefined) {
|
||
mergedSynth.max_chunks_per_transcript = dbMaxChunksPerTranscript;
|
||
}
|
||
if (mergedPatterns.lookback_days === undefined && dbLookbackDays !== undefined) {
|
||
mergedPatterns.lookback_days = dbLookbackDays;
|
||
}
|
||
if (mergedPatterns.min_evidence === undefined && dbMinEvidence !== undefined) {
|
||
mergedPatterns.min_evidence = dbMinEvidence;
|
||
}
|
||
|
||
// Only construct the dream container when at least one leaf was populated
|
||
// — mirrors the content_sanity pattern so empty brains keep `cfg.dream`
|
||
// undefined.
|
||
if (Object.keys(mergedSynth).length > 0 || Object.keys(mergedPatterns).length > 0) {
|
||
const mergedDream: NonNullable<GBrainConfig['dream']> = {};
|
||
if (Object.keys(mergedSynth).length > 0) mergedDream.synthesize = mergedSynth;
|
||
if (Object.keys(mergedPatterns).length > 0) mergedDream.patterns = mergedPatterns;
|
||
merged.dream = mergedDream;
|
||
}
|
||
|
||
return merged;
|
||
}
|
||
|
||
/**
|
||
* v0.37 (D6): canonical list of known config keys for `gbrain config set`
|
||
* validation. Includes both the static GBrainConfig fields (file plane)
|
||
* and well-known DB-plane keys.
|
||
*
|
||
* This is NOT a runtime allow-list applied to reads — gateway/reader code
|
||
* still tolerates extra keys. It's the suggestion source for "did you mean"
|
||
* Levenshtein on `set`. Missing keys can be passed through with `--force`.
|
||
*
|
||
* When adding a new persistent config key:
|
||
* 1. Add it to the GBrainConfig interface (if file-plane) OR document it
|
||
* below (if DB-plane).
|
||
* 2. Add the canonical name to this list so `gbrain config set` accepts it
|
||
* without `--force`.
|
||
*/
|
||
export const KNOWN_CONFIG_KEYS: readonly string[] = [
|
||
// File-plane (GBrainConfig static fields)
|
||
'engine',
|
||
'database_url',
|
||
'database_path',
|
||
'openai_api_key',
|
||
'anthropic_api_key',
|
||
'zeroentropy_api_key',
|
||
'openrouter_api_key',
|
||
'embedding_model',
|
||
'embedding_dimensions',
|
||
'embedding_disabled',
|
||
'expansion_model',
|
||
'chat_model',
|
||
'chat_fallback_chain',
|
||
'provider_base_urls',
|
||
'storage',
|
||
'eval',
|
||
'eval.capture',
|
||
'eval.scrub_pii',
|
||
'embedding_multimodal',
|
||
'embedding_multimodal_model',
|
||
'embedding_image_ocr',
|
||
'embedding_image_ocr_model',
|
||
'embedding_columns',
|
||
'search_embedding_column',
|
||
'remote_mcp',
|
||
'sync',
|
||
'sync.repo_path',
|
||
'sync.last_commit',
|
||
// Gateway-native subagent loop toggle (routes subagent jobs through the
|
||
// provider-agnostic gateway.toolLoop for non-Anthropic providers). The
|
||
// subagent handler's error message tells users to `config set` this, so it
|
||
// must be a known key or `config set` rejects it without --force.
|
||
'agent.use_gateway_loop',
|
||
// DB-plane (v0.32.3 search modes + related)
|
||
'search.mode',
|
||
'search.cache.enabled',
|
||
'search.cache.similarity_threshold',
|
||
'search.cache.ttl_seconds',
|
||
'search.token_budget',
|
||
'search.expansion',
|
||
'search.intent_weighting',
|
||
'search.limit_default',
|
||
'search.mode_upgrade_notice_shown',
|
||
'search.unified_multimodal',
|
||
'search.unified_multimodal_only',
|
||
'search.cross_modal.llm_intent',
|
||
'search.image_query.max_bytes',
|
||
'search.reranker.enabled',
|
||
'search.track_retrieval',
|
||
// Models tier system (v0.31.12)
|
||
'models.default',
|
||
'models.tier.utility',
|
||
'models.tier.reasoning',
|
||
'models.tier.deep',
|
||
'models.tier.subagent',
|
||
'models.aliases',
|
||
'models.dream.synthesize',
|
||
'models.dream.patterns',
|
||
'models.dream.synthesize_verdict',
|
||
'models.drift',
|
||
'models.auto_think',
|
||
'models.think',
|
||
'models.subagent',
|
||
'models.expansion',
|
||
'models.chat',
|
||
'models.eval.longmemeval',
|
||
'facts.extraction_model',
|
||
// Dream cycle config
|
||
'dream.synthesize.session_corpus_dir',
|
||
'dream.synthesize.meeting_transcripts_dir',
|
||
'dream.synthesize.last_completion_ts',
|
||
'dream.synthesize.verdict_model',
|
||
'dream.synthesize.max_prompt_tokens',
|
||
'dream.synthesize.max_chunks_per_transcript',
|
||
'dream.patterns.lookback_days',
|
||
'dream.patterns.min_evidence',
|
||
// Emotional weight (v0.29)
|
||
'emotional_weight.high_tags',
|
||
'emotional_weight.user_holder',
|
||
// Cycle phase config
|
||
'cycle.grade_takes.write_gstack_learnings',
|
||
// Content sanity (v0.41)
|
||
'content_sanity.bytes_warn',
|
||
'content_sanity.bytes_block',
|
||
'content_sanity.junk_patterns_enabled',
|
||
'content_sanity.disabled',
|
||
// Content-quality gate (v0.42, issue #1699)
|
||
'content_sanity.junk_disposition',
|
||
'content_sanity.max_markup_ratio',
|
||
'content_sanity.prose_check_enabled',
|
||
// MCP skill-catalog publishing (PR1)
|
||
'mcp.publish_skills',
|
||
'mcp.publish_skills_prompted',
|
||
'mcp.skills_dir',
|
||
// MCP advisor publishing (#2180): separate gate from publish_skills because
|
||
// the advisor exposes operational diagnostics (version/jobs/key presence),
|
||
// not prose skills. Default OFF; read-only over MCP.
|
||
'mcp.publish_advisor',
|
||
// Skill-nag suppression (#2180): brain-resident pack install nag off-switch.
|
||
'skillpack.nag_disabled',
|
||
// Self-upgrade (v0.42; file plane, read on the hot path)
|
||
'self_upgrade.mode',
|
||
'self_upgrade.mode_prompted',
|
||
'self_upgrade.quiet_hours',
|
||
'self_upgrade.failed_versions',
|
||
'self_upgrade.attempting_version',
|
||
'self_upgrade.last_check_ts',
|
||
'self_upgrade.last_applied_version',
|
||
// Misc
|
||
'artifacts_sync_mode',
|
||
'cross_project_learnings',
|
||
// Link resolution (issue #972)
|
||
'link_resolution',
|
||
'link_resolution.global_basename',
|
||
// Spend controls (v0.42.42.0, issue #2139). Previously `--force`-only — the
|
||
// operator had to discover these by reading source. Registered so `config
|
||
// set` accepts them directly. See docs/operations/spend-controls.md.
|
||
'spend.posture',
|
||
// Life Chronicle (v0.42.56.0, #2390). The release notes' enable command is
|
||
// `gbrain config set auto_chronicle true`, but the key was never registered
|
||
// — so the documented command failed with "Unknown config key" and the
|
||
// operator had to discover --force by reading source. Same class as the
|
||
// spend-controls registration above.
|
||
'auto_chronicle',
|
||
// Takes bootstrap (v0.41.18.0, A12). The onboard remediation's two-gate
|
||
// consent reads this key, and enabling it is the documented path to
|
||
// `gbrain takes extract --from-pages` — same unregistered-key class.
|
||
'takes.bootstrap_enabled',
|
||
'sync.cost_gate_min_usd',
|
||
'sync.federated_v2',
|
||
'embed.backfill_cooldown_min',
|
||
'embed.backfill_max_usd_per_source_24h',
|
||
'embed.backfill_max_usd',
|
||
];
|
||
|
||
/**
|
||
* v0.37 (D6): well-known prefix patterns for DB-plane keys that have
|
||
* unbounded sub-keys. Used as a softer gate before falling back to
|
||
* Levenshtein suggestion in `gbrain config set`.
|
||
*/
|
||
export const KNOWN_CONFIG_KEY_PREFIXES: readonly string[] = [
|
||
'search.', // search.* (mode, cache.*, etc.)
|
||
'models.', // models.* (tier, aliases, per-task)
|
||
'dream.', // dream.synthesize.*, dream.patterns.*
|
||
'cycle.', // cycle.<phase>.*
|
||
'embedding_columns.', // per-column overrides
|
||
'provider_base_urls.', // per-provider base URL overrides
|
||
'content_sanity.', // v0.41 content-sanity tunables
|
||
'mcp.', // mcp.publish_skills, mcp.skills_dir (PR1 skill catalog)
|
||
'autopilot.', // autopilot.nightly_quality_probe.*, autopilot.auto_drain.* (#1685)
|
||
'chronicle.', // chronicle.tz + future Life Chronicle knobs (#2390)
|
||
'self_upgrade.', // v0.42 self-upgrade (mode, quiet_hours, state)
|
||
];
|
||
|
||
export function saveConfig(config: GBrainConfig): void {
|
||
mkdirSync(getConfigDir(), { recursive: true });
|
||
writeFileSync(getConfigPath(), JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
|
||
try {
|
||
chmodSync(getConfigPath(), 0o600);
|
||
} catch {
|
||
// chmod may fail on some platforms
|
||
}
|
||
// v0.35.8.0: ensure the per-home `.gitignore` exists on every config-write
|
||
// path. Cheap, idempotent, doesn't clobber user edits. Catches the case
|
||
// where `~/.gbrain/` lives inside a git worktree (Conductor + gstack
|
||
// workspaces hit this) so `git add` doesn't accidentally stage the brain.
|
||
// The doctor check `home_dir_in_worktree` surfaces vectors this can't
|
||
// close (already-tracked files, screenshots, backups, `git add -f`).
|
||
ensureGitignore();
|
||
}
|
||
|
||
/**
|
||
* Idempotently lay down `~/.gbrain/.gitignore` containing the single line `*`.
|
||
* Honors GBRAIN_HOME via `configDir()`. Best-effort: errors are logged to
|
||
* stderr and never block the caller. Never clobbers a `.gitignore` whose
|
||
* content the user has customized.
|
||
*
|
||
* Called from:
|
||
* - `saveConfig()` so any config-writing path lays it down.
|
||
* - `gbrain post-upgrade` so existing users get it on next upgrade.
|
||
*
|
||
* What this DOES cover: a casual `git add ~/.gbrain` from inside an enclosing
|
||
* worktree — the directory-local `.gitignore` blocks everything below it.
|
||
*
|
||
* What this does NOT cover (the CHANGELOG names these honestly):
|
||
* - Files already tracked before the .gitignore landed (no remediation here).
|
||
* - Screenshots, sync folders (Dropbox/iCloud), Time Machine backups.
|
||
* - `git add -f ~/.gbrain` (deliberate force-add bypasses .gitignore).
|
||
* - Out-of-band copy operations (rsync, cp -r, scp).
|
||
*
|
||
* The doctor check `home_dir_in_worktree` surfaces these vectors at audit
|
||
* time so the user can act on them.
|
||
*/
|
||
export function ensureGitignore(): void {
|
||
try {
|
||
const dir = configDir();
|
||
const file = join(dir, '.gitignore');
|
||
mkdirSync(dir, { recursive: true });
|
||
if (existsSync(file)) {
|
||
// Don't clobber user customization. Only write when the file is missing
|
||
// OR when its content is empty (zero-byte placeholder).
|
||
try {
|
||
const existing = readFileSync(file, 'utf-8');
|
||
if (existing.trim().length > 0) return;
|
||
} catch {
|
||
// Read failed but file exists — leave it alone to be safe.
|
||
return;
|
||
}
|
||
}
|
||
writeFileSync(file, '*\n', { mode: 0o600 });
|
||
try { chmodSync(file, 0o600); } catch { /* platform-specific */ }
|
||
} catch (e) {
|
||
// Best-effort: log to stderr, never block the caller.
|
||
const msg = e instanceof Error ? e.message : String(e);
|
||
process.stderr.write(`[gbrain] ensureGitignore failed (${msg}); continuing\n`);
|
||
}
|
||
}
|
||
|
||
export function toEngineConfig(config: GBrainConfig): EngineConfig {
|
||
return {
|
||
engine: config.engine,
|
||
database_url: config.database_url,
|
||
database_path: config.database_path,
|
||
};
|
||
}
|
||
|
||
export function configDir(): string {
|
||
// Allow override for tests, Docker, and multi-tenant deployments.
|
||
// GBRAIN_HOME is a parent dir; we always append '.gbrain' ourselves so
|
||
// setting GBRAIN_HOME=/tmp/x yields configDir() === '/tmp/x/.gbrain'.
|
||
// Validates the override: must be absolute, no '..' segments.
|
||
const override = process.env.GBRAIN_HOME;
|
||
if (override && override.trim()) {
|
||
const trimmed = override.trim();
|
||
if (!isAbsolute(trimmed)) {
|
||
throw new Error(`GBRAIN_HOME must be an absolute path; got: ${trimmed}`);
|
||
}
|
||
if (trimmed.split(/[\\/]/).includes('..')) {
|
||
throw new Error(`GBRAIN_HOME must not contain '..' segments; got: ${trimmed}`);
|
||
}
|
||
return join(trimmed, '.gbrain');
|
||
}
|
||
return join(homedir(), '.gbrain');
|
||
}
|
||
|
||
export function configPath(): string {
|
||
return join(configDir(), 'config.json');
|
||
}
|
||
|
||
/**
|
||
* Sugar for joining paths under the active gbrain home. Use this anywhere you
|
||
* would otherwise write `join(homedir(), '.gbrain', ...rest)`. Honors
|
||
* GBRAIN_HOME, validates input, and centralizes the convention so future
|
||
* audits stay simple.
|
||
*/
|
||
export function gbrainPath(...segments: string[]): string {
|
||
return join(configDir(), ...segments);
|
||
}
|
||
|
||
/**
|
||
* Introspect where the active DB URL would come from if we tried to connect.
|
||
* Never throws, never connects. Env vars take precedence (matches loadConfig).
|
||
*/
|
||
export function getDbUrlSource(): DbUrlSource {
|
||
if (process.env.GBRAIN_DATABASE_URL) return 'env:GBRAIN_DATABASE_URL';
|
||
// Same #427 guard as loadConfig: a DATABASE_URL that Bun auto-loaded from
|
||
// a cwd .env file is not an operator-provided source. Keeping this in
|
||
// lockstep with loadConfig matters because doctor uses this to tell the
|
||
// user where the URL came from — reporting env:DATABASE_URL while
|
||
// loadConfig ignores it would send them debugging the wrong layer.
|
||
if (effectiveEnvDatabaseUrl()) return 'env:DATABASE_URL';
|
||
if (!existsSync(configPath())) return null;
|
||
try {
|
||
const raw = readFileSync(configPath(), 'utf-8');
|
||
const parsed = JSON.parse(raw) as Partial<GBrainConfig>;
|
||
if (parsed.database_url) return 'config-file';
|
||
if (parsed.database_path) return 'config-file-path';
|
||
return null;
|
||
} catch {
|
||
// Config file exists but is unreadable/malformed — treat as null source.
|
||
return null;
|
||
}
|
||
}
|