mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-15 01:12:20 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
921048827a | ||
|
|
0dff84b16a |
@@ -686,6 +686,7 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
|
||||
try {
|
||||
const { MinionQueue } = await import('../core/minions/queue.ts');
|
||||
const { computeRecommendations, embeddingProviderConfigured, HOSTED_EMBED_KEY_CONFIG } = await import('../core/brain-score-recommendations.ts');
|
||||
const { countExtractionLag } = await import('../core/remediation/context.ts');
|
||||
const queue = new MinionQueue(engine);
|
||||
const slotMs = Math.floor(Date.now() / (baseInterval * 1000)) * baseInterval * 1000;
|
||||
const slot = new Date(slotMs).toISOString();
|
||||
@@ -877,6 +878,9 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
|
||||
return !!(process.env[envVar] || (cfgField ? embedKeyCfg[cfgField] : undefined));
|
||||
}),
|
||||
hasChatApiKey: !!(process.env.ANTHROPIC_API_KEY || await engine.getConfig('anthropic_api_key')),
|
||||
// Real extraction-lag gate for sync.repo/extract.all — same counter
|
||||
// loadRecommendationContext uses (replaces the health.stale_pages proxy).
|
||||
extractionLagPages: await countExtractionLag(engine),
|
||||
};
|
||||
// v0.41.18.0 (A5 + A19 + A22, T15): consult onboard recommendations
|
||||
// ALONGSIDE doctor's brain-score recommendations. Onboard's 4 new
|
||||
|
||||
@@ -146,6 +146,16 @@ export interface RecommendationContext {
|
||||
chatModel?: string;
|
||||
/** Whether the chat provider has a usable API key. */
|
||||
hasChatApiKey?: boolean;
|
||||
/**
|
||||
* Count of pages needing link/timeline extraction — the SAME staleness the
|
||||
* `gbrain extract --stale` walk and doctor's `links_extraction_lag` check use
|
||||
* (`engine.countStalePagesForExtraction`). Gates the sync→extract pipeline
|
||||
* (sync.repo / extract.all). Replaces the old `health.stale_pages` gate, which
|
||||
* counted "pages whose updated_at predates their newest timeline entry" — a
|
||||
* proxy that broke when the updated_at-on-timeline-insert trigger was dropped
|
||||
* (migration v10) and never reflected real extraction work.
|
||||
*/
|
||||
extractionLagPages?: number;
|
||||
}
|
||||
|
||||
/** Triage result for one check. */
|
||||
@@ -192,20 +202,28 @@ export function computeRecommendations(
|
||||
const source = ctx.sourceId ?? 'default';
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// sync.repo — fires when sync hasn't run recently OR pages are stale
|
||||
// sync.repo + extract.all — the materialization pipeline, gated on the REAL
|
||||
// extraction lag (pages whose link/timeline edges are stale), NOT on the
|
||||
// legacy `health.stale_pages` proxy. `extractionLagPages` comes from the same
|
||||
// counter the `extract --stale` walk + doctor's `links_extraction_lag` use, so
|
||||
// the recommendation can only fire when running extract will actually reduce
|
||||
// it (and clear the rec). See RecommendationContext.extractionLagPages.
|
||||
// sync.repo is the prerequisite: re-sync so pages are current before extract
|
||||
// materializes their edges.
|
||||
// ---------------------------------------------------------------------
|
||||
if (ctx.repoPath && health.stale_pages > 0) {
|
||||
const extractionLag = ctx.extractionLagPages ?? 0;
|
||||
if (ctx.repoPath && extractionLag > 0) {
|
||||
const params = { repoPath: ctx.repoPath, sourceId: ctx.sourceId, noEmbed: true };
|
||||
out.push({
|
||||
id: 'sync.repo',
|
||||
job: 'sync',
|
||||
params,
|
||||
idempotency_key: idemKey(source, 'sync', params),
|
||||
severity: health.stale_pages > 50 ? 'high' : 'medium',
|
||||
est_seconds: Math.min(600, 30 + health.stale_pages * 0.5),
|
||||
severity: extractionLag > 50 ? 'high' : 'medium',
|
||||
est_seconds: Math.min(600, 30 + extractionLag * 0.5),
|
||||
est_usd_cost: 0, // sync is fs+DB only
|
||||
depends_on: [],
|
||||
rationale: `${health.stale_pages} stale page${health.stale_pages === 1 ? '' : 's'} on disk`,
|
||||
rationale: `Sync before extracting ${extractionLag} page${extractionLag === 1 ? '' : 's'} with stale link/timeline edges`,
|
||||
status: 'remediable',
|
||||
});
|
||||
}
|
||||
@@ -237,7 +255,7 @@ export function computeRecommendations(
|
||||
est_seconds: Math.min(3600, 5 + health.missing_embeddings * 0.05),
|
||||
est_usd_cost,
|
||||
// sync should run first so embed sees fresh pages.
|
||||
depends_on: ctx.repoPath && health.stale_pages > 0 ? ['sync.repo'] : [],
|
||||
depends_on: ctx.repoPath && extractionLag > 0 ? ['sync.repo'] : [],
|
||||
rationale: `${health.missing_embeddings} chunk${health.missing_embeddings === 1 ? '' : 's'} invisible to vector search`,
|
||||
status: 'remediable',
|
||||
});
|
||||
@@ -267,7 +285,7 @@ export function computeRecommendations(
|
||||
// Triggered when sync.repo fires (because sync was set to noEmbed:true,
|
||||
// and noExtract:true after T5 lands → extract job is the materializer).
|
||||
// ---------------------------------------------------------------------
|
||||
if (ctx.repoPath && health.stale_pages > 0) {
|
||||
if (ctx.repoPath && extractionLag > 0) {
|
||||
const params = { mode: 'all', dir: ctx.repoPath };
|
||||
out.push({
|
||||
id: 'extract.all',
|
||||
@@ -278,7 +296,7 @@ export function computeRecommendations(
|
||||
est_seconds: Math.min(600, 30 + health.page_count * 0.01),
|
||||
est_usd_cost: 0,
|
||||
depends_on: ['sync.repo'],
|
||||
rationale: 'Materialize link + timeline edges from fresh pages',
|
||||
rationale: `Materialize link + timeline edges for ${extractionLag} page${extractionLag === 1 ? '' : 's'} with stale extraction`,
|
||||
status: 'remediable',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -908,10 +908,6 @@ export const KNOWN_CONFIG_KEYS: readonly string[] = [
|
||||
'facts.extraction_model',
|
||||
// #2113: output-token cap for the per-turn facts extractor (default 4000).
|
||||
'facts.extraction_max_tokens',
|
||||
// Owner opt-in: let the local stdio MCP pipe read this owner's private facts
|
||||
// (find_trajectory / recall). Default off; HTTP transport ignores it. See
|
||||
// src/core/facts/reader-trust.ts.
|
||||
'facts.trust_local_reads',
|
||||
// Dream cycle config
|
||||
'dream.synthesize.session_corpus_dir',
|
||||
'dream.synthesize.meeting_transcripts_dir',
|
||||
|
||||
@@ -591,8 +591,6 @@ export interface TrajectoryOpts {
|
||||
sourceIds?: string[];
|
||||
/** When true, filters to visibility='world' only. Set by MCP layer from ctx.remote. */
|
||||
remote?: boolean;
|
||||
/** Owner opt-in: read private facts despite `remote`. See facts/reader-trust.ts. */
|
||||
trustedFactReads?: boolean;
|
||||
/** Metric filter. When set, only facts with this canonical metric label participate. */
|
||||
metric?: string;
|
||||
/**
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
import type { OperationContext } from './../operations.ts';
|
||||
import type { FactRow } from './../engine.ts';
|
||||
import { effectiveConfidence } from './decay.ts';
|
||||
import { readableFactVisibilities } from './reader-trust.ts';
|
||||
|
||||
const DEFAULT_TTL_MS = 30_000;
|
||||
const DEFAULT_TOP_K = 10;
|
||||
@@ -51,13 +50,7 @@ export async function getBrainHotMemoryMeta(
|
||||
const sessionId = (ctx as { source_session?: string }).source_session
|
||||
?? null;
|
||||
const allowListHash = hashAllowList(ctx.takesHoldersAllowList);
|
||||
// Visibility tier: untrusted remote → world-only; trusted local +
|
||||
// owner-trusted reads → all rows. Folded into the cache key (the header's
|
||||
// "cache entries don't bleed across tiers" invariant): trustedFactReads is
|
||||
// re-read from config per call, so a mid-session opt-out must not keep
|
||||
// serving a private-inclusive cached payload for the TTL window.
|
||||
const visibility = readableFactVisibilities(ctx);
|
||||
const cacheKey = `${sourceId}::${sessionId ?? '_'}::${allowListHash}::${visibility ? 'world' : 'all'}`;
|
||||
const cacheKey = `${sourceId}::${sessionId ?? '_'}::${allowListHash}`;
|
||||
|
||||
const ttl = Math.max(1000, opts.ttlMs ?? DEFAULT_TTL_MS);
|
||||
const topK = Math.max(1, Math.min(opts.topK ?? DEFAULT_TOP_K, 25));
|
||||
@@ -68,6 +61,10 @@ export async function getBrainHotMemoryMeta(
|
||||
return cached.payload;
|
||||
}
|
||||
|
||||
// Build a fresh payload. Visibility tier: remote → world-only;
|
||||
// local → all rows.
|
||||
const visibility = ctx.remote === false ? undefined : ['world'] as ('world' | 'private')[];
|
||||
|
||||
let rows: FactRow[] = [];
|
||||
if (sessionId) {
|
||||
rows = await ctx.engine.listFactsBySession(sourceId, sessionId, {
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* Fact-read visibility trust.
|
||||
*
|
||||
* Fact rows are tagged `private` | `world`. Remote/untrusted callers
|
||||
* (`remote === true`) see only `world` rows — the posture that keeps a
|
||||
* published or HTTP-served brain from leaking private claims to strangers.
|
||||
*
|
||||
* But the stdio MCP server is an unauthenticated LOCAL pipe: on a single-owner
|
||||
* machine the caller IS the owner, yet it still defaults `remote: true` for
|
||||
* safety, so the owner's own agent is denied the owner's own private facts
|
||||
* (e.g. `find_trajectory` returns empty over MCP even though the facts exist).
|
||||
*
|
||||
* `trustedFactReads` is a narrow, READ-ONLY trust elevation, deliberately
|
||||
* DECOUPLED from `remote` so every other remote protection — file_upload
|
||||
* confinement, source isolation, fence stripping, takes-holder scoping — stays
|
||||
* fully in force. The stdio MCP server sets it ONLY when the brain owner opts
|
||||
* in via the `facts.trust_local_reads` config (default off). The HTTP/published
|
||||
* transport never sets it, so a served brain stays world-only regardless.
|
||||
*/
|
||||
export interface FactReaderTrust {
|
||||
/**
|
||||
* Mirrors OperationContext.remote. FAIL-CLOSED: anything not strictly
|
||||
* `false` is treated as remote/untrusted (CLAUDE.md trust invariant).
|
||||
*/
|
||||
remote?: boolean;
|
||||
/** Owner opt-in: this remote caller may read private facts. */
|
||||
trustedFactReads?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the reader is restricted to `visibility = 'world'` rows.
|
||||
* Fail-closed: an unset/undefined `remote` is untrusted — only an explicit
|
||||
* `remote: false` (trusted local CLI) or an explicit owner opt-in
|
||||
* (`trustedFactReads: true`) reads private rows.
|
||||
*/
|
||||
export function factsWorldOnly(t: FactReaderTrust): boolean {
|
||||
return t.remote !== false && t.trustedFactReads !== true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Visibility filter for list-style fact reads: `['world']` when the reader is
|
||||
* world-only, `undefined` (no filter — all rows) when it is trusted.
|
||||
*/
|
||||
export function readableFactVisibilities(
|
||||
t: FactReaderTrust,
|
||||
): ('private' | 'world')[] | undefined {
|
||||
return factsWorldOnly(t) ? ['world'] : undefined;
|
||||
}
|
||||
+7
-14
@@ -18,7 +18,6 @@ import { captureEvalCandidate, isEvalCaptureEnabled, isEvalScrubEnabled } from '
|
||||
import type { HybridSearchMeta } from './types.ts';
|
||||
import { extractPageLinks, isAutoLinkEnabled, isAutoTimelineEnabled, isGlobalBasenameEnabled, parseTimelineEntries, makeResolver, type UnresolvedFrontmatterRef } from './link-extraction.ts';
|
||||
import { isFactsBackstopEligible } from './facts/eligibility.ts';
|
||||
import { readableFactVisibilities } from './facts/reader-trust.ts';
|
||||
import { stripTakesFence } from './takes-fence.ts';
|
||||
import { stripFactsFence } from './facts-fence.ts';
|
||||
import { getContentFlag } from './quarantine.ts';
|
||||
@@ -333,15 +332,6 @@ export interface OperationContext {
|
||||
* remote/untrusted (defense in depth in case the type is bypassed via cast).
|
||||
*/
|
||||
remote: boolean;
|
||||
/**
|
||||
* Owner opt-in (`facts.trust_local_reads`): allow this remote caller to read
|
||||
* `private` facts. A NARROW, read-only trust elevation decoupled from
|
||||
* `remote` — every other remote protection (file confinement, source
|
||||
* isolation, fence stripping, takes scoping) stays in force. Set ONLY by the
|
||||
* stdio MCP server when the config is on; the HTTP transport never sets it.
|
||||
* Consulted via `src/core/facts/reader-trust.ts`.
|
||||
*/
|
||||
trustedFactReads?: boolean;
|
||||
/**
|
||||
* Subagent runtime context (v0.16+). Set by the subagent tool dispatcher when
|
||||
* dispatching an op as a tool call from an LLM loop. Used to enforce per-op
|
||||
@@ -3639,7 +3629,6 @@ const find_trajectory: Operation = {
|
||||
entitySlug: p.entity_slug,
|
||||
...scope,
|
||||
remote: ctx.remote === true,
|
||||
trustedFactReads: ctx.trustedFactReads === true,
|
||||
metric,
|
||||
kind,
|
||||
since,
|
||||
@@ -4000,9 +3989,13 @@ const recall: Operation = {
|
||||
const includeExpired = p.include_expired === true;
|
||||
const grep = typeof p.grep === 'string' ? p.grep.toLowerCase() : null;
|
||||
|
||||
// Visibility filter: world-only for untrusted remote callers; all rows for
|
||||
// trusted local CLI and owner-trusted reads (facts.trust_local_reads).
|
||||
const visibility = readableFactVisibilities(ctx);
|
||||
// Visibility filter: remote callers see world-only unless their token
|
||||
// grants elevated visibility (future-proofing; v0.31 ships world-only
|
||||
// for remote, all for local CLI).
|
||||
const visibility =
|
||||
ctx.remote === false
|
||||
? undefined
|
||||
: ['world'] as ('private' | 'world')[];
|
||||
|
||||
let rows: Awaited<ReturnType<typeof ctx.engine.listFactsByEntity>> = [];
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ import type {
|
||||
} from './engine.ts';
|
||||
import { MAX_SEARCH_LIMIT, clampSearchLimit } from './engine.ts';
|
||||
import { withRetry, BULK_RETRY_OPTS, resolveBulkRetryOpts, computeNextDelay, type BatchAuditSite } from './retry.ts';
|
||||
import { factsWorldOnly } from './facts/reader-trust.ts';
|
||||
import { logBatchRetry as auditLogBatchRetry, logBatchExhausted as auditLogBatchExhausted } from './audit/batch-retry-audit.ts';
|
||||
import { runMigrations } from './migrate.ts';
|
||||
import { PGLITE_SCHEMA_SQL, getPGLiteSchema } from './pglite-schema.ts';
|
||||
@@ -4319,10 +4318,7 @@ export class PGLiteEngine implements BrainEngine {
|
||||
const useArray = Array.isArray(opts.sourceIds) && opts.sourceIds.length > 0;
|
||||
const sourceIds = useArray ? opts.sourceIds! : null;
|
||||
const sourceId = opts.sourceId ?? 'default';
|
||||
// Direct-engine contract: unset `remote` here means a trusted in-process
|
||||
// caller (CLI/tests) — the fail-closed default lives in the op layer, which
|
||||
// always passes explicit booleans. Normalize before the fail-closed helper.
|
||||
const remoteFilter = factsWorldOnly({ remote: opts.remote === true, trustedFactReads: opts.trustedFactReads === true });
|
||||
const remoteFilter = opts.remote === true;
|
||||
|
||||
// Build SQL dynamically. PGLite uses $N positional params; we
|
||||
// assemble the WHERE clauses + params array in tandem to keep them
|
||||
|
||||
@@ -14,7 +14,6 @@ import type {
|
||||
SourceRow,
|
||||
} from './engine.ts';
|
||||
import { withRetry, BULK_RETRY_OPTS, resolveBulkRetryOpts, computeNextDelay, type BatchAuditSite } from './retry.ts';
|
||||
import { factsWorldOnly } from './facts/reader-trust.ts';
|
||||
import { logBatchRetry as auditLogBatchRetry, logBatchExhausted as auditLogBatchExhausted } from './audit/batch-retry-audit.ts';
|
||||
import type {
|
||||
DomainBankSampleOpts, CorpusSampleOpts, DomainBankRow,
|
||||
@@ -4533,10 +4532,7 @@ export class PostgresEngine implements BrainEngine {
|
||||
const useArray = Array.isArray(opts.sourceIds) && opts.sourceIds.length > 0;
|
||||
const sourceIds = useArray ? opts.sourceIds! : null;
|
||||
const sourceId = opts.sourceId ?? 'default';
|
||||
// Direct-engine contract: unset `remote` here means a trusted in-process
|
||||
// caller (CLI/tests) — the fail-closed default lives in the op layer, which
|
||||
// always passes explicit booleans. Normalize before the fail-closed helper.
|
||||
const remoteFilter = factsWorldOnly({ remote: opts.remote === true, trustedFactReads: opts.trustedFactReads === true });
|
||||
const remoteFilter = opts.remote === true;
|
||||
|
||||
// Source-scope predicate: array path (federated) wins over scalar.
|
||||
// Engine.ts contract: returns chronological points; regressions +
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import type { BrainEngine } from '../engine.ts';
|
||||
import type { RecommendationContext } from '../brain-score-recommendations.ts';
|
||||
import { LINK_EXTRACTOR_VERSION_TS } from '../link-extraction.ts';
|
||||
|
||||
// Re-export so consumers can `import { RecommendationContext } from '../remediation'`
|
||||
// — the canonical RecommendationContext type still lives in
|
||||
@@ -68,5 +69,29 @@ export async function loadRecommendationContext(
|
||||
embeddingDimensions,
|
||||
embeddingProviderConfigured: embeddingConfigured,
|
||||
hasChatApiKey: !!(process.env.ANTHROPIC_API_KEY || fileCfg?.anthropic_api_key),
|
||||
extractionLagPages: await countExtractionLag(engine),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Real extraction-lag count — the SAME staleness `gbrain extract --stale`
|
||||
* processes (engine.countStalePagesForExtraction with
|
||||
* versionTs=LINK_EXTRACTOR_VERSION_TS, matching doctor's links_extraction_lag
|
||||
* check — without versionTs, pages stamped before an extractor version bump
|
||||
* would lag for doctor/extract but never trip this gate). Drives the
|
||||
* sync→extract recommendation pipeline; replaces the legacy
|
||||
* `health.stale_pages` proxy that no longer reflected real extraction work
|
||||
* after the v10 trigger drop.
|
||||
*
|
||||
* Shared by loadRecommendationContext AND the D7 per-step recheck in
|
||||
* runRemediation — the recheck MUST refresh this gate alongside getHealth,
|
||||
* or a completed extract step keeps re-firing off the frozen initial count.
|
||||
*/
|
||||
export async function countExtractionLag(engine: BrainEngine): Promise<number> {
|
||||
try {
|
||||
return await engine.countStalePagesForExtraction({ versionTs: LINK_EXTRACTOR_VERSION_TS });
|
||||
} catch {
|
||||
/* counter unavailable (very old brain / mid-migration) — treat as 0 */
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
computeRecommendations,
|
||||
} from '../brain-score-recommendations.ts';
|
||||
import type { RemediationStep } from '../remediation-step.ts';
|
||||
import { loadRecommendationContext } from './context.ts';
|
||||
import { countExtractionLag, loadRecommendationContext } from './context.ts';
|
||||
import { computeRemediationPlan } from './plan.ts';
|
||||
import type {
|
||||
RemediationHooks,
|
||||
@@ -65,7 +65,7 @@ export async function runRemediation(
|
||||
clearRemediationCheckpoint,
|
||||
} = await import('../remediation-checkpoint.ts');
|
||||
|
||||
const ctx = await loadRecommendationContext(engine);
|
||||
let ctx = await loadRecommendationContext(engine);
|
||||
|
||||
// Pre-flight ceiling check via the shared plan computation.
|
||||
const initialPlan = await computeRemediationPlan(engine, { targetScore });
|
||||
@@ -305,6 +305,11 @@ export async function runRemediation(
|
||||
// steps with bumped retry suffix (D1).
|
||||
if (recs.length === 0 || stepCount >= maxJobs) break;
|
||||
const freshHealth = await engine.getHealth();
|
||||
// Refresh the extraction-lag gate alongside health: ctx was loaded once
|
||||
// before the loop, and a completed sync/extract step is exactly what
|
||||
// drives the count down. Reusing the frozen initial count would re-fire
|
||||
// sync.repo/extract.all every recheck until maxJobs.
|
||||
ctx = { ...ctx, extractionLagPages: await countExtractionLag(engine) };
|
||||
recs = computeRecommendations(freshHealth, ctx).filter((r) => r.status === 'remediable');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1423,6 +1423,15 @@ export interface BrainStats {
|
||||
export interface BrainHealth {
|
||||
page_count: number;
|
||||
embed_coverage: number;
|
||||
/**
|
||||
* LEGACY proxy: count of pages whose `updated_at` predates their newest
|
||||
* timeline entry. This bumped meaningfully only while a trigger updated
|
||||
* `pages.updated_at` on timeline insert; that trigger was dropped in
|
||||
* migration v10, so the metric no longer reflects real "needs work" state.
|
||||
* NO LONGER gates remediations — the sync→extract pipeline now gates on
|
||||
* `RecommendationContext.extractionLagPages` (the real extraction-lag from
|
||||
* `countStalePagesForExtraction`). Retained for the CLI health line + back-compat.
|
||||
*/
|
||||
stale_pages: number;
|
||||
/**
|
||||
* Islanded pages — zero inbound AND zero outbound links. A hub page
|
||||
|
||||
@@ -30,13 +30,6 @@ export interface ToolResult {
|
||||
export interface DispatchOpts {
|
||||
/** Defaults to true (remote/untrusted). Local CLI callers (`gbrain call`) pass false. */
|
||||
remote?: boolean;
|
||||
/**
|
||||
* Owner opt-in (`facts.trust_local_reads`): let this remote caller read
|
||||
* `private` facts. Set ONLY by the stdio MCP server; the HTTP transport
|
||||
* leaves it unset so a served brain stays world-only. See
|
||||
* `src/core/facts/reader-trust.ts`.
|
||||
*/
|
||||
trustedFactReads?: boolean;
|
||||
/** Override the default stderr logger (e.g. CLI uses console.* directly). */
|
||||
logger?: OperationContext['logger'];
|
||||
/**
|
||||
@@ -210,7 +203,6 @@ export function buildOperationContext(
|
||||
logger: opts.logger || stderrLogger,
|
||||
dryRun: !!params.dry_run,
|
||||
remote: opts.remote ?? true,
|
||||
trustedFactReads: opts.trustedFactReads === true,
|
||||
takesHoldersAllowList: opts.takesHoldersAllowList,
|
||||
// v0.34 D4: sourceId is REQUIRED at the type level. Auto-fill 'default'
|
||||
// for single-source brains and any caller who didn't resolve a sourceId.
|
||||
|
||||
@@ -35,16 +35,6 @@ export async function startMcpServer(engine: BrainEngine) {
|
||||
// shape and cast through `any` (the SDK accepts it via the ServerResult union).
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request: any): Promise<any> => {
|
||||
const { name, arguments: params } = request.params;
|
||||
// Owner opt-in: the stdio pipe is local + unauthenticated, so on a
|
||||
// single-owner machine its caller is the owner. When facts.trust_local_reads
|
||||
// is on, let fact reads (find_trajectory / recall) see this owner's own
|
||||
// private facts. Narrow + read-only — every other remote protection stays
|
||||
// on (remote stays true). HTTP transport never sets this. Best-effort: a
|
||||
// config read blip falls back to the safe world-only default.
|
||||
let trustedFactReads = false;
|
||||
try {
|
||||
trustedFactReads = (await engine.getConfig('facts.trust_local_reads')) === 'true';
|
||||
} catch { /* keep world-only default */ }
|
||||
// v0.28: stdio MCP has no per-token auth (local pipe). Default the
|
||||
// takes-holder allow-list to ['world'] so agent-facing callers don't
|
||||
// see private hunches via takes_list / takes_search / query. Operators
|
||||
@@ -52,7 +42,6 @@ export async function startMcpServer(engine: BrainEngine) {
|
||||
// `gbrain call <op>` (sets remote=false in src/cli.ts).
|
||||
return dispatchToolCall(engine, name, params, {
|
||||
remote: true,
|
||||
trustedFactReads,
|
||||
takesHoldersAllowList: ['world'],
|
||||
// v0.31: source defaults to 'default' for stdio (no per-token scope).
|
||||
// Operators who want a different source on stdio MCP should set
|
||||
|
||||
@@ -119,13 +119,12 @@ describe('computeRecommendations', () => {
|
||||
expect(recs.find((r) => r.id === 'embed.stale')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('stale pages + dead links produce sync + backlinks + extract', () => {
|
||||
test('extraction lag + dead links produce sync + backlinks + extract', () => {
|
||||
const health = makeHealth({
|
||||
stale_pages: 25,
|
||||
dead_links: 8,
|
||||
brain_score: 70,
|
||||
});
|
||||
const recs = computeRecommendations(health, { repoPath: '/brain', embeddingProviderConfigured: true });
|
||||
const recs = computeRecommendations(health, { repoPath: '/brain', embeddingProviderConfigured: true, extractionLagPages: 25 });
|
||||
const ids = recs.map((r) => r.id);
|
||||
expect(ids).toContain('sync.repo');
|
||||
expect(ids).toContain('backlinks.fix');
|
||||
@@ -133,18 +132,17 @@ describe('computeRecommendations', () => {
|
||||
});
|
||||
|
||||
test('extract.all depends on sync.repo (D14: stable ids)', () => {
|
||||
const health = makeHealth({ stale_pages: 10 });
|
||||
const recs = computeRecommendations(health, { repoPath: '/brain', embeddingProviderConfigured: true });
|
||||
const health = makeHealth();
|
||||
const recs = computeRecommendations(health, { repoPath: '/brain', embeddingProviderConfigured: true, extractionLagPages: 10 });
|
||||
const extract = recs.find((r) => r.id === 'extract.all');
|
||||
expect(extract?.depends_on).toContain('sync.repo');
|
||||
});
|
||||
|
||||
test('embed.stale depends on sync.repo when sync also needed', () => {
|
||||
test('embed.stale depends on sync.repo when extraction also needed', () => {
|
||||
const health = makeHealth({
|
||||
stale_pages: 10,
|
||||
missing_embeddings: 100,
|
||||
});
|
||||
const recs = computeRecommendations(health, { repoPath: '/brain', embeddingProviderConfigured: true });
|
||||
const recs = computeRecommendations(health, { repoPath: '/brain', embeddingProviderConfigured: true, extractionLagPages: 10 });
|
||||
const embed = recs.find((r) => r.id === 'embed.stale');
|
||||
expect(embed?.depends_on).toContain('sync.repo');
|
||||
});
|
||||
@@ -159,9 +157,9 @@ describe('computeRecommendations', () => {
|
||||
test('severity ordering: critical before high before medium', () => {
|
||||
const health = makeHealth({
|
||||
missing_embeddings: 100, // critical
|
||||
stale_pages: 80, // high
|
||||
});
|
||||
const recs = computeRecommendations(health, { repoPath: '/brain', embeddingProviderConfigured: true });
|
||||
// extractionLagPages > 50 → sync.repo fires at 'high' severity.
|
||||
const recs = computeRecommendations(health, { repoPath: '/brain', embeddingProviderConfigured: true, extractionLagPages: 80 });
|
||||
const critIdx = recs.findIndex((r) => r.severity === 'critical');
|
||||
const highIdx = recs.findIndex((r) => r.severity === 'high');
|
||||
expect(critIdx).toBeLessThan(highIdx);
|
||||
@@ -170,11 +168,10 @@ describe('computeRecommendations', () => {
|
||||
// D6 #5 — THE critical regression test for the agent contract.
|
||||
test('D6 #5: determinism — same input twice produces identical output', () => {
|
||||
const health = makeHealth({
|
||||
stale_pages: 10,
|
||||
missing_embeddings: 50,
|
||||
dead_links: 3,
|
||||
});
|
||||
const ctx = { repoPath: '/brain', embeddingProviderConfigured: true, sourceId: 'default' };
|
||||
const ctx = { repoPath: '/brain', embeddingProviderConfigured: true, sourceId: 'default', extractionLagPages: 10 };
|
||||
const run1 = computeRecommendations(health, ctx);
|
||||
const run2 = computeRecommendations(health, ctx);
|
||||
expect(JSON.stringify(run1)).toBe(JSON.stringify(run2));
|
||||
|
||||
@@ -165,22 +165,6 @@ describe('findTrajectory — visibility filter (D-CDX-1 / R6)', () => {
|
||||
const all = await engine.findTrajectory({ entitySlug: 'traj-vis-default' });
|
||||
expect(all.length).toBe(2);
|
||||
});
|
||||
|
||||
test('remote=true + trustedFactReads bypasses world-only (owner-trusted reads)', async () => {
|
||||
await insertTyped({ entity_slug: 'traj-vis-trusted', metric: 'mrr', value: 50000, visibility: 'private', valid_from: new Date('2026-01-15') });
|
||||
await insertTyped({ entity_slug: 'traj-vis-trusted', metric: 'mrr', value: 99999, visibility: 'world', valid_from: new Date('2026-04-12') });
|
||||
|
||||
// Untrusted remote: world only.
|
||||
const untrusted = await engine.findTrajectory({ entitySlug: 'traj-vis-trusted', remote: true });
|
||||
expect(untrusted.length).toBe(1);
|
||||
expect(untrusted[0].value).toBe(99999);
|
||||
|
||||
// Owner-trusted remote: sees the private point too. remote stays true — only
|
||||
// fact-read visibility is elevated.
|
||||
const trusted = await engine.findTrajectory({ entitySlug: 'traj-vis-trusted', remote: true, trustedFactReads: true });
|
||||
expect(trusted.length).toBe(2);
|
||||
expect(trusted.map(p => p.value).sort((a, b) => (a! - b!))).toEqual([50000, 99999]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findTrajectory — metric + since + until filters', () => {
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import {
|
||||
factsWorldOnly,
|
||||
readableFactVisibilities,
|
||||
} from '../src/core/facts/reader-trust.ts';
|
||||
|
||||
describe('factsWorldOnly', () => {
|
||||
test('FAIL-CLOSED: unset remote is untrusted (world-only)', () => {
|
||||
expect(factsWorldOnly({})).toBe(true);
|
||||
expect(factsWorldOnly({ remote: undefined })).toBe(true);
|
||||
expect(factsWorldOnly({ trustedFactReads: false })).toBe(true);
|
||||
});
|
||||
|
||||
test('explicit remote=false (trusted local CLI) sees all', () => {
|
||||
expect(factsWorldOnly({ remote: false })).toBe(false);
|
||||
expect(factsWorldOnly({ remote: false, trustedFactReads: false })).toBe(false);
|
||||
});
|
||||
|
||||
test('untrusted remote callers are world-only', () => {
|
||||
expect(factsWorldOnly({ remote: true })).toBe(true);
|
||||
expect(factsWorldOnly({ remote: true, trustedFactReads: false })).toBe(true);
|
||||
});
|
||||
|
||||
test('owner-trusted remote reads bypass the world-only filter', () => {
|
||||
expect(factsWorldOnly({ remote: true, trustedFactReads: true })).toBe(false);
|
||||
});
|
||||
|
||||
test('trustedFactReads is a no-op for an already-trusted local caller', () => {
|
||||
expect(factsWorldOnly({ remote: false, trustedFactReads: true })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readableFactVisibilities', () => {
|
||||
test("world-only readers get the ['world'] filter (incl. unset remote)", () => {
|
||||
expect(readableFactVisibilities({ remote: true })).toEqual(['world']);
|
||||
expect(readableFactVisibilities({})).toEqual(['world']);
|
||||
});
|
||||
|
||||
test('trusted readers get undefined (no filter — all rows)', () => {
|
||||
expect(readableFactVisibilities({ remote: false })).toBeUndefined();
|
||||
expect(readableFactVisibilities({ remote: true, trustedFactReads: true })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
// test/remediation-context-extraction-lag.test.ts
|
||||
//
|
||||
// Pins the v-next fix: the sync→extract remediation pipeline gates on REAL
|
||||
// extraction lag, not the legacy `health.stale_pages` proxy (which counted
|
||||
// "updated_at predates newest timeline entry" — meaningless after the v10
|
||||
// trigger drop). loadRecommendationContext now populates `extractionLagPages`
|
||||
// from `engine.countStalePagesForExtraction` — the SAME counter the
|
||||
// `gbrain extract --stale` walk and doctor's `links_extraction_lag` use — so a
|
||||
// recommendation can only fire when running extract will actually reduce it.
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'bun:test';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import { loadRecommendationContext } from '../src/core/remediation/context.ts';
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
});
|
||||
|
||||
describe('loadRecommendationContext — extractionLagPages wiring', () => {
|
||||
it('is 0 on an empty brain (nothing to extract)', async () => {
|
||||
const ctx = await loadRecommendationContext(engine);
|
||||
expect(ctx.extractionLagPages).toBe(0);
|
||||
});
|
||||
|
||||
it('reflects the real extraction-lag count once a page needs extraction', async () => {
|
||||
// A freshly-imported page has links_extracted_at = NULL, which the canonical
|
||||
// countStalePagesForExtraction predicate counts as stale-for-extraction.
|
||||
await engine.putPage('p0', {
|
||||
title: 'p0',
|
||||
type: 'note' as never,
|
||||
compiled_truth: 'body that is long enough to pass any minimum-length guards in the codebase',
|
||||
timeline: '',
|
||||
frontmatter: {},
|
||||
source_path: 'p0.md',
|
||||
});
|
||||
const ctx = await loadRecommendationContext(engine);
|
||||
expect(ctx.extractionLagPages).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('counts pages stamped before LINK_EXTRACTOR_VERSION_TS (version-bump arm)', async () => {
|
||||
// Backdate p0 so BOTH the NULL arm and the updated_at arm are quiet:
|
||||
// updated_at < links_extracted_at, but links_extracted_at predates the
|
||||
// extractor version stamp. doctor's links_extraction_lag and
|
||||
// `extract --stale` both count this page; the remediation gate must too.
|
||||
await engine.executeRaw(
|
||||
`UPDATE pages SET updated_at = '2020-01-01T00:00:00Z'::timestamptz,
|
||||
links_extracted_at = '2020-01-02T00:00:00Z'::timestamptz
|
||||
WHERE slug = 'p0'`,
|
||||
[],
|
||||
);
|
||||
const ctx = await loadRecommendationContext(engine);
|
||||
expect(ctx.extractionLagPages).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
// test/remediation-run-d7-refresh.serial.test.ts
|
||||
//
|
||||
// Pins the D7-recheck half of the extraction-lag gate fix: runRemediation
|
||||
// loads RecommendationContext ONCE before the step loop, and the per-step
|
||||
// recheck (D7) must REFRESH ctx.extractionLagPages alongside getHealth.
|
||||
// Without the refresh, a completed sync/extract step keeps re-firing off
|
||||
// the frozen initial count — the plan never converges and the loop burns
|
||||
// steps until maxJobs.
|
||||
//
|
||||
// SERIAL (R2): uses top-level mock.module for the minion queue +
|
||||
// wait-for-completion so no real worker is needed — mocks leak across
|
||||
// files in a shard process, so this file must run in its own process.
|
||||
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
// The fake brain: sync.repo clears the extraction lag when it "runs"
|
||||
// (today's sync materializes link/timeline edges; extract.all is the
|
||||
// explicit re-materializer). The frozen-ctx bug makes runRemediation
|
||||
// ignore that and resubmit sync.repo on every D7 recheck.
|
||||
let extractionLag = 25;
|
||||
const submittedJobs: string[] = [];
|
||||
|
||||
mock.module('../src/core/minions/queue.ts', () => ({
|
||||
MinionQueue: class {
|
||||
constructor(_engine: unknown) {}
|
||||
async add(job: string): Promise<{ id: number }> {
|
||||
submittedJobs.push(job);
|
||||
if (job === 'sync' || job === 'extract') extractionLag = 0;
|
||||
return { id: submittedJobs.length };
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('../src/core/minions/wait-for-completion.ts', () => ({
|
||||
waitForCompletion: async () => ({ status: 'completed' }),
|
||||
}));
|
||||
|
||||
const health = () => ({
|
||||
page_count: 100,
|
||||
embed_coverage: 1.0,
|
||||
stale_pages: 0, // legacy proxy stays 0 — the real counter drives the gate
|
||||
orphan_pages: 0,
|
||||
missing_embeddings: 0,
|
||||
brain_score: 70,
|
||||
dead_links: 0,
|
||||
link_coverage: 1.0,
|
||||
timeline_coverage: 1.0,
|
||||
most_connected: [],
|
||||
embed_coverage_score: 35,
|
||||
link_density_score: 25,
|
||||
timeline_coverage_score: 15,
|
||||
no_orphans_score: 15,
|
||||
no_dead_links_score: 10,
|
||||
});
|
||||
|
||||
const fakeEngine = {
|
||||
kind: 'pglite' as const,
|
||||
getHealth: async () => health(),
|
||||
getConfig: async (key: string) =>
|
||||
key === 'sync.repo_path' ? '/tmp/brain-example' : null,
|
||||
countStalePagesForExtraction: async () => extractionLag,
|
||||
};
|
||||
|
||||
describe('runRemediation D7 recheck — extraction-lag gate refresh', () => {
|
||||
test('a completed materializer step clears the gate; the pipeline is not resubmitted', async () => {
|
||||
const { runRemediation } = await import('../src/core/remediation/run.ts');
|
||||
const result = await runRemediation(
|
||||
// Only the methods the orchestrator touches are needed.
|
||||
fakeEngine as never,
|
||||
{ targetScore: 0, maxJobs: 6 },
|
||||
);
|
||||
// Frozen-ctx bug: extractionLagPages stays 25 forever, so every D7
|
||||
// recheck re-introduces the sync/extract pipeline and the loop burns
|
||||
// all 6 maxJobs. With the refresh, the plan converges after the first
|
||||
// completed step: no step id is ever submitted twice.
|
||||
const ids = result.submitted.map((s) => s.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
expect(submittedJobs.length).toBeLessThan(3);
|
||||
expect(extractionLag).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user