mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 06:23:01 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user