From 80606d5df045f49a9a0d6481bc2d96fc99682069 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Tue, 21 Jul 2026 15:02:50 -0700 Subject: [PATCH] fix(doctor,think): file-plane pack resolution, page-vs-page conflicts, cycle-freshness tuning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Takeover of three community PRs, each salvaged to its correct core and rebased onto master: - #2469: onboard checks (type_proliferation, pack_upgrade_available) passed cfg:null to loadActivePack, so a schema pack selected via `gbrain schema use` (tier-6 file-plane ~/.gbrain/config.json) never resolved and the checks silently compared against the default pack. Now pass loadConfig(); register schema_pack in KNOWN_CONFIG_KEYS. Dropped the fork grab-bag (~22 unrelated files) from the original PR. - #2479: the think synthesis prompt limited the Conflicts rule to take-vs-take, so pages-only brains surfaced zero conflicts. Expanded the rule to conflicts across pages and/or takes with severity tags + a VARIANTS rule, using generic placeholder slugs per the privacy rule (the original hardcoded contributor-brain slugs). Added env knobs GBRAIN_THINK_GATHER_LIMIT / GBRAIN_THINK_EXCERPT_LEN / GBRAIN_THINK_MAX_OUTPUT_TOKENS (defaults unchanged). - #2505: checkCycleFreshness thresholds were env-only; nightly-cadence brains need DB-plane keys + source scoping. Added doctor.cycle_freshness.{warn,fail}_hours and .source_{allowlist,ignorelist} (env wins over config, pace-mode precedence). Dropped the original PR's DREAM_SOURCE_ALLOWLIST fallback — a fork-private cron env var that exists nowhere upstream. Co-authored-by: onchito-walks Co-authored-by: Adityaranjan-VeloxAI Co-authored-by: sene1337 Co-Authored-By: Claude Fable 5 --- src/commands/doctor.ts | 182 ++++++++++++++++++++++- src/core/config.ts | 9 ++ src/core/onboard/checks.ts | 10 +- src/core/think/gather.ts | 19 ++- src/core/think/index.ts | 14 +- src/core/think/prompt.ts | 19 ++- test/config-set.test.ts | 11 ++ test/doctor-cycle-freshness.test.ts | 100 +++++++++++++ test/onboard-pack-upgrade-checks.test.ts | 80 +++++++++- test/think-env-knobs.test.ts | 71 +++++++++ 10 files changed, 495 insertions(+), 20 deletions(-) create mode 100644 test/think-env-knobs.test.ts diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index c9457d318..4ef585c5c 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -3833,6 +3833,133 @@ export async function checkPoolBudget(_engine: BrainEngine): Promise { } } +type CycleFreshnessSourceScope = { + allowlist?: Set; + ignorelist: Set; + invalid: string[]; + configured: boolean; +}; + +const CYCLE_FRESHNESS_SOURCE_ID_RE = /^[A-Za-z0-9_-]+$/; + +type DoctorSourceRow = Awaited>[number]; + +function parseCycleFreshnessSourceList(raw: string | null | undefined, label: string): { + ids: string[]; + invalid: string[]; +} | null { + if (raw === null || raw === undefined || raw.trim() === '') return null; + const parts = raw.split(',').map((s) => s.trim()).filter(Boolean); + const ids: string[] = []; + const seen = new Set(); + const invalid: string[] = []; + for (const part of parts) { + if (!CYCLE_FRESHNESS_SOURCE_ID_RE.test(part)) { + invalid.push(`${label} contains invalid source id "${part}"`); + continue; + } + if (!seen.has(part)) { + seen.add(part); + ids.push(part); + } + } + return { ids, invalid }; +} + +async function getCycleFreshnessConfig(engine: BrainEngine, key: string): Promise { + try { + return await engine.getConfig(key); + } catch { + return null; + } +} + +function parseCycleFreshnessHours(raw: string | null | undefined, label: string, fallback: number): number | null { + if (raw === null || raw === undefined || raw.trim() === '') return null; + const n = Number(raw); + if (!Number.isFinite(n) || n <= 0) { + if (!_envNumberWarned.has(label)) { + _envNumberWarned.add(label); + console.warn(`[gbrain doctor] Ignoring invalid ${label}=${raw}; using default ${fallback}h.`); + } + return fallback; + } + return n; +} + +/** env var > DB config key > hard default (pace-mode precedence). */ +async function resolveCycleFreshnessHours( + engine: BrainEngine, + env: NodeJS.ProcessEnv | undefined, + envKey: string, + dbKey: string, + fallback: number, +): Promise { + const scopedEnv = env ?? process.env; + const envHours = parseCycleFreshnessHours(scopedEnv[envKey], envKey, fallback); + if (envHours !== null) return envHours; + const dbHours = parseCycleFreshnessHours(await getCycleFreshnessConfig(engine, dbKey), dbKey, fallback); + if (dbHours !== null) return dbHours; + return fallback; +} + +async function resolveCycleFreshnessSourceScope( + engine: BrainEngine, + env: NodeJS.ProcessEnv = process.env, +): Promise { + const dbAllowlist = await getCycleFreshnessConfig(engine, 'doctor.cycle_freshness.source_allowlist'); + const dbIgnorelist = await getCycleFreshnessConfig(engine, 'doctor.cycle_freshness.source_ignorelist'); + const allowRaw = + (env.GBRAIN_CYCLE_FRESHNESS_SOURCE_ALLOWLIST?.trim() ? env.GBRAIN_CYCLE_FRESHNESS_SOURCE_ALLOWLIST : undefined) ?? + dbAllowlist; + const ignoreRaw = + (env.GBRAIN_CYCLE_FRESHNESS_SOURCE_IGNORELIST?.trim() ? env.GBRAIN_CYCLE_FRESHNESS_SOURCE_IGNORELIST : undefined) ?? + dbIgnorelist; + + const parsedAllow = parseCycleFreshnessSourceList(allowRaw, 'source allowlist'); + const parsedIgnore = parseCycleFreshnessSourceList(ignoreRaw, 'source ignorelist'); + return { + allowlist: parsedAllow ? new Set(parsedAllow.ids) : undefined, + ignorelist: new Set(parsedIgnore?.ids ?? []), + invalid: [...(parsedAllow?.invalid ?? []), ...(parsedIgnore?.invalid ?? [])], + configured: !!parsedAllow || !!parsedIgnore, + }; +} + +function applyCycleFreshnessSourceScope( + sources: DoctorSourceRow[], + scope: CycleFreshnessSourceScope, +): DoctorSourceRow[] { + return sources.filter((source) => { + if (scope.allowlist && !scope.allowlist.has(source.id)) return false; + if (scope.ignorelist.has(source.id)) return false; + return true; + }); +} + +function cycleFreshnessScopeDetails( + sources: DoctorSourceRow[], + scopedSources: DoctorSourceRow[], + scope: CycleFreshnessSourceScope, +): Record | undefined { + if (!scope.configured) return undefined; + return { + checked_source_count: scopedSources.length, + skipped_source_count: sources.length - scopedSources.length, + source_allowlist: scope.allowlist ? Array.from(scope.allowlist).sort() : undefined, + source_ignorelist: Array.from(scope.ignorelist).sort(), + }; +} + +function cycleFreshnessScopeSuffix( + sources: DoctorSourceRow[], + scopedSources: DoctorSourceRow[], + scope: CycleFreshnessSourceScope, +): string { + if (!scope.configured) return ''; + return ` (${scopedSources.length}/${sources.length} source(s) checked by cycle_freshness source scope)`; +} + /** * v0.38 — per-source `last_full_cycle_at` freshness check. * @@ -3845,13 +3972,16 @@ export async function checkPoolBudget(_engine: BrainEngine): Promise { * * Default thresholds: warn at 6h, fail at 24h. Tighter than sync_freshness * because full-cycle staleness compounds (sync stale → extract stale → - * embed stale → search stale). Env overrides: + * embed stale → search stale). Env/config overrides (env wins, #2505): * - GBRAIN_CYCLE_FRESHNESS_WARN_HOURS (default 6) * - GBRAIN_CYCLE_FRESHNESS_FAIL_HOURS (default 24) + * - GBRAIN_CYCLE_FRESHNESS_SOURCE_ALLOWLIST / _IGNORELIST + * - DB config: doctor.cycle_freshness.warn_hours / fail_hours + * - DB config: doctor.cycle_freshness.source_allowlist / source_ignorelist */ export async function checkCycleFreshness( engine: BrainEngine, - opts?: { nowMs?: number }, + opts?: { nowMs?: number; env?: NodeJS.ProcessEnv }, ): Promise { try { const sources = await engine.listAllSources({ localPathOnly: true }); @@ -3863,8 +3993,41 @@ export async function checkCycleFreshness( }; } - const warnHours = _resolveSyncFreshnessHours('GBRAIN_CYCLE_FRESHNESS_WARN_HOURS', 6); - const failHours = _resolveSyncFreshnessHours('GBRAIN_CYCLE_FRESHNESS_FAIL_HOURS', 24); + const scope = await resolveCycleFreshnessSourceScope(engine, opts?.env); + if (scope.invalid.length > 0) { + return { + name: 'cycle_freshness', + status: 'warn', + message: `Invalid cycle_freshness source scope: ${scope.invalid.join('; ')}`, + details: cycleFreshnessScopeDetails(sources, sources, scope), + }; + } + const scopedSources = applyCycleFreshnessSourceScope(sources, scope); + const details = cycleFreshnessScopeDetails(sources, scopedSources, scope); + const scopeSuffix = cycleFreshnessScopeSuffix(sources, scopedSources, scope); + if (scopedSources.length === 0) { + return { + name: 'cycle_freshness', + status: 'ok', + message: `No federated sources to cycle after cycle_freshness source scope${scopeSuffix}`, + details, + }; + } + + const warnHours = await resolveCycleFreshnessHours( + engine, + opts?.env, + 'GBRAIN_CYCLE_FRESHNESS_WARN_HOURS', + 'doctor.cycle_freshness.warn_hours', + 6, + ); + const failHours = await resolveCycleFreshnessHours( + engine, + opts?.env, + 'GBRAIN_CYCLE_FRESHNESS_FAIL_HOURS', + 'doctor.cycle_freshness.fail_hours', + 24, + ); const warnMs = warnHours * 60 * 60 * 1000; const failMs = failHours * 60 * 60 * 1000; const now = opts?.nowMs ?? Date.now(); @@ -3873,7 +4036,7 @@ export async function checkCycleFreshness( let hasWarnings = false; let hasFailures = false; - for (const source of sources) { + for (const source of scopedSources) { const display = source.name && source.name !== source.id ? `'${source.id}' (${source.name})` : `'${source.id}'`; @@ -3909,20 +4072,23 @@ export async function checkCycleFreshness( return { name: 'cycle_freshness', status: 'fail', - message: `${issues.join('; ')}. Run \`gbrain dream --source \` for each stale source, or start \`gbrain autopilot\`.`, + message: `${issues.join('; ')}. Run \`gbrain dream --source \` for each stale source, or start \`gbrain autopilot\`${scopeSuffix}.`, + details, }; } if (hasWarnings) { return { name: 'cycle_freshness', status: 'warn', - message: `${issues.join('; ')}.`, + message: `${issues.join('; ')}${scopeSuffix}.`, + details, }; } return { name: 'cycle_freshness', status: 'ok', - message: `All ${sources.length} federated source(s) cycled recently`, + message: `All ${scopedSources.length} federated source(s) cycled recently${scopeSuffix}`, + details, }; } catch (e) { return { diff --git a/src/core/config.ts b/src/core/config.ts index 4ca00cdc9..b4bc50387 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -865,6 +865,9 @@ export const KNOWN_CONFIG_KEYS: readonly string[] = [ 'sync', 'sync.repo_path', 'sync.last_commit', + // Active schema pack (tier-4 DB plane via `gbrain config set schema_pack`; + // tier-6 file plane written by `gbrain schema use`). #2469. + 'schema_pack', // 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 @@ -930,6 +933,12 @@ export const KNOWN_CONFIG_KEYS: readonly string[] = [ 'emotional_weight.user_holder', // Cycle phase config 'cycle.grade_takes.write_gstack_learnings', + // Doctor cycle-freshness tuning (#2505): DB-plane thresholds + source + // scoping for nightly-cadence brains. Env vars win over these keys. + 'doctor.cycle_freshness.warn_hours', + 'doctor.cycle_freshness.fail_hours', + 'doctor.cycle_freshness.source_allowlist', + 'doctor.cycle_freshness.source_ignorelist', // Content sanity (v0.41) 'content_sanity.bytes_warn', 'content_sanity.bytes_block', diff --git a/src/core/onboard/checks.ts b/src/core/onboard/checks.ts index 3886fa2dc..7227849ed 100644 --- a/src/core/onboard/checks.ts +++ b/src/core/onboard/checks.ts @@ -18,6 +18,7 @@ import type { BrainEngine } from '../engine.ts'; import type { RemediationStep } from '../remediation-step.ts'; import { makeRemediationStep } from '../remediation-step.ts'; +import { loadConfig } from '../config.ts'; /** Shared shape returned by all four checks. */ export interface OnboardCheckResult { @@ -393,7 +394,10 @@ export async function checkPackUpgradeAvailable( try { dbConfig = (await engine.getConfig('schema_pack')) ?? undefined; } catch { /* engine.config may not exist on very old brains */ } - const active = await loadActivePack({ cfg: null, remote: false, dbConfig }) + // Pass the file-plane config (tier 6) so a schema_pack written by + // `gbrain schema use` to ~/.gbrain/config.json resolves here; cfg:null + // silently fell back to the default pack (#2469). + const active = await loadActivePack({ cfg: loadConfig(), remote: false, dbConfig }) .catch(() => null); if (!active) { return { @@ -467,7 +471,9 @@ export async function checkTypeProliferation( try { dbConfig = (await engine.getConfig('schema_pack')) ?? undefined; } catch { /* tolerate pre-config brains */ } - const active = await loadActivePack({ cfg: null, remote: false, dbConfig }) + // File-plane config (tier 6) so `gbrain schema use` selections resolve + // instead of always falling back to the default pack (#2469). + const active = await loadActivePack({ cfg: loadConfig(), remote: false, dbConfig }) .catch(() => null); if (active) declared = active.manifest.page_types.length; } catch { diff --git a/src/core/think/gather.ts b/src/core/think/gather.ts index 881df4c39..2d8510817 100644 --- a/src/core/think/gather.ts +++ b/src/core/think/gather.ts @@ -24,7 +24,8 @@ export interface ThinkGatherOpts { question: string; /** Anchor entity slug. When set, the graph stream activates. */ anchor?: string; - /** Soft cap on total results across all streams. Default 40. */ + /** Soft cap on total results across all streams. Default 40, overridable via + * GBRAIN_THINK_GATHER_LIMIT (widen for conflict/variant recall, e.g. drift probes). */ gatherLimit?: number; /** Soft cap on take results. Default 30. */ takesLimit?: number; @@ -58,6 +59,18 @@ export interface ThinkGatherResult { const RRF_K = 60; +/** + * Read a positive-integer tuning knob from the environment, falling back to + * the upstream default. Lets retrieval breadth be tuned per-install (e.g. a + * brain with many near-duplicate sources that needs wider conflict/variant + * recall) without changing default behavior. Matches gbrain's GBRAIN_* env + * knob pattern (env above config, incident escape hatch). + */ +export function envInt(name: string, fallback: number): number { + const n = parseInt(process.env[name] ?? '', 10); + return Number.isFinite(n) && n > 0 ? n : fallback; +} + /** Reciprocal-rank fusion: 1/(k+rank). Stable, parameter-light, matches search/hybrid.ts k. */ function rrfScore(rank: number): number { return 1 / (RRF_K + rank); @@ -101,7 +114,7 @@ export async function runGather( engine: BrainEngine, opts: ThinkGatherOpts, ): Promise { - const gatherLimit = opts.gatherLimit ?? 40; + const gatherLimit = opts.gatherLimit ?? envInt('GBRAIN_THINK_GATHER_LIMIT', 40); const takesLimit = opts.takesLimit ?? 30; const graphDepth = opts.graphDepth ?? 2; const sourceScope = opts.sourceIds && opts.sourceIds.length > 0 @@ -192,7 +205,7 @@ export async function runGather( * Pages are rendered as `excerpt`; * takes are rendered via the renderTakesBlock helper from sanitize.ts. */ -export function renderPagesBlock(pages: SearchResult[], excerptLen = 600): string { +export function renderPagesBlock(pages: SearchResult[], excerptLen = envInt('GBRAIN_THINK_EXCERPT_LEN', 600)): string { return pages.map((p, idx) => { const slug = String((p as unknown as { slug?: string }).slug ?? ''); const excerpt = String( diff --git a/src/core/think/index.ts b/src/core/think/index.ts index e11d7475f..4cf8f4bf3 100644 --- a/src/core/think/index.ts +++ b/src/core/think/index.ts @@ -19,7 +19,7 @@ import type Anthropic from '@anthropic-ai/sdk'; import type { BrainEngine, SynthesisEvidenceInput } from '../engine.ts'; -import { runGather, renderPagesBlock, takesHitToTakeForPrompt } from './gather.ts'; +import { runGather, renderPagesBlock, takesHitToTakeForPrompt, envInt } from './gather.ts'; import { renderTakesBlock } from './sanitize.ts'; import { buildThinkSystemPrompt, buildThinkUserMessage } from './prompt.ts'; import { resolveCitations, type ParsedCitation } from './cite-render.ts'; @@ -160,9 +160,15 @@ const DEFAULT_MAX_OUTPUT_TOKENS = 4000; const THINKING_DEFAULT_MAX_OUTPUT_TOKENS = 16000; const THINKING_BY_DEFAULT_MODEL_RE = /^anthropic[:/]claude-[a-z0-9]+-5(?:[.-]|$)/i; export function maxOutputTokensFor(modelStr: string): number { - return THINKING_BY_DEFAULT_MODEL_RE.test(modelStr) - ? THINKING_DEFAULT_MAX_OUTPUT_TOKENS - : DEFAULT_MAX_OUTPUT_TOKENS; + // GBRAIN_THINK_MAX_OUTPUT_TOKENS overrides both defaults: conflict-dense + // syntheses (e.g. drift probes over a brain with many divergent sources) + // can truncate mid-JSON at 4000 — raise the cap per-install (#2479). + return envInt( + 'GBRAIN_THINK_MAX_OUTPUT_TOKENS', + THINKING_BY_DEFAULT_MODEL_RE.test(modelStr) + ? THINKING_DEFAULT_MAX_OUTPUT_TOKENS + : DEFAULT_MAX_OUTPUT_TOKENS, + ); } function inferIntent(question: string, anchor?: string): string { diff --git a/src/core/think/prompt.ts b/src/core/think/prompt.ts index 7107ef729..6ad850d05 100644 --- a/src/core/think/prompt.ts +++ b/src/core/think/prompt.ts @@ -50,8 +50,23 @@ Hard rules: Inline the citation immediately after the claim it supports. Never fabricate slugs/rows. - If a take has weight < 0.5 or kind=hunch, mark it explicitly: "garry has a hunch (w=0.4) that..." rather than asserting it as established. Confidence is part of the data. -- If two takes contradict (different holders, opposite claims), surface BOTH in a "Conflicts" - section. Never silently pick one. +- CONFLICTS: If two or more SOURCES (pages and/or takes) give conflicting or divergent values + for the SAME fact — a number stated against a different base, a stale value, a contradictory + commitment, or different holders making opposite claims — surface ALL of them in a "Conflicts" + section. Never silently pick one, and NEVER reconcile a lower figure as a "discount" or + "variant" unless a source EXPLICITLY states that relationship. In the Conflicts section emit + ONE bullet per conflict, each STARTING with a severity tag in square brackets: + [HIGH] = money/pricing/contract/identity/legal/client-facing commitment; [MEDIUM] = timeline/ + scope/operational; [LOW] = wording/cosmetic. Follow the tag with the fact and each conflicting + value with its [slug]. Example: + "- [HIGH] Phase 1 price: $2,000 [finance/acme-pricing] vs 1k AUD / 50% off [outreach/acme-scripts]" + The Conflicts section is ONLY for genuine disagreements. Do NOT emit a bullet to report that + sources AGREE, that a value is "consistent across sources", or that "no contradiction was found" + — if a fact is consistent, omit it entirely. An explicitly-stated discount off a stated base is + NOT a conflict. If there are zero genuine conflicts, omit the Conflicts section. +- VARIANTS: When a fact legitimately has conditional or variant values (e.g. standard vs warm-lead + vs mentor pricing; per-segment terms; per-phase prices), ENUMERATE every variant with its + condition and [slug]. Do not collapse to a single "standard" value or drop the variants. - If you cannot answer because the brain doesn't contain the relevant data, say so in the "Gaps" section. List the specific missing pieces. Do not make up answers. - Never instruct the user (no "you should" / "I recommend X"). The brain reports; the user decides. diff --git a/test/config-set.test.ts b/test/config-set.test.ts index 042cc599a..69e5bdcb9 100644 --- a/test/config-set.test.ts +++ b/test/config-set.test.ts @@ -30,6 +30,17 @@ describe('KNOWN_CONFIG_KEYS', () => { expect(KNOWN_CONFIG_KEYS).toContain('models.tier.subagent'); }); + test('contains the cycle_freshness tuning keys (#2505)', () => { + expect(KNOWN_CONFIG_KEYS).toContain('doctor.cycle_freshness.warn_hours'); + expect(KNOWN_CONFIG_KEYS).toContain('doctor.cycle_freshness.fail_hours'); + expect(KNOWN_CONFIG_KEYS).toContain('doctor.cycle_freshness.source_allowlist'); + expect(KNOWN_CONFIG_KEYS).toContain('doctor.cycle_freshness.source_ignorelist'); + }); + + test('contains schema_pack (#2469 — `gbrain config set schema_pack` is a known key)', () => { + expect(KNOWN_CONFIG_KEYS).toContain('schema_pack'); + }); + test('contains the dream synthesize timeout keys (#1594)', () => { expect(KNOWN_CONFIG_KEYS).toContain('dream.synthesize.subagent_timeout_ms'); expect(KNOWN_CONFIG_KEYS).toContain('dream.synthesize.subagent_wait_timeout_ms'); diff --git a/test/doctor-cycle-freshness.test.ts b/test/doctor-cycle-freshness.test.ts index 1eebc216d..a81febbcc 100644 --- a/test/doctor-cycle-freshness.test.ts +++ b/test/doctor-cycle-freshness.test.ts @@ -121,4 +121,104 @@ describe('doctor checkCycleFreshness', () => { expect(result.status).toBe('ok'); expect(result.message).toMatch(/No federated sources/); }); + + // #2505 — DB-plane thresholds + source scoping for nightly-cadence brains. + + test('DB-configured thresholds support nightly cadence with grace', async () => { + await engine.executeRaw(`UPDATE sources SET local_path = NULL WHERE id = 'default'`); + await seed('nightly-fresh', agoH(29)); + await seed('missed-one-night', agoH(36)); + await engine.setConfig('doctor.cycle_freshness.warn_hours', '30'); + await engine.setConfig('doctor.cycle_freshness.fail_hours', '48'); + + const result = await checkCycleFreshness(engine, { nowMs: NOW, env: {} as NodeJS.ProcessEnv }); + + expect(result.status).toBe('warn'); + expect(result.message).toMatch(/missed-one-night/); + expect(result.message).not.toMatch(/nightly-fresh/); + }); + + test('env thresholds override DB-configured thresholds', async () => { + await engine.executeRaw(`UPDATE sources SET local_path = NULL WHERE id = 'default'`); + await seed('env-overrides-db', agoH(36)); + await engine.setConfig('doctor.cycle_freshness.warn_hours', '30'); + await engine.setConfig('doctor.cycle_freshness.fail_hours', '48'); + + const result = await checkCycleFreshness(engine, { + nowMs: NOW, + env: { + GBRAIN_CYCLE_FRESHNESS_WARN_HOURS: '6', + GBRAIN_CYCLE_FRESHNESS_FAIL_HOURS: '24', + } as NodeJS.ProcessEnv, + }); + + expect(result.status).toBe('fail'); + expect(result.message).toMatch(/env-overrides-db/); + }); + + test('DB source allowlist limits the check to intentionally dreamed sources', async () => { + await engine.executeRaw(`UPDATE sources SET local_path = NULL WHERE id = 'default'`); + await seed('ops-brain', agoH(1)); + await seed('reference-brain', undefined); + await engine.setConfig('doctor.cycle_freshness.source_allowlist', 'ops-brain'); + + const result = await checkCycleFreshness(engine, { nowMs: NOW, env: {} as NodeJS.ProcessEnv }); + + expect(result.status).toBe('ok'); + expect(result.message).toMatch(/1\/2 source\(s\) checked/); + expect(result.details).toMatchObject({ + checked_source_count: 1, + skipped_source_count: 1, + source_allowlist: ['ops-brain'], + }); + }); + + test('env allowlist overrides DB allowlist', async () => { + await engine.executeRaw(`UPDATE sources SET local_path = NULL WHERE id = 'default'`); + await seed('env-scoped', agoH(1)); + await seed('never-cycled', undefined); + await engine.setConfig('doctor.cycle_freshness.source_allowlist', 'never-cycled'); + + const result = await checkCycleFreshness(engine, { + nowMs: NOW, + env: { GBRAIN_CYCLE_FRESHNESS_SOURCE_ALLOWLIST: 'env-scoped' } as NodeJS.ProcessEnv, + }); + + expect(result.status).toBe('ok'); + expect(result.details).toMatchObject({ + checked_source_count: 1, + skipped_source_count: 1, + source_allowlist: ['env-scoped'], + }); + }); + + test('source ignorelist can suppress intentionally excluded stale sources', async () => { + await engine.executeRaw(`UPDATE sources SET local_path = NULL WHERE id = 'default'`); + await seed('fresh-operating', agoH(1)); + await seed('excluded-reference', undefined); + await engine.setConfig('doctor.cycle_freshness.source_ignorelist', 'excluded-reference'); + + const result = await checkCycleFreshness(engine, { nowMs: NOW, env: {} as NodeJS.ProcessEnv }); + + expect(result.status).toBe('ok'); + expect(result.details).toMatchObject({ + checked_source_count: 1, + skipped_source_count: 1, + source_ignorelist: ['excluded-reference'], + }); + }); + + test('invalid source scope returns warn instead of silently hiding sources', async () => { + await engine.executeRaw(`UPDATE sources SET local_path = NULL WHERE id = 'default'`); + await seed('fresh', agoH(1)); + + const result = await checkCycleFreshness(engine, { + nowMs: NOW, + env: { GBRAIN_CYCLE_FRESHNESS_SOURCE_ALLOWLIST: 'fresh,bad/source' } as NodeJS.ProcessEnv, + }); + + expect(result.status).toBe('warn'); + expect(result.message).toMatch(/Invalid cycle_freshness source scope/); + expect(result.message).toMatch(/bad\/source/); + }); }); diff --git a/test/onboard-pack-upgrade-checks.test.ts b/test/onboard-pack-upgrade-checks.test.ts index ff842ac61..4309067b1 100644 --- a/test/onboard-pack-upgrade-checks.test.ts +++ b/test/onboard-pack-upgrade-checks.test.ts @@ -14,17 +14,33 @@ import { } from '../src/core/onboard/checks.ts'; import { toOnboardRecommendation } from '../src/core/onboard/render.ts'; import { _resetPackCacheForTests } from '../src/core/schema-pack/registry.ts'; -import { _resetPackLocatorForTests } from '../src/core/schema-pack/load-active.ts'; +import { + __setPackLocatorForTests, + _resetPackLocatorForTests, +} from '../src/core/schema-pack/load-active.ts'; +import { withEnv } from './helpers/with-env.ts'; +import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; let engine: PGLiteEngine; +let priorGbrainHome: string | undefined; beforeAll(async () => { + // #2469: the onboard checks now read the file-plane config via + // loadConfig(). Point GBRAIN_HOME at an empty temp dir so the host + // machine's real ~/.gbrain/config.json (schema_pack, etc.) can't leak + // into the default-behavior tests below. + priorGbrainHome = process.env.GBRAIN_HOME; + process.env.GBRAIN_HOME = mkdtempSync(join(tmpdir(), 'gbrain-onboard-home-')); engine = new PGLiteEngine(); await engine.connect({}); await engine.initSchema(); }); afterAll(async () => { + if (priorGbrainHome === undefined) delete process.env.GBRAIN_HOME; + else process.env.GBRAIN_HOME = priorGbrainHome; await engine.disconnect(); }); @@ -98,6 +114,68 @@ describe('checkTypeProliferation (D16 pack-aware ratio)', () => { expect(result.check.status).toBe('warn'); expect(result.check.message).toMatch(new RegExp(`${seedCount} distinct`)); }); + + it('resolves the pack from file-plane config.json schema_pack (tier 6, #2469)', async () => { + // A pack selected via `gbrain schema use` lives ONLY in + // ~/.gbrain/config.json. With cfg:null the check silently fell back to + // the default pack (declared≈15) and never flagged proliferation against + // the user's actual (smaller) pack. + const home = mkdtempSync(join(tmpdir(), 'gbrain-onboard-')); + const packDir = join(home, 'packs'); + mkdirSync(join(home, '.gbrain'), { recursive: true }); + mkdirSync(packDir, { recursive: true }); + const packPath = join(packDir, 'pack.yaml'); + writeFileSync(packPath, [ + 'api_version: gbrain-schema-pack-v1', + 'name: tiny-file-plane', + 'version: 1.0.0', + 'description: ""', + 'gbrain_min_version: 0.38.0', + 'extends: null', + 'borrow_from: []', + 'page_types:', + ' - name: note', + ' primitive: entity', + ' path_prefixes:', + ' - notes/', + ' aliases: []', + ' extractable: false', + ' expert_routing: false', + ' - name: meeting', + ' primitive: entity', + ' path_prefixes:', + ' - meetings/', + ' aliases: []', + ' extractable: false', + ' expert_routing: false', + 'link_types: []', + 'frontmatter_links: []', + 'takes_kinds:', + ' - fact', + ' - take', + ' - bet', + ' - hunch', + 'enrichable_types: []', + 'filing_rules: []', + '', + ].join('\n'), 'utf-8'); + writeFileSync( + join(home, '.gbrain', 'config.json'), + JSON.stringify({ schema_pack: 'tiny-file-plane' }) + '\n', + 'utf-8', + ); + __setPackLocatorForTests((name) => (name === 'tiny-file-plane' ? packPath : null)); + + // 8 distinct types: fail against tiny-file-plane (declared 2 → fail >4), + // ok against the declared=15 fallback the old cfg:null path produced. + await seedPages(['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'].map((s) => `custom-${s}`)); + + await withEnv({ GBRAIN_HOME: home, GBRAIN_SCHEMA_PACK: undefined }, async () => { + const result = await checkTypeProliferation(engine); + expect(result.check.status).toBe('fail'); + expect(result.check.message).toContain('pack declares 2'); + }); + }); }); describe('checkDanglingAliases (F12 source-scoped JOIN)', () => { diff --git a/test/think-env-knobs.test.ts b/test/think-env-knobs.test.ts new file mode 100644 index 000000000..8ebe378d2 --- /dev/null +++ b/test/think-env-knobs.test.ts @@ -0,0 +1,71 @@ +/** + * #2479 — think pipeline fixes: + * 1. The synthesis prompt's Conflicts rule covers page-vs-page (and + * page-vs-take) disagreements, not just take-vs-take — pages-only brains + * surfaced zero conflicts before. + * 2. Retrieval breadth / output-cap env knobs (GBRAIN_THINK_GATHER_LIMIT, + * GBRAIN_THINK_EXCERPT_LEN, GBRAIN_THINK_MAX_OUTPUT_TOKENS) tune + * conflict/variant recall per-install without changing defaults. + */ +import { describe, test, expect } from 'bun:test'; +import { THINK_SYSTEM_PROMPT_BASE } from '../src/core/think/prompt.ts'; +import { renderPagesBlock, envInt } from '../src/core/think/gather.ts'; +import { maxOutputTokensFor } from '../src/core/think/index.ts'; +import { withEnv } from './helpers/with-env.ts'; + +describe('think prompt — conflicts across pages AND takes (#2479)', () => { + test('Conflicts rule covers pages and/or takes, with severity tags', () => { + expect(THINK_SYSTEM_PROMPT_BASE).toContain('pages and/or takes'); + expect(THINK_SYSTEM_PROMPT_BASE).toContain('[HIGH]'); + expect(THINK_SYSTEM_PROMPT_BASE).toContain('VARIANTS'); + }); + + test('example slugs are generic placeholders (privacy rule)', () => { + expect(THINK_SYSTEM_PROMPT_BASE).toContain('finance/acme-pricing'); + expect(THINK_SYSTEM_PROMPT_BASE).not.toMatch(/velox|warm_dm/i); + }); +}); + +describe('think env knobs (#2479)', () => { + test('envInt: valid positive int wins, garbage/absent falls back', async () => { + await withEnv({ GBRAIN_TEST_KNOB: '7' }, () => { + expect(envInt('GBRAIN_TEST_KNOB', 40)).toBe(7); + }); + await withEnv({ GBRAIN_TEST_KNOB: 'banana' }, () => { + expect(envInt('GBRAIN_TEST_KNOB', 40)).toBe(40); + }); + await withEnv({ GBRAIN_TEST_KNOB: '-3' }, () => { + expect(envInt('GBRAIN_TEST_KNOB', 40)).toBe(40); + }); + expect(envInt('GBRAIN_TEST_KNOB_UNSET', 40)).toBe(40); + }); + + test('renderPagesBlock excerpt length honors GBRAIN_THINK_EXCERPT_LEN', async () => { + const pages = [ + { slug: 'notes/long', compiled_truth: 'x'.repeat(1000) }, + ] as unknown as Parameters[0]; + + // Default stays 600. + const dflt = renderPagesBlock(pages); + expect(dflt).toContain('x'.repeat(600)); + expect(dflt).not.toContain('x'.repeat(601)); + + await withEnv({ GBRAIN_THINK_EXCERPT_LEN: '50' }, () => { + const out = renderPagesBlock(pages); + expect(out).toContain('x'.repeat(50)); + expect(out).not.toContain('x'.repeat(51)); + }); + }); + + test('maxOutputTokensFor honors GBRAIN_THINK_MAX_OUTPUT_TOKENS', async () => { + await withEnv({ GBRAIN_THINK_MAX_OUTPUT_TOKENS: '9000' }, () => { + expect(maxOutputTokensFor('openai:gpt-4o')).toBe(9000); + expect(maxOutputTokensFor('anthropic:claude-sonnet-5')).toBe(9000); + }); + // Defaults unchanged without the env var. + await withEnv({ GBRAIN_THINK_MAX_OUTPUT_TOKENS: undefined }, () => { + expect(maxOutputTokensFor('openai:gpt-4o')).toBe(4000); + expect(maxOutputTokensFor('anthropic:claude-sonnet-5')).toBe(16000); + }); + }); +});