mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-15 09:22:18 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eeecc90968 | ||
|
|
ca47c054b8 | ||
|
|
97df1e78b7 | ||
|
|
1392243d3b | ||
|
|
8345abce42 |
@@ -515,60 +515,6 @@ async function registerClient(name: string, args: string[]) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.42.x (#1914): rescope an existing OAuth client's write source and/or
|
||||
* federated read scope. This is the operator surface the DCR registration
|
||||
* comment promised ("rescope via the CLI later") — DCR clients land with
|
||||
* source_id='default' / federated_read=['default'] and must not self-widen,
|
||||
* so widening happens here (trusted local CLI) or via the requireAdmin
|
||||
* /admin/api/rescope-client endpoint.
|
||||
*/
|
||||
async function rescopeClient(clientId: string, args: string[]) {
|
||||
const usage = 'Usage: auth rescope-client <client_id> [--source SOURCE] [--federated-read SRC1,SRC2,...]';
|
||||
if (!clientId) {
|
||||
console.error(usage);
|
||||
process.exit(1);
|
||||
}
|
||||
let sourceId: string | undefined;
|
||||
let federatedRead: string[] | undefined;
|
||||
for (let i = 0; i < args.length; i += 2) {
|
||||
const flag = args[i];
|
||||
const value = args[i + 1];
|
||||
if (value === undefined || value.startsWith('--')) {
|
||||
console.error(`Error: ${flag} requires a value`);
|
||||
console.error(usage);
|
||||
process.exit(1);
|
||||
}
|
||||
if (flag === '--source') sourceId = value;
|
||||
else if (flag === '--federated-read') {
|
||||
federatedRead = value.split(',').map(s => s.trim()).filter(Boolean);
|
||||
} else {
|
||||
console.error(`Error: Unknown flag: ${flag}`);
|
||||
console.error(usage);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
if (sourceId === undefined && federatedRead === undefined) {
|
||||
console.error('Error: pass --source and/or --federated-read');
|
||||
console.error(usage);
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
await withConfiguredSql(async (sql) => {
|
||||
const { GBrainOAuthProvider } = await import('../core/oauth-provider.ts');
|
||||
const provider = new GBrainOAuthProvider({ sql });
|
||||
const result = await provider.rescopeClient(clientId, { sourceId, federatedRead });
|
||||
console.log(`OAuth client rescoped: "${result.clientName}" (${result.clientId})\n`);
|
||||
console.log(` Write source: ${result.sourceId}`);
|
||||
console.log(` Federated reads: ${result.federatedRead.join(', ') || '<none>'}`);
|
||||
console.log('\nTakes effect on the client\'s next request (existing tokens included).');
|
||||
});
|
||||
} catch (e: any) {
|
||||
console.error('Error:', e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry point for the `gbrain auth` CLI subcommand. Also reused by the
|
||||
* direct-script path (see bottom of file) so `bun run src/commands/auth.ts`
|
||||
@@ -610,7 +556,6 @@ export async function runAuth(args: string[]): Promise<void> {
|
||||
return;
|
||||
}
|
||||
case 'register-client': await registerClient(rest[0], rest.slice(1)); return;
|
||||
case 'rescope-client': await rescopeClient(rest[0], rest.slice(1)); return;
|
||||
case 'revoke-client': await revokeClient(rest[0]); return;
|
||||
case 'test': {
|
||||
const tokenIdx = rest.indexOf('--token');
|
||||
@@ -648,11 +593,6 @@ Usage:
|
||||
--bound-slug-prefixes <prefix1,prefix2> Bind submit_agent writes to slug prefixes
|
||||
--bound-max-concurrent <n> Bound submit_agent concurrency (default: 1)
|
||||
--budget-usd-per-day <usd> Bound submit_agent daily spend cap
|
||||
gbrain auth rescope-client <client_id> [options] Change an existing client's source scope (e.g. a DCR
|
||||
client stuck on the 'default' source). Only the flags
|
||||
you pass change; the other axis is left as-is.
|
||||
--source <id> New write source
|
||||
--federated-read <id1,id2,...> New read-scope source list
|
||||
gbrain auth revoke-client <client_id> Hard-delete an OAuth 2.1 client (cascades to tokens + codes)
|
||||
gbrain auth test <url> --token <token> Smoke-test a remote MCP server
|
||||
`);
|
||||
|
||||
+3
-20
@@ -249,13 +249,6 @@ async function resolveAIOptions(opts: ResolveAIOptionsArgs): Promise<ResolvedAIO
|
||||
|
||||
// --- Tier 1+2: explicit flags ---------------------------------------------
|
||||
|
||||
// #2301: an explicit embedding flag on THIS invocation overrides the
|
||||
// persisted deferred-setup sentinel above. Without this, a stale
|
||||
// `embedding_disabled: true` in config.json made every re-init defer
|
||||
// embedding — including `gbrain init --embedding-model ...`, the exact
|
||||
// recovery path the deferred-setup message tells users to take.
|
||||
if (verbose || shorthand) delete out.noEmbedding;
|
||||
|
||||
if (verbose) {
|
||||
out.embedding_model = verbose;
|
||||
} else if (shorthand) {
|
||||
@@ -442,7 +435,7 @@ function printNoEmbeddingProviderHint(typos: Array<{ userSet: string; suggested:
|
||||
console.error(' gbrain init --pglite --embedding-model openai:text-embedding-3-large');
|
||||
console.error('');
|
||||
console.error('Or defer setup: gbrain init --pglite --no-embedding');
|
||||
console.error(' (you can configure later with `gbrain init --force --embedding-model <provider>:<model>`)');
|
||||
console.error(' (you can configure later with `gbrain config set embedding_model <id>`)');
|
||||
// D13: surface near-miss env vars (e.g. OPENAPI_API_KEY → OPENAI_API_KEY).
|
||||
if (typos.length > 0) {
|
||||
console.error('');
|
||||
@@ -840,7 +833,7 @@ async function initPGLite(opts: {
|
||||
let resolvedModel: string | undefined;
|
||||
if (opts.aiOpts?.noEmbedding) {
|
||||
// D9 deferred-setup mode: skip preflight, no model/dim resolved.
|
||||
console.log(` --no-embedding: deferred setup — run \`gbrain init --force --embedding-model <provider>:<model>\` before import`);
|
||||
console.log(` --no-embedding: deferred setup — configure with \`gbrain config set embedding_model <id>\` before import`);
|
||||
} else if (opts.aiOpts?.embedding_model) {
|
||||
const { resolveSchemaEmbeddingDim } = await import('../core/embedding-dim-check.ts');
|
||||
const pre = resolveSchemaEmbeddingDim({
|
||||
@@ -979,12 +972,6 @@ async function initPGLite(opts: {
|
||||
// unless explicitly overridden by --schema-pack on re-init.
|
||||
...(opts.schemaPack ? { schema_pack: opts.schemaPack } : {}),
|
||||
};
|
||||
// #2301: a resolved embedding model supersedes any stale deferred-setup
|
||||
// sentinel carried over via ...existingFile — otherwise the sentinel
|
||||
// re-defers embedding on every future init/embed forever.
|
||||
if (!opts.aiOpts?.noEmbedding && resolvedModel && resolvedDim) {
|
||||
delete config.embedding_disabled;
|
||||
}
|
||||
// PR1: new installs publish their skill catalog over MCP by default
|
||||
// (existing config wins on re-init, so a prior opt-out is preserved).
|
||||
config.mcp = { publish_skills: true, ...(config.mcp ?? {}) };
|
||||
@@ -1069,7 +1056,7 @@ async function initPostgres(opts: {
|
||||
let resolvedDim: number | undefined;
|
||||
let resolvedModel: string | undefined;
|
||||
if (opts.aiOpts?.noEmbedding) {
|
||||
console.log(` --no-embedding: deferred setup — run \`gbrain init --force --embedding-model <provider>:<model>\` before import`);
|
||||
console.log(` --no-embedding: deferred setup — configure with \`gbrain config set embedding_model <id>\` before import`);
|
||||
} else if (opts.aiOpts?.embedding_model) {
|
||||
const { resolveSchemaEmbeddingDim } = await import('../core/embedding-dim-check.ts');
|
||||
const pre = resolveSchemaEmbeddingDim({
|
||||
@@ -1233,10 +1220,6 @@ async function initPostgres(opts: {
|
||||
// v0.42 (T17): same schema_pack default as PGLite path.
|
||||
...(opts.schemaPack ? { schema_pack: opts.schemaPack } : {}),
|
||||
};
|
||||
// #2301: same stale-sentinel drop as the PGLite path above.
|
||||
if (!opts.aiOpts?.noEmbedding && resolvedModel && resolvedDim) {
|
||||
delete config.embedding_disabled;
|
||||
}
|
||||
// PR1: new installs publish their skill catalog over MCP by default
|
||||
// (existing config wins on re-init, so a prior opt-out is preserved).
|
||||
config.mcp = { publish_skills: true, ...(config.mcp ?? {}) };
|
||||
|
||||
@@ -1567,38 +1567,6 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
|
||||
}
|
||||
});
|
||||
|
||||
// v0.42.x (#1914): rescope an OAuth client's write source / federated read
|
||||
// scope. Admin-gated on purpose — DCR clients must never self-widen their
|
||||
// scope (fail-closed trust); only the operator rescopes, here or via
|
||||
// `gbrain auth rescope-client`. Source ids are validated by the canonical
|
||||
// validator inside rescopeClient.
|
||||
app.post('/admin/api/rescope-client', requireAdmin, express.json(), async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { clientId, sourceId, federatedRead } = req.body ?? {};
|
||||
if (!clientId || typeof clientId !== 'string') {
|
||||
res.status(400).json({ error: 'clientId required' });
|
||||
return;
|
||||
}
|
||||
if (federatedRead !== undefined &&
|
||||
!(Array.isArray(federatedRead) && federatedRead.every((s: unknown) => typeof s === 'string'))) {
|
||||
res.status(400).json({ error: 'federatedRead must be an array of source id strings' });
|
||||
return;
|
||||
}
|
||||
if (sourceId !== undefined && typeof sourceId !== 'string') {
|
||||
res.status(400).json({ error: 'sourceId must be a string' });
|
||||
return;
|
||||
}
|
||||
const result = await oauthProvider.rescopeClient(clientId, { sourceId, federatedRead });
|
||||
res.json(result);
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : 'Rescope failed';
|
||||
const status = /No OAuth client found/.test(message) ? 404
|
||||
: /Invalid source_id|requires --source|cannot be empty|does not exist/.test(message) ? 400
|
||||
: 500;
|
||||
res.status(status).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Revoke OAuth client
|
||||
app.post('/admin/api/revoke-client', requireAdmin, express.json(), async (req: Request, res: Response) => {
|
||||
try {
|
||||
|
||||
@@ -486,9 +486,44 @@ const DEFAULT_PARALLELISM = 4;
|
||||
* src/core/errors.ts (the v0.19.0 envelope every new agent-facing
|
||||
* surface uses) rather than introducing a new BrainstormError class.
|
||||
*/
|
||||
/** File-config slice the orchestrator reads (see loadConfig in core/config.ts). */
|
||||
export interface BrainstormRunConfig {
|
||||
embedding_model?: string;
|
||||
chat_model?: string;
|
||||
emotional_weight?: { user_holder?: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Model used for the cost preview + hard cost ceiling. Mirrors what the
|
||||
* gateway will actually run: explicit --model override, else the configured
|
||||
* chat_model (gateway default), else the hardcoded gateway fallback. Before
|
||||
* this resolved through config, a non-Sonnet chat_model got its preview
|
||||
* priced against the wrong model. (Takeover of PR #1855 by @starm2010.)
|
||||
*/
|
||||
export function resolveBrainstormChatModel(
|
||||
config: { chat_model?: string },
|
||||
modelOverride?: string,
|
||||
): string {
|
||||
return modelOverride ?? config.chat_model ?? 'anthropic:claude-sonnet-4-6';
|
||||
}
|
||||
|
||||
/**
|
||||
* Judge-phase model precedence: --judge-model flag, else the
|
||||
* `models.brainstorm.judge` config key, else undefined (falls back to
|
||||
* `modelOverride` then the gateway default at the runJudge callsite).
|
||||
*/
|
||||
export async function resolveBrainstormJudgeModel(
|
||||
engine: BrainEngine,
|
||||
judgeModelFlag?: string,
|
||||
): Promise<string | undefined> {
|
||||
if (judgeModelFlag) return judgeModelFlag;
|
||||
const configured = await engine.getConfig('models.brainstorm.judge');
|
||||
return configured ?? undefined;
|
||||
}
|
||||
|
||||
export async function runBrainstorm(
|
||||
engine: BrainEngine,
|
||||
config: { embedding_model?: string; emotional_weight?: { user_holder?: string } },
|
||||
config: BrainstormRunConfig,
|
||||
opts: BrainstormOptions
|
||||
): Promise<BrainstormResult> {
|
||||
// v0.39.3.0 (Phase 5, CV11+T4): outer try/catch around the orchestrator
|
||||
@@ -510,7 +545,7 @@ export async function runBrainstorm(
|
||||
|
||||
async function runBrainstormImpl(
|
||||
engine: BrainEngine,
|
||||
config: { embedding_model?: string; emotional_weight?: { user_holder?: string } },
|
||||
config: BrainstormRunConfig,
|
||||
opts: BrainstormOptions,
|
||||
): Promise<BrainstormResult> {
|
||||
// v0.39.0.0 T10: install a gateway-layer BudgetTracker scope around the
|
||||
@@ -530,7 +565,7 @@ async function runBrainstormImpl(
|
||||
|
||||
async function _runBrainstormInner(
|
||||
engine: BrainEngine,
|
||||
config: { embedding_model?: string; emotional_weight?: { user_holder?: string } },
|
||||
config: BrainstormRunConfig,
|
||||
opts: BrainstormOptions,
|
||||
): Promise<BrainstormResult> {
|
||||
const profile = opts.profile ?? BRAINSTORM_PROFILE;
|
||||
@@ -539,7 +574,7 @@ async function _runBrainstormInner(
|
||||
const embedFn = opts.embedQueryFn ?? embedQuery;
|
||||
|
||||
// ---- Phase 0: cost preview + TTY grace ----
|
||||
const modelStr = opts.modelOverride ?? 'anthropic:claude-sonnet-4-6';
|
||||
const modelStr = resolveBrainstormChatModel(config, opts.modelOverride);
|
||||
const { aborted, estimate } = await previewCostAndWait({
|
||||
profile,
|
||||
model: modelStr,
|
||||
@@ -848,7 +883,7 @@ async function _runBrainstormInner(
|
||||
far_slug: i.far_slug,
|
||||
}));
|
||||
const judgeResult = await runJudge(profile.judge_config, judgeInput, {
|
||||
modelOverride: opts.judgeModel ?? opts.modelOverride,
|
||||
modelOverride: (await resolveBrainstormJudgeModel(engine, opts.judgeModel)) ?? opts.modelOverride,
|
||||
chatFn: opts.chatFn,
|
||||
activeBiasTags: activeBiasTags ?? undefined,
|
||||
abortSignal: opts.abortSignal,
|
||||
|
||||
@@ -962,6 +962,7 @@ export const KNOWN_CONFIG_KEYS: readonly string[] = [
|
||||
'models.subagent',
|
||||
'models.expansion',
|
||||
'models.chat',
|
||||
'models.brainstorm.judge',
|
||||
'models.eval.longmemeval',
|
||||
'facts.extraction_model',
|
||||
// #2113: output-token cap for the per-turn facts extractor (default 4000).
|
||||
|
||||
@@ -39,11 +39,11 @@
|
||||
|
||||
import { randomUUID, createHash } from 'node:crypto';
|
||||
import { BaseCyclePhase, type ScopedReadOpts, type BasePhaseOpts } from './base-phase.ts';
|
||||
import { chat as gatewayChat, getChatModel } from '../ai/gateway.ts';
|
||||
import { chat as gatewayChat, getChatModel, probeChatModel } from '../ai/gateway.ts';
|
||||
import { normalizeModelId } from '../model-id.ts';
|
||||
import { writeReceipt } from '../extract/receipt-writer.ts';
|
||||
import { upsertExtractRollup } from '../extract/rollup-writer.ts';
|
||||
import { GBrainError } from '../types.ts';
|
||||
import type { Page, PageFilters } from '../types.ts';
|
||||
import type { OperationContext } from '../operations.ts';
|
||||
import type { BrainEngine } from '../engine.ts';
|
||||
import type { PhaseStatus, CyclePhase } from '../cycle.ts';
|
||||
@@ -160,6 +160,48 @@ export interface ProposeTakesResult {
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/** Narrow projection of `pages` — the only columns this phase reads. */
|
||||
interface ProposeTakesPageRow {
|
||||
slug: string;
|
||||
source_id: string;
|
||||
compiled_truth: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load proposal candidates with a narrow projection instead of
|
||||
* `engine.listPages` (`SELECT p.*`). The phase only reads slug, source_id
|
||||
* and compiled_truth — skipping timeline/frontmatter/title keeps large
|
||||
* toasted columns out of the hot path. Scope precedence mirrors
|
||||
* `sourceScopeOpts`: federated array (`sourceIds`) beats scalar
|
||||
* (`sourceId`); ordering matches `PAGE_SORT_SQL.updated_desc` with an id
|
||||
* tiebreak for determinism. (Takeover of PR #1979's projection by
|
||||
* @shawnduggan.)
|
||||
*/
|
||||
async function listCandidatePages(
|
||||
engine: BrainEngine,
|
||||
scope: ScopedReadOpts,
|
||||
limit: number,
|
||||
): Promise<ProposeTakesPageRow[]> {
|
||||
const where = ['deleted_at IS NULL'];
|
||||
const params: unknown[] = [];
|
||||
if (scope.sourceIds && scope.sourceIds.length > 0) {
|
||||
params.push(scope.sourceIds);
|
||||
where.push(`source_id = ANY($${params.length}::text[])`);
|
||||
} else if (scope.sourceId) {
|
||||
params.push(scope.sourceId);
|
||||
where.push(`source_id = $${params.length}`);
|
||||
}
|
||||
params.push(limit);
|
||||
return engine.executeRaw<ProposeTakesPageRow>(
|
||||
`SELECT slug, source_id, compiled_truth
|
||||
FROM pages
|
||||
WHERE ${where.join(' AND ')}
|
||||
ORDER BY updated_at DESC, id DESC
|
||||
LIMIT $${params.length}`,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the content_hash key for the idempotency cache. SHA-256 of the
|
||||
* page body suffices — page slug + prompt_version are separate columns in
|
||||
@@ -330,6 +372,34 @@ class ProposeTakesPhase extends BaseCyclePhase {
|
||||
const phaseStartMs = Date.now();
|
||||
const proposalRunId = `propose-${new Date().toISOString().slice(0, 19).replace(/[-:T]/g, '')}-${randomUUID().slice(0, 8)}`;
|
||||
|
||||
const modelId = opts.model ?? getChatModel();
|
||||
|
||||
// With the default (gateway) extractor, skip cheaply when the resolved
|
||||
// model's provider can't run — same probe semantics as patterns.ts /
|
||||
// think/index.ts: unknown provider/model or Anthropic-without-key skips;
|
||||
// other providers' auth surfaces lazily at chat() time. An injected
|
||||
// extractor bypasses the gateway, so it is never gated. (Takeover of
|
||||
// PR #1979's intent by @shawnduggan.)
|
||||
if (!opts.extractor) {
|
||||
const probe = probeChatModel(normalizeModelId(modelId));
|
||||
if (!probe.ok) {
|
||||
return {
|
||||
summary: `propose_takes skipped: ${probe.detail}`,
|
||||
details: {
|
||||
reason: 'no_provider',
|
||||
model: modelId,
|
||||
pages_scanned: 0,
|
||||
cache_hits: 0,
|
||||
cache_misses: 0,
|
||||
proposals_inserted: 0,
|
||||
budget_exhausted: false,
|
||||
warnings: [],
|
||||
},
|
||||
status: 'skipped',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const result: ProposeTakesResult = {
|
||||
pages_scanned: 0,
|
||||
cache_hits: 0,
|
||||
@@ -340,19 +410,12 @@ class ProposeTakesPhase extends BaseCyclePhase {
|
||||
};
|
||||
|
||||
// Load pages eligible for proposal. Source-scoped per BaseCyclePhase.
|
||||
const pageFilters: PageFilters = {
|
||||
...scope,
|
||||
limit: pageLimit,
|
||||
sort: 'updated_desc',
|
||||
};
|
||||
const pages: Page[] = await engine.listPages(pageFilters);
|
||||
const pages = await listCandidatePages(engine, scope, pageLimit);
|
||||
|
||||
if (opts.reporter) {
|
||||
opts.reporter.start('propose_takes.pages' as never, pages.length);
|
||||
}
|
||||
|
||||
const modelId = opts.model ?? getChatModel();
|
||||
|
||||
for (const page of pages) {
|
||||
// Phase deadline check. Break (not throw) so the phase returns a
|
||||
// partial result with deadline_hit:true; work already banked stays.
|
||||
@@ -509,4 +572,5 @@ export const __testing = {
|
||||
contentHash,
|
||||
hasCompleteFence,
|
||||
extractExistingTakesForDedup,
|
||||
listCandidatePages,
|
||||
};
|
||||
|
||||
@@ -71,8 +71,9 @@ export function assertEmbeddingEnabled(cfg: { embedding_disabled?: boolean } | n
|
||||
throw new EmbeddingDisabledError(
|
||||
'This brain was initialized with `--no-embedding` (deferred setup).\n' +
|
||||
'Configure an embedding provider before running embed / import:\n' +
|
||||
' gbrain init --force --embedding-model <provider>:<model> # re-init to size schema\n' +
|
||||
'(`gbrain config set embedding_model` is refused — schema-sizing fields are set at init.)\n',
|
||||
' gbrain config set embedding_model <provider>:<model>\n' +
|
||||
' gbrain config set embedding_dimensions <N>\n' +
|
||||
' gbrain init --force --embedding-model <provider>:<model> # re-init to size schema\n',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ import type { OAuthRegisteredClientsStore } from '@modelcontextprotocol/sdk/serv
|
||||
import type { AuthInfo as SdkAuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js';
|
||||
import { InvalidTokenError, InvalidClientMetadataError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
|
||||
import { hashToken, generateToken, isUndefinedColumnError } from './utils.ts';
|
||||
import { assertValidSourceId } from './source-id.ts';
|
||||
import { hasScope, assertAllowedScopes, parseScopeString, InvalidScopeError } from './scope.ts';
|
||||
import type { AuthInfo as CoreAuthInfo } from './operations.ts';
|
||||
import { parseLegacyTokenScope } from './legacy-token-scope.ts';
|
||||
@@ -1007,66 +1006,6 @@ export class GBrainOAuthProvider implements OAuthServerProvider {
|
||||
return { clientId, clientSecret };
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.42.x (#1914): admin-gated rescope for an existing OAuth client.
|
||||
*
|
||||
* DCR clients self-register with source_id='default' +
|
||||
* federated_read=['default'] and MUST NOT be able to widen their own
|
||||
* scope (fail-closed trust). This is the trusted-operator surface that
|
||||
* changes it afterward: `gbrain auth rescope-client` (local CLI) and
|
||||
* POST /admin/api/rescope-client (requireAdmin) both route here.
|
||||
*
|
||||
* Omitted fields are left untouched (COALESCE). Takes effect on the
|
||||
* client's NEXT request even for already-issued tokens, because
|
||||
* verifyAccessToken re-reads oauth_clients on every verification.
|
||||
*/
|
||||
async rescopeClient(
|
||||
clientId: string,
|
||||
opts: { sourceId?: string; federatedRead?: string[] },
|
||||
): Promise<{ clientId: string; clientName: string; sourceId: string; federatedRead: string[] }> {
|
||||
const { sourceId, federatedRead } = opts;
|
||||
if (sourceId === undefined && federatedRead === undefined) {
|
||||
throw new Error('rescope-client requires --source and/or --federated-read');
|
||||
}
|
||||
if (sourceId !== undefined) assertValidSourceId(sourceId);
|
||||
if (federatedRead !== undefined) {
|
||||
if (federatedRead.length === 0) {
|
||||
throw new Error('--federated-read cannot be empty (pass at least one source id)');
|
||||
}
|
||||
for (const s of federatedRead) assertValidSourceId(s);
|
||||
}
|
||||
let rows: Record<string, unknown>[];
|
||||
try {
|
||||
rows = await this.sql`
|
||||
UPDATE oauth_clients
|
||||
SET source_id = COALESCE(${sourceId ?? null}::text, source_id),
|
||||
federated_read = COALESCE(${federatedRead ? pgArray(federatedRead) : null}::text[], federated_read)
|
||||
WHERE client_id = ${clientId}
|
||||
RETURNING client_id, client_name, source_id, federated_read
|
||||
`;
|
||||
} catch (err) {
|
||||
if (isUndefinedColumnError(err, 'source_id') || isUndefinedColumnError(err, 'federated_read')) {
|
||||
throw new Error('rescope-client requires an up-to-date OAuth schema; run `gbrain apply-migrations --yes` and retry.');
|
||||
}
|
||||
// FK oauth_clients.source_id → sources(id): translate the raw 23503
|
||||
// into an actionable message.
|
||||
if ((err as { code?: string })?.code === '23503') {
|
||||
throw new Error(`Source "${sourceId}" does not exist. Create it first: gbrain sources add ${sourceId} ...`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
if (rows.length === 0) {
|
||||
throw new Error(`No OAuth client found with id "${clientId}"`);
|
||||
}
|
||||
const row = rows[0];
|
||||
return {
|
||||
clientId: row.client_id as string,
|
||||
clientName: (row.client_name as string | null) ?? '',
|
||||
sourceId: (row.source_id as string | null) ?? 'default',
|
||||
federatedRead: Array.isArray(row.federated_read) ? (row.federated_read as string[]) : [],
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Internal: Issue access + optional refresh tokens
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -4835,11 +4835,11 @@ export class PGLiteEngine implements BrainEngine {
|
||||
const { rows } = await this.db.query(
|
||||
`SELECT t.id AS take_id, t.page_id, p.slug AS page_slug, t.row_num,
|
||||
t.claim, t.kind, t.holder, t.weight,
|
||||
similarity(t.claim, $1)::real AS score
|
||||
word_similarity($1, t.claim)::real AS score
|
||||
FROM takes t
|
||||
JOIN pages p ON p.id = t.page_id
|
||||
WHERE t.active
|
||||
AND t.claim % $1
|
||||
AND $1 <% t.claim
|
||||
AND ($2::text[] IS NULL OR t.holder = ANY($2::text[]))
|
||||
AND ($4::text[] IS NULL OR p.source_id = ANY($4::text[]))
|
||||
AND ($5::text IS NULL OR p.source_id = $5::text)
|
||||
|
||||
@@ -4962,11 +4962,11 @@ export class PostgresEngine implements BrainEngine {
|
||||
const rows = await sql`
|
||||
SELECT t.id AS take_id, t.page_id, p.slug AS page_slug, t.row_num,
|
||||
t.claim, t.kind, t.holder, t.weight,
|
||||
similarity(t.claim, ${query})::real AS score
|
||||
word_similarity(${query}, t.claim)::real AS score
|
||||
FROM takes t
|
||||
JOIN pages p ON p.id = t.page_id
|
||||
WHERE t.active
|
||||
AND t.claim % ${query}
|
||||
AND ${query} <% t.claim
|
||||
AND (
|
||||
${opts.takesHoldersAllowList ?? null}::text[] IS NULL
|
||||
OR t.holder = ANY(${opts.takesHoldersAllowList ?? null}::text[])
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Brainstorm model configurability (takeover of PR #1855 by @starm2010).
|
||||
*
|
||||
* - The cost preview + hard cost ceiling price the model that will actually
|
||||
* run: --model override → configured chat_model → gateway fallback. Before
|
||||
* this, the preview always priced anthropic:claude-sonnet-4-6 even when
|
||||
* the configured chat_model was something else.
|
||||
* - The judge phase honors the `models.brainstorm.judge` config key when no
|
||||
* --judge-model flag is passed.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import {
|
||||
resolveBrainstormChatModel,
|
||||
resolveBrainstormJudgeModel,
|
||||
} from '../../src/core/brainstorm/orchestrator.ts';
|
||||
import type { BrainEngine } from '../../src/core/engine.ts';
|
||||
|
||||
function mockEngine(configValues: Record<string, string>): { engine: BrainEngine; reads: string[] } {
|
||||
const reads: string[] = [];
|
||||
const engine = {
|
||||
async getConfig(key: string): Promise<string | null> {
|
||||
reads.push(key);
|
||||
return configValues[key] ?? null;
|
||||
},
|
||||
} as unknown as BrainEngine;
|
||||
return { engine, reads };
|
||||
}
|
||||
|
||||
describe('resolveBrainstormChatModel', () => {
|
||||
test('--model override wins over config', () => {
|
||||
expect(resolveBrainstormChatModel({ chat_model: 'openai:gpt-5' }, 'anthropic:claude-opus-4-6'))
|
||||
.toBe('anthropic:claude-opus-4-6');
|
||||
});
|
||||
|
||||
test('configured chat_model wins over the hardcoded fallback', () => {
|
||||
expect(resolveBrainstormChatModel({ chat_model: 'openai:gpt-5' }))
|
||||
.toBe('openai:gpt-5');
|
||||
});
|
||||
|
||||
test('falls back to the gateway default model when nothing is configured', () => {
|
||||
expect(resolveBrainstormChatModel({})).toBe('anthropic:claude-sonnet-4-6');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveBrainstormJudgeModel', () => {
|
||||
test('--judge-model flag wins without touching config', async () => {
|
||||
const { engine, reads } = mockEngine({ 'models.brainstorm.judge': 'openai:gpt-5' });
|
||||
const out = await resolveBrainstormJudgeModel(engine, 'anthropic:claude-opus-4-6');
|
||||
expect(out).toBe('anthropic:claude-opus-4-6');
|
||||
expect(reads).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('models.brainstorm.judge config key is honored when no flag is passed', async () => {
|
||||
const { engine, reads } = mockEngine({ 'models.brainstorm.judge': 'openai:gpt-5' });
|
||||
const out = await resolveBrainstormJudgeModel(engine);
|
||||
expect(out).toBe('openai:gpt-5');
|
||||
expect(reads).toEqual(['models.brainstorm.judge']);
|
||||
});
|
||||
|
||||
test('returns undefined (defer to modelOverride / gateway default) when unset', async () => {
|
||||
const { engine } = mockEngine({});
|
||||
expect(await resolveBrainstormJudgeModel(engine)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,111 +0,0 @@
|
||||
/**
|
||||
* #2301 — re-init with an explicit --embedding-model must recover a brain
|
||||
* that was initialized with --no-embedding (deferred setup).
|
||||
*
|
||||
* Pre-fix: resolveAIOptions honored the persisted `embedding_disabled: true`
|
||||
* sentinel BEFORE the explicit flag and never cleared noEmbedding, and the
|
||||
* persistence merge carried the sentinel forward via ...existingFile. Result:
|
||||
* every re-init (including the recovery command the deferred-setup error
|
||||
* itself recommends) silently re-deferred embedding, forever.
|
||||
*
|
||||
* Hermetic: in-process runInit, GBRAIN_HOME pinned to a tmpdir (same pattern
|
||||
* as test/e2e/fresh-install-pglite.test.ts).
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { mkdtempSync, rmSync, readFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { configureGateway, resetGateway } from '../../src/core/ai/gateway.ts';
|
||||
|
||||
describe('E2E: re-init with --embedding-model after --no-embedding init (#2301)', () => {
|
||||
let tmpHome: string;
|
||||
let origHome: string | undefined;
|
||||
let origZeKey: string | undefined;
|
||||
let origOpenaiKey: string | undefined;
|
||||
let origVoyageKey: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpHome = mkdtempSync(join(tmpdir(), 'gbrain-e2e-reinit-'));
|
||||
origHome = process.env.GBRAIN_HOME;
|
||||
origZeKey = process.env.ZEROENTROPY_API_KEY;
|
||||
origOpenaiKey = process.env.OPENAI_API_KEY;
|
||||
origVoyageKey = process.env.VOYAGE_API_KEY;
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
delete process.env.VOYAGE_API_KEY;
|
||||
process.env.GBRAIN_HOME = tmpHome;
|
||||
process.env.ZEROENTROPY_API_KEY = 'sk-test-ze';
|
||||
resetGateway();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpHome, { recursive: true, force: true });
|
||||
if (origHome === undefined) delete process.env.GBRAIN_HOME;
|
||||
else process.env.GBRAIN_HOME = origHome;
|
||||
if (origZeKey === undefined) delete process.env.ZEROENTROPY_API_KEY;
|
||||
else process.env.ZEROENTROPY_API_KEY = origZeKey;
|
||||
if (origOpenaiKey !== undefined) process.env.OPENAI_API_KEY = origOpenaiKey;
|
||||
if (origVoyageKey !== undefined) process.env.VOYAGE_API_KEY = origVoyageKey;
|
||||
// Restore legacy-preload gateway state (mirrors fresh-install-pglite.test.ts).
|
||||
configureGateway({
|
||||
embedding_model: 'openai:text-embedding-3-large',
|
||||
embedding_dimensions: 1536,
|
||||
env: { ...process.env },
|
||||
});
|
||||
});
|
||||
|
||||
async function runInitCapturing(args: string[]): Promise<string> {
|
||||
const { runInit } = await import('../../src/commands/init.ts');
|
||||
const origLog = console.log;
|
||||
const origWarn = console.warn;
|
||||
const stdoutBuf: string[] = [];
|
||||
console.log = (...a: unknown[]) => {
|
||||
stdoutBuf.push(a.map(x => (typeof x === 'string' ? x : JSON.stringify(x))).join(' '));
|
||||
};
|
||||
console.warn = () => {};
|
||||
try {
|
||||
await runInit(args);
|
||||
} finally {
|
||||
console.log = origLog;
|
||||
console.warn = origWarn;
|
||||
}
|
||||
return stdoutBuf.join('\n');
|
||||
}
|
||||
|
||||
const cfgPath = () => join(tmpHome, '.gbrain', 'config.json');
|
||||
const readCfg = () => JSON.parse(readFileSync(cfgPath(), 'utf-8'));
|
||||
|
||||
test('explicit --embedding-model clears the persisted embedding_disabled sentinel', async () => {
|
||||
// Step 1: deferred-setup init writes the sentinel.
|
||||
const out1 = await runInitCapturing(['--pglite', '--non-interactive', '--no-embedding']);
|
||||
expect(out1).toContain('deferred setup');
|
||||
const cfg1 = readCfg();
|
||||
expect(cfg1.embedding_disabled).toBe(true);
|
||||
expect(cfg1.embedding_model).toBeUndefined();
|
||||
|
||||
// Step 2: re-init with an explicit embedding model — the recovery path.
|
||||
// Pre-fix this printed the deferred-setup line again and re-persisted
|
||||
// embedding_disabled: true.
|
||||
const out2 = await runInitCapturing([
|
||||
'--pglite', '--non-interactive', '--skip-embed-check',
|
||||
'--embedding-model', 'zeroentropyai:zembed-1',
|
||||
'--embedding-dimensions', '1280',
|
||||
]);
|
||||
expect(out2).not.toContain('deferred setup');
|
||||
expect(out2).toContain('zeroentropyai:zembed-1');
|
||||
|
||||
const cfg2 = readCfg();
|
||||
expect(cfg2.embedding_model).toBe('zeroentropyai:zembed-1');
|
||||
expect(cfg2.embedding_dimensions).toBe(1280);
|
||||
expect(cfg2.embedding_disabled).toBeUndefined();
|
||||
}, 60000);
|
||||
|
||||
test('re-init WITHOUT flags still honors the deferred-setup sentinel (no regression)', async () => {
|
||||
await runInitCapturing(['--pglite', '--non-interactive', '--no-embedding']);
|
||||
const out = await runInitCapturing(['--pglite', '--non-interactive']);
|
||||
expect(out).toContain('deferred setup');
|
||||
const cfg = readCfg();
|
||||
expect(cfg.embedding_disabled).toBe(true);
|
||||
expect(cfg.embedding_model).toBeUndefined();
|
||||
}, 60000);
|
||||
});
|
||||
@@ -166,82 +166,6 @@ describe('client registration', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// rescopeClient (#1914) — admin-gated rescope of a DCR-defaulted client
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('rescopeClient', () => {
|
||||
beforeAll(async () => {
|
||||
// oauth_clients.source_id has FK → sources(id); create the targets.
|
||||
for (const id of ['wiki', 'essays', 'alpha', 'gamma']) {
|
||||
await sql`INSERT INTO sources (id, name) VALUES (${id}, ${id}) ON CONFLICT (id) DO NOTHING`;
|
||||
}
|
||||
});
|
||||
|
||||
test('DCR client stuck on default gets rescoped; existing tokens pick it up', async () => {
|
||||
// Simulate the DCR path: self-registered client lands with
|
||||
// source_id='default', federated_read=['default']. client_credentials
|
||||
// over DCR needs the explicit --enable-dcr-insecure opt-in, so build a
|
||||
// provider with that flag just for this registration.
|
||||
const dcrProvider = new GBrainOAuthProvider({ sql, tokenTtl: 60, allowClientCredentialsDcr: true });
|
||||
const dcr = await dcrProvider.clientsStore.registerClient!({
|
||||
client_name: 'dcr-stuck-client',
|
||||
redirect_uris: [],
|
||||
grant_types: ['client_credentials'],
|
||||
scope: 'read',
|
||||
token_endpoint_auth_method: 'client_secret_post',
|
||||
} as any);
|
||||
const clientId = dcr.client_id;
|
||||
const [before] = await sql`SELECT source_id, federated_read FROM oauth_clients WHERE client_id = ${clientId}`;
|
||||
expect(before.source_id).toBe('default');
|
||||
expect(before.federated_read).toEqual(['default']);
|
||||
|
||||
// Issue a token BEFORE the rescope — it must see the new scope after.
|
||||
const tokens = await provider.exchangeClientCredentials(clientId, dcr.client_secret!, 'read');
|
||||
|
||||
const result = await provider.rescopeClient(clientId, {
|
||||
sourceId: 'wiki',
|
||||
federatedRead: ['wiki', 'essays'],
|
||||
});
|
||||
expect(result.sourceId).toBe('wiki');
|
||||
expect(result.federatedRead).toEqual(['wiki', 'essays']);
|
||||
|
||||
const authInfo = await provider.verifyAccessToken(tokens.access_token) as unknown as CoreAuthInfo;
|
||||
expect(authInfo.sourceId).toBe('wiki');
|
||||
expect(authInfo.allowedSources).toEqual(['wiki', 'essays']);
|
||||
});
|
||||
|
||||
test('partial rescope leaves the other axis untouched', async () => {
|
||||
const { clientId } = await provider.registerClientManual(
|
||||
'partial-rescope', ['client_credentials'], 'read', [], 'alpha', ['alpha', 'beta'],
|
||||
);
|
||||
const result = await provider.rescopeClient(clientId, { federatedRead: ['beta'] });
|
||||
expect(result.sourceId).toBe('alpha'); // untouched
|
||||
expect(result.federatedRead).toEqual(['beta']);
|
||||
|
||||
const result2 = await provider.rescopeClient(clientId, { sourceId: 'gamma' });
|
||||
expect(result2.sourceId).toBe('gamma');
|
||||
expect(result2.federatedRead).toEqual(['beta']); // untouched
|
||||
});
|
||||
|
||||
test('rejects invalid source ids, empty federated list, no-op calls, unknown client', async () => {
|
||||
const { clientId } = await provider.registerClientManual(
|
||||
'rescope-validation', ['client_credentials'], 'read',
|
||||
);
|
||||
await expect(provider.rescopeClient(clientId, { sourceId: '../etc' })).rejects.toThrow('Invalid source_id');
|
||||
await expect(provider.rescopeClient(clientId, { federatedRead: ['ok', 'Not Valid!'] })).rejects.toThrow('Invalid source_id');
|
||||
await expect(provider.rescopeClient(clientId, { federatedRead: [] })).rejects.toThrow('cannot be empty');
|
||||
await expect(provider.rescopeClient(clientId, {})).rejects.toThrow('requires --source and/or --federated-read');
|
||||
await expect(provider.rescopeClient('gbrain_cl_nonexistent', { sourceId: 'wiki' })).rejects.toThrow('No OAuth client found');
|
||||
// FK: write source must exist in sources(id).
|
||||
await expect(provider.rescopeClient(clientId, { sourceId: 'no-such-source' })).rejects.toThrow('does not exist');
|
||||
|
||||
// Validation failures must not have mutated the row.
|
||||
const [row] = await sql`SELECT source_id FROM oauth_clients WHERE client_id = ${clientId}`;
|
||||
expect(row.source_id).toBe('default');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Client Credentials Exchange
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { withEnv, emptyHome } from './helpers/with-env.ts';
|
||||
import {
|
||||
runPhaseProposeTakes,
|
||||
parseExtractorOutput,
|
||||
@@ -52,6 +53,14 @@ function buildMockEngine(opts: {
|
||||
},
|
||||
async executeRaw<T>(sql: string, params?: unknown[]): Promise<T[]> {
|
||||
captured.push({ sql, params: params ?? [] });
|
||||
// Narrow candidate-page projection (replaces listPages in the phase).
|
||||
if (sql.includes('SELECT slug, source_id, compiled_truth')) {
|
||||
return opts.pages.map((p) => ({
|
||||
slug: p.slug,
|
||||
source_id: p.source_id,
|
||||
compiled_truth: p.compiled_truth,
|
||||
})) as T[];
|
||||
}
|
||||
// SELECT idempotency check
|
||||
if (sql.includes('SELECT id FROM take_proposals')) {
|
||||
const [sourceId, slug, ch, pv] = params ?? [];
|
||||
@@ -476,4 +485,72 @@ New prose appended here.`;
|
||||
resetGateway();
|
||||
}
|
||||
});
|
||||
|
||||
test('default extractor skips cleanly when the Anthropic chat model has no key', async () => {
|
||||
// Empty GBRAIN_HOME so hasAnthropicKey's config-file fallback can't find
|
||||
// the operator's real key.
|
||||
await withEnv({ GBRAIN_HOME: emptyHome(), ANTHROPIC_API_KEY: undefined }, async () => {
|
||||
configureGateway({ chat_model: 'anthropic:claude-sonnet-4-6', env: {} });
|
||||
try {
|
||||
const { engine, captured } = buildMockEngine({
|
||||
pages: [buildPage({ slug: 'wiki/a', body: 'claim-ish prose' })],
|
||||
});
|
||||
const result = await runPhaseProposeTakes(buildCtx(engine));
|
||||
|
||||
expect(result.status).toBe('skipped');
|
||||
expect((result.details as Record<string, unknown>).reason).toBe('no_provider');
|
||||
// Skips BEFORE touching the engine — no page scan, no cache probes.
|
||||
expect(captured).toHaveLength(0);
|
||||
} finally {
|
||||
resetGateway();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('an injected extractor is never gated on provider availability', async () => {
|
||||
await withEnv({ GBRAIN_HOME: emptyHome(), ANTHROPIC_API_KEY: undefined }, async () => {
|
||||
configureGateway({ chat_model: 'anthropic:claude-sonnet-4-6', env: {} });
|
||||
try {
|
||||
const { engine } = buildMockEngine({
|
||||
pages: [buildPage({ slug: 'wiki/b', body: 'still processed' })],
|
||||
});
|
||||
const extractor: ProposeTakesExtractor = async () => [];
|
||||
const result = await runPhaseProposeTakes(buildCtx(engine), { extractor });
|
||||
|
||||
expect(result.status).toBe('ok');
|
||||
expect((result.details as Record<string, unknown>).pages_scanned).toBe(1);
|
||||
} finally {
|
||||
resetGateway();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('loads proposal candidates with a narrow page projection', async () => {
|
||||
const pages = [buildPage({ slug: 'wiki/narrow', body: 'A narrow projection avoids unrelated page columns.' })];
|
||||
const { engine, captured } = buildMockEngine({ pages });
|
||||
const extractor: ProposeTakesExtractor = async () => [];
|
||||
await runPhaseProposeTakes(buildCtx(engine), { extractor });
|
||||
|
||||
const pageSelect = captured.find(c => c.sql.includes('FROM pages'));
|
||||
expect(pageSelect).toBeDefined();
|
||||
expect(pageSelect!.sql).toContain('SELECT slug, source_id, compiled_truth');
|
||||
expect(pageSelect!.sql).not.toContain('*');
|
||||
// Scalar sourceId scope from ctx binds as a plain equality param.
|
||||
expect(pageSelect!.params[0]).toBe('default');
|
||||
});
|
||||
|
||||
test('narrow projection: federated sourceIds beat scalar sourceId', async () => {
|
||||
const { engine, captured } = buildMockEngine({ pages: [] });
|
||||
const extractor: ProposeTakesExtractor = async () => [];
|
||||
const ctx = {
|
||||
...buildCtx(engine),
|
||||
auth: { allowedSources: ['team-a', 'team-b'] },
|
||||
} as OperationContext;
|
||||
await runPhaseProposeTakes(ctx, { extractor });
|
||||
|
||||
const pageSelect = captured.find(c => c.sql.includes('FROM pages'));
|
||||
expect(pageSelect).toBeDefined();
|
||||
expect(pageSelect!.sql).toContain('source_id = ANY(');
|
||||
expect(pageSelect!.params[0]).toEqual(['team-a', 'team-b']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -100,6 +100,22 @@ describe('searchTakes', () => {
|
||||
const worldHits = await engine.searchTakes('founder', { takesHoldersAllowList: ['world'] });
|
||||
expect(worldHits.every(h => h.holder === 'world')).toBe(true);
|
||||
});
|
||||
|
||||
// #3267: whole-string trigram % structurally can't match a short keyword
|
||||
// against a long claim (similarity between the full strings stays under the
|
||||
// 0.3 threshold). word_similarity (<%) matches the keyword against the
|
||||
// best-matching word span instead.
|
||||
test('single-word keyword matches a long claim containing it (#3267)', async () => {
|
||||
await engine.addTakesBatch([
|
||||
{
|
||||
page_id: acmePageId, row_num: 50,
|
||||
claim: 'Acme will consolidate the mid-market vertical SaaS landscape through disciplined acquisitions and a shared billing platform over the next five years',
|
||||
kind: 'bet', holder: 'garry', weight: 0.6,
|
||||
},
|
||||
]);
|
||||
const hits = await engine.searchTakes('consolidate');
|
||||
expect(hits.some(h => h.claim.includes('consolidate the mid-market'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateTake', () => {
|
||||
|
||||
Reference in New Issue
Block a user