Compare commits

..
Author SHA1 Message Date
Garry TanandClaude Fable 5 5882d5261a fix(search): match both slash-split and literal slash forms in FTS queries (#2380 review)
Review finding on the normalize-only approach: Postgres' text-search
parser emits the joined file-alias lexeme on the INDEX side too
(to_tsvector('english','acme/widget') -> 'acme/widget'), so replacing
'/' with whitespace in the query made documents containing the literal
slash form (file paths, URLs, pasted titles) unreachable — the split-word
AND pass can't match the joined lexeme and the OR fallback can't either.
Pre-fix, those exact-form queries DID match.

Replace the TS-side normalizeKeywordQuery with buildWebsearchQueryExpr
in sql-ranking.ts (shared by both engines, keeping them in lockstep): a
slash-bearing query now binds the raw text once and matches
(websearch_to_tsquery(translate($1,'/',' ')) || websearch_to_tsquery($1))
— split-word prose AND literal slash forms alike. Slash-free queries keep
the byte-identical single-parse SQL and identical ts_rank.

New regression test pins the literal-slash arm (verified failing under
the normalize-only expression); the two AND-vs-OR slash tests still pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 11:35:32 -07:00
a5a80549e5 fix(search,embed): normalize / in FTS queries; CPU-safe defaults for local embedding endpoints
Two backlog fixes:

1. Takeover of #2380 (search): Postgres' default text-search parser
   classifies foo/bar as a single file-alias token mapped to the simple
   dictionary, so websearch_to_tsquery produces one un-stemmed lexeme
   that never matches indexed text — slash-containing queries bypassed
   FTS AND semantics (zero primary hits, OR-fallback results only).
   normalizeKeywordQuery() replaces / with whitespace before parse.
   Beyond the original PR: also routes searchTitles through the
   normalizer (the PR only covered the two chunk arms), applies it in
   BOTH engines, and drops the stray node_modules symlink from the diff.

2. Fixes #2552 (embed): cloud-tuned embedding defaults silently wedge
   CPU-only Ollama boxes. The ollama recipe now declares a conservative
   static batch cap (max_batch_tokens 4096 x chars_per_token 2, ~8K
   chars/request) instead of no_batch_cap — Ollama never returns a
   recognizable token-limit error, so the recursive-halving safety net
   can't fire. Bulk embed auto-caps worker fan-out at 2 for local
   endpoints (ollama / llama-server / localhost base URL) unless
   GBRAIN_EMBED_CONCURRENCY is set explicitly, and gbrain doctor grows
   an embed_concurrency check that warns when an explicit override fans
   out against a local endpoint.

Co-authored-by: rwbaker <rwbaker@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 15:12:33 -07:00
23 changed files with 380 additions and 533 deletions
+1 -3
View File
@@ -1411,8 +1411,7 @@ This is the dispatcher. Skills are the implementation. **Read the skill file bef
| "get more out of gbrain", "is my brain set up right", "weekly brain checkup", "advise me on my brain", "gbrain advisor" | `skills/gbrain-advisor/SKILL.md` |
| Save or load reports | `skills/reports/SKILL.md` |
| "Create a skill", "improve this skill" | `skills/skill-creator/SKILL.md` |
| "save this learning to the vault", "capture this skill in Obsidian", "record this workflow in my notes", "put this setup change in the vault" | `skills/skill-vault-capture-policy/SKILL.md` |
| "Skillify this", "is this a skill?", "make this proper", "add tests and evals for this" | `skills/skillify/SKILL.md` |
| "Skillify this", "is this a skill?", "make this proper" | `skills/skillify/SKILL.md` |
| "Compress my resolver", "AGENTS.md too large", "RESOLVER.md too big", "functional area dispatcher", "shrink routing table" | `skills/functional-area-resolver/SKILL.md` |
| "Is gbrain healthy?", morning health check, skillpack-check | `skills/skillpack-check/SKILL.md` |
| "harvest this skill into gbrain", "publish this skill to gbrain", "lift this skill upstream", "share this skill with other gbrain clients", "promote my skill to gbrain" | `skills/skillpack-harvest/SKILL.md` |
@@ -1430,7 +1429,6 @@ This is the dispatcher. Skills are the implementation. **Read the skill file bef
| "Set up GBrain", first boot | `skills/setup/SKILL.md` |
| "Now what?", "fill my brain", "cold start", "bootstrap", "import my data", "what should I import first" | `skills/cold-start/SKILL.md` |
| "Migrate from Obsidian/Notion/Logseq" | `skills/migrate/SKILL.md` |
| "Connect Obsidian to gbrain", "import my vault to gbrain", "sync vault and gbrain", "embed gbrain after vault update", "is gbrain synced with my vault" | `skills/obsidian-gbrain-safe-index/SKILL.md` |
| Brain health check, maintenance run | `skills/maintain/SKILL.md` |
| "Extract links", "build link graph", "populate timeline" | `skills/maintain/SKILL.md` (extraction sections) |
| "Run dream", "process today's session", "synthesize my conversations", "consolidate yesterday's conversations", "what patterns did you see", "did the dream cycle run" | `skills/maintain/SKILL.md` (dream cycle section) |
+2 -3
View File
@@ -39,8 +39,8 @@
"skills/briefing",
"skills/citation-fixer",
"skills/concept-synthesis",
"skills/cron-scheduler",
"skills/cross-modal-review",
"skills/cron-scheduler",
"skills/daily-task-manager",
"skills/daily-task-prep",
"skills/data-research",
@@ -54,11 +54,10 @@
"skills/media-ingest",
"skills/meeting-ingestion",
"skills/minion-orchestrator",
"skills/obsidian-gbrain-safe-index",
"skills/perplexity-research",
"skills/query",
"skills/repo-architecture",
"skills/reports",
"skills/repo-architecture",
"skills/signal-detector",
"skills/skill-creator",
"skills/skillify",
+1 -3
View File
@@ -60,8 +60,7 @@ This is the dispatcher. Skills are the implementation. **Read the skill file bef
| "get more out of gbrain", "is my brain set up right", "weekly brain checkup", "advise me on my brain", "gbrain advisor" | `skills/gbrain-advisor/SKILL.md` |
| Save or load reports | `skills/reports/SKILL.md` |
| "Create a skill", "improve this skill" | `skills/skill-creator/SKILL.md` |
| "save this learning to the vault", "capture this skill in Obsidian", "record this workflow in my notes", "put this setup change in the vault" | `skills/skill-vault-capture-policy/SKILL.md` |
| "Skillify this", "is this a skill?", "make this proper", "add tests and evals for this" | `skills/skillify/SKILL.md` |
| "Skillify this", "is this a skill?", "make this proper" | `skills/skillify/SKILL.md` |
| "Compress my resolver", "AGENTS.md too large", "RESOLVER.md too big", "functional area dispatcher", "shrink routing table" | `skills/functional-area-resolver/SKILL.md` |
| "Is gbrain healthy?", morning health check, skillpack-check | `skills/skillpack-check/SKILL.md` |
| "harvest this skill into gbrain", "publish this skill to gbrain", "lift this skill upstream", "share this skill with other gbrain clients", "promote my skill to gbrain" | `skills/skillpack-harvest/SKILL.md` |
@@ -79,7 +78,6 @@ This is the dispatcher. Skills are the implementation. **Read the skill file bef
| "Set up GBrain", first boot | `skills/setup/SKILL.md` |
| "Now what?", "fill my brain", "cold start", "bootstrap", "import my data", "what should I import first" | `skills/cold-start/SKILL.md` |
| "Migrate from Obsidian/Notion/Logseq" | `skills/migrate/SKILL.md` |
| "Connect Obsidian to gbrain", "import my vault to gbrain", "sync vault and gbrain", "embed gbrain after vault update", "is gbrain synced with my vault" | `skills/obsidian-gbrain-safe-index/SKILL.md` |
| Brain health check, maintenance run | `skills/maintain/SKILL.md` |
| "Extract links", "build link graph", "populate timeline" | `skills/maintain/SKILL.md` (extraction sections) |
| "Run dream", "process today's session", "synthesize my conversations", "consolidate yesterday's conversations", "what patterns did you see", "did the dream cycle run" | `skills/maintain/SKILL.md` (dream cycle section) |
+1 -11
View File
@@ -2,7 +2,7 @@
"name": "gbrain",
"version": "0.32.3.0",
"conformance_version": "1.0.0",
"description": "Personal knowledge brain with hybrid RAG search GStack mod for agent platforms",
"description": "Personal knowledge brain with hybrid RAG search \u2014 GStack mod for agent platforms",
"skills": [
{
"name": "ingest",
@@ -34,11 +34,6 @@
"path": "migrate/SKILL.md",
"description": "Universal migration from Obsidian, Notion, Logseq, markdown, CSV, JSON, Roam"
},
{
"name": "obsidian-gbrain-safe-index",
"path": "obsidian-gbrain-safe-index/SKILL.md",
"description": "Connect and sync an Obsidian-style Markdown vault with gbrain using a cost-controlled import-first workflow, explicit embedding gates, and durable skill/workflow capture."
},
{
"name": "setup",
"path": "setup/SKILL.md",
@@ -268,11 +263,6 @@
"name": "skill-optimizer",
"path": "skill-optimizer/SKILL.md",
"description": "Self-evolving skill optimization via gbrain skillopt — SkillOpt-paper-grounded text-space optimizer with validation gating (median-of-3 + epsilon=0.05), bundled-skill safety, bootstrap review sentinel, per-skill DB lock, and atomic versioned writes."
},
{
"name": "skill-vault-capture-policy",
"path": "skill-vault-capture-policy/SKILL.md",
"description": "Capture durable operational learnings, new agent skills, and environment/setup changes into the Obsidian vault instead of transient chat memory."
}
],
"dependencies": {
-137
View File
@@ -1,137 +0,0 @@
---
name: obsidian-gbrain-safe-index
version: 1.0.0
description: |
Connect, maintain, and sync an Obsidian-style Markdown vault with gbrain while preserving a cost-controlled workflow: the vault remains the source of truth, gbrain is the searchable/embedded index, durable skills/workflows are captured into the vault, and paid embedding runs only after explicit approval.
triggers:
- "connect Obsidian to gbrain"
- "import my vault to gbrain"
- "sync vault and gbrain"
- "capture this skill in my vault"
- "embed gbrain after vault update"
- "is gbrain synced with my vault"
tools:
- terminal
- read_file
- search_files
- write_file
- patch
mutating: true
---
# Obsidian → gbrain Safe Index and Capture
## Contract
This skill guarantees:
- Treats the user's Obsidian-style Markdown vault as the source of truth before gbrain indexing.
- Keeps gbrain in conservative mode unless the user explicitly approves a more expensive mode.
- Imports vault changes with `--no-embed` first, then embeds only after explicit approval for the specific paid action.
- Captures durable new skills, workflows, and environment learnings into the vault instead of leaving them only in chat memory.
- Verifies every sync with concrete `gbrain stats`, search mode, and, when embeddings run, exact embedded chunk counts.
## Phases
1. **Resolve the vault path.**
- Prefer an existing environment variable such as `OBSIDIAN_VAULT_PATH` or `WIKI_PATH`.
- If no path is configured, search likely note directories and ask the user before writing.
- Verify the directory exists and contains markdown files or an `.obsidian` directory.
2. **Read vault operating rules before writing.**
- If the vault has `SCHEMA.md`, `index.md`, `log.md`, `AGENTS.md`, or similar operating files, read them before ingest/query/major edit.
- Respect immutable source folders such as `raw/` when the vault declares them.
- Use the vault's native link convention, usually Obsidian `[[wikilinks]]`, for durable relationships.
3. **MECE/capture decision.**
- If new knowledge belongs on an existing page, update that page.
- If it is a distinct recurring workflow or operational policy, create a small meta or concept page following the vault schema.
- Update the vault index/catalog for every new page when the vault maintains one.
- Append a log entry for meaningful vault updates when the vault maintains a log.
4. **Safe gbrain import path.**
- Pre-check source directory; do not import a nonexistent path.
- Run `gbrain config set search.mode conservative` before/after risky reinit steps.
- Run `gbrain import "$OBSIDIAN_VAULT_PATH" --no-embed`.
- Run `gbrain extract links --source fs --dir "$OBSIDIAN_VAULT_PATH"` when wikilinks changed materially.
5. **Paid embedding gate.**
- Do not run `gbrain embed --stale` unless the user explicitly asks or a prior instruction clearly approved this exact paid action.
- Before embedding, verify provider readiness with `gbrain providers test --model <provider:model>`.
- Confirm the configured embedding dimensions match the local schema.
- After embedding, verify `gbrain stats` and record exact `Pages`, `Chunks`, `Embedded`, and `Links` counts.
6. **Final verification and vault echo.**
- Run `gbrain stats` and `gbrain search modes`.
- If vault files changed, re-import with `--no-embed`; if embedding was approved, embed stale chunks afterward.
- Report what changed, what was free/local, what used API billing, and what remains pending.
## Output Format
Use a compact status table:
| Item | Status |
|---|---|
| Vault path | `/path` |
| Vault updated | yes/no + files |
| gbrain mode | conservative/balanced/tokenmax |
| Import | `--no-embed` completed / skipped / failed |
| Pages/chunks | exact counts from `gbrain stats` |
| Embeddings | exact count; note whether this run used API billing |
| Links | exact count |
| Background jobs | none / list exact jobs |
Then include:
- **Safe next step:** free/local action.
- **Paid next step:** embedding/LLM action, if any, with explicit approval requirement.
## Anti-Patterns
- Creating a duplicate skill/page when an existing Obsidian, gbrain, or vault-ingest skill already covers the workflow.
- Running `gbrain embed --stale`, `gbrain dream`, `gbrain autopilot --install`, `gbrain onboard --auto`, or `tokenmax` without explicit cost approval.
- Importing a nonexistent or wrong directory and treating a zero-page import as success.
- Forgetting to update the vault index/catalog and log after creating or materially updating vault pages.
- Recording API keys, tokens, or raw secrets in the vault or final response.
## Tools Used
- `read_file` — read vault schema/index/log and target notes.
- `search_files` — find existing vault pages and avoid duplicates.
- `write_file` / `patch` — create or update vault pages.
- `terminal` — run `gbrain`, `git`, and environment checks with secret values redacted.
## Safe Commands
```bash
export PATH="$HOME/.bun/bin:$PATH"
gbrain config set search.mode conservative
gbrain import "$OBSIDIAN_VAULT_PATH" --no-embed
gbrain extract links --source fs --dir "$OBSIDIAN_VAULT_PATH"
gbrain stats
gbrain search modes
```
## Paid / Approval-Gated Commands
```bash
gbrain providers test --model <provider:model>
gbrain embed --stale
gbrain dream
gbrain autopilot --install
gbrain onboard --auto --max-usd 5
gbrain config set search.mode tokenmax
```
## Verification Checklist
- [ ] Vault path exists and is the intended source.
- [ ] Vault operating files were read before edits when present.
- [ ] Existing pages/skills were searched to avoid duplicates.
- [ ] New/updated vault pages follow the vault schema and link convention.
- [ ] Vault index/catalog updated for new pages when present.
- [ ] Vault log appended for meaningful actions when present.
- [ ] `gbrain import ... --no-embed` completed.
- [ ] `gbrain stats` recorded pages/chunks/embeddings/links.
- [ ] `gbrain search modes` confirms conservative mode unless a different mode was explicitly approved.
- [ ] No paid/background commands ran without approval.
@@ -1,11 +0,0 @@
// Routing eval fixtures for skills/obsidian-gbrain-safe-index.
// Positive cases: intents embed a trigger phrase in natural surrounding context
// (never verbatim-identical to a trigger — the fixture linter rejects tautologies).
{"intent": "help me connect Obsidian to gbrain for my notes", "expected_skill": "obsidian-gbrain-safe-index"}
{"intent": "import my vault to gbrain but skip embeddings for now", "expected_skill": "obsidian-gbrain-safe-index"}
{"intent": "please sync vault and gbrain after I edit notes", "expected_skill": "obsidian-gbrain-safe-index"}
{"intent": "run embed gbrain after vault update tonight", "expected_skill": "obsidian-gbrain-safe-index"}
{"intent": "hey is gbrain synced with my vault right now", "expected_skill": "obsidian-gbrain-safe-index"}
// Negative cases: related but owned by other skills. Assert NO route to this skill.
{"intent": "migrate my notes from Notion to gbrain", "expected_skill": null}
{"intent": "what is on my calendar tomorrow", "expected_skill": null}
-100
View File
@@ -1,100 +0,0 @@
---
name: skill-vault-capture-policy
version: 1.0.0
description: |
Use when the user wants a durable operational learning, new agent skill, or
important environment/setup change to be captured into the Obsidian vault
instead of left only in transient chat memory. Covers the capture rule,
preferred page patterns, and index/log update obligations.
triggers:
- "save this learning to the vault"
- "capture this skill in Obsidian"
- "record this workflow in my notes"
- "put this setup change in the vault"
- "should we add this to the knowledge base"
tools:
- read_file
- search_files
- write_file
- patch
mutating: true
---
# Skill Vault Capture Policy
## Contract
This skill guarantees:
- Durable operational learnings, newly adopted skills, and important environment/setup changes are captured into the Obsidian vault rather than left only in chat memory.
- An existing page is updated when the knowledge clearly belongs there; a new page is created only when the topic is distinct and likely to recur.
- Every new vault page is added to `index.md`.
- Every meaningful create/update appends a dated entry to `log.md`.
- Small linked pages are preferred over one giant running note.
## Phases
1. **Classify the learning.**
- New gbrain operating rule, cost control, or embedding/provider change.
- New agent-fork / harness / coding-tool integration fact.
- New Obsidian vault workflow or structure decision.
- New recurring agent skill that changes how the agent should operate here.
2. **Avoid duplicates.**
- Search the vault for an existing page that already owns the topic.
- If found, update it with a new section or dated note rather than creating a near-duplicate.
3. **Create when distinct.**
- Place new pages under the vault schema: `_meta/` for operating notes, `concepts/` for workflows, `entities/` for tools/people.
- Use YAML frontmatter and at least two `[[wikilinks]]` unless it is a short seed page.
4. **Update navigation.**
- Add the page to `index.md` under the correct type heading.
- Append a `## [YYYY-MM-DD] create|update | subject` entry to `log.md`.
5. **Report the capture.**
- State which files changed and whether `index.md` / `log.md` were updated.
## Output Format
Use a short status block:
| Item | Status |
|---|---|
| Learning classified | type |
| Page created/updated | path |
| index.md updated | yes/no |
| log.md updated | yes/no |
## Anti-Patterns
- Leaving durable learnings only in chat memory.
- Creating a near-duplicate page instead of updating the existing one.
- Forgetting to update `index.md` and `log.md`.
- Writing one giant running note instead of small linked pages.
- Recording secrets, API keys, or raw credentials in the vault.
## Tools Used
- `read_file` — read `SCHEMA.md`, `index.md`, `log.md`, and target pages.
- `search_files` — find existing pages to avoid duplicates.
- `write_file` / `patch` — create or update vault pages and navigation.
## Safe Commands
```bash
# inspect vault navigation before writing
read SCHEMA.md index.md log.md
# create or update a page, then refresh catalog/log
# index.md: add [[page-slug]] under the matching type heading
# log.md: append ## [YYYY-MM-DD] create|update | subject
```
## Verification Checklist
- [ ] Vault schema/read files were checked before writing.
- [ ] Existing pages were searched to avoid duplicates.
- [ ] New/updated page has frontmatter and wikilinks.
- [ ] `index.md` updated for new pages.
- [ ] `log.md` appended for meaningful actions.
- [ ] No secrets or raw credentials were written.
@@ -1,10 +0,0 @@
// Routing eval fixtures for skills/skill-vault-capture-policy.
// Positive cases: intents embed a trigger phrase in natural surrounding context
// (never verbatim-identical to a trigger — the fixture linter rejects tautologies).
{"intent": "please save this learning to the vault so we keep it", "expected_skill": "skill-vault-capture-policy", "ambiguous_with": ["idea-ingest"]}
{"intent": "we should capture this skill in Obsidian for reuse", "expected_skill": "skill-vault-capture-policy", "ambiguous_with": ["capture"]}
{"intent": "can you record this workflow in my notes for next time", "expected_skill": "skill-vault-capture-policy"}
{"intent": "put this setup change in the vault before we forget", "expected_skill": "skill-vault-capture-policy"}
// Negative cases: related but owned by other skills or out of scope.
{"intent": "connect my Obsidian vault to gbrain", "expected_skill": null}
{"intent": "what is on my calendar tomorrow", "expected_skill": null}
+59
View File
@@ -820,6 +820,10 @@ export async function doctorReportRemote(engine: BrainEngine): Promise<DoctorRep
// v0.42.x (#1794, 4A): pool-budget nudge when GBRAIN_MAX_CONNECTIONS is set.
checks.push(await checkPoolBudget(engine));
// #2552: warn when an explicit embed-concurrency override fans out against
// a local single-slot embedding endpoint (silent backfill starvation).
checks.push(await checkEmbedConcurrency());
// v0.42.7 (#1696): link-extraction lag. Strictly SQL (single indexed COUNT),
// safe on the thin-client/remote path — remote operators on checkout-less
// Postgres brains are exactly who can't otherwise see the extraction backlog.
@@ -3815,6 +3819,61 @@ export function computePoolBudgetCheck(
};
}
/**
* #2552: warn when an explicit GBRAIN_EMBED_CONCURRENCY override fans out
* against a local single-slot embedding endpoint (Ollama / llama-server /
* localhost base URL). Requests serialize on the one loaded model, so N
* parallel pages multiply latency xN and can exceed the fetch timeout with
* no surfaced error the backfill silently starves. (When the env var is
* unset, embed auto-caps at LOCAL_EMBED_CONCURRENCY_CAP and this check
* reports ok.) Pure; exported for tests.
*/
export function computeEmbedConcurrencyCheck(
isLocalEndpoint: boolean,
envValue: string | undefined,
localCap: number,
): Check {
const name = 'embed_concurrency';
if (!isLocalEndpoint) {
return { name, status: 'ok', message: 'Embedding endpoint is not a local inference server — cloud concurrency defaults apply.' };
}
const parsed = envValue ? parseInt(envValue, 10) : NaN;
if (envValue && Number.isFinite(parsed) && parsed > localCap) {
return {
name,
status: 'warn',
message:
`GBRAIN_EMBED_CONCURRENCY=${parsed} against a local embedding endpoint. ` +
`Local inference servers serialize requests, so ${parsed} parallel pages multiply ` +
`latency x${parsed} and can exceed the fetch timeout — the embed backfill stalls ` +
`with no error. Unset GBRAIN_EMBED_CONCURRENCY (auto-caps at ${localCap}) or set it <= ${localCap}.`,
};
}
return {
name,
status: 'ok',
message: `Local embedding endpoint detected; embed concurrency capped at ${envValue ? parsed : localCap}.`,
};
}
/** Thin gateway/env wrapper over `computeEmbedConcurrencyCheck`. */
export async function checkEmbedConcurrency(): Promise<Check> {
try {
const { isLocalEmbeddingEndpoint, LOCAL_EMBED_CONCURRENCY_CAP } = await import('../core/ai/gateway.ts');
return computeEmbedConcurrencyCheck(
isLocalEmbeddingEndpoint(),
process.env.GBRAIN_EMBED_CONCURRENCY,
LOCAL_EMBED_CONCURRENCY_CAP,
);
} catch (err) {
return {
name: 'embed_concurrency',
status: 'ok',
message: `Skipped (${err instanceof Error ? err.message : String(err)})`,
};
}
}
/** Thin env/engine wrapper over `computePoolBudgetCheck`. */
export async function checkPoolBudget(_engine: BrainEngine): Promise<Check> {
try {
+30 -8
View File
@@ -1,5 +1,6 @@
import type { BrainEngine } from '../core/engine.ts';
import { embedBatch, currentEmbeddingSignature } from '../core/embedding.ts';
import { isLocalEmbeddingEndpoint, LOCAL_EMBED_CONCURRENCY_CAP } from '../core/ai/gateway.ts';
import type { ChunkInput } from '../core/types.ts';
import { chunkText } from '../core/chunkers/recursive.ts';
import { createProgress, type ProgressReporter } from '../core/progress.ts';
@@ -176,6 +177,31 @@ export class EmbeddingDimMismatchError extends Error {
}
}
/**
* #2552: resolve the bulk-embed worker count. Env override or the
* cloud-tuned default of 20 — but when the operator did NOT set
* GBRAIN_EMBED_CONCURRENCY and the embedding endpoint is a local inference
* server (Ollama / llama-server / localhost base URL), cap at
* LOCAL_EMBED_CONCURRENCY_CAP: 20 parallel pages against a single-slot
* server serialize on the one loaded model, multiply latency x20 past the
* fetch timeout, and starve the backfill with no surfaced error. An
* explicit env value always wins (`gbrain doctor` warns instead).
* Pacing only ever LOWERS concurrency (Codex P2).
*/
export function resolveEmbedConcurrency(paceMaxConcurrency?: number): number {
const envSet = !!process.env.GBRAIN_EMBED_CONCURRENCY;
const base = parseInt(process.env.GBRAIN_EMBED_CONCURRENCY || '20', 10);
let resolved = base;
if (!envSet && isLocalEmbeddingEndpoint() && base > LOCAL_EMBED_CONCURRENCY_CAP) {
resolved = LOCAL_EMBED_CONCURRENCY_CAP;
serr(
`[embed] local embedding endpoint detected — capping concurrency at ` +
`${LOCAL_EMBED_CONCURRENCY_CAP} (set GBRAIN_EMBED_CONCURRENCY to override)`,
);
}
return paceMaxConcurrency ? Math.min(resolved, paceMaxConcurrency) : resolved;
}
/**
* Pre-flight check: read the actual schema column dim and compare to the
* gateway's resolved dim. Throws `EmbeddingDimMismatchError` on mismatch
@@ -677,10 +703,8 @@ async function embedAll(
// Paced runs lower this to the resolved cap (the real lever vs pooler-slot
// starvation); unpaced keeps the env/default 20. Codex P2: only ever LOWER —
// never raise above an operator's existing env cap.
const BASE_CONCURRENCY = parseInt(process.env.GBRAIN_EMBED_CONCURRENCY || '20', 10);
const CONCURRENCY = staleOpts?.paceMaxConcurrency
? Math.min(BASE_CONCURRENCY, staleOpts.paceMaxConcurrency)
: BASE_CONCURRENCY;
// #2552: local endpoints auto-cap — see resolveEmbedConcurrency.
const CONCURRENCY = resolveEmbedConcurrency(staleOpts?.paceMaxConcurrency);
async function embedOnePage(page: typeof pages[number]) {
// #1737: bail before doing any work for this page if the run was aborted.
@@ -855,10 +879,8 @@ async function embedAllStale(
// Paced runs lower concurrency to the resolved cap (E-1: worker count IS the
// lever on this single pool, no separate permit). Codex P2: pacing only ever
// LOWERS concurrency — never raise above an operator's existing env cap.
const BASE_CONCURRENCY = parseInt(process.env.GBRAIN_EMBED_CONCURRENCY || '20', 10);
const CONCURRENCY = staleOpts?.paceMaxConcurrency
? Math.min(BASE_CONCURRENCY, staleOpts.paceMaxConcurrency)
: BASE_CONCURRENCY;
// #2552: local endpoints auto-cap — see resolveEmbedConcurrency.
const CONCURRENCY = resolveEmbedConcurrency(staleOpts?.paceMaxConcurrency);
const pacer = staleOpts?.pacer ?? createNoopPacer();
// D3 + D3a + D8: wall-clock budget. 30 min default; env override.
+27
View File
@@ -683,6 +683,33 @@ export function getEmbeddingDimensions(): number {
return requireConfig().embedding_dimensions ?? DEFAULT_EMBEDDING_DIMENSIONS;
}
/**
* #2552: cap for parallel bulk-embed workers against a local inference
* server. A single-slot Ollama/llama-server serializes requests, so the
* cloud-tuned 20-worker fan-out multiplies latency x20 and blows past the
* fetch timeout with no surfaced error (the backfill silently starves).
*/
export const LOCAL_EMBED_CONCURRENCY_CAP = 2;
/**
* #2552: true when the configured embedding model routes to a local
* inference server — the `ollama` / `llama-server` recipes, or any recipe
* whose base URL was explicitly pointed at localhost. Bulk callers use this
* to pick CPU-safe concurrency defaults; `gbrain doctor` uses it to warn
* about an explicit cloud-sized override. Fail-open: unconfigured or
* unresolvable gateway → false (cloud behavior, the historical default).
*/
export function isLocalEmbeddingEndpoint(): boolean {
try {
const { recipe } = resolveRecipe(getEmbeddingModel());
if (recipe.id === 'ollama' || recipe.id === 'llama-server') return true;
const base = requireConfig().base_urls?.[recipe.id] ?? '';
return /\/\/(localhost|127\.0\.0\.1|\[::1\])(:|\/|$)/i.test(base);
} catch {
return false;
}
}
/**
* v0.28.11: returns the configured multimodal embedding model when set,
* or undefined if the brain falls back to `embedding_model` for multimodal
+11 -3
View File
@@ -29,9 +29,17 @@ export const ollama: Recipe = {
trust_custom_dims: true, // #2271: local models carry varied native dims
cost_per_1m_tokens_usd: 0,
price_last_verified: '2026-04-20',
// Ollama's batch capacity depends on the locally loaded model + the
// OLLAMA_NUM_PARALLEL config; no static cap to declare. v0.32 (#779).
no_batch_cap: true,
// #2552: Ollama's true batch capacity depends on the locally loaded
// model + OLLAMA_NUM_PARALLEL, but the previous `no_batch_cap: true`
// meant a whole page went out in ONE request — on a CPU-only box that
// multiplies latency past the fetch timeout and the backfill starves
// with no surfaced error. Ollama doesn't return a recognizable
// token-limit error either, so the recursive-halving safety net never
// fires; a conservative static pre-split cap is the only guard.
// 4096 tokens x 2 chars/token ~= 8K chars per request (code-dense
// pages run ~2 chars/token, not the tiktoken-ish 4).
max_batch_tokens: 4096,
chars_per_token: 2,
},
},
setup_hint: 'Install Ollama from https://ollama.ai, then `ollama pull nomic-embed-text` and `ollama serve`.',
+1
View File
@@ -141,6 +141,7 @@ export const OPS_CHECK_NAMES: ReadonlySet<string> = new Set([
'pgbouncer_prepare',
'pgvector',
'pool_budget',
'embed_concurrency',
'progressive_batch_audit_health',
'queue_health',
'reranker_health',
+16 -7
View File
@@ -56,7 +56,7 @@ import { GBrainError, PAGE_SORT_SQL, ENRICH_ORDER_SQL } from './types.ts';
import { finalizeLastSeen } from './chronicle/last-seen.ts';
import { computeAnomaliesFromBuckets } from './cycle/anomaly.ts';
import { resolveBoostMap, resolveHardExcludes } from './search/source-boost.ts';
import { buildSourceFactorCase, buildHardExcludeClause, buildVisibilityClause, buildRecencyComponentSql, buildBestPerPagePoolCte, buildOrFallbackWebsearchQuery } from './search/sql-ranking.ts';
import { buildSourceFactorCase, buildHardExcludeClause, buildVisibilityClause, buildRecencyComponentSql, buildBestPerPagePoolCte, buildOrFallbackWebsearchQuery, buildWebsearchQueryExpr } from './search/sql-ranking.ts';
import {
normalizeEngineColumn,
buildVectorCastFragment,
@@ -1591,6 +1591,8 @@ export class PGLiteEngine implements BrainEngine {
}
// v0.20.0 Cathedral II Layer 10 C1/C2: language + symbol-kind filters.
// #2380: slash-bearing queries match both the split-word and literal
// slash forms — see buildWebsearchQueryExpr in ./search/sql-ranking.ts.
const params: unknown[] = [query, innerLimit, limit, offset];
let extraFilter = '';
if (opts?.language) {
@@ -1630,6 +1632,7 @@ export class PGLiteEngine implements BrainEngine {
// FTS config name (e.g. 'english', 'pt_br'). Validated by getFtsLanguage()
// — safe to interpolate into raw SQL.
const ftsLang = getFtsLanguage();
const ftsQueryExpr = buildWebsearchQueryExpr(ftsLang, '$1', query);
const keywordSql =
`WITH ranked AS (
@@ -1637,14 +1640,14 @@ export class PGLiteEngine implements BrainEngine {
p.slug, p.id as page_id, p.title, p.type, p.source_id,
p.effective_date, p.effective_date_source,
cc.id as chunk_id, cc.chunk_index, cc.chunk_text, cc.chunk_source,
ts_rank(cc.search_vector, websearch_to_tsquery('${ftsLang}', $1)) * ${sourceFactorCase} AS score,
ts_rank(cc.search_vector, ${ftsQueryExpr}) * ${sourceFactorCase} AS score,
CASE WHEN p.updated_at < (
SELECT MAX(te.created_at) FROM timeline_entries te WHERE te.page_id = p.id
) THEN true ELSE false END AS stale
FROM content_chunks cc
JOIN pages p ON p.id = cc.page_id
JOIN sources s ON s.id = p.source_id
WHERE cc.search_vector @@ websearch_to_tsquery('${ftsLang}', $1) ${detailFilter}${extraFilter} ${hardExcludeClause} ${visibilityClause}
WHERE cc.search_vector @@ ${ftsQueryExpr} ${detailFilter}${extraFilter} ${hardExcludeClause} ${visibilityClause}
-- v0.27.1: hide image rows from default text-keyword search so
-- OCR text doesn't drown text-page hits. Image-similarity queries
-- run a separate vector path on embedding_image.
@@ -1712,7 +1715,10 @@ export class PGLiteEngine implements BrainEngine {
// FTS config name (e.g. 'english', 'pt_br'). Validated by getFtsLanguage()
// — safe to interpolate into raw SQL.
const ftsLang = getFtsLanguage();
const ftsQueryExpr = buildWebsearchQueryExpr(ftsLang, '$1', query);
// #2380: slash-bearing queries match both the split-word and literal
// slash forms — see buildWebsearchQueryExpr in ./search/sql-ranking.ts.
const params: unknown[] = [query, limit, offset];
let extraFilter = '';
if (opts?.type) {
@@ -1760,7 +1766,7 @@ export class PGLiteEngine implements BrainEngine {
COALESCE(rep.chunk_index, 0) as chunk_index,
COALESCE(rep.chunk_text, '') as chunk_text,
COALESCE(rep.chunk_source, 'compiled_truth') as chunk_source,
ts_rank_cd(p.search_vector, websearch_to_tsquery('${ftsLang}', $1)) * ${sourceFactorCase} AS score,
ts_rank_cd(p.search_vector, ${ftsQueryExpr}) * ${sourceFactorCase} AS score,
CASE WHEN p.updated_at < (
SELECT MAX(te.created_at) FROM timeline_entries te WHERE te.page_id = p.id
) THEN true ELSE false END AS stale
@@ -1775,7 +1781,7 @@ export class PGLiteEngine implements BrainEngine {
ORDER BY (cc.chunk_source = 'compiled_truth') DESC, cc.chunk_index ASC
LIMIT 1
) rep ON true
WHERE p.search_vector @@ websearch_to_tsquery('${ftsLang}', $1)
WHERE p.search_vector @@ ${ftsQueryExpr}
${extraFilter} ${hardExcludeClause} ${visibilityClause}
ORDER BY score DESC, p.id ASC
LIMIT $2 OFFSET $3`;
@@ -1962,6 +1968,8 @@ export class PGLiteEngine implements BrainEngine {
});
}
// #2380: slash-bearing queries match both the split-word and literal
// slash forms — see buildWebsearchQueryExpr in ./search/sql-ranking.ts.
const params: unknown[] = [query, limit, offset];
let extraFilter = '';
if (opts?.language) {
@@ -1996,20 +2004,21 @@ export class PGLiteEngine implements BrainEngine {
// FTS config name (e.g. 'english', 'pt_br'). Validated by getFtsLanguage()
// — safe to interpolate into raw SQL.
const ftsLang = getFtsLanguage();
const ftsQueryExpr = buildWebsearchQueryExpr(ftsLang, '$1', query);
const { rows } = await this.db.query(
`SELECT
p.slug, p.id as page_id, p.title, p.type, p.source_id,
p.effective_date, p.effective_date_source,
cc.id as chunk_id, cc.chunk_index, cc.chunk_text, cc.chunk_source,
ts_rank(cc.search_vector, websearch_to_tsquery('${ftsLang}', $1)) * ${sourceFactorCase} AS score,
ts_rank(cc.search_vector, ${ftsQueryExpr}) * ${sourceFactorCase} AS score,
CASE WHEN p.updated_at < (
SELECT MAX(te.created_at) FROM timeline_entries te WHERE te.page_id = p.id
) THEN true ELSE false END AS stale
FROM content_chunks cc
JOIN pages p ON p.id = cc.page_id
JOIN sources s ON s.id = p.source_id
WHERE cc.search_vector @@ websearch_to_tsquery('${ftsLang}', $1) ${detailFilter}${extraFilter} ${hardExcludeClause} ${visibilityClause}
WHERE cc.search_vector @@ ${ftsQueryExpr} ${detailFilter}${extraFilter} ${hardExcludeClause} ${visibilityClause}
ORDER BY score DESC
LIMIT $2 OFFSET $3`,
params
+16 -7
View File
@@ -64,7 +64,7 @@ import { ConnectionManager } from './connection-manager.ts';
import { logConnectionEvent } from './connection-audit.ts';
import { validateSlug, contentHash, rowToPage, rowToStalePage, rowToChunk, rowToSearchResult, parseEmbedding, tryParseEmbedding, takeRowToTake, takeHitRowToHit, isUndefinedTableError, warnOncePerProcess } from './utils.ts';
import { resolveBoostMap, resolveHardExcludes } from './search/source-boost.ts';
import { buildSourceFactorCase, buildHardExcludeClause, buildVisibilityClause, buildRecencyComponentSql, buildBestPerPagePoolCte, buildOrFallbackWebsearchQuery } from './search/sql-ranking.ts';
import { buildSourceFactorCase, buildHardExcludeClause, buildVisibilityClause, buildRecencyComponentSql, buildBestPerPagePoolCte, buildOrFallbackWebsearchQuery, buildWebsearchQueryExpr } from './search/sql-ranking.ts';
import { DEFAULT_EMBEDDING_MODEL, DEFAULT_EMBEDDING_DIMENSIONS } from './ai/defaults.ts';
import { DELETE_BATCH_SIZE } from './engine-constants.ts';
@@ -1691,6 +1691,8 @@ export class PostgresEngine implements BrainEngine {
const hardExcludePrefixes = resolveHardExcludes(opts?.exclude_slug_prefixes, opts?.include_slug_prefixes);
const hardExcludeClause = buildHardExcludeClause('p.slug', hardExcludePrefixes);
// #2380: slash-bearing queries match both the split-word and literal
// slash forms — see buildWebsearchQueryExpr in ./search/sql-ranking.ts.
const params: unknown[] = [query];
let typeClause = '';
if (type) {
@@ -1761,6 +1763,7 @@ export class PostgresEngine implements BrainEngine {
// FTS config name (e.g. 'english', 'pt_br'). Validated by getFtsLanguage()
// — safe to interpolate into raw SQL.
const ftsLang = getFtsLanguage();
const ftsQueryExpr = buildWebsearchQueryExpr(ftsLang, '$1', query);
const rawQuery = `
WITH ranked_chunks AS (
@@ -1768,11 +1771,11 @@ export class PostgresEngine implements BrainEngine {
p.slug, p.id as page_id, p.title, p.type, p.source_id,
p.effective_date, p.effective_date_source,
cc.id as chunk_id, cc.chunk_index, cc.chunk_text, cc.chunk_source,
ts_rank(cc.search_vector, websearch_to_tsquery('${ftsLang}', $1)) * ${sourceFactorCase} AS score
ts_rank(cc.search_vector, ${ftsQueryExpr}) * ${sourceFactorCase} AS score
FROM content_chunks cc
JOIN pages p ON p.id = cc.page_id
JOIN sources s ON s.id = p.source_id
WHERE cc.search_vector @@ websearch_to_tsquery('${ftsLang}', $1)
WHERE cc.search_vector @@ ${ftsQueryExpr}
${typeClause}
${typesClause}
${excludeSlugsClause}
@@ -1863,7 +1866,10 @@ export class PostgresEngine implements BrainEngine {
// FTS config name (e.g. 'english', 'pt_br'). Validated by getFtsLanguage()
// — safe to interpolate into raw SQL.
const ftsLang = getFtsLanguage();
const ftsQueryExpr = buildWebsearchQueryExpr(ftsLang, '$1', query);
// #2380: slash-bearing queries match both the split-word and literal
// slash forms — see buildWebsearchQueryExpr in ./search/sql-ranking.ts.
const params: unknown[] = [query];
let typeClause = '';
if (opts?.type) {
@@ -1923,7 +1929,7 @@ export class PostgresEngine implements BrainEngine {
COALESCE(rep.chunk_index, 0) as chunk_index,
COALESCE(rep.chunk_text, '') as chunk_text,
COALESCE(rep.chunk_source, 'compiled_truth') as chunk_source,
ts_rank_cd(p.search_vector, websearch_to_tsquery('${ftsLang}', $1)) * ${sourceFactorCase} AS score,
ts_rank_cd(p.search_vector, ${ftsQueryExpr}) * ${sourceFactorCase} AS score,
false AS stale
FROM pages p
JOIN sources s ON s.id = p.source_id
@@ -1936,7 +1942,7 @@ export class PostgresEngine implements BrainEngine {
ORDER BY (cc.chunk_source = 'compiled_truth') DESC, cc.chunk_index ASC
LIMIT 1
) rep ON true
WHERE p.search_vector @@ websearch_to_tsquery('${ftsLang}', $1)
WHERE p.search_vector @@ ${ftsQueryExpr}
${typeClause}
${typesClause}
${excludeSlugsClause}
@@ -2000,6 +2006,8 @@ export class PostgresEngine implements BrainEngine {
const hardExcludePrefixes = resolveHardExcludes(opts?.exclude_slug_prefixes, opts?.include_slug_prefixes);
const hardExcludeClause = buildHardExcludeClause('p.slug', hardExcludePrefixes);
// #2380: slash-bearing queries match both the split-word and literal
// slash forms — see buildWebsearchQueryExpr in ./search/sql-ranking.ts.
const params: unknown[] = [query];
let typeClause = '';
if (type) {
@@ -2060,18 +2068,19 @@ export class PostgresEngine implements BrainEngine {
// FTS config name (e.g. 'english', 'pt_br'). Validated by getFtsLanguage()
// — safe to interpolate into raw SQL.
const ftsLang = getFtsLanguage();
const ftsQueryExpr = buildWebsearchQueryExpr(ftsLang, '$1', query);
const rawQuery = `
SELECT
p.slug, p.id as page_id, p.title, p.type, p.source_id,
p.effective_date, p.effective_date_source,
cc.id as chunk_id, cc.chunk_index, cc.chunk_text, cc.chunk_source,
ts_rank(cc.search_vector, websearch_to_tsquery('${ftsLang}', $1)) * ${sourceFactorCase} AS score,
ts_rank(cc.search_vector, ${ftsQueryExpr}) * ${sourceFactorCase} AS score,
false AS stale
FROM content_chunks cc
JOIN pages p ON p.id = cc.page_id
JOIN sources s ON s.id = p.source_id
WHERE cc.search_vector @@ websearch_to_tsquery('${ftsLang}', $1)
WHERE cc.search_vector @@ ${ftsQueryExpr}
${typeClause}
${typesClause}
${excludeSlugsClause}
+22
View File
@@ -251,6 +251,28 @@ export function buildOrFallbackWebsearchQuery(query: string): string | null {
return tokens.join(' OR ');
}
/**
* #2380: FTS query expression for slash-bearing queries. Postgres' default
* text-search parser classifies `foo/bar` as a single `file`-alias lexeme —
* on BOTH the query side and the index side. So a raw `foo/bar` query only
* matched documents carrying the identical joined lexeme (literal paths),
* and a slash-split query only matches documents whose text had the words
* separated. Neither form alone covers both document shapes; OR the two
* parses so a slash query matches prose ("foo and bar", stemmed, AND
* semantics) AND literal slash forms ("src/core/x.ts") alike.
*
* Slash-free queries return the plain single-parse expression — byte-
* identical SQL and identical ts_rank to the historical behavior.
*
* `ftsLang` is validated by getFtsLanguage() (safe to interpolate);
* `param` is a `$N` placeholder, never user text.
*/
export function buildWebsearchQueryExpr(ftsLang: string, param: string, query: string): string {
const plain = `websearch_to_tsquery('${ftsLang}', ${param})`;
if (!query.includes('/')) return plain;
return `(websearch_to_tsquery('${ftsLang}', translate(${param}, '/', ' ')) || ${plain})`;
}
// ============================================================
// v0.29.1 — Recency component SQL builder
// ============================================================
@@ -28,8 +28,8 @@ describe('v0.32 #779: no_batch_cap suppresses the missing-max_batch_tokens warni
resetGateway();
});
test('Ollama, LiteLLM, llama-server all declare no_batch_cap: true', () => {
for (const id of ['ollama', 'litellm', 'llama-server']) {
test('LiteLLM and llama-server declare no_batch_cap: true', () => {
for (const id of ['litellm', 'llama-server']) {
const r = getRecipe(id);
expect(r, `${id} not registered`).toBeDefined();
expect(
@@ -39,6 +39,18 @@ describe('v0.32 #779: no_batch_cap suppresses the missing-max_batch_tokens warni
}
});
test('#2552: Ollama declares a conservative static batch cap, not no_batch_cap', () => {
// A CPU-only Ollama box wedges when a whole page ships in one request;
// Ollama never returns a token-limit error so the recursive-halving
// safety net can't fire. The pre-split cap is the only guard.
const r = getRecipe('ollama');
expect(r).toBeDefined();
const e = r!.touchpoints.embedding!;
expect(e.no_batch_cap).toBeUndefined();
expect(e.max_batch_tokens).toBe(4096);
expect(e.chars_per_token).toBe(2);
});
test('configureGateway does NOT warn for ollama/litellm/llama-server', () => {
warnSpy.mockClear();
resetGateway();
@@ -1,61 +0,0 @@
/**
* E2E smoke for skills/obsidian-gbrain-safe-index.
*
* Verifies the from-trigger-to-side-effect path that skillify requires:
* a real user trigger phrase routes to the skill, the resolver/check
* pipeline treats it as reachable, and the skill file exposes the
* gbrain commands the workflow actually runs.
*
* This stays local-only (no paid embedding, no external API): it asserts
* the documented safe/import path is present and parseable, not that it
* mutates a live brain.
*/
import { describe, expect, it } from 'bun:test';
import { existsSync, readFileSync } from 'fs';
import { join } from 'path';
const SKILLS = join(import.meta.dir, '..', '..', 'skills');
const SKILL_MD = join(SKILLS, 'obsidian-gbrain-safe-index', 'SKILL.md');
const RESOLVER = join(SKILLS, 'RESOLVER.md');
const TRIGGER_PHRASES = [
'connect my Obsidian vault to gbrain',
'import my vault to gbrain',
'sync vault and gbrain',
'capture this skill in my vault',
'embed gbrain after vault update',
'is gbrain synced with my vault',
];
describe('obsidian-gbrain-safe-index E2E', () => {
it('resolver maps real trigger phrasings to the skill', () => {
const resolver = readFileSync(RESOLVER, 'utf-8');
expect(resolver).toContain('obsidian-gbrain-safe-index/SKILL.md');
// Each representative phrase shares a token substring with a resolver row.
const rows = resolver
.split('\n')
.filter((l) => l.includes('obsidian-gbrain-safe-index/SKILL.md'))
.join('\n');
for (const phrase of TRIGGER_PHRASES) {
const hit = phrase
.toLowerCase()
.split(/\s+/)
.some((tok) => tok.length > 3 && rows.toLowerCase().includes(tok));
expect(hit, `no resolver token for: ${phrase}`).toBe(true);
}
});
it('skill documents the gbrain import-first safe path', () => {
const body = readFileSync(SKILL_MD, 'utf-8');
expect(body).toContain('gbrain import');
expect(body).toContain('--no-embed');
expect(body).toContain('gbrain config set search.mode conservative');
// Paid gate must be explicit, not a silent default.
expect(body).toContain('gbrain embed --stale');
});
it('skill is reachable from the skill tree', () => {
expect(existsSync(SKILL_MD)).toBe(true);
});
});
@@ -1,52 +0,0 @@
/**
* E2E smoke for skills/skill-vault-capture-policy.
*
* Verifies the from-trigger-to-side-effect path: a real capture request
* routes to the skill, and the skill documents the vault navigation
* obligations (index.md / log.md) that make a capture durable.
*
* Local-only: it asserts documented behavior, not live vault writes.
*/
import { describe, expect, it } from 'bun:test';
import { existsSync, readFileSync } from 'fs';
import { join } from 'path';
const SKILLS = join(import.meta.dir, '..', '..', 'skills');
const SKILL_MD = join(SKILLS, 'skill-vault-capture-policy', 'SKILL.md');
const RESOLVER = join(SKILLS, 'RESOLVER.md');
const TRIGGER_PHRASES = [
'save this learning to the vault',
'capture this skill in Obsidian',
'record this workflow in my notes',
'put this setup change in the knowledge base',
];
describe('skill-vault-capture-policy E2E', () => {
it('resolver maps real capture phrasings to the skill', () => {
const resolver = readFileSync(RESOLVER, 'utf-8');
expect(resolver).toContain('skill-vault-capture-policy/SKILL.md');
const rows = resolver
.split('\n')
.filter((l) => l.includes('skill-vault-capture-policy/SKILL.md'))
.join('\n');
for (const phrase of TRIGGER_PHRASES) {
const hit = phrase
.toLowerCase()
.split(/\s+/)
.some((tok) => tok.length > 3 && rows.toLowerCase().includes(tok));
expect(hit, `no resolver token for: ${phrase}`).toBe(true);
}
});
it('skill documents index.md and log.md update obligations', () => {
const body = readFileSync(SKILL_MD, 'utf-8');
expect(body).toContain('index.md');
expect(body).toContain('log.md');
});
it('skill is reachable from the skill tree', () => {
expect(existsSync(SKILL_MD)).toBe(true);
});
});
+118
View File
@@ -0,0 +1,118 @@
/**
* #2552: cloud-tuned embedding defaults silently wedge CPU-only local
* endpoints (Ollama). Three-part fix under test:
*
* 1. `isLocalEmbeddingEndpoint()` — gateway helper detecting local
* inference servers (ollama / llama-server recipes, localhost base URL).
* 2. `resolveEmbedConcurrency()` — embed auto-caps the 20-worker fan-out
* at LOCAL_EMBED_CONCURRENCY_CAP for local endpoints unless the
* operator set GBRAIN_EMBED_CONCURRENCY explicitly.
* 3. `computeEmbedConcurrencyCheck()` — doctor warns when an explicit env
* override fans out against a local endpoint.
*
* Serial: mutates process.env and the module-global gateway config.
*/
import { afterAll, afterEach, describe, expect, test } from 'bun:test';
import {
configureGateway,
resetGateway,
isLocalEmbeddingEndpoint,
LOCAL_EMBED_CONCURRENCY_CAP,
} from '../src/core/ai/gateway.ts';
import { resolveEmbedConcurrency } from '../src/commands/embed.ts';
import { computeEmbedConcurrencyCheck } from '../src/commands/doctor.ts';
const SAVED_ENV = process.env.GBRAIN_EMBED_CONCURRENCY;
afterEach(() => {
resetGateway();
if (SAVED_ENV === undefined) delete process.env.GBRAIN_EMBED_CONCURRENCY;
else process.env.GBRAIN_EMBED_CONCURRENCY = SAVED_ENV;
});
afterAll(() => {
resetGateway();
});
describe('#2552 isLocalEmbeddingEndpoint', () => {
test('false when the gateway is not configured (fail-open to cloud behavior)', () => {
resetGateway();
expect(isLocalEmbeddingEndpoint()).toBe(false);
});
test('true for the ollama recipe', () => {
configureGateway({ embedding_model: 'ollama:nomic-embed-text', env: {} });
expect(isLocalEmbeddingEndpoint()).toBe(true);
});
test('true for the llama-server recipe', () => {
configureGateway({ embedding_model: 'llama-server:my-gguf', env: {} });
expect(isLocalEmbeddingEndpoint()).toBe(true);
});
test('false for a cloud recipe', () => {
configureGateway({
embedding_model: 'openai:text-embedding-3-small',
env: { OPENAI_API_KEY: 'fake' },
});
expect(isLocalEmbeddingEndpoint()).toBe(false);
});
test('true when a cloud recipe base URL is explicitly pointed at localhost', () => {
configureGateway({
embedding_model: 'openai:text-embedding-3-small',
env: { OPENAI_API_KEY: 'fake' },
base_urls: { openai: 'http://localhost:8080/v1' },
});
expect(isLocalEmbeddingEndpoint()).toBe(true);
});
});
describe('#2552 resolveEmbedConcurrency', () => {
test('caps at LOCAL_EMBED_CONCURRENCY_CAP for a local endpoint when env is unset', () => {
delete process.env.GBRAIN_EMBED_CONCURRENCY;
configureGateway({ embedding_model: 'ollama:nomic-embed-text', env: {} });
expect(resolveEmbedConcurrency()).toBe(LOCAL_EMBED_CONCURRENCY_CAP);
});
test('explicit env override always wins, even against a local endpoint', () => {
process.env.GBRAIN_EMBED_CONCURRENCY = '10';
configureGateway({ embedding_model: 'ollama:nomic-embed-text', env: {} });
expect(resolveEmbedConcurrency()).toBe(10);
});
test('cloud endpoints keep the historical default of 20', () => {
delete process.env.GBRAIN_EMBED_CONCURRENCY;
configureGateway({ env: { OPENAI_API_KEY: 'fake' } });
expect(resolveEmbedConcurrency()).toBe(20);
});
test('pacing only ever lowers concurrency', () => {
delete process.env.GBRAIN_EMBED_CONCURRENCY;
configureGateway({ embedding_model: 'ollama:nomic-embed-text', env: {} });
expect(resolveEmbedConcurrency(1)).toBe(1);
expect(resolveEmbedConcurrency(16)).toBe(LOCAL_EMBED_CONCURRENCY_CAP);
});
});
describe('#2552 computeEmbedConcurrencyCheck (doctor)', () => {
test('ok for non-local endpoints', () => {
expect(computeEmbedConcurrencyCheck(false, '20', 2).status).toBe('ok');
});
test('warn when an explicit override exceeds the local cap', () => {
const check = computeEmbedConcurrencyCheck(true, '20', 2);
expect(check.status).toBe('warn');
expect(check.message).toContain('GBRAIN_EMBED_CONCURRENCY=20');
});
test('ok when env is unset against a local endpoint (auto-cap applies)', () => {
expect(computeEmbedConcurrencyCheck(true, undefined, 2).status).toBe('ok');
});
test('ok when the override is at or under the cap', () => {
expect(computeEmbedConcurrencyCheck(true, '2', 2).status).toBe('ok');
expect(computeEmbedConcurrencyCheck(true, '1', 2).status).toBe('ok');
});
});
-51
View File
@@ -1,51 +0,0 @@
import { describe, expect, it } from 'bun:test';
import { readFileSync, existsSync } from 'fs';
import { join } from 'path';
const SKILL_DIR = join(import.meta.dir, '..', 'skills', 'obsidian-gbrain-safe-index');
const SKILL_MD = join(SKILL_DIR, 'SKILL.md');
const RESOLVER = join(import.meta.dir, '..', 'skills', 'RESOLVER.md');
function parseFrontmatter(raw: string): Record<string, unknown> {
const m = raw.match(/^---\n([\s\S]*?)\n---/);
if (!m) throw new Error('no frontmatter');
const out: Record<string, unknown> = {};
for (const line of m[1].split('\n')) {
const mm = line.match(/^([a-zA-Z_]+):\s*(.*)$/);
if (mm) out[mm[1]] = mm[2].trim();
}
return out;
}
describe('obsidian-gbrain-safe-index skill', () => {
it('has a SKILL.md with required frontmatter', () => {
expect(existsSync(SKILL_MD)).toBe(true);
const fm = parseFrontmatter(readFileSync(SKILL_MD, 'utf-8'));
expect(fm['name']).toBe('obsidian-gbrain-safe-index');
expect(fm['description']).toBeTruthy();
});
it('has the required conformance sections', () => {
const body = readFileSync(SKILL_MD, 'utf-8');
for (const section of ['## Contract', '## Phases', '## Output Format', '## Anti-Patterns']) {
expect(body.includes(section), `missing ${section}`).toBe(true);
}
});
it('is registered in RESOLVER.md', () => {
expect(existsSync(RESOLVER)).toBe(true);
const resolver = readFileSync(RESOLVER, 'utf-8');
expect(resolver.includes('obsidian-gbrain-safe-index/SKILL.md')).toBe(true);
});
it('has routing-eval fixtures that exercise real trigger phrasings', () => {
const evalPath = join(SKILL_DIR, 'routing-eval.jsonl');
expect(existsSync(evalPath)).toBe(true);
const lines = readFileSync(evalPath, 'utf-8')
.split('\n')
.filter((l) => l.trim() && !l.trim().startsWith('//'))
.map((l) => JSON.parse(l));
const positives = lines.filter((l) => l.expected_skill === 'obsidian-gbrain-safe-index');
expect(positives.length).toBeGreaterThanOrEqual(5);
});
});
+61
View File
@@ -216,6 +216,67 @@ describe('PGLiteEngine: Search', () => {
expect(results.length).toBe(0);
});
// Regression (#2380): queries containing `/` used to bypass FTS AND
// semantics. Postgres' default text-search parser classifies `foo/bar` as
// a `file`-alias token mapped to the `simple` dictionary, so it became a
// single un-stemmed lexeme `'foo/bar'` that never matches indexed text —
// the primary FTS pass returned 0 and the OR fallback took over, matching
// pages that contain EITHER term. searchKeyword/searchTitles now normalize
// `/` to whitespace before websearch_to_tsquery parses, so the primary
// AND pass matches directly.
test('searchKeyword: slash query matches with AND semantics, not OR fallback', async () => {
// Decoy shares only ONE of the two query terms ('enterprise').
await engine.putPage('concepts/enterprise-pricing', {
type: 'concept', title: 'Widget Pricing',
compiled_truth: 'Enterprise pricing for widgets.',
});
await engine.upsertChunks('concepts/enterprise-pricing', [
{ chunk_index: 0, chunk_text: 'Enterprise pricing for widgets', chunk_source: 'compiled_truth' },
]);
// Both terms co-occur only in the novamind chunk. Pre-fix this returned
// BOTH pages (primary pass zero-hit → OR fallback); post-fix the primary
// AND pass returns exactly the co-occurrence page.
const results = await engine.searchKeyword('NovaMind/enterprise');
expect(results.length).toBe(1);
expect(results[0].slug).toBe('companies/novamind');
});
test('searchTitles: slash query matches with AND semantics, not OR fallback', async () => {
await engine.putPage('companies/novamind-enterprise', {
type: 'company', title: 'NovaMind Enterprise Platform',
compiled_truth: 'Placeholder body.',
});
await engine.putPage('guides/enterprise-sales', {
type: 'concept', title: 'Enterprise Sales Guide',
compiled_truth: 'Placeholder body.',
});
// Pre-fix: `NovaMind/Enterprise` parsed as one file-alias lexeme → the
// primary title pass returned 0 and the OR fallback matched BOTH titles.
const results = await engine.searchTitles('NovaMind/Enterprise');
expect(results.length).toBe(1);
expect(results[0].slug).toBe('companies/novamind-enterprise');
});
test('searchKeyword: slash query still matches the literal slash form (file paths)', async () => {
// The INDEX side also emits the joined file-alias lexeme for literal
// `foo/bar` text, so a query normalized to split words alone would go
// blind to documents containing the literal slash form (paths, URLs).
// buildWebsearchQueryExpr ORs both parses; this pins the raw arm.
await engine.putPage('runbooks/widget-deploy', {
type: 'concept', title: 'Widget Deploy Runbook',
compiled_truth: 'Runbook for the acme/widget deployment pipeline.',
});
await engine.upsertChunks('runbooks/widget-deploy', [
{ chunk_index: 0, chunk_text: 'Runbook for the acme/widget deployment pipeline', chunk_source: 'compiled_truth' },
]);
const results = await engine.searchKeyword('acme/widget');
expect(results.length).toBe(1);
expect(results[0].slug).toBe('runbooks/widget-deploy');
});
test('tsvector trigger populates search_vector on insert', async () => {
// Verify the PL/pgSQL trigger fires and content_chunks.search_vector is
// populated from chunk_text. v0.20.0 Cathedral II Layer 3 moved FTS from
-64
View File
@@ -1,64 +0,0 @@
import { describe, expect, it } from 'bun:test';
import { readFileSync, existsSync } from 'fs';
import { join } from 'path';
import { DEFAULT_PRIVATE_PATTERNS } from '../src/core/skillpack/harvest-lint.ts';
const SKILL_DIR = join(import.meta.dir, '..', 'skills', 'skill-vault-capture-policy');
const SKILL_MD = join(SKILL_DIR, 'SKILL.md');
const RESOLVER = join(import.meta.dir, '..', 'skills', 'RESOLVER.md');
function parseFrontmatter(raw: string): Record<string, unknown> {
const m = raw.match(/^---\n([\s\S]*?)\n---/);
if (!m) throw new Error('no frontmatter');
const out: Record<string, unknown> = {};
for (const line of m[1].split('\n')) {
const mm = line.match(/^([a-zA-Z_]+):\s*(.*)$/);
if (mm) out[mm[1]] = mm[2].trim();
}
return out;
}
describe('skill-vault-capture-policy skill', () => {
it('has a SKILL.md with required frontmatter', () => {
expect(existsSync(SKILL_MD)).toBe(true);
const fm = parseFrontmatter(readFileSync(SKILL_MD, 'utf-8'));
expect(fm['name']).toBe('skill-vault-capture-policy');
expect(fm['description']).toBeTruthy();
});
it('has the required conformance sections', () => {
const body = readFileSync(SKILL_MD, 'utf-8');
for (const section of ['## Contract', '## Phases', '## Output Format', '## Anti-Patterns']) {
expect(body.includes(section), `missing ${section}`).toBe(true);
}
});
it('is registered in RESOLVER.md', () => {
expect(existsSync(RESOLVER)).toBe(true);
expect(readFileSync(RESOLVER, 'utf-8').includes('skill-vault-capture-policy/SKILL.md')).toBe(true);
});
it('contains no private user or agent-fork names (privacy rule)', () => {
for (const file of [SKILL_MD, join(SKILL_DIR, 'routing-eval.jsonl')]) {
const body = readFileSync(file, 'utf-8');
// DEFAULT_PRIVATE_PATTERNS[0] is the banned fork-name pattern; sourced
// from harvest-lint so this file never contains the literal itself
// (scripts/check-privacy.sh would reject it).
const forkName = new RegExp(DEFAULT_PRIVATE_PATTERNS[0], 'i');
for (const name of [/\bAdam\b/, /\bHermes\b/, /\bHerdr\b/, /\bArk\b/, forkName]) {
expect(name.test(body), `private name ${name} in ${file}`).toBe(false);
}
}
});
it('has routing-eval fixtures', () => {
const evalPath = join(SKILL_DIR, 'routing-eval.jsonl');
expect(existsSync(evalPath)).toBe(true);
const positives = readFileSync(evalPath, 'utf-8')
.split('\n')
.filter((l) => l.trim() && !l.trim().startsWith('//'))
.map((l) => JSON.parse(l))
.filter((l) => l.expected_skill === 'skill-vault-capture-policy');
expect(positives.length).toBeGreaterThanOrEqual(4);
});
});