Compare commits

..
Author SHA1 Message Date
f68eb9d905 fix(takes): kill propose_takes rescan loop, keep every claim, and route cycle/brainstorm models through config (#2804 #2805)
Takeover of #2804 and #2805 (stacked), rebased onto current master.

Rescan loop + dropped claims (#2804):
- Zero-claim scans now write a status='empty' sentinel row so the
  (source_id, page_slug, content_hash, prompt_version) cache hits on
  every subsequent cycle instead of re-spending the extractor LLM call
  on the same unchanged page forever.
- The idempotency index gains md5(claim_text): multi-claim pages keep
  every claim (the 4-column unique index silently dropped claims 2..N
  via ON CONFLICT DO NOTHING). proposals_inserted now counts rows that
  actually landed (RETURNING id), not insert attempts.
- Migration renumbered 123 -> 125 (master shipped v123
  configurable_fts_language and v124 after the PR was cut; the
  duplicate v123 would never have run under the version > current
  runner). Schema parity across schema.sql, pglite-schema.ts,
  schema-embedded.ts.

Model-tier honoring (#2805), reworked for current master:
- propose_takes, grade_takes, calibration_profile resolve their model
  through resolveModel(models.<phase> > models.default > env >
  getChatModel()) — per-phase config keys are now honored while the
  gateway chat model (already tier-resolved by
  reconfigureGatewayWithEngine) stays the default, preserving master's
  #2451 semantics (provider-prefixed stored ids, nested prefixes like
  openrouter:... intact) and the #2997 opts.model ?? getChatModel()
  idiom. The PR's original bare-tail-everywhere approach was dropped
  where it conflicted with #2451; grade_takes keeps the bare tail ONLY
  for its cache key / stored judge_model_id (continuity with historical
  rows), while the judge call itself gets the full resolved string —
  fixing the live label-vs-actual mismatch (call rode chat_model,
  telemetry recorded a hardcoded claude-sonnet-4-6).
- brainstorm resolves via models.brainstorm > tier chain (was
  hardcoded anthropic:claude-sonnet-4-6).
- gbrain models lists the four new per-task keys; output caps and
  context-window entries added for newer Claude models.

Tests: test/propose-takes-rescan.test.ts (new, hermetic PGLite);
config-key coverage in propose-takes/grade-takes tests; existing #2451
and gateway-chat-model tests pass unchanged.

Co-authored-by: p3ob7o <p3ob7o@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 14:33:19 -07:00
26 changed files with 419 additions and 502 deletions
-43
View File
@@ -466,11 +466,6 @@ async function main() {
const result = JSON.parse(JSON.stringify(rawResult, bigintToStringReplacer));
const output = formatResult(op.name, result);
if (output) process.stdout.write(output);
// #1484 — invisible-miss hint: a bare query/search that hit zero results
// on a multi-source brain tells the user (stderr) which source it
// actually searched and how to widen the scope.
const hint = await sourceScopeHint(op.name, params, ctx.sourceId, engine, result);
if (hint) console.error(hint);
} catch (e: unknown) {
// v0.42.20.0 (codex D4): on error, set exitCode + return so the `finally`
// STILL runs (drains every background-work sink + disconnects). A bare
@@ -842,44 +837,6 @@ async function makeContext(engine: BrainEngine, params: Record<string, unknown>)
};
}
/**
* #1484 — a bare `gbrain query`/`search` silently scopes to the resolved
* source (usually 'default'); on a multi-source brain a zero-hit run looks
* identical to "the brain doesn't know this" even when the answer lives in
* another source. Returns a stderr hint when (a) the op is query/search,
* (b) it returned zero results, (c) the caller did NOT scope explicitly
* (--source / --source-id / --all-sources), and (d) the brain has >1
* registered source. Best-effort: any lookup failure returns null.
*
* Exported for tests (same import-safety contract as formatResult).
*/
export async function sourceScopeHint(
opName: string,
params: Record<string, unknown>,
sourceId: string,
engine: BrainEngine,
result: unknown,
): Promise<string | null> {
if (opName !== 'query' && opName !== 'search') return null;
if (!Array.isArray(result) || result.length > 0) return null;
// Explicit scoping (flag tier) = user intent; don't second-guess it.
if (params.source || params.source_id || params.all_sources) return null;
if (sourceId === '__all__') return null;
try {
const rows = await engine.executeRaw<{ n: number }>(
`SELECT count(*)::int AS n FROM sources`,
);
const n = Number(rows[0]?.n ?? 0);
if (n <= 1) return null;
return (
`Hint: this brain has ${n} sources; you searched only "${sourceId}". ` +
`Retry with --source-id __all__ (all sources) or --source-id <id>.`
);
} catch {
return null; // hint is best-effort; never fail the query over it
}
}
// Exported for tests (same import-safety contract as cliAliases/printOpHelp).
export function formatResult(opName: string, result: unknown): string {
switch (opName) {
+4
View File
@@ -50,6 +50,10 @@ const PER_TASK_KEYS: Array<{ key: string; tier: ModelTier; description: string }
{ key: 'models.eval.contradictions_judge', tier: 'utility', description: 'Contradiction probe judge (v0.34 temporal-aware)' },
{ key: 'models.expansion', tier: 'utility', description: 'Query expansion for hybrid search' },
{ key: 'models.chat', tier: 'reasoning', description: 'Default `gateway.chat()` model' },
{ key: 'models.propose_takes', tier: 'reasoning', description: 'propose_takes claim extractor' },
{ key: 'models.grade_takes', tier: 'reasoning', description: 'grade_takes verdict judge' },
{ key: 'models.calibration_profile', tier: 'reasoning', description: 'Calibration profile generator' },
{ key: 'models.brainstorm', tier: 'reasoning', description: '`gbrain brainstorm` orchestrator' },
];
interface ModelEntry {
+20 -49
View File
@@ -1000,24 +1000,9 @@ const voyageCompatFetch = (async (input: RequestInfo | URL, init?: RequestInit)
// Voyage diverges from OpenAI in two places that break the parser:
// - `embedding` is a base64 string (SDK schema expects `number[]`)
// - `usage` lacks `prompt_tokens` (SDK schema requires it when usage present)
//
// #1610: read the body ONCE via text() and JSON.parse it. The pre-fix
// `await resp.clone().json()` truncated large bodies on bun < 1.1.27
// (oven-sh/bun#6348) — the parse threw, the catch fell back to the raw
// response, and multi-chunk pages died with "Invalid JSON response".
// Every JSON return path below rebuilds the Response so a stale
// Content-Length/Content-Encoding header from the original can't lie
// about the rewritten body.
const bodyText = await resp.text();
const rebuild = (body: string) => {
const headers = new Headers(resp.headers);
headers.delete('content-length');
headers.delete('content-encoding');
return new Response(body, { status: resp.status, statusText: resp.statusText, headers });
};
try {
const json: any = JSON.parse(bodyText);
if (!json || typeof json !== 'object') return rebuild(bodyText);
const json: any = await resp.clone().json();
if (!json || typeof json !== 'object') return resp;
let modified = false;
if (Array.isArray(json.data)) {
for (const item of json.data) {
@@ -1052,19 +1037,22 @@ const voyageCompatFetch = (async (input: RequestInfo | URL, init?: RequestInit)
: 0;
modified = true;
}
if (!modified) return rebuild(bodyText);
return rebuild(JSON.stringify(json));
if (!modified) return resp;
return new Response(JSON.stringify(json), {
status: resp.status,
statusText: resp.statusText,
headers: resp.headers,
});
} catch (err) {
// OOM-cap throws MUST propagate. The catch is here for "Voyage returned
// JSON I can't reshape" (parse error, unexpected schema) — falling back
// to the original body is correct in that case. Letting the
// to the original response is correct in that case. Letting the
// too-large response through here would defeat the entire purpose of
// Layer 2 (the per-embedding cap that fires when Content-Length wasn't
// available to Layer 1).
if (err instanceof VoyageResponseTooLargeError) throw err;
// If parsing/transformation fails, pass the original body through
// (rebuilt — resp's body stream is already consumed by text()).
return rebuild(bodyText);
// If parsing/transformation fails, fall back to the original response.
return resp;
}
}) as unknown as typeof fetch;
@@ -1204,21 +1192,9 @@ const zeroEntropyCompatFetch = (async (input: RequestInfo | URL, init?: RequestI
// validates. Also map usage.total_tokens → prompt_tokens (SDK requires
// prompt_tokens when `usage` is present — same divergence Voyage hit at
// gateway.ts:655).
//
// #1610: read the body ONCE via text() + JSON.parse — `resp.clone().json()`
// truncated large bodies on bun < 1.1.27 (oven-sh/bun#6348), so the parse
// threw and the catch fell back to the RAW ZE `{results: ...}` shape, which
// the AI SDK schema rejects → "Invalid JSON response" on multi-chunk pages.
const bodyText = await resp.text();
const rebuild = (body: string) => {
const headers = new Headers(resp.headers);
headers.delete('content-length');
headers.delete('content-encoding');
return new Response(body, { status: resp.status, statusText: resp.statusText, headers });
};
try {
const json: any = JSON.parse(bodyText);
if (!json || typeof json !== 'object') return rebuild(bodyText);
const json: any = await resp.clone().json();
if (!json || typeof json !== 'object') return resp;
let modified = false;
if (Array.isArray(json.results) && !Array.isArray(json.data)) {
// Layer 2 OOM cap — per-embedding size. ZE returns float[] arrays,
@@ -1252,25 +1228,20 @@ const zeroEntropyCompatFetch = (async (input: RequestInfo | URL, init?: RequestI
// SDK also expects total_tokens; ZE provides it directly.
modified = true;
}
if (!modified) return rebuild(bodyText);
return rebuild(JSON.stringify(json));
if (!modified) return resp;
return new Response(JSON.stringify(json), {
status: resp.status,
statusText: resp.statusText,
headers: resp.headers,
});
} catch (err) {
// OOM-cap throws MUST propagate. Voyage's pattern: instanceof check on
// its own tagged class. Same here — only rethrow our own cap class.
if (err instanceof ZeroEntropyResponseTooLargeError) throw err;
return rebuild(bodyText);
return resp;
}
}) as unknown as typeof fetch;
/**
* Test-only seams (#1610): the compat shims are module-private closures;
* exporting them lets tests drive the response-rewrite paths behaviorally
* (truncating clone(), stale Content-Length) without a live provider.
* Same pattern as __getShrinkStateForTests.
*/
export const __voyageCompatFetchForTests = voyageCompatFetch;
export const __zeroEntropyCompatFetchForTests = zeroEntropyCompatFetch;
/**
* Generic asymmetric-embedding shim for openai-compatible recipes that
* ship no compat fetch of their own (llama-server, litellm, ollama, ...).
+3
View File
@@ -61,7 +61,10 @@ export const MAX_OUTPUT_TOKENS_CEIL = 32_000;
* (with a readable error) instead of the provider's opaque HTTP 400.
*/
export const ANTHROPIC_OUTPUT_CAPS: Record<string, number> = {
'claude-fable-5': 64_000,
'claude-opus-4-8': 32_000,
'claude-opus-4-7': 32_000,
'claude-sonnet-5': 64_000,
'claude-sonnet-4-6': 64_000,
'claude-haiku-4-5': 64_000,
'claude-haiku-4-5-20251001': 64_000,
+9 -1
View File
@@ -32,6 +32,7 @@
*/
import type { BrainEngine } from '../engine.ts';
import { resolveModel } from '../model-config.ts';
import { chat as defaultChat, embedQuery, type ChatResult, type ChatOpts } from '../ai/gateway.ts';
import { hybridSearch, hybridSearchCached } from '../search/hybrid.ts';
import { fetchFar, type CloseRef, type FarPage } from './domain-bank.ts';
@@ -538,7 +539,14 @@ async function _runBrainstormInner(
const embedFn = opts.embedQueryFn ?? embedQuery;
// ---- Phase 0: cost preview + TTY grace ----
const modelStr = opts.modelOverride ?? 'anthropic:claude-sonnet-4-6';
// Tier-resolved (mirrors the cycle phases): honors models.brainstorm >
// models.default > models.tier.reasoning; the fallback keeps stock
// behavior identical (reasoning tier default IS claude-sonnet-4-6).
const modelStr = opts.modelOverride ?? await resolveModel(engine, {
configKey: 'models.brainstorm',
tier: 'reasoning',
fallback: 'anthropic:claude-sonnet-4-6',
});
const { aborted, estimate } = await previewCostAndWait({
profile,
model: modelStr,
+13 -3
View File
@@ -26,8 +26,8 @@
*/
import { BaseCyclePhase, type ScopedReadOpts, type BasePhaseOpts } from './base-phase.ts';
import { chat as gatewayChat } from '../ai/gateway.ts';
import { TIER_DEFAULTS } from '../model-config.ts';
import { chat as gatewayChat, getChatModel } from '../ai/gateway.ts';
import { resolveModel } from '../model-config.ts';
import { gateVoice, type VoiceGateGenerator, type VoiceGateJudge } from '../calibration/voice-gate.ts';
import { patternStatementTemplate, type PatternStatementSlots } from '../calibration/templates.ts';
// v0.41 T10 — domain widening. The aggregator module resolves the active
@@ -229,7 +229,16 @@ class CalibrationProfilePhase extends BaseCyclePhase {
): Promise<{ summary: string; details: Record<string, unknown>; status?: PhaseStatus }> {
const holder = opts.holder ?? 'garry';
const promptVersion = opts.promptVersion ?? CALIBRATION_PROFILE_PROMPT_VERSION;
const modelId = opts.model ?? TIER_DEFAULTS.reasoning;
// Resolved once (see propose-takes.ts for the chain): models.calibration_profile
// > models.default > env > the gateway's chat model (itself resolved
// through models.chat + the reasoning tier). Provider-prefixed per #2451
// — a bare id would make gateway.chat() throw "missing a provider
// prefix". Drives the generator's chat call, the budget label, and the
// persisted model_id, so the three can never disagree.
const modelId = opts.model ?? await resolveModel(engine, {
configKey: 'models.calibration_profile',
fallback: getChatModel(),
});
const gradeCompletion = opts.gradeCompletion ?? 1.0;
const patternsGenerator = opts.patternsGenerator ?? defaultPatternsGenerator;
const biasTagsGenerator = opts.biasTagsGenerator ?? defaultBiasTagsGenerator;
@@ -265,6 +274,7 @@ class CalibrationProfilePhase extends BaseCyclePhase {
scorecard,
holder,
attempt,
modelHint: modelId,
...(feedback !== undefined ? { feedback } : {}),
});
return lines.join('\n');
+22 -3
View File
@@ -36,7 +36,9 @@
import { createHash } from 'node:crypto';
import { BaseCyclePhase, type ScopedReadOpts, type BasePhaseOpts } from './base-phase.ts';
import { chat as gatewayChat } from '../ai/gateway.ts';
import { chat as gatewayChat, getChatModel } from '../ai/gateway.ts';
import { resolveModel } from '../model-config.ts';
import { splitProviderModelId } from '../model-id.ts';
import { GBrainError } from '../types.ts';
import type { OperationContext } from '../operations.ts';
import type { BrainEngine, Take, TakeResolution } from '../engine.ts';
@@ -395,7 +397,24 @@ class GradeTakesPhase extends BaseCyclePhase {
const autoResolve = opts.autoResolve ?? false; // D17 default OFF
const autoResolveThreshold = opts.autoResolveThreshold ?? 0.95; // D12 conservative
const resolvedByLabel = opts.resolvedByLabel ?? 'gbrain:grade_takes';
const judgeModelId = opts.model ?? 'claude-sonnet-4-6';
// Resolve the judge model ONCE (see propose-takes.ts for the chain —
// same label-vs-actual split fixed here: the judge call rode the
// gateway's chat_model while the grade cache key, evidence signature,
// and budget label recorded a hardcoded 4.6).
// NOTE: changing the resolved judge model invalidates the grade cache
// (judge_model_id is part of its key) — a one-time, budget-capped
// re-grade wave that is CORRECT, since the actual judge did change.
const judgeModelFull = opts.model ?? await resolveModel(engine, {
configKey: 'models.grade_takes',
fallback: getChatModel(),
});
// Bare tail for cache keys / evidence signatures / stored ids — the
// grade cache has always been keyed on bare ids; the gateway default
// resolves provider-prefixed, and normalizing preserves cache continuity
// on stock installs (no spurious re-judge wave from a prefix change). A
// genuinely different configured judge still invalidates, which is
// correct. The FULL string drives the actual judge call.
const judgeModelId = splitProviderModelId(judgeModelFull).model || judgeModelFull;
const useEnsemble = opts.useEnsemble ?? false;
const ensembleThreshold = opts.ensembleThreshold ?? 0.85;
@@ -468,7 +487,7 @@ class GradeTakesPhase extends BaseCyclePhase {
// Call the single-model judge. Errors on a single take log warning + continue.
let verdict: JudgeVerdict;
try {
verdict = await judge({ take, evidence, modelHint: opts.model });
verdict = await judge({ take, evidence, modelHint: judgeModelFull });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
result.warnings.push(`judge failed on take ${take.id}: ${msg}`);
+58 -16
View File
@@ -5,11 +5,16 @@
* a tuned LLM extractor, writes the extracted gradeable claims to the
* `take_proposals` queue. User accepts/rejects via `gbrain takes propose`.
*
* Idempotency contract (D17 schema spec):
* The unique index on (source_id, page_slug, content_hash, prompt_version)
* means an unchanged page never re-spends LLM tokens. Bumping
* PROPOSE_TAKES_PROMPT_VERSION cleanly invalidates the cache so a tuned
* prompt re-runs proposals on every page.
* Idempotency contract (D17 schema spec; per-claim rows since migration v125):
* Every scan of a (source_id, page_slug, content_hash, prompt_version)
* tuple leaves at least one row one per extracted claim, or a single
* status='empty' sentinel when extraction yields nothing so an unchanged
* page never re-spends LLM tokens. Pre-v125 only proposal rows were
* written: a zero-claim page never entered the cache and was re-extracted
* on EVERY cycle (observed live: ~60 such pages × every cycle 1,400
* wasted extractor calls / ~$15 per day ~90% of total autopilot spend).
* Bumping PROPOSE_TAKES_PROMPT_VERSION cleanly invalidates the cache so a
* tuned prompt re-runs proposals on every page.
*
* F2 fence dedup:
* The phase reads the page's existing `<!-- gbrain:takes:begin -->` fence
@@ -40,6 +45,7 @@
import { randomUUID, createHash } from 'node:crypto';
import { BaseCyclePhase, type ScopedReadOpts, type BasePhaseOpts } from './base-phase.ts';
import { chat as gatewayChat, getChatModel } from '../ai/gateway.ts';
import { resolveModel } from '../model-config.ts';
import { writeReceipt } from '../extract/receipt-writer.ts';
import { upsertExtractRollup } from '../extract/rollup-writer.ts';
import { GBrainError } from '../types.ts';
@@ -307,6 +313,18 @@ class ProposeTakesPhase extends BaseCyclePhase {
const promptVersion = opts.promptVersion ?? PROPOSE_TAKES_PROMPT_VERSION;
const pageLimit = opts.pageLimit ?? 100;
const skipPagesWithFence = opts.skipPagesWithFence ?? false;
// Resolve the extractor model ONCE: models.propose_takes >
// models.default > GBRAIN_MODEL env > the gateway's chat model (which
// reconfigureGatewayWithEngine already resolved through models.chat +
// the reasoning tier). One resolved provider-prefixed string drives the
// actual chat call, the budget estimate, AND the stored model_id — so
// the recorded model can never disagree with the model that ran (#2451
// convention: stored ids are provider-prefixed, nested prefixes like
// openrouter:anthropic/... stay intact).
const extractorModelId = opts.model ?? await resolveModel(engine, {
configKey: 'models.propose_takes',
fallback: getChatModel(),
});
const proposalRunId = `propose-${new Date().toISOString().slice(0, 19).replace(/[-:T]/g, '')}-${randomUUID().slice(0, 8)}`;
const result: ProposeTakesResult = {
@@ -330,8 +348,6 @@ class ProposeTakesPhase extends BaseCyclePhase {
opts.reporter.start('propose_takes.pages' as never, pages.length);
}
const modelId = opts.model ?? getChatModel();
for (const page of pages) {
result.pages_scanned += 1;
this.tick(opts);
@@ -361,7 +377,7 @@ class ProposeTakesPhase extends BaseCyclePhase {
// Budget pre-check before the LLM call. Estimate: ~1500 input tokens + 500 output.
const budget = this.checkBudget({
modelId,
modelId: extractorModelId,
estimatedInputTokens: 1500,
maxOutputTokens: 500,
});
@@ -380,7 +396,7 @@ class ProposeTakesPhase extends BaseCyclePhase {
pagePath: page.slug,
pageBody: body,
existingTakes,
modelHint: opts.model,
modelHint: extractorModelId,
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
@@ -388,16 +404,42 @@ class ProposeTakesPhase extends BaseCyclePhase {
continue;
}
// Write proposals to take_proposals. Each row is a separate INSERT
// because the composite idempotency key is on the per-page tuple — a
// bulk UPSERT would collapse a same-page-multi-claim run into one row.
for (const p of proposals) {
// Zero-claim scans MUST still enter the idempotency cache. Pre-v125
// only proposal rows were written, so a page whose extraction yielded
// no gradeable claims never got a row for its (page, content_hash) —
// the cache check above missed on every subsequent cycle and the LLM
// call was re-spent on the same unchanged page, forever. The sentinel
// row (status='empty', empty claim_text) is invisible to the review
// queue (pending_idx is partial on status='pending'); it exists only
// so the cache check hits.
if (proposals.length === 0) {
await engine.executeRaw(
`INSERT INTO take_proposals
(source_id, page_slug, content_hash, prompt_version, proposal_run_id,
status, claim_text, kind, holder, weight, domain, dedup_against_fence_rows, model_id)
VALUES ($1, $2, $3, $4, $5, 'empty', '', 'none', 'brain', 0, NULL, NULL, $6)
ON CONFLICT (source_id, page_slug, content_hash, prompt_version, md5(claim_text)) DO NOTHING`,
[sourceId, page.slug, ch, promptVersion, proposalRunId, extractorModelId],
);
continue;
}
// Write proposals to take_proposals, one row per claim. The v125
// idempotency index includes md5(claim_text), so a same-page
// multi-claim run keeps EVERY claim — the pre-v125 four-column unique
// index made claims 2..N conflict with claim 1 and ON CONFLICT DO
// NOTHING silently dropped them (the review queue only ever saw the
// first claim of each page version). RETURNING id keeps
// proposals_inserted honest: it counts rows that actually landed,
// not insert attempts.
for (const p of proposals) {
const landed = await engine.executeRaw<{ id: number }>(
`INSERT INTO take_proposals
(source_id, page_slug, content_hash, prompt_version, proposal_run_id,
claim_text, kind, holder, weight, domain, dedup_against_fence_rows, model_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
ON CONFLICT (source_id, page_slug, content_hash, prompt_version) DO NOTHING`,
ON CONFLICT (source_id, page_slug, content_hash, prompt_version, md5(claim_text)) DO NOTHING
RETURNING id`,
[
sourceId,
page.slug,
@@ -410,10 +452,10 @@ class ProposeTakesPhase extends BaseCyclePhase {
p.weight,
p.domain ?? null,
JSON.stringify(existingTakes),
modelId,
extractorModelId,
],
);
result.proposals_inserted += 1;
if (landed.length > 0) result.proposals_inserted += 1;
}
}
+3
View File
@@ -58,8 +58,11 @@ const SUMMARY_SLUG_RE = /^[a-z0-9][a-z0-9\-]*(\/[a-z0-9][a-z0-9\-]*)*$/;
* resolver returns for known Anthropic aliases.
*/
const MODEL_CONTEXT_TOKENS: Record<string, number> = {
'claude-fable-5': 1_000_000,
'claude-opus-4-8': 1_000_000,
'claude-opus-4-7': 1_000_000,
'claude-opus-4-6': 1_000_000,
'claude-sonnet-5': 1_000_000,
'claude-sonnet-4-6': 200_000,
'claude-sonnet-4-5': 200_000,
'claude-haiku-4-5-20251001': 200_000,
+35
View File
@@ -5671,6 +5671,41 @@ export const MIGRATIONS: Migration[] = [
`);
},
},
{
version: 125,
name: 'take_proposals_empty_scan_sentinels_and_per_claim_rows',
// v0.42.x — kill the propose_takes rescan loop + stop dropping claims.
//
// Two defects, one schema touch:
// 1. Zero-claim scans never entered the idempotency cache (only
// proposal rows were written), so pages whose extraction yielded
// nothing were re-extracted on EVERY cycle. Observed live: ~60
// such pages per run ≈ 1,400 wasted extractor calls / ~$15 per
// day — ~90% of total autopilot LLM spend. Fix: the phase now
// writes a status='empty' sentinel row per zero-claim scan; the
// status CHECK gains the 'empty' value. Sentinels are excluded
// from the partial pending index, so the review queue never sees
// them.
// 2. The 4-column unique index collapsed a same-page multi-claim run
// to its FIRST claim: rows 2..N conflicted and ON CONFLICT DO
// NOTHING silently dropped them (verified live: exactly one row
// per (page, hash) across 3 days of runs). Fix: the idempotency
// index gains md5(claim_text) — per-claim rows, while the
// 4-column prefix still serves the per-scan cache lookup.
//
// Existing data is index-safe by construction: the old index guaranteed
// at most one row per 4-tuple, so the widened index has no duplicates
// to trip on. The DROP+ADD CONSTRAINT pair is idempotent as a unit.
idempotent: true,
sql: `
ALTER TABLE take_proposals DROP CONSTRAINT IF EXISTS take_proposals_status_check;
ALTER TABLE take_proposals ADD CONSTRAINT take_proposals_status_check
CHECK (status IN ('pending','accepted','rejected','superseded','empty'));
DROP INDEX IF EXISTS take_proposals_idempotency_idx;
CREATE UNIQUE INDEX IF NOT EXISTS take_proposals_idempotency_idx
ON take_proposals (source_id, page_slug, content_hash, prompt_version, md5(claim_text));
`,
},
];
export const LATEST_VERSION = MIGRATIONS.length > 0
+2 -13
View File
@@ -499,7 +499,7 @@ export function linkReadScopeOpts(ctx: OperationContext): { sourceId?: string; s
* FAIL-CLOSED: anything not strictly `ctx.remote === false` is untrusted.
*
* This is the SINGLE resolver for every read op that accepts a per-call
* `source_id` / `all_sources` parameter (query, search, code_callers, code_callees,
* `source_id` / `all_sources` parameter (query, code_callers, code_callees,
* get_page, search_by_image, code_blast, code_flow). Inlining the `__all__`
* branch per handler is the bug class that leaked cross-source reads (#1924,
* #1371): a remote client could pass `source_id: '__all__'` to opt out of its
@@ -1442,24 +1442,13 @@ const search: Operation = {
limit: { type: 'number', description: 'Max results (default 20)' },
offset: { type: 'number', description: 'Skip first N results (for pagination)' },
mode: { type: 'string', description: 'Search mode (conservative|balanced|tokenmax). Local callers only.' },
source_id: {
type: 'string',
description:
"Scope search to a single source. Defaults to OperationContext.sourceId. Pass '__all__' to span every source for trusted local callers; for remote callers '__all__' spans only your granted sources.",
},
all_sources: { type: 'boolean', description: "Span sources (equivalent to source_id=__all__): every source locally, your grant remotely." },
},
handler: async (ctx, p) => {
const startedAt = Date.now();
const queryText = p.query as string;
const limit = (p.limit as number) || 20;
const offset = (p.offset as number) || 0;
// #1484 follow-up: route through the canonical fail-closed resolver so
// `--source-id __all__` / `all_sources` behave the same as on `query`
// (the zero-hit CLI hint advises exactly that retry). Without a per-call
// param, `search` silently ignored --source-id — the retry looked like
// a genuine miss.
const scope = resolveRequestedScope(ctx, p.source_id as string | undefined, p.all_sources === true);
const scope = sourceScopeOpts(ctx);
// T4/D5 — per-call mode honored ONLY for trusted/local callers so a remote
// OAuth client can't escalate to the costly tokenmax bundle. Local + unknown
+2 -2
View File
@@ -762,7 +762,7 @@ CREATE TABLE IF NOT EXISTS take_proposals (
proposed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
proposal_run_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','accepted','rejected','superseded')),
CHECK (status IN ('pending','accepted','rejected','superseded','empty')),
claim_text TEXT NOT NULL,
kind TEXT NOT NULL,
holder TEXT NOT NULL,
@@ -777,7 +777,7 @@ CREATE TABLE IF NOT EXISTS take_proposals (
predicted_brier_bucket_n INTEGER
);
CREATE UNIQUE INDEX IF NOT EXISTS take_proposals_idempotency_idx
ON take_proposals (source_id, page_slug, content_hash, prompt_version);
ON take_proposals (source_id, page_slug, content_hash, prompt_version, md5(claim_text));
CREATE INDEX IF NOT EXISTS take_proposals_pending_idx
ON take_proposals (source_id, status, proposed_at DESC)
WHERE status = 'pending';
+10 -4
View File
@@ -1274,8 +1274,14 @@ CREATE INDEX IF NOT EXISTS calibration_profiles_published_idx
WHERE published = true;
-- take_proposals: propose_takes phase queue. Idempotency cache via the
-- composite unique index (source_id, page_slug, content_hash, prompt_version)
-- mirrors v0.23 dream_verdicts. proposal_run_id supports --rollback by run.
-- composite unique index (source_id, page_slug, content_hash, prompt_version,
-- md5(claim_text)) the 4-column prefix is the per-scan cache key (mirrors
-- v0.23 dream_verdicts); md5(claim_text) makes rows per-claim so multi-claim
-- pages keep every claim (v125). status='empty' rows are zero-claim scan
-- sentinels: they hold the cache slot for a page version whose extraction
-- yielded nothing the phase never re-spends the LLM call on that page
-- version. Excluded from the partial pending index. proposal_run_id supports
-- --rollback by run.
CREATE TABLE IF NOT EXISTS take_proposals (
id BIGSERIAL PRIMARY KEY,
source_id TEXT NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
@@ -1286,7 +1292,7 @@ CREATE TABLE IF NOT EXISTS take_proposals (
proposed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
proposal_run_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','accepted','rejected','superseded')),
CHECK (status IN ('pending','accepted','rejected','superseded','empty')),
claim_text TEXT NOT NULL,
kind TEXT NOT NULL,
holder TEXT NOT NULL,
@@ -1301,7 +1307,7 @@ CREATE TABLE IF NOT EXISTS take_proposals (
predicted_brier_bucket_n INTEGER
);
CREATE UNIQUE INDEX IF NOT EXISTS take_proposals_idempotency_idx
ON take_proposals (source_id, page_slug, content_hash, prompt_version);
ON take_proposals (source_id, page_slug, content_hash, prompt_version, md5(claim_text));
CREATE INDEX IF NOT EXISTS take_proposals_pending_idx
ON take_proposals (source_id, status, proposed_at DESC)
WHERE status = 'pending';
+2 -12
View File
@@ -1323,18 +1323,8 @@ export async function hybridSearch(
if (effectiveModality === 'both' && imageVectorList !== null) {
vectorLists = [...vectorLists, imageVectorList];
}
} catch (err) {
// Embedding/vector failure is non-fatal fall back to keyword-only
// but say WHY (#1626): this arm only runs when the embedding provider
// probed available, so a throw here is a real failure (embed timeout,
// transient pooler error on the searchVector fan-out). Pre-fix the bare
// catch made a cross-source `--source __all__` run silently collapse to
// keyword-only/"No results" with zero diagnostics.
warnOncePerProcess(
'hybrid-vector-arm-failed',
`[gbrain] vector arm failed (fail-open, keyword-only fallback): ` +
`${err instanceof Error ? err.message : String(err)}`,
);
} catch {
// Embedding failure is non-fatal, fall back to keyword-only
}
}
+10 -4
View File
@@ -1270,8 +1270,14 @@ CREATE INDEX IF NOT EXISTS calibration_profiles_published_idx
WHERE published = true;
-- take_proposals: propose_takes phase queue. Idempotency cache via the
-- composite unique index (source_id, page_slug, content_hash, prompt_version)
-- mirrors v0.23 dream_verdicts. proposal_run_id supports --rollback by run.
-- composite unique index (source_id, page_slug, content_hash, prompt_version,
-- md5(claim_text)) — the 4-column prefix is the per-scan cache key (mirrors
-- v0.23 dream_verdicts); md5(claim_text) makes rows per-claim so multi-claim
-- pages keep every claim (v125). status='empty' rows are zero-claim scan
-- sentinels: they hold the cache slot for a page version whose extraction
-- yielded nothing — the phase never re-spends the LLM call on that page
-- version. Excluded from the partial pending index. proposal_run_id supports
-- --rollback by run.
CREATE TABLE IF NOT EXISTS take_proposals (
id BIGSERIAL PRIMARY KEY,
source_id TEXT NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
@@ -1282,7 +1288,7 @@ CREATE TABLE IF NOT EXISTS take_proposals (
proposed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
proposal_run_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','accepted','rejected','superseded')),
CHECK (status IN ('pending','accepted','rejected','superseded','empty')),
claim_text TEXT NOT NULL,
kind TEXT NOT NULL,
holder TEXT NOT NULL,
@@ -1297,7 +1303,7 @@ CREATE TABLE IF NOT EXISTS take_proposals (
predicted_brier_bucket_n INTEGER
);
CREATE UNIQUE INDEX IF NOT EXISTS take_proposals_idempotency_idx
ON take_proposals (source_id, page_slug, content_hash, prompt_version);
ON take_proposals (source_id, page_slug, content_hash, prompt_version, md5(claim_text));
CREATE INDEX IF NOT EXISTS take_proposals_pending_idx
ON take_proposals (source_id, status, proposed_at DESC)
WHERE status = 'pending';
@@ -1,115 +0,0 @@
/**
* #1610 Voyage/ZeroEntropy compat shims must read the response body ONCE
* via text() instead of `resp.clone().json()`.
*
* On bun < 1.1.27, Response.clone() truncates large bodies (oven-sh/bun#6348):
* the clone().json() parse threw, the shim's catch fell back to the ORIGINAL
* response whose wire shape (ZE `{results: ...}`, Voyage base64 embeddings)
* the AI SDK's openai-compatible Zod schema rejects and multi-chunk pages
* failed with "Invalid JSON response".
*
* These tests simulate the truncating clone() and assert the shims still
* return the fully rewritten body. They also pin that the rewritten Response
* does NOT carry the original (now stale) Content-Length header, which lied
* about the rewritten body's size (gateway.ts previously copied
* `headers: resp.headers` verbatim).
*/
import { afterEach, describe, expect, test } from 'bun:test';
import {
__voyageCompatFetchForTests,
__zeroEntropyCompatFetchForTests,
} from '../../src/core/ai/gateway.ts';
const origFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = origFetch;
});
/** Build a Response whose clone() truncates the body (bun < 1.1.27 behavior). */
function truncatingCloneResponse(body: string): Response {
const headers = {
'content-type': 'application/json',
// Deliberately stale after any rewrite: the original wire body's length.
'content-length': String(Buffer.byteLength(body)),
};
const resp = new Response(body, { status: 200, headers });
(resp as any).clone = () =>
new Response(body.slice(0, 32), { status: 200, headers });
return resp;
}
describe('voyageCompatFetch — single body read (#1610)', () => {
test('rewrites base64 embeddings even when clone() truncates the body', async () => {
const floats = new Float32Array([0.5, 0.25, -1]);
const b64 = Buffer.from(floats.buffer).toString('base64');
const wireBody = JSON.stringify({
object: 'list',
data: [{ object: 'embedding', embedding: b64, index: 0 }],
model: 'voyage-3',
usage: { total_tokens: 7 },
});
globalThis.fetch = (async () => truncatingCloneResponse(wireBody)) as unknown as typeof fetch;
const out = await __voyageCompatFetchForTests('https://api.voyageai.com/v1/embeddings', {
method: 'POST',
body: JSON.stringify({ input: ['hello'], model: 'voyage-3' }),
headers: { 'content-type': 'application/json' },
});
const json: any = await out.json();
expect(Array.from(json.data[0].embedding)).toEqual([0.5, 0.25, -1]);
expect(json.usage.prompt_tokens).toBe(7);
// Stale Content-Length from the wire body must not survive the rewrite.
expect(out.headers.get('content-length')).toBeNull();
expect(out.headers.get('content-encoding')).toBeNull();
});
});
describe('zeroEntropyCompatFetch — single body read (#1610)', () => {
test('rewrites {results} → {data} even when clone() truncates the body', async () => {
const wireBody = JSON.stringify({
results: [{ embedding: [0.1, 0.2] }, { embedding: [0.3, 0.4] }],
usage: { total_bytes: 42, total_tokens: 9 },
});
let fetchedUrl = '';
globalThis.fetch = (async (url: string | URL | Request) => {
fetchedUrl = String(url);
return truncatingCloneResponse(wireBody);
}) as unknown as typeof fetch;
const out = await __zeroEntropyCompatFetchForTests('https://api.zeroentropy.dev/v1/embeddings', {
method: 'POST',
body: JSON.stringify({ input: ['hello'], model: 'zembed-1' }),
headers: { 'content-type': 'application/json' },
});
expect(fetchedUrl.endsWith('/v1/models/embed')).toBe(true);
const json: any = await out.json();
// The AI SDK schema requires {data: [{embedding, index}]} — the raw ZE
// {results} fallback is exactly the pre-fix "Invalid JSON response".
expect(json.results).toBeUndefined();
expect(json.data).toHaveLength(2);
expect(json.data[0]).toEqual({ object: 'embedding', embedding: [0.1, 0.2], index: 0 });
expect(json.data[1].index).toBe(1);
expect(json.usage.prompt_tokens).toBe(9);
expect(out.headers.get('content-length')).toBeNull();
});
test('non-JSON body falls back to the original bytes (rebuilt, still readable)', async () => {
const wireBody = 'plain text, not json';
globalThis.fetch = (async () =>
new Response(wireBody, {
status: 200,
headers: { 'content-type': 'application/json' },
})) as unknown as typeof fetch;
const out = await __zeroEntropyCompatFetchForTests('https://api.zeroentropy.dev/v1/embeddings', {
method: 'POST',
body: JSON.stringify({ input: ['hello'] }),
});
// Body was consumed by the shim's single read; the fallback must
// rebuild a readable Response rather than return the drained original.
expect(await out.text()).toBe(wireBody);
});
});
+4 -6
View File
@@ -98,18 +98,16 @@ describe('zeroEntropyCompatFetch — OOM caps', () => {
expect(src).toMatch(/MAX_ZEROENTROPY_RESPONSE_BYTES\s*=\s*256\s*\*\s*1024\s*\*\s*1024/);
});
test('Layer 1: Content-Length pre-check before the body is read', async () => {
test('Layer 1: Content-Length pre-check before resp.clone().json()', async () => {
const src = await Bun.file(GATEWAY_PATH).text();
// Find the zeroEntropyCompatFetch block bounds, then assert ordering
// within it (mirroring the voyage cap test pattern). #1610 moved the
// body read from `resp.clone().json()` to a single `resp.text()` (bun
// < 1.1.27 truncates clone()d bodies, oven-sh/bun#6348).
// within it (mirroring the voyage cap test pattern).
const zeFetchStart = src.indexOf('const zeroEntropyCompatFetch');
expect(zeFetchStart).toBeGreaterThan(0);
const block = src.slice(zeFetchStart, zeFetchStart + 9000);
const block = src.slice(zeFetchStart, zeFetchStart + 8000);
const preCheckIdx = block.indexOf("resp.headers.get('content-length')");
const jsonParseIdx = block.indexOf('const bodyText = await resp.text()');
const jsonParseIdx = block.indexOf('await resp.clone().json()');
expect(preCheckIdx).toBeGreaterThan(0);
expect(jsonParseIdx).toBeGreaterThan(0);
// The pre-check MUST appear before the JSON parse — Voyage's lesson
+1
View File
@@ -38,6 +38,7 @@ function buildMockEngine(opts: { scorecard: TakesScorecard }): {
} {
const captured: CapturedSql[] = [];
const engine = {
async getConfig() { return null; },
kind: 'pglite',
async getScorecard() {
return opts.scorecard;
-61
View File
@@ -1,61 +0,0 @@
/**
* #1484 invisible-miss hint. A bare `gbrain query` resolves to a single
* source (usually 'default'); on a multi-source brain a zero-hit run gave no
* signal that the answer might live in another source. sourceScopeHint
* returns the stderr hint exactly when: query/search op + zero results +
* no explicit scoping param + >1 registered source.
*/
import { describe, expect, test } from 'bun:test';
import { sourceScopeHint } from '../src/cli.ts';
import type { BrainEngine } from '../src/core/engine.ts';
function fakeEngine(sourceCount: number, fail = false): BrainEngine {
return {
executeRaw: async () => {
if (fail) throw new Error('sources table missing');
return [{ n: sourceCount }];
},
} as unknown as BrainEngine;
}
describe('sourceScopeHint (#1484)', () => {
test('fires on a bare zero-hit query against a multi-source brain', async () => {
const hint = await sourceScopeHint('query', {}, 'default', fakeEngine(3), []);
expect(hint).toContain('3 sources');
expect(hint).toContain('"default"');
expect(hint).toContain('--source-id __all__');
});
test('fires for search too', async () => {
const hint = await sourceScopeHint('search', {}, 'wiki', fakeEngine(2), []);
expect(hint).toContain('"wiki"');
});
test('silent when results were found', async () => {
expect(await sourceScopeHint('query', {}, 'default', fakeEngine(3), [{ slug: 'a' }])).toBeNull();
});
test('silent when the caller scoped explicitly', async () => {
expect(await sourceScopeHint('query', { source_id: 'wiki' }, 'wiki', fakeEngine(3), [])).toBeNull();
expect(await sourceScopeHint('query', { source: 'wiki' }, 'wiki', fakeEngine(3), [])).toBeNull();
expect(await sourceScopeHint('query', { all_sources: true }, '__all__', fakeEngine(3), [])).toBeNull();
});
test('silent when the resolved scope is already __all__', async () => {
expect(await sourceScopeHint('query', {}, '__all__', fakeEngine(3), [])).toBeNull();
});
test('silent on a single-source brain', async () => {
expect(await sourceScopeHint('query', {}, 'default', fakeEngine(1), [])).toBeNull();
});
test('silent for non-search ops and non-array results', async () => {
expect(await sourceScopeHint('get_stats', {}, 'default', fakeEngine(3), [])).toBeNull();
expect(await sourceScopeHint('query', {}, 'default', fakeEngine(3), { rows: [] })).toBeNull();
});
test('best-effort: sources lookup failure returns null, never throws', async () => {
expect(await sourceScopeHint('query', {}, 'default', fakeEngine(3, true), [])).toBeNull();
});
});
+1
View File
@@ -47,6 +47,7 @@ function buildMockEngine(opts: { takes: Take[] }): {
const captured: CapturedSql[] = [];
const resolves: CapturedResolve[] = [];
const engine = {
async getConfig() { return null; },
kind: 'pglite',
async listTakes() {
return opts.takes;
+24
View File
@@ -46,12 +46,14 @@ interface CapturedResolve {
function buildMockEngine(opts: {
takes: Take[];
cachedGrades?: Set<string>; // composite-key strings already in take_grade_cache
config?: Record<string, string>; // engine.getConfig plane (models.grade_takes etc.)
}): { engine: BrainEngine; captured: CapturedSql[]; resolves: CapturedResolve[] } {
const captured: CapturedSql[] = [];
const resolves: CapturedResolve[] = [];
const cached = opts.cachedGrades ?? new Set<string>();
const engine = {
async getConfig(key: string) { return opts.config?.[key] ?? null; },
kind: 'pglite',
async listTakes() {
return opts.takes;
@@ -224,6 +226,28 @@ describe('runPhaseGradeTakes — phase integration', () => {
expect(resolves).toHaveLength(0); // no canonical mutation
});
test('models.grade_takes config drives the judge call; cache key stays bare-tailed', async () => {
// Pre-fix the judge call rode the gateway's chat_model while the cache
// key / budget label recorded a hardcoded 'claude-sonnet-4-6'. The phase
// now resolves models.grade_takes; the judge gets the FULL string and
// the cache row keys on the bare tail (continuity with historical rows).
const takes = [buildTake({ id: 1, sinceDate: '2023-01-01' })];
const { engine, captured } = buildMockEngine({
takes,
config: { 'models.grade_takes': 'anthropic:claude-sonnet-5' },
});
const hints: Array<string | undefined> = [];
const judge: JudgeFn = async ({ modelHint }) => {
hints.push(modelHint);
return { verdict: 'correct', confidence: 0.9, reasoning: 'held' };
};
const result = await runPhaseGradeTakes(buildCtx(engine), { judge });
expect(result.status).toBe('ok');
expect(hints).toEqual(['anthropic:claude-sonnet-5']); // actual call gets the FULL string
const inserts = captured.filter(c => c.sql.includes('INSERT INTO take_grade_cache'));
expect(inserts[0]!.params[2]).toBe('claude-sonnet-5'); // judge_model_id is the bare tail
});
test('D17: auto-resolve OFF by default — even high-confidence verdict does NOT mutate takes', async () => {
const takes = [buildTake({ id: 1, sinceDate: '2023-01-01' })];
const { engine, resolves } = buildMockEngine({ takes });
@@ -1,77 +0,0 @@
/**
* #1626 hybridSearch's text-vector arm must not fail DARK.
*
* The arm only runs when the embedding provider probed available, so a throw
* inside it (embed timeout, transient pooler error on searchVector) is a real
* failure. Pre-fix, a bare `catch {}` swallowed it and the run silently
* collapsed to keyword-only under `--source __all__` on a strained pooler
* that read as a non-deterministic "No results". The fix logs the swallowed
* reason via warnOncePerProcess while keeping the keyword fallback.
*/
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { hybridSearch } from '../src/core/search/hybrid.ts';
import {
__setEmbedTransportForTests,
configureGateway,
resetGateway,
} from '../src/core/ai/gateway.ts';
import { _resetWarnOnceForTests } from '../src/core/utils.ts';
let engine: PGLiteEngine;
const origWarn = console.warn;
beforeAll(async () => {
// Pin the gateway to OpenAI with a stub key (put-page-provenance pattern):
// embed() runs instantiateEmbedding — which requires OPENAI_API_KEY — BEFORE
// the stubbed transport is reached. Without this, a keyless CI environment
// throws the config error instead of the transport's, and the assertion on
// the swallowed reason fails. The key never leaves the process.
configureGateway({
embedding_model: 'openai:text-embedding-3-large',
embedding_dimensions: 1536,
env: { ...process.env, OPENAI_API_KEY: process.env.OPENAI_API_KEY || 'sk-test-stub' },
});
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
await engine.putPage('people/alice-example', {
type: 'person',
title: 'Alice Example',
compiled_truth: 'Alice Example is a test person for the vector-arm warn test.',
});
});
afterAll(async () => {
console.warn = origWarn;
__setEmbedTransportForTests(null);
resetGateway();
await engine.disconnect();
});
describe('hybridSearch vector-arm failure telemetry (#1626)', () => {
test('embed failure logs the swallowed reason and falls back to keyword', async () => {
_resetWarnOnceForTests();
// Installing a transport makes isAvailable('embedding') true (test-seam
// fast path), so the vector arm RUNS — and then throws.
__setEmbedTransportForTests(() => {
throw new Error('pooler exploded mid-fanout');
});
const warnings: string[] = [];
console.warn = (...args: unknown[]) => {
warnings.push(args.map(String).join(' '));
};
try {
const results = await hybridSearch(engine, 'alice');
// Keyword fallback still returns results — fail-open preserved.
expect(results.some((r) => r.slug === 'people/alice-example')).toBe(true);
} finally {
console.warn = origWarn;
__setEmbedTransportForTests(null);
}
const armWarnings = warnings.filter((w) => w.includes('vector arm failed'));
expect(armWarnings).toHaveLength(1);
expect(armWarnings[0]).toContain('pooler exploded mid-fanout');
});
});
+160
View File
@@ -0,0 +1,160 @@
/**
* propose_takes rescan-loop + dropped-claims regression tests (migration v125).
*
* Two live-observed defects, both fixed by the v125 schema + phase change:
*
* 1. RESCAN LOOP a page whose extraction yielded zero claims never
* entered the idempotency cache (only proposal rows were written), so
* every cycle re-spent the extractor call on the same unchanged page.
* Live impact: ~60 such pages × every cycle 1,400 wasted LLM calls
* (~$15) per day ~90% of total autopilot spend. Fix: status='empty'
* sentinel row per zero-claim scan.
*
* 2. DROPPED CLAIMS the 4-column unique index collapsed a same-page
* multi-claim run to its first claim (rows 2..N conflicted, ON CONFLICT
* DO NOTHING dropped them silently; verified live: exactly 1 row per
* (page, hash) over 3 days). Fix: idempotency index gains
* md5(claim_text).
*
* Hermetic: PGLite engine + injected extractor; no gateway, no LLM.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { runPhaseProposeTakes, type ProposeTakesExtractor, type ProposedTake } from '../src/core/cycle/propose-takes.ts';
import type { OperationContext } from '../src/core/operations.ts';
let engine: PGLiteEngine;
function ctx(): OperationContext {
return {
engine,
remote: false,
config: {} as OperationContext['config'],
logger: { info() {}, warn() {}, error() {}, debug() {} } as unknown as OperationContext['logger'],
} as unknown as OperationContext;
}
/** Extractor stub that counts invocations per page slug. */
function countingExtractor(
claimsBySlug: Record<string, ProposedTake[]>,
): { extractor: ProposeTakesExtractor; calls: string[] } {
const calls: string[] = [];
const extractor: ProposeTakesExtractor = async ({ pagePath }) => {
calls.push(pagePath);
return claimsBySlug[pagePath] ?? [];
};
return { extractor, calls };
}
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
await engine.putPage('notes/zero-claims', {
type: 'note',
title: 'pure narrative',
compiled_truth: 'A quiet walk in the park. Nothing opinionated happened at all today.',
});
await engine.putPage('notes/three-claims', {
type: 'note',
title: 'opinionated',
compiled_truth: 'I bet acme-example wins the market. widget-co will struggle. fund-a is overexposed.',
});
});
afterAll(async () => {
await engine.disconnect();
});
describe('rescan loop — zero-claim scans enter the cache', () => {
test('second run cache-hits: extractor is NOT called again on unchanged pages', async () => {
const claims = {
'notes/three-claims': [
{ claim_text: 'acme-example wins the market', kind: 'bet' as const, holder: 'brain', weight: 0.7 },
{ claim_text: 'widget-co will struggle', kind: 'take' as const, holder: 'brain', weight: 0.6 },
{ claim_text: 'fund-a is overexposed', kind: 'take' as const, holder: 'brain', weight: 0.55 },
],
};
const first = countingExtractor(claims);
const r1 = await runPhaseProposeTakes(ctx(), { extractor: first.extractor });
expect(r1.status).toBe('ok');
// Both pages extracted on the first pass.
expect(first.calls).toContain('notes/zero-claims');
expect(first.calls).toContain('notes/three-claims');
const second = countingExtractor(claims);
const r2 = await runPhaseProposeTakes(ctx(), { extractor: second.extractor });
expect(r2.status).toBe('ok');
// THE regression: pre-fix the zero-claim page missed the cache every
// run and was re-extracted here. (Run 1's receipt page legitimately
// appears once — it's a new page — and its zero-claim scan now caches
// too; pre-fix, receipts re-scanned forever as well.)
expect(second.calls).not.toContain('notes/zero-claims');
expect(second.calls).not.toContain('notes/three-claims');
// Run 2 inserted nothing, so no new receipt page exists: run 3 must be
// fully quiescent — zero extractor calls, zero cache misses.
const third = countingExtractor(claims);
const r3 = await runPhaseProposeTakes(ctx(), { extractor: third.extractor });
expect(r3.status).toBe('ok');
expect(third.calls).toEqual([]);
expect((r3.details as Record<string, unknown>).cache_misses).toBe(0);
});
test('zero-claim scan wrote an "empty" sentinel invisible to the pending queue', async () => {
const sentinel = await engine.executeRaw<{ status: string; claim_text: string }>(
`SELECT status, claim_text FROM take_proposals WHERE page_slug = 'notes/zero-claims'`,
[],
);
expect(sentinel.length).toBe(1);
expect(sentinel[0].status).toBe('empty');
expect(sentinel[0].claim_text).toBe('');
const pending = await engine.executeRaw<{ n: number }>(
`SELECT COUNT(*)::int AS n FROM take_proposals WHERE page_slug = 'notes/zero-claims' AND status = 'pending'`,
[],
);
expect(Number(pending[0].n)).toBe(0);
});
});
describe('dropped claims — per-claim rows survive the idempotency index', () => {
test('a 3-claim page stores 3 rows and reports an honest inserted count', async () => {
const rows = await engine.executeRaw<{ claim_text: string }>(
`SELECT claim_text FROM take_proposals WHERE page_slug = 'notes/three-claims' AND status = 'pending' ORDER BY id`,
[],
);
// Pre-fix the 4-column unique index kept only the FIRST claim.
expect(rows.length).toBe(3);
expect(rows.map(r => r.claim_text)).toEqual([
'acme-example wins the market',
'widget-co will struggle',
'fund-a is overexposed',
]);
});
test('content change re-extracts and stores the new version separately', async () => {
await engine.putPage('notes/zero-claims', {
type: 'note',
title: 'pure narrative',
compiled_truth: 'Updated: I now believe acme-example is undervalued and will re-rate within a year.',
});
const claims = {
'notes/zero-claims': [
{ claim_text: 'acme-example is undervalued', kind: 'take' as const, holder: 'brain', weight: 0.6 },
],
};
const run = countingExtractor(claims);
const r = await runPhaseProposeTakes(ctx(), { extractor: run.extractor });
expect(r.status).toBe('ok');
// Changed page re-extracts; the unchanged 3-claim page stays cached.
expect(run.calls).toEqual(['notes/zero-claims']);
expect((r.details as Record<string, unknown>).proposals_inserted).toBe(1);
const all = await engine.executeRaw<{ status: string }>(
`SELECT status FROM take_proposals WHERE page_slug = 'notes/zero-claims' ORDER BY id`,
[],
);
// Old hash's sentinel + new hash's pending claim coexist.
expect(all.map(r2 => r2.status).sort()).toEqual(['empty', 'pending']);
});
});
+33 -1
View File
@@ -41,12 +41,16 @@ interface CapturedSql {
function buildMockEngine(opts: {
pages: Page[];
existingProposals?: Set<string>; // composite-key strings already in take_proposals
config?: Record<string, string>; // engine.getConfig plane (models.tier.* etc.)
}): { engine: BrainEngine; captured: CapturedSql[] } {
const captured: CapturedSql[] = [];
const existing = opts.existingProposals ?? new Set<string>();
const engine = {
kind: 'pglite',
async getConfig(key: string) {
return opts.config?.[key] ?? null;
},
async listPages() {
return opts.pages;
},
@@ -59,7 +63,12 @@ function buildMockEngine(opts: {
if (existing.has(key)) return [{ id: 1 } as unknown as T];
return [];
}
// INSERT — return nothing
// INSERT ... RETURNING id — emulate a successful insert so the
// honest proposals_inserted counter (counts RETURNING rows, not
// attempts) sees the row land. Conflicted inserts would return [].
if (sql.includes('INSERT INTO take_proposals') && sql.includes('RETURNING id')) {
return [{ id: 1 } as unknown as T];
}
return [];
},
} as unknown as BrainEngine;
@@ -267,6 +276,29 @@ describe('runPhaseProposeTakes — phase integration', () => {
expect(inserts[0]!.params[9]).toBe('market'); // domain
});
test('extractor model resolves through models.propose_takes config', async () => {
// Pre-fix the phase's only model knob was the gateway chat model — there
// was no per-phase config key. The phase now resolves once via
// resolveModel(models.propose_takes > models.default > env > gateway
// chat model); the extractor hint and the stored model_id must both
// reflect the configured override (full provider-prefixed string, #2451).
const pages = [buildPage({ slug: 'wiki/concepts/tier-routing', body: 'Tier-routed models will win.' })];
const { engine, captured } = buildMockEngine({
pages,
config: { 'models.propose_takes': 'anthropic:claude-sonnet-5' },
});
const seen: Array<string | undefined> = [];
const extractor: ProposeTakesExtractor = async ({ modelHint }) => {
seen.push(modelHint);
return [{ claim_text: 'tier-routed models win', kind: 'bet', holder: 'brain', weight: 0.7 }];
};
const result = await runPhaseProposeTakes(buildCtx(engine), { extractor });
expect(result.status).toBe('ok');
expect(seen).toEqual(['anthropic:claude-sonnet-5']); // chat call gets the FULL string
const inserts = captured.filter(c => c.sql.includes('INSERT INTO take_proposals'));
expect(inserts[0]!.params[11]).toBe('anthropic:claude-sonnet-5'); // stored model_id matches the call
});
test('cache hit: page already in take_proposals is skipped', async () => {
const body = 'A page that was already processed.';
const pages = [buildPage({ slug: 'wiki/old-page', body })];
-87
View File
@@ -1,87 +0,0 @@
/**
* #1484 follow-up the `search` op must honor per-call `source_id` /
* `all_sources` through the canonical fail-closed resolver
* (resolveRequestedScope), exactly like `query` does.
*
* Pre-fix, `search` had no source_id param at all: the zero-hit CLI hint
* advised "retry with --source-id __all__", the flag parsed into params,
* NOTHING consumed it, and the retry silently re-ran the same single-source
* search an invisible false negative (and the retry's params.source_id
* suppressed the hint, so the user got no second warning).
*/
import { describe, expect, test } from 'bun:test';
import { operationsByName } from '../src/core/operations.ts';
import type { OperationContext } from '../src/core/operations.ts';
import type { BrainEngine } from '../src/core/engine.ts';
const searchOp = operationsByName['search'];
/** Fake engine: keyword-only config so the handler's scope goes straight to
* searchKeyword, where we capture the opts it was called with. */
function makeCtx(remote: boolean, allowedSources?: string[]) {
const captured: { opts?: Record<string, unknown> } = {};
const engine = {
getConfig: async (key: string) => (key === 'search.mcp_keyword_only' ? 'true' : null),
searchKeyword: async (_q: string, opts: Record<string, unknown>) => {
captured.opts = opts;
return [];
},
} as unknown as BrainEngine;
const ctx = {
engine,
config: { engine: 'pglite' },
logger: { info: () => {}, warn: () => {}, error: () => {} },
dryRun: false,
remote,
sourceId: 'default',
...(allowedSources ? { auth: { allowedSources } } : {}),
} as unknown as OperationContext;
return { ctx, captured };
}
describe('search op per-call source scope (#1484 follow-up)', () => {
test('op declares source_id + all_sources params (the CLI hint advises them)', () => {
expect(searchOp.params.source_id).toBeDefined();
expect(searchOp.params.all_sources).toBeDefined();
});
test('default: scopes to ctx.sourceId', async () => {
const { ctx, captured } = makeCtx(false);
await searchOp.handler(ctx, { query: 'x' });
expect(captured.opts?.sourceId).toBe('default');
});
test("local + source_id '__all__' spans the whole brain (no source filter)", async () => {
const { ctx, captured } = makeCtx(false);
await searchOp.handler(ctx, { query: 'x', source_id: '__all__' });
expect(captured.opts?.sourceId).toBeUndefined();
expect(captured.opts?.sourceIds).toBeUndefined();
});
test('local + all_sources=true spans the whole brain', async () => {
const { ctx, captured } = makeCtx(false);
await searchOp.handler(ctx, { query: 'x', all_sources: true });
expect(captured.opts?.sourceId).toBeUndefined();
expect(captured.opts?.sourceIds).toBeUndefined();
});
test('explicit source_id wins over ctx.sourceId', async () => {
const { ctx, captured } = makeCtx(false);
await searchOp.handler(ctx, { query: 'x', source_id: 'wiki' });
expect(captured.opts?.sourceId).toBe('wiki');
});
test("remote + '__all__' collapses to the caller's grant (fail-closed)", async () => {
const { ctx, captured } = makeCtx(true, ['wiki', 'essays']);
await searchOp.handler(ctx, { query: 'x', source_id: '__all__' });
expect(captured.opts?.sourceIds).toEqual(['wiki', 'essays']);
});
test('remote + out-of-grant source_id is denied', async () => {
const { ctx } = makeCtx(true, ['wiki']);
await expect(searchOp.handler(ctx, { query: 'x', source_id: 'secrets' })).rejects.toThrow(
/outside your granted sources/,
);
});
});
+3 -5
View File
@@ -34,7 +34,7 @@ describe('v0.31.8 — voyage Content-Length pre-check + per-item cap', () => {
expect(source).toMatch(/MAX_VOYAGE_RESPONSE_BYTES\s*=\s*256\s*\*\s*1024\s*\*\s*1024/);
});
test('Layer 1: Content-Length pre-check fires BEFORE the body is read (D10 OOM defense)', async () => {
test('Layer 1: Content-Length pre-check fires BEFORE resp.clone().json() (D10 OOM defense)', async () => {
const source = await Bun.file(new URL('../src/core/ai/gateway.ts', import.meta.url)).text();
// Anchor relative to the post-fetch handler block. The function declaration
// contains an OUTBOUND request body section earlier; we want to verify
@@ -47,10 +47,8 @@ describe('v0.31.8 — voyage Content-Length pre-check + per-item cap', () => {
// doesn't pin to comment text.
const preCheckIdx = inboundBlock.indexOf("resp.headers.get('content-length')");
// Use the full lvalue assignment so the match doesn't accidentally hit
// comment text that mentions the body read for context. (#1610 moved the
// read from `resp.clone().json()` to a single `resp.text()` — bun <
// 1.1.27 truncates clone()d bodies, oven-sh/bun#6348.)
const jsonParseIdx = inboundBlock.indexOf('const bodyText = await resp.text()');
// comment text that mentions `await resp.clone().json()` for context.
const jsonParseIdx = inboundBlock.indexOf('const json: any = await resp.clone().json()');
expect(preCheckIdx).toBeGreaterThan(0);
expect(jsonParseIdx).toBeGreaterThan(0);
// The pre-check MUST appear before the JSON parse — otherwise the OOM