feat(config): embedding_disabled sentinel + strict unknown-key rejection

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.
This commit is contained in:
Garry Tan
2026-05-21 11:19:51 -07:00
parent d3ef6b51cd
commit 507eaf10d4
3 changed files with 279 additions and 0 deletions
+31
View File
@@ -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
+119
View File
@@ -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 <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+).
@@ -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.<phase>.*
'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 });
+129
View File
@@ -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');
});
});