mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-17 10:22:34 +00:00
Compare commits
83
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c7e020d20 | ||
|
|
50406fc212 | ||
|
|
0556dbdc2c | ||
|
|
220af4b2d0 | ||
|
|
fe6850b067 | ||
|
|
8fc93c8fac | ||
|
|
3454dca0b4 | ||
|
|
5dcf3e7b2f | ||
|
|
dbca701008 | ||
|
|
4b6cf32c9f | ||
|
|
0c66715f90 | ||
|
|
55af5fc091 | ||
|
|
10b5746053 | ||
|
|
5bee08c3c4 | ||
|
|
f02919c041 | ||
|
|
66fa5fba22 | ||
|
|
e79b8d5780 | ||
|
|
fc1f88cdcb | ||
|
|
70ffe4a2a2 | ||
|
|
5a295bc293 | ||
|
|
2724c3b6c9 | ||
|
|
1233051a20 | ||
|
|
53c9086945 | ||
|
|
033fd24fe8 | ||
|
|
8915fba476 | ||
|
|
372f013158 | ||
|
|
439bbaac3a | ||
|
|
a1bb7683d0 | ||
|
|
94535fc0e0 | ||
|
|
a6aafddd23 | ||
|
|
c92af9a7d6 | ||
|
|
beedacde56 | ||
|
|
f8dbfca2f5 | ||
|
|
b7f70970c1 | ||
|
|
1b099aeaca | ||
|
|
2941e17798 | ||
|
|
503f61e6e4 | ||
|
|
0367c800a4 | ||
|
|
23df0227bd | ||
|
|
3225bdf768 | ||
|
|
6ec5261700 | ||
|
|
b0d136ee6d | ||
|
|
47d7e95b74 | ||
|
|
c0d4def5bc | ||
|
|
c0a4b80f0d | ||
|
|
0bd752b3f7 | ||
|
|
6e4c2435e3 | ||
|
|
1a9ab6a95f | ||
|
|
7c06af281d | ||
|
|
5ac81b0d0a | ||
|
|
c0cb6c533b | ||
|
|
d67be8b570 | ||
|
|
a356f64e4f | ||
|
|
a1dadebd60 | ||
|
|
c43ed81c72 | ||
|
|
1d0df706fe | ||
|
|
8078c46ab7 | ||
|
|
e20a6a5328 | ||
|
|
1a449bf501 | ||
|
|
74358329e1 | ||
|
|
a8a94f5742 | ||
|
|
f065eb1509 | ||
|
|
49cf5202cb | ||
|
|
b928f40bcd | ||
|
|
bb5a66942d | ||
|
|
6cf4f3122d | ||
|
|
6cf8d8d66c | ||
|
|
355fbc6947 | ||
|
|
2c96787867 | ||
|
|
8a5296f3cb | ||
|
|
8837bfe5f2 | ||
|
|
e1526bfebe | ||
|
|
44cae62324 | ||
|
|
56ccc14bcc | ||
|
|
e7ffbc057c | ||
|
|
0a757bf780 | ||
|
|
2b020ba2bd | ||
|
|
d43fb631bc | ||
|
|
292b8b1637 | ||
|
|
e78ad9ff9e | ||
|
|
2840734d70 | ||
|
|
d69f211629 | ||
|
|
02ba4b4fc2 |
@@ -206,7 +206,11 @@ jobs:
|
||||
needs: cache-check
|
||||
if: needs.cache-check.outputs.hit != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
# 22, not 15: under parallel PR load the PGLite WASM cold-starts stretch a
|
||||
# shard past 15 min while every test is still passing — the timeout then
|
||||
# cancels the job and the test-status gate reads it as a failure. 13 runs
|
||||
# died this way on 2026-07-21/22 alone.
|
||||
timeout-minutes: 22
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
|
||||
@@ -37,9 +37,19 @@ export async function findCodeDef(
|
||||
// trigger) are first-class definitions in the SQL sense. The chunker's
|
||||
// normalizeSymbolType maps create_table → 'table' etc, so adding the SQL
|
||||
// kinds here is what makes `gbrain code-def users` work against SQL.
|
||||
// Method-level + member definitions. normalizeSymbolType only canonicalizes
|
||||
// some node types; the rest fall through `type.replace(/_/g, ' ')`, so
|
||||
// tree-sitter's method_declaration → 'method declaration', struct_specifier →
|
||||
// 'struct specifier', protocol_declaration → 'protocol declaration', etc.
|
||||
// Without these, code-def is blind to every method, constructor, field, C
|
||||
// struct, and Swift protocol — which is most of an OO codebase. The plain
|
||||
// 'struct' entry above never matched for the same reason (C emits the
|
||||
// 'struct specifier' fallback form).
|
||||
const DEF_TYPES = [
|
||||
'function', 'class', 'interface', 'type', 'enum', 'struct', 'trait', 'module', 'contract',
|
||||
'table', 'view', 'index', 'procedure', 'schema', 'database', 'trigger',
|
||||
'method declaration', 'method definition', 'constructor declaration',
|
||||
'field declaration', 'field definition', 'struct specifier', 'protocol declaration',
|
||||
];
|
||||
const params: unknown[] = [symbol, limit];
|
||||
let whereLang = '';
|
||||
|
||||
@@ -4963,8 +4963,7 @@ export async function buildChecks(
|
||||
message:
|
||||
`${unmatched}/${sample.length} conversation pages (${unmatchedPct.toFixed(1)}%) match NO built-in pattern. ` +
|
||||
`Breakdown: ${breakdown}. ` +
|
||||
`Investigate: gbrain conversation-parser scan <slug> | ` +
|
||||
`Enable LLM fallback (opt-in): gbrain config set conversation_parser.llm_fallback_enabled true`,
|
||||
`Investigate: gbrain conversation-parser scan <slug>`,
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
|
||||
+12
-1
@@ -1743,7 +1743,18 @@ export async function extractStaleFromDB(
|
||||
// `page.updated_at.toISOString()` — the JS Date is ms-truncated, so the
|
||||
// µs-precision DB updated_at stayed strictly greater and the page never
|
||||
// cleared on Postgres. Stamping the exact value makes them equal.
|
||||
processedRefs.push({ slug: page.slug, source_id: page.source_id, extractedAt: page.updated_at_iso });
|
||||
//
|
||||
// BUT the stamp must also clear the version-staleness clause
|
||||
// (`links_extracted_at < versionTs`). A page whose updated_at predates
|
||||
// versionTs would otherwise be stamped below the threshold and read as
|
||||
// stale forever — a permanent re-extract loop that never clears the lag.
|
||||
// GREATEST(updated_at, versionTs) preserves the race semantics (a real
|
||||
// future edit advances updated_at > versionTs >= stamp → re-extracts)
|
||||
// while lifting old pages to the threshold so they clear.
|
||||
const stampIso = page.updated_at.getTime() >= Date.parse(versionTs)
|
||||
? page.updated_at_iso
|
||||
: versionTs;
|
||||
processedRefs.push({ slug: page.slug, source_id: page.source_id, extractedAt: stampIso });
|
||||
}
|
||||
|
||||
// Flush NON-swallowing (CDX-4): a throw here propagates out of the sweep so
|
||||
|
||||
@@ -98,8 +98,17 @@ export function findBareTweetHits(compiledTruth: string, slug: string): BareTwee
|
||||
}
|
||||
// If the line already contains a tweet URL, it's cited — skip
|
||||
if (URL_NEARBY_RE.test(line)) continue;
|
||||
// If the line carries an explicit source citation (e.g.
|
||||
// "[Source: X, @handle, 2026-05-28]"), it's already attributed — skip.
|
||||
// Catches instructional/example lines in recipe docs that demonstrate
|
||||
// the CORRECT citation format. (v0.42.x)
|
||||
if (/\[\s*source:/i.test(line)) continue;
|
||||
// Strip inline-code spans (`...`) before matching: phrases shown as
|
||||
// inline-code templates in docs are examples, not bare claims. The
|
||||
// fenced-code skip above only covers ``` blocks, not inline backticks.
|
||||
const lineForMatch = line.replace(/`[^`]*`/g, '');
|
||||
for (const re of BARE_TWEET_PHRASES) {
|
||||
const m = line.match(re);
|
||||
const m = lineForMatch.match(re);
|
||||
if (m) {
|
||||
hits.push({ slug, line: i + 1, rawLine: line.trim(), phrase: m[0] });
|
||||
break; // one finding per line is enough
|
||||
|
||||
@@ -1664,7 +1664,13 @@ export async function registerBuiltinHandlers(
|
||||
|
||||
worker.register('backlinks', async (job) => {
|
||||
const { runBacklinksCore } = await import('./backlinks.ts');
|
||||
const action: 'check' | 'fix' = job.data.action === 'check' ? 'check' : 'fix';
|
||||
// Default to 'check', not 'fix': backlinks jobs submitted with an empty
|
||||
// payload (e.g. the sync→embed→backlinks chains enqueued after ingestion)
|
||||
// must never rewrite tracked brain pages with generated "Referenced in"
|
||||
// timeline bullets. Mirrors the documented intent in src/core/cycle.ts
|
||||
// (runPhaseBacklinks). The filesystem fixer stays available explicitly
|
||||
// via '{"action":"fix"}' or `gbrain check-backlinks fix`.
|
||||
const action: 'check' | 'fix' = job.data.action === 'fix' ? 'fix' : 'check';
|
||||
const dir = typeof job.data.dir === 'string'
|
||||
? job.data.dir
|
||||
: (await engine.getConfig('sync.repo_path')) ?? '.';
|
||||
|
||||
@@ -127,7 +127,12 @@ export function lintContent(content: string, filePath: string, opts: LintContent
|
||||
}
|
||||
|
||||
// Rule: Wrapping code fences (```markdown ... ```)
|
||||
if (content.match(/^```(?:markdown|md)\s*\n/m) && content.match(/\n```\s*$/m)) {
|
||||
// Detector intentionally has NO /m flag so ^/$ match start/end of the whole
|
||||
// file, not inner lines. Keeps detector in sync with fixContent() below,
|
||||
// which also has no /m flag. Without this, lint reports "fixable" false
|
||||
// positives on any page that simply contains a ```markdown code block, but
|
||||
// fixContent can never strip them (its regex only matches whole-file wrappers).
|
||||
if (content.match(/^```(?:markdown|md)\s*\n/) && content.match(/\n```\s*$/)) {
|
||||
issues.push({
|
||||
file: filePath, line: 1, rule: 'code-fence-wrap',
|
||||
message: 'Page wrapped in ```markdown code fences (LLM artifact)',
|
||||
|
||||
+14
-1
@@ -536,7 +536,20 @@ function shouldSkipProvider(modelStr: string, skip: string[]): boolean {
|
||||
|
||||
export async function runModels(engine: BrainEngine, args: string[]): Promise<void> {
|
||||
const json = args.includes('--json');
|
||||
const sub = args[1] === 'doctor' ? 'doctor' : args[1] === 'help' || args.includes('--help') || args.includes('-h') ? 'help' : 'read';
|
||||
// args is `subArgs` from cli.ts `handleCliOnly` — the leading 'models'
|
||||
// token has already been stripped. The subcommand is at args[0], NOT
|
||||
// args[1]. Pre-fix this check was `args[1]`, so `gbrain models doctor`
|
||||
// silently fell through to the read view. The doctor probe path was
|
||||
// unreachable from the CLI.
|
||||
//
|
||||
// --help honored FIRST so `gbrain models doctor --help` shows usage
|
||||
// instead of running network probes (which would spend tokens or
|
||||
// exit nonzero when the user only asked for help). Pre-fix the
|
||||
// args[1] ternary happened to dodge this by always falling through
|
||||
// to the args.includes('--help') branch; the args[0] rewrite needs
|
||||
// explicit ordering to preserve that behavior.
|
||||
const hasHelp = args.includes('--help') || args.includes('-h') || args[0] === 'help';
|
||||
const sub = hasHelp ? 'help' : args[0] === 'doctor' ? 'doctor' : 'read';
|
||||
|
||||
if (sub === 'help') {
|
||||
process.stdout.write(
|
||||
|
||||
@@ -843,6 +843,21 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
|
||||
// reverse proxies / tunnels; default to localhost for dev.
|
||||
const issuerUrl = new URL(publicUrl || `http://localhost:${port}`);
|
||||
|
||||
// MCP authorization spec (2025-06-18 draft §5.1) and RFC 9728 require the
|
||||
// protected resource server to return its discovery metadata URL in the
|
||||
// WWW-Authenticate header on 401 responses:
|
||||
//
|
||||
// WWW-Authenticate: Bearer resource_metadata="<URL>"
|
||||
//
|
||||
// Clients (claude.ai, Cursor, every other MCP-aware OAuth client) use that
|
||||
// URL to find the authorization-server discovery doc + token endpoint
|
||||
// without the user having to paste those URLs manually. Pre-fix the header
|
||||
// shipped `Bearer error="invalid_token", ...` with no resource_metadata
|
||||
// parameter, so MCP clients couldn't begin the OAuth flow from a fresh
|
||||
// 401 — they would silently fail to connect with a generic "couldn't
|
||||
// reach the MCP server" error.
|
||||
const resourceMetadataUrl = `${issuerUrl.toString().replace(/\/$/, '')}/.well-known/oauth-protected-resource`;
|
||||
|
||||
// F9: cookie `secure` flag honors both the request's TLS state (req.secure
|
||||
// is set when express trust-proxy lands an X-Forwarded-Proto: https) AND
|
||||
// the operator's declared issuer protocol (so a Cloudflare-tunnel deploy
|
||||
@@ -1601,7 +1616,7 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
|
||||
res.status(405).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Method not allowed' }, id: null });
|
||||
});
|
||||
|
||||
app.post('/mcp', requireBearerAuth({ verifier: oauthProvider }), async (req: Request, res: Response) => {
|
||||
app.post('/mcp', requireBearerAuth({ verifier: oauthProvider, resourceMetadataUrl }), async (req: Request, res: Response) => {
|
||||
const startTime = Date.now();
|
||||
const authInfo = (req as any).auth as AuthInfo;
|
||||
|
||||
@@ -1944,7 +1959,7 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
|
||||
app.post(
|
||||
'/ingest',
|
||||
ingestRateLimiter,
|
||||
requireBearerAuth({ verifier: oauthProvider, requiredScopes: ['write'] }),
|
||||
requireBearerAuth({ verifier: oauthProvider, requiredScopes: ['write'], resourceMetadataUrl }),
|
||||
express.raw({ type: '*/*', limit: ingestMaxBytes }),
|
||||
async (req: Request, res: Response) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { existsSync, readFileSync, writeFileSync, statSync, realpathSync } from 'fs';
|
||||
import { execFileSync } from 'child_process';
|
||||
import { join, relative } from 'path';
|
||||
import { isPathContained, isResolvedContained } from '../core/path-confine.ts';
|
||||
import type { BrainEngine } from '../core/engine.ts';
|
||||
import { DELETE_BATCH_SIZE } from '../core/engine-constants.ts';
|
||||
import { importFile } from '../core/import-file.ts';
|
||||
@@ -1112,15 +1113,14 @@ function createSyncBaselineCommit(repoPath: string): void {
|
||||
* #774 NAV-1 TOCTOU: true only if filePath realpath-resolves inside gitRoot.
|
||||
* Guards symlink escape at the per-file level (a committed symlink whose
|
||||
* target lives outside the repo), not just at scope entry.
|
||||
*
|
||||
* #3057: delegates to the shared separator-agnostic helper — the previous
|
||||
* inline `startsWith(rootReal + '/')` was always false on Windows
|
||||
* (realpathSync returns backslashes), recording every in-repo file as
|
||||
* SYMLINK_NOT_ALLOWED and freezing the sync bookmark.
|
||||
*/
|
||||
function isPathSafe(filePath: string, gitRoot: string): boolean {
|
||||
try {
|
||||
const real = realpathSync(filePath);
|
||||
const rootReal = realpathSync(gitRoot);
|
||||
return real === rootReal || real.startsWith(rootReal + '/');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return isPathContained(filePath, gitRoot);
|
||||
}
|
||||
|
||||
function hasOriginRemote(repoPath: string): boolean {
|
||||
@@ -1887,7 +1887,8 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
|
||||
// NAV-1/NAV-2 scope-entry guard: the realpath-resolved scope must live
|
||||
// inside the realpath-resolved git root. Catches `--src-subpath ../escape`
|
||||
// AND a symlinked subdir pointing outside the repo, before any git op runs.
|
||||
if (syncScopeRoot !== gitContextRoot && !syncScopeRoot.startsWith(gitContextRoot + '/')) {
|
||||
// #3057: separator-agnostic (both sides already realpath'd above).
|
||||
if (!isResolvedContained(syncScopeRoot, gitContextRoot)) {
|
||||
throw new Error(
|
||||
`Sync scope ${syncScopeRoot} resolves outside git repo ${gitContextRoot}. ` +
|
||||
`Refusing to sync: possible path traversal via --src-subpath.`,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* degrades to gather-only output with a warning if missing.
|
||||
*/
|
||||
import type { BrainEngine } from '../core/engine.ts';
|
||||
import { runThink, persistSynthesis } from '../core/think/index.ts';
|
||||
import { runThink, persistSynthesis, stripGapsSection } from '../core/think/index.ts';
|
||||
import { loadConfig, isThinClient } from '../core/config.ts';
|
||||
import { callRemoteTool, unpackToolResult } from '../core/mcp-client.ts';
|
||||
|
||||
@@ -157,7 +157,7 @@ prints what would have been the input (exit 0).
|
||||
|
||||
// Human-readable output
|
||||
console.log(`# ${question}\n`);
|
||||
console.log(result.answer);
|
||||
console.log(stripGapsSection(result.answer));
|
||||
console.log('');
|
||||
if (result.gaps.length > 0) {
|
||||
console.log('## Gaps');
|
||||
|
||||
@@ -263,6 +263,15 @@ export function dimsProviderOptions(
|
||||
if (modelId === 'text-embedding-v3' || modelId === 'embedding-3') {
|
||||
return { openaiCompatible: { dimensions: dims } };
|
||||
}
|
||||
// Qwen3-Embedding family on Ollama (and any other openai-compatible
|
||||
// provider serving it) supports Matryoshka truncation via `dimensions`.
|
||||
// Native sizes: 0.6B=1024, 4B=2560, 8B=4096. Without `dimensions`,
|
||||
// Ollama returns the native size and brains configured for narrower
|
||||
// widths hard-fail with a dim-mismatch error. Pattern match the bare
|
||||
// model name + any `:tag` (e.g. `qwen3-embedding:4b`, `qwen3-embedding:0.6b`).
|
||||
if (modelId === 'qwen3-embedding' || modelId.startsWith('qwen3-embedding:')) {
|
||||
return { openaiCompatible: { dimensions: dims } };
|
||||
}
|
||||
// MiniMax embo-01 takes a `type: 'db' | 'query'` field for asymmetric
|
||||
// retrieval. Today still hardcoded to 'db' for back-compat — opting
|
||||
// into the new inputType seam is a follow-up (see plan's deferred
|
||||
|
||||
+27
-1
@@ -599,6 +599,8 @@ function warnRecipesMissingBatchTokens(): void {
|
||||
// LiteLLM proxy, llama-server) — they ship without a static cap because
|
||||
// the cap depends on a user-launched server. Warning is noise for them.
|
||||
if (embedding.no_batch_cap === true) continue;
|
||||
// A declared item-count cap is a real batch cap — no warning needed.
|
||||
if (embedding.max_batch_items !== undefined) continue;
|
||||
if (_warnedRecipes.has(recipe.id)) continue;
|
||||
_warnedRecipes.add(recipe.id);
|
||||
// eslint-disable-next-line no-console
|
||||
@@ -1517,10 +1519,17 @@ export async function embed(texts: string[], opts?: EmbedOpts): Promise<Float32A
|
||||
|
||||
// Pre-split is gated on max_batch_tokens. Recipes without it (e.g. OpenAI)
|
||||
// ride the fast path: one embedMany call, no recursion safety net.
|
||||
const batches = maxBatchTokens
|
||||
const tokenBatches = maxBatchTokens
|
||||
? splitByTokenBudget(truncated, Math.floor(maxBatchTokens * effectiveSafetyFactor(recipe)), charsPerToken)
|
||||
: [truncated];
|
||||
|
||||
// Hard COUNT cap (e.g. llama-server's "maximum allowed batch size 32").
|
||||
// Token budget can't bound item count, so re-split any oversized batch.
|
||||
const maxBatchItems = embedding?.max_batch_items;
|
||||
const batches = maxBatchItems
|
||||
? tokenBatches.flatMap(b => capBatchItems(b, maxBatchItems))
|
||||
: tokenBatches;
|
||||
|
||||
const allEmbeddings: Float32Array[] = [];
|
||||
let _embedThrew = false;
|
||||
try {
|
||||
@@ -1596,6 +1605,23 @@ export function splitByTokenBudget(
|
||||
return batches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a batch into sub-batches of at most `maxItems` inputs. Enforces a
|
||||
* hard COUNT cap that the token-budget split can't (many tiny inputs fit
|
||||
* under any token budget). Used for endpoints like llama.cpp's llama-server
|
||||
* that reject requests exceeding their launch batch size.
|
||||
*
|
||||
* @internal exported for tests; not part of the public gateway API.
|
||||
*/
|
||||
export function capBatchItems(texts: string[], maxItems: number): string[][] {
|
||||
if (maxItems <= 0 || texts.length <= maxItems) return [texts];
|
||||
const batches: string[][] = [];
|
||||
for (let i = 0; i < texts.length; i += maxItems) {
|
||||
batches.push(texts.slice(i, i + maxItems));
|
||||
}
|
||||
return batches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the error looks like a provider batch-token-limit error.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { Recipe } from '../types.ts';
|
||||
|
||||
/**
|
||||
* Alibaba DashScope (灵积) reranker. DashScope's OpenAI-compatible surface
|
||||
* splits by capability: embeddings live under `/compatible-mode/v1` (see the
|
||||
* sibling `dashscope` recipe) while rerank lives under `/compatible-api/v1`
|
||||
* with a PLURAL leaf — `POST {base}/reranks`. Wire shape matches ZeroEntropy:
|
||||
* request `{model, query, documents, top_n?}`, response
|
||||
* `{results: [{index, relevance_score}]}` — so it rides gateway.rerank()'s
|
||||
* native path with only the recipe-pluggable `path` override (v0.40.6.1).
|
||||
*
|
||||
* This is a SEPARATE recipe rather than a reranker touchpoint on `dashscope`
|
||||
* because the two capabilities need different base URLs (`compatible-mode`
|
||||
* vs `compatible-api`) and `provider_base_urls` is keyed by recipe id — one
|
||||
* recipe can't point embeddings and rerank at different prefixes. Same
|
||||
* topology precedent as llama-server vs llama-server-reranker.
|
||||
*
|
||||
* Live-verified against the China endpoint (2026-07): `/reranks` with
|
||||
* `qwen3-rerank` → 200 `results[].relevance_score`; `/rerank` (singular)
|
||||
* → 404; `gte-rerank-v2` → 404 "Unsupported model for OpenAI compatibility
|
||||
* mode" (native-API only, so it is deliberately NOT listed here).
|
||||
*
|
||||
* Note: the international endpoint requires a region-aware DASHSCOPE_API_KEY.
|
||||
* China-region users point at https://dashscope.aliyuncs.com/compatible-api/v1
|
||||
* via `provider_base_urls['dashscope-rerank']`, mirroring the embedding
|
||||
* recipe's convention.
|
||||
*/
|
||||
export const dashscopeRerank: Recipe = {
|
||||
id: 'dashscope-rerank',
|
||||
name: 'Alibaba DashScope (灵积, reranker)',
|
||||
tier: 'openai-compat',
|
||||
implementation: 'openai-compatible',
|
||||
base_url_default: 'https://dashscope-intl.aliyuncs.com/compatible-api/v1',
|
||||
auth_env: {
|
||||
required: ['DASHSCOPE_API_KEY'],
|
||||
setup_url: 'https://help.aliyun.com/zh/model-studio/getting-started/',
|
||||
},
|
||||
touchpoints: {
|
||||
reranker: {
|
||||
// Only the model verified live on the OpenAI-compat /reranks surface.
|
||||
// gte-rerank-v2 exists on DashScope's native API but the compat path
|
||||
// rejects it ("Unsupported model for OpenAI compatibility mode").
|
||||
models: ['qwen3-rerank'],
|
||||
default_model: 'qwen3-rerank',
|
||||
// Mirror ZE's defensive per-request ceiling; gateway.rerank()
|
||||
// pre-flights body size and fails open.
|
||||
max_payload_bytes: 5_000_000,
|
||||
// PLURAL leaf under compatible-api — the whole reason this recipe
|
||||
// exists. `${base_url}${path}` → `…/compatible-api/v1/reranks`.
|
||||
path: '/reranks',
|
||||
// Hosted API: no local warmup, but cross-region latency can exceed
|
||||
// the 5s gateway default (same rationale as llama-server-reranker).
|
||||
default_timeout_ms: 30_000,
|
||||
},
|
||||
},
|
||||
setup_hint:
|
||||
'Get an API key at https://help.aliyun.com/zh/model-studio/getting-started/, then ' +
|
||||
'`export DASHSCOPE_API_KEY=...` and `gbrain config set search.reranker.model ' +
|
||||
'dashscope-rerank:qwen3-rerank`. China-region accounts: `gbrain config set ' +
|
||||
'provider_base_urls.dashscope-rerank https://dashscope.aliyuncs.com/compatible-api/v1`.',
|
||||
};
|
||||
@@ -19,6 +19,7 @@ import { together } from './together.ts';
|
||||
import { llamaServer } from './llama-server.ts';
|
||||
import { minimax } from './minimax.ts';
|
||||
import { dashscope } from './dashscope.ts';
|
||||
import { dashscopeRerank } from './dashscope-rerank.ts';
|
||||
import { zhipu } from './zhipu.ts';
|
||||
import { azureOpenAI } from './azure-openai.ts';
|
||||
import { zeroentropyai } from './zeroentropyai.ts';
|
||||
@@ -42,6 +43,7 @@ const ALL: Recipe[] = [
|
||||
llamaServerReranker,
|
||||
minimax,
|
||||
dashscope,
|
||||
dashscopeRerank,
|
||||
zhipu,
|
||||
azureOpenAI,
|
||||
zeroentropyai,
|
||||
|
||||
@@ -35,9 +35,12 @@ export const llamaServer: Recipe = {
|
||||
trust_custom_dims: true, // #2271: user knows the launched model's native dim
|
||||
cost_per_1m_tokens_usd: 0,
|
||||
price_last_verified: '2026-05-10',
|
||||
// llama-server's batch capacity is set by `--ctx-size` at launch
|
||||
// time; no static cap to declare. v0.32 (#779).
|
||||
no_batch_cap: true,
|
||||
// llama-server enforces a hard request-COUNT cap equal to its launch
|
||||
// batch size (`--batch-size`, default 32): it rejects requests with
|
||||
// more inputs with `batch size N > maximum allowed batch size 32`.
|
||||
// The token-budget split can't bound item count, so cap it here. A
|
||||
// server launched with a larger `-b` can raise this. v0.32 (#779).
|
||||
max_batch_items: 32,
|
||||
},
|
||||
},
|
||||
/**
|
||||
|
||||
@@ -54,6 +54,16 @@ export interface EmbeddingTouchpoint {
|
||||
* `max_batch_tokens` is also set.
|
||||
*/
|
||||
safety_factor?: number;
|
||||
/**
|
||||
* Maximum number of inputs per embedding request. Some endpoints enforce a
|
||||
* hard COUNT cap independent of token budget — notably llama.cpp's
|
||||
* `llama-server`, which rejects requests with more inputs than its launch
|
||||
* batch size (e.g. `batch size 100 > maximum allowed batch size 32`). The
|
||||
* token-budget pre-split cannot bound item count (many tiny chunks fit under
|
||||
* any token budget), so this is enforced as a separate hard re-split after
|
||||
* the token split. When unset, no count cap is applied.
|
||||
*/
|
||||
max_batch_items?: number;
|
||||
/**
|
||||
* v0.27.1: when true, at least one model in this recipe accepts image
|
||||
* inputs via a multimodal embedding endpoint (e.g. Voyage's
|
||||
|
||||
@@ -217,9 +217,19 @@ export function parseResolverEntries(resolverContent: string): ResolverEntry[] {
|
||||
// `skillsDir/*/SKILL.md` when manifest.json is missing — the scenario
|
||||
// needed for AGENTS.md-only OpenClaw deployments. See D-CX-12 / F-ENG-1.
|
||||
|
||||
/** Simple YAML frontmatter parser — extracts triggers array if present. */
|
||||
function extractTriggers(skillContent: string): string[] {
|
||||
const fmMatch = skillContent.match(/^---\n([\s\S]*?)\n---/);
|
||||
/**
|
||||
* Simple YAML frontmatter parser — extracts triggers array if present.
|
||||
*
|
||||
* Normalizes CRLF → LF before parsing so Windows checkouts (where
|
||||
* `core.autocrlf=true` is the default) parse correctly. Without this,
|
||||
* the `^---\n` and `^triggers:\s*\n` regexes never match because the
|
||||
* file content is `---\r\n` / `triggers:\r\n`, and every skill on
|
||||
* Windows is reported as `mece_gap` regardless of its actual content.
|
||||
* CI runs on Ubuntu-only so the bug only surfaces in user environments.
|
||||
*/
|
||||
export function extractTriggers(skillContent: string): string[] {
|
||||
const content = skillContent.replace(/\r\n/g, '\n');
|
||||
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
||||
if (!fmMatch) return [];
|
||||
const fm = fmMatch[1];
|
||||
const triggersMatch = fm.match(/^triggers:\s*\n((?:\s+-\s+.+\n?)*)/m);
|
||||
|
||||
@@ -168,6 +168,15 @@ export interface CodeChunkOptions {
|
||||
largeChunkThresholdTokens?: number;
|
||||
fallbackChunkSizeWords?: number;
|
||||
fallbackOverlapWords?: number;
|
||||
/**
|
||||
* Hard upper bound (estimated tokens) on any single emitted chunk. A node
|
||||
* the AST splitter can't break up (a giant object/array literal, a single
|
||||
* huge assignment, a massive template literal) would otherwise be emitted
|
||||
* whole and rejected by the embedder ("input exceeds context length").
|
||||
* Chunks over this budget are recursively re-split. Default 2000 fits the
|
||||
* smallest common embedder context (e.g. nomic-embed-text, 2048).
|
||||
*/
|
||||
maxChunkTokens?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -549,6 +558,7 @@ export function parseWithTimeout(
|
||||
}
|
||||
|
||||
const DEFAULT_CHUNKER_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_MAX_CHUNK_TOKENS = 2000;
|
||||
|
||||
function resolveChunkerTimeoutMs(): number {
|
||||
const raw = process.env.GBRAIN_CHUNKER_TIMEOUT_MS;
|
||||
@@ -706,9 +716,9 @@ export async function chunkCodeTextFull(
|
||||
}
|
||||
|
||||
if (chunks.length === 0) {
|
||||
return { chunks: fallbackChunks(source, filePath, language, opts), edges: rawEdges };
|
||||
return { chunks: capOversizedChunks(fallbackChunks(source, filePath, language, opts), filePath, language, opts), edges: rawEdges };
|
||||
}
|
||||
return { chunks: mergeSmallSiblings(chunks, chunkTarget), edges: rawEdges };
|
||||
return { chunks: capOversizedChunks(mergeSmallSiblings(chunks, chunkTarget), filePath, language, opts), edges: rawEdges };
|
||||
} catch {
|
||||
return { chunks: fallbackChunks(source, filePath, language, opts), edges: [] };
|
||||
} finally {
|
||||
@@ -814,6 +824,73 @@ function buildMergedChunk(group: CodeChunk[], index: number): CodeChunk {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Final safety net: guarantee no emitted chunk exceeds the embedder's context
|
||||
* budget. tree-sitter splitting (splitLargeNode) can only break up a node that
|
||||
* exposes a `body` with >= 2 named children. A node without one — a giant
|
||||
* object/array literal, a single huge assignment, a massive template literal —
|
||||
* is emitted whole, producing a chunk far larger than the embedder accepts.
|
||||
* The embedder then rejects it ("input exceeds context length") and the chunk
|
||||
* is never embedded. Recursively re-split any over-budget chunk; fall back to a
|
||||
* hard character split for pathological no-whitespace content (e.g. a minified
|
||||
* one-liner) where word/line splitting can't get under budget.
|
||||
*/
|
||||
function capOversizedChunks(
|
||||
chunks: CodeChunk[],
|
||||
filePath: string,
|
||||
language: SupportedCodeLanguage,
|
||||
opts: CodeChunkOptions,
|
||||
): CodeChunk[] {
|
||||
const cap = opts.maxChunkTokens ?? DEFAULT_MAX_CHUNK_TOKENS;
|
||||
if (!chunks.some((c) => estimateTokens(c.text) > cap)) return chunks;
|
||||
const out: CodeChunk[] = [];
|
||||
for (const c of chunks) {
|
||||
if (estimateTokens(c.text) <= cap) {
|
||||
out.push({ ...c, index: out.length });
|
||||
continue;
|
||||
}
|
||||
// Strip the structured header ("[Lang] path:N-M symbol\n\n") so the splitter
|
||||
// works on the raw body; buildChunk re-adds a header to each piece.
|
||||
const body = c.text.replace(/^\[[^\]]+\] [^\n]+\n\n/, '');
|
||||
for (const piece of splitToTokenBudget(body, cap, opts)) {
|
||||
if (!piece.trim()) continue;
|
||||
out.push(buildChunk({
|
||||
body: piece,
|
||||
filePath,
|
||||
language,
|
||||
symbolName: c.metadata.symbolName,
|
||||
symbolType: c.metadata.symbolType,
|
||||
startLine: c.metadata.startLine,
|
||||
endLine: c.metadata.endLine,
|
||||
index: out.length,
|
||||
parentSymbolPath: c.metadata.parentSymbolPath,
|
||||
}));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Split `text` into pieces each estimated <= cap tokens. Word/line-aware
|
||||
* (recursiveChunk) first; a hard character split is the last resort for
|
||||
* content with no whitespace to break on. */
|
||||
function splitToTokenBudget(text: string, cap: number, opts: CodeChunkOptions): string[] {
|
||||
const out: string[] = [];
|
||||
const pieces = recursiveChunk(text, {
|
||||
chunkSize: opts.fallbackChunkSizeWords ?? 300,
|
||||
chunkOverlap: opts.fallbackOverlapWords ?? 50,
|
||||
}).map((p) => p.text);
|
||||
for (const piece of pieces) {
|
||||
if (estimateTokens(piece) <= cap) {
|
||||
out.push(piece);
|
||||
continue;
|
||||
}
|
||||
// ~3.5 chars/token is a conservative cl100k estimate for source text.
|
||||
const charBudget = Math.max(1, Math.floor(cap * 3.5));
|
||||
for (let i = 0; i < piece.length; i += charBudget) out.push(piece.slice(i, i + charBudget));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------- Internals ----------
|
||||
|
||||
function fallbackChunks(
|
||||
|
||||
+33
-1
@@ -620,7 +620,10 @@ export function loadConfig(): GBrainConfig | null {
|
||||
* size the schema and must be stable across engine connect.
|
||||
*/
|
||||
export async function loadConfigWithEngine(
|
||||
engine: { getConfig(key: string): Promise<string | null | undefined> },
|
||||
engine: {
|
||||
getConfig(key: string): Promise<string | null | undefined>;
|
||||
listConfigKeys?(prefix: string): Promise<string[]>;
|
||||
},
|
||||
base?: GBrainConfig | null,
|
||||
): Promise<GBrainConfig | null> {
|
||||
// Codex /ship finding #3: when there's no file config AND no env DB URL,
|
||||
@@ -657,11 +660,31 @@ export async function loadConfigWithEngine(
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
async function dbPrefixMap(prefix: string): Promise<Record<string, string> | undefined> {
|
||||
if (typeof engine.listConfigKeys !== 'function') return undefined;
|
||||
let keys: string[];
|
||||
try {
|
||||
keys = await engine.listConfigKeys(prefix);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const out: Record<string, string> = {};
|
||||
for (const key of keys.sort()) {
|
||||
if (!key.startsWith(prefix)) continue;
|
||||
const leaf = key.slice(prefix.length);
|
||||
if (!leaf) continue;
|
||||
const value = await dbStr(key);
|
||||
if (value !== undefined) out[leaf] = value;
|
||||
}
|
||||
return Object.keys(out).length > 0 ? out : undefined;
|
||||
}
|
||||
|
||||
const dbMultimodal = await dbBool('embedding_multimodal');
|
||||
const dbMultimodalModel = await dbStr('embedding_multimodal_model');
|
||||
const dbOcr = await dbBool('embedding_image_ocr');
|
||||
const dbOcrModel = await dbStr('embedding_image_ocr_model');
|
||||
const dbProviderBaseUrls = await dbPrefixMap('provider_base_urls.');
|
||||
// v0.36 (D7) — embedding-column registry merge. Stored as JSON string in
|
||||
// the config table. Parse + shape-check here; full registry validation
|
||||
// (regex on keys, type/dim/provider field shapes) runs in the resolver at
|
||||
@@ -685,6 +708,15 @@ export async function loadConfigWithEngine(
|
||||
if (merged.embedding_image_ocr_model === undefined && dbOcrModel !== undefined) {
|
||||
merged.embedding_image_ocr_model = dbOcrModel;
|
||||
}
|
||||
if (dbProviderBaseUrls !== undefined) {
|
||||
const next = { ...(merged.provider_base_urls ?? {}) };
|
||||
for (const [providerId, baseUrl] of Object.entries(dbProviderBaseUrls)) {
|
||||
if (next[providerId] === undefined) next[providerId] = baseUrl;
|
||||
}
|
||||
if (Object.keys(next).length > 0) {
|
||||
merged.provider_base_urls = next;
|
||||
}
|
||||
}
|
||||
if (merged.embedding_columns === undefined && dbEmbeddingColumns !== undefined) {
|
||||
try {
|
||||
const parsed = JSON.parse(dbEmbeddingColumns);
|
||||
|
||||
@@ -45,7 +45,6 @@ import { embedBatch } from './embedding.ts';
|
||||
import { resolveContextualRetrievalMode } from './contextual-retrieval-resolver.ts';
|
||||
import {
|
||||
buildContextualPrefix,
|
||||
extractFirstTwoSentences,
|
||||
modeRequiresHaiku,
|
||||
modeRequiresWrapper,
|
||||
sanitizeTitle,
|
||||
@@ -57,10 +56,8 @@ import {
|
||||
SYNOPSIS_DOC_MAX_CHARS,
|
||||
type GeneratePerChunkSynopsisResult,
|
||||
} from './page-summary.ts';
|
||||
import {
|
||||
logSynopsisFailure,
|
||||
type SynopsisFailureKind,
|
||||
} from './audit-synopsis.ts';
|
||||
import type { SynopsisFailureKind } from './audit-synopsis.ts';
|
||||
import { runSlidingPool } from './worker-pool.ts';
|
||||
import type { BrainEngine } from './engine.ts';
|
||||
import type { ChunkInput, CRMode, Page } from './types.ts';
|
||||
import type { SourceRow } from './sources-ops.ts';
|
||||
@@ -73,6 +70,24 @@ import type { SourceRow } from './sources-ops.ts';
|
||||
* corpus_generation hash.
|
||||
*/
|
||||
export const TITLE_WRAPPER_VERSION = 1;
|
||||
const DEFAULT_HAIKU_MODEL = 'anthropic:claude-haiku-4-5-20251001';
|
||||
export const DEFAULT_CONTEXTUAL_CHUNK_CONCURRENCY = 4;
|
||||
export const MAX_CONTEXTUAL_CHUNK_CONCURRENCY = 16;
|
||||
|
||||
export function resolveContextualChunkConcurrency(
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
): number {
|
||||
const raw = env.GBRAIN_CONTEXTUAL_CHUNK_CONCURRENCY;
|
||||
if (raw === undefined || raw.trim() === '') return DEFAULT_CONTEXTUAL_CHUNK_CONCURRENCY;
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n)) return DEFAULT_CONTEXTUAL_CHUNK_CONCURRENCY;
|
||||
return clampContextualChunkConcurrency(n);
|
||||
}
|
||||
|
||||
function clampContextualChunkConcurrency(n: number): number {
|
||||
if (!Number.isFinite(n)) return DEFAULT_CONTEXTUAL_CHUNK_CONCURRENCY;
|
||||
return Math.max(1, Math.min(MAX_CONTEXTUAL_CHUNK_CONCURRENCY, Math.trunc(n)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Embedding model placeholder. The actual model name lands here from
|
||||
@@ -208,12 +223,16 @@ export interface ReembedPageArgs {
|
||||
* src/core/minions/rate-leases.ts here; inline callers (import-file,
|
||||
* reindex command) pass undefined and rely on gateway-level retry.
|
||||
*/
|
||||
acquireSynopsisLease?: () => Promise<void>;
|
||||
releaseSynopsisLease?: () => Promise<void>;
|
||||
acquireSynopsisLease?: () => Promise<unknown>;
|
||||
releaseSynopsisLease?: (lease?: unknown) => Promise<void>;
|
||||
/**
|
||||
* Intra-page per-chunk synopsis concurrency. 1 preserves the legacy
|
||||
* sequential loop exactly; higher values only parallelize Haiku synopsis
|
||||
* calls. Embedding remains one batch after all synopses succeed.
|
||||
*/
|
||||
chunkConcurrency?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_HAIKU_MODEL = 'anthropic:claude-haiku-4-5-20251001';
|
||||
|
||||
/**
|
||||
* Re-embed one page through the active CR mode. Implements the D26 P0-2
|
||||
* two-phase build pattern.
|
||||
@@ -432,82 +451,41 @@ async function tryBuildPhase1(opts: {
|
||||
}
|
||||
|
||||
// per_chunk_synopsis path. Read source text via fallback chain,
|
||||
// generate synopsis per chunk sequentially within this page (D10),
|
||||
// generate synopsis per chunk through a bounded sliding pool, then
|
||||
// batch embed at the end (D27 P2-2).
|
||||
const sourceText = readSourceTextWithFallback(page, chunks);
|
||||
const wrappedTexts: string[] = [];
|
||||
const wrappedTexts: string[] = new Array(chunks.length);
|
||||
const chunkConcurrency = clampContextualChunkConcurrency(
|
||||
args.chunkConcurrency ?? resolveContextualChunkConcurrency(),
|
||||
);
|
||||
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const c = chunks[i];
|
||||
|
||||
// Code chunks always bypass the wrapper (D20-T4) — pass through.
|
||||
if (c.chunk_source === 'fenced_code') {
|
||||
wrappedTexts.push(c.chunk_text);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Acquire rate-lease per chunk (D26 P0-3). Inline callers pass no
|
||||
// hooks; only the Minion handler wires through rate-leases.ts.
|
||||
if (args.acquireSynopsisLease) {
|
||||
await args.acquireSynopsisLease();
|
||||
}
|
||||
|
||||
let synopsisResult: GeneratePerChunkSynopsisResult;
|
||||
try {
|
||||
synopsisResult = await generatePerChunkSynopsis({
|
||||
documentText: sourceText,
|
||||
chunkText: c.chunk_text,
|
||||
pageTitle: page.title,
|
||||
pageSlug: args.pageSlug,
|
||||
sourceId: args.sourceId,
|
||||
chunkIndex: c.chunk_index,
|
||||
model: haikuModel,
|
||||
abortSignal: args.abortSignal,
|
||||
const poolResult = await runSlidingPool({
|
||||
items: chunks,
|
||||
workers: chunkConcurrency,
|
||||
signal: args.abortSignal,
|
||||
onError: 'abort',
|
||||
failureLabel: (c) => String(c.chunk_index),
|
||||
onItem: async (c, i) => {
|
||||
wrappedTexts[i] = await buildWrappedChunkText({
|
||||
chunk: c,
|
||||
sourceText,
|
||||
safeTitle,
|
||||
page,
|
||||
args,
|
||||
haikuModel,
|
||||
});
|
||||
} finally {
|
||||
if (args.releaseSynopsisLease) {
|
||||
try {
|
||||
await args.releaseSynopsisLease();
|
||||
} catch {
|
||||
// Lease release failure shouldn't abort the page; surfacing it
|
||||
// would race with the synopsis result. Audit-only.
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (synopsisResult.kind === 'success') {
|
||||
const prefix = buildContextualPrefix(safeTitle, synopsisResult.synopsis);
|
||||
wrappedTexts.push(
|
||||
wrapChunkForEmbedding(c.chunk_text, prefix, c.chunk_source),
|
||||
);
|
||||
continue;
|
||||
if (poolResult.failures.length > 0) {
|
||||
const failure = [...poolResult.failures].sort((a, b) => a.idx - b.idx)[0].error;
|
||||
if (failure instanceof ChunkSynopsisPhase1Error) {
|
||||
return failure.result;
|
||||
}
|
||||
|
||||
// Failure classification per D27 P1-2:
|
||||
// refusal | empty | malformed → page-level fall-back to title-only
|
||||
// auth_failure → permanent (won't fix with retry)
|
||||
// rate_limit | timeout | network | provider_5xx → transient
|
||||
// source_missing → walked into fallback already; would be 'malformed'
|
||||
// from generatePerChunkSynopsis if we ever propagated it here
|
||||
if (
|
||||
synopsisResult.kind === 'refusal' ||
|
||||
synopsisResult.kind === 'empty' ||
|
||||
synopsisResult.kind === 'malformed'
|
||||
) {
|
||||
return { kind: 'page_level_fallback_requested', cause: synopsisResult.kind };
|
||||
}
|
||||
if (synopsisResult.kind === 'auth_failure') {
|
||||
return {
|
||||
kind: 'permanent',
|
||||
cause: synopsisResult.kind,
|
||||
detail: synopsisResult.detail ?? 'auth failure',
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: 'transient',
|
||||
cause: synopsisResult.kind,
|
||||
detail: synopsisResult.detail ?? 'transient',
|
||||
};
|
||||
throw failure;
|
||||
}
|
||||
if (poolResult.aborted || args.abortSignal?.aborted) {
|
||||
return { kind: 'transient', cause: 'timeout', detail: 'aborted' };
|
||||
}
|
||||
|
||||
// All chunks synthesized successfully. Single batch embed (D27 P2-2).
|
||||
@@ -528,6 +506,113 @@ async function tryBuildPhase1(opts: {
|
||||
}
|
||||
}
|
||||
|
||||
class ChunkSynopsisPhase1Error extends Error {
|
||||
constructor(readonly result: Exclude<Phase1Result, Phase1Success>) {
|
||||
super(`chunk synopsis failed: ${result.kind}`);
|
||||
this.name = 'ChunkSynopsisPhase1Error';
|
||||
}
|
||||
}
|
||||
|
||||
async function buildWrappedChunkText(opts: {
|
||||
chunk: ChunkInput;
|
||||
sourceText: string;
|
||||
safeTitle: string;
|
||||
page: Page;
|
||||
args: ReembedPageArgs;
|
||||
haikuModel: string;
|
||||
}): Promise<string> {
|
||||
const { chunk: c, sourceText, safeTitle, page, args, haikuModel } = opts;
|
||||
|
||||
// Code chunks always bypass the wrapper (D20-T4) — pass through.
|
||||
if (c.chunk_source === 'fenced_code') {
|
||||
return c.chunk_text;
|
||||
}
|
||||
|
||||
// Acquire rate-lease per chunk (D26 P0-3). Inline callers pass no
|
||||
// hooks; only the Minion handler wires through rate-leases.ts.
|
||||
let lease: unknown;
|
||||
let leaseAcquired = false;
|
||||
let synopsisResult: GeneratePerChunkSynopsisResult;
|
||||
try {
|
||||
if (args.acquireSynopsisLease) {
|
||||
try {
|
||||
lease = await args.acquireSynopsisLease();
|
||||
} catch (err) {
|
||||
if (args.abortSignal?.aborted || isAbortError(err)) {
|
||||
throw new ChunkSynopsisPhase1Error({
|
||||
kind: 'transient',
|
||||
cause: 'timeout',
|
||||
detail: 'aborted',
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
leaseAcquired = true;
|
||||
}
|
||||
synopsisResult = await generatePerChunkSynopsis({
|
||||
documentText: sourceText,
|
||||
chunkText: c.chunk_text,
|
||||
pageTitle: page.title,
|
||||
pageSlug: args.pageSlug,
|
||||
sourceId: args.sourceId,
|
||||
chunkIndex: c.chunk_index,
|
||||
model: haikuModel,
|
||||
abortSignal: args.abortSignal,
|
||||
});
|
||||
} finally {
|
||||
if (leaseAcquired && args.releaseSynopsisLease) {
|
||||
try {
|
||||
await args.releaseSynopsisLease(lease);
|
||||
} catch {
|
||||
// Lease release failure shouldn't abort the page; surfacing it
|
||||
// would race with the synopsis result. Audit-only.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (synopsisResult.kind === 'success') {
|
||||
const prefix = buildContextualPrefix(safeTitle, synopsisResult.synopsis);
|
||||
return wrapChunkForEmbedding(c.chunk_text, prefix, c.chunk_source);
|
||||
}
|
||||
|
||||
// Failure classification per D27 P1-2:
|
||||
// refusal | empty | malformed → page-level fall-back to title-only
|
||||
// auth_failure → permanent (won't fix with retry)
|
||||
// rate_limit | timeout | network | provider_5xx → transient
|
||||
// source_missing → walked into fallback already; would be 'malformed'
|
||||
// from generatePerChunkSynopsis if we ever propagated it here
|
||||
if (
|
||||
synopsisResult.kind === 'refusal' ||
|
||||
synopsisResult.kind === 'empty' ||
|
||||
synopsisResult.kind === 'malformed'
|
||||
) {
|
||||
throw new ChunkSynopsisPhase1Error({
|
||||
kind: 'page_level_fallback_requested',
|
||||
cause: synopsisResult.kind,
|
||||
});
|
||||
}
|
||||
if (synopsisResult.kind === 'auth_failure') {
|
||||
throw new ChunkSynopsisPhase1Error({
|
||||
kind: 'permanent',
|
||||
cause: synopsisResult.kind,
|
||||
detail: synopsisResult.detail ?? 'auth failure',
|
||||
});
|
||||
}
|
||||
throw new ChunkSynopsisPhase1Error({
|
||||
kind: 'transient',
|
||||
cause: synopsisResult.kind,
|
||||
detail: synopsisResult.detail ?? 'transient',
|
||||
});
|
||||
}
|
||||
|
||||
function isAbortError(err: unknown): boolean {
|
||||
return (
|
||||
typeof err === 'object' &&
|
||||
err !== null &&
|
||||
(err as { name?: unknown }).name === 'AbortError'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Source-text fallback chain per D11:
|
||||
* 1. read page.source_path from disk (truest "document")
|
||||
|
||||
@@ -1218,11 +1218,21 @@ export interface BrainEngine {
|
||||
*
|
||||
* Uses the `%` trigram operator (GIN-indexed) + the standard `similarity()`
|
||||
* function. Both engines support pg_trgm (PGLite 0.3+, Postgres always).
|
||||
*
|
||||
* `sourceId` constrains the search to a single source and filters out
|
||||
* soft-deleted pages. Mirrors the same filters `tryFuzzyMatch` in
|
||||
* `src/core/entities/resolve.ts` got via #1436 (v0.41.13.0). Omit for the
|
||||
* historical unscoped behavior — live-mode callers that already know
|
||||
* the source should pass it to avoid cross-source slug suggestions that
|
||||
* get silently dropped at the FK filter downstream. Batch-mode callers
|
||||
* (e.g. `gbrain extract`) intentionally omit it to build a cross-source
|
||||
* resolution map.
|
||||
*/
|
||||
findByTitleFuzzy(
|
||||
name: string,
|
||||
dirPrefix?: string,
|
||||
minSimilarity?: number,
|
||||
sourceId?: string,
|
||||
): Promise<{ slug: string; similarity: number } | null>;
|
||||
/**
|
||||
* v0.34.1 (#861 — P0 leak seal): `opts.sourceId` / `opts.sourceIds`
|
||||
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
} from './embedding-context.ts';
|
||||
import { loadSearchModeConfig, resolveSearchMode } from './search/mode.ts';
|
||||
import { normalizeAliasList } from './search/alias-normalize.ts';
|
||||
import { isUndefinedTableError, warnOncePerProcess } from './utils.ts';
|
||||
import { isUndefinedTableError, validateSlug, warnOncePerProcess } from './utils.ts';
|
||||
import { computeCorpusGeneration } from './contextual-retrieval-service.ts';
|
||||
import { runGuardrails } from './guardrails.ts';
|
||||
|
||||
@@ -314,6 +314,7 @@ export async function importFromContent(
|
||||
};
|
||||
}
|
||||
|
||||
slug = validateSlug(slug);
|
||||
const parsed = parseMarkdown(content, slug + '.md', { activePack: opts.activePack });
|
||||
|
||||
// v0.42 (#1699 trust boundary): strip gate-owned markers from UNTRUSTED
|
||||
|
||||
@@ -980,10 +980,14 @@ export function makeResolver(
|
||||
|
||||
// Step 3: pg_trgm fuzzy title match — both modes. Tries each hint in
|
||||
// order; first hint with a ≥0.55 similarity match wins. If no hints,
|
||||
// try the whole pages table.
|
||||
// try the whole pages table. When opts.sourceId is set, the fuzzy
|
||||
// search is constrained to that source (and skips soft-deleted pages)
|
||||
// so cross-source slug suggestions don't get silently dropped at the
|
||||
// FK filter downstream. Mirrors the same scope fix `tryFuzzyMatch` got
|
||||
// via #1436.
|
||||
const searchHints = hints.length > 0 ? hints : [undefined];
|
||||
for (const hint of searchHints) {
|
||||
const match = await engine.findByTitleFuzzy(trimmed, hint, 0.55);
|
||||
const match = await engine.findByTitleFuzzy(trimmed, hint, 0.55, opts.sourceId);
|
||||
if (match) {
|
||||
cache.set(cacheKey, match.slug);
|
||||
return match.slug;
|
||||
|
||||
@@ -40,6 +40,7 @@ import { UnrecoverableError } from '../types.ts';
|
||||
import type { BrainEngine } from '../../engine.ts';
|
||||
import {
|
||||
reembedPageWithContextualRetrieval,
|
||||
resolveContextualChunkConcurrency,
|
||||
type ReembedPageResult,
|
||||
} from '../../contextual-retrieval-service.ts';
|
||||
import {
|
||||
@@ -132,7 +133,7 @@ export function makeContextualReindexHandler(opts: MakeContextualReindexHandlerO
|
||||
// call inside the service acquires/releases a lease against the
|
||||
// shared key across all worker processes.
|
||||
const maxConcurrent = resolveMaxConcurrent();
|
||||
let currentLeaseId: number | null = null;
|
||||
const chunkConcurrency = resolveContextualChunkConcurrency();
|
||||
|
||||
const result: ReembedPageResult = await reembedPageWithContextualRetrieval({
|
||||
engine,
|
||||
@@ -141,32 +142,32 @@ export function makeContextualReindexHandler(opts: MakeContextualReindexHandlerO
|
||||
globalMode,
|
||||
killSwitchDisabled,
|
||||
abortSignal: ctx.signal,
|
||||
chunkConcurrency,
|
||||
acquireSynopsisLease: async () => {
|
||||
// Poll-acquire with brief backoff. The service's per-chunk loop
|
||||
// is sequential within a page; this guards against the cross-
|
||||
// worker pile-up.
|
||||
// is bounded within a page; this guards against the cross-worker
|
||||
// pile-up and remains the global rate governor.
|
||||
let attempts = 0;
|
||||
const maxAttempts = 60; // ~1 min max wait per chunk before giving up
|
||||
while (attempts < maxAttempts) {
|
||||
if (ctx.signal.aborted) throw abortError();
|
||||
const res = await acquireLease(engine, RATE_LEASE_KEY, ctx.id, maxConcurrent, {
|
||||
ttlMs: 60_000,
|
||||
});
|
||||
if (res.acquired && res.leaseId != null) {
|
||||
currentLeaseId = res.leaseId;
|
||||
return;
|
||||
return res.leaseId;
|
||||
}
|
||||
attempts++;
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
await sleepWithAbort(1000, ctx.signal);
|
||||
}
|
||||
throw new Error(
|
||||
`Failed to acquire ${RATE_LEASE_KEY} lease after ${maxAttempts} attempts; ` +
|
||||
`Haiku rate limit pile-up too deep.`,
|
||||
);
|
||||
},
|
||||
releaseSynopsisLease: async () => {
|
||||
if (currentLeaseId != null) {
|
||||
await releaseLease(engine, currentLeaseId);
|
||||
currentLeaseId = null;
|
||||
releaseSynopsisLease: async (lease) => {
|
||||
if (typeof lease === 'number') {
|
||||
await releaseLease(engine, lease);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -218,6 +219,26 @@ async function tryLoadPageAcrossSources(
|
||||
return null;
|
||||
}
|
||||
|
||||
function sleepWithAbort(ms: number, signal: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal.aborted) {
|
||||
reject(abortError());
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(resolve, ms);
|
||||
signal.addEventListener('abort', () => {
|
||||
clearTimeout(timer);
|
||||
reject(abortError());
|
||||
}, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function abortError(): Error {
|
||||
const err = new Error('aborted');
|
||||
err.name = 'AbortError';
|
||||
return err;
|
||||
}
|
||||
|
||||
function classifyResult(
|
||||
pageSlug: string,
|
||||
result: ReembedPageResult,
|
||||
|
||||
@@ -87,6 +87,11 @@ function walkMarkdownAndMdxFiles(
|
||||
for (const entry of entries) {
|
||||
if (truncated) return;
|
||||
if (entry.startsWith('.')) continue;
|
||||
// Skip heavy non-content dirs so the walk doesn't exhaust the time
|
||||
// budget on dependency/build trees (node_modules can be 50k+ files
|
||||
// with zero .md). These are never gbrain page sources.
|
||||
if (entry === 'node_modules' || entry === 'dist' || entry === 'build' ||
|
||||
entry === '.next' || entry === 'vendor' || entry === 'target') continue;
|
||||
const full = join(d, entry);
|
||||
let isDir = false;
|
||||
try {
|
||||
@@ -95,6 +100,9 @@ function walkMarkdownAndMdxFiles(
|
||||
continue;
|
||||
}
|
||||
if (isDir) {
|
||||
// Time check on directory descent too, so a deep dependency-free
|
||||
// tree still respects the deadline even before any .md is found.
|
||||
if (Date.now() >= deadlineMs) { truncated = true; return; }
|
||||
walk(full);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1153,7 +1153,11 @@ async function runAutoLink(
|
||||
|
||||
// Live-mode resolver: per-put throwaway cache, pg_trgm + optional search.
|
||||
// Issue #972 (codex [P1]): pass sourceId so basename resolution stays
|
||||
// within this page's source — no cross-source basename edges.
|
||||
// within this page's source — no cross-source basename edges. Also scopes
|
||||
// the fuzzy fallback (findByTitleFuzzy) to the same source the put_page is
|
||||
// targeting — without it, cross-source slug suggestions get silently dropped
|
||||
// at the FK filter and the link looks like it failed to resolve. Twin of
|
||||
// #1436's `tryFuzzyMatch` fix.
|
||||
const resolver = makeResolver(engine, { mode: 'live', sourceId: opts?.sourceId });
|
||||
// Issue #972: opt-in bare-wikilink basename resolution. Off by default.
|
||||
const globalBasename = await isGlobalBasenameEnabled(engine);
|
||||
|
||||
@@ -20,11 +20,34 @@
|
||||
*/
|
||||
|
||||
import { realpathSync, existsSync, type Stats } from 'fs';
|
||||
import * as nodePath from 'path';
|
||||
import { resolve as resolvePath, relative, isAbsolute, dirname, basename, join } from 'path';
|
||||
|
||||
/**
|
||||
* Pure containment predicate over ALREADY-resolved paths: true iff `child`
|
||||
* IS `parent` or lives under it. Separator-agnostic via `path.relative`.
|
||||
*
|
||||
* #3057: the previous `startsWith(parent + '/')` form is false for EVERY
|
||||
* path on Windows — `realpathSync` returns backslash separators there — so
|
||||
* every containment check built on it failed closed and blocked sync
|
||||
* entirely (same separator class as #2828/#2836). `pathMod` is injectable
|
||||
* so POSIX CI can pin the win32 semantics with `path.win32`.
|
||||
*/
|
||||
export function isResolvedContained(
|
||||
child: string,
|
||||
parent: string,
|
||||
pathMod: Pick<typeof nodePath, 'relative' | 'isAbsolute' | 'sep'> = nodePath,
|
||||
): boolean {
|
||||
const rel = pathMod.relative(parent, child);
|
||||
// '' = same path; '..' or '../…' = escapes upward; absolute = different
|
||||
// root entirely (e.g. another drive on Windows, where relative() returns
|
||||
// the child verbatim).
|
||||
return rel === '' || (rel !== '..' && !rel.startsWith('..' + pathMod.sep) && !pathMod.isAbsolute(rel));
|
||||
}
|
||||
|
||||
/**
|
||||
* Symlink-safe path confinement: realpath BOTH sides, then a separator-aware
|
||||
* prefix check. A plain `startsWith()` on un-resolved paths would let a
|
||||
* containment check. A plain `startsWith()` on un-resolved paths would let a
|
||||
* `parent/skills` symlink → `/etc` (or `$GBRAIN_HOME/clones/<id>` → `/etc`)
|
||||
* bypass the boundary; resolving first defeats that.
|
||||
*
|
||||
@@ -41,9 +64,7 @@ export function isPathContained(child: string, parent: string): boolean {
|
||||
} catch {
|
||||
return false; // missing / unresolvable path → not contained
|
||||
}
|
||||
// Append a separator so /foo doesn't match /foobar.
|
||||
const parentWithSep = resolvedParent.endsWith('/') ? resolvedParent : resolvedParent + '/';
|
||||
return resolvedChild === resolvedParent || resolvedChild.startsWith(parentWithSep);
|
||||
return isResolvedContained(resolvedChild, resolvedParent);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+46
-11
@@ -1060,6 +1060,16 @@ export class PGLiteEngine implements BrainEngine {
|
||||
RETURNING id, source_id, slug, type, title, compiled_truth, timeline, frontmatter, content_hash, created_at, updated_at, effective_date, effective_date_source, import_filename, source_kind, source_uri, ingested_via, ingested_at`,
|
||||
[sourceId, slug, page.type, pageKind, page.title, page.compiled_truth, page.timeline || '', JSON.stringify(frontmatter), hash, effectiveDate, effectiveDateSource, importFilename, chunkerVersion, sourcePath, sourceKind, sourceUri, ingestedVia, ingestedAt]
|
||||
);
|
||||
// PGLite can return zero rows from INSERT ... ON CONFLICT DO UPDATE ...
|
||||
// RETURNING in no-op/trigger edge cases, which made rowToPage(undefined)
|
||||
// throw "undefined is not an object (evaluating 'row.deleted_at')" and
|
||||
// skip the file during sync. The row WAS written, so re-read instead of
|
||||
// crashing.
|
||||
if (rows.length === 0) {
|
||||
const reread = await this.getPage(slug, { sourceId });
|
||||
if (reread) return reread;
|
||||
throw new Error(`putPage: RETURNING produced no row for ${sourceId}/${slug}`);
|
||||
}
|
||||
return rowToPage(rows[0] as Record<string, unknown>);
|
||||
}
|
||||
|
||||
@@ -2912,22 +2922,41 @@ export class PGLiteEngine implements BrainEngine {
|
||||
name: string,
|
||||
dirPrefix?: string,
|
||||
minSimilarity: number = 0.55,
|
||||
sourceId?: string,
|
||||
): Promise<{ slug: string; similarity: number } | null> {
|
||||
// Inline threshold comparison instead of `SET LOCAL pg_trgm.similarity_threshold`.
|
||||
// The GUC only scopes to the current transaction and pglite auto-commits each
|
||||
// .query() call, so the SET LOCAL would be a no-op. Using similarity() >= $N
|
||||
// directly gives predictable behavior. Tie-breaker: sort by slug so re-runs
|
||||
// pick the same winner.
|
||||
//
|
||||
// `sourceId` + `deleted_at IS NULL` mirror the filters `tryFuzzyMatch` in
|
||||
// `src/core/entities/resolve.ts` got via #1436 (v0.41.13.0). Without them,
|
||||
// fuzzy resolution could suggest cross-source slugs that the caller then
|
||||
// silently drops at the FK filter — making it look like the match failed
|
||||
// when in fact it picked the wrong page.
|
||||
const prefixPattern = dirPrefix ? `${dirPrefix}/%` : '%';
|
||||
const { rows } = await this.db.query(
|
||||
`SELECT slug, similarity(title, $1) AS sim
|
||||
FROM pages
|
||||
WHERE similarity(title, $1) >= $3
|
||||
AND slug LIKE $2
|
||||
ORDER BY sim DESC, slug ASC
|
||||
LIMIT 1`,
|
||||
[name, prefixPattern, minSimilarity]
|
||||
);
|
||||
const { rows } = sourceId
|
||||
? await this.db.query(
|
||||
`SELECT slug, similarity(title, $1) AS sim
|
||||
FROM pages
|
||||
WHERE similarity(title, $1) >= $3
|
||||
AND slug LIKE $2
|
||||
AND source_id = $4
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY sim DESC, slug ASC
|
||||
LIMIT 1`,
|
||||
[name, prefixPattern, minSimilarity, sourceId]
|
||||
)
|
||||
: await this.db.query(
|
||||
`SELECT slug, similarity(title, $1) AS sim
|
||||
FROM pages
|
||||
WHERE similarity(title, $1) >= $3
|
||||
AND slug LIKE $2
|
||||
ORDER BY sim DESC, slug ASC
|
||||
LIMIT 1`,
|
||||
[name, prefixPattern, minSimilarity]
|
||||
);
|
||||
if (rows.length === 0) return null;
|
||||
const row = rows[0] as { slug: string; sim: number };
|
||||
return { slug: row.slug, similarity: row.sim };
|
||||
@@ -5207,7 +5236,7 @@ export class PGLiteEngine implements BrainEngine {
|
||||
// dashboard, v0.10.3 metrics give entity-page-level granularity.
|
||||
const { rows: [h] } = await this.db.query(`
|
||||
WITH entity_pages AS (
|
||||
SELECT id, slug FROM pages WHERE type IN ('person', 'company')
|
||||
SELECT id, slug FROM pages WHERE type IN ('entity', 'person', 'company')
|
||||
)
|
||||
SELECT
|
||||
(SELECT count(*) FROM pages) as page_count,
|
||||
@@ -5236,7 +5265,7 @@ export class PGLiteEngine implements BrainEngine {
|
||||
SELECT p.slug,
|
||||
(SELECT count(*) FROM links l WHERE l.from_page_id = p.id OR l.to_page_id = p.id)::int as link_count
|
||||
FROM pages p
|
||||
WHERE p.type IN ('person', 'company')
|
||||
WHERE p.type IN ('entity', 'person', 'company')
|
||||
ORDER BY link_count DESC
|
||||
LIMIT 5
|
||||
`);
|
||||
@@ -5837,6 +5866,11 @@ export class PGLiteEngine implements BrainEngine {
|
||||
params.push(escaped);
|
||||
prefixCondition = `AND p.slug LIKE $${params.length} ESCAPE '\\'`;
|
||||
}
|
||||
// TIM-37: exclude briefing pages from their own Brain Pulse. See the
|
||||
// matching block in postgres-engine.ts getRecentSalience() for context.
|
||||
const excludeBriefings = !(slugPrefix && slugPrefix.startsWith('briefings'))
|
||||
? `AND p.slug NOT LIKE 'briefings/%'`
|
||||
: '';
|
||||
params.push(limit);
|
||||
const limitParam = `$${params.length}`;
|
||||
|
||||
@@ -5872,6 +5906,7 @@ export class PGLiteEngine implements BrainEngine {
|
||||
LEFT JOIN takes t ON t.page_id = p.id AND t.active = TRUE
|
||||
WHERE GREATEST(p.updated_at, COALESCE(p.salience_touched_at, p.updated_at)) >= $1::timestamptz
|
||||
${prefixCondition}
|
||||
${excludeBriefings}
|
||||
GROUP BY p.id
|
||||
ORDER BY score DESC
|
||||
LIMIT ${limitParam}`,
|
||||
|
||||
+31
-16
@@ -108,6 +108,35 @@ function isProcessAlive(pid: number): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function formatLockTimestamp(value: unknown): string {
|
||||
return typeof value === 'number' && Number.isFinite(value)
|
||||
? new Date(value).toISOString()
|
||||
: 'unknown time';
|
||||
}
|
||||
|
||||
function pgliteLockTimeoutError(lockDir: string): Error {
|
||||
const lockPath = join(lockDir, LOCK_FILE);
|
||||
try {
|
||||
const lockData = JSON.parse(readFileSync(lockPath, 'utf-8'));
|
||||
const pid = String(lockData.pid ?? 'unknown');
|
||||
const command = String(lockData.command ?? 'unknown');
|
||||
const serveHint = command.includes('gbrain serve')
|
||||
? ' The holder looks like `gbrain serve`, so this is probably serve↔sync contention from an MCP/HTTP server; stop that server/client and rerun the command.'
|
||||
: '';
|
||||
|
||||
return new Error(
|
||||
`GBrain: Timed out waiting for PGLite data-dir lock. Process ${pid} has held it since ${formatLockTimestamp(lockData.acquired_at)} (command: ${command}). ` +
|
||||
`Lock directory: ${lockDir}. If that process is dead, remove the lock directory and try again. ` +
|
||||
`This is a PGLite data-dir lock, not the \`gbrain-sync:*\` advisory lock; \`gbrain sync --break-lock\` will not clear a live PGLite holder.` +
|
||||
serveHint,
|
||||
);
|
||||
} catch {
|
||||
return new Error(
|
||||
`GBrain: Timed out waiting for PGLite lock. Remove ${lockDir} and try again.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to acquire an exclusive lock on the PGLite data directory.
|
||||
* Returns { acquired: true } if the lock was obtained, { acquired: false } otherwise.
|
||||
@@ -177,28 +206,14 @@ export async function acquireLock(dataDir: string | undefined, opts?: { timeoutM
|
||||
// mkdir failed — someone else grabbed it between our check and mkdir
|
||||
// This is fine, we'll retry
|
||||
if (Date.now() - startTime >= timeoutMs) {
|
||||
// Timeout — report which process holds the lock
|
||||
const lockPath = join(lockDir, LOCK_FILE);
|
||||
try {
|
||||
const lockData = JSON.parse(readFileSync(lockPath, 'utf-8'));
|
||||
throw new Error(
|
||||
`GBrain: Timed out waiting for PGLite lock. Process ${lockData.pid} has held it since ${new Date(lockData.acquired_at).toISOString()} (command: ${lockData.command}). ` +
|
||||
`If that process is dead, remove ${lockDir} and try again.`
|
||||
);
|
||||
} catch (readErr) {
|
||||
if (readErr instanceof Error && readErr.message.startsWith('GBrain')) throw readErr;
|
||||
throw new Error(
|
||||
`GBrain: Timed out waiting for PGLite lock. Remove ${lockDir} and try again.`
|
||||
);
|
||||
}
|
||||
throw pgliteLockTimeoutError(lockDir);
|
||||
}
|
||||
// Brief wait before retry
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
}
|
||||
|
||||
// Should not reach here, but just in case
|
||||
throw new Error(`GBrain: Timed out waiting for PGLite lock.`);
|
||||
throw pgliteLockTimeoutError(lockDir);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+37
-10
@@ -3082,6 +3082,7 @@ export class PostgresEngine implements BrainEngine {
|
||||
name: string,
|
||||
dirPrefix?: string,
|
||||
minSimilarity: number = 0.55,
|
||||
sourceId?: string,
|
||||
): Promise<{ slug: string; similarity: number } | null> {
|
||||
const sql = this.sql;
|
||||
// Use the `similarity()` function directly with an explicit threshold
|
||||
@@ -3094,15 +3095,33 @@ export class PostgresEngine implements BrainEngine {
|
||||
// Tie-breaker: sort by slug after similarity so re-runs return the
|
||||
// same winner when multiple pages score equally (prevents churn
|
||||
// in put_page auto-link reconciliation).
|
||||
//
|
||||
// `sourceId` + `deleted_at IS NULL` mirror the filters `tryFuzzyMatch`
|
||||
// in `src/core/entities/resolve.ts` got via #1436 (v0.41.13.0). Without
|
||||
// them, fuzzy resolution could suggest cross-source slugs that the
|
||||
// caller then silently drops at the FK filter in
|
||||
// `operations.ts:reconcileLinks` (the `allSlugs` filter) — making it
|
||||
// look like the match failed when in fact it picked the wrong page.
|
||||
const prefixPattern = dirPrefix ? `${dirPrefix}/%` : '%';
|
||||
const rows = await sql`
|
||||
SELECT slug, similarity(title, ${name}) AS sim
|
||||
FROM pages
|
||||
WHERE similarity(title, ${name}) >= ${minSimilarity}
|
||||
AND slug LIKE ${prefixPattern}
|
||||
ORDER BY sim DESC, slug ASC
|
||||
LIMIT 1
|
||||
`;
|
||||
const rows = sourceId
|
||||
? await sql`
|
||||
SELECT slug, similarity(title, ${name}) AS sim
|
||||
FROM pages
|
||||
WHERE similarity(title, ${name}) >= ${minSimilarity}
|
||||
AND slug LIKE ${prefixPattern}
|
||||
AND source_id = ${sourceId}
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY sim DESC, slug ASC
|
||||
LIMIT 1
|
||||
`
|
||||
: await sql`
|
||||
SELECT slug, similarity(title, ${name}) AS sim
|
||||
FROM pages
|
||||
WHERE similarity(title, ${name}) >= ${minSimilarity}
|
||||
AND slug LIKE ${prefixPattern}
|
||||
ORDER BY sim DESC, slug ASC
|
||||
LIMIT 1
|
||||
`;
|
||||
if (rows.length === 0) return null;
|
||||
const row = rows[0] as { slug: string; sim: number };
|
||||
return { slug: row.slug, similarity: row.sim };
|
||||
@@ -5327,7 +5346,7 @@ export class PostgresEngine implements BrainEngine {
|
||||
// dashboard health.
|
||||
const [h] = await sql`
|
||||
WITH entity_pages AS (
|
||||
SELECT id, slug FROM pages WHERE type IN ('person', 'company')
|
||||
SELECT id, slug FROM pages WHERE type IN ('entity', 'person', 'company')
|
||||
)
|
||||
SELECT
|
||||
(SELECT count(*) FROM pages) as page_count,
|
||||
@@ -5353,7 +5372,7 @@ export class PostgresEngine implements BrainEngine {
|
||||
SELECT p.slug,
|
||||
(SELECT count(*) FROM links l WHERE l.from_page_id = p.id OR l.to_page_id = p.id)::int as link_count
|
||||
FROM pages p
|
||||
WHERE p.type IN ('person', 'company')
|
||||
WHERE p.type IN ('entity', 'person', 'company')
|
||||
ORDER BY link_count DESC
|
||||
LIMIT 5
|
||||
`;
|
||||
@@ -6153,6 +6172,13 @@ export class PostgresEngine implements BrainEngine {
|
||||
const prefixCondition = slugPrefix
|
||||
? sql`AND p.slug LIKE ${slugPrefix.replace(/[\\%_]/g, (c) => '\\' + c) + '%'} ESCAPE '\\'`
|
||||
: sql``;
|
||||
// TIM-37: exclude briefing pages from their own Brain Pulse. The cron
|
||||
// briefing writes to 90_Briefings/, gets re-ingested, and would otherwise
|
||||
// top tomorrow's salience as pure self-reference. Suppress unless the
|
||||
// caller explicitly asked for the briefings/ prefix.
|
||||
const excludeBriefings = !(slugPrefix && slugPrefix.startsWith('briefings'))
|
||||
? sql`AND p.slug NOT LIKE 'briefings/%'`
|
||||
: sql``;
|
||||
// v0.29.1: third score term via buildRecencyComponentSql. Default
|
||||
// 'flat' = v0.29.0 behavior (1 / (1 + days_old)). 'on' opts into the
|
||||
// per-prefix decay map (concepts/ evergreen, daily/ aggressive, etc.).
|
||||
@@ -6186,6 +6212,7 @@ export class PostgresEngine implements BrainEngine {
|
||||
LEFT JOIN takes t ON t.page_id = p.id AND t.active = TRUE
|
||||
WHERE GREATEST(p.updated_at, COALESCE(p.salience_touched_at, p.updated_at)) >= ${boundaryIso}::timestamptz
|
||||
${prefixCondition}
|
||||
${excludeBriefings}
|
||||
GROUP BY p.id
|
||||
ORDER BY score DESC
|
||||
LIMIT ${limit}
|
||||
|
||||
+35
-1
@@ -553,6 +553,40 @@ export async function runThink(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip a "## Gaps" section from an answer body.
|
||||
*
|
||||
* `think` returns gaps in the structured `gaps` array, which the CLI and the
|
||||
* persisted synthesis page render exactly once. The system prompt also used to
|
||||
* ask for a "Gaps" section inside the answer prose, so a model that still emits
|
||||
* one would make the output show "## Gaps" twice — once from the prose, once
|
||||
* from the structured array. This removes the prose section so the structured
|
||||
* array stays the single source of truth.
|
||||
*
|
||||
* Matches a heading line `## Gaps` (level 2-6, case-insensitive) and removes it
|
||||
* through the next heading of the same-or-higher level, or end of string.
|
||||
* Returns the input unchanged when there is no such section.
|
||||
*/
|
||||
export function stripGapsSection(answer: string): string {
|
||||
if (!answer) return answer;
|
||||
const lines = answer.split('\n');
|
||||
let start = -1;
|
||||
let level = 0;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const m = /^(#{2,6})\s+gaps\s*$/i.exec(lines[i]);
|
||||
if (m) { start = i; level = m[1].length; break; }
|
||||
}
|
||||
if (start === -1) return answer;
|
||||
let end = lines.length;
|
||||
for (let i = start + 1; i < lines.length; i++) {
|
||||
const h = /^(#{1,6})\s+\S/.exec(lines[i]);
|
||||
if (h && h[1].length <= level) { end = i; break; }
|
||||
}
|
||||
const kept = [...lines.slice(0, start), ...lines.slice(end)].join('\n');
|
||||
// Drop trailing blank lines left by removing a trailing section.
|
||||
return kept.replace(/\s+$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a synthesis page + its evidence. Returns the saved slug.
|
||||
* Synthesis pages are written under `synthesis/<slugified-question>-<date>.md`.
|
||||
@@ -582,7 +616,7 @@ export async function persistSynthesis(
|
||||
const body = [
|
||||
`# ${result.question}`,
|
||||
'',
|
||||
result.answer,
|
||||
stripGapsSection(result.answer),
|
||||
'',
|
||||
result.gaps.length > 0 ? '## Gaps\n\n' + result.gaps.map(g => `- ${g}`).join('\n') : '',
|
||||
].filter(Boolean).join('\n');
|
||||
|
||||
@@ -52,19 +52,19 @@ Hard rules:
|
||||
rather than asserting it as established. Confidence is part of the data.
|
||||
- If two takes contradict (different holders, opposite claims), surface BOTH in a "Conflicts"
|
||||
section. Never silently pick one.
|
||||
- If you cannot answer because the brain doesn't contain the relevant data, say so in the
|
||||
"Gaps" section. List the specific missing pieces. Do not make up answers.
|
||||
- If the brain doesn't contain data needed to answer, do NOT make it up. Record each
|
||||
missing piece in the structured "gaps" array (below), not as a section in the answer prose.
|
||||
- Never instruct the user (no "you should" / "I recommend X"). The brain reports; the user decides.
|
||||
- Output MUST be valid JSON matching the schema below. No prose outside JSON.
|
||||
|
||||
Output schema:
|
||||
{
|
||||
"answer": "<markdown body. Inline citations like [slug#row] or [slug]. Sections: Answer, Conflicts (optional), Gaps>",
|
||||
"answer": "<markdown body. Inline citations like [slug#row] or [slug]. Sections: Answer, Conflicts (optional). Do NOT add a Gaps section here — gaps belong in the gaps array.>",
|
||||
"citations": [
|
||||
{"page_slug": "people/alice-example", "row_num": 3, "citation_index": 1},
|
||||
{"page_slug": "companies/acme-example", "row_num": null, "citation_index": 2}
|
||||
],
|
||||
"gaps": ["specific missing data point 1", "specific missing data point 2"]
|
||||
"gaps": ["a specific, self-contained missing-or-stale data point, citing the [slug] where relevant", "another specific gap"]
|
||||
}
|
||||
|
||||
The "row_num" field is required for take citations and MUST be null for page-only citations.`;
|
||||
@@ -83,7 +83,7 @@ export function buildThinkSystemPrompt(opts: ThinkSystemPromptOpts = {}): string
|
||||
lines.push(`\nThis is a temporal question. Order key claims chronologically when it helps the reader.`);
|
||||
}
|
||||
if (opts.willSave) {
|
||||
lines.push(`\nThis synthesis will be persisted as a brain page. Aim for completeness — cover Answer, Conflicts, and Gaps thoroughly.`);
|
||||
lines.push(`\nThis synthesis will be persisted as a brain page. Aim for completeness — cover the Answer and any Conflicts thoroughly, and list every missing piece in the structured "gaps" array.`);
|
||||
}
|
||||
if (opts.withCalibration) {
|
||||
lines.push(
|
||||
@@ -92,7 +92,7 @@ export function buildThinkSystemPrompt(opts: ThinkSystemPromptOpts = {}): string
|
||||
lines.push(`- Name both the user's PRIOR (default reasoning) AND the COUNTER-PRIOR from their hedged-domain self.`);
|
||||
lines.push(`- Reference active bias tags by name when relevant ("this fits the over-confident-geography pattern").`);
|
||||
lines.push(`- Do NOT silently substitute the debiased answer. ALWAYS surface both priors transparently.`);
|
||||
lines.push(`- Track-record sentences belong in a "Calibration" section in the answer body, between Conflicts and Gaps.`);
|
||||
lines.push(`- Track-record sentences belong in a "Calibration" section in the answer body, after the Conflicts section (if present).`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export interface TrajectoryRegression {
|
||||
from_date: string; // YYYY-MM-DD
|
||||
to_value: number;
|
||||
to_date: string;
|
||||
delta_pct: number; // negative for a drop; range typically [-1, 0)
|
||||
delta_pct: number; // negative for a numeric drop; may be < -1 across zero
|
||||
}
|
||||
|
||||
export interface TrajectoryStats {
|
||||
@@ -82,8 +82,10 @@ function cosineSim(a: Float32Array, b: Float32Array): number {
|
||||
*
|
||||
* Iterates per-metric (so trajectories that interleave mrr + arr + team_size
|
||||
* don't trip false regressions across metric boundaries). Within each metric,
|
||||
* walks consecutive value pairs; a pair fires when
|
||||
* `(newer - older) / older <= -threshold`.
|
||||
* walks consecutive value pairs; a pair fires when the newer value is lower
|
||||
* than the older value by at least the threshold. The relative delta uses
|
||||
* `abs(older)` as the denominator so negative-valued metrics (net income,
|
||||
* cash flow, etc.) do not invert improvement and regression.
|
||||
*
|
||||
* Pre-condition: caller passed points sorted by (valid_from ASC, fact_id ASC).
|
||||
* The engine's `findTrajectory` enforces this. No re-sort here.
|
||||
@@ -111,7 +113,7 @@ export function detectRegressions(
|
||||
// Guard against division-by-zero: a metric starting at exactly 0
|
||||
// can't compute a relative delta. Skip.
|
||||
if (oldVal === 0) continue;
|
||||
const delta = (newVal - oldVal) / oldVal;
|
||||
const delta = (newVal - oldVal) / Math.abs(oldVal);
|
||||
if (delta <= -threshold) {
|
||||
out.push({
|
||||
metric,
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
resetGateway,
|
||||
embed,
|
||||
splitByTokenBudget,
|
||||
capBatchItems,
|
||||
isTokenLimitError,
|
||||
__setEmbedTransportForTests,
|
||||
__getShrinkStateForTests,
|
||||
@@ -151,6 +152,41 @@ describe('splitByTokenBudget (pure helper)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('capBatchItems (hard COUNT cap helper)', () => {
|
||||
test('batch at or under the cap is returned as a single batch (no copy of contents)', () => {
|
||||
const texts = ['a', 'b', 'c'];
|
||||
expect(capBatchItems(texts, 3)).toEqual([texts]);
|
||||
expect(capBatchItems(texts, 10)).toEqual([texts]);
|
||||
});
|
||||
|
||||
test('oversized batch splits into chunks of at most maxItems', () => {
|
||||
const texts = Array.from({ length: 100 }, (_, i) => `t${i}`);
|
||||
const result = capBatchItems(texts, 32);
|
||||
expect(result.map(b => b.length)).toEqual([32, 32, 32, 4]);
|
||||
expect(result.every(b => b.length <= 32)).toBe(true);
|
||||
});
|
||||
|
||||
test('exact multiple splits evenly with no trailing empty batch', () => {
|
||||
const texts = Array.from({ length: 64 }, (_, i) => `t${i}`);
|
||||
expect(capBatchItems(texts, 32).map(b => b.length)).toEqual([32, 32]);
|
||||
});
|
||||
|
||||
test('order is preserved across the split (concatenation round-trips)', () => {
|
||||
const texts = Array.from({ length: 70 }, (_, i) => `t${i}`);
|
||||
expect(capBatchItems(texts, 32).flat()).toEqual(texts);
|
||||
});
|
||||
|
||||
test('maxItems <= 0 is a no-op (single batch) — never produces empty/infinite batches', () => {
|
||||
const texts = ['a', 'b', 'c'];
|
||||
expect(capBatchItems(texts, 0)).toEqual([texts]);
|
||||
expect(capBatchItems(texts, -5)).toEqual([texts]);
|
||||
});
|
||||
|
||||
test('empty input returns a single empty batch', () => {
|
||||
expect(capBatchItems([], 32)).toEqual([[]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isTokenLimitError (pure helper)', () => {
|
||||
test('matches Voyage error format', () => {
|
||||
expect(isTokenLimitError(VOYAGE_TOKEN_LIMIT_ERROR)).toBe(true);
|
||||
|
||||
@@ -28,8 +28,8 @@ describe('v0.32 #779: no_batch_cap suppresses the missing-max_batch_tokens warni
|
||||
resetGateway();
|
||||
});
|
||||
|
||||
test('Ollama, LiteLLM, llama-server all declare no_batch_cap: true', () => {
|
||||
for (const id of ['ollama', 'litellm', 'llama-server']) {
|
||||
test('Ollama, LiteLLM declare no_batch_cap: true', () => {
|
||||
for (const id of ['ollama', 'litellm']) {
|
||||
const r = getRecipe(id);
|
||||
expect(r, `${id} not registered`).toBeDefined();
|
||||
expect(
|
||||
@@ -39,6 +39,16 @@ describe('v0.32 #779: no_batch_cap suppresses the missing-max_batch_tokens warni
|
||||
}
|
||||
});
|
||||
|
||||
test('llama-server declares a hard item-count cap (max_batch_items: 32)', () => {
|
||||
// llama.cpp enforces a request-COUNT cap equal to its launch --batch-size
|
||||
// (default 32); declaring max_batch_items both bounds batches AND suppresses
|
||||
// the missing-max_batch_tokens warning. Replaces the prior no_batch_cap flag.
|
||||
const r = getRecipe('llama-server');
|
||||
expect(r, 'llama-server not registered').toBeDefined();
|
||||
expect(r!.touchpoints.embedding?.max_batch_items).toBe(32);
|
||||
expect(r!.touchpoints.embedding?.no_batch_cap).toBeUndefined();
|
||||
});
|
||||
|
||||
test('configureGateway does NOT warn for ollama/litellm/llama-server', () => {
|
||||
warnSpy.mockClear();
|
||||
resetGateway();
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* dashscope-rerank recipe smoke.
|
||||
*
|
||||
* Sibling of recipe-llama-server-reranker.test.ts. Pins the recipe shape so:
|
||||
* - id + tier + implementation + base_url stay byte-stable
|
||||
* - reranker touchpoint declares the PLURAL `/reranks` leaf (the whole
|
||||
* reason this recipe exists — DashScope's compatible-api surface 404s
|
||||
* on singular `/rerank`) + `default_timeout_ms`
|
||||
* - only live-verified models are listed (gte-rerank-v2 is native-API only
|
||||
* and rejected by the OpenAI-compat surface)
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { getRecipe } from '../../src/core/ai/recipes/index.ts';
|
||||
import { defaultResolveAuth } from '../../src/core/ai/gateway.ts';
|
||||
import { AIConfigError } from '../../src/core/ai/errors.ts';
|
||||
|
||||
describe('recipe: dashscope-rerank', () => {
|
||||
test('registered with expected shape', () => {
|
||||
const r = getRecipe('dashscope-rerank');
|
||||
expect(r).toBeDefined();
|
||||
expect(r!.id).toBe('dashscope-rerank');
|
||||
expect(r!.tier).toBe('openai-compat');
|
||||
expect(r!.implementation).toBe('openai-compatible');
|
||||
expect(r!.base_url_default).toBe(
|
||||
'https://dashscope-intl.aliyuncs.com/compatible-api/v1',
|
||||
);
|
||||
expect(r!.auth_env?.required).toEqual(['DASHSCOPE_API_KEY']);
|
||||
});
|
||||
|
||||
test('declares reranker touchpoint with PLURAL /reranks path + timeout', () => {
|
||||
const r = getRecipe('dashscope-rerank')!;
|
||||
const tp = r.touchpoints.reranker;
|
||||
expect(tp).toBeDefined();
|
||||
expect(tp!.path).toBe('/reranks');
|
||||
expect(tp!.default_timeout_ms).toBe(30_000);
|
||||
expect(tp!.max_payload_bytes).toBe(5_000_000);
|
||||
});
|
||||
|
||||
test('base_url + path concatenation produces /v1/reranks, NOT /v1/v1/…', () => {
|
||||
const r = getRecipe('dashscope-rerank')!;
|
||||
const combined =
|
||||
r.base_url_default!.replace(/\/$/, '') + (r.touchpoints.reranker!.path ?? '/models/rerank');
|
||||
expect(combined).toBe('https://dashscope-intl.aliyuncs.com/compatible-api/v1/reranks');
|
||||
expect(combined).not.toContain('/v1/v1/');
|
||||
expect(combined.endsWith('/reranks')).toBe(true);
|
||||
});
|
||||
|
||||
test('lists only the live-verified compat-surface model', () => {
|
||||
const r = getRecipe('dashscope-rerank')!;
|
||||
const tp = r.touchpoints.reranker!;
|
||||
expect(tp.models).toEqual(['qwen3-rerank']);
|
||||
expect(tp.default_model).toBe('qwen3-rerank');
|
||||
// gte-rerank-v2 is native-API only; the compat surface rejects it.
|
||||
expect(tp.models).not.toContain('gte-rerank-v2');
|
||||
});
|
||||
|
||||
test('default auth: DASHSCOPE_API_KEY set → Bearer token', () => {
|
||||
const r = getRecipe('dashscope-rerank')!;
|
||||
const auth = defaultResolveAuth(
|
||||
r,
|
||||
{ DASHSCOPE_API_KEY: 'sk-dashscope-fake' },
|
||||
'reranker',
|
||||
);
|
||||
expect(auth.headerName).toBe('Authorization');
|
||||
expect(auth.token).toBe('Bearer sk-dashscope-fake');
|
||||
});
|
||||
|
||||
test('default auth: missing DASHSCOPE_API_KEY → AIConfigError', () => {
|
||||
const r = getRecipe('dashscope-rerank')!;
|
||||
expect(() => defaultResolveAuth(r, {}, 'reranker')).toThrow(AIConfigError);
|
||||
});
|
||||
|
||||
test('does not perturb the sibling dashscope embedding recipe', () => {
|
||||
const emb = getRecipe('dashscope')!;
|
||||
expect(emb.base_url_default).toBe('https://dashscope-intl.aliyuncs.com/compatible-mode/v1');
|
||||
expect(emb.touchpoints.reranker).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Ollama Matryoshka dims passthrough.
|
||||
*
|
||||
* Several embedding models served via Ollama (Qwen3-Embedding family) support
|
||||
* Matryoshka truncation through the `dimensions` field on /v1/embeddings.
|
||||
* Without this passthrough, gbrain ignores user-selected reduced dims and the
|
||||
* provider returns its native size, causing dim-mismatch failures against
|
||||
* brains configured for smaller widths.
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { dimsProviderOptions } from '../../src/core/ai/dims.ts';
|
||||
|
||||
describe('dims: ollama Matryoshka models', () => {
|
||||
test('qwen3-embedding:4b threads dimensions=1536', () => {
|
||||
expect(dimsProviderOptions('openai-compatible', 'qwen3-embedding:4b', 1536))
|
||||
.toEqual({ openaiCompatible: { dimensions: 1536 } });
|
||||
});
|
||||
|
||||
test('qwen3-embedding:0.6b threads dimensions=512', () => {
|
||||
expect(dimsProviderOptions('openai-compatible', 'qwen3-embedding:0.6b', 512))
|
||||
.toEqual({ openaiCompatible: { dimensions: 512 } });
|
||||
});
|
||||
|
||||
test('qwen3-embedding:8b threads dimensions=2048', () => {
|
||||
expect(dimsProviderOptions('openai-compatible', 'qwen3-embedding:8b', 2048))
|
||||
.toEqual({ openaiCompatible: { dimensions: 2048 } });
|
||||
});
|
||||
|
||||
test('bare qwen3-embedding (no quant tag) also recognized', () => {
|
||||
expect(dimsProviderOptions('openai-compatible', 'qwen3-embedding', 1024))
|
||||
.toEqual({ openaiCompatible: { dimensions: 1024 } });
|
||||
});
|
||||
|
||||
test('unrelated openai-compat model returns undefined (regression guard)', () => {
|
||||
expect(dimsProviderOptions('openai-compatible', 'nomic-embed-text', 768))
|
||||
.toBeUndefined();
|
||||
expect(dimsProviderOptions('openai-compatible', 'mxbai-embed-large', 1024))
|
||||
.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Structural regression for the backlinks Minion handler default.
|
||||
*
|
||||
* Backlinks jobs submitted with an EMPTY payload (the sync→embed→backlinks
|
||||
* chains enqueued after every ingestion) must run as 'check', never 'fix'.
|
||||
* The pre-fix handler inverted the default (`=== 'check' ? 'check' : 'fix'`),
|
||||
* so every routine post-ingestion job rewrote tracked brain pages with
|
||||
* generated "Referenced in" timeline bullets — contradicting the documented
|
||||
* intent in src/core/cycle.ts (runPhaseBacklinks): "Maintenance cycles must
|
||||
* not rewrite tracked brain pages with generated 'Referenced in' timeline
|
||||
* bullets."
|
||||
*
|
||||
* Source-grep is the right tool here (see fix-wave-structural.test.ts): the
|
||||
* handler dynamically imports runBacklinksCore and walks a real repo dir, so
|
||||
* a behavioral test would require heavy mocking that hides the regression
|
||||
* behind a test seam. The rule is "this specific default must stay 'check'".
|
||||
*/
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { readFileSync } from 'fs';
|
||||
|
||||
describe('backlinks Minion handler — empty payload defaults to check, not fix', () => {
|
||||
const src = readFileSync('src/commands/jobs.ts', 'utf8');
|
||||
|
||||
// Isolate the backlinks register block so assertions can't accidentally
|
||||
// match another handler's action parsing.
|
||||
const blockMatch = src.match(
|
||||
/worker\.register\('backlinks',[\s\S]*?runBacklinksCore\(\{[\s\S]*?\}\);/
|
||||
);
|
||||
|
||||
test('the backlinks handler block exists', () => {
|
||||
expect(blockMatch).not.toBeNull();
|
||||
});
|
||||
|
||||
test("default action is 'check' (explicit opt-in required for 'fix')", () => {
|
||||
const block = blockMatch![0];
|
||||
expect(block).toMatch(
|
||||
/job\.data\.action\s*===\s*'fix'\s*\?\s*'fix'\s*:\s*'check'/
|
||||
);
|
||||
});
|
||||
|
||||
test('the inverted (fix-by-default) shape stays absent', () => {
|
||||
const block = blockMatch![0];
|
||||
expect(block).not.toMatch(
|
||||
/job\.data\.action\s*===\s*'check'\s*\?\s*'check'\s*:\s*'fix'/
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
checkResolvable,
|
||||
parseResolverEntries,
|
||||
extractDelegationTargets,
|
||||
extractTriggers,
|
||||
} from "../src/core/check-resolvable.ts";
|
||||
|
||||
const SKILLS_DIR = join(import.meta.dir, "..", "skills");
|
||||
@@ -195,6 +196,39 @@ describe("parseResolverEntries", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractTriggers", () => {
|
||||
const LF_FRONTMATTER =
|
||||
"---\nname: query\ndescription: Test\ntriggers:\n - \"what do we know\"\n - \"tell me about\"\ntools:\n - search\n---\n\n# Body\n";
|
||||
|
||||
test("parses triggers from LF-terminated frontmatter", () => {
|
||||
const triggers = extractTriggers(LF_FRONTMATTER);
|
||||
expect(triggers).toEqual(["what do we know", "tell me about"]);
|
||||
});
|
||||
|
||||
test("parses triggers from CRLF-terminated frontmatter (Windows checkouts)", () => {
|
||||
// Regression: `core.autocrlf=true` is the Windows default. Without
|
||||
// CRLF→LF normalization, every Windows skill is reported as a false
|
||||
// mece_gap warning because the `^---\n` regex never matches `---\r\n`.
|
||||
const crlf = LF_FRONTMATTER.replace(/\n/g, "\r\n");
|
||||
const triggers = extractTriggers(crlf);
|
||||
expect(triggers).toEqual(["what do we know", "tell me about"]);
|
||||
});
|
||||
|
||||
test("returns [] when frontmatter is missing", () => {
|
||||
expect(extractTriggers("# Just a body, no frontmatter\n")).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns [] when triggers field is absent from frontmatter", () => {
|
||||
const fm = "---\nname: query\ndescription: Test\ntools:\n - search\n---\n";
|
||||
expect(extractTriggers(fm)).toEqual([]);
|
||||
});
|
||||
|
||||
test("strips surrounding quotes from trigger values", () => {
|
||||
const fm = "---\nname: x\ntriggers:\n - \"double quoted\"\n - 'single quoted'\n - unquoted\n---\n";
|
||||
expect(extractTriggers(fm)).toEqual(["double quoted", "single quoted", "unquoted"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkResolvable — real skills directory", () => {
|
||||
const report = checkResolvable(SKILLS_DIR);
|
||||
|
||||
|
||||
@@ -8,13 +8,15 @@
|
||||
//
|
||||
// PGLite-only: in-memory engine, no DATABASE_URL needed.
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import { loadConfigWithEngine, type GBrainConfig } from '../src/core/config.ts';
|
||||
import {
|
||||
__setRerankTransportForTests,
|
||||
configureGateway,
|
||||
getEmbeddingModel,
|
||||
getMultimodalModel,
|
||||
rerank,
|
||||
resetGateway,
|
||||
} from '../src/core/ai/gateway.ts';
|
||||
import type { AIGatewayConfig } from '../src/core/ai/types.ts';
|
||||
@@ -52,10 +54,16 @@ afterAll(async () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
resetGateway();
|
||||
__setRerankTransportForTests(null);
|
||||
// Clear any prior config rows so tests are independent. setConfig with
|
||||
// empty string is treated as undefined by loadConfigWithEngine (per
|
||||
// dbStr semantics), so this is safe to call between tests.
|
||||
await engine.setConfig('embedding_multimodal_model', '');
|
||||
await engine.setConfig('provider_base_urls.llama-server-reranker', '');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
__setRerankTransportForTests(null);
|
||||
});
|
||||
|
||||
describe('cli connectEngine — embedding_multimodal_model DB→gateway plumbing', () => {
|
||||
@@ -122,4 +130,34 @@ describe('cli connectEngine — embedding_multimodal_model DB→gateway plumbing
|
||||
expect(getEmbeddingModel()).toBe('openai:text-embedding-3-large');
|
||||
expect(getMultimodalModel()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('DB-set provider_base_urls.llama-server-reranker flows to gateway.rerank URL', async () => {
|
||||
await engine.setConfig('provider_base_urls.llama-server-reranker', 'http://127.0.0.1:8091/v1');
|
||||
|
||||
const baseConfig: GBrainConfig = {
|
||||
engine: 'pglite',
|
||||
embedding_model: 'openai:text-embedding-3-large',
|
||||
embedding_dimensions: 1536,
|
||||
};
|
||||
|
||||
const merged = await loadConfigWithEngine(engine, baseConfig);
|
||||
configureGateway(buildGatewayConfig(merged!));
|
||||
|
||||
let capturedUrl = '';
|
||||
__setRerankTransportForTests(async (url) => {
|
||||
capturedUrl = url;
|
||||
return new Response(JSON.stringify({ results: [{ index: 0, relevance_score: 0.9 }] }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
await rerank({
|
||||
query: 'q',
|
||||
documents: ['d'],
|
||||
model: 'llama-server-reranker:qwen3-reranker-4b',
|
||||
});
|
||||
|
||||
expect(capturedUrl).toBe('http://127.0.0.1:8091/v1/rerank');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,20 +1,38 @@
|
||||
/**
|
||||
* Pure-function tests for src/core/contextual-retrieval-service.ts.
|
||||
*
|
||||
* The full service test (PHASE 1 + PHASE 2 happy path, refusal restart,
|
||||
* transient error propagation) needs a real PGLite + gateway stub seam.
|
||||
* That lands in test/e2e/contextual-retrieval.test.ts. This file pins
|
||||
* the service's pure helpers: corpus_generation hash composition + the
|
||||
* expectedMode helper used by the T9 reindex sweep predicate.
|
||||
* This file pins the service's pure helpers plus hermetic service behavior
|
||||
* driven through fake engine + gateway seams. Full PGLite coverage lives in
|
||||
* test/e2e/contextual-retrieval-pglite.test.ts.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { afterEach, describe, test, expect } from 'bun:test';
|
||||
import {
|
||||
computeCorpusGeneration,
|
||||
computeSourceTextHash,
|
||||
expectedModeForPageSourceOnly,
|
||||
reembedPageWithContextualRetrieval,
|
||||
resolveContextualChunkConcurrency,
|
||||
TITLE_WRAPPER_VERSION,
|
||||
} from '../src/core/contextual-retrieval-service.ts';
|
||||
import {
|
||||
__setChatTransportForTests,
|
||||
__setEmbedTransportForTests,
|
||||
configureGateway,
|
||||
resetGateway,
|
||||
type ChatOpts,
|
||||
type ChatResult,
|
||||
} from '../src/core/ai/gateway.ts';
|
||||
import type { ChunkInput } from '../src/core/types.ts';
|
||||
import { withEnv } from './helpers/with-env.ts';
|
||||
|
||||
const TEST_DIMS = 1536;
|
||||
|
||||
afterEach(() => {
|
||||
__setChatTransportForTests(null);
|
||||
__setEmbedTransportForTests(null);
|
||||
resetGateway();
|
||||
});
|
||||
|
||||
describe('computeCorpusGeneration', () => {
|
||||
test('returns 16-char hex hash', () => {
|
||||
@@ -138,3 +156,317 @@ describe('expectedModeForPageSourceOnly (T9 reindex sweep helper)', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveContextualChunkConcurrency', () => {
|
||||
test('defaults to 4 and reads the process env', async () => {
|
||||
await withEnv({ GBRAIN_CONTEXTUAL_CHUNK_CONCURRENCY: undefined }, async () => {
|
||||
expect(resolveContextualChunkConcurrency()).toBe(4);
|
||||
});
|
||||
await withEnv({ GBRAIN_CONTEXTUAL_CHUNK_CONCURRENCY: '7' }, async () => {
|
||||
expect(resolveContextualChunkConcurrency()).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
test('clamps to [1, 16] and ignores invalid values', () => {
|
||||
expect(resolveContextualChunkConcurrency({
|
||||
GBRAIN_CONTEXTUAL_CHUNK_CONCURRENCY: '0',
|
||||
})).toBe(1);
|
||||
expect(resolveContextualChunkConcurrency({
|
||||
GBRAIN_CONTEXTUAL_CHUNK_CONCURRENCY: '-3',
|
||||
})).toBe(1);
|
||||
expect(resolveContextualChunkConcurrency({
|
||||
GBRAIN_CONTEXTUAL_CHUNK_CONCURRENCY: '99',
|
||||
})).toBe(16);
|
||||
expect(resolveContextualChunkConcurrency({
|
||||
GBRAIN_CONTEXTUAL_CHUNK_CONCURRENCY: '1.9',
|
||||
})).toBe(1);
|
||||
expect(resolveContextualChunkConcurrency({
|
||||
GBRAIN_CONTEXTUAL_CHUNK_CONCURRENCY: 'not-a-number',
|
||||
})).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('per-chunk synopsis concurrency', () => {
|
||||
test('concurrency > 1 preserves chunk-order embed input', async () => {
|
||||
const chunks = makeChunks(['alpha', 'beta', 'gamma', 'delta']);
|
||||
const delays: Record<string, number> = { alpha: 30, beta: 5, gamma: 20, delta: 1 };
|
||||
const sequential = await runWithChatStub({
|
||||
chunks,
|
||||
concurrency: 1,
|
||||
delayForChunk: (chunk) => delays[chunk] ?? 1,
|
||||
});
|
||||
const parallel = await runWithChatStub({
|
||||
chunks,
|
||||
concurrency: 4,
|
||||
delayForChunk: (chunk) => delays[chunk] ?? 1,
|
||||
});
|
||||
|
||||
expect(parallel.result.kind).toBe('success');
|
||||
expect(parallel.embedInputs).toEqual(sequential.embedInputs);
|
||||
expect(parallel.embeddedChunks.map((c) => c.chunk_text)).toEqual(
|
||||
chunks.map((c) => c.chunk_text),
|
||||
);
|
||||
});
|
||||
|
||||
test('concurrency is bounded', async () => {
|
||||
let active = 0;
|
||||
let maxActive = 0;
|
||||
let leaseActive = 0;
|
||||
let maxLeaseActive = 0;
|
||||
let acquired = 0;
|
||||
let released = 0;
|
||||
const chunks = makeChunks(Array.from({ length: 8 }, (_, i) => `chunk-${i}`));
|
||||
const out = await runWithChatStub({
|
||||
chunks,
|
||||
concurrency: 3,
|
||||
acquireSynopsisLease: async () => {
|
||||
acquired++;
|
||||
leaseActive++;
|
||||
maxLeaseActive = Math.max(maxLeaseActive, leaseActive);
|
||||
return acquired;
|
||||
},
|
||||
releaseSynopsisLease: async () => {
|
||||
released++;
|
||||
leaseActive--;
|
||||
},
|
||||
chat: async (opts) => {
|
||||
active++;
|
||||
maxActive = Math.max(maxActive, active);
|
||||
try {
|
||||
await delay(20, opts.abortSignal);
|
||||
return chatSuccess(`Synopsis for ${extractChunk(opts)}`);
|
||||
} finally {
|
||||
active--;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
expect(out.result.kind).toBe('success');
|
||||
expect(maxActive).toBeGreaterThan(1);
|
||||
expect(maxActive).toBeLessThanOrEqual(3);
|
||||
expect(maxLeaseActive).toBeLessThanOrEqual(3);
|
||||
expect(acquired).toBe(8);
|
||||
expect(released).toBe(8);
|
||||
expect(leaseActive).toBe(0);
|
||||
});
|
||||
|
||||
test('one chunk failure aborts queued work and falls back at page level', async () => {
|
||||
let started = 0;
|
||||
const chunks = makeChunks(Array.from({ length: 9 }, (_, i) => `chunk-${i}`));
|
||||
const out = await runWithChatStub({
|
||||
chunks,
|
||||
concurrency: 3,
|
||||
chat: async (opts) => {
|
||||
started++;
|
||||
const chunk = extractChunk(opts);
|
||||
if (chunk === 'chunk-0') return chatSuccess('');
|
||||
await delay(30, opts.abortSignal);
|
||||
return chatSuccess(`Synopsis for ${chunk}`);
|
||||
},
|
||||
});
|
||||
|
||||
expect(out.result.kind).toBe('page_fallback');
|
||||
expect(started).toBeLessThanOrEqual(3);
|
||||
});
|
||||
|
||||
test('fenced code chunks bypass synopsis calls and leases', async () => {
|
||||
let chatCalls = 0;
|
||||
let leaseCalls = 0;
|
||||
const chunks: ChunkInput[] = [
|
||||
{ chunk_index: 0, chunk_text: 'intro', chunk_source: 'compiled_truth' },
|
||||
{ chunk_index: 1, chunk_text: 'const x = 1;', chunk_source: 'fenced_code' },
|
||||
{ chunk_index: 2, chunk_text: 'outro', chunk_source: 'compiled_truth' },
|
||||
];
|
||||
|
||||
const out = await runWithChatStub({
|
||||
chunks,
|
||||
concurrency: 3,
|
||||
acquireSynopsisLease: async () => {
|
||||
leaseCalls++;
|
||||
},
|
||||
releaseSynopsisLease: async () => {},
|
||||
chat: async (opts) => {
|
||||
chatCalls++;
|
||||
return chatSuccess(`Synopsis for ${extractChunk(opts)}`);
|
||||
},
|
||||
});
|
||||
|
||||
expect(out.result.kind).toBe('success');
|
||||
expect(chatCalls).toBe(2);
|
||||
expect(leaseCalls).toBe(2);
|
||||
expect(out.embedInputs[1]).toBe('const x = 1;');
|
||||
});
|
||||
|
||||
test('abortSignal cancels in-flight and queued synopsis work promptly', async () => {
|
||||
const controller = new AbortController();
|
||||
let started = 0;
|
||||
const chunks = makeChunks(Array.from({ length: 20 }, (_, i) => `chunk-${i}`));
|
||||
const startedAt = Date.now();
|
||||
const promise = runWithChatStub({
|
||||
chunks,
|
||||
concurrency: 4,
|
||||
abortSignal: controller.signal,
|
||||
chat: async (opts) => {
|
||||
started++;
|
||||
await delay(1000, opts.abortSignal);
|
||||
return chatSuccess(`Synopsis for ${extractChunk(opts)}`);
|
||||
},
|
||||
});
|
||||
setTimeout(() => controller.abort(), 20);
|
||||
|
||||
const out = await promise;
|
||||
expect(out.result.kind).toBe('transient_error');
|
||||
if (out.result.kind === 'transient_error') {
|
||||
expect(out.result.cause).toBe('timeout');
|
||||
}
|
||||
expect(started).toBeLessThanOrEqual(4);
|
||||
expect(Date.now() - startedAt).toBeLessThan(300);
|
||||
});
|
||||
});
|
||||
|
||||
function makeChunks(texts: string[]): ChunkInput[] {
|
||||
return texts.map((text, i) => ({
|
||||
chunk_index: i,
|
||||
chunk_text: text,
|
||||
chunk_source: 'compiled_truth',
|
||||
}));
|
||||
}
|
||||
|
||||
async function runWithChatStub(opts: {
|
||||
chunks: ChunkInput[];
|
||||
concurrency: number;
|
||||
abortSignal?: AbortSignal;
|
||||
delayForChunk?: (chunk: string) => number;
|
||||
chat?: (opts: ChatOpts) => Promise<ChatResult>;
|
||||
acquireSynopsisLease?: () => Promise<unknown>;
|
||||
releaseSynopsisLease?: (lease?: unknown) => Promise<void>;
|
||||
}) {
|
||||
configureGateway({
|
||||
embedding_model: 'openai:text-embedding-3-large',
|
||||
embedding_dimensions: TEST_DIMS,
|
||||
env: { OPENAI_API_KEY: 'sk-test' },
|
||||
});
|
||||
|
||||
const embedInputs: string[][] = [];
|
||||
__setEmbedTransportForTests(async ({ values }: any) => {
|
||||
embedInputs.push([...values]);
|
||||
return {
|
||||
embeddings: values.map((_: string, i: number) =>
|
||||
Array.from({ length: TEST_DIMS }, () => 0.001 + i * 0.001),
|
||||
),
|
||||
usage: { tokens: 0 },
|
||||
} as any;
|
||||
});
|
||||
|
||||
__setChatTransportForTests(opts.chat ?? (async (chatOpts) => {
|
||||
const chunk = extractChunk(chatOpts);
|
||||
await delay(opts.delayForChunk?.(chunk) ?? 1, chatOpts.abortSignal);
|
||||
return chatSuccess(`Synopsis for ${chunk}`);
|
||||
}));
|
||||
|
||||
const engine = makeServiceEngine(opts.chunks);
|
||||
const result = await reembedPageWithContextualRetrieval({
|
||||
engine,
|
||||
pageSlug: 'wiki/concepts/concurrency-test',
|
||||
sourceId: 'default',
|
||||
globalMode: 'per_chunk_synopsis',
|
||||
chunkConcurrency: opts.concurrency,
|
||||
abortSignal: opts.abortSignal,
|
||||
...(opts.acquireSynopsisLease && { acquireSynopsisLease: opts.acquireSynopsisLease }),
|
||||
...(opts.releaseSynopsisLease && { releaseSynopsisLease: opts.releaseSynopsisLease }),
|
||||
});
|
||||
|
||||
return {
|
||||
result,
|
||||
embedInputs: embedInputs.flat(),
|
||||
embeddedChunks: engine.embeddedChunks as ChunkInput[],
|
||||
};
|
||||
}
|
||||
|
||||
function makeServiceEngine(chunks: ChunkInput[]) {
|
||||
const engine: any = {
|
||||
embeddedChunks: [] as ChunkInput[],
|
||||
async getPage() {
|
||||
return {
|
||||
id: 1,
|
||||
slug: 'wiki/concepts/concurrency-test',
|
||||
source_id: 'default',
|
||||
type: 'concept',
|
||||
title: 'Concurrency Test',
|
||||
compiled_truth: chunks.map((c) => c.chunk_text).join('\n\n'),
|
||||
timeline: '',
|
||||
frontmatter: {},
|
||||
created_at: new Date('2026-01-01T00:00:00Z'),
|
||||
updated_at: new Date('2026-01-01T00:00:00Z'),
|
||||
deleted_at: null,
|
||||
};
|
||||
},
|
||||
async executeRaw() {
|
||||
return [{
|
||||
id: 'default',
|
||||
name: 'Default',
|
||||
local_path: null,
|
||||
last_commit: null,
|
||||
last_sync_at: null,
|
||||
config: {},
|
||||
created_at: new Date('2026-01-01T00:00:00Z'),
|
||||
contextual_retrieval_mode: null,
|
||||
trust_frontmatter_overrides: false,
|
||||
}];
|
||||
},
|
||||
async getChunks() {
|
||||
return chunks;
|
||||
},
|
||||
async transaction(fn: (tx: any) => Promise<void>) {
|
||||
await fn({
|
||||
upsertChunks: async (_slug: string, embedded: ChunkInput[]) => {
|
||||
engine.embeddedChunks = embedded;
|
||||
},
|
||||
updatePageContextualRetrievalState: async () => {},
|
||||
});
|
||||
},
|
||||
async updatePageContextualRetrievalState() {},
|
||||
};
|
||||
return engine;
|
||||
}
|
||||
|
||||
function extractChunk(opts: ChatOpts): string {
|
||||
const content = String(opts.messages[0]?.content ?? '');
|
||||
return content.match(/<chunk>\n([\s\S]*?)\n<\/chunk>/)?.[1] ?? '';
|
||||
}
|
||||
|
||||
function chatSuccess(text: string): ChatResult {
|
||||
return {
|
||||
text,
|
||||
blocks: [],
|
||||
stopReason: 'end',
|
||||
usage: {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
cache_creation_tokens: 0,
|
||||
},
|
||||
model: 'stub:chat',
|
||||
providerId: 'stub',
|
||||
};
|
||||
}
|
||||
|
||||
function delay(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(abortError());
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(resolve, ms);
|
||||
signal?.addEventListener('abort', () => {
|
||||
clearTimeout(timer);
|
||||
reject(abortError());
|
||||
}, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function abortError(): Error {
|
||||
const err = new Error('aborted');
|
||||
err.name = 'AbortError';
|
||||
return err;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,10 @@ import {
|
||||
const skip = !hasDatabase();
|
||||
const describeE2E = skip ? describe.skip : describe;
|
||||
|
||||
if (skip) {
|
||||
console.log('Skipping E2E doctor --progress-json tests (DATABASE_URL not set)');
|
||||
}
|
||||
|
||||
const CLI = join(import.meta.dir, '..', '..', 'src', 'cli.ts');
|
||||
|
||||
describeE2E('gbrain doctor --progress-json (E2E)', () => {
|
||||
|
||||
@@ -29,9 +29,15 @@ afterAll(async () => {
|
||||
});
|
||||
|
||||
async function truncateAll() {
|
||||
for (const t of ['content_chunks', 'links', 'tags', 'raw_data', 'timeline_entries', 'page_versions', 'ingest_log', 'pages']) {
|
||||
for (const t of ['content_chunks', 'links', 'tags', 'raw_data', 'timeline_entries', 'page_versions', 'ingest_log', 'config', 'pages']) {
|
||||
await (engine as any).db.exec(`DELETE FROM ${t}`);
|
||||
}
|
||||
// Re-seed the two config keys this file touches back to their documented
|
||||
// defaults (both default to ON). This makes every test deterministic even if
|
||||
// an earlier test threw before its finally restored auto_link/auto_timeline,
|
||||
// and even though absent-key already resolves truthy via isAuto*Enabled.
|
||||
await engine.setConfig('auto_link', 'true');
|
||||
await engine.setConfig('auto_timeline', 'true');
|
||||
}
|
||||
|
||||
function makeContext(): OperationContext {
|
||||
@@ -77,10 +83,12 @@ describe('E2E graph quality (v0.10.1 pipeline)', () => {
|
||||
await runExtract(engine, ['links', '--source', 'db']);
|
||||
await runExtract(engine, ['timeline', '--source', 'db']);
|
||||
|
||||
// Verify graph populated.
|
||||
// Verify graph populated. Concrete floors derived from the seeded fixtures:
|
||||
// resolvable entity refs: alice->acme, bob->acme, standup->alice, standup->bob = 4
|
||||
// timeline lines: alice(2) + bob(1) + acme(1) + standup(1) = 5
|
||||
const stats = await engine.getStats();
|
||||
expect(stats.link_count).toBeGreaterThan(0);
|
||||
expect(stats.timeline_entry_count).toBeGreaterThan(0);
|
||||
expect(stats.link_count).toBeGreaterThanOrEqual(4);
|
||||
expect(stats.timeline_entry_count).toBeGreaterThanOrEqual(5);
|
||||
|
||||
// Verify typed link inference.
|
||||
const aliceLinks = await engine.getLinks('people/alice');
|
||||
@@ -91,7 +99,16 @@ describe('E2E graph quality (v0.10.1 pipeline)', () => {
|
||||
const bobAcme = bobLinks.find(l => l.to_slug === 'companies/acme');
|
||||
expect(bobAcme?.link_type).toBe('invested_in');
|
||||
|
||||
// The standup meeting references both Alice and Bob as attendees. Assert the
|
||||
// exact attendee edges are present and typed 'attended' (a plain .every()
|
||||
// would silently pass if a meeting->company edge were misclassified or if the
|
||||
// attendee edges were missing entirely).
|
||||
const meetingLinks = await engine.getLinks('meetings/standup');
|
||||
const attended = new Set(
|
||||
meetingLinks.filter(l => l.link_type === 'attended').map(l => l.to_slug),
|
||||
);
|
||||
expect(attended.has('people/alice')).toBe(true);
|
||||
expect(attended.has('people/bob')).toBe(true);
|
||||
expect(meetingLinks.every(l => l.link_type === 'attended')).toBe(true);
|
||||
});
|
||||
|
||||
@@ -118,7 +135,9 @@ Attendees: [Alice](people/alice). Discussed [Acme](companies/acme).
|
||||
// The response should include auto_links results.
|
||||
expect((result as any).auto_links).toBeDefined();
|
||||
const autoLinks = (result as any).auto_links;
|
||||
expect(autoLinks.created).toBeGreaterThan(0);
|
||||
// The page references exactly two seeded, resolvable targets (Alice + Acme),
|
||||
// so exactly two links are created.
|
||||
expect(autoLinks.created).toBe(2);
|
||||
expect(autoLinks.errors).toBe(0);
|
||||
|
||||
// Verify links actually exist in DB.
|
||||
@@ -283,6 +302,53 @@ Mention of [Alice](people/alice).
|
||||
expect(paths[0].link_type).toBe('works_at');
|
||||
});
|
||||
|
||||
test('graph-query traversal: direction out and both, plus depth:2 multi-hop', async () => {
|
||||
// Seed a 2-hop chain: alice -works_at-> acme -partnered_with-> beta.
|
||||
await engine.putPage('people/alice', { type: 'person', title: 'Alice', compiled_truth: '', timeline: '' });
|
||||
await engine.putPage('companies/acme', { type: 'company', title: 'Acme', compiled_truth: '', timeline: '' });
|
||||
await engine.putPage('companies/beta', { type: 'company', title: 'Beta', compiled_truth: '', timeline: '' });
|
||||
await engine.addLink('people/alice', 'companies/acme', '', 'works_at');
|
||||
await engine.addLink('companies/acme', 'companies/beta', '', 'partnered_with');
|
||||
|
||||
// direction:'out' from alice, depth 1 -> only the first hop.
|
||||
const out1 = await engine.traversePaths('people/alice', { direction: 'out', depth: 1 });
|
||||
expect(out1.length).toBe(1);
|
||||
expect(out1[0].from_slug).toBe('people/alice');
|
||||
expect(out1[0].to_slug).toBe('companies/acme');
|
||||
expect(out1[0].depth).toBe(1);
|
||||
|
||||
// depth:2 -> both hops, depths 1 and 2.
|
||||
const out2 = await engine.traversePaths('people/alice', { direction: 'out', depth: 2 });
|
||||
const out2Edges = new Set(out2.map(p => `${p.from_slug}->${p.to_slug}@${p.depth}`));
|
||||
expect(out2Edges.has('people/alice->companies/acme@1')).toBe(true);
|
||||
expect(out2Edges.has('companies/acme->companies/beta@2')).toBe(true);
|
||||
expect(out2.length).toBe(2);
|
||||
|
||||
// direction:'both' from acme depth 1 -> sees the inbound edge from alice AND
|
||||
// the outbound edge to beta. Edges keep their natural from->to orientation.
|
||||
const both = await engine.traversePaths('companies/acme', { direction: 'both', depth: 1 });
|
||||
const bothEdges = new Set(both.map(p => `${p.from_slug}->${p.to_slug}`));
|
||||
expect(bothEdges.has('people/alice->companies/acme')).toBe(true);
|
||||
expect(bothEdges.has('companies/acme->companies/beta')).toBe(true);
|
||||
});
|
||||
|
||||
test('graph-query cycle safety: A->B->A terminates and returns bounded results', async () => {
|
||||
await engine.putPage('people/alice', { type: 'person', title: 'Alice', compiled_truth: '', timeline: '' });
|
||||
await engine.putPage('people/bob', { type: 'person', title: 'Bob', compiled_truth: '', timeline: '' });
|
||||
// Create a 2-cycle: alice -> bob -> alice.
|
||||
await engine.addLink('people/alice', 'people/bob', '', 'knows');
|
||||
await engine.addLink('people/bob', 'people/alice', '', 'knows');
|
||||
|
||||
// High depth must NOT loop forever; the visited-set guard bounds the walk.
|
||||
const paths = await engine.traversePaths('people/alice', { direction: 'out', depth: 100 });
|
||||
const edges = new Set(paths.map(p => `${p.from_slug}->${p.to_slug}`));
|
||||
// Both edges of the cycle are reachable exactly once.
|
||||
expect(edges.has('people/alice->people/bob')).toBe(true);
|
||||
expect(edges.has('people/bob->people/alice')).toBe(true);
|
||||
// Bounded: there are only two edges in the graph, so no path explosion.
|
||||
expect(paths.length).toBe(2);
|
||||
});
|
||||
|
||||
test('search backlink boost: well-connected pages rank higher', async () => {
|
||||
// Create 3 pages all matching a search term, but with different inbound link counts.
|
||||
await engine.putPage('topic/popular', {
|
||||
|
||||
@@ -21,6 +21,10 @@ import { hasDatabase, setupDB, teardownDB, getEngine, getConn } from './helpers.
|
||||
const skip = !hasDatabase();
|
||||
const describeE2E = skip ? describe.skip : describe;
|
||||
|
||||
if (skip) {
|
||||
console.log('Skipping E2E JSONB roundtrip tests (DATABASE_URL not set)');
|
||||
}
|
||||
|
||||
describeE2E('E2E: JSONB roundtrip — v0.12.1 reliability wave', () => {
|
||||
beforeAll(async () => { await setupDB(); });
|
||||
afterAll(async () => { await teardownDB(); });
|
||||
|
||||
@@ -56,6 +56,7 @@ describe('E2E: MCP Tool Generation', () => {
|
||||
expect(names).toContain('get_health');
|
||||
expect(names).toContain('sync_brain');
|
||||
expect(names).toContain('file_upload');
|
||||
expect(names).toContain('find_orphans');
|
||||
});
|
||||
|
||||
test('MCP server module can be imported', async () => {
|
||||
|
||||
@@ -175,6 +175,15 @@ describeE2E('E2E: Search', () => {
|
||||
for (const [query, score] of Object.entries(scores)) {
|
||||
console.log(` "${query}": ${(score * 100).toFixed(0)}%`);
|
||||
}
|
||||
|
||||
// Guard value: every known-item query must surface at least one ground-truth
|
||||
// doc in the top 5. This is a deliberately loose floor (not a tuned P@5
|
||||
// threshold) — it catches a total keyword-retrieval regression without
|
||||
// breaking on every scoring/fixture tweak. Without it this test asserted
|
||||
// nothing and a 0%-precision result passed silently.
|
||||
for (const [query, score] of Object.entries(scores)) {
|
||||
expect(score).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -205,10 +214,22 @@ describeE2E('E2E: Links', () => {
|
||||
}, 30_000);
|
||||
|
||||
test('traverse_graph finds connected pages', async () => {
|
||||
// Links should already be added from prior test in this describe block
|
||||
const graph = await callOp('traverse_graph', { slug: 'people/sarah-chen', depth: 2 }) as any;
|
||||
// Self-contained: do not depend on a prior test's add_link. add_link is
|
||||
// idempotent (ON CONFLICT DO NOTHING), so re-adding here is safe whether or
|
||||
// not the round-trip test ran first, and the test no longer false-passes or
|
||||
// false-fails based on describe-block ordering.
|
||||
await callOp('add_link', {
|
||||
from: 'people/sarah-chen',
|
||||
to: 'companies/novamind',
|
||||
link_type: 'founded',
|
||||
});
|
||||
|
||||
const graph = await callOp('traverse_graph', { slug: 'people/sarah-chen', depth: 2 }) as any[];
|
||||
expect(Array.isArray(graph)).toBe(true);
|
||||
expect(graph.length).toBeGreaterThanOrEqual(1);
|
||||
// Content assertion, not just shape: the linked company must be reachable.
|
||||
const reachable = graph.map((n: any) => n.slug ?? n.to_slug ?? n.to_page_slug);
|
||||
expect(reachable).toContain('companies/novamind');
|
||||
});
|
||||
|
||||
test('remove_link removes the link', async () => {
|
||||
@@ -469,8 +490,14 @@ describeE2E('E2E: Admin', () => {
|
||||
test('get_health returns valid structure', async () => {
|
||||
const health = await callOp('get_health') as any;
|
||||
expect(health).toBeDefined();
|
||||
expect(typeof health.page_count).toBe('number');
|
||||
expect(typeof health.embed_coverage).toBe('number');
|
||||
// Value bounds, not just types: page_count must match the fixture inventory
|
||||
// and embed_coverage is a 0..1 fraction (src/commands/doctor.ts multiplies
|
||||
// by 100 and compares to 0.9). Type-only checks let embed_coverage: -9999
|
||||
// through; these catch a genuinely broken health payload.
|
||||
expect(health.page_count).toBe(16);
|
||||
expect(Number.isFinite(health.embed_coverage)).toBe(true);
|
||||
expect(health.embed_coverage).toBeGreaterThanOrEqual(0);
|
||||
expect(health.embed_coverage).toBeLessThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -488,7 +515,17 @@ describeE2E('E2E: Chunks & Resolution', () => {
|
||||
test('get_chunks returns chunks for imported page', async () => {
|
||||
const chunks = await callOp('get_chunks', { slug: 'people/sarah-chen' }) as any[];
|
||||
expect(chunks.length).toBeGreaterThan(0);
|
||||
expect(chunks[0].chunk_text).toBeTruthy();
|
||||
// Content + ordering, not just truthiness (a whitespace-only chunk is truthy):
|
||||
// every chunk has real text and a numeric index, the indexes are
|
||||
// non-decreasing in return order, and the page's own name appears somewhere.
|
||||
for (const c of chunks) {
|
||||
expect(typeof c.chunk_text).toBe('string');
|
||||
expect(c.chunk_text.trim().length).toBeGreaterThan(0);
|
||||
expect(typeof c.chunk_index).toBe('number');
|
||||
}
|
||||
const indexes = chunks.map((c: any) => c.chunk_index);
|
||||
expect(indexes).toEqual([...indexes].sort((x, y) => x - y));
|
||||
expect(chunks.some((c: any) => c.chunk_text.includes('Sarah'))).toBe(true);
|
||||
}, 30_000);
|
||||
|
||||
test('resolve_slugs finds partial match', async () => {
|
||||
@@ -662,9 +699,29 @@ describeE2E('E2E: file_list LIMIT enforcement', () => {
|
||||
}, 30_000);
|
||||
|
||||
test('file_list without slug also respects LIMIT 100', async () => {
|
||||
// The 150 rows from the previous test are still in the DB
|
||||
// Self-sufficient: seed our own >100 rows rather than relying on the
|
||||
// previous test's 150 rows surviving in the DB. A bun reorder, a focused
|
||||
// `-t` run, or a failure mid-insert in the prior test would otherwise leave
|
||||
// this asserting against an indeterminate row count.
|
||||
const sql = getConn();
|
||||
const seedSlug = 'test-limit-noslug';
|
||||
await sql`
|
||||
INSERT INTO pages (slug, title, type, compiled_truth, frontmatter)
|
||||
VALUES (${seedSlug}, ${'Test Limit NoSlug'}, ${'note'}, ${'body'}, ${'{}'}::jsonb)
|
||||
ON CONFLICT (source_id, slug) DO NOTHING
|
||||
`;
|
||||
for (let i = 0; i < 120; i++) {
|
||||
await sql`
|
||||
INSERT INTO files (page_slug, filename, storage_path, mime_type, size_bytes, content_hash, metadata)
|
||||
VALUES (${seedSlug}, ${'nf-' + String(i).padStart(3, '0') + '.txt'}, ${seedSlug + '/nf-' + i + '.txt'}, ${'text/plain'}, ${100}, ${'nhash-' + i}, ${'{}'}::jsonb)
|
||||
ON CONFLICT (storage_path) DO NOTHING
|
||||
`;
|
||||
}
|
||||
const total = await sql`SELECT count(*)::int AS n FROM files`;
|
||||
expect(Number(total[0].n)).toBeGreaterThan(100); // cap is actually exercised
|
||||
|
||||
const files = await callOp('file_list', {}) as any[];
|
||||
expect(files.length).toBeLessThanOrEqual(100);
|
||||
expect(files.length).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+149
-111
@@ -78,6 +78,19 @@ function freshTempHome(label: string) {
|
||||
return dir;
|
||||
}
|
||||
|
||||
// Restore HOME/PATH to the captured originals. Called from each test's
|
||||
// `finally` so a throw mid-test can never leave HOME/PATH pointed at a temp
|
||||
// dir for the rest of the bun process (which would silently break unrelated
|
||||
// suites that read HOME). PATH keeps the shim prepended because the
|
||||
// module-level shim install is what subsequent tests in this suite rely on;
|
||||
// afterAll does the final teardown to the pristine origPath.
|
||||
function restoreHomePath() {
|
||||
if (origHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = origHome;
|
||||
if (origPath === undefined) delete process.env.PATH;
|
||||
else process.env.PATH = `${fakeBinDir}:${origPath ?? ''}`;
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
if (SKIP) {
|
||||
console.log('[migration-flow.e2e] DATABASE_URL not set — skipping.');
|
||||
@@ -100,6 +113,15 @@ afterAll(() => {
|
||||
|
||||
beforeEach(() => {
|
||||
if (SKIP) return;
|
||||
// Robust restore: if a prior test threw before its own finally ran (or
|
||||
// before afterAll), HOME/PATH could still point at a dead temp dir. Reset
|
||||
// them to the captured originals at the start of every test so a throw in
|
||||
// one test can never leak a temp HOME/PATH into sibling suites that read
|
||||
// them. freshTempHome() re-points HOME per test immediately after this.
|
||||
if (origHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = origHome;
|
||||
if (origPath === undefined) delete process.env.PATH;
|
||||
else process.env.PATH = `${fakeBinDir}:${origPath ?? ''}`;
|
||||
try { if (tmp) rmSync(tmp, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
});
|
||||
|
||||
@@ -114,144 +136,160 @@ const COMMON_OPTS = {
|
||||
describeE2E('E2E: v0.11.0 orchestrator against live Postgres', () => {
|
||||
test('fresh install flow: schema → smoke → prefs → host-rewrite → completed', async () => {
|
||||
tmp = freshTempHome('fresh');
|
||||
const result = await v0_11_0.orchestrator(COMMON_OPTS);
|
||||
try {
|
||||
const result = await v0_11_0.orchestrator(COMMON_OPTS);
|
||||
|
||||
// Orchestrator returns a structured result (status is `complete` when
|
||||
// no pending-host-work TODOs fired, `partial` otherwise).
|
||||
expect(result.version).toBe('0.11.0');
|
||||
expect(['complete', 'partial']).toContain(result.status);
|
||||
// Orchestrator returns a structured result (status is `complete` when
|
||||
// no pending-host-work TODOs fired, `partial` otherwise).
|
||||
expect(result.version).toBe('0.11.0');
|
||||
expect(['complete', 'partial']).toContain(result.status);
|
||||
|
||||
// Phase D: preferences.json exists with 0o600 + mode=pain_triggered.
|
||||
const prefsPath = join(tmp, '.gbrain', 'preferences.json');
|
||||
expect(existsSync(prefsPath)).toBe(true);
|
||||
expect(statSync(prefsPath).mode & 0o777).toBe(0o600);
|
||||
const prefs = loadPreferences();
|
||||
expect(prefs.minion_mode).toBe('pain_triggered');
|
||||
expect(prefs.set_at).toBeTruthy();
|
||||
expect(prefs.set_in_version).toBeTruthy();
|
||||
// Phase D: preferences.json exists with 0o600 + mode=pain_triggered.
|
||||
const prefsPath = join(tmp, '.gbrain', 'preferences.json');
|
||||
expect(existsSync(prefsPath)).toBe(true);
|
||||
expect(statSync(prefsPath).mode & 0o777).toBe(0o600);
|
||||
const prefs = loadPreferences();
|
||||
expect(prefs.minion_mode).toBe('pain_triggered');
|
||||
expect(prefs.set_at).toBeTruthy();
|
||||
expect(prefs.set_in_version).toBeTruthy();
|
||||
|
||||
// Bug 3 (v0.14.2) — orchestrator no longer writes completed.jsonl.
|
||||
// The runner (apply-migrations.ts) persists the result after the
|
||||
// orchestrator returns. A direct orchestrator call in E2E leaves the
|
||||
// ledger empty; the runner path is tested separately in
|
||||
// test/apply-migrations.test.ts + test/migration-resume.test.ts.
|
||||
const completed = loadCompletedMigrations();
|
||||
const v0110Entries = completed.filter(e => e.version === '0.11.0');
|
||||
expect(v0110Entries.length).toBe(0);
|
||||
// Bug 3 (v0.14.2) — orchestrator no longer writes completed.jsonl.
|
||||
// The runner (apply-migrations.ts) persists the result after the
|
||||
// orchestrator returns. A direct orchestrator call in E2E leaves the
|
||||
// ledger empty; the runner path is tested separately in
|
||||
// test/apply-migrations.test.ts + test/migration-resume.test.ts.
|
||||
const completed = loadCompletedMigrations();
|
||||
const v0110Entries = completed.filter(e => e.version === '0.11.0');
|
||||
expect(v0110Entries.length).toBe(0);
|
||||
|
||||
// Phase F is skipped per COMMON_OPTS — autopilot should NOT have been
|
||||
// installed on this host.
|
||||
expect(result.autopilot_installed).toBe(false);
|
||||
// Phase F is skipped per COMMON_OPTS — autopilot should NOT have been
|
||||
// installed on this host.
|
||||
expect(result.autopilot_installed).toBe(false);
|
||||
} finally {
|
||||
restoreHomePath();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test('idempotent rerun: second invocation is a safe no-op', async () => {
|
||||
tmp = freshTempHome('rerun');
|
||||
const first = await v0_11_0.orchestrator(COMMON_OPTS);
|
||||
expect(['complete', 'partial']).toContain(first.status);
|
||||
try {
|
||||
const first = await v0_11_0.orchestrator(COMMON_OPTS);
|
||||
expect(['complete', 'partial']).toContain(first.status);
|
||||
|
||||
const second = await v0_11_0.orchestrator(COMMON_OPTS);
|
||||
expect(['complete', 'partial']).toContain(second.status);
|
||||
const second = await v0_11_0.orchestrator(COMMON_OPTS);
|
||||
expect(['complete', 'partial']).toContain(second.status);
|
||||
|
||||
// Bug 3 (v0.14.2) — orchestrator does not write completed.jsonl, so
|
||||
// repeated direct invocations don't accumulate ledger entries. Assert
|
||||
// the preferences state stays stable (the real idempotency signal for
|
||||
// this orchestrator is "running again doesn't corrupt preferences").
|
||||
expect(loadPreferences().minion_mode).toBe('pain_triggered');
|
||||
const completed = loadCompletedMigrations();
|
||||
expect(completed.filter(e => e.version === '0.11.0').length).toBe(0);
|
||||
// Bug 3 (v0.14.2) — orchestrator does not write completed.jsonl, so
|
||||
// repeated direct invocations don't accumulate ledger entries. Assert
|
||||
// the preferences state stays stable (the real idempotency signal for
|
||||
// this orchestrator is "running again doesn't corrupt preferences").
|
||||
expect(loadPreferences().minion_mode).toBe('pain_triggered');
|
||||
const completed = loadCompletedMigrations();
|
||||
expect(completed.filter(e => e.version === '0.11.0').length).toBe(0);
|
||||
} finally {
|
||||
restoreHomePath();
|
||||
}
|
||||
}, 90_000);
|
||||
|
||||
test('host rewrite: builtin handlers auto-rewritten, non-builtins queued as JSONL TODOs', async () => {
|
||||
tmp = freshTempHome('host-rewrite');
|
||||
// Fixture: AGENTS.md + cron/jobs.json with a mix of gbrain-builtin and
|
||||
// non-builtin handlers.
|
||||
const claudeDir = join(tmp, '.claude');
|
||||
mkdirSync(claudeDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(claudeDir, 'AGENTS.md'),
|
||||
'# Test AGENTS.md\n\nSome existing content referencing sessions_spawn routing.\n',
|
||||
);
|
||||
mkdirSync(join(claudeDir, 'cron'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(claudeDir, 'cron', 'jobs.json'),
|
||||
JSON.stringify({
|
||||
jobs: [
|
||||
{ schedule: '*/5 * * * *', kind: 'agentTurn', skill: 'sync' }, // builtin
|
||||
{ schedule: '0 */30 * * *', kind: 'agentTurn', skill: 'ea-inbox-sweep' }, // non-builtin
|
||||
{ schedule: '*/10 * * * *', kind: 'agentTurn', skill: 'embed' }, // builtin
|
||||
{ schedule: '0 8 * * *', kind: 'agentTurn', skill: 'morning-briefing' }, // non-builtin
|
||||
],
|
||||
}, null, 2) + '\n',
|
||||
);
|
||||
try {
|
||||
// Fixture: AGENTS.md + cron/jobs.json with a mix of gbrain-builtin and
|
||||
// non-builtin handlers.
|
||||
const claudeDir = join(tmp, '.claude');
|
||||
mkdirSync(claudeDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(claudeDir, 'AGENTS.md'),
|
||||
'# Test AGENTS.md\n\nSome existing content referencing sessions_spawn routing.\n',
|
||||
);
|
||||
mkdirSync(join(claudeDir, 'cron'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(claudeDir, 'cron', 'jobs.json'),
|
||||
JSON.stringify({
|
||||
jobs: [
|
||||
{ schedule: '*/5 * * * *', kind: 'agentTurn', skill: 'sync' }, // builtin
|
||||
{ schedule: '0 */30 * * *', kind: 'agentTurn', skill: 'ea-inbox-sweep' }, // non-builtin
|
||||
{ schedule: '*/10 * * * *', kind: 'agentTurn', skill: 'embed' }, // builtin
|
||||
{ schedule: '0 8 * * *', kind: 'agentTurn', skill: 'morning-briefing' }, // non-builtin
|
||||
],
|
||||
}, null, 2) + '\n',
|
||||
);
|
||||
|
||||
const result = await v0_11_0.orchestrator(COMMON_OPTS);
|
||||
const result = await v0_11_0.orchestrator(COMMON_OPTS);
|
||||
|
||||
// Builtins rewritten in place; non-builtins left alone.
|
||||
const cronAfter = JSON.parse(readFileSync(join(claudeDir, 'cron', 'jobs.json'), 'utf-8'));
|
||||
expect(cronAfter.jobs[0].kind).toBe('shell'); // sync (builtin)
|
||||
expect(cronAfter.jobs[0].cmd).toContain('gbrain jobs submit sync');
|
||||
expect(cronAfter.jobs[1].kind).toBe('agentTurn'); // ea-inbox-sweep (non-builtin)
|
||||
expect(cronAfter.jobs[2].kind).toBe('shell'); // embed (builtin)
|
||||
expect(cronAfter.jobs[3].kind).toBe('agentTurn'); // morning-briefing (non-builtin)
|
||||
// Builtins rewritten in place; non-builtins left alone.
|
||||
const cronAfter = JSON.parse(readFileSync(join(claudeDir, 'cron', 'jobs.json'), 'utf-8'));
|
||||
expect(cronAfter.jobs[0].kind).toBe('shell'); // sync (builtin)
|
||||
expect(cronAfter.jobs[0].cmd).toContain('gbrain jobs submit sync');
|
||||
expect(cronAfter.jobs[1].kind).toBe('agentTurn'); // ea-inbox-sweep (non-builtin)
|
||||
expect(cronAfter.jobs[2].kind).toBe('shell'); // embed (builtin)
|
||||
expect(cronAfter.jobs[3].kind).toBe('agentTurn'); // morning-briefing (non-builtin)
|
||||
|
||||
// files_rewritten counts the 2 builtin rewrites.
|
||||
expect(result.files_rewritten).toBeGreaterThanOrEqual(2);
|
||||
// files_rewritten counts the 2 builtin rewrites.
|
||||
expect(result.files_rewritten).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// pending_host_work counts the 2 non-builtin TODOs.
|
||||
expect(result.pending_host_work).toBe(2);
|
||||
// pending_host_work counts the 2 non-builtin TODOs.
|
||||
expect(result.pending_host_work).toBe(2);
|
||||
|
||||
// Status is "partial" because non-builtin TODOs remain.
|
||||
expect(result.status).toBe('partial');
|
||||
// Status is "partial" because non-builtin TODOs remain.
|
||||
expect(result.status).toBe('partial');
|
||||
|
||||
// AGENTS.md got the marker injected.
|
||||
const agentsMdAfter = readFileSync(join(claudeDir, 'AGENTS.md'), 'utf-8');
|
||||
expect(agentsMdAfter).toContain('gbrain:subagent-routing v0.11.0');
|
||||
expect(agentsMdAfter).toContain('skills/conventions/subagent-routing.md');
|
||||
// AGENTS.md got the marker injected.
|
||||
const agentsMdAfter = readFileSync(join(claudeDir, 'AGENTS.md'), 'utf-8');
|
||||
expect(agentsMdAfter).toContain('gbrain:subagent-routing v0.11.0');
|
||||
expect(agentsMdAfter).toContain('skills/conventions/subagent-routing.md');
|
||||
|
||||
// JSONL TODO file written under ~/.gbrain/migrations/.
|
||||
const jsonlPath = join(tmp, '.gbrain', 'migrations', 'pending-host-work.jsonl');
|
||||
expect(existsSync(jsonlPath)).toBe(true);
|
||||
const lines = readFileSync(jsonlPath, 'utf-8').split('\n').filter(l => l.trim());
|
||||
expect(lines.length).toBe(2);
|
||||
const todos = lines.map(l => JSON.parse(l));
|
||||
const handlers = todos.map(t => t.handler).sort();
|
||||
expect(handlers).toEqual(['ea-inbox-sweep', 'morning-briefing']);
|
||||
for (const todo of todos) {
|
||||
expect(todo.type).toBe('cron-handler-needs-host-registration');
|
||||
expect(todo.status).toBe('pending');
|
||||
expect(todo.manifest_path).toContain('cron/jobs.json');
|
||||
// JSONL TODO file written under ~/.gbrain/migrations/.
|
||||
const jsonlPath = join(tmp, '.gbrain', 'migrations', 'pending-host-work.jsonl');
|
||||
expect(existsSync(jsonlPath)).toBe(true);
|
||||
const lines = readFileSync(jsonlPath, 'utf-8').split('\n').filter(l => l.trim());
|
||||
expect(lines.length).toBe(2);
|
||||
const todos = lines.map(l => JSON.parse(l));
|
||||
const handlers = todos.map(t => t.handler).sort();
|
||||
expect(handlers).toEqual(['ea-inbox-sweep', 'morning-briefing']);
|
||||
for (const todo of todos) {
|
||||
expect(todo.type).toBe('cron-handler-needs-host-registration');
|
||||
expect(todo.status).toBe('pending');
|
||||
expect(todo.manifest_path).toContain('cron/jobs.json');
|
||||
}
|
||||
} finally {
|
||||
restoreHomePath();
|
||||
}
|
||||
}, 90_000);
|
||||
|
||||
test('resumable: partial run → orchestrator re-run → complete', async () => {
|
||||
tmp = freshTempHome('resumable');
|
||||
// Simulate a stopgap-written partial entry BEFORE running the orchestrator.
|
||||
mkdirSync(join(tmp, '.gbrain', 'migrations'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(tmp, '.gbrain', 'migrations', 'completed.jsonl'),
|
||||
JSON.stringify({
|
||||
version: '0.11.0',
|
||||
status: 'partial',
|
||||
apply_migrations_pending: true,
|
||||
mode: 'pain_triggered',
|
||||
source: 'fix-v0.11.0.sh',
|
||||
ts: new Date().toISOString(),
|
||||
}) + '\n',
|
||||
);
|
||||
try {
|
||||
// Simulate a stopgap-written partial entry BEFORE running the orchestrator.
|
||||
mkdirSync(join(tmp, '.gbrain', 'migrations'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(tmp, '.gbrain', 'migrations', 'completed.jsonl'),
|
||||
JSON.stringify({
|
||||
version: '0.11.0',
|
||||
status: 'partial',
|
||||
apply_migrations_pending: true,
|
||||
mode: 'pain_triggered',
|
||||
source: 'fix-v0.11.0.sh',
|
||||
ts: new Date().toISOString(),
|
||||
}) + '\n',
|
||||
);
|
||||
|
||||
// Orchestrator re-running on a partial → should succeed (schema apply
|
||||
// and smoke are idempotent; prefs are preserved from the partial
|
||||
// record; host-rewrite runs its safe-skip pass). Per Bug 3 (v0.14.2),
|
||||
// the orchestrator itself doesn't append to completed.jsonl — the
|
||||
// runner does. The stopgap's partial entry stays unchanged here.
|
||||
const result = await v0_11_0.orchestrator(COMMON_OPTS);
|
||||
expect(['complete', 'partial']).toContain(result.status);
|
||||
// Orchestrator re-running on a partial → should succeed (schema apply
|
||||
// and smoke are idempotent; prefs are preserved from the partial
|
||||
// record; host-rewrite runs its safe-skip pass). Per Bug 3 (v0.14.2),
|
||||
// the orchestrator itself doesn't append to completed.jsonl — the
|
||||
// runner does. The stopgap's partial entry stays unchanged here.
|
||||
const result = await v0_11_0.orchestrator(COMMON_OPTS);
|
||||
expect(['complete', 'partial']).toContain(result.status);
|
||||
|
||||
const completed = loadCompletedMigrations();
|
||||
const v0110 = completed.filter(e => e.version === '0.11.0');
|
||||
// Just the stopgap partial — orchestrator doesn't add its own entry.
|
||||
expect(v0110.length).toBe(1);
|
||||
expect(v0110[0].status).toBe('partial');
|
||||
expect(v0110[0].source).toBe('fix-v0.11.0.sh');
|
||||
const completed = loadCompletedMigrations();
|
||||
const v0110 = completed.filter(e => e.version === '0.11.0');
|
||||
// Just the stopgap partial — orchestrator doesn't add its own entry.
|
||||
expect(v0110.length).toBe(1);
|
||||
expect(v0110[0].status).toBe('partial');
|
||||
expect(v0110[0].source).toBe('fix-v0.11.0.sh');
|
||||
} finally {
|
||||
restoreHomePath();
|
||||
}
|
||||
}, 90_000);
|
||||
});
|
||||
|
||||
@@ -94,7 +94,7 @@ describeE2E('E2E: Minions resilience (OpenClaw real-world patterns)', () => {
|
||||
}, 30_000);
|
||||
|
||||
// --- 2. Runaway handler: ignores AbortSignal, dead-lettered by handleTimeouts ---
|
||||
test('runaway handler: ignores AbortSignal, handleTimeouts dead-letters in <2s', async () => {
|
||||
test('runaway handler: ignores AbortSignal, handleTimeouts dead-letters it', async () => {
|
||||
const { a, b } = await makeEngines();
|
||||
try {
|
||||
const queue = new MinionQueue(a);
|
||||
@@ -133,8 +133,14 @@ describeE2E('E2E: Minions resilience (OpenClaw real-world patterns)', () => {
|
||||
worker.stop();
|
||||
await startP;
|
||||
|
||||
// Correctness gate: the job MUST be dead-lettered with the timeout reason.
|
||||
// We intentionally do NOT assert a wall-clock upper bound (deadAt - started):
|
||||
// on a loaded CI runner the stall/timeout sweep cadence varies, and the only
|
||||
// thing that matters is that the runaway job terminates as 'dead'. The 3s poll
|
||||
// deadline above is the real timeout — if the sweep is too slow, finalStatus
|
||||
// stays '' and this toBe('dead') fails loudly.
|
||||
expect(finalStatus).toBe('dead');
|
||||
expect(deadAt - started).toBeLessThan(2000);
|
||||
void deadAt; // retained for debugging; no timing assertion (flake-prone)
|
||||
|
||||
const final = await queue.getJob(job.id);
|
||||
expect(final?.error_text).toMatch(/timeout exceeded/i);
|
||||
@@ -304,7 +310,7 @@ describeE2E('E2E: Minions resilience (OpenClaw real-world patterns)', () => {
|
||||
}, 60_000);
|
||||
|
||||
// --- 5. Cascade kill under load: cancelJob aborts all live descendants ---
|
||||
test('cascade kill: cancelJob on parent aborts 10 live children within 2s', async () => {
|
||||
test('cascade kill: cancelJob on parent aborts 10 live children', async () => {
|
||||
const { a, b } = await makeEngines();
|
||||
try {
|
||||
const queue = new MinionQueue(a);
|
||||
@@ -374,8 +380,12 @@ describeE2E('E2E: Minions resilience (OpenClaw real-world patterns)', () => {
|
||||
worker.stop();
|
||||
await startP;
|
||||
|
||||
// Correctness gate: all 10 cooperative handlers observed the abort and the
|
||||
// DB shows every descendant + root cancelled. We do NOT assert a wall-clock
|
||||
// upper bound on cancelElapsed — the 3s abort poll deadline above already
|
||||
// bounds the wait, and asserting a tighter time flakes on shared runners.
|
||||
expect(abortedChildren.size).toBe(10);
|
||||
expect(cancelElapsed).toBeLessThan(3000);
|
||||
void cancelElapsed; // retained for debugging; no timing assertion (flake-prone)
|
||||
|
||||
// DB truth: every descendant + root is 'cancelled'
|
||||
const conn = getConn();
|
||||
|
||||
@@ -78,7 +78,11 @@ describeE2E('v0.18.0 multi-source — Postgres schema shape (fresh install)', ()
|
||||
);
|
||||
expect(rows.length).toBe(1);
|
||||
expect(rows[0].is_nullable).toBe('NO');
|
||||
expect(String(rows[0].column_default)).toContain('default');
|
||||
// Postgres renders a TEXT DEFAULT 'default' literal as `'default'::text`.
|
||||
// Assert the exact stored expression rather than a loose substring so a
|
||||
// drift in the schema DEFAULT (e.g. a different sentinel source id) fails
|
||||
// here instead of silently passing.
|
||||
expect(String(rows[0].column_default)).toBe("'default'::text");
|
||||
});
|
||||
|
||||
test('composite UNIQUE pages(source_id, slug) replaces global UNIQUE(slug)', async () => {
|
||||
@@ -292,6 +296,18 @@ describeE2E('v0.18.0 multi-source — cascade delete covers every dependent row'
|
||||
`INSERT INTO files (source_id, page_id, filename, storage_path, content_hash)
|
||||
VALUES ('cascadetest', ${aliceId}, 'alice.pdf', 'cascadetest/people/alice/alice.pdf', 'fh1')`,
|
||||
);
|
||||
const aliceFile = await conn.unsafe(
|
||||
`SELECT id FROM files WHERE source_id = 'cascadetest' AND storage_path = 'cascadetest/people/alice/alice.pdf'`,
|
||||
);
|
||||
const aliceFileId = aliceFile[0].id as number;
|
||||
|
||||
// file_migration_ledger row keyed on the file (FK file_id ON DELETE
|
||||
// CASCADE). Removing the source cascades sources → files → ledger.
|
||||
await conn.unsafe(
|
||||
`INSERT INTO file_migration_ledger (file_id, storage_path_old, storage_path_new, status)
|
||||
VALUES (${aliceFileId}, 'cascadetest/people/alice/alice.pdf', 'cascadetest/people/alice/alice.pdf', 'pending')
|
||||
ON CONFLICT (file_id) DO NOTHING`,
|
||||
);
|
||||
|
||||
// Sanity: everything exists
|
||||
expect((await conn.unsafe(`SELECT COUNT(*)::int AS n FROM pages WHERE source_id = 'cascadetest'`))[0].n).toBe(2);
|
||||
@@ -299,6 +315,7 @@ describeE2E('v0.18.0 multi-source — cascade delete covers every dependent row'
|
||||
expect((await conn.unsafe(`SELECT COUNT(*)::int AS n FROM timeline_entries WHERE page_id = ${aliceId}`))[0].n).toBe(1);
|
||||
expect((await conn.unsafe(`SELECT COUNT(*)::int AS n FROM links WHERE from_page_id = ${aliceId}`))[0].n).toBe(1);
|
||||
expect((await conn.unsafe(`SELECT COUNT(*)::int AS n FROM files WHERE source_id = 'cascadetest'`))[0].n).toBe(1);
|
||||
expect((await conn.unsafe(`SELECT COUNT(*)::int AS n FROM file_migration_ledger WHERE file_id = ${aliceFileId}`))[0].n).toBe(1);
|
||||
|
||||
// Remove the source.
|
||||
// v0.26.5: populated sources require --confirm-destructive; --yes alone is rejected.
|
||||
@@ -310,6 +327,7 @@ describeE2E('v0.18.0 multi-source — cascade delete covers every dependent row'
|
||||
expect((await conn.unsafe(`SELECT COUNT(*)::int AS n FROM timeline_entries WHERE page_id = ${aliceId}`))[0].n).toBe(0);
|
||||
expect((await conn.unsafe(`SELECT COUNT(*)::int AS n FROM links WHERE from_page_id = ${aliceId}`))[0].n).toBe(0);
|
||||
expect((await conn.unsafe(`SELECT COUNT(*)::int AS n FROM files WHERE source_id = 'cascadetest'`))[0].n).toBe(0);
|
||||
expect((await conn.unsafe(`SELECT COUNT(*)::int AS n FROM file_migration_ledger WHERE file_id = ${aliceFileId}`))[0].n).toBe(0);
|
||||
|
||||
// The sources row itself is gone.
|
||||
const src = await conn.unsafe(`SELECT id FROM sources WHERE id = 'cascadetest'`);
|
||||
@@ -378,8 +396,10 @@ describeE2E('v0.18.0 multi-source — sync --source routes through sources table
|
||||
|
||||
test('performSync with no sourceId falls back to global sync.repo_path', async () => {
|
||||
const engine = getEngine();
|
||||
// Global config is still '/some/other/default/path' from the
|
||||
// previous test. Without --source, performSync uses it.
|
||||
// Self-contained: set the global config this test depends on directly
|
||||
// instead of inheriting the side effect of the previous test. Without
|
||||
// --source, performSync must read this global key.
|
||||
await engine.setConfig('sync.repo_path', '/some/other/default/path');
|
||||
let err: Error | null = null;
|
||||
try {
|
||||
await performSync(engine, {});
|
||||
|
||||
@@ -112,4 +112,27 @@ describe('v0.29 E2E — getRecentSalience (Garry test)', () => {
|
||||
const rows = await engine.getRecentSalience({ days: 7, slugPrefix: 'nope/does-not-exist/' });
|
||||
expect(rows).toEqual([]);
|
||||
});
|
||||
|
||||
// TIM-37: the daily briefing writes to the vault and re-ingests as
|
||||
// `briefings/<date>`. Without this filter the briefing itself would top
|
||||
// every subsequent Brain Pulse — self-reference with no signal.
|
||||
describe('TIM-37 — briefings excluded from their own Brain Pulse', () => {
|
||||
test('default query hides briefings/* slugs', async () => {
|
||||
await engine.putPage('briefings/2026-05-19', {
|
||||
type: 'note',
|
||||
title: 'Daily Briefing — 2026-05-19',
|
||||
compiled_truth: 'Auto-generated cron briefing.',
|
||||
});
|
||||
const rows = await engine.getRecentSalience({ days: 7, limit: 50 });
|
||||
expect(rows.some(r => r.slug.startsWith('briefings/'))).toBe(false);
|
||||
});
|
||||
|
||||
test('explicit slugPrefix=briefings/ still returns them', async () => {
|
||||
const rows = await engine.getRecentSalience({ days: 7, slugPrefix: 'briefings/' });
|
||||
expect(rows.length).toBeGreaterThan(0);
|
||||
for (const r of rows) {
|
||||
expect(r.slug.startsWith('briefings/')).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -128,6 +128,17 @@ describe('SearchResult fields', () => {
|
||||
expect(r.chunk_index).toBeDefined();
|
||||
expect(typeof r.chunk_index).toBe('number');
|
||||
});
|
||||
|
||||
test('empty keyword query returns a defined array without throwing', async () => {
|
||||
const results = await engine.searchKeyword('');
|
||||
expect(Array.isArray(results)).toBe(true);
|
||||
});
|
||||
|
||||
test('zero vector search returns a defined array without throwing', async () => {
|
||||
const zeroVector = new Float32Array(1536);
|
||||
const results = await engine.searchVector(zeroVector);
|
||||
expect(Array.isArray(results)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detail parameter', () => {
|
||||
@@ -145,9 +156,11 @@ describe('detail parameter', () => {
|
||||
});
|
||||
|
||||
test('detail=low on vector search filters to compiled_truth', async () => {
|
||||
// Use a timeline-direction embedding — with detail=low, should get no results
|
||||
// or only compiled_truth results
|
||||
// Use a timeline-direction embedding — detail=low filters to compiled_truth.
|
||||
// Vector search returns every chunk with an embedding (ordered by distance),
|
||||
// so the seeded compiled_truth chunks are non-empty and ALL compiled_truth.
|
||||
const results = await engine.searchVector(basisEmbedding(1), { detail: 'low' });
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
for (const r of results) {
|
||||
expect(r.chunk_source).toBe('compiled_truth');
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ describeE2E('E2E: Check-Update', () => {
|
||||
expect(stdout).toContain('--json');
|
||||
});
|
||||
|
||||
test('handles no-releases gracefully (current repo state)', async () => {
|
||||
test('check-update --json contract holds regardless of real release state', async () => {
|
||||
const proc = Bun.spawn(['bun', 'run', 'src/cli.ts', 'check-update', '--json'], {
|
||||
cwd: new URL('../..', import.meta.url).pathname,
|
||||
stdout: 'pipe',
|
||||
@@ -84,8 +84,16 @@ describeE2E('E2E: Check-Update', () => {
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
const output = JSON.parse(stdout);
|
||||
// With no releases, should return false and an error
|
||||
expect(output.update_available).toBe(false);
|
||||
// Don't pin update_available to a literal value — the repo may or may not
|
||||
// have a published release. Assert the JSON shape instead.
|
||||
expect(typeof output.update_available).toBe('boolean');
|
||||
expect(output.current_version).toBe(VERSION);
|
||||
if (output.latest_version != null) {
|
||||
expect(typeof output.latest_version).toBe('string');
|
||||
}
|
||||
if (output.release_url != null) {
|
||||
expect(typeof output.release_url).toBe('string');
|
||||
}
|
||||
});
|
||||
|
||||
test('version comparison wiring works end-to-end', () => {
|
||||
|
||||
@@ -209,6 +209,32 @@ describe('gbrain extract --stale', () => {
|
||||
expect(usRows[0]?.eq).toBe(true);
|
||||
});
|
||||
|
||||
test('REGRESSION: page with updated_at BEFORE LINK_EXTRACTOR_VERSION_TS clears (no permanent-stale loop)', async () => {
|
||||
// The v112 watermark column ships with no backfill, so every pre-existing
|
||||
// page starts NULL-stale — and most pre-date the version bump. Pre-fix,
|
||||
// extractStaleFromDB stamped links_extracted_at = read updated_at; for a
|
||||
// page edited before LINK_EXTRACTOR_VERSION_TS the stamp landed BELOW the
|
||||
// version threshold, so the version arm (links_extracted_at < versionTs)
|
||||
// re-flagged it stale forever — an infinite re-extract loop that never
|
||||
// cleared the lag (observed: 97% of pages stuck permanently).
|
||||
await engine.putPage('people/alice', personPage('Alice'));
|
||||
await engine.putPage('companies/acme', companyPage('Acme', '[Alice](people/alice) leads [Acme](companies/acme).'));
|
||||
// Backdate every page to BEFORE the extractor version timestamp.
|
||||
await engine.executeRaw(`UPDATE pages SET updated_at = '2020-01-01T00:00:00Z'`);
|
||||
expect(await engine.countStalePagesForExtraction({ versionTs: LINK_EXTRACTOR_VERSION_TS })).toBe(2);
|
||||
|
||||
await runExtract(engine, ['--stale']);
|
||||
// Fixed: stamp = GREATEST(read updated_at, versionTs) → lifts old pages to
|
||||
// the threshold so the version arm clears, while a real future edit still
|
||||
// advances updated_at past the stamp (CDX-1 race protection preserved).
|
||||
expect(await engine.countStalePagesForExtraction({ versionTs: LINK_EXTRACTOR_VERSION_TS })).toBe(0);
|
||||
|
||||
// Second run must ALSO find 0 — the defining symptom of the bug was that it
|
||||
// never converged.
|
||||
await runExtract(engine, ['--stale']);
|
||||
expect(await engine.countStalePagesForExtraction({ versionTs: LINK_EXTRACTOR_VERSION_TS })).toBe(0);
|
||||
});
|
||||
|
||||
test('CDX-4 (D2): a link-flush throw aborts the sweep and leaves pages UNSTAMPED', async () => {
|
||||
await engine.putPage('people/alice', personPage('Alice'));
|
||||
await engine.putPage('companies/acme', companyPage('Acme', '[Alice](people/alice) founded [Acme](companies/acme).'));
|
||||
|
||||
@@ -38,6 +38,35 @@ afterAll(() => {
|
||||
});
|
||||
|
||||
describe('importFile', () => {
|
||||
test('normalizes mixed-case importFromContent slug before tag/chunk writes (#2680)', async () => {
|
||||
const engine = mockEngine();
|
||||
|
||||
const result = await importFromContent(engine, 'session/GenerateText-shape-confirmed', `---
|
||||
type: concept
|
||||
title: Mixed Case
|
||||
tags: [llm, shape]
|
||||
---
|
||||
|
||||
Content here.
|
||||
`, { noEmbed: true });
|
||||
|
||||
expect(result.status).toBe('imported');
|
||||
expect(result.slug).toBe('session/generatetext-shape-confirmed');
|
||||
|
||||
const calls = (engine as any)._calls;
|
||||
const putCall = calls.find((c: any) => c.method === 'putPage');
|
||||
expect(putCall.args[0]).toBe('session/generatetext-shape-confirmed');
|
||||
|
||||
const tagCalls = calls.filter((c: any) => c.method === 'addTag');
|
||||
expect(tagCalls.map((c: any) => c.args[0])).toEqual([
|
||||
'session/generatetext-shape-confirmed',
|
||||
'session/generatetext-shape-confirmed',
|
||||
]);
|
||||
|
||||
const chunkCall = calls.find((c: any) => c.method === 'upsertChunks');
|
||||
expect(chunkCall.args[0]).toBe('session/generatetext-shape-confirmed');
|
||||
});
|
||||
|
||||
test('imports a valid markdown file', async () => {
|
||||
const filePath = join(TMP, 'test-page.md');
|
||||
writeFileSync(filePath, `---
|
||||
|
||||
@@ -1236,6 +1236,43 @@ describe('makeResolver — fallback chain', () => {
|
||||
const out = await r.resolveBasenameMatches!('struktura');
|
||||
expect(out.sort()).toEqual(['notes/struktura', 'struktura']);
|
||||
});
|
||||
|
||||
test('opts.sourceId is forwarded to findByTitleFuzzy (twin of #1436 fix)', async () => {
|
||||
// Captures every (name, dirPrefix, minSimilarity, sourceId) call so we
|
||||
// can assert the resolver threads sourceId through. Without the wire-up,
|
||||
// findByTitleFuzzy would be called with sourceId=undefined and the SQL
|
||||
// could return cross-source slug suggestions that the FK filter
|
||||
// downstream silently drops.
|
||||
const calls: Array<{ name: string; dirPrefix?: string; minSimilarity?: number; sourceId?: string }> = [];
|
||||
const engine = {
|
||||
async getPage() { return null; },
|
||||
async findByTitleFuzzy(name: string, dirPrefix?: string, minSimilarity?: number, sourceId?: string) {
|
||||
calls.push({ name, dirPrefix, minSimilarity, sourceId });
|
||||
return null;
|
||||
},
|
||||
async searchKeyword() { return []; },
|
||||
} as unknown as BrainEngine;
|
||||
const r = makeResolver(engine, { mode: 'batch', sourceId: 'src-a' });
|
||||
await r.resolve('Alice Example', 'people');
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
expect(calls.every(c => c.sourceId === 'src-a')).toBe(true);
|
||||
});
|
||||
|
||||
test('opts.sourceId omitted → findByTitleFuzzy receives undefined (back-compat)', async () => {
|
||||
const calls: Array<{ sourceId?: string }> = [];
|
||||
const engine = {
|
||||
async getPage() { return null; },
|
||||
async findByTitleFuzzy(_name: string, _dirPrefix?: string, _min?: number, sourceId?: string) {
|
||||
calls.push({ sourceId });
|
||||
return null;
|
||||
},
|
||||
async searchKeyword() { return []; },
|
||||
} as unknown as BrainEngine;
|
||||
const r = makeResolver(engine, { mode: 'batch' });
|
||||
await r.resolve('Alice Example', 'people');
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
expect(calls.every(c => c.sourceId === undefined)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FRONTMATTER_LINK_MAP integrity', () => {
|
||||
|
||||
@@ -32,6 +32,29 @@ describe('lintContent', () => {
|
||||
expect(issues.some(i => i.rule === 'code-fence-wrap')).toBe(true);
|
||||
});
|
||||
|
||||
test('no false positive: page CONTAINS an inner ```markdown code block', () => {
|
||||
// Real-world case: a docs/SKILL page that shows a markdown example inline.
|
||||
// Before this fix, the detector used the /m flag so ^/$ matched start/end
|
||||
// of any line, which fired on any file that simply contained a ```markdown
|
||||
// line. But fixContent's regex has no /m flag and can only strip whole-file
|
||||
// wrappers, so the issue was reported as "fixable: true" yet never fixed.
|
||||
const content =
|
||||
'---\ntitle: Skill\n---\n\n# Skill\n\nExample input shape:\n\n' +
|
||||
'```markdown\n# Inner page\nContent.\n```\n\nThat ends the example.\n';
|
||||
const issues = lintContent(content, 'test.md');
|
||||
expect(issues.filter(i => i.rule === 'code-fence-wrap')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('no false positive: multiple inner ```markdown blocks', () => {
|
||||
// Documentation pages frequently include several markdown examples.
|
||||
const content =
|
||||
'---\ntitle: Examples\n---\n\n# Examples\n\nFirst:\n\n' +
|
||||
'```markdown\nfoo\n```\n\nSecond:\n\n' +
|
||||
'```markdown\nbar\n```\n\nDone.\n';
|
||||
const issues = lintContent(content, 'test.md');
|
||||
expect(issues.filter(i => i.rule === 'code-fence-wrap')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('detects placeholder dates', () => {
|
||||
const content = '---\ntitle: Test\ntype: person\ncreated: YYYY-MM-DD\n---\n\n# Test';
|
||||
const issues = lintContent(content, 'test.md');
|
||||
|
||||
@@ -9,6 +9,7 @@ import { loadConfigWithEngine, type GBrainConfig } from '../src/core/config.ts';
|
||||
|
||||
interface FakeEngine {
|
||||
getConfig(key: string): Promise<string | null | undefined>;
|
||||
listConfigKeys?(prefix: string): Promise<string[]>;
|
||||
}
|
||||
|
||||
function makeEngine(map: Record<string, string | null | undefined>): FakeEngine {
|
||||
@@ -16,6 +17,9 @@ function makeEngine(map: Record<string, string | null | undefined>): FakeEngine
|
||||
async getConfig(key: string) {
|
||||
return map[key];
|
||||
},
|
||||
async listConfigKeys(prefix: string) {
|
||||
return Object.keys(map).filter(key => key.startsWith(prefix));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -92,6 +96,31 @@ describe('loadConfigWithEngine (Phase 4 / F3)', () => {
|
||||
expect(merged?.embedding_image_ocr).toBe(true);
|
||||
});
|
||||
|
||||
test('DB provider_base_urls.<provider> fills the gateway base URL map', async () => {
|
||||
const base: GBrainConfig = { engine: 'pglite' };
|
||||
const engine = makeEngine({
|
||||
'provider_base_urls.llama-server-reranker': 'http://127.0.0.1:8091/v1',
|
||||
});
|
||||
const merged = await loadConfigWithEngine(engine, base);
|
||||
expect(merged?.provider_base_urls?.['llama-server-reranker']).toBe('http://127.0.0.1:8091/v1');
|
||||
});
|
||||
|
||||
test('provider_base_urls merge is per-provider: file value wins and DB fills siblings', async () => {
|
||||
const base: GBrainConfig = {
|
||||
engine: 'pglite',
|
||||
provider_base_urls: {
|
||||
'llama-server-reranker': 'http://file.example/v1',
|
||||
},
|
||||
};
|
||||
const engine = makeEngine({
|
||||
'provider_base_urls.llama-server-reranker': 'http://db.example/v1',
|
||||
'provider_base_urls.openrouter': 'http://openrouter.example/v1',
|
||||
});
|
||||
const merged = await loadConfigWithEngine(engine, base);
|
||||
expect(merged?.provider_base_urls?.['llama-server-reranker']).toBe('http://file.example/v1');
|
||||
expect(merged?.provider_base_urls?.openrouter).toBe('http://openrouter.example/v1');
|
||||
});
|
||||
|
||||
test('engine.getConfig throwing is non-fatal — file/env config still returned', async () => {
|
||||
const base: GBrainConfig = {
|
||||
engine: 'pglite',
|
||||
|
||||
@@ -15,10 +15,10 @@ import {
|
||||
mkdtempSync, mkdirSync, writeFileSync, rmSync, symlinkSync, chmodSync,
|
||||
lstatSync, type Stats,
|
||||
} from 'fs';
|
||||
import { join } from 'path';
|
||||
import { join, win32, posix } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import {
|
||||
isTrustedDotfile, isPathContained, realpathOrResolve, isWriteTargetContained,
|
||||
isTrustedDotfile, isPathContained, isResolvedContained, realpathOrResolve, isWriteTargetContained,
|
||||
} from '../src/core/path-confine.ts';
|
||||
import { validateSlug } from '../src/core/utils.ts';
|
||||
import { resolveSourceId } from '../src/core/source-resolver.ts';
|
||||
@@ -149,6 +149,37 @@ describe('isPathContained', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// #3057: the containment predicate must be separator-agnostic. On Windows,
|
||||
// realpathSync returns backslash separators, and the old
|
||||
// `startsWith(parent + '/')` form was false for EVERY in-repo file — sync's
|
||||
// isPathSafe recorded every file SYMLINK_NOT_ALLOWED and froze the bookmark.
|
||||
// win32 semantics are pinned here via the injectable path module so this
|
||||
// regression is caught on POSIX CI.
|
||||
describe('isResolvedContained — win32 separators (#3057)', () => {
|
||||
test('in-repo file with backslash separators IS contained', () => {
|
||||
expect(isResolvedContained('C:\\repo\\notes\\a.md', 'C:\\repo', win32)).toBe(true);
|
||||
});
|
||||
test('root itself is contained', () => {
|
||||
expect(isResolvedContained('C:\\repo', 'C:\\repo', win32)).toBe(true);
|
||||
});
|
||||
test('upward escape is NOT contained', () => {
|
||||
expect(isResolvedContained('C:\\other\\x.md', 'C:\\repo', win32)).toBe(false);
|
||||
expect(isResolvedContained('C:\\', 'C:\\repo', win32)).toBe(false);
|
||||
});
|
||||
test('sibling prefix does not match (C:\\repo vs C:\\repofoo)', () => {
|
||||
expect(isResolvedContained('C:\\repofoo\\x.md', 'C:\\repo', win32)).toBe(false);
|
||||
});
|
||||
test('different drive is NOT contained', () => {
|
||||
expect(isResolvedContained('D:\\repo\\x.md', 'C:\\repo', win32)).toBe(false);
|
||||
});
|
||||
test('posix semantics unchanged', () => {
|
||||
expect(isResolvedContained('/repo/notes/a.md', '/repo', posix)).toBe(true);
|
||||
expect(isResolvedContained('/repofoo/x.md', '/repo', posix)).toBe(false);
|
||||
expect(isResolvedContained('/repo', '/repo', posix)).toBe(true);
|
||||
expect(isResolvedContained('/', '/repo', posix)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('realpathOrResolve', () => {
|
||||
test('resolves a symlink to its real target', () => {
|
||||
const dir = scratch();
|
||||
|
||||
@@ -1264,6 +1264,7 @@ describe('PGLiteEngine: getHealth graph metrics', () => {
|
||||
await engine.putPage('people/alice', { ...testPage, type: 'person', title: 'Alice' });
|
||||
await engine.putPage('people/bob', { ...testPage, type: 'person', title: 'Bob' });
|
||||
await engine.putPage('companies/acme', { ...testPage, type: 'company', title: 'Acme' });
|
||||
await engine.putPage('entities/project-x', { ...testPage, type: 'entity', title: 'Project X' });
|
||||
});
|
||||
|
||||
test('link_coverage = 0 when no links exist', async () => {
|
||||
@@ -1272,17 +1273,17 @@ describe('PGLiteEngine: getHealth graph metrics', () => {
|
||||
});
|
||||
|
||||
test('link_coverage = % of entity pages with >= 1 inbound link', async () => {
|
||||
// Acme gets 1 inbound link (from Alice), Alice/Bob get 0 inbound.
|
||||
// 1 of 3 entity pages has inbound links -> 33%.
|
||||
// Acme gets 1 inbound link (from Alice), Alice/Bob/Reddit get 0 inbound.
|
||||
// 1 of 4 entity pages has inbound links -> 25%.
|
||||
await engine.addLink('people/alice', 'companies/acme', '', 'works_at');
|
||||
const h = await engine.getHealth();
|
||||
expect(h.link_coverage).toBeCloseTo(1 / 3, 2);
|
||||
expect(h.link_coverage).toBeCloseTo(1 / 4, 2);
|
||||
});
|
||||
|
||||
test('timeline_coverage = % with >= 1 timeline entry', async () => {
|
||||
await engine.addTimelineEntry('people/alice', { date: '2026-01-15', summary: 'Joined' });
|
||||
const h = await engine.getHealth();
|
||||
expect(h.timeline_coverage).toBeCloseTo(1 / 3, 2);
|
||||
expect(h.timeline_coverage).toBeCloseTo(1 / 4, 2);
|
||||
});
|
||||
|
||||
test('most_connected lists top entities by link count', async () => {
|
||||
@@ -1295,14 +1296,14 @@ describe('PGLiteEngine: getHealth graph metrics', () => {
|
||||
});
|
||||
|
||||
test('orphan_pages: pages with neither inbound nor outbound links', async () => {
|
||||
// All 3 pages start with no links. Expect 3 orphans.
|
||||
// All 4 pages start with no links. Expect 4 orphans.
|
||||
const h = await engine.getHealth();
|
||||
expect(h.orphan_pages).toBe(3);
|
||||
expect(h.orphan_pages).toBe(4);
|
||||
|
||||
// Add alice -> acme. Alice has outbound, acme has inbound, only Bob is orphan.
|
||||
// Add alice -> acme. Alice has outbound, acme has inbound, Bob and Reddit are orphan.
|
||||
await engine.addLink('people/alice', 'companies/acme', '', 'works_at');
|
||||
const h2 = await engine.getHealth();
|
||||
expect(h2.orphan_pages).toBe(1);
|
||||
expect(h2.orphan_pages).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ describe('pglite-lock #2058 heartbeat + steal-grace', () => {
|
||||
if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function writeHolder(fields: { pid: number; acquiredAgoMs: number; refreshedAgoMs: number }) {
|
||||
function writeHolder(fields: { pid: number; acquiredAgoMs: number; refreshedAgoMs: number; command?: string }) {
|
||||
const lockDir = join(TEST_DIR, '.gbrain-lock');
|
||||
mkdirSync(lockDir, { recursive: true });
|
||||
const now = Date.now();
|
||||
@@ -117,7 +117,7 @@ describe('pglite-lock #2058 heartbeat + steal-grace', () => {
|
||||
pid: fields.pid,
|
||||
acquired_at: now - fields.acquiredAgoMs,
|
||||
refreshed_at: now - fields.refreshedAgoMs,
|
||||
command: 'test holder',
|
||||
command: fields.command ?? 'test holder',
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -146,6 +146,26 @@ describe('pglite-lock #2058 heartbeat + steal-grace', () => {
|
||||
expect(existsSync(join(TEST_DIR, '.gbrain-lock'))).toBe(true);
|
||||
});
|
||||
|
||||
test('explains live gbrain serve contention is not a sync advisory lock', async () => {
|
||||
writeHolder({
|
||||
pid: process.pid,
|
||||
acquiredAgoMs: 60_000,
|
||||
refreshedAgoMs: 0,
|
||||
command: 'bun /Users/master/.bun/bin/gbrain serve',
|
||||
});
|
||||
|
||||
let message = '';
|
||||
try {
|
||||
await acquireLock(TEST_DIR, { timeoutMs: 100 });
|
||||
} catch (error) {
|
||||
message = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
expect(message).toContain('serve↔sync contention');
|
||||
expect(message).toContain('not the `gbrain-sync:*` advisory lock');
|
||||
expect(message).toContain('`gbrain sync --break-lock` will not clear a live PGLite holder');
|
||||
expect(existsSync(join(TEST_DIR, '.gbrain-lock'))).toBe(true);
|
||||
});
|
||||
|
||||
test('[REGRESSION] releaseLock does NOT remove a lock that was stolen + re-acquired by another process', async () => {
|
||||
// We acquire, then simulate a steal: another process reaped us past grace
|
||||
// and now owns the lock (different pid + acquired_at). Our releaseLock must
|
||||
|
||||
@@ -218,15 +218,21 @@ describe('progress reporter', () => {
|
||||
test('only one process-level signal handler installed across many reporters', () => {
|
||||
// Baseline: one handler already installed by prior tests in this file.
|
||||
const installedBefore = __signalHandlerInstalledForTest();
|
||||
// liveReporters is module-global, so a reporter left running by ANOTHER
|
||||
// test file in the same shard shows up here. Assert the DELTA (these 50
|
||||
// lifecycles leak nothing) instead of an absolute zero — the absolute
|
||||
// form flaked whenever shard composition changed and an unrelated file
|
||||
// held a live reporter across this test.
|
||||
const liveBefore = __liveReporterCountForTest();
|
||||
const { stream } = sink(false);
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const p = createProgress({ mode: 'json', stream, minIntervalMs: 0, minItems: 1 });
|
||||
p.start(`phase_${i}`, 1);
|
||||
p.finish();
|
||||
}
|
||||
// After 50 reporter lifecycles, still exactly one handler and zero leaked live entries.
|
||||
// After 50 reporter lifecycles, still exactly one handler and no new live entries.
|
||||
expect(__signalHandlerInstalledForTest()).toBe(installedBefore || true);
|
||||
expect(__liveReporterCountForTest()).toBe(0);
|
||||
expect(__liveReporterCountForTest()).toBe(liveBefore);
|
||||
});
|
||||
|
||||
test('startHeartbeat() fires heartbeats and stop() clears', async () => {
|
||||
|
||||
@@ -27,9 +27,24 @@ import {
|
||||
} from '../../src/core/ai/gateway.ts';
|
||||
import type { PageInput, SearchOpts } from '../../src/core/types.ts';
|
||||
import type { RerankInput, RerankResult } from '../../src/core/ai/gateway.ts';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
|
||||
// These tests stub the gateway at 1536 dims (DIMS). Since v0.36.3.0 hybridSearch
|
||||
// resolves the embedding column via loadConfig(), whose precedence is
|
||||
// cfg.embedding_dimensions > gateway dims > default — so a contributor's real
|
||||
// ~/.gbrain/config.json (e.g. text-embedding-3-small at 1280) outranks the stub,
|
||||
// the 1536-d stub vector then fails the gateway dim check, search silently falls
|
||||
// back to keyword-only, and the reranker never runs (0 docs → 4 tests fail). CI
|
||||
// is green only because a fresh runner has no config file (#1527). Isolate
|
||||
// GBRAIN_HOME to an empty tmpdir so loadConfig() returns null and the stub's dims
|
||||
// win — same idiom as emptyHome() in test/ai/gateway-probe-chat-model.test.ts.
|
||||
let prevGbrainHome: string | undefined;
|
||||
let isolatedHome: string;
|
||||
|
||||
const DIMS = 1536; // gateway default embedding dim
|
||||
const FAKE_EMB = Array.from({ length: DIMS }, (_, j) => (j === 0 ? 1 : 0.01));
|
||||
|
||||
@@ -40,6 +55,12 @@ function stubEmbeddings(): void {
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
// Hermetic config home: ignore the machine's real ~/.gbrain so its
|
||||
// embedding_dimensions can't outrank the 1536-d stub (see note above, #1527).
|
||||
prevGbrainHome = process.env.GBRAIN_HOME;
|
||||
isolatedHome = mkdtempSync(join(tmpdir(), 'gbrain-rerank-home-'));
|
||||
process.env.GBRAIN_HOME = isolatedHome;
|
||||
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
@@ -85,6 +106,9 @@ afterAll(async () => {
|
||||
__setEmbedTransportForTests(null);
|
||||
resetGateway();
|
||||
await engine.disconnect();
|
||||
if (prevGbrainHome === undefined) delete process.env.GBRAIN_HOME;
|
||||
else process.env.GBRAIN_HOME = prevGbrainHome;
|
||||
rmSync(isolatedHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('hybridSearch — reranker disabled (pass-through)', () => {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { stripGapsSection } from '../src/core/think/index.ts';
|
||||
import { buildThinkSystemPrompt } from '../src/core/think/prompt.ts';
|
||||
|
||||
// `gbrain think` returns gaps in the structured `gaps` array, which both the
|
||||
// CLI (`src/commands/think.ts`) and the persisted synthesis page
|
||||
// (`persistSynthesis`) render exactly once. Older prompts also asked for a
|
||||
// "Gaps" section inside the answer prose, so a model that still emits one made
|
||||
// the output print "## Gaps" twice. `stripGapsSection` removes the prose
|
||||
// section so the structured array is the single source of truth.
|
||||
|
||||
describe('stripGapsSection', () => {
|
||||
test('removes a trailing "## Gaps" section', () => {
|
||||
const answer = 'The answer with a claim [people/alice].\n\n## Gaps\n- no update since 2026-03-22 [projects/acme]\n- pricing not recorded';
|
||||
const out = stripGapsSection(answer);
|
||||
expect(out).not.toContain('## Gaps');
|
||||
expect(out).not.toContain('no update since');
|
||||
expect(out).toContain('The answer with a claim [people/alice].');
|
||||
});
|
||||
|
||||
test('removes a level-3 "### Gaps" section', () => {
|
||||
const out = stripGapsSection('Body text.\n\n### Gaps\n- missing thing');
|
||||
expect(out).not.toMatch(/#+\s+Gaps/i);
|
||||
expect(out).toBe('Body text.');
|
||||
});
|
||||
|
||||
test('is case-insensitive', () => {
|
||||
expect(stripGapsSection('Body.\n\n## GAPS\n- x')).toBe('Body.');
|
||||
expect(stripGapsSection('Body.\n\n## gaps\n- x')).toBe('Body.');
|
||||
});
|
||||
|
||||
test('returns the answer unchanged when there is no Gaps section', () => {
|
||||
const answer = 'Just an answer.\n\n## Conflicts\n- a vs b';
|
||||
expect(stripGapsSection(answer)).toBe(answer);
|
||||
});
|
||||
|
||||
test('does not match a heading that merely starts with "Gaps"', () => {
|
||||
const answer = 'Body.\n\n## Gaps in the coverage\n- this is real content';
|
||||
expect(stripGapsSection(answer)).toBe(answer);
|
||||
});
|
||||
|
||||
test('stops at the next same-or-higher heading (preserves later content)', () => {
|
||||
const answer = 'Intro.\n\n## Gaps\n- missing x\n\n## Sources\n- [a]';
|
||||
const out = stripGapsSection(answer);
|
||||
expect(out).not.toContain('missing x');
|
||||
expect(out).toContain('## Sources');
|
||||
expect(out).toContain('- [a]');
|
||||
});
|
||||
|
||||
test('handles empty / falsy input', () => {
|
||||
expect(stripGapsSection('')).toBe('');
|
||||
});
|
||||
|
||||
test('the bug repro: strip + structured render yields exactly one "## Gaps"', () => {
|
||||
// Mirrors the render in src/commands/think.ts: print the (stripped) answer,
|
||||
// then append one "## Gaps" block from the structured `gaps` array.
|
||||
const answer = 'Answer prose [people/alice].\n\n## Gaps\n- the prose gap, slightly different wording';
|
||||
const gaps = ['the structured gap'];
|
||||
const rendered =
|
||||
stripGapsSection(answer) + '\n\n## Gaps\n' + gaps.map((g) => `- ${g}`).join('\n');
|
||||
expect((rendered.match(/## Gaps/g) ?? []).length).toBe(1);
|
||||
expect(rendered).toContain('the structured gap');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildThinkSystemPrompt — gaps go in the structured array, not the answer body', () => {
|
||||
test('the answer schema no longer lists "Gaps" as a body section', () => {
|
||||
const out = buildThinkSystemPrompt({});
|
||||
expect(out).not.toContain('Sections: Answer, Conflicts (optional), Gaps');
|
||||
expect(out).toContain('gaps belong in the gaps array');
|
||||
});
|
||||
|
||||
test('still requires the structured "gaps" array', () => {
|
||||
const out = buildThinkSystemPrompt({});
|
||||
expect(out).toContain('"gaps"');
|
||||
});
|
||||
|
||||
test('preserves the Conflicts section and the Hard rules', () => {
|
||||
const out = buildThinkSystemPrompt({});
|
||||
expect(out).toContain('Conflicts');
|
||||
expect(out).toContain('Hard rules:');
|
||||
expect(out).toContain('Cite EVERY substantive claim');
|
||||
});
|
||||
|
||||
test('willSave mode routes gaps to the structured array (no body Gaps section)', () => {
|
||||
const out = buildThinkSystemPrompt({ willSave: true });
|
||||
expect(out).not.toContain('cover Answer, Conflicts, and Gaps thoroughly');
|
||||
expect(out).toContain('structured "gaps" array');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { TrajectoryPoint } from '../src/core/engine.ts';
|
||||
import {
|
||||
DEFAULT_REGRESSION_THRESHOLD,
|
||||
detectRegressions,
|
||||
} from '../src/core/trajectory.ts';
|
||||
|
||||
function point(args: {
|
||||
id: number;
|
||||
metric?: string;
|
||||
value: number;
|
||||
date: string;
|
||||
}): TrajectoryPoint {
|
||||
return {
|
||||
fact_id: args.id,
|
||||
valid_from: new Date(args.date),
|
||||
metric: args.metric ?? 'net_income',
|
||||
value: args.value,
|
||||
unit: 'USD',
|
||||
period: 'monthly',
|
||||
event_type: null,
|
||||
text: `${args.metric ?? 'net_income'} = ${args.value}`,
|
||||
source_session: null,
|
||||
source_markdown_slug: null,
|
||||
embedding: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('detectRegressions', () => {
|
||||
test('keeps existing positive-valued drop behavior', () => {
|
||||
const regs = detectRegressions([
|
||||
point({ id: 1, metric: 'mrr', value: 200000, date: '2026-01-01' }),
|
||||
point({ id: 2, metric: 'mrr', value: 150000, date: '2026-02-01' }),
|
||||
], DEFAULT_REGRESSION_THRESHOLD);
|
||||
|
||||
expect(regs).toHaveLength(1);
|
||||
expect(regs[0]).toMatchObject({
|
||||
metric: 'mrr',
|
||||
from_value: 200000,
|
||||
to_value: 150000,
|
||||
});
|
||||
expect(regs[0].delta_pct).toBeCloseTo(-0.25, 4);
|
||||
});
|
||||
|
||||
test('does not flag a negative-valued metric improving toward zero', () => {
|
||||
const regs = detectRegressions([
|
||||
point({ id: 1, value: -1000, date: '2026-01-01' }),
|
||||
point({ id: 2, value: -500, date: '2026-02-01' }),
|
||||
], DEFAULT_REGRESSION_THRESHOLD);
|
||||
|
||||
expect(regs).toEqual([]);
|
||||
});
|
||||
|
||||
test('flags a negative-valued metric worsening away from zero', () => {
|
||||
const regs = detectRegressions([
|
||||
point({ id: 1, value: -500, date: '2026-01-01' }),
|
||||
point({ id: 2, value: -1000, date: '2026-02-01' }),
|
||||
], DEFAULT_REGRESSION_THRESHOLD);
|
||||
|
||||
expect(regs).toHaveLength(1);
|
||||
expect(regs[0]).toMatchObject({
|
||||
metric: 'net_income',
|
||||
from_value: -500,
|
||||
to_value: -1000,
|
||||
from_date: '2026-01-01',
|
||||
to_date: '2026-02-01',
|
||||
});
|
||||
expect(regs[0].delta_pct).toBeCloseTo(-1.0, 4);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user