mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-29 16:39:15 +00:00
fix(doctor,think): file-plane pack resolution, page-vs-page conflicts, cycle-freshness tuning
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 <onchito-walks@users.noreply.github.com> Co-authored-by: Adityaranjan-VeloxAI <Adityaranjan-VeloxAI@users.noreply.github.com> Co-authored-by: sene1337 <sene1337@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
onchito-walks
Adityaranjan-VeloxAI
sene1337
Claude Fable 5
parent
0612b0daa8
commit
80606d5df0
+174
-8
@@ -3833,6 +3833,133 @@ export async function checkPoolBudget(_engine: BrainEngine): Promise<Check> {
|
||||
}
|
||||
}
|
||||
|
||||
type CycleFreshnessSourceScope = {
|
||||
allowlist?: Set<string>;
|
||||
ignorelist: Set<string>;
|
||||
invalid: string[];
|
||||
configured: boolean;
|
||||
};
|
||||
|
||||
const CYCLE_FRESHNESS_SOURCE_ID_RE = /^[A-Za-z0-9_-]+$/;
|
||||
|
||||
type DoctorSourceRow = Awaited<ReturnType<BrainEngine['listAllSources']>>[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<string>();
|
||||
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<string | null> {
|
||||
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<number> {
|
||||
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<CycleFreshnessSourceScope> {
|
||||
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<string, unknown> | 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<Check> {
|
||||
*
|
||||
* 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<Check> {
|
||||
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 <id>\` for each stale source, or start \`gbrain autopilot\`.`,
|
||||
message: `${issues.join('; ')}. Run \`gbrain dream --source <id>\` 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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<ThinkGatherResult> {
|
||||
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 `<page slug="..." score="...">excerpt</page>`;
|
||||
* 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(
|
||||
|
||||
+10
-4
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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<typeof renderPagesBlock>[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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user