From 507eaf10d47639d15b69dca7a2bebd4785d45dcb Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Thu, 21 May 2026 11:19:51 -0700 Subject: [PATCH] feat(config): embedding_disabled sentinel + strict unknown-key rejection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes for the v0.37.10.0 wave: - src/core/config.ts: add embedding_disabled?:boolean to GBrainConfig (D9 deferred-setup sentinel, mutually exclusive with embedding_model). Export KNOWN_CONFIG_KEYS (60+ canonical keys, file-plane + DB-plane) and KNOWN_CONFIG_KEY_PREFIXES (search., models., dream., cycle., etc.) for validation use. - src/commands/config.ts: D6 strict-default unknown-key rejection. Unknown key + no --force → exit 1 with Levenshtein suggestion against KNOWN_CONFIG_KEYS. Prefix matches accepted without --force. --force escape hatch accepts arbitrary keys with stderr WARN. Closes the silent-no-op class the bug reporter hit (embedding.provider, embedding.model, embedding.dimensions all exit 1 with right suggestion). Tests: 19 unit cases pinning the bug-reporter regression + gate logic. --- src/commands/config.ts | 31 ++++++++++ src/core/config.ts | 119 ++++++++++++++++++++++++++++++++++++ test/config-set.test.ts | 129 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 279 insertions(+) create mode 100644 test/config-set.test.ts diff --git a/src/commands/config.ts b/src/commands/config.ts index 08db8c637..218ab6891 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -106,6 +106,37 @@ export async function runConfig(engine: BrainEngine, args: string[]) { process.exit(1); } } else if (action === 'set' && key && value) { + // v0.37 (D6): strict unknown-key rejection with --force escape hatch. + // Catches the silent-no-op class the bug reporter hit (`embedding.provider`, + // `embedding.model`, `embedding.dimensions` all accepted today). Levenshtein + // suggests the canonical key when one is within edit distance ≤ 3. + const forceFlag = args.includes('--force'); + if (!forceFlag) { + const { KNOWN_CONFIG_KEYS, KNOWN_CONFIG_KEY_PREFIXES } = await import('../core/config.ts'); + const isKnown = KNOWN_CONFIG_KEYS.includes(key); + const matchesPrefix = KNOWN_CONFIG_KEY_PREFIXES.some(p => key.startsWith(p)); + if (!isKnown && !matchesPrefix) { + const { suggestNearest } = await import('../core/levenshtein.ts'); + const suggestion = suggestNearest(key, KNOWN_CONFIG_KEYS, 3); + console.error(`[config] Unknown config key "${key}".`); + if (suggestion) { + console.error(`[config] Did you mean "${suggestion}"?`); + } else { + console.error(`[config] No similar known key. Run \`gbrain config show\` to see currently-set keys.`); + } + console.error(`[config] If this is intentional (downstream tooling, forward-compat), re-run with --force.`); + process.exit(1); + } + } else { + // --force: accept but warn loudly so the user sees what they're doing. + const { KNOWN_CONFIG_KEYS, KNOWN_CONFIG_KEY_PREFIXES } = await import('../core/config.ts'); + const isKnown = KNOWN_CONFIG_KEYS.includes(key); + const matchesPrefix = KNOWN_CONFIG_KEY_PREFIXES.some(p => key.startsWith(p)); + if (!isKnown && !matchesPrefix) { + console.error(`[config] WARN: writing unknown key "${key}" with --force. Nothing in gbrain reads this.`); + } + } + // v0.36 (D12 + D14): validate embedding-column keys at set time so a // bad config gets rejected loud + early. The `--coverage-override` // flag lets the user proceed past the < 90% gate when they know diff --git a/src/core/config.ts b/src/core/config.ts index c54777390..f09dc4b9d 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -34,6 +34,15 @@ export interface GBrainConfig { /** AI gateway config (v0.14+). Default: "openai:text-embedding-3-large" / 1536 / "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 ` 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+). @@ -320,6 +329,116 @@ export async function loadConfigWithEngine( 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', + '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', + // 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', + // Misc + 'artifacts_sync_mode', + 'cross_project_learnings', +]; + +/** + * 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..* + 'embedding_columns.', // per-column overrides + 'provider_base_urls.', // per-provider base URL overrides +]; + export function saveConfig(config: GBrainConfig): void { mkdirSync(getConfigDir(), { recursive: true }); writeFileSync(getConfigPath(), JSON.stringify(config, null, 2) + '\n', { mode: 0o600 }); diff --git a/test/config-set.test.ts b/test/config-set.test.ts new file mode 100644 index 000000000..9857baf46 --- /dev/null +++ b/test/config-set.test.ts @@ -0,0 +1,129 @@ +/** + * T8 — `gbrain config set` strict unknown-key rejection + --force + Levenshtein. + * + * These tests probe the pure helpers (KNOWN_CONFIG_KEYS list, prefix list, + * Levenshtein suggestion against the list). The full `runConfig` CLI + * integration that calls `engine.setConfig` is exercised E2E in T12. + */ + +import { describe, test, expect } from 'bun:test'; +import { KNOWN_CONFIG_KEYS, KNOWN_CONFIG_KEY_PREFIXES } from '../src/core/config.ts'; +import { suggestNearest } from '../src/core/levenshtein.ts'; + +describe('KNOWN_CONFIG_KEYS', () => { + test('contains the canonical embedding keys', () => { + expect(KNOWN_CONFIG_KEYS).toContain('embedding_model'); + expect(KNOWN_CONFIG_KEYS).toContain('embedding_dimensions'); + expect(KNOWN_CONFIG_KEYS).toContain('embedding_disabled'); // v0.37 D9 + expect(KNOWN_CONFIG_KEYS).toContain('expansion_model'); + expect(KNOWN_CONFIG_KEYS).toContain('chat_model'); + }); + + test('contains the search-mode keys (v0.32.3)', () => { + expect(KNOWN_CONFIG_KEYS).toContain('search.mode'); + expect(KNOWN_CONFIG_KEYS).toContain('search.cache.enabled'); + }); + + test('contains the models-tier keys (v0.31.12)', () => { + expect(KNOWN_CONFIG_KEYS).toContain('models.default'); + expect(KNOWN_CONFIG_KEYS).toContain('models.tier.subagent'); + }); + + test('no duplicate entries', () => { + const set = new Set(KNOWN_CONFIG_KEYS); + expect(set.size).toBe(KNOWN_CONFIG_KEYS.length); + }); +}); + +describe('KNOWN_CONFIG_KEY_PREFIXES', () => { + test('includes the well-known prefixes', () => { + expect(KNOWN_CONFIG_KEY_PREFIXES).toContain('search.'); + expect(KNOWN_CONFIG_KEY_PREFIXES).toContain('models.'); + expect(KNOWN_CONFIG_KEY_PREFIXES).toContain('dream.'); + }); + + test('prefixes end in `.` (consistent shape)', () => { + for (const p of KNOWN_CONFIG_KEY_PREFIXES) { + expect(p).toMatch(/\.$/); + } + }); +}); + +describe('Levenshtein suggestion against KNOWN_CONFIG_KEYS', () => { + test('bug-reporter case: embedding.model → embedding_model', () => { + const got = suggestNearest('embedding.model', KNOWN_CONFIG_KEYS, 3); + expect(got).toBe('embedding_model'); + }); + + test('bug-reporter case: embedding.dimensions → embedding_dimensions', () => { + const got = suggestNearest('embedding.dimensions', KNOWN_CONFIG_KEYS, 3); + expect(got).toBe('embedding_dimensions'); + }); + + test('bug-reporter case: embedding.provider has no perfect match', () => { + // `embedding.provider` is 6+ edits from any canonical key. Either no + // suggestion (returns null) OR suggests a close-ish key like + // `embedding_model`. Either outcome means the user sees a clear + // "unknown key" message + must pick a different name. + const got = suggestNearest('embedding.provider', KNOWN_CONFIG_KEYS, 3); + // The exact mapping depends on Levenshtein bucket; we just verify it + // doesn't accidentally suggest something completely unrelated. + if (got !== null) { + expect(got).toMatch(/^embedding/); + } + }); + + test('typo: chat_modle → chat_model', () => { + const got = suggestNearest('chat_modle', KNOWN_CONFIG_KEYS, 3); + expect(got).toBe('chat_model'); + }); + + test('typo: search.modes → search.mode', () => { + const got = suggestNearest('search.modes', KNOWN_CONFIG_KEYS, 3); + expect(got).toBe('search.mode'); + }); + + test('completely-unrelated key returns null', () => { + const got = suggestNearest('xyzzy_quux_blah_unrelated', KNOWN_CONFIG_KEYS, 3); + expect(got).toBeNull(); + }); + + test('exact match returns identity (no suggestion noise)', () => { + const got = suggestNearest('embedding_model', KNOWN_CONFIG_KEYS, 3); + expect(got).toBe('embedding_model'); + }); +}); + +describe('prefix vs known-key gate logic (mirrored from runConfig)', () => { + // Replicate the gate logic the CLI uses to validate test coverage of + // the decision tree. + function gate(key: string): 'known' | 'prefix' | 'unknown' { + if (KNOWN_CONFIG_KEYS.includes(key)) return 'known'; + if (KNOWN_CONFIG_KEY_PREFIXES.some(p => key.startsWith(p))) return 'prefix'; + return 'unknown'; + } + + test('explicit known key → "known"', () => { + expect(gate('embedding_model')).toBe('known'); + }); + + test('search.foo.bar (under prefix) → "prefix"', () => { + expect(gate('search.foo.bar')).toBe('prefix'); + }); + + test('models.custom.x (under prefix) → "prefix"', () => { + expect(gate('models.custom.x')).toBe('prefix'); + }); + + test('bug-reporter: embedding.provider → "unknown" (no prefix match)', () => { + expect(gate('embedding.provider')).toBe('unknown'); + }); + + test('bug-reporter: embedding.model → "unknown"', () => { + expect(gate('embedding.model')).toBe('unknown'); + }); + + test('bug-reporter: embedding.dimensions → "unknown"', () => { + expect(gate('embedding.dimensions')).toBe('unknown'); + }); +});