mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-17 10:22:34 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b46b349028 | ||
|
|
73256e56a0 |
@@ -79,7 +79,7 @@ per-release `**vX.Y.Z:**` narration — CI enforces this
|
||||
- `docs/architecture/RETRIEVAL.md` + `docs/architecture/RETRIEVAL_MAXPOOL_INCIDENT.md` — retrieval-pipeline architecture reference + the named-thing-miss incident write-up (root cause, the five-layer fix, the eval that pins it).
|
||||
- `src/core/types.ts` extension + `src/core/operations.ts:search` + `src/core/import-file.ts` + `src/cli.ts` + `src/core/search/telemetry.ts` — the wiring layer for the retrieval cathedral. `SearchResult` gains `evidence`, `create_safety`, `title_match_boost`, `alias_hit` (all optional; evidence/create_safety reference the union types in `evidence.ts`). The `search` MCP op uses a cheap-hybrid path by default and accepts a per-call `mode` (conservative|balanced|tokenmax) honored ONLY for trusted/local callers (`resolvePerCallMode(ctx, ...)` — remote callers use the configured mode so a remote provider can't force tokenmax spend); every search path stamps evidence fail-soft. `importFromContent` projects frontmatter `aliases:` into `page_aliases` via `normalizeAliasList` + `engine.setPageAliases` so new + changed pages register aliases at ingest. `src/cli.ts` adds the `gbrain search diagnose` dispatch (lazy import) and reconciles the `search` CLI path with the cheap-hybrid op. `src/core/search/telemetry.ts` extends the rollup with the rank-1 base_score drift signal (sum/count + 3 coarse buckets, aggregate not per-query), surfaced via `gbrain search stats`, backed by migration v111's `search_telemetry` columns. Tests: `test/cli-search-dispatch.test.ts`, `test/search/per-call-mode.test.ts`, `test/search/telemetry-rank1.test.ts`, `test/search/title-boost-stage.test.ts`, `test/search/alias-hop.test.ts`, `test/search/evidence.test.ts`, `test/search/searchvector-maxpool.test.ts`, `test/search/pre-migration-failopen.test.ts`.
|
||||
- `src/commands/eval.ts` — `gbrain eval` command: single-run table + A/B config comparison. Sub-subcommand dispatch on `args[0]` routes `gbrain eval export` + `gbrain eval prune` + `gbrain eval replay` into session-capture handlers; bare `gbrain eval --qrels …` fall-through preserves the legacy IR-metrics flow. `gbrain eval cross-modal` is in the dispatch (the user-facing path is the cli.ts no-DB branch — `src/commands/eval.ts:cross-modal` only fires when callers re-enter with an existing engine).
|
||||
- `src/commands/eval-cross-modal.ts` — multi-model quality gate. Three different-provider frontier models score the OUTPUT against the TASK on a 5-dim list. Verdict `pass` (exit 0) / `fail` (exit 1) / `inconclusive` (exit 2; <2/3 model successes). Reuses `src/core/ai/gateway.ts:chat()` so config/auth/aliasing comes from the gateway recipe registry — no parallel provider stack. Self-configures the gateway (`configureGateway(loadConfig() + process.env)`) since the cli.ts dispatch bypasses `connectEngine()`. Default cycles 3 in TTY, 1 in non-TTY (partial cost guardrail) via the shared `resolveCycleDefault(explicit, isTty)` in `src/core/eval/cycle-default.ts`; the cost-estimate banner appends `cycleDefaultSuffix(...)` (`for 1 cycle(s) (non-interactive default; --cycles N for more)`) when the value is the silent non-TTY fallback, so the 1-vs-3 difference isn't hidden. Receipts land at `gbrainPath('eval-receipts')/<slug>-<sha8-of-output>.json`. `--batch <jsonl> [--limit N] [--concurrent N] [--max-usd FLOAT] [--yes]` fans out cross-modal scoring across a LongMemEval-shape JSONL; mutually exclusive with `--task` (fail-fast usage error if both set); filters `kind: "by_type_summary"` rows; pre-flight cost estimate refuses if `> --max-usd` without `--yes` (default cap 5.00 USD). Semaphore-bounded fan-out via inline `runWithLimit<T>(items, limit, fn)` (exported for unit tests): max N questions in-flight × 3 model slots = ceiling of 3N parallel API calls (default `--concurrent 3` → 9). Per-question receipts land in a per-batch tempdir and are deleted at end of run; the summary receipt inlines per-question verdicts as JSON, not file paths. Exit precedence (batch-level policy, NOT inherited from aggregate.ts): ERROR > FAIL > INCONCLUSIVE > PASS. DI seam: `runEvalCrossModal(args, opts?: {runEval?: typeof runEval})` mirrors `runEvalLongMemEval(args, {client?})`; tests pass `opts.runEval` to bypass real LLM calls AND the gateway availability check. Pinned by `test/eval-cross-modal-batch.test.ts`.
|
||||
- `src/commands/eval-cross-modal.ts` — multi-model quality gate. Three different-provider frontier models score the OUTPUT against the TASK on a 5-dim list. Verdict `pass` (exit 0) / `fail` (exit 1) / `inconclusive` (exit 2; <2/3 model successes). Reuses `src/core/ai/gateway.ts:chat()` so config/auth/aliasing comes from the gateway recipe registry — no parallel provider stack. Self-configures the gateway via `buildGatewayConfig(loadConfig() ?? {})` since the cli.ts dispatch bypasses `connectEngine()`, so file-plane keys, env base URLs, and provider_chat_options follow the same adapter path as runtime. Default cycles 3 in TTY, 1 in non-TTY (partial cost guardrail) via the shared `resolveCycleDefault(explicit, isTty)` in `src/core/eval/cycle-default.ts`; the cost-estimate banner appends `cycleDefaultSuffix(...)` (`for 1 cycle(s) (non-interactive default; --cycles N for more)`) when the value is the silent non-TTY fallback, so the 1-vs-3 difference isn't hidden. Receipts land at `gbrainPath('eval-receipts')/<slug>-<sha8-of-output>.json`. `--batch <jsonl> [--limit N] [--concurrent N] [--max-usd FLOAT] [--yes]` fans out cross-modal scoring across a LongMemEval-shape JSONL; mutually exclusive with `--task` (fail-fast usage error if both set); filters `kind: "by_type_summary"` rows; pre-flight cost estimate refuses if `> --max-usd` without `--yes` (default cap 5.00 USD). Semaphore-bounded fan-out via inline `runWithLimit<T>(items, limit, fn)` (exported for unit tests): max N questions in-flight × 3 model slots = ceiling of 3N parallel API calls (default `--concurrent 3` → 9). Per-question receipts land in a per-batch tempdir and are deleted at end of run; the summary receipt inlines per-question verdicts as JSON, not file paths. Exit precedence (batch-level policy, NOT inherited from aggregate.ts): ERROR > FAIL > INCONCLUSIVE > PASS. DI seam: `runEvalCrossModal(args, opts?: {runEval?: typeof runEval})` mirrors `runEvalLongMemEval(args, {client?})`; tests pass `opts.runEval` to bypass real LLM calls AND the gateway availability check. Pinned by `test/eval-cross-modal-batch.test.ts`.
|
||||
- `src/core/eval/cycle-default.ts` — single source of truth for the eval cycle-count default. Exports `DEFAULT_CYCLES_TTY = 3`, `DEFAULT_CYCLES_NONTTY = 1`, `resolveCycleDefault(explicit, isTty): {cycles, usedNonTtyDefault}`, and `cycleDefaultSuffix(r)` (returns ` (non-interactive default; --cycles N for more)` only when the non-TTY default was applied, else `''`). Consumed by `eval-cross-modal.ts`, `eval-takes-quality.ts` (run + regress), and `takes-quality-eval/runner.ts` (core uses only the constant — library stays TTY-agnostic; the CLI owns the TTY=3 upgrade + banner annotation). `eval-suspected-contradictions.ts` applies the same transparency to its `$5`/`$1` budget default via a `budgetUsdExplicit` flag (the budget is overwritten in-place so explicitness can't be inferred post-hoc). Not shared with `resolveWorkersWithClamp` (different domain, no engine, no dedup). Pinned by `test/eval/cycle-default.test.ts`, `test/eval-suspected-contradictions-budget-default.test.ts`.
|
||||
- `src/core/cross-modal-eval/json-repair.ts` — `parseModelJSON(raw)` named export with a 4-strategy fallback chain (direct parse → fence-strip → trailing-comma + single-quote + embedded-newline repair → regex nuclear option). Adversarial input throws rather than fabricating scores — the aggregator treats a throw as "this model contributed nothing this cycle" so the gate stays correct at >=2/3 successes.
|
||||
- `src/core/cross-modal-eval/aggregate.ts` — pure verdict logic. Pass criterion: `(successes >= 2) AND (every dim mean >= 7) AND (every dim min across models >= 5)`. Inconclusive when <2/3 models returned parseable scores (regression guard for the v1 `Object.values({}).every(...) === true` empty-array PASS bug).
|
||||
@@ -138,7 +138,7 @@ per-release `**vX.Y.Z:**` narration — CI enforces this
|
||||
- `src/core/minions/queue.ts` extension — `MinionQueue.add()` rejects `subagent` jobs whose `data.model` resolves via `isAnthropicProvider()` to a non-Anthropic provider. Lazy-imports `model-config.ts` to avoid pulling engine types into queue's eager-load surface. Layer 1 of the three-layer subagent provider enforcement (layers 2+3: `model-config.ts:enforceSubagentAnthropic` runtime fallback + `src/commands/doctor.ts` `subagent_provider` check). Pinned by `test/agent-cli.test.ts`.
|
||||
- `src/commands/models.ts` — `gbrain models [--json]` read-only routing dashboard: prints tier defaults (`utility`/`reasoning`/`deep`/`subagent`), the resolved value for each (re-walking the resolution chain), every per-task override (11 `PER_TASK_KEYS`: `models.dream.synthesize`, `models.dream.patterns`, `models.drift`, `models.auto_think`, `models.think`, `models.subagent`, `facts.extraction_model`, `models.eval.longmemeval`, `models.expansion`, `models.chat`, `models.dream.synthesize_verdict`), the alias map, and a source-of-truth column (`default` / `config: <key>` / `env: <VAR>`). `gbrain models doctor [--skip=<provider>] [--json]` fires a 1-token `gateway.chat()` probe against each configured chat + expansion model and classifies failures into `{model_not_found, auth, rate_limit, network, unknown}`. Wired into `cli.ts` dispatch + `CLI_ONLY` set. A zero-token `embedding_config` probe runs FIRST, before any chat/expansion probes spend money: `probeEmbeddingConfig()` reads `getEmbeddingModel()` + `getEmbeddingDimensions()` and (for Voyage flexible-dim models) checks `isValidVoyageOutputDim(dims)` against `VOYAGE_VALID_OUTPUT_DIMS`. `ProbeStatus` variant `'config'` + optional `fix?: string` on `ProbeResult` surface a paste-ready `gbrain config set ...` line in human + JSON output; touchpoint label `'embedding_config'` joins `'chat'` and `'expansion'`.
|
||||
- `src/core/init-embed-check.ts` — embedding-key validation at `gbrain init`. `runInitEmbedCheck(opts)` runs a config-only `diagnoseEmbedding` (catches a missing key for ANY provider) plus a best-effort `liveTestEmbed` (1-token `gateway.embed(['probe'], {inputType:'query', abortSignal})`, 5s `AbortController` timeout, never throws — catches an invalid/expired key). Loud warning to stderr; init still exits 0 (`--no-embedding` is the deferred-setup escape; `--skip-embed-check` / `GBRAIN_INIT_SKIP_EMBED_CHECK=1` skip the check). Builds the effective env (`process.env` + file-plane `openai/anthropic/zeroentropy_api_key` from `loadConfigFileOnly()` + `opts.apiKey`) and configures the gateway via `buildGatewayConfig` before diagnose/probe, so the check sees the same keys AND provider base URLs runtime will (no false "missing key" for config.json-keyed users; the probe hits the right endpoint). Init-specific warning text names `--no-embedding` / `--skip-embed-check`, not the sync-flavored `--no-embed`. Wired into `initPGLite` + `initPostgres` in `src/commands/init.ts`, with the result added to the `--json` envelope as `embedding_check {ok, reason?, live_ok?}`. Pinned by `test/init-embed-check.test.ts` (hermetic via the gateway embed-transport seam + `withEnv`).
|
||||
- `src/core/ai/build-gateway-config.ts` — `buildGatewayConfig(c: GBrainConfig): AIGatewayConfig`, extracted from `src/cli.ts` (which re-exports it for back-compat). Lets core modules (`init-embed-check.ts`) reuse it without importing the CLI entrypoint. Single owner of folding file-plane API keys (openai/anthropic/zeroentropy) into the gateway env and threading local-server `*_BASE_URL` env vars into base_urls. `process.env` wins EXCEPT empty-string / undefined values are dropped before the merge, so an injected empty `ANTHROPIC_API_KEY=''` (Claude Code neuters subprocess LLM calls this way) can't clobber a valid config-plane key; `'0'` / `'false'` are preserved. Pinned by `test/ai/build-gateway-config.test.ts`.
|
||||
- `src/core/ai/build-gateway-config.ts` — `buildGatewayConfig(c: GBrainConfig): AIGatewayConfig`, extracted from `src/cli.ts` (which re-exports it for back-compat). Single owner of translating stored config into gateway config — consumed by CLI runtime, init (`gbrain init`'s three configureGateway sites), `init-embed-check.ts`, the eval commands (cross-modal, takes-quality), provider diagnostics, and the in-process migration path. Folds file-plane API keys (openai/anthropic/zeroentropy/openrouter) into the gateway env and threads local-server `*_BASE_URL` env vars into base_urls; caller-provided `provider_base_urls` config wins over env base URLs. `process.env` wins EXCEPT empty-string / undefined values are dropped before the merge, so an injected empty `ANTHROPIC_API_KEY=''` (Claude Code neuters subprocess LLM calls this way) can't clobber a valid config-plane key; `'0'` / `'false'` are preserved. Pinned by `test/ai/build-gateway-config.test.ts`.
|
||||
- `src/commands/doctor.ts` extension — `subagent_provider` check (layer 3 of 3). Warns when `models.tier.subagent` is explicitly set non-Anthropic (message names the bad value + paste-ready fix `gbrain config set models.tier.subagent anthropic:claude-sonnet-4-6`); also warns when `models.default` would sneak `subagent` into a non-Anthropic provider via tier inheritance. OK when subagent tier resolves to Anthropic. Tests in `test/doctor.test.ts`.
|
||||
- `src/core/skill-trigger-index.ts` — Shared loader that unions per-skill SKILL.md frontmatter `triggers:` with curated RESOLVER.md / AGENTS.md rows from `skillsDir` AND the parent dir (preserves the OpenClaw workspace-root layout). UNION semantics: explicit RESOLVER.md rows ADD to frontmatter triggers (don't replace). Dedup keyed on `(skillPath, trigger.trim().toLowerCase())`. Three consumers fold through this primitive — `checkResolvable`, `runRoutingEvalCli`, `mounts-cache.composeResolvers` — so fixing frontmatter reaches all of them. Exports `loadSkillTriggerIndex(skillsDir): SkillTriggerEntry[]`, `entriesToResolverContent(entries): string` (synthesizes a markdown-table resolver string for `runRoutingEval`'s string-content API), `findPrimaryResolverPath(skillsDir): string | null`, the `FRONTMATTER_SECTION` constant, and `_resetWarnedSkillsForTests`. Skip rules: non-directory entries, `_*`/`.*` prefixes, `conventions/`+`migrations/` subdirs, skills with no `SKILL.md` (deprecated `install/` graceful-skipped), no `triggers:` array, or malformed YAML (warn-once + skip). Reuses `parseSkillFrontmatter` from `src/core/skill-frontmatter.ts` (regex-based, not full YAML). Pinned by `test/skill-trigger-index.test.ts` (18 hermetic cases). CI gate `bun run check:resolver` (= `bun src/cli.ts check-resolvable --strict --skills-dir skills/`) wired into `bun run verify`.
|
||||
- `src/core/skill-catalog.ts` — host-repo skill catalog backing the MCP `list_skills` / `get_skill` ops. Lets a thin MCP client (Codex desktop, Claude Code, Claude Cowork, Perplexity) DISCOVER + FOLLOW the agent repo's fat-markdown skills over `gbrain serve` — a skill is prose, so "using" one = fetching its body then calling the gbrain MCP tools the server already exposes. Read-scope, NOT localOnly (defensible only via the full mitigation stack): (1) **publish gate** — `assertPublishEnabled(ctx, publishSkills)`; remote callers require `mcp.publish_skills === true`, default-OFF so an upgrade never silently grants existing read tokens host-skill read; local callers (`ctx.remote === false`) always pass. (2) **path confinement** — `assertSkillNameShape` rejects separators/`..`/null/space before any FS access; the client `name` is a manifest LOOKUP KEY (via `loadOrDeriveManifest`), never a raw path segment; `confineManifestPath` does realpath + relative-containment + `SKILL.md`-regular-file check on EVERY entry (defeats poisoned manifest.json `path`, symlink/`..` escape). (3) **frontmatter allowlist** — `GetSkillResult.frontmatter` projects a safe subset; private `writes_to` + `sources` dropped. (4) **prose-only + 256KB cap** (`MAX_SKILL_MD_BYTES`, env `GBRAIN_MAX_SKILL_MD_BYTES`), size-checked twice (statSync + UTF-8 byte length). (5) **no install_path serve for remote** — remote callers use `autoDetectSkillsDir` (no install-path tier) so a hosted gbrain with no agent repo returns `storage_error`; local callers use `autoDetectSkillsDirReadOnly`. (6) MCP rate-limiter caps call rate. Config reads honor BOTH planes: `readMcpPublishSkills` / `readMcpSkillsDir` prefer the DB plane (`engine.getConfig`) over the file plane (`ctx.config.mcp`). Tool-honesty: `crossReferenceTools(declared, ctx)` splits a skill's declared `tools:` into `usable_tools` vs `unavailable_tools`; `buildSkillCatalog`'s `instructions` envelope (`SKILL_CATALOG_INSTRUCTIONS`) carries the "these are prose, follow-then-call-tools" protocol. Skills are host-filesystem repo-global — `sourceScopeOpts(ctx)` / `ctx.brainId` deliberately do NOT apply. `buildSkillCatalog` is resilient (one malformed/escaping skill is skipped, never throws). Config keys in `src/core/config.ts`: `GBrainConfig.mcp?: { publish_skills?, skills_dir? }` + `KNOWN_CONFIG_KEYS` entries `mcp.publish_skills`/`mcp.publish_skills_prompted`/`mcp.skills_dir` + `mcp.` prefix in `KNOWN_CONFIG_KEY_PREFIXES`. `src/commands/init.ts` writes `config.mcp = { publish_skills: true, ... }` for new installs (existing config wins on re-init). `src/commands/upgrade.ts:runPostUpgrade` adds a one-time consent prompt (gated by `mcp.publish_skills_prompted`; existing installs stay OFF until owner opts in). Two ops register in `src/core/operations.ts` (`list_skills` with optional `section` filter + `cliHints:{name:'skills'}`; `get_skill` taking `name` + `cliHints:{name:'skill', positional:['name']}`) and dynamically import this module to avoid the import cycle (skill-catalog statically imports the `operations` array). Descriptions in `src/core/operations-descriptions.ts` (`LIST_SKILLS_DESCRIPTION`, `GET_SKILL_DESCRIPTION`, `SKILL_CATALOG_INSTRUCTIONS`, `SKILL_CLIENT_GUIDANCE`), pinned by `test/operations-descriptions.test.ts`. CLI: `gbrain skills` / `gbrain skill <name>`. Pinned by `test/skill-catalog.test.ts`, `test/skill-catalog-security.test.ts` (path-confinement / poisoned-manifest / symlink-escape), `test/skill-catalog-transports.test.ts` (publish-gate + remote-vs-local) over `test/fixtures/skill-catalog/`.
|
||||
|
||||
@@ -223,16 +223,14 @@ export GBRAIN_REMOTE_CLIENT_ID=<Alice's client_id>
|
||||
export GBRAIN_REMOTE_CLIENT_SECRET=<Alice's client_secret>
|
||||
export GBRAIN_REMOTE_MCP_URL=https://brain.acme-co.com/mcp
|
||||
|
||||
gbrain search "performance review"
|
||||
gbrain search "performance review" --remote
|
||||
```
|
||||
|
||||
(On a thin-client install every shared op routes through the remote MCP server automatically — no flag needed. The env vars select whose credentials the call uses.)
|
||||
|
||||
Alice should see results only from `customers` and `shared`. The performance-review notes live in `internal`, which she's not scoped to read. She shouldn't see them.
|
||||
|
||||
```bash
|
||||
# Terminal 2, as Bob (export his credentials similarly)
|
||||
gbrain search "performance review"
|
||||
gbrain search "performance review" --remote
|
||||
```
|
||||
|
||||
Bob should see the performance-review notes from `internal`, plus anything related from `shared`. He shouldn't see anything that lives only in `customers`.
|
||||
@@ -516,7 +514,7 @@ The first sync embeds every page, which takes time. Check `gbrain sources status
|
||||
|
||||
### "I see a page I shouldn't see"
|
||||
|
||||
This shouldn't happen, but if you suspect it, run `gbrain search <query> --json` as the constrained client (thin-client install, with the client's `GBRAIN_REMOTE_*` env exported) and inspect the `source_id` field on every returned result. Every row should be in the client's `--federated-read` set. If one isn't, file an issue with the exact slug and source IDs.
|
||||
This shouldn't happen, but if you suspect it, run `gbrain search <query> --remote --json` as the constrained client and inspect the `source_id` field on every returned result. Every row should be in the client's `--federated-read` set. If one isn't, file an issue with the exact slug and source IDs.
|
||||
|
||||
### "The synthesized answer is wrong"
|
||||
|
||||
|
||||
+5
-84
@@ -464,11 +464,7 @@ async function main() {
|
||||
// routed path. Date → ISO string; bigint → string (postgres.js shape);
|
||||
// Buffer → object. Microsecond-cost; eliminates a whole drift bug class.
|
||||
const result = JSON.parse(JSON.stringify(rawResult, bigintToStringReplacer));
|
||||
// #380 pass-through: `--json` (undeclared on most ops, promised by docs)
|
||||
// emits the raw op result instead of the human formatter.
|
||||
const output = params.json === true
|
||||
? JSON.stringify(result, null, 2) + '\n'
|
||||
: formatResult(op.name, result);
|
||||
const output = formatResult(op.name, result);
|
||||
if (output) process.stdout.write(output);
|
||||
} catch (e: unknown) {
|
||||
// v0.42.20.0 (codex D4): on error, set exitCode + return so the `finally`
|
||||
@@ -551,10 +547,7 @@ async function runThinClientRouted(
|
||||
signal: sigintController.signal,
|
||||
});
|
||||
const result = unpackToolResult(raw);
|
||||
// #380: same --json seam as the local-engine path (renderer parity).
|
||||
const output = params.json === true
|
||||
? JSON.stringify(result, null, 2) + '\n'
|
||||
: formatResult(op.name, result);
|
||||
const output = formatResult(op.name, result);
|
||||
if (output) process.stdout.write(output);
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof RemoteMcpError) {
|
||||
@@ -764,28 +757,10 @@ export function resolveQueryImage(
|
||||
return { path: imagePath, base64, mime };
|
||||
}
|
||||
|
||||
/**
|
||||
* #380: undeclared flags that are honored DOWNSTREAM of parseOpArgs and must
|
||||
* keep passing through when unknown flags become hard errors:
|
||||
* - source → makeContext's resolveSourceId (the --source axis)
|
||||
* - brain → the mount/brain routing axis (docs promise the flag)
|
||||
* - dry_run → makeContext's ctx.dryRun (ops without a declared dry_run)
|
||||
* - json → raw-JSON output seam (local + thin-client paths)
|
||||
*/
|
||||
const PASSTHROUGH_VALUE_FLAGS = new Set(['source', 'brain']);
|
||||
const PASSTHROUGH_BOOL_FLAGS = new Set(['dry_run', 'json']);
|
||||
|
||||
export function parseOpArgs(op: Operation, args: string[]): Record<string, unknown> {
|
||||
const params: Record<string, unknown> = {};
|
||||
const positional = op.cliHints?.positional || [];
|
||||
let posIdx = 0;
|
||||
const cliName = op.cliHints?.name || op.name;
|
||||
const MAX_STDIN = 5_000_000; // 5MB cap, shared by stdin and --file
|
||||
// #380: `--file <path>` fills the op's declared stdin param (put's `content`)
|
||||
// from a file. Driven by cliHints.stdin — no per-op hard-coding — and
|
||||
// disabled when the op declares a real `file` param of its own.
|
||||
const fileParam = op.cliHints?.stdin && !op.params.file ? op.cliHints.stdin : undefined;
|
||||
let filePath: string | undefined;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
@@ -799,42 +774,12 @@ export function parseOpArgs(op: Operation, args: string[]): Record<string, unkno
|
||||
}
|
||||
}
|
||||
const key = arg.slice(2).replace(/-/g, '_');
|
||||
if (fileParam && key === 'file') {
|
||||
if (i + 1 >= args.length) {
|
||||
console.error(`Error: ${arg} requires a value.`);
|
||||
process.exit(1);
|
||||
}
|
||||
filePath = args[++i];
|
||||
continue;
|
||||
}
|
||||
const paramDef = op.params[key];
|
||||
if (!paramDef) {
|
||||
if (PASSTHROUGH_BOOL_FLAGS.has(key)) {
|
||||
params[key] = true;
|
||||
continue;
|
||||
}
|
||||
if (PASSTHROUGH_VALUE_FLAGS.has(key)) {
|
||||
if (i + 1 >= args.length) {
|
||||
console.error(`Error: ${arg} requires a value.`);
|
||||
process.exit(1);
|
||||
}
|
||||
params[key] = args[++i];
|
||||
continue;
|
||||
}
|
||||
// #380: unknown flags were silently swallowed into params, so typos
|
||||
// like `put --file` created empty pages instead of erroring.
|
||||
console.error(`Unknown option for gbrain ${cliName}: ${arg}`);
|
||||
console.error(`Run 'gbrain ${cliName} --help' for valid flags.`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (paramDef.type === 'boolean') {
|
||||
if (paramDef?.type === 'boolean') {
|
||||
params[key] = true;
|
||||
} else if (i + 1 < args.length) {
|
||||
params[key] = args[++i];
|
||||
if (paramDef.type === 'number') params[key] = Number(params[key]);
|
||||
} else {
|
||||
console.error(`Error: ${arg} requires a value.`);
|
||||
process.exit(1);
|
||||
if (paramDef?.type === 'number') params[key] = Number(params[key]);
|
||||
}
|
||||
} else if (posIdx < positional.length) {
|
||||
const key = positional[posIdx++];
|
||||
@@ -843,30 +788,10 @@ export function parseOpArgs(op: Operation, args: string[]): Record<string, unkno
|
||||
}
|
||||
}
|
||||
|
||||
// #380: resolve --file AFTER the loop so --file/--content conflicts are
|
||||
// caught in either order.
|
||||
if (filePath !== undefined && fileParam) {
|
||||
if (params[fileParam] !== undefined) {
|
||||
console.error(`Error: use only one of --file, --${fileParam}, or stdin for gbrain ${cliName}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
let fileContent: string;
|
||||
try {
|
||||
fileContent = readFileSync(filePath, 'utf-8');
|
||||
} catch (e) {
|
||||
console.error(`Error: cannot read --file ${filePath}: ${e instanceof Error ? e.message : String(e)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (Buffer.byteLength(fileContent, 'utf-8') > MAX_STDIN) {
|
||||
console.error(`Error: file content exceeds ${MAX_STDIN} bytes. Split into smaller inputs.`);
|
||||
process.exit(1);
|
||||
}
|
||||
params[fileParam] = fileContent;
|
||||
}
|
||||
|
||||
// Read stdin for content params
|
||||
if (op.cliHints?.stdin && !params[op.cliHints.stdin] && !process.stdin.isTTY) {
|
||||
const stdinContent = readFileSync(0, 'utf-8');
|
||||
const MAX_STDIN = 5_000_000; // 5MB
|
||||
if (Buffer.byteLength(stdinContent, 'utf-8') > MAX_STDIN) {
|
||||
console.error(`Error: stdin content exceeds ${MAX_STDIN} bytes. Split into smaller inputs.`);
|
||||
process.exit(1);
|
||||
@@ -2328,10 +2253,6 @@ export function printOpHelp(op: Operation, invokedName?: string) {
|
||||
const prefix = isPos ? ` <${key}>` : ` --${key.replace(/_/g, '-')}`;
|
||||
console.log(`${prefix.padEnd(28)} ${def.description || ''}${req}`);
|
||||
}
|
||||
// #380: ops that read stdin also accept --file <path> (parseOpArgs).
|
||||
if (op.cliHints?.stdin && !op.params.file) {
|
||||
console.log(`${' --file <path>'.padEnd(28)} Read ${op.cliHints.stdin} from a file (alternative to --${op.cliHints.stdin} or stdin)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,8 @@ import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
import { gbrainPath, loadConfig } from '../core/config.ts';
|
||||
import { gbrainPath, loadConfig, type GBrainConfig } from '../core/config.ts';
|
||||
import { buildGatewayConfig } from '../core/ai/build-gateway-config.ts';
|
||||
import { configureGateway, isAvailable } from '../core/ai/gateway.ts';
|
||||
import { runWithLimit } from '../core/worker-pool.ts';
|
||||
import { resolveCycleDefault, cycleDefaultSuffix } from '../core/eval/cycle-default.ts';
|
||||
@@ -264,32 +265,11 @@ function isTTY(): boolean {
|
||||
* Returns true on success; false (and prints a hint) when no config is found.
|
||||
*/
|
||||
function configureGatewayForCli(): boolean {
|
||||
// Route through buildGatewayConfig (the single adapter seam) so file-plane
|
||||
// API keys, env base URLs, and provider_chat_options follow the same
|
||||
// precedence as the runtime path. No config file is fine — env alone serves.
|
||||
const config = loadConfig();
|
||||
if (!config) {
|
||||
// No config file is fine for the eval command — env vars alone may serve.
|
||||
// We still call configureGateway so gateway recipes can read the env map.
|
||||
configureGateway({
|
||||
embedding_model: undefined,
|
||||
embedding_dimensions: undefined,
|
||||
expansion_model: undefined,
|
||||
chat_model: undefined,
|
||||
chat_fallback_chain: undefined,
|
||||
base_urls: undefined,
|
||||
provider_chat_options: undefined,
|
||||
env: { ...process.env },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
configureGateway({
|
||||
embedding_model: config.embedding_model,
|
||||
embedding_dimensions: config.embedding_dimensions,
|
||||
expansion_model: config.expansion_model,
|
||||
chat_model: config.chat_model,
|
||||
chat_fallback_chain: config.chat_fallback_chain,
|
||||
base_urls: config.provider_base_urls,
|
||||
provider_chat_options: config.provider_chat_options,
|
||||
env: { ...process.env },
|
||||
});
|
||||
configureGateway(buildGatewayConfig(config ?? ({} as GBrainConfig)));
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
*/
|
||||
import type { BrainEngine } from '../core/engine.ts';
|
||||
import { configureGateway } from '../core/ai/gateway.ts';
|
||||
import { loadConfig } from '../core/config.ts';
|
||||
import { buildGatewayConfig } from '../core/ai/build-gateway-config.ts';
|
||||
import { loadConfig, type GBrainConfig } from '../core/config.ts';
|
||||
import { runEval, DEFAULT_MODEL_PANEL } from '../core/takes-quality-eval/runner.ts';
|
||||
import { resolveCycleDefault, cycleDefaultSuffix } from '../core/eval/cycle-default.ts';
|
||||
import { writeReceipt } from '../core/takes-quality-eval/receipt-write.ts';
|
||||
@@ -127,8 +128,12 @@ export async function runReplayNoBrain(argv: string[]): Promise<number> {
|
||||
export async function runEvalTakesQuality(engine: BrainEngine, args: string[]): Promise<void> {
|
||||
// Self-configure the AI gateway (mirrors eval-cross-modal pattern). The
|
||||
// gateway needs config.ai_gateway + env vars; configureGateway reads both.
|
||||
// Route through buildGatewayConfig: the old `{ ...cfg, ...process.env }`
|
||||
// spread never populated the gateway's `env` field (the gateway NEVER reads
|
||||
// process.env at call time), so availability checks saw no keys at all and
|
||||
// file-plane API keys / provider base URLs were dropped.
|
||||
const cfg = loadConfig();
|
||||
configureGateway({ ...cfg, ...(process.env as Record<string, string>) } as any);
|
||||
configureGateway(buildGatewayConfig(cfg ?? ({} as GBrainConfig)));
|
||||
|
||||
const { subcmd, argv, json } = parseSubcmd(args);
|
||||
|
||||
|
||||
+12
-14
@@ -7,6 +7,7 @@ import { homedir } from 'os';
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
import { saveConfig, loadConfig, loadConfigFileOnly, toEngineConfig, gbrainPath, configPath, isThinClient, effectiveEnvDatabaseUrl, type GBrainConfig } from '../core/config.ts';
|
||||
import { buildGatewayConfig } from '../core/ai/build-gateway-config.ts';
|
||||
import { createEngine } from '../core/engine-factory.ts';
|
||||
import { discoverOAuth, mintClientCredentialsToken, smokeTestMcp } from '../core/remote-mcp-probe.ts';
|
||||
import { runInitEmbedCheck } from '../core/init-embed-check.ts';
|
||||
@@ -722,7 +723,8 @@ async function configureGatewayWithMergedPrecedence(
|
||||
// pollutes config.json.
|
||||
const envOverlay = loadConfig() ?? ({} as GBrainConfig);
|
||||
|
||||
const merged = {
|
||||
const merged: GBrainConfig = {
|
||||
...envOverlay,
|
||||
embedding_model: aiOpts?.embedding_model ?? envOverlay.embedding_model ?? existingFile.embedding_model,
|
||||
embedding_dimensions: aiOpts?.embedding_dimensions ?? envOverlay.embedding_dimensions ?? existingFile.embedding_dimensions,
|
||||
expansion_model: aiOpts?.expansion_model ?? envOverlay.expansion_model ?? existingFile.expansion_model,
|
||||
@@ -730,13 +732,9 @@ async function configureGatewayWithMergedPrecedence(
|
||||
};
|
||||
|
||||
const { configureGateway, getEmbeddingModel, getEmbeddingDimensions, getExpansionModel, getChatModel } = await import('../core/ai/gateway.ts');
|
||||
configureGateway({
|
||||
embedding_model: merged.embedding_model,
|
||||
embedding_dimensions: merged.embedding_dimensions,
|
||||
expansion_model: merged.expansion_model,
|
||||
chat_model: merged.chat_model,
|
||||
env: { ...process.env },
|
||||
});
|
||||
// buildGatewayConfig (the single adapter seam) so file-plane API keys and
|
||||
// env base URLs reach the gateway — a hand-rolled config here dropped them.
|
||||
configureGateway(buildGatewayConfig(merged));
|
||||
|
||||
// Read back resolved values — gateway applies internal defaults for unset
|
||||
// fields, so these are the values that actually shaped the schema.
|
||||
@@ -831,13 +829,13 @@ async function initPGLite(opts: {
|
||||
// resolveAIOptions above: CLI flags > env vars > existing file > gateway
|
||||
// defaults.
|
||||
const { configureGateway } = await import('../core/ai/gateway.ts');
|
||||
configureGateway({
|
||||
configureGateway(buildGatewayConfig({
|
||||
...(loadConfig() ?? ({} as GBrainConfig)),
|
||||
embedding_model: resolvedModel ?? opts.aiOpts?.embedding_model,
|
||||
embedding_dimensions: resolvedDim ?? opts.aiOpts?.embedding_dimensions,
|
||||
expansion_model: opts.aiOpts?.expansion_model,
|
||||
chat_model: opts.aiOpts?.chat_model,
|
||||
env: { ...process.env },
|
||||
});
|
||||
} as GBrainConfig));
|
||||
if (resolvedModel) console.log(` Embedding: ${resolvedModel} (${resolvedDim}d)`);
|
||||
if (opts.aiOpts?.expansion_model) console.log(` Expansion: ${opts.aiOpts.expansion_model}`);
|
||||
if (opts.aiOpts?.chat_model) console.log(` Chat: ${opts.aiOpts.chat_model}`);
|
||||
@@ -1046,13 +1044,13 @@ async function initPostgres(opts: {
|
||||
|
||||
// T6: unconditional configureGateway BEFORE initSchema.
|
||||
const { configureGateway } = await import('../core/ai/gateway.ts');
|
||||
configureGateway({
|
||||
configureGateway(buildGatewayConfig({
|
||||
...(loadConfig() ?? ({} as GBrainConfig)),
|
||||
embedding_model: resolvedModel ?? opts.aiOpts?.embedding_model,
|
||||
embedding_dimensions: resolvedDim ?? opts.aiOpts?.embedding_dimensions,
|
||||
expansion_model: opts.aiOpts?.expansion_model,
|
||||
chat_model: opts.aiOpts?.chat_model,
|
||||
env: { ...process.env },
|
||||
});
|
||||
} as GBrainConfig));
|
||||
if (resolvedModel) console.log(` Embedding: ${resolvedModel} (${resolvedDim}d)`);
|
||||
if (opts.aiOpts?.expansion_model) console.log(` Expansion: ${opts.aiOpts.expansion_model}`);
|
||||
if (opts.aiOpts?.chat_model) console.log(` Chat: ${opts.aiOpts.chat_model}`);
|
||||
|
||||
@@ -66,15 +66,11 @@ export async function runMigrateOnlyCore(opts?: { timeoutMs?: number }): Promise
|
||||
|
||||
// configureGateway BEFORE initSchema (init.ts B.3): a schema bump on a brain
|
||||
// whose file config is missing embedding fields must not fall through to
|
||||
// stale hardcoded fallbacks. loadConfig already merged env; propagate it.
|
||||
// stale hardcoded fallbacks. Route through buildGatewayConfig so file-plane
|
||||
// API keys and provider base URLs follow the same precedence as runtime.
|
||||
const { configureGateway } = await import('../../core/ai/gateway.ts');
|
||||
configureGateway({
|
||||
embedding_model: config.embedding_model,
|
||||
embedding_dimensions: config.embedding_dimensions,
|
||||
expansion_model: config.expansion_model,
|
||||
chat_model: config.chat_model,
|
||||
env: { ...process.env },
|
||||
});
|
||||
const { buildGatewayConfig } = await import('../../core/ai/build-gateway-config.ts');
|
||||
configureGateway(buildGatewayConfig(config));
|
||||
|
||||
const timeoutMs = opts?.timeoutMs ?? MIGRATE_ONLY_TIMEOUT_MS;
|
||||
const engine = await createEngine(toEngineConfig(config));
|
||||
|
||||
@@ -12,8 +12,8 @@ import { parseModelId } from './ai/model-resolver.ts';
|
||||
* `resolveKey` closure without re-parsing recipes.
|
||||
*
|
||||
* Only OPENAI_API_KEY and ZEROENTROPY_API_KEY appear here because those are the
|
||||
* only embedding keys `buildGatewayConfig` (src/cli.ts) folds from config into
|
||||
* the gateway env. VOYAGE_API_KEY / GOOGLE_GENERATIVE_AI_API_KEY are deliberately
|
||||
* only embedding keys `buildGatewayConfig` (src/core/ai/build-gateway-config.ts)
|
||||
* folds from config into the gateway env. VOYAGE_API_KEY / GOOGLE_GENERATIVE_AI_API_KEY are deliberately
|
||||
* absent: their config fields are NOT threaded to the gateway today, so the
|
||||
* producer closures fall through to checking `process.env` ONLY for them. That
|
||||
* matches what the gateway can actually use (the recipes read those keys from
|
||||
|
||||
@@ -769,7 +769,7 @@ const get_page: Operation = {
|
||||
|
||||
const put_page: Operation = {
|
||||
name: 'put_page',
|
||||
description: 'Write/update a page (markdown with frontmatter). Chunks, embeds, reconciles tags, and (when auto_link/auto_timeline are enabled) extracts + reconciles graph links and timeline entries. On the CLI, `gbrain put SLUG --file PATH` reads content from a file (also `--content` or stdin). For provenance write-through and a binary-NUL guard, prefer `gbrain capture --file PATH --slug SLUG` (v0.39.3.0).',
|
||||
description: 'Write/update a page (markdown with frontmatter). Chunks, embeds, reconciles tags, and (when auto_link/auto_timeline are enabled) extracts + reconciles graph links and timeline entries. For large content on Windows (pipe-buffer limit ~45KB) or any file-as-input workflow, use `gbrain capture --file PATH --slug SLUG` — capture reads the file as a Buffer with a binary-NUL guard and adds provenance write-through (v0.39.3.0).',
|
||||
params: {
|
||||
slug: { type: 'string', required: true, description: 'Page slug' },
|
||||
content: { type: 'string', required: true, description: 'Full markdown content with YAML frontmatter' },
|
||||
@@ -1384,10 +1384,7 @@ const list_pages: Operation = {
|
||||
params: {
|
||||
type: { type: 'string', description: 'Filter by page type' },
|
||||
tag: { type: 'string', description: 'Filter by tag' },
|
||||
limit: { type: 'number', description: 'Max results (default 50, capped at 100 — use offset to paginate beyond)' },
|
||||
// #2876: the 100-row cap was silent and there was no way past it even
|
||||
// though both engines already support OFFSET on listPages.
|
||||
offset: { type: 'number', description: 'Skip first N results (pagination; pair with limit)' },
|
||||
limit: { type: 'number', description: 'Max results (default 50)' },
|
||||
// v0.29 — surface filter that already exists on PageFilters.
|
||||
updated_after: {
|
||||
type: 'string',
|
||||
@@ -1418,10 +1415,6 @@ const list_pages: Operation = {
|
||||
type: p.type as any,
|
||||
tag: p.tag as string,
|
||||
limit: clampSearchLimit(p.limit as number | undefined, 50, 100),
|
||||
// #2876: thread pagination through (engines already honor offset).
|
||||
offset: Number.isFinite(p.offset as number) && (p.offset as number) > 0
|
||||
? Math.floor(p.offset as number)
|
||||
: undefined,
|
||||
includeDeleted: (p.include_deleted as boolean) === true,
|
||||
updated_after: typeof p.updated_after === 'string' ? p.updated_after : undefined,
|
||||
sort,
|
||||
|
||||
@@ -25,6 +25,7 @@ import { withEnv } from '../helpers/with-env.ts';
|
||||
|
||||
const PASSTHROUGHS: Array<{ envVar: string; recipeId: string }> = [
|
||||
{ envVar: 'LLAMA_SERVER_BASE_URL', recipeId: 'llama-server' },
|
||||
{ envVar: 'LLAMA_SERVER_RERANKER_BASE_URL', recipeId: 'llama-server-reranker' },
|
||||
{ envVar: 'OLLAMA_BASE_URL', recipeId: 'ollama' },
|
||||
{ envVar: 'LMSTUDIO_BASE_URL', recipeId: 'lmstudio' },
|
||||
{ envVar: 'LITELLM_BASE_URL', recipeId: 'litellm' },
|
||||
|
||||
@@ -1,41 +1,8 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { parseOpArgs } from '../src/cli.ts';
|
||||
import { operationsByName } from '../src/core/operations.ts';
|
||||
|
||||
describe('parseOpArgs', () => {
|
||||
// #380: `gbrain put SLUG --file PATH` reads content from the file instead
|
||||
// of silently swallowing the flag and creating an empty page.
|
||||
test('put --file reads the stdin param (content) from a file', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'gbrain-put-file-'));
|
||||
try {
|
||||
const pagePath = join(dir, 'page.md');
|
||||
writeFileSync(pagePath, '# From file\n\nBody loaded from --file.\n');
|
||||
const params = parseOpArgs(operationsByName.put_page, ['concepts/from-file', '--file', pagePath]);
|
||||
expect(params.slug).toBe('concepts/from-file');
|
||||
expect(params.content).toBe('# From file\n\nBody loaded from --file.\n');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// #380 regression guard: undeclared-but-honored flags must keep passing
|
||||
// through when unknown flags become hard errors (--source is read by
|
||||
// makeContext; --json by the output seam; --dry-run by ctx.dryRun).
|
||||
test('pass-through allowlist flags survive on ops that do not declare them', () => {
|
||||
const params = parseOpArgs(operationsByName.get_page, [
|
||||
'people/alice-example', '--source', 'wiki', '--json', '--dry-run',
|
||||
]);
|
||||
expect(params).toEqual({
|
||||
slug: 'people/alice-example',
|
||||
source: 'wiki',
|
||||
json: true,
|
||||
dry_run: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('--no-<boolean> maps to false without consuming the next flag', () => {
|
||||
const params = parseOpArgs(operationsByName.query, [
|
||||
'freshEmbedSourceScope code source',
|
||||
|
||||
@@ -11,32 +11,13 @@
|
||||
import { afterAll, 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 { buildGatewayConfig } from '../src/core/ai/build-gateway-config.ts';
|
||||
import {
|
||||
configureGateway,
|
||||
getEmbeddingModel,
|
||||
getMultimodalModel,
|
||||
resetGateway,
|
||||
} from '../src/core/ai/gateway.ts';
|
||||
import type { AIGatewayConfig } from '../src/core/ai/types.ts';
|
||||
|
||||
// Mirror the cli.ts buildGatewayConfig helper exactly. Keeping a copy here
|
||||
// (instead of exporting from cli.ts) is intentional: the test asserts the
|
||||
// shape of the contract, not the helper's identity. If cli.ts drifts, the
|
||||
// e2e behavior these tests care about (DB-set value lands in gateway) still
|
||||
// holds, but a helper-shape test would also catch the drift in PR review.
|
||||
function buildGatewayConfig(c: GBrainConfig): AIGatewayConfig {
|
||||
return {
|
||||
embedding_model: c.embedding_model,
|
||||
embedding_dimensions: c.embedding_dimensions,
|
||||
embedding_multimodal_model: c.embedding_multimodal_model,
|
||||
expansion_model: c.expansion_model,
|
||||
chat_model: c.chat_model,
|
||||
chat_fallback_chain: c.chat_fallback_chain,
|
||||
base_urls: c.provider_base_urls,
|
||||
provider_chat_options: c.provider_chat_options,
|
||||
env: { ...process.env },
|
||||
};
|
||||
}
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
|
||||
@@ -47,6 +28,7 @@ beforeAll(async () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
resetGateway(); // don't leak this file's gateway config into shard siblings
|
||||
await engine.disconnect();
|
||||
});
|
||||
|
||||
|
||||
+1
-71
@@ -1,5 +1,5 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
@@ -120,76 +120,6 @@ describe('CLI dispatch integration', () => {
|
||||
expect(exitCode).toBe(0);
|
||||
});
|
||||
|
||||
// #380 / PR #856: put --help documents the --file input path.
|
||||
test('put --help documents --file input', async () => {
|
||||
const proc = Bun.spawn(['bun', 'run', 'src/cli.ts', 'put', '--help'], {
|
||||
cwd: repoRoot,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
const stdout = await new Response(proc.stdout).text();
|
||||
const exitCode = await proc.exited;
|
||||
expect(stdout).toContain('Usage: gbrain put');
|
||||
expect(stdout).toContain('--file <path>');
|
||||
expect(exitCode).toBe(0);
|
||||
});
|
||||
|
||||
// #380: unknown flags on shared ops are a hard error (previously silently
|
||||
// swallowed into params — `put --file` created empty pages). parseOpArgs
|
||||
// runs BEFORE engine connect, so the error must fire without a brain.
|
||||
test('unknown shared-op flags fail before DB connection', async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'gbrain-cli-unknown-flag-'));
|
||||
try {
|
||||
const proc = Bun.spawn(['bun', 'run', 'src/cli.ts', 'get', 'people/alice', '--bogus'], {
|
||||
cwd: repoRoot,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
env: isolatedEnv(home),
|
||||
});
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
const exitCode = await proc.exited;
|
||||
expect(stderr).toContain('Unknown option for gbrain get: --bogus');
|
||||
expect(stderr).not.toContain('No brain configured');
|
||||
expect(exitCode).toBe(1);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('put rejects combining --file and --content', async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'gbrain-cli-put-conflict-'));
|
||||
try {
|
||||
const pagePath = join(home, 'page.md');
|
||||
writeFileSync(pagePath, 'file body\n');
|
||||
const proc = Bun.spawn(
|
||||
['bun', 'run', 'src/cli.ts', 'put', 'a/b', '--content', 'inline', '--file', pagePath],
|
||||
{ cwd: repoRoot, stdout: 'pipe', stderr: 'pipe', env: isolatedEnv(home) },
|
||||
);
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
const exitCode = await proc.exited;
|
||||
expect(stderr).toContain('use only one of --file, --content, or stdin');
|
||||
expect(exitCode).toBe(1);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('put --file with a missing path errors instead of writing an empty page', async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'gbrain-cli-put-missing-file-'));
|
||||
try {
|
||||
const proc = Bun.spawn(
|
||||
['bun', 'run', 'src/cli.ts', 'put', 'a/b', '--file', join(home, 'nope.md')],
|
||||
{ cwd: repoRoot, stdout: 'pipe', stderr: 'pipe', env: isolatedEnv(home) },
|
||||
);
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
const exitCode = await proc.exited;
|
||||
expect(stderr).toContain('cannot read --file');
|
||||
expect(exitCode).toBe(1);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('upgrade --help prints usage without running upgrade', async () => {
|
||||
const proc = Bun.spawn(['bun', 'run', 'src/cli.ts', 'upgrade', '--help'], {
|
||||
cwd: repoRoot,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* eval-takes-quality gateway self-config — adapter-boundary regression
|
||||
* (takeover of PR #2430).
|
||||
*
|
||||
* The old callsite spread `{ ...cfg, ...process.env }` straight into
|
||||
* configureGateway. The gateway NEVER reads process.env at call time — it
|
||||
* reads `_config.env` — and that spread never populated an `env` field at
|
||||
* all, so every availability/diagnose check dereferenced `undefined.env[k]`
|
||||
* and file-plane API keys (config.json `openai_api_key` etc.) were dropped.
|
||||
* Routing through buildGatewayConfig fixes both. This test fails (throws)
|
||||
* on the old code path.
|
||||
*/
|
||||
import { afterAll, describe, expect, test } from 'bun:test';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { runEvalTakesQuality } from '../src/commands/eval-takes-quality.ts';
|
||||
import { isAvailable, resetGateway } from '../src/core/ai/gateway.ts';
|
||||
import { withEnv } from './helpers/with-env.ts';
|
||||
import type { BrainEngine } from '../src/core/engine.ts';
|
||||
|
||||
afterAll(() => {
|
||||
resetGateway();
|
||||
});
|
||||
|
||||
describe('runEvalTakesQuality — gateway self-config routes through buildGatewayConfig', () => {
|
||||
test('file-plane openai_api_key reaches the gateway env (help path, engine untouched)', async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'gbrain-etq-gw-'));
|
||||
try {
|
||||
mkdirSync(join(home, '.gbrain'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(home, '.gbrain', 'config.json'),
|
||||
JSON.stringify({
|
||||
engine: 'pglite',
|
||||
database_path: join(home, '.gbrain', 'brain'),
|
||||
openai_api_key: 'sk-file-plane-test',
|
||||
}),
|
||||
);
|
||||
await withEnv(
|
||||
{
|
||||
GBRAIN_HOME: home,
|
||||
OPENAI_API_KEY: undefined,
|
||||
DATABASE_URL: undefined,
|
||||
GBRAIN_DATABASE_URL: undefined,
|
||||
},
|
||||
async () => {
|
||||
// 'help' returns before touching the engine, but the gateway is
|
||||
// configured first — exactly the seam under test.
|
||||
await runEvalTakesQuality({} as BrainEngine, ['--help']);
|
||||
expect(isAvailable('embedding', 'openai:text-embedding-3-small')).toBe(true);
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { operationsByName } from '../src/core/operations.ts';
|
||||
|
||||
/**
|
||||
* #2876: `gbrain list --limit` silently clamped at 100 with no pagination.
|
||||
* list_pages now declares `offset` (both engines already supported it on
|
||||
* PageFilters) and the limit description discloses the 100-row cap.
|
||||
*/
|
||||
describe('list_pages pagination (#2876)', () => {
|
||||
const listPagesOp = operationsByName.list_pages;
|
||||
|
||||
function makeCtx(captured: unknown[]) {
|
||||
return {
|
||||
engine: {
|
||||
listPages: async (filters: unknown) => {
|
||||
captured.push(filters);
|
||||
return [];
|
||||
},
|
||||
},
|
||||
config: { engine: 'pglite' },
|
||||
logger: { info() {}, warn() {}, error() {} },
|
||||
dryRun: false,
|
||||
remote: false,
|
||||
sourceId: 'default',
|
||||
} as any;
|
||||
}
|
||||
|
||||
test('declares offset param and discloses the 100-row cap on limit', () => {
|
||||
expect(listPagesOp.params.offset).toBeDefined();
|
||||
expect(listPagesOp.params.offset.type).toBe('number');
|
||||
expect(listPagesOp.params.limit.description).toContain('100');
|
||||
});
|
||||
|
||||
test('threads offset through to engine.listPages', async () => {
|
||||
const captured: any[] = [];
|
||||
await listPagesOp.handler(makeCtx(captured), { limit: 10, offset: 30 });
|
||||
expect(captured[0].offset).toBe(30);
|
||||
expect(captured[0].limit).toBe(10);
|
||||
});
|
||||
|
||||
test('drops negative, non-finite, and zero offsets', async () => {
|
||||
const captured: any[] = [];
|
||||
const ctx = makeCtx(captured);
|
||||
await listPagesOp.handler(ctx, { offset: -5 });
|
||||
await listPagesOp.handler(ctx, { offset: Infinity });
|
||||
await listPagesOp.handler(ctx, { offset: 0 });
|
||||
for (const f of captured) expect(f.offset).toBeUndefined();
|
||||
});
|
||||
|
||||
test('floors fractional offsets', async () => {
|
||||
const captured: any[] = [];
|
||||
await listPagesOp.handler(makeCtx(captured), { offset: 7.9 });
|
||||
expect(captured[0].offset).toBe(7);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user