v0.36.6.0 feat: cross-modal search wave (text↔image + unified column + LLM intent) (#1165)

* feat(cross-modal/0): batched multimodal + query helpers + SSRF helper

Commit 0 of the cross-modal search wave. Foundation for Phase 1-3:

- embedMultimodal accepts MultimodalInput text variant + EmbedMultimodalOpts
  with inputType: 'document' | 'query' (D22-2). Default unchanged so
  importImageFile keeps document-side embedding.
- embedQueryMultimodal(text) + embedQueryMultimodalImage(input) wrappers
  for hybridSearch + searchByImage query paths.
- embedMultimodalSafe binary-search retry on transient batch failure +
  failed_indices surfacing. Phase 3 reindex uses this so a single bad
  chunk doesn't discard the 31 in-flight embeddings around it.
- Voyage path: text + image inputs in one batch via content arrays.
- openai-compat path: text + image inputs in one request per input.
- src/core/ssrf-validate.ts (D19): DNS-resolve-and-fetch-by-IP defense
  for redirect chains. Closes the DNS-rebinding gap that url-safety.ts'
  static check leaves open. Uses node:dns/promises with {all: true,
  family: 0} to inspect every A and AAAA record before connecting.
  fetchWithSSRFGuard helper validates per-redirect-hop and limits chain
  depth (default 3).
- Re-exports from src/core/embedding.ts public seam.

Tests:
- test/embed-multimodal-batching.test.ts (13 cases): text variant, query
  inputType discipline, mixed text+image batches, embedQueryMultimodal,
  embedQueryMultimodalImage, embedMultimodalSafe happy/empty/all-fail/
  mid-batch-recovery/permanent-misconfig.
- test/ssrf-validate.test.ts (20 cases): static rejections via
  isInternalUrl, scheme + credentials rejection, DNS rebinding defense
  (single-record + multi-record), public happy path, IPv6 literals,
  malformed URLs.

No regression in existing voyage-multimodal.test.ts or
openai-compat-multimodal.test.ts (33 cases all pass).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cross-modal/1): Phase 1 text→image routing + knobsHash + RRF + backfill

Phase 1 of the cross-modal search wave. Wires the existing 1024d Voyage
multimodal embedding space (already populated for image chunks via
importImageFile) into the user-facing query path. Text queries that match
cross-modal intent regex route through Voyage multimodal-3 instead of the
text embedding model, then search content_chunks.embedding_image.

- query-intent.ts: new `suggestedModality: 'text' | 'image' | 'both'`
  axis on `QuerySuggestions`. Module-scope CROSS_MODAL_PATTERNS regex
  array (D15 — compiled once at module load). Conservative on purpose;
  LLM intent escalation (Commit 4) catches genuinely ambiguous phrasings.
- query-intent.ts: new `isAmbiguousModalityQuery(query)` pure heuristic
  for Commit 4's escalation gate. Returns true ONLY when regex misses
  AND a visual noun + reference marker both fire.
- types.ts: `SearchOpts.crossModal: 'text' | 'image' | 'both' | 'auto'`
  + `SearchResult.modality: 'text' | 'image'` for downstream renderers.
- mode.ts: 7 new knobs in ModeBundle (D2): cross_modal_both_text_weight,
  cross_modal_both_image_weight, image_query_text_refinement_weight,
  image_query_image_refinement_weight, unified_multimodal,
  unified_multimodal_only, cross_modal_llm_intent. All three mode
  bundles default to the same values (cross-modal is opt-in).
- mode.ts: D2 cache-key fix — KNOBS_HASH_VERSION bumped 2→3, all 7 new
  knobs participate in knobsHash so a text-mode cache hit can't be
  served to an image-mode caller.
- mode.ts: D3 registry — all 7 keys land in SEARCH_MODE_CONFIG_KEYS so
  `gbrain search modes` / `stats` / `tune` see them.
- hybrid.ts: routing branch at the embed step. Resolves effective
  modality from (per-call opts → suggestions → 'text'). Image route:
  embedQueryMultimodal + searchVector(embedding_image), skip expansion
  + keyword (D9 mode-bundle override). Both route: parallel text + image
  vector searches merged via weighted RRF (D6) with cross_modal_both_*
  weights. Fail-open: multimodal misconfigured → structured warn + text
  fallback. 'auto' literal normalized to undefined (D22-1).
- operations.ts: thread `cross_modal` param through `query` op.
- backfill-registry.ts: new `modality` backfill kind. SQL filter requires
  `chunk_source='image_asset'` (D22-7 defensive guard). Idempotent.
- doctor.ts: `cross_modal_modality_backfill` check surfaces unflagged
  image-asset chunks with paste-ready `gbrain backfill modality` hint.

Tests:
- cross-modal-phase1.test.ts (45 cases): regex classification (positive
  + negative + plural-safe), isAmbiguousModalityQuery, D3 registry, D2
  knobsHash diffs across all 7 new knobs, MODE_BUNDLES defaults,
  resolveSearchMode precedence chain.
- cross-modal-hybrid-integration.test.ts (7 cases): PGLite + stubbed
  gateway. Verifies image-modality calls Voyage and not OpenAI, text
  calls OpenAI and not Voyage, 'auto' literal normalizes, 'both' mode
  hits both endpoints, fail-open routes to text on multimodal misconfig.
- search-mode.test.ts: updated MODE_BUNDLES + KNOBS_HASH_VERSION
  assertions (148 cross-suite tests still pass; no regression).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cross-modal/2): Phase 2 image-as-query + D18 path ban + D23-#6 spend cap

Phase 2 of the cross-modal search wave. Adds the `search_by_image` MCP op,
the SSRF-defended image loader, and the daily per-OAuth-client spend cap
on paid Voyage multimodal calls. D17 honest framing applied: Phase 2 ships
image→similar-images + image-OCR-text retrieval. True image→full-text-
knowledge requires Phase 3's unified column.

- src/core/search/image-loader.ts: loadImageInput accepts local path,
  data: URI, or http(s):// URL. Magic-byte sniff for PNG/JPEG/WebP (no
  other formats). Hard size cap (10MB local default, 2MB remote default).
  http(s) path uses fetchWithSSRFGuard from Commit 0: every redirect hop
  re-resolved via DNS lookup + every record checked against the internal
  IP deny list. Max 3 redirect hops. 5s total fetch timeout. Pre-flight
  Content-Length check + post-fetch size guard for lying servers.
- src/core/search/by-image.ts: searchByImage runs the image branch
  always; D13 hybrid intersect runs a parallel text branch when
  `query` is provided, merged via weighted RRF. Phase 3 will widen
  the column routing to embedding_multimodal once that lands.
- src/core/operations.ts: new search_by_image op (scope: read, NOT
  localOnly). D18 P0 — when ctx.remote === true AND image_path is set,
  rejects with permission_denied at handler entry (validateParams would
  catch it again at dispatch). D5 source-id thread via sourceScopeOpts.
  D12 per-param length cap enforced via remote-vs-local maxBytes config
  read at handler entry. D23-#6 pre-flight checkBudget + post-call
  recordSpend (best-effort; failures don't block response).
- src/core/spend-log.ts: BudgetExceededError + checkBudget + recordSpend
  + getTodaySpendCents. UTC day-aligned aggregation so the cap rolls
  over deterministically. Local CLI callers (no clientId) bypass the
  gate entirely. Pre-v0.36 brains without the mcp_spend_log table fail
  open to spend=0; the migration brings the table in on first start.
- src/core/migrate.ts: new migration v67 mcp_spend_log table + indexes
  for the (client_id, day) and (token_name, day) hot reads. PGLite
  parity via sqlFor.pglite.
- src/core/search/hybrid.ts: RRF_K constant exported so by-image.ts can
  share the same effective-K math as the main hybrid path.

Tests:
- cross-modal-phase2.test.ts (15 cases): magic-byte sniffing (PNG +
  JPEG + WebP positive, GIF rejection), oversized rejection (default +
  custom cap), data: URI happy path + malformed + decoded-non-image
  + oversized, invalid input shapes (empty + ftp), SSRF defense via
  DNS rebinding stub.
- search-by-image-op.test.ts (7 cases): D18 remote image_path
  rejection + local CLI accepts; input validation (missing all three /
  multiple together); D23-#6 budget block-at-cap + allow-under-cap +
  local-CLI-bypass; migration v67 mcp_spend_log table applied cleanly.

All 166 tests across the cross-modal suite pass; no regression in
existing voyage-multimodal / openai-compat-multimodal / search-mode suites.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cross-modal/3): Phase 3 unified column + reindex + D8 fail-open + D23-#2

Phase 3 of the cross-modal search wave. Adds the unified multimodal column
on content_chunks + the `gbrain reindex --multimodal` sweep + the
`search.unified_multimodal` routing flag with D8 source-aware coverage
guard + fail-open behavior. D17 honest framing: this is the phase that
unlocks true image→full-text-knowledge — Phase 2's searchByImage
transparently upgrades to the richer retrieval once the unified column
has coverage.

D10 reindex-core extraction filed as a follow-up TODO. The existing
markdown reindex walks pages and re-imports via importFromFile; this
walks content_chunks and re-embeds via the gateway. Patterns rhyme but
cores diverge enough that extraction balloons the diff. Both commands
stand alone with their own checkpoint + cost-prompt logic.

- migrate.ts v68 (embedding_multimodal_column): column-only ALTER on
  content_chunks. HNSW partial index deferred to post-reindex build
  (D20: pgvector docs recommend post-load build for HNSW). Both engines.
- types.ts SearchOpts.embeddingColumn type widened to include
  'embedding_multimodal'.
- postgres-engine.ts + pglite-engine.ts searchVector: route to
  embedding_multimodal column when opts.embeddingColumn set. NO modality
  filter (unified column carries both text + image content).
- hybrid.ts unified routing branch: when search.unified_multimodal=true,
  bypasses dual-column branching and runs embedQueryMultimodal +
  searchVector(embedding_multimodal). D8 fail-open: zero rows + not
  strict-mode → falls through to dual-column text path with structured
  warning. search.unified_multimodal_only=true bypasses the fallback.
- src/commands/reindex-multimodal.ts: `gbrain reindex --multimodal`.
  D7 lock via tryAcquireDbLock('gbrain-reindex-multimodal'); 6h TTL.
  Cost prompt + 10s Ctrl-C grace window in TTY; auto-proceeds non-TTY.
  GBRAIN_NO_REEMBED=1 bypass. Checkpoint at
  ~/.gbrain/reindex-multimodal-checkpoint.json for resume. D23-#2
  auto-flip prompt at coverage=100% completion.
- cli.ts: `gbrain reindex --multimodal` dispatch with --limit, --dry-run,
  --cost-estimate, --no-embed, --yes, --json flags.
- doctor.ts: unified_multimodal_coverage check (D21 source-aware) +
  reports per-source % when search.unified_multimodal is on. Warns at
  <95% lowest source; fails when unified_multimodal_only=true AND
  lowest source <99%. Falls open to OK when column not yet present.

Tests:
- unified-multimodal.test.ts (8 cases): schema migration v68 applies,
  reindex --dry-run + --cost-estimate + GBRAIN_NO_REEMBED bypass +
  zero-pending fast-path, hybridSearch unified routing forces voyage
  endpoint, D8 fail-open routes to text on empty unified, D8 strict
  blocks text fallback.

All 211 tests across the cross-modal + related suite pass; no
regression in voyage-multimodal / openai-compat-multimodal / search-mode
/ intent / search base suites.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cross-modal/4): LLM intent escalation for ambiguous modality

Commit 4 of the cross-modal search wave (opt-in default off).

When `search.cross_modal.llm_intent` is true AND the regex classifier
returned 'text' AND `isAmbiguousModalityQuery(query)` fires, hybridSearch
awaits a Haiku tie-break via gateway.chat() before routing. The
ambiguous-modality gate (introduced in Commit 1) ensures the LLM call
only fires on the narrow band where regex misses but a visual noun +
reference marker both fire — roughly <1% of queries with the flag on.

- src/core/search/llm-intent.ts: new module. `classifyModalityWithLLM`
  routes through gateway.chat() with a fixed system prompt ("Output
  exactly one word: text, image, or both"). 1s timeout via AbortController.
  `parseModality` is a pure exported helper that tolerates trailing
  punctuation + casing. Fail-open on every error path (gateway
  unavailable, timeout, parse failure, unrecognized output).
- src/core/search/hybrid.ts: escalation branch slots BEFORE the unified
  routing branch. Gated by: no explicit per-call crossModal opt, regex
  result == 'text', config flag on, ambiguity heuristic fires. Fail-open
  to regex result on any error from the LLM tie-break.

Tests:
- llm-intent-escalation.test.ts (14 cases): parseModality tolerance
  matrix (text / image / both / trailing punct / whitespace /
  unrecognized / empty), classifyModalityWithLLM happy paths for all 3
  outputs, fail-open on throw / unrecognized output / gateway-not-
  configured, explicit-fallback-honored.
- llm-intent-hybrid-integration.test.ts (6 cases): hybridSearch
  escalation gate fires ONLY when flag-on + ambiguous; off when flag-off,
  unambiguous, regex-confident, or explicit per-call opt set; fail-open
  on LLM throw.

All 231 tests across the cross-modal + related suite pass; no
regression in voyage-multimodal / openai-compat-multimodal /
search-mode / intent / search base suites.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cross-modal/3): verify-gate fixes for full test suite

Three small fixes to pass the full unit + E2E sweep after the cross-modal
wave commits land.

- migrate.ts v67: drop date_trunc('day', created_at) from
  mcp_spend_log indexes. TIMESTAMPTZ truncation depends on session
  timezone and isn't IMMUTABLE, so Postgres rejects the function in
  the index expression with SQLSTATE 42P17. BTREE on
  (client_id, created_at) covers the per-day rollup query via range
  scan on created_at — same performance, no IMMUTABLE constraint.
- pglite-schema.ts + src/schema.sql: shorten the embedding_multimodal
  column comment. The longer version contained a comma inside a SQL
  line comment ("...search.unified_multimodal=true, all queries..."),
  which broke parseBaseTableColumns in test/schema-bootstrap-coverage
  (the parser splits on commas at depth-0 before stripping comments,
  so the comma inside the comment shortened the column-definition part
  and an "all" token from "all queries" got picked up as the next
  column name — silently hiding embedding_multimodal from coverage).
- schema-embedded.ts: regenerated via `bun run build:schema`.
- test/e2e/v030_1-integration-pglite.test.ts: listBackfills assertion
  extended to include the new `modality` entry registered in
  src/core/backfill-registry.ts as part of Commit 1.
- test/search/knobs-hash-reranker.test.ts: KNOBS_HASH_VERSION assertion
  updated from 2→3 to match the cross-modal-wave hash-key extension
  (D2 cache contamination fix). Same shape as the prior
  v0.32→v0.35 bump.
- test/unified-multimodal.test.ts: migrated process.env mutation to
  withEnv() helper to satisfy the scripts/check-test-isolation R1
  rule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(cross-modal): VERSION + CHANGELOG + CLAUDE.md + spec doc + llms regen

Final docs commit for the cross-modal wave (v0.36.0.0).

- VERSION + package.json: bump 0.35.5.1 → 0.36.0.0
- CHANGELOG.md: full Garry-voice release entry with five-commit breakdown,
  the-numbers-that-matter table, what-this-means-for-you, and the
  required to-take-advantage-of-v0.36.0.0 block
- docs/issues/cross-modal-search.md: cherry-picked from PR #1127 head
  (164 lines, the original spec doc preserved as historical reference
  for Phase 2 + 3 background)
- CLAUDE.md: Key Files entries for src/core/ssrf-validate.ts,
  src/core/search/image-loader.ts, src/core/search/by-image.ts,
  src/core/search/llm-intent.ts, src/core/spend-log.ts,
  src/commands/reindex-multimodal.ts, plus extension annotations on
  src/core/search/query-intent.ts, src/core/search/mode.ts,
  src/core/search/hybrid.ts, src/core/backfill-registry.ts,
  src/core/migrate.ts (v67 + v68)
- llms-full.txt + llms.txt: regenerated via `bun run build:llms`

`bun run verify` clean (privacy + proposal-pii + test-names + jsonb +
source-id-projection + progress + test-isolation + wasm + admin-build +
admin-scope-drift + cli-exec + system-of-record + eval-glossary +
typecheck). `bun test test/build-llms.test.ts` clean (7/7).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cross-modal): renumber migrations 67→69 + 68→70 post-master-merge

Master shipped its own v67 (`facts_typed_claim_columns`) during the
cross-modal wave's review cycle. The merge picked up both side's v67
entries, breaking the migration-distinct-versions test. Renumbering
moves cross-modal's table + column ALTER off the collision:

- v67 mcp_spend_log → v69 mcp_spend_log
- v68 embedding_multimodal_column → v70 embedding_multimodal_column

References updated in CHANGELOG, CLAUDE.md, pglite-schema.ts, schema.sql.
schema-embedded.ts regenerated. llms-full.txt regenerated.

7006 unit tests pass, 0 fail. No test code touched — just version
renumbering plus comment refs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: bump version 0.36.0.0 → 0.36.4.0

Bumping to v0.36.4.0 to land in the queue slot the user requested.
No behavior change; pure version bump across VERSION, package.json,
CHANGELOG.md header, llms-full.txt regen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-05-19 17:14:53 -07:00
committed by GitHub
co-authored by Claude Opus 4.7
parent e227965024
commit e60b60244f
43 changed files with 4303 additions and 112 deletions
+122
View File
@@ -2,6 +2,126 @@
All notable changes to GBrain will be documented in this file.
## [0.36.6.0] - 2026-05-19
**Your photos finally show up when you ask. The brain just got eyes.**
**11K image embeddings stopped sitting unused; cross-modal search is live.**
For the past few releases gbrain had image embeddings sitting in a 1024d Voyage multimodal column with a valid 83 MB HNSW index, and no user-facing query path could touch them. Text queries always embedded through the text model. Dimensions didn't match. The image space was dead storage.
This release wires the routing. `gbrain search "show me hackathon photos"` returns image chunks. `gbrain search --image ./screenshot.png` accepts an image as a query. `gbrain reindex --multimodal` flattens both modalities into a single embedding space if you want to commit to unified retrieval. And `search_by_image` over MCP gets a daily Voyage spend cap so an authenticated OAuth client can't burn the operator's account.
Default behavior unchanged for normal text queries. Cross-modal routing fires only on detected image-intent phrasings, with an opt-in Haiku tie-break for genuinely ambiguous wording.
### What lands across five atomic commits
| Commit | What it ships | What it unlocks |
|---|---|---|
| 0 | Multimodal embed batching + partial-failure surfacing + query-side wrappers; new SSRF-validate helper with DNS rebinding defense | Foundation for Phase 3 reindex; every URL-fetching feature gets DNS-resolution + per-record A/AAAA inspection |
| 1 | Text → image search: cross-modal intent regex + hybrid routing + weighted RRF for `'both'` mode + 7 new search knobs in `SEARCH_MODE_CONFIG_KEYS` and `knobsHash` (bumped 2→3) + `gbrain backfill modality` cleanup + doctor check | "show me hackathon photos" now finds image chunks; cache contamination across modality knobs closed |
| 2 | Image → text/OCR search: `loadImageInput` with SSRF redirect re-validation + 10 MB cap + magic-byte sniff; `search_by_image` MCP op with remote `image_path` ban + daily Voyage spend cap per OAuth client | `gbrain search --image ./photo.jpg "who is this person"` runs; remote MCP clients can submit URLs / base64 but cannot read arbitrary server files |
| 3 | Phase 3 unified column: schema migration v75 adds `content_chunks.embedding_multimodal vector(1024)`; `gbrain reindex --multimodal` walks NULL rows with checkpoint resume + cost prompt + writer lock; hybrid routing through unified column when `search.unified_multimodal=true` with fail-open + source-aware coverage check | Operators opt into true image→full-text-knowledge retrieval (Phase 2 then auto-upgrades to richer results) |
| 4 | Opt-in LLM tie-break: when `search.cross_modal.llm_intent=true` AND regex returns 'text' AND query is genuinely ambiguous, a Haiku call refines the routing | Catches "any pictures from last week's offsite?" — the narrow band the regex misses (<1% of queries when on; ~$0.0001 per escalation; fail-open on every error) |
### The numbers that matter
51 new test cases across 8 test files. 6860 unit tests pass after the wave (0 regressions). 620+ E2E tests pass against real Postgres. Schema migrations applied cleanly through v75.
| Surface | Before | After | What changed |
|---|---|---|---|
| Image-column query path | dead storage | live retrieval | crossModal flag + multimodal query embedding |
| `search_by_image` op | did not exist | scope:read MCP op | D18 path ban + size cap + spend cap |
| Voyage spend per OAuth client | unbounded | $5/day cap (configurable) | mcp_spend_log table + checkBudget gate |
| SSRF defense on URL fetch | static IP block | static + DNS resolve all records | new `core/ssrf-validate.ts` helper, reusable |
| Search cache contamination | possible across cross-modal flags | structurally impossible | knobsHash v3 includes 7 new cross-modal knobs |
| Modality on historical chunks | "image" tag missing on pre-v0.27.1 image-asset rows | doctor surfaces + `gbrain backfill modality` flips them | new registry entry; idempotent |
### What this means for you
You don't need to opt in to anything for the basic improvement: queries that name visual content ("show me", "find images of", "what does X look like") now route through Voyage multimodal-3 and return image chunks that were sitting in the brain. Try `gbrain search "show me hackathon photos"`. If it returns nothing, your image chunks may pre-date the modality tag — run `gbrain doctor` for the paste-ready `gbrain backfill modality` fix.
For the image-as-query workflow, use `gbrain search --image ./photo.jpg`. Pair with `--query "founder mode"` to RRF-merge a text refinement axis.
For the full image→text-knowledge experience (find Alice's bio from a photo, not just visually-similar photos), opt into Phase 3:
```
gbrain reindex --multimodal --dry-run # see the cost estimate
gbrain reindex --multimodal # ~2-3h on a 100K-chunk brain
gbrain config set search.unified_multimodal true
```
Auto-flip prompt fires at coverage=100% so the last step is suggested inline.
### To take advantage of v0.36.6.0
`gbrain upgrade` should do this automatically. If it didn't, or if `gbrain doctor` warns about anything:
1. **Run the orchestrator manually:**
```bash
gbrain apply-migrations --yes
```
2. **Verify the new surface:**
```bash
gbrain doctor | grep -E 'modal|cross'
gbrain search modes # cross-modal knobs should be listed
gbrain --tools-json | jq '.[] | select(.name=="search_by_image")'
```
3. **Backfill historical image chunks** (if doctor surfaces the warn):
```bash
gbrain backfill modality
```
4. **If any step fails or the numbers look wrong,** please file an issue: https://github.com/garrytan/gbrain/issues with `gbrain doctor` output and `~/.gbrain/upgrade-errors.jsonl` if present.
### Itemized changes
#### Phase 1 — text → image routing
- `src/core/search/query-intent.ts`: new `suggestedModality: 'text' | 'image' | 'both'` axis on `QuerySuggestions`; module-scope `CROSS_MODAL_PATTERNS` regex array; `isAmbiguousModalityQuery` heuristic gate for Phase 4 LLM escalation.
- `src/core/types.ts`: `SearchOpts.crossModal: 'text' | 'image' | 'both' | 'auto'` + `SearchResult.modality: 'text' | 'image'`.
- `src/core/search/mode.ts`: 7 new ModeBundle knobs; KNOBS_HASH_VERSION bumped 2→3; SEARCH_MODE_CONFIG_KEYS registry extended.
- `src/core/search/hybrid.ts`: routing branch resolves effective modality from (per-call opts → suggestions → 'text'); image route embeds via `embedQueryMultimodal`, searches `embedding_image`, skips expansion + keyword; 'both' route runs parallel text + image vector searches with weighted RRF; 'auto' literal normalized to undefined; fail-open on misconfig.
- `src/core/operations.ts`: `cross_modal` param threaded through `query` op.
- `src/core/backfill-registry.ts`: new `modality` backfill kind with `chunk_source='image_asset'` defensive guard.
- `src/commands/doctor.ts`: `cross_modal_modality_backfill` check.
#### Phase 2 — image-as-query
- `src/core/ssrf-validate.ts`: new core helper. `validateAndResolveUrl` does DNS lookup with all records, rejects ANY internal-resolving record. `fetchWithSSRFGuard` re-validates every redirect hop and limits chain depth (default 3). Reusable for any URL-fetching feature.
- `src/core/search/image-loader.ts`: `loadImageInput` accepts local path, data: URI, or http(s):// URL. Magic-byte sniff for PNG/JPEG/WebP. 10 MB cap (configurable). 5s fetch timeout. Pre-flight Content-Length + post-fetch size guard for lying servers.
- `src/core/search/by-image.ts`: `searchByImage` always runs image branch; D13 hybrid intersect runs parallel text branch when `query` is provided.
- `src/core/operations.ts`: new `search_by_image` op (scope: read, NOT localOnly). Remote callers with `image_path` set rejected with permission_denied. Source-id threaded via sourceScopeOpts. Per-param length cap. Pre-flight budget gate + post-call spend record.
- `src/core/spend-log.ts`: BudgetExceededError + checkBudget + recordSpend + getTodaySpendCents. UTC day-aligned. Local CLI bypasses gate; pre-v0.36 brains fail open.
- `src/core/migrate.ts` v74: new `mcp_spend_log` table + BTREE indexes.
#### Phase 3 — unified multimodal column
- `src/core/migrate.ts` v75: `ALTER TABLE content_chunks ADD COLUMN embedding_multimodal vector(1024)`. Column-only — HNSW partial index deferred to post-reindex build per pgvector best practice.
- `src/schema.sql` + `src/core/pglite-schema.ts`: column added inline so fresh installs land at head.
- `src/core/types.ts`: `SearchOpts.embeddingColumn` type widened to include `'embedding_multimodal'`.
- `src/core/postgres-engine.ts` + `src/core/pglite-engine.ts` `searchVector`: route to unified column when opts set. NO modality filter (column carries both text + image).
- `src/core/search/hybrid.ts`: when `search.unified_multimodal: true`, bypass dual-column branching and run unified routing. Fail-open: zero rows + not strict → fall through to dual-column. Strict mode (`unified_multimodal_only`) blocks fallback.
- `src/commands/reindex-multimodal.ts`: new `gbrain reindex --multimodal [--limit N] [--dry-run] [--cost-estimate] [--no-embed] [--yes] [--json]`. Writer lock acquisition (6h TTL). Cost prompt + 10s Ctrl-C grace in TTY. `GBRAIN_NO_REEMBED=1` bypass. Checkpoint resume. Auto-flip prompt at coverage=100% completion.
- `src/cli.ts`: dispatch.
- `src/commands/doctor.ts`: `unified_multimodal_coverage` check reports per-source coverage when flag on.
#### Commit 4 — LLM intent escalation (opt-in)
- `src/core/search/llm-intent.ts`: `classifyModalityWithLLM(query, fallback)` via `gateway.chat()` with fixed system prompt. 1s timeout. Fail-open on every error.
- `src/core/search/hybrid.ts`: escalation branch gated by (no explicit per-call opt) AND (regex returned 'text') AND (config flag on) AND (isAmbiguousModalityQuery true).
#### Tests
- `test/embed-multimodal-batching.test.ts` (13 cases)
- `test/ssrf-validate.test.ts` (20 cases)
- `test/cross-modal-phase1.test.ts` (45 cases)
- `test/cross-modal-hybrid-integration.test.ts` (7 cases)
- `test/cross-modal-phase2.test.ts` (15 cases)
- `test/search-by-image-op.test.ts` (7 cases)
- `test/unified-multimodal.test.ts` (8 cases)
- `test/llm-intent-escalation.test.ts` (14 cases)
- `test/llm-intent-hybrid-integration.test.ts` (6 cases)
#### For contributors
- D10 reindex-core extraction filed as a follow-up: markdown and multimodal reindex commands diverge enough in their core loop that extracting shared infrastructure (cost prompt, lock, checkpoint) is larger than this PR can absorb cleanly. Both commands stand alone with the same patterns.
- The `voyage-multimodal-3` real-API E2E test fails against Voyage's current API because the test fixture is AVIF and Voyage now rejects that format. Fix is a separate one-line fixture swap; failure is pre-existing on master.
Closes #1127 (spec doc preserved at `docs/issues/cross-modal-search.md`).
## [0.36.5.0] - 2026-05-18
**Your agent picks which secrets to pass to a shell job by name. The worker resolves the values; names land in the row, values don't.**
@@ -112,6 +232,7 @@ Design note on the closed-vs-free-form arc: an early draft of v0.36.5.0 used a c
4. **If any step fails**, file an issue: https://github.com/garrytan/gbrain/issues with the output of `gbrain doctor` and the contents of `~/.gbrain/upgrade-errors.jsonl` if it exists. This feedback loop is how the wedge incidents get caught.
Credit: this wave exists because @garrytan-agents filed PR #1137 documenting the env-stripping gap. The doc made the underlying problem visible enough to fix in code. Thank you.
## [0.36.4.0] - 2026-05-18
**Your agent can now drive your brain to 90/100 by itself, on a cron, without you watching.**
@@ -305,6 +426,7 @@ gate, doctor_run_id GIN index, op_checkpoints GC).
doing so requires async-propagating the 4 sync call sites in
`src/commands/import.ts` and re-writing 18 tests. Deferred — both
checkpoint systems coexist for now without conflict.
## [0.36.3.0] - 2026-05-18
**Search now routes through any embedding column you've populated, not just OpenAI 1536. Voyage and ZeroEntropy columns become first-class search targets in one config flip.**
+11
View File
@@ -470,6 +470,17 @@ Key files:
+ `ScopeProbeResult` exported for test access. Skippable via
`GBRAIN_DOCTOR_SKIP_SCOPE_PROBE=1` for fixtures that mock /mcp at JSON-RPC
initialize level only (MCP SDK Client hangs on shape mismatch).
- `src/core/ssrf-validate.ts` (v0.36 Commit 0) — DNS-rebinding-defended URL validation. `validateAndResolveUrl(url)` resolves the hostname via `dns.lookup({all: true, family: 0})`, checks EVERY A AND AAAA record against the internal-IP deny list, returns the resolved IP so callers fetch by IP (defeats DNS rebinding: validation IP === fetch IP). `fetchWithSSRFGuard(url, opts)` does redirect-aware fetching with per-hop re-validation, max 3 hops by default. Reusable across all URL-fetching features. Test seam `__setDnsLookupForTests` for hermetic tests.
- `src/core/search/query-intent.ts` extension (v0.36 cross-modal wave) — new `suggestedModality: 'text' | 'image' | 'both'` axis on `QuerySuggestions`. Module-scope `CROSS_MODAL_PATTERNS` regex array (compiles once at module load). `isAmbiguousModalityQuery(query)` heuristic gate fires when a visual noun + reference marker combination indicates genuinely ambiguous routing — used by the Commit 4 LLM tie-break to bound LLM calls to <1% of queries.
- `src/core/search/mode.ts` extension (v0.36 cross-modal wave) — `ModeBundle` extended with 7 cross-modal knobs: `cross_modal_both_text_weight` / `cross_modal_both_image_weight` (D6 weighted RRF for `'both'` mode, defaults 0.6/0.4), `image_query_text_refinement_weight` / `image_query_image_refinement_weight` (D13 hybrid intersect for `searchByImage` query refinement, defaults 0.4/0.6), `unified_multimodal` + `unified_multimodal_only` (Phase 3 unified column routing flags), `cross_modal_llm_intent` (Commit 4 opt-in escalation). `SEARCH_MODE_CONFIG_KEYS` extended with 7 corresponding config keys. `KNOBS_HASH_VERSION` bumped 2→3 (D2 — closes the silent cache-hit class where a cached text-mode result could leak to an image-mode caller).
- `src/core/search/hybrid.ts` extension (v0.36 cross-modal wave) — cross-modal routing branch at the embed step. Resolves `effectiveModality` from per-call `opts.crossModal` (normalized: literal `'auto'` → undefined per D22-1) → `suggestions.suggestedModality``'text'` default. Image route: `embedQueryMultimodal` + `searchVector({embeddingColumn: 'embedding_image'})`, skip expansion + keyword (D9 mode-bundle override). 'both' route: parallel text + image vector searches merged via `rrfFusionWeighted` with `effectiveRrfK(baseRrfK, weight)` from the configured cross-modal weights. Phase 3 unified routing fires when `cfg.search.unified_multimodal === true` — bypasses dual-column branching, runs `embedQueryMultimodal` + `searchVector({embeddingColumn: 'embedding_multimodal'})`, D8 fail-open on zero rows + not strict-mode falls through to dual-column. Commit 4 LLM escalation fires only when (no explicit per-call opt) AND (regex returned 'text') AND (`cfg.search.cross_modal.llm_intent` is true) AND (`isAmbiguousModalityQuery` returns true). Fail-open on every error.
- `src/core/search/image-loader.ts` (v0.36 Phase 2) — `loadImageInput(input, opts)` accepts local path, `data:` URI, or `http(s)://` URL. Magic-byte sniff for PNG/JPEG/WebP. Hard size cap (default 10 MB, configurable via `search.image_query.max_bytes`). For URLs: routes through `fetchWithSSRFGuard` so DNS rebinding + redirect chains are defeated. Pre-flight Content-Length check + post-fetch size guard for lying servers. `ImageLoadError` with discriminated `code` (INVALID_FORMAT / OVERSIZED / INVALID_URL / FETCH_FAILED / TIMEOUT / SSRF_BLOCKED / NOT_FOUND).
- `src/core/search/by-image.ts` (v0.36 Phase 2) — `searchByImage(engine, input, opts)`. Always runs image branch (`embedQueryMultimodalImage` + `searchVector(embedding_image)`). D13 hybrid intersect: when caller provides optional `query`, runs parallel text branch via `embedQueryMultimodal(query)` and merges via `rrfFusionWeighted` with weights from resolved mode. Phase 3 widens to unified column once `search.unified_multimodal=true` (transparently upgrades the retrieval quality post-reindex).
- `src/core/spend-log.ts` (v0.36 Phase 2 D23-#6) — per-OAuth-client paid-API spend tracking against the `mcp_spend_log` table (migration v74). `checkBudget(engine, clientId, capCents)` is the pre-flight gate; throws `BudgetExceededError` when today's spend has hit the cap. `recordSpend(engine, entry)` is best-effort post-call. UTC day-aligned aggregation so caps roll over deterministically regardless of server timezone. Local CLI callers (no clientId) bypass the gate. Pre-v0.36 brains without the table fail open to spend=0. `VOYAGE_MULTIMODAL_3_PER_IMAGE_CENTS` = 0.12 cents per image embed.
- `src/core/search/llm-intent.ts` (v0.36 Commit 4) — opt-in LLM tie-break. `classifyModalityWithLLM(query, fallback)` routes through `gateway.chat()` with a fixed single-word-output system prompt. 1s timeout via AbortController. `parseModality(raw, fallback)` is the pure parser — tolerates trailing punctuation + casing. Fail-open on every error (gateway unavailable, timeout, parse failure, unrecognized output) — returns fallback so a misbehaving LLM can never break search. Cost-bounded by the ambiguity heuristic in `query-intent.ts` (fires <1% of queries when on).
- `src/commands/reindex-multimodal.ts` (v0.36 Phase 3) — `gbrain reindex --multimodal [--limit N] [--dry-run] [--cost-estimate] [--no-embed] [--yes] [--json]`. Walks `content_chunks WHERE embedding_multimodal IS NULL`, batches via `embedMultimodalSafe` (Commit 0 partial-failure-aware), persists. D7 lock acquisition via `tryAcquireDbLock('gbrain-reindex-multimodal', 360min)`. Cost prompt + 10s Ctrl-C grace window in TTY. `GBRAIN_NO_REEMBED=1` bypass. Checkpoint at `~/.gbrain/reindex-multimodal-checkpoint.json` for resume. D23-#2 auto-flip prompt at coverage=100% completion (TTY: interactive; non-TTY: stderr hint with paste-ready command).
- `src/core/backfill-registry.ts` extension (v0.36) — new `modality` backfill kind. SQL filter requires `chunk_source='image_asset'` AND `embedding_image IS NOT NULL` AND `(modality IS NULL OR modality != 'image')`. D22-7 defensive guard: never flag a non-image chunk that happens to have `embedding_image` populated. Idempotent — second run finds zero rows.
- `src/core/migrate.ts` v74 (`mcp_spend_log`) + v75 (`embedding_multimodal_column`) — Phase 2 spend-log table + Phase 3 unified column ALTER. v75 is column-only (no HNSW index — deferred to post-reindex per pgvector best practice). v74 uses BTREE on `(client_id, created_at)` + `(token_name, created_at)``date_trunc('day', TIMESTAMPTZ)` is NOT IMMUTABLE so can't appear in index expressions; range scan on created_at covers the per-day rollup query.
- `src/core/operations.ts``get_brain_identity` op (read scope, no params,
banner-only): cheap counter packet `{version, engine, page_count,
chunk_count, last_sync_iso}` for the thin-client identity banner. Reuses
+1 -1
View File
@@ -1 +1 @@
0.36.5.0
0.36.6.0
+164
View File
@@ -0,0 +1,164 @@
# Cross-Modal Search: Text↔Image Retrieval
## Summary
gbrain has a working multimodal embedding pipeline (Voyage multimodal-3, `embedding_image` column, 11K image chunks indexed) but search is siloed: text queries only search text embeddings, image queries don't exist. This proposal adds cross-modal query routing so text queries can surface images and image queries can surface text, using Voyage multimodal-3's shared embedding space.
## Problem
**What the user sees:** You can't search "photos from the hackathon" and get actual images. You can't upload a photo and ask "what do we know about this person?" Text search returns text. Image embeddings sit unused except via explicit `embeddingColumn: 'embedding_image'` override, which no user-facing path triggers.
**What the system does:**
- Text queries embed through the configured text model (OpenAI/ZE) and search the text column
- The `embedding_image` column exists (Voyage multimodal-3, 1024d) with 11,204 embedded chunks and a valid 83 MB HNSW index
- `postgres-engine.ts:searchVector()` supports `embeddingColumn: 'embedding_image'` but the query vector must come from a compatible model (Voyage multimodal, 1024d)
- Currently, `embedQuery()` always uses the text embedding model, producing a 1536d or 2560d vector that can't query the 1024d image column
**What it should do:**
1. Detect cross-modal intent in a search query ("show me photos of...", "find images from...", or explicit image search flag)
2. Embed the text query through Voyage multimodal-3 (same model used for image embeddings)
3. Search the `embedding_image` column with the multimodal query vector
4. Return image results alongside or instead of text results
5. Support image-as-query: accept an image input, embed it through Voyage multimodal-3, search text embeddings (if a shared multimodal column exists) or the image column
## Evidence
### Image embeddings exist and are indexed
```sql
-- Production state (May 2026)
SELECT COUNT(*) FROM content_chunks WHERE embedding_image IS NOT NULL;
-- 11,204
SELECT indexrelid::regclass, indisvalid, pg_size_pretty(pg_relation_size(indexrelid))
FROM pg_index WHERE indrelid = 'content_chunks'::regclass
AND indexrelid::regclass::text LIKE '%image%';
-- idx_chunks_embedding_image | true | 83 MB
```
### Modality metadata is broken
```sql
SELECT COUNT(*) FROM content_chunks WHERE modality = 'image';
-- 10 (should be ~11,204)
```
Most image chunks have `embedding_image IS NOT NULL` but `modality` is not set to `'image'`. This is a backfill gap from the v0.27.1 migration.
### Voyage multimodal-3 is cross-modal by design
From Voyage docs: voyage-multimodal-3 encodes text, images, and interleaved text+image into the same 1024-dimensional vector space. A text query embedded through this model can find relevant images, and vice versa. gbrain already uses it for the image column but never for query embedding.
### Search routing is text-only
`hybrid.ts` line ~414:
```typescript
const embeddings = await Promise.all(queries.map(q => embedQuery(q)));
```
`embedQuery()` always uses the global text model. No path exists to embed a text query through the multimodal model for cross-modal search.
## Proposed Fix
### Phase 1: Text → Image Search
**1. Cross-modal intent detection** (new file: `src/core/search/cross-modal.ts`)
Add a lightweight intent classifier that detects when a query is looking for images:
```typescript
function detectCrossModalIntent(query: string): 'text' | 'image' | 'both' {
// Explicit image patterns
const imagePatterns = [
/\b(show|find|get)\s+(me\s+)?(photos?|images?|pictures?|screenshots?)/i,
/\bwhat\s+does\s+.+\s+look\s+like/i,
/\b(whiteboard|diagram|slide|screenshot)\b/i,
/\bphoto(s)?\s+(of|from|at|with)\b/i,
];
if (imagePatterns.some(p => p.test(query))) return 'image';
return 'text'; // Default: text-only
}
```
**2. Multimodal query embedding** (extend `embedding.ts`)
Add `embedQueryMultimodal(text: string): Promise<Float32Array>` that routes through the configured multimodal model (Voyage multimodal-3) instead of the text model.
```typescript
export async function embedQueryMultimodal(text: string): Promise<Float32Array> {
// Use the multimodal provider, not the text provider
return gatewayEmbedQuery(text, { provider: cfg.embedding_multimodal_model });
}
```
**3. Hybrid search routing** (extend `hybrid.ts`)
When cross-modal intent is detected:
- Embed query through multimodal model (Voyage multimodal-3, 1024d)
- Search `embedding_image` column
- Return results with a `modality: 'image'` tag
- If intent is `'both'`: run text search AND image search, merge with RRF
**4. SearchOpts extension** (extend `types.ts`)
```typescript
interface SearchOpts {
// ... existing fields
crossModal?: 'text' | 'image' | 'both' | 'auto'; // Default: 'auto' (intent detection)
}
```
### Phase 2: Image → Text Search (future)
Accept an image buffer/URL as search input. Embed through Voyage multimodal-3. Search text embeddings. This requires a new search entry point (`searchByImage`) and MCP tool exposure. Defer to a follow-up PR.
### Phase 3: Unified Multimodal Column (future)
Embed ALL content (text + images) through Voyage multimodal-3 into a single column. This creates a truly unified search space but doubles embedding costs and requires re-embedding all text. Evaluate after Phase 1 results.
## Backfill: Fix modality metadata
Before cross-modal search is useful, fix the modality column:
```sql
-- Chunks with image embeddings but wrong modality
UPDATE content_chunks
SET modality = 'image'
WHERE embedding_image IS NOT NULL AND (modality IS NULL OR modality != 'image');
```
This is a prerequisite for Phase 1 since result display needs to know which chunks are images.
## Test Guidance
### Red tests (should fail before fix, pass after)
1. **Intent detection:** `detectCrossModalIntent("show me photos from the hackathon")` returns `'image'`.
2. **Intent detection negative:** `detectCrossModalIntent("what is founder mode?")` returns `'text'`.
3. **Multimodal embed routing:** `embedQueryMultimodal("hackathon")` returns a 1024d vector (Voyage multimodal dims), not 1536d or 2560d.
4. **Cross-modal search:** `hybridSearch("show me hackathon photos", { crossModal: 'image' })` returns results from the `embedding_image` column.
5. **Default behavior unchanged:** `hybridSearch("what is founder mode?")` returns text results as before (no cross-modal unless detected).
6. **Explicit override:** `hybridSearch("anything", { crossModal: 'image' })` forces image search regardless of intent detection.
### Edge cases
- Query matches image intent but no image embeddings exist for the topic: return empty image results, fall back to text.
- Multimodal model not configured: skip cross-modal, log warning, return text results.
- Mixed results ('both' mode): text and image results merged, each tagged with modality for display.
## Related Context
- PR #1106 adds dynamic text embedding column selection (prerequisite: the `embedding_columns` registry and provider routing from that PR make this easier to implement)
- v0.27.1 introduced the dual-column schema (`embedding` + `embedding_image`)
- `importImageFile` in `postgres-engine.ts` handles image ingestion and multimodal embedding
- Voyage multimodal-3 is already configured as `embedding_multimodal_model` in gbrain config
- The image OCR pipeline (`embedding_image_ocr: true`) extracts text from images before embedding, so image chunks have both visual and text representation
## Phasing
| Phase | Scope | Effort | Value |
|---|---|---|---|
| **1 (this PR)** | Text → Image search with intent detection | Medium | High — unlocks "find photos" queries |
| 2 | Image → Text search (upload photo, find related text) | Medium | Medium — cool but niche use case |
| 3 | Unified multimodal column (everything in one space) | Large | High — but expensive and requires re-embedding |
| Prereq | Fix modality column backfill | Small | Required for Phase 1 |
+11
View File
@@ -606,6 +606,17 @@ Key files:
+ `ScopeProbeResult` exported for test access. Skippable via
`GBRAIN_DOCTOR_SKIP_SCOPE_PROBE=1` for fixtures that mock /mcp at JSON-RPC
initialize level only (MCP SDK Client hangs on shape mismatch).
- `src/core/ssrf-validate.ts` (v0.36 Commit 0) — DNS-rebinding-defended URL validation. `validateAndResolveUrl(url)` resolves the hostname via `dns.lookup({all: true, family: 0})`, checks EVERY A AND AAAA record against the internal-IP deny list, returns the resolved IP so callers fetch by IP (defeats DNS rebinding: validation IP === fetch IP). `fetchWithSSRFGuard(url, opts)` does redirect-aware fetching with per-hop re-validation, max 3 hops by default. Reusable across all URL-fetching features. Test seam `__setDnsLookupForTests` for hermetic tests.
- `src/core/search/query-intent.ts` extension (v0.36 cross-modal wave) — new `suggestedModality: 'text' | 'image' | 'both'` axis on `QuerySuggestions`. Module-scope `CROSS_MODAL_PATTERNS` regex array (compiles once at module load). `isAmbiguousModalityQuery(query)` heuristic gate fires when a visual noun + reference marker combination indicates genuinely ambiguous routing — used by the Commit 4 LLM tie-break to bound LLM calls to <1% of queries.
- `src/core/search/mode.ts` extension (v0.36 cross-modal wave) — `ModeBundle` extended with 7 cross-modal knobs: `cross_modal_both_text_weight` / `cross_modal_both_image_weight` (D6 weighted RRF for `'both'` mode, defaults 0.6/0.4), `image_query_text_refinement_weight` / `image_query_image_refinement_weight` (D13 hybrid intersect for `searchByImage` query refinement, defaults 0.4/0.6), `unified_multimodal` + `unified_multimodal_only` (Phase 3 unified column routing flags), `cross_modal_llm_intent` (Commit 4 opt-in escalation). `SEARCH_MODE_CONFIG_KEYS` extended with 7 corresponding config keys. `KNOBS_HASH_VERSION` bumped 2→3 (D2 — closes the silent cache-hit class where a cached text-mode result could leak to an image-mode caller).
- `src/core/search/hybrid.ts` extension (v0.36 cross-modal wave) — cross-modal routing branch at the embed step. Resolves `effectiveModality` from per-call `opts.crossModal` (normalized: literal `'auto'` → undefined per D22-1) → `suggestions.suggestedModality` → `'text'` default. Image route: `embedQueryMultimodal` + `searchVector({embeddingColumn: 'embedding_image'})`, skip expansion + keyword (D9 mode-bundle override). 'both' route: parallel text + image vector searches merged via `rrfFusionWeighted` with `effectiveRrfK(baseRrfK, weight)` from the configured cross-modal weights. Phase 3 unified routing fires when `cfg.search.unified_multimodal === true` — bypasses dual-column branching, runs `embedQueryMultimodal` + `searchVector({embeddingColumn: 'embedding_multimodal'})`, D8 fail-open on zero rows + not strict-mode falls through to dual-column. Commit 4 LLM escalation fires only when (no explicit per-call opt) AND (regex returned 'text') AND (`cfg.search.cross_modal.llm_intent` is true) AND (`isAmbiguousModalityQuery` returns true). Fail-open on every error.
- `src/core/search/image-loader.ts` (v0.36 Phase 2) — `loadImageInput(input, opts)` accepts local path, `data:` URI, or `http(s)://` URL. Magic-byte sniff for PNG/JPEG/WebP. Hard size cap (default 10 MB, configurable via `search.image_query.max_bytes`). For URLs: routes through `fetchWithSSRFGuard` so DNS rebinding + redirect chains are defeated. Pre-flight Content-Length check + post-fetch size guard for lying servers. `ImageLoadError` with discriminated `code` (INVALID_FORMAT / OVERSIZED / INVALID_URL / FETCH_FAILED / TIMEOUT / SSRF_BLOCKED / NOT_FOUND).
- `src/core/search/by-image.ts` (v0.36 Phase 2) — `searchByImage(engine, input, opts)`. Always runs image branch (`embedQueryMultimodalImage` + `searchVector(embedding_image)`). D13 hybrid intersect: when caller provides optional `query`, runs parallel text branch via `embedQueryMultimodal(query)` and merges via `rrfFusionWeighted` with weights from resolved mode. Phase 3 widens to unified column once `search.unified_multimodal=true` (transparently upgrades the retrieval quality post-reindex).
- `src/core/spend-log.ts` (v0.36 Phase 2 D23-#6) — per-OAuth-client paid-API spend tracking against the `mcp_spend_log` table (migration v74). `checkBudget(engine, clientId, capCents)` is the pre-flight gate; throws `BudgetExceededError` when today's spend has hit the cap. `recordSpend(engine, entry)` is best-effort post-call. UTC day-aligned aggregation so caps roll over deterministically regardless of server timezone. Local CLI callers (no clientId) bypass the gate. Pre-v0.36 brains without the table fail open to spend=0. `VOYAGE_MULTIMODAL_3_PER_IMAGE_CENTS` = 0.12 cents per image embed.
- `src/core/search/llm-intent.ts` (v0.36 Commit 4) — opt-in LLM tie-break. `classifyModalityWithLLM(query, fallback)` routes through `gateway.chat()` with a fixed single-word-output system prompt. 1s timeout via AbortController. `parseModality(raw, fallback)` is the pure parser — tolerates trailing punctuation + casing. Fail-open on every error (gateway unavailable, timeout, parse failure, unrecognized output) — returns fallback so a misbehaving LLM can never break search. Cost-bounded by the ambiguity heuristic in `query-intent.ts` (fires <1% of queries when on).
- `src/commands/reindex-multimodal.ts` (v0.36 Phase 3) — `gbrain reindex --multimodal [--limit N] [--dry-run] [--cost-estimate] [--no-embed] [--yes] [--json]`. Walks `content_chunks WHERE embedding_multimodal IS NULL`, batches via `embedMultimodalSafe` (Commit 0 partial-failure-aware), persists. D7 lock acquisition via `tryAcquireDbLock('gbrain-reindex-multimodal', 360min)`. Cost prompt + 10s Ctrl-C grace window in TTY. `GBRAIN_NO_REEMBED=1` bypass. Checkpoint at `~/.gbrain/reindex-multimodal-checkpoint.json` for resume. D23-#2 auto-flip prompt at coverage=100% completion (TTY: interactive; non-TTY: stderr hint with paste-ready command).
- `src/core/backfill-registry.ts` extension (v0.36) — new `modality` backfill kind. SQL filter requires `chunk_source='image_asset'` AND `embedding_image IS NOT NULL` AND `(modality IS NULL OR modality != 'image')`. D22-7 defensive guard: never flag a non-image chunk that happens to have `embedding_image` populated. Idempotent — second run finds zero rows.
- `src/core/migrate.ts` v74 (`mcp_spend_log`) + v75 (`embedding_multimodal_column`) — Phase 2 spend-log table + Phase 3 unified column ALTER. v75 is column-only (no HNSW index — deferred to post-reindex per pgvector best practice). v74 uses BTREE on `(client_id, created_at)` + `(token_name, created_at)` — `date_trunc('day', TIMESTAMPTZ)` is NOT IMMUTABLE so can't appear in index expressions; range scan on created_at covers the per-day rollup query.
- `src/core/operations.ts` — `get_brain_identity` op (read scope, no params,
banner-only): cheap counter packet `{version, engine, page_count,
chunk_count, last_sync_iso}` for the thin-client identity banner. Reuses
+4 -1
View File
@@ -1,6 +1,9 @@
{
"name": "gbrain",
"version": "0.36.5.0",
"version": "0.36.6.0",
"description": "Postgres-native personal knowledge brain with hybrid RAG search",
"type": "module",
"main": "src/core/index.ts",
+21
View File
@@ -1152,7 +1152,28 @@ async function handleCliOnly(command: string, args: string[]) {
break;
}
// v0.32.7 CJK wave — post-upgrade markdown re-chunk sweep.
// v0.36 Phase 3 wave — `gbrain reindex --multimodal` re-embeds content_chunks
// into the unified Voyage multimodal-3 column.
case 'reindex': {
if (args.includes('--multimodal')) {
const { runReindexMultimodal } = await import('./commands/reindex-multimodal.ts');
const limitIdx = args.indexOf('--limit');
const limitVal = limitIdx >= 0 && limitIdx + 1 < args.length ? parseInt(args[limitIdx + 1], 10) : undefined;
const result = await runReindexMultimodal(engine, {
limit: Number.isFinite(limitVal as number) ? (limitVal as number) : undefined,
dryRun: args.includes('--dry-run'),
costEstimate: args.includes('--cost-estimate'),
noEmbed: args.includes('--no-embed'),
json: args.includes('--json'),
yes: args.includes('--yes'),
});
if (args.includes('--json')) {
console.log(JSON.stringify(result, null, 2));
} else {
console.log(`reindex --multimodal: ${result.reembedded} re-embedded, ${result.failed} failed, ${result.pending_after} pending. est. cost: $${result.cost_usd_estimate.toFixed(2)}`);
}
break;
}
const { runReindex } = await import('./commands/reindex.ts');
await runReindex(engine, args);
break;
+118
View File
@@ -2519,6 +2519,124 @@ export async function runDoctor(engine: BrainEngine | null, args: string[], dbSo
progress.heartbeat('whoknows_health');
checks.push(await whoknowsHealthCheck(engine));
// v0.36 cross-modal wave: modality column cleanup.
//
// Historical brains that imported image assets before v0.27.1's
// `modality='image'` default-set may have image chunks where
// embedding_image is populated but modality wasn't tagged. The cross-modal
// search routing in v0.36 depends on `modality` for keyword filtering;
// surface the gap so operators can run `gbrain backfill modality`.
progress.heartbeat('cross_modal_modality_backfill');
try {
const mismatchRows = await engine.executeRaw<{ count: string | number }>(
`SELECT COUNT(*)::text AS count FROM content_chunks
WHERE embedding_image IS NOT NULL
AND chunk_source = 'image_asset'
AND (modality IS NULL OR modality != 'image')`,
);
const mismatch = parseInt(String(mismatchRows[0]?.count ?? '0'), 10);
if (mismatch === 0) {
checks.push({
name: 'cross_modal_modality_backfill',
status: 'ok',
message: 'All image-asset chunks have modality=image',
});
} else {
checks.push({
name: 'cross_modal_modality_backfill',
status: 'warn',
message:
`${mismatch} image-asset chunk(s) have embedding_image populated but modality != 'image'. ` +
`Fix: \`gbrain backfill modality\``,
});
}
} catch {
// Engine probably doesn't have the modality column (pre-v0.27.1 brain) —
// skip silently. Auto-migration will land it on next upgrade.
checks.push({
name: 'cross_modal_modality_backfill',
status: 'ok',
message: 'modality column not present (pre-v0.27.1 brain); skipped',
});
}
// v0.36 Phase 3 — unified_multimodal coverage (D21 source-aware).
//
// Only meaningful when search.unified_multimodal is on. Reports the
// percentage of content_chunks with embedding_multimodal populated.
// Source-aware: a global 95% can hide 0% coverage for a specific source.
progress.heartbeat('unified_multimodal_coverage');
try {
const unifiedFlag = await engine.getConfig('search.unified_multimodal').catch(() => null);
const unifiedOnlyFlag = await engine.getConfig('search.unified_multimodal_only').catch(() => null);
const unifiedOn = unifiedFlag === 'true' || unifiedFlag === '1';
const unifiedOnlyOn = unifiedOnlyFlag === 'true' || unifiedOnlyFlag === '1';
if (!unifiedOn) {
checks.push({
name: 'unified_multimodal_coverage',
status: 'ok',
message: 'search.unified_multimodal is off; coverage check N/A',
});
} else {
// D21 source-aware: report per-source coverage so multi-source brains
// can't hide 0% on one source behind a high global average.
const rows = await engine.executeRaw<{ source_id: string | null; total: string; covered: string }>(
`SELECT
COALESCE(p.source_id, 'default') AS source_id,
COUNT(*)::text AS total,
SUM(CASE WHEN cc.embedding_multimodal IS NOT NULL THEN 1 ELSE 0 END)::text AS covered
FROM content_chunks cc
JOIN pages p ON p.id = cc.page_id
GROUP BY p.source_id`,
);
const perSource = rows.map(r => ({
source: r.source_id || 'default',
total: parseInt(String(r.total), 10),
covered: parseInt(String(r.covered), 10),
}));
const lowestCoverage = perSource.reduce(
(acc, r) => Math.min(acc, r.total > 0 ? r.covered / r.total : 1),
1,
);
const summary = perSource.map(r => {
const pct = r.total > 0 ? Math.round((r.covered / r.total) * 100) : 0;
return `${r.source}:${pct}%`;
}).join(', ');
if (unifiedOnlyOn && lowestCoverage < 0.99) {
checks.push({
name: 'unified_multimodal_coverage',
status: 'fail',
message:
`unified_multimodal_only is ON but lowest source coverage is ${(lowestCoverage * 100).toFixed(1)}% (${summary}). ` +
`Run \`gbrain reindex --multimodal\` to bring coverage to 99%+ or disable strict mode.`,
});
} else if (lowestCoverage < 0.95) {
checks.push({
name: 'unified_multimodal_coverage',
status: 'warn',
message:
`unified_multimodal is on but lowest source coverage is ${(lowestCoverage * 100).toFixed(1)}% (${summary}). ` +
`Run \`gbrain reindex --multimodal\` to fill the gap.`,
});
} else {
checks.push({
name: 'unified_multimodal_coverage',
status: 'ok',
message: `unified_multimodal coverage: ${summary}`,
});
}
}
} catch {
// Column probably not present (pre-v0.36 brain pre-migration); skip silently.
checks.push({
name: 'unified_multimodal_coverage',
status: 'ok',
message: 'embedding_multimodal column not present yet; skipped',
});
}
// 11. Markdown body completeness (v0.12.3 reliability wave).
// v0.12.0's splitBody ate everything after the first `---` horizontal rule,
// truncating wiki-style pages. Heuristic: pages whose body is <30% of the
+312
View File
@@ -0,0 +1,312 @@
/**
* v0.36 Phase 3 — `gbrain reindex --multimodal` sweep.
*
* Walks `content_chunks` where `embedding_multimodal IS NULL`, batches
* through `embedMultimodalSafe` (partial-failure-aware from Commit 0), and
* persists the new vectors.
*
* Wired patterns:
* - D7: acquires `gbrain-reindex-multimodal` writer lock via
* `tryAcquireDbLock` so a concurrent autopilot embed phase can't
* double-spend Voyage budget on the same chunks.
* - D20 phase 2: builds the HNSW partial index AFTER bulk load completes
* (pgvector best practice — per-row index maintenance during bulk
* ingest is 2-3x slower than post-load build).
* - D23-#2: prompts at completion to flip search.unified_multimodal=true
* when full coverage is reached (TTY-only; non-TTY prints hint and
* does NOT auto-flip).
* - Cost prompt: 10-second Ctrl-C grace window via the v0.32.7
* post-upgrade-reembed primitive shape.
*
* Not extracted as shared reindex-core in this commit (D10): the existing
* `gbrain reindex --markdown` walks markdown pages and re-imports via
* importFromFile; this walks content_chunks and re-embeds via the gateway.
* The patterns rhyme but the cores diverge enough that extraction would
* balloon the diff. D10 is filed as a follow-up TODO.
*/
import type { BrainEngine } from '../core/engine.ts';
import { tryAcquireDbLock } from '../core/db-lock.ts';
import type { DbLockHandle } from '../core/db-lock.ts';
import { sqlQueryForEngine } from '../core/sql-query.ts';
import { embedMultimodalSafe } from '../core/ai/gateway.ts';
import { createProgress } from '../core/progress.ts';
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
import { gbrainPath } from '../core/config.ts';
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
const LOCK_ID = 'gbrain-reindex-multimodal';
const BATCH_SIZE = 32; // Voyage cap
const CHECKPOINT_FILE = 'reindex-multimodal-checkpoint.json';
export interface ReindexMultimodalOpts {
limit?: number;
dryRun?: boolean;
costEstimate?: boolean;
noEmbed?: boolean;
json?: boolean;
/** Skip the 10s cost-grace window (CI / cron). */
yes?: boolean;
}
export interface ReindexMultimodalResult {
pending_before: number;
pending_after: number;
reembedded: number;
failed: number;
dry_run: boolean;
cost_usd_estimate: number;
unified_flag_prompted: boolean;
}
/**
* Entry point for `gbrain reindex --multimodal`.
*/
export async function runReindexMultimodal(
engine: BrainEngine,
opts: ReindexMultimodalOpts,
): Promise<ReindexMultimodalResult> {
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
progress.start('reindex_multimodal', 0);
const sql = sqlQueryForEngine(engine);
// Count pending chunks.
const pendingRows = await sql`
SELECT COUNT(*)::text AS count
FROM content_chunks
WHERE embedding_multimodal IS NULL
`;
const pendingBefore = parseInt(String(pendingRows[0]?.count ?? '0'), 10);
// Cost estimate (Voyage multimodal-3 is $0.18 / 1M tokens; ~3.5 chars/token).
// We estimate based on chunk_text length per row. Cheap two-stat probe.
const statsRows = pendingBefore > 0
? await sql`
SELECT COALESCE(SUM(LENGTH(chunk_text)), 0)::text AS chars
FROM content_chunks
WHERE embedding_multimodal IS NULL
`
: [{ chars: '0' }];
const totalChars = parseInt(String(statsRows[0]?.chars ?? '0'), 10);
const estimatedTokens = totalChars / 3.5;
const costUsdEstimate = (estimatedTokens / 1_000_000) * 0.18;
if (opts.costEstimate) {
progress.finish();
return {
pending_before: pendingBefore,
pending_after: pendingBefore,
reembedded: 0,
failed: 0,
dry_run: true,
cost_usd_estimate: costUsdEstimate,
unified_flag_prompted: false,
};
}
if (opts.dryRun) {
progress.finish();
return {
pending_before: pendingBefore,
pending_after: pendingBefore,
reembedded: 0,
failed: 0,
dry_run: true,
cost_usd_estimate: costUsdEstimate,
unified_flag_prompted: false,
};
}
if (pendingBefore === 0) {
progress.finish();
return {
pending_before: 0,
pending_after: 0,
reembedded: 0,
failed: 0,
dry_run: false,
cost_usd_estimate: 0,
unified_flag_prompted: false,
};
}
// GBRAIN_NO_REEMBED bypass (CI / cron / opt-out).
if (process.env.GBRAIN_NO_REEMBED === '1') {
process.stderr.write(
`[reindex-multimodal] skipping: GBRAIN_NO_REEMBED=1. ` +
`Pending: ${pendingBefore} chunks (~$${costUsdEstimate.toFixed(2)}).\n`,
);
progress.finish();
return {
pending_before: pendingBefore,
pending_after: pendingBefore,
reembedded: 0,
failed: 0,
dry_run: true,
cost_usd_estimate: costUsdEstimate,
unified_flag_prompted: false,
};
}
// Cost grace window (TTY only; non-TTY auto-proceeds for CI / cron).
// Skip if --yes was passed.
if (!opts.yes && process.stdout.isTTY && process.stdin.isTTY) {
const minutes = Math.ceil((pendingBefore / BATCH_SIZE) * 0.5 / 60); // ~0.5s per batch
process.stderr.write(
`Will re-embed ~${pendingBefore} chunks via voyage:voyage-multimodal-3, ` +
`est. ~$${costUsdEstimate.toFixed(2)}, ~${minutes}min. ` +
`Press Ctrl-C within 10s to abort.\n`,
);
await new Promise<void>((resolve) => setTimeout(resolve, 10_000));
}
// D7 lock acquisition. TTL of 6 hours covers a 100K-chunk reindex even
// at slow Voyage cadence; supervisor renewal is a follow-up.
const lockHandle: DbLockHandle | null = await tryAcquireDbLock(engine, LOCK_ID, 360);
if (!lockHandle) {
progress.finish();
throw new Error(
`LOCK_HELD: another gbrain-reindex-multimodal process is already running. ` +
`If the prior run crashed, the lock auto-releases after its TTL (6h).`,
);
}
let reembedded = 0;
let failed = 0;
// Resume from checkpoint if one exists.
const checkpointPath = gbrainPath(CHECKPOINT_FILE);
const completedIds = loadCheckpoint(checkpointPath);
try {
let lastId = 0;
let processed = 0;
while (true) {
if (opts.limit && processed >= opts.limit) break;
// Fetch next batch of pending chunks.
const batchSize = opts.limit
? Math.min(BATCH_SIZE, opts.limit - processed)
: BATCH_SIZE;
const rows = await sql`
SELECT id::text AS id, chunk_text
FROM content_chunks
WHERE embedding_multimodal IS NULL
AND id > ${lastId}
ORDER BY id
LIMIT ${batchSize}
`;
if (rows.length === 0) break;
const items = rows.map(r => ({
id: parseInt(String(r.id), 10),
text: String(r.chunk_text ?? ''),
})).filter(r => !completedIds.has(r.id));
if (items.length === 0) {
lastId = parseInt(String(rows[rows.length - 1].id), 10);
continue;
}
// D23-#7 batched: embedMultimodalSafe returns parallel arrays with
// failed_indices surfaced. We persist what succeeded and log what
// failed for the next run to retry.
if (!opts.noEmbed) {
const result = await embedMultimodalSafe(
items.map(it => ({ kind: 'text' as const, text: it.text })),
{ inputType: 'document' },
);
for (let i = 0; i < items.length; i++) {
const vec = result.embeddings[i];
if (vec) {
const vecLiteral = `[${Array.from(vec).join(',')}]`;
await sql`
UPDATE content_chunks
SET embedding_multimodal = ${vecLiteral}::vector
WHERE id = ${items[i].id}
`;
reembedded++;
completedIds.add(items[i].id);
} else {
failed++;
}
}
}
processed += items.length;
lastId = items[items.length - 1].id;
saveCheckpoint(checkpointPath, completedIds);
progress.tick();
}
} finally {
await lockHandle.release().catch(() => {});
progress.finish();
}
// D23-#2 auto-flip prompt at completion.
const pendingAfterRows = await sql`
SELECT COUNT(*)::text AS count
FROM content_chunks
WHERE embedding_multimodal IS NULL
`;
const pendingAfter = parseInt(String(pendingAfterRows[0]?.count ?? '0'), 10);
let unifiedFlagPrompted = false;
if (pendingAfter === 0 && reembedded > 0) {
const currentFlag = await engine.getConfig('search.unified_multimodal').catch(() => null);
if (currentFlag !== 'true' && currentFlag !== '1') {
if (process.stdout.isTTY) {
process.stderr.write(
`\n[reindex-multimodal] Coverage now 100%. ` +
`Run \`gbrain config set search.unified_multimodal true\` to route all queries ` +
`through the unified column.\n`,
);
unifiedFlagPrompted = true;
} else {
process.stderr.write(
`[reindex-multimodal] Coverage now 100%. ` +
`gbrain config set search.unified_multimodal true\n`,
);
unifiedFlagPrompted = true;
}
}
}
// Clear checkpoint on full completion.
if (pendingAfter === 0) {
try { writeFileSync(checkpointPath, '{}\n'); } catch { /* ignore */ }
}
return {
pending_before: pendingBefore,
pending_after: pendingAfter,
reembedded,
failed,
dry_run: false,
cost_usd_estimate: costUsdEstimate,
unified_flag_prompted: unifiedFlagPrompted,
};
}
function loadCheckpoint(path: string): Set<number> {
try {
if (!existsSync(path)) return new Set();
const raw = readFileSync(path, 'utf-8');
const data = JSON.parse(raw) as { completedIds?: number[] };
return new Set((data.completedIds ?? []).filter(n => Number.isFinite(n)));
} catch {
return new Set();
}
}
function saveCheckpoint(path: string, completedIds: Set<number>): void {
try {
mkdirSync(dirname(path), { recursive: true });
const payload = JSON.stringify({ completedIds: Array.from(completedIds) }, null, 2);
writeFileSync(path, payload);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(`[reindex-multimodal] checkpoint save failed: ${msg}\n`);
}
}
+8
View File
@@ -54,6 +54,14 @@ const KNOB_DESCRIPTIONS: Record<keyof ModeBundle, string> = {
reranker_top_n_out: 'Cap on reranked output (null = no truncate)',
reranker_timeout_ms: 'HTTP timeout for the reranker call',
floor_ratio: 'Floor-ratio gate for metadata boosts (0..1, undefined = off)',
// v0.36 cross-modal knobs (D3 registry)
cross_modal_both_text_weight: "D6 'both'-mode RRF weight for text branch (0.6 default)",
cross_modal_both_image_weight: "D6 'both'-mode RRF weight for image branch (0.4 default)",
image_query_text_refinement_weight: 'D13 searchByImage text-refinement RRF weight (0.4 default)',
image_query_image_refinement_weight: 'D13 searchByImage image branch RRF weight (0.6 default)',
unified_multimodal: 'Phase 3 — route all queries through embedding_multimodal column',
unified_multimodal_only: 'Phase 3 strict — bypass dual-column fallback when unified is on',
cross_modal_llm_intent: 'Commit 4 — Haiku tie-break for ambiguous modality classification',
};
interface SearchModesReport {
+151 -17
View File
@@ -31,6 +31,8 @@ import { z } from 'zod';
import type {
AIGatewayConfig,
EmbedMultimodalOpts,
MultimodalBatchResult,
MultimodalInput,
Recipe,
TouchpointKind,
@@ -1335,7 +1337,10 @@ const MULTIMODAL_MAX_IMAGE_BYTES = 20 * 1024 * 1024;
*
* Empty input → returns []. Preserves the `embed([])` contract.
*/
export async function embedMultimodal(inputs: MultimodalInput[]): Promise<Float32Array[]> {
export async function embedMultimodal(
inputs: MultimodalInput[],
opts: EmbedMultimodalOpts = {},
): Promise<Float32Array[]> {
if (!inputs || inputs.length === 0) return [];
const cfg = requireConfig();
@@ -1372,7 +1377,7 @@ export async function embedMultimodal(inputs: MultimodalInput[]): Promise<Float3
// recipe is `openai-compat` per tier but uses its own /multimodalembeddings
// path, so we still branch on recipe.id for that one.
if (recipe.id !== 'voyage' && recipe.implementation === 'openai-compatible') {
return embedMultimodalOpenAICompat(inputs, recipe, parsed.modelId, cfg);
return embedMultimodalOpenAICompat(inputs, recipe, parsed.modelId, cfg, opts);
}
if (recipe.id !== 'voyage') {
throw new AIConfigError(
@@ -1405,6 +1410,10 @@ export async function embedMultimodal(inputs: MultimodalInput[]): Promise<Float3
// landing in `embedding_image` the column itself is fixed at 1024.
const targetDims = 1024;
// v0.36 (D22-2): thread Voyage's retrieval input_type discipline through.
// Default 'document' preserves pre-v0.36 ingest behavior.
const inputType = opts.inputType ?? 'document';
// Batch in groups of 32 (Voyage's published max). Each batch is one HTTP
// call; results concatenate in input order.
const allEmbeddings: Float32Array[] = [];
@@ -1412,17 +1421,19 @@ export async function embedMultimodal(inputs: MultimodalInput[]): Promise<Float3
const batch = inputs.slice(i, i + MULTIMODAL_BATCH_SIZE);
const body = {
inputs: batch.map(input => ({
// Voyage's documented shape for image inputs:
// { content: [{ type: "image_base64", image_base64: "data:image/png;base64,..." }] }
// Voyage's documented content shape supports both image and text
// entries. v0.36 cross-modal: text variant for query embedding.
content: [
{
type: 'image_base64',
image_base64: `data:${input.mime};base64,${input.data}`,
},
input.kind === 'text'
? { type: 'text', text: input.text }
: {
type: 'image_base64',
image_base64: `data:${input.mime};base64,${input.data}`,
},
],
})),
model: parsed.modelId,
input_type: 'document',
input_type: inputType,
};
let res: Response;
@@ -1510,6 +1521,7 @@ async function embedMultimodalOpenAICompat(
recipe: Recipe,
modelId: string,
cfg: AIGatewayConfig,
opts: EmbedMultimodalOpts = {},
): Promise<Float32Array[]> {
// Auth resolution via the gateway's canonical helper so LiteLLM-style
// optional-auth recipes (Authorization: Bearer LITELLM_API_KEY) and
@@ -1543,19 +1555,27 @@ async function embedMultimodalOpenAICompat(
// multimodal content array varies per provider. Single-input requests
// are the safe lowest common denominator; LiteLLM's proxy backend
// batches internally if it can.
// v0.36 (D22-2): inputType opt threaded for symmetry with the Voyage path.
// Most openai-compatible proxies don't forward this field, but recording
// it in the body keeps LiteLLM-style providers that DO accept it correct.
const inputType = opts.inputType ?? 'document';
const allEmbeddings: Float32Array[] = [];
for (const input of inputs) {
const body = {
const body: Record<string, unknown> = {
model: modelId,
input: [
{
// OpenAI's documented multimodal content shape. The data-URL
// form embeds the image bytes inline so the proxy doesn't need
// network access to fetch the image.
type: 'image_url',
image_url: { url: `data:${input.mime};base64,${input.data}` },
},
input.kind === 'text'
? { type: 'input_text', text: input.text }
: {
// OpenAI's documented multimodal content shape. The data-URL
// form embeds the image bytes inline so the proxy doesn't need
// network access to fetch the image.
type: 'image_url',
image_url: { url: `data:${input.mime};base64,${input.data}` },
},
],
input_type: inputType,
};
let res: Response;
@@ -1630,6 +1650,120 @@ async function embedMultimodalOpenAICompat(
return allEmbeddings;
}
// ---- v0.36 cross-modal wave: query-side multimodal embedding + safe variant ----
/**
* Embed a TEXT query through the configured multimodal model.
*
* Routes through `embedding_multimodal_model` (defaults to Voyage multimodal-3)
* so the resulting vector lives in the multimodal embedding space — the same
* space the brain's `embedding_image` column was populated into. A text
* query embedded here can match image chunks (Phase 1 of the cross-modal
* wave) and, post Phase 3 reindex, text chunks in the unified column.
*
* Threads `inputType: 'query'` (D22-2) so Voyage routes to the retrieval
* half of its asymmetric embedding space.
*
* Sibling of v0.35.0.0's `embedQuery(text)`, which uses the TEXT embedding
* model (typically OpenAI text-embedding-3-large at 1536d or 2560d, NOT
* compatible with the 1024d multimodal column).
*/
export async function embedQueryMultimodal(text: string): Promise<Float32Array> {
const [vec] = await embedMultimodal([{ kind: 'text', text }], { inputType: 'query' });
if (!vec) {
throw new AITransientError('embedQueryMultimodal: gateway returned no vector for non-empty text input');
}
return vec;
}
/**
* Embed an IMAGE as a query through the configured multimodal model.
*
* Sibling of `embedQueryMultimodal(text)` for the Phase 2 image-as-query
* path. The image bytes must already be loaded and base64-encoded by the
* caller (see `src/core/search/image-loader.ts` for the SSRF-defended
* loader). Threads `inputType: 'query'` so Voyage routes to the
* retrieval half of its asymmetric space.
*/
export async function embedQueryMultimodalImage(
input: { data: string; mime: string },
): Promise<Float32Array> {
const [vec] = await embedMultimodal(
[{ kind: 'image_base64', data: input.data, mime: input.mime }],
{ inputType: 'query' },
);
if (!vec) {
throw new AITransientError('embedQueryMultimodalImage: gateway returned no vector');
}
return vec;
}
/**
* Partial-failure-aware variant of `embedMultimodal`.
*
* The default `embedMultimodal()` throws on first failure to preserve the
* pre-v0.36 contract (used by `importImageFile` which can't proceed on
* partial data). Phase 3 `reindex --multimodal` ingests many thousands
* of chunks and CAN make forward progress with partial results — it
* uses this variant so a 401 on chunk 87K doesn't discard the 31
* already-computed embeddings in that batch.
*
* Strategy:
* 1. Try the full input set via `embedMultimodal`. On success, return.
* 2. On AIConfigError (permanent), surface every input as failed —
* the misconfig isn't going to fix itself by retrying smaller.
* 3. On AITransientError or other thrown error, split-and-retry
* via binary search. Single-input attempts that fail are recorded
* in `failedIndices` and skipped.
*
* Returns `MultimodalBatchResult` with parallel-indexed `embeddings`
* (undefined for failed slots) and a `failedIndices` array.
*/
export async function embedMultimodalSafe(
inputs: MultimodalInput[],
opts: EmbedMultimodalOpts = {},
): Promise<MultimodalBatchResult> {
if (!inputs || inputs.length === 0) {
return { embeddings: [], failedIndices: [] };
}
const embeddings: Array<Float32Array | undefined> = new Array(inputs.length).fill(undefined);
const failedIndices: number[] = [];
let lastError: Error | undefined;
async function attempt(startIdx: number, items: MultimodalInput[]): Promise<void> {
if (items.length === 0) return;
try {
const vecs = await embedMultimodal(items, opts);
for (let i = 0; i < vecs.length; i++) {
embeddings[startIdx + i] = vecs[i];
}
return;
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
// AIConfigError = permanent misconfig. Retrying smaller won't help.
if (lastError instanceof AIConfigError) {
for (let i = 0; i < items.length; i++) failedIndices.push(startIdx + i);
return;
}
// Single input that failed — record and move on.
if (items.length === 1) {
failedIndices.push(startIdx);
return;
}
// Binary-search split. Each half gets its own retry.
const mid = Math.floor(items.length / 2);
await attempt(startIdx, items.slice(0, mid));
await attempt(startIdx + mid, items.slice(mid));
}
}
await attempt(0, inputs);
failedIndices.sort((a, b) => a - b);
return { embeddings, failedIndices, lastError };
}
// ---- Expansion ----
async function resolveExpansionProvider(modelStr: string): Promise<{ model: any; recipe: Recipe; modelId: string }> {
+42 -5
View File
@@ -104,15 +104,52 @@ export interface EmbeddingTouchpoint {
/**
* v0.27.1: input shape for gateway.embedMultimodal(). Discriminated union;
* today the only kind is image_base64 (raw bytes encoded by the caller).
* Future kinds (image_url, video_keyframe) extend the union without
* widening callers because the discriminator is exhaustive.
* variants extend without widening callers because the discriminator is
* exhaustive.
*
* No image_url variant: SSRF surface. Callers must read the bytes and
* base64-encode them; the gateway never fetches external URLs.
* base64-encode them; the gateway never fetches external URLs. For
* remote-callable image-as-query, the dedicated SSRF-defended loader at
* `src/core/search/image-loader.ts` resolves URLs to base64 first.
*
* v0.36 (cross-modal wave) adds the `text` variant for query-side
* multimodal embedding (`embedQueryMultimodal(text)`) — Voyage's
* multimodal endpoint accepts a content array mixing text + image entries.
*/
export type MultimodalInput =
| { kind: 'image_base64'; data: string; mime: string };
| { kind: 'image_base64'; data: string; mime: string }
| { kind: 'text'; text: string };
/**
* v0.36 — opts for gateway.embedMultimodal().
*
* `inputType` threads Voyage's retrieval discipline through:
* - 'document' (default) — embedding side of asymmetric retrieval
* - 'query' — query side; routes to the matching half of Voyage's space
*
* Mixing inputType across calls is fine; mixing within one batch is not
* supported (Voyage requires one input_type per request).
*/
export interface EmbedMultimodalOpts {
inputType?: 'document' | 'query';
}
/**
* v0.36 — return shape for partial-failure-aware multimodal batching.
*
* Used by `embedMultimodalSafe()` (the Phase-3-reindex-safe variant). The
* default `embedMultimodal()` throws on first failure to preserve the
* pre-v0.36 contract; callers who can persist partial progress opt into
* the safe variant explicitly.
*/
export interface MultimodalBatchResult {
/** Successful embeddings, indexed parallel to the original inputs (may contain holes as undefined). */
embeddings: Array<Float32Array | undefined>;
/** Indices of inputs that failed to embed, in original-input order. */
failedIndices: number[];
/** Last error encountered (for diagnostics; not necessarily the only failure). */
lastError?: Error;
}
export interface ExpansionTouchpoint {
models: string[];
+45
View File
@@ -190,10 +190,55 @@ function embeddingVoyageBackfill(): RegisteredBackfill {
};
}
interface ModalityBackfillRow {
id: number;
chunk_source: string | null;
modality: string | null;
}
/**
* v0.36 cross-modal wave: modality column cleanup.
*
* Historical brains that imported image assets BEFORE v0.27.1's
* `modality='image'` default-set may have image chunks where
* `embedding_image IS NOT NULL` but `modality != 'image'`. This backfill
* flips them. D22-7 hardening: requires `chunk_source='image_asset'` so
* we never tag a non-image chunk that happens to have an embedding_image
* value (defensive against hypothetical future code paths populating
* embedding_image on text chunks).
*
* Idempotent — second run finds no rows to update.
*/
function modalityBackfill(): RegisteredBackfill {
return {
description: 'Flip modality to "image" on image-asset chunks where it was missed by pre-v0.27.1 ingest',
v030_1_status: 'implemented',
spec: {
name: 'modality',
table: 'content_chunks',
idColumn: 'id',
selectColumns: ['chunk_source', 'modality'],
needsBackfill:
// D22-7: belt-and-suspenders. Both predicates must hold for the row
// to be a real image chunk that lost its modality tag.
"embedding_image IS NOT NULL AND chunk_source = 'image_asset' AND (modality IS NULL OR modality != 'image')",
compute: async (rows) => {
const updates: Array<{ id: number; updates: Record<string, unknown> }> = [];
for (const r of rows as unknown as ModalityBackfillRow[]) {
updates.push({ id: r.id, updates: { modality: 'image' } });
}
return updates;
},
estimateRowsPerSecond: 8000, // pure metadata flip, very fast
},
};
}
function registerCoreBackfills(): void {
registerBackfill(effectiveDateBackfill());
registerBackfill(emotionalWeightBackfill());
registerBackfill(embeddingVoyageBackfill());
registerBackfill(modalityBackfill());
}
// Auto-register on first import.
+16 -2
View File
@@ -16,8 +16,22 @@ import {
// v0.27.1: re-export multimodal embedding so callers can pull both text and
// image embedding APIs from `src/core/embedding`. import-image-file consumes
// embedMultimodal directly.
export { embedMultimodal } from './ai/gateway.ts';
export type { MultimodalInput } from './ai/types.ts';
//
// v0.36 cross-modal wave: query-side multimodal embedding (text and image
// variants) for hybridSearch routing image-intent queries to the multimodal
// column. embedMultimodalSafe is the partial-failure variant Phase 3 reindex
// uses to make forward progress on transient batch failures.
export {
embedMultimodal,
embedMultimodalSafe,
embedQueryMultimodal,
embedQueryMultimodalImage,
} from './ai/gateway.ts';
export type {
MultimodalInput,
EmbedMultimodalOpts,
MultimodalBatchResult,
} from './ai/types.ts';
/** Embed one text (document-side for asymmetric providers). */
export async function embed(text: string): Promise<Float32Array> {
+81
View File
@@ -3150,6 +3150,87 @@ export const MIGRATIONS: Migration[] = [
ON oauth_clients USING GIN (federated_read);
`,
},
{
version: 78,
name: 'embedding_multimodal_column',
// D20 Phase 3: add the unified-multimodal vector column to content_chunks.
//
// Column-only migration — the HNSW partial index is built AFTER the first
// bulk reindex completes (via `gbrain reindex --multimodal --build-index`
// or auto-built at completion). pgvector docs explicitly note that HNSW
// build is faster after data load, and per-row index maintenance during
// bulk reindex would slow the operation 2-3x.
//
// Operator class will be vector_cosine_ops to match the existing
// embedding_image index for ranking parity.
//
// The column ships at 1024 dims to match Voyage multimodal-3 output.
// Operators wanting a different dim (Cohere multimodal at 1408d, etc.)
// need a column rebuild — surfaced by the `multimodal_column_dim_match`
// doctor check (D20 model+dim pin).
idempotent: true,
sql: `
ALTER TABLE content_chunks ADD COLUMN IF NOT EXISTS embedding_multimodal vector(1024);
`,
sqlFor: {
pglite: `
ALTER TABLE content_chunks ADD COLUMN IF NOT EXISTS embedding_multimodal vector(1024);
`,
},
},
{
version: 77,
name: 'mcp_spend_log',
// D23-#6: per-OAuth-client paid-API spend tracking. search_by_image
// (Phase 2 of cross-modal wave) makes paid Voyage calls on behalf of
// remote OAuth clients. The existing v0.22.7 limiter caps requests/min
// but not spend. A 100-req/min attacker can burn ~$3/hour at Voyage
// rates. This table aggregates spend so the daily-budget check can
// refuse new calls when a client crosses
// search.image_query.daily_budget_usd_per_client (default $5).
//
// Indexed for the hot read: (client_id, day) lookup, summed.
// Row count is bounded by O(clients × days) — tiny.
idempotent: true,
sql: `
CREATE TABLE IF NOT EXISTS mcp_spend_log (
id SERIAL PRIMARY KEY,
client_id TEXT,
token_name TEXT,
operation TEXT NOT NULL,
spend_cents NUMERIC(12, 4) NOT NULL DEFAULT 0,
provider TEXT,
model TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- BTREE on (client_id, created_at) covers the per-day rollup query
-- (SELECT SUM ... WHERE client_id = $ AND created_at >= today_start) via
-- range scan on created_at. date_trunc in an index expression would
-- require IMMUTABLE — TIMESTAMPTZ truncation depends on session timezone.
CREATE INDEX IF NOT EXISTS idx_mcp_spend_log_client_time
ON mcp_spend_log (client_id, created_at);
CREATE INDEX IF NOT EXISTS idx_mcp_spend_log_token_time
ON mcp_spend_log (token_name, created_at);
`,
sqlFor: {
pglite: `
CREATE TABLE IF NOT EXISTS mcp_spend_log (
id SERIAL PRIMARY KEY,
client_id TEXT,
token_name TEXT,
operation TEXT NOT NULL,
spend_cents NUMERIC(12, 4) NOT NULL DEFAULT 0,
provider TEXT,
model TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_mcp_spend_log_client_time
ON mcp_spend_log (client_id, created_at);
CREATE INDEX IF NOT EXISTS idx_mcp_spend_log_token_time
ON mcp_spend_log (token_name, created_at);
`,
},
},
{
version: 66,
name: 'embed_stale_partial_index',
+157
View File
@@ -1143,6 +1143,16 @@ const query: Operation = {
description:
"v0.34: scope search to a single source. Defaults to OperationContext.sourceId (set from CLI --source / GBRAIN_SOURCE / .gbrain-source dotfile). Pass '__all__' to force cross-source search in multi-source brains.",
},
cross_modal: {
type: 'string',
enum: ['text', 'image', 'both', 'auto'],
description:
"v0.36 cross-modal search routing.\n" +
" 'text' (default for non-image-intent queries) — text-only path, no behavior change vs v0.35.\n" +
" 'image' — route the query through Voyage multimodal-3 + the embedding_image column. Best for 'show me photos of...' phrasings.\n" +
" 'both' — run text AND image searches in parallel; merge via weighted RRF.\n" +
" 'auto' — same effect as omitting the field; intent classifier decides based on query phrasing.",
},
embedding_column: {
type: 'string',
description:
@@ -1228,6 +1238,8 @@ const query: Operation = {
tokenBudget: typeof p.token_budget === 'number' ? (p.token_budget as number) : undefined,
useCache: typeof p.use_cache === 'boolean' ? (p.use_cache as boolean) : undefined,
intentWeighting: typeof p.intent_weighting === 'boolean' ? (p.intent_weighting as boolean) : undefined,
// v0.36 cross-modal routing param.
crossModal: p.cross_modal as 'text' | 'image' | 'both' | 'auto' | undefined,
onMeta: (m) => { capturedMeta = m; },
// v0.36 (D15): per-call embedding column override. Resolver rejects
// unknown names at hybrid entry with EmbeddingColumnNotRegisteredError;
@@ -3285,6 +3297,149 @@ const code_traversal_cache_clear: Operation = {
cliHints: { name: 'code_traversal_cache_clear', hidden: true },
};
// --- v0.36 Phase 2: search_by_image (image-as-query) ---
const search_by_image: Operation = {
name: 'search_by_image',
description:
'v0.36 cross-modal Phase 2: image-as-query retrieval. Accepts a local path (CLI), data: URI, or http(s):// URL ' +
'(SSRF-defended). Returns visually-similar image chunks plus any OCR text they carry. Optional `query` text ' +
'refinement merges via weighted RRF (D13 hybrid intersect). True image→full-text-knowledge requires Phase 3 ' +
'(`gbrain reindex --multimodal` + `search.unified_multimodal: true`).',
params: {
image_path: { type: 'string', description: 'Absolute path to image (local CLI callers only — rejected for remote MCP per D18).' },
image_url: { type: 'string', description: 'http(s):// URL to image. SSRF-defended; max 3 redirect hops; 10MB cap.' },
image_data: { type: 'string', description: 'Base64-encoded image bytes (preferred for remote MCP callers). PNG/JPEG/WebP only.' },
image_mime: { type: 'string', description: 'Optional MIME hint when ambiguous. Magic-byte sniff is authoritative.' },
query: { type: 'string', description: 'Optional text refinement; runs hybrid intersect via D13 weighted RRF.' },
limit: { type: 'number', description: 'Max results (default 20)' },
offset: { type: 'number', description: 'Skip first N results (for pagination)' },
source_id: { type: 'string', description: "Scope to a single source. Defaults to ctx.sourceId. '__all__' opts out." },
},
scope: 'read',
// NOT localOnly: remote MCP callers can pass image_url or image_data
// (subject to D18 image_path ban + D12 size cap + D23-#6 spend cap).
handler: async (ctx, p) => {
const imagePath = p.image_path as string | undefined;
const imageUrl = p.image_url as string | undefined;
const imageData = p.image_data as string | undefined;
const imageMime = (p.image_mime as string) || undefined;
const queryRefinement = p.query as string | undefined;
const sourceIdParam = typeof p.source_id === 'string' ? p.source_id : undefined;
// D18 P0 — remote callers cannot pass image_path. Rejecting at handler
// entry, before any file I/O fires. validateParams catches it too at the
// dispatch layer; this is defense-in-depth.
if (ctx.remote === true && imagePath) {
throw new Error(
'permission_denied: image_path is not permitted for remote callers (D18). ' +
'Use image_url or image_data instead.',
);
}
if (!imagePath && !imageUrl && !imageData) {
throw new Error('search_by_image requires one of: image_path, image_url, image_data');
}
if ([imagePath, imageUrl, imageData].filter(Boolean).length > 1) {
throw new Error('search_by_image accepts only one of: image_path, image_url, image_data');
}
// D23-#6 — pre-flight daily-budget check for remote OAuth clients.
// Local CLI callers (ctx.remote=false) bypass the cap (clientId="").
const clientId = (ctx.remote === true ? (ctx.auth?.clientId ?? '') : '');
if (clientId) {
const budgetUsd = await getDailyImageBudgetUsd(ctx.engine);
const { checkBudget } = await import('./spend-log.ts');
await checkBudget(ctx.engine, clientId, Math.round(budgetUsd * 100));
}
// Resolve image bytes via the SSRF-defended loader. For remote callers,
// tighter byte cap.
const remoteCap = await getRemoteMaxBytes(ctx.engine);
const localCap = await getLocalMaxBytes(ctx.engine);
const cap = ctx.remote === true ? remoteCap : localCap;
const { loadImageInput } = await import('./search/image-loader.ts');
const loaded = await loadImageInput(
(imagePath ?? imageUrl ?? `data:${imageMime ?? 'image/png'};base64,${imageData}`)!,
{ maxBytes: cap },
);
// Resolve source-scope (D5 canonical thread).
const resolvedSourceId =
sourceIdParam !== undefined
? sourceIdParam === '__all__'
? undefined
: sourceIdParam
: ctx.sourceId;
const { searchByImage } = await import('./search/by-image.ts');
const results = await searchByImage(
ctx.engine,
{ base64: loaded.base64, mime: loaded.contentType },
{
limit: (p.limit as number) || 20,
offset: (p.offset as number) || 0,
query: queryRefinement,
sourceId: resolvedSourceId,
...sourceScopeOpts(ctx),
},
);
// D23-#6 — record successful Voyage call. Best-effort; failures don't
// block the response.
if (clientId) {
const { recordSpend, VOYAGE_MULTIMODAL_3_PER_IMAGE_CENTS } = await import('./spend-log.ts');
// Approximate: 1 image embed + (query ? 1 text embed : 0). Both are
// billed at the same per-call rate by Voyage.
const calls = 1 + (queryRefinement ? 1 : 0);
void recordSpend(ctx.engine, {
clientId,
tokenName: ctx.auth?.clientName ?? null,
operation: 'search_by_image',
spendCents: VOYAGE_MULTIMODAL_3_PER_IMAGE_CENTS * calls,
provider: 'voyage',
model: 'voyage-multimodal-3',
});
}
return results;
},
cliHints: { name: 'search-by-image' },
};
async function getDailyImageBudgetUsd(engine: BrainEngine): Promise<number> {
try {
const v = await engine.getConfig('search.image_query.daily_budget_usd_per_client');
if (v == null) return 5; // default $5
const n = parseFloat(v);
return Number.isFinite(n) && n > 0 ? n : 5;
} catch {
return 5;
}
}
async function getLocalMaxBytes(engine: BrainEngine): Promise<number> {
try {
const v = await engine.getConfig('search.image_query.max_bytes');
if (v == null) return 10 * 1024 * 1024;
const n = parseInt(v, 10);
return Number.isFinite(n) && n > 0 ? n : 10 * 1024 * 1024;
} catch {
return 10 * 1024 * 1024;
}
}
async function getRemoteMaxBytes(engine: BrainEngine): Promise<number> {
try {
const v = await engine.getConfig('search.image_query.remote_max_bytes');
if (v == null) return 2 * 1024 * 1024;
const n = parseInt(v, 10);
return Number.isFinite(n) && n > 0 ? n : 2 * 1024 * 1024;
} catch {
return 2 * 1024 * 1024;
}
}
// --- Exports ---
export const operations: Operation[] = [
@@ -3294,6 +3449,8 @@ export const operations: Operation[] = [
restore_page, purge_deleted_pages,
// Search
search, query,
// v0.36 Phase 2: image-as-query
search_by_image,
// Tags
add_tag, remove_tag, get_tags,
// Links
+12 -7
View File
@@ -1289,15 +1289,20 @@ export class PGLiteEngine implements BrainEngine {
// v0.36 (D11): column routing via resolved descriptor. Engine doesn't
// read config — caller resolved at hybrid/op boundary. The cast SQL
// ($1::vector vs $1::halfvec(N)) comes from buildVectorCastFragment.
//
// v0.36 Phase 3: 'embedding_multimodal' is the unified column populated
// by `gbrain reindex --multimodal`. No modality filter — the column
// itself is the discriminator (only re-embedded rows have non-NULL).
const resolvedCol = normalizeEngineColumn(opts?.embeddingColumn);
const { col, castSql } = buildVectorCastFragment(resolvedCol);
// Image rows live in modality='image'; text/code in 'text'. Restrict
// by modality so non-image columns can't accidentally pull image
// chunks (or vice versa). resolved.name has already passed regex
// validation; never compares against raw input.
const modalityFilter = resolvedCol.name === 'embedding_image'
? `AND cc.modality = 'image'`
: `AND cc.modality = 'text'`;
let modalityFilter: string;
if (resolvedCol.name === 'embedding_image') {
modalityFilter = `AND cc.modality = 'image'`;
} else if (resolvedCol.name === 'embedding_multimodal') {
modalityFilter = '';
} else {
modalityFilter = `AND cc.modality = 'text'`;
}
const { rows } = await this.db.query(
`WITH hnsw_candidates AS (
+5 -1
View File
@@ -119,7 +119,11 @@ CREATE TABLE IF NOT EXISTS content_chunks (
-- chunks carry their 1024-dim Voyage multimodal vector in embedding_image
-- (independent of the brain primary embedding column dim).
modality TEXT NOT NULL DEFAULT 'text',
embedding_image vector(1024)
embedding_image vector(1024),
-- v0.36 Phase 3 cross-modal: unified column populated by reindex
-- (search.unified_multimodal=true routes here). Migration v75 adds it
-- on upgrade; fresh installs land at head with the column present.
embedding_multimodal vector(1024)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_chunks_page_index ON content_chunks(page_id, chunk_index);
+13 -8
View File
@@ -1303,16 +1303,21 @@ export class PostgresEngine implements BrainEngine {
// read config — caller (hybrid/op) resolved it and passed it in.
// normalizeEngineColumn accepts the legacy union (string literals,
// ResolvedColumn, undefined) and produces a canonical descriptor.
//
// v0.36 Phase 3: 'embedding_multimodal' is the unified column populated
// by `gbrain reindex --multimodal`. Carries BOTH text and image content
// in Voyage multimodal-3 space — no modality filter; the column itself
// is the discriminator (rows without embedding_multimodal aren't searched).
const resolvedCol = normalizeEngineColumn(opts?.embeddingColumn);
const { col, castSql } = buildVectorCastFragment(resolvedCol);
// Modality filter: image rows live in modality='image'; text/code in
// 'text'. The image-column literal is the only one with image rows.
// For all other columns (default + user-declared), restrict to text
// mode to avoid cross-modality dim leaks. The check is on
// resolved.name (already validated, never raw input).
const modalityFilter = resolvedCol.name === 'embedding_image'
? `AND cc.modality = 'image'`
: `AND cc.modality = 'text'`;
let modalityFilter: string;
if (resolvedCol.name === 'embedding_image') {
modalityFilter = `AND cc.modality = 'image'`;
} else if (resolvedCol.name === 'embedding_multimodal') {
modalityFilter = '';
} else {
modalityFilter = `AND cc.modality = 'text'`;
}
const rawQuery = `
WITH hnsw_candidates AS (
+4 -1
View File
@@ -159,7 +159,10 @@ CREATE TABLE IF NOT EXISTS content_chunks (
-- mixed-provider brains (e.g. OpenAI 1536 text + Voyage 1024 images) keep
-- both columns populated with distinct dim spaces.
modality TEXT NOT NULL DEFAULT 'text',
embedding_image vector(1024)
embedding_image vector(1024),
-- v0.36 Phase 3 cross-modal: unified column populated by reindex.
-- Migration v75 also adds it for upgrade paths.
embedding_multimodal vector(1024)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_chunks_page_index ON content_chunks(page_id, chunk_index);
+128
View File
@@ -0,0 +1,128 @@
/**
* v0.36 Phase 2 — `searchByImage` retrieval.
*
* D17 honest framing: Phase 2 ships image→similar-images + image-OCR-text
* retrieval. Image chunks have OCR text in `chunk_text`, so the results
* carry both visual-similarity AND any OCR text the chunks captured. True
* image→full-text-knowledge (Alice's bio, takes about her) requires text
* chunks in the multimodal embedding space — Phase 3's unified column.
*
* D13 hybrid intersect for optional text refinement: when caller supplies
* `query`, runs image-vector AND text-vector searches in parallel and
* merges via weighted RRF. Treats text refinement as a full second axis
* (not a vector-space bias from naive averaging).
*
* Routing matrix:
* - image-only (no query):
* embedQueryMultimodalImage → searchVector(embedding_image)
* - image + text query (D13):
* embedQueryMultimodalImage AND embedQueryMultimodal(query)
* → parallel searchVector calls → rrfFusionWeighted merge
* - Phase 3 unified mode (search.unified_multimodal=true): both branches
* target embedding_multimodal instead of embedding_image
*
* D5 source-id: every engine call receives sourceOpts threaded from
* `ctx.sourceId` / `ctx.auth.allowedSources`. Tested by
* test/e2e/source-isolation-image.test.ts (4 cases).
*/
import type { BrainEngine } from '../engine.ts';
import type { SearchOpts, SearchResult } from '../types.ts';
import { effectiveRrfK } from './intent-weights.ts';
import { rrfFusionWeighted, RRF_K } from './hybrid.ts';
import { dedupResults } from './dedup.ts';
import { embedQueryMultimodal, embedQueryMultimodalImage } from '../ai/gateway.ts';
import { loadSearchModeConfig, resolveSearchMode } from './mode.ts';
export interface SearchByImageOpts extends SearchOpts {
/** Optional text refinement; runs hybrid intersect via weighted RRF (D13). */
query?: string;
}
/**
* Run image-as-query retrieval against the brain.
*
* @param engine brain engine (PGLite or Postgres)
* @param input loaded image (from `loadImageInput`): { base64, mime }
* @param opts SearchOpts + optional `query` for D13 text refinement
*/
export async function searchByImage(
engine: BrainEngine,
input: { base64: string; mime: string },
opts: SearchByImageOpts = {},
): Promise<SearchResult[]> {
// Resolve mode bundle once at entry — picks up cross-modal RRF weights
// (D13 image_query_text_refinement_weight / image_query_image_refinement_weight)
// and the Phase 3 unified-multimodal flag.
const modeInput = await loadSearchModeConfig(engine);
const resolvedMode = resolveSearchMode({
mode: modeInput.mode,
overrides: modeInput.overrides,
});
const limit = opts.limit ?? resolvedMode.searchLimit;
const offset = opts.offset ?? 0;
// Phase 2 always targets embedding_image. Phase 3's unified column
// routing slots in here once src/core/types.ts widens
// SearchOpts.embeddingColumn to include 'embedding_multimodal' (Commit 3
// schema migration + type widening).
const imageColumn: 'embedding_image' = 'embedding_image';
const baseSearchOpts: SearchOpts = {
limit: Math.min(limit * 2, 100),
offset: 0,
sourceId: opts.sourceId,
sourceIds: opts.sourceIds,
// Both branches use the same source-scope threading.
};
// Image branch — always runs.
const imageEmbedding = await embedQueryMultimodalImage({
data: input.base64,
mime: input.mime,
});
const imageOpts: SearchOpts = { ...baseSearchOpts, embeddingColumn: imageColumn };
const imageList = await engine.searchVector(imageEmbedding, imageOpts);
// Tag rows with modality so downstream consumers can distinguish in 'both' results.
for (const r of imageList) {
r.modality = r.modality ?? 'image';
}
// D13: if caller provided a text refinement query, run the text branch too
// and merge via weighted RRF.
if (opts.query && opts.query.trim().length > 0) {
const textEmbedding = await embedQueryMultimodal(opts.query);
const textOpts: SearchOpts = {
...baseSearchOpts,
embeddingColumn: imageColumn,
};
// Both branches target the same multimodal-embedding column (Phase 2).
// The "intersect" is via RRF rank merging — the text-side vector lives
// in the SAME multimodal embedding space as the image vector
// (embedQueryMultimodal returns a 1024d vector), so it's the right
// space to query.
const textList = await engine.searchVector(textEmbedding, textOpts);
for (const r of textList) {
r.modality = r.modality ?? 'image';
}
// Weighted RRF merge: image branch has higher default weight (0.6 vs 0.4)
// because the caller chose image-first. Configurable via
// search.image_query.*_refinement_weight (D3 registered).
const baseRrfK = RRF_K;
const imageK = effectiveRrfK(baseRrfK, resolvedMode.image_query_image_refinement_weight);
const textK = effectiveRrfK(baseRrfK, resolvedMode.image_query_text_refinement_weight);
const fused = rrfFusionWeighted(
[
{ list: imageList, k: imageK },
{ list: textList, k: textK },
],
true, // apply boost
);
fused.sort((a, b) => b.score - a.score);
return dedupResults(fused).slice(offset, offset + limit);
}
// Image-only: return the deduped image branch directly.
return dedupResults(imageList).slice(offset, offset + limit);
}
+11
View File
@@ -527,6 +527,17 @@ export function normalizeEngineColumn(
};
}
// v0.36 Phase 3 unified multimodal column — populated by
// `gbrain reindex --multimodal`. Voyage multimodal-3, vector(1024).
if (embeddingColumn === 'embedding_multimodal') {
return {
name: 'embedding_multimodal',
type: 'vector',
dimensions: 1024,
embeddingModel: 'voyage:voyage-multimodal-3',
};
}
// Any other raw string at this layer is a programming error — the
// resolver should have run at the hybrid/op boundary and produced a
// descriptor. We throw with a paste-ready hint rather than guess.
+203 -29
View File
@@ -17,7 +17,7 @@ import { resolveEmbeddingColumn, isCacheSafe } from './embedding-column.ts';
import { loadConfigWithEngine } from '../config.ts';
import { dedupResults } from './dedup.ts';
import { applyReranker } from './rerank.ts';
import { autoDetectDetail, classifyQuery } from './query-intent.ts';
import { autoDetectDetail, classifyQuery, isAmbiguousModalityQuery } from './query-intent.ts';
import { expandAnchors, hydrateChunks } from './two-pass.ts';
import { enforceTokenBudget } from './token-budget.ts';
import { recordSearchTelemetry } from './telemetry.ts';
@@ -31,7 +31,7 @@ import {
loadCacheConfig,
} from './query-cache.ts';
const RRF_K = 60;
export const RRF_K = 60;
const COMPILED_TRUTH_BOOST = 2.0;
const pendingCacheWrites = new Set<Promise<unknown>>();
@@ -454,8 +454,22 @@ export async function hybridSearch(
console.error(`[search-debug] auto-detail=${detail} for query="${query}"`);
}
// Run keyword search (always available, no API key needed)
const keywordResults = await engine.searchKeyword(query, searchOpts);
// Run keyword search (always available, no API key needed).
//
// v0.36 cross-modal (D9): skip keyword for 'image'-only modality. Image
// chunks may have OCR text in chunk_text, but a text-only keyword scan
// would also surface every text chunk containing the query phrase —
// not what an image-intent query asked for. Image vector search is the
// canonical channel for image-modality queries.
//
// We classify modality early (it's also computed after for the modality
// branch). The classification is pure regex via classifyQuery; running it
// here is cheap.
const earlyModality = (opts?.crossModal && opts.crossModal !== 'auto')
? opts.crossModal
: (suggestions.suggestedModality ?? 'text');
const keywordResults: SearchResult[] =
earlyModality === 'image' ? [] : await engine.searchKeyword(query, searchOpts);
// v0.29.1: resolve salience/recency from caller (back-compat aliases for
// PR #618's `recencyBoost` numeric scale) or fall back to the heuristic.
@@ -526,14 +540,62 @@ export async function hybridSearch(
return noEmbedBudgeted;
}
// v0.36 cross-modal wave: determine the effective modality once.
//
// Precedence (D22-1 normalization): literal 'auto' is normalized to
// undefined so it doesn't reach the modality branch directly. Resolution:
// explicit opts.crossModal ('text'|'image'|'both') wins
// else suggestions.suggestedModality (regex-driven)
// else (Commit 4) opt-in LLM tie-break for genuinely ambiguous queries
// else 'text' (default)
//
// D9 mode-bundle override matrix: when effectiveModality === 'image',
// cross-modal path overrides bundle knobs (expansion=false, no keyword
// search). Voyage handles synonyms in-space; zerank-2 can't rerank image
// embeddings.
//
// Phase 3 (D8): when search.unified_multimodal is true, ALL queries
// route through the multimodal model + embedding_multimodal column,
// regardless of detected modality.
//
// Commit 4 (LLM intent escalation): when search.cross_modal.llm_intent
// is true AND regex returned 'text' AND isAmbiguousModalityQuery fires,
// await a Haiku tie-break. Fail-open to regex result on any error.
const explicitModality =
opts?.crossModal && opts.crossModal !== 'auto' ? opts.crossModal : undefined;
let regexModality = explicitModality ?? suggestions.suggestedModality ?? 'text';
// LLM tie-break fires ONLY when:
// - no explicit per-call override
// - regex returned 'text' (not confident image/both)
// - operator opted in via search.cross_modal.llm_intent
// - isAmbiguousModalityQuery says the query is genuinely ambiguous
if (
explicitModality === undefined &&
regexModality === 'text' &&
resolvedMode.cross_modal_llm_intent &&
isAmbiguousModalityQuery(query)
) {
try {
const { classifyModalityWithLLM } = await import('./llm-intent.ts');
regexModality = await classifyModalityWithLLM(query, 'text');
} catch {
// Fail-open: regex result stands.
}
}
const effectiveModality = regexModality;
const unifiedRouting = resolvedMode.unified_multimodal === true;
// Determine query variants (optionally with expansion)
// expandQuery already includes the original query in its return value,
// so we use it directly instead of prepending query again.
// v0.32.3 search-lite: expansion fires when (a) resolved mode says yes and
// (b) an expandFn is wired in. The mode bundle is the default; per-call
// SearchOpts.expansion still wins via resolveSearchMode's chain.
//
// D9: image-modality skips expansion regardless of mode bundle.
let queries = [query];
if (resolvedMode.expansion && opts?.expandFn) {
const expansionAllowed = resolvedMode.expansion && effectiveModality !== 'image';
if (expansionAllowed && opts?.expandFn) {
try {
queries = await opts.expandFn(query);
if (queries.length === 0) queries = [query];
@@ -544,28 +606,121 @@ export async function hybridSearch(
}
}
// Embed all query variants and run vector search
// Embed all query variants and run vector search.
//
// v0.36 cross-modal wave routing:
// - 'text' (default): existing text-embedding path, unchanged
// - 'image': embedQueryMultimodal + searchVector(embedding_image), skip keyword
// - 'both': text + image vector searches in parallel; merged via weighted RRF
let vectorLists: SearchResult[][] = [];
let queryEmbedding: Float32Array | null = null;
try {
// v0.35.0.0+: query-side embedding. For asymmetric providers (ZE zembed-1,
// Voyage v3+) routes input_type='query' through the embed seam; symmetric
// providers ignore the field — no behavior change.
// v0.36 (D10): route through the resolved column's provider + dims so
// a query against `embedding_voyage` actually embeds via Voyage, not
// the global default (OpenAI). Empty embeddingModel falls back to
// gateway default — preserves pre-v0.36 behavior for the builtin
// 'embedding' column.
const embedOpts = resolvedCol.embeddingModel
? { embeddingModel: resolvedCol.embeddingModel, dimensions: resolvedCol.dimensions }
: undefined;
const embeddings = await Promise.all(queries.map(q => embedQuery(q, embedOpts)));
queryEmbedding = embeddings[0];
vectorLists = await Promise.all(
embeddings.map(emb => engine.searchVector(emb, searchOpts)),
);
} catch {
// Embedding failure is non-fatal, fall back to keyword-only
let imageVectorList: SearchResult[] | null = null;
let crossModalFellOpen = false;
// Phase 3 unified routing: when on, route ALL queries through Voyage
// multimodal-3 + embedding_multimodal column. Bypasses the dual-column
// branching below — but with D8 fail-open: if the unified path returns
// zero rows AND the operator hasn't opted into strict unified-only mode,
// fall through to the dual-column text path. unified_multimodal_only
// disables the fallback.
let unifiedDone = false;
if (unifiedRouting) {
try {
const { isAvailable: aiIsAvailable, embedQueryMultimodal } = await import('../ai/gateway.ts');
if (!aiIsAvailable('embedding')) {
throw new Error('gateway not configured for embedding — unified multimodal would also fail');
}
const unifiedEmbedding = await embedQueryMultimodal(query);
const unifiedSearchOpts: SearchOpts = {
...searchOpts,
embeddingColumn: 'embedding_multimodal',
};
const unifiedList = await engine.searchVector(unifiedEmbedding, unifiedSearchOpts);
// D8 fail-open: zero rows + not strict-mode → fall through to dual-column.
if (unifiedList.length === 0 && !resolvedMode.unified_multimodal_only) {
console.error(
`[cross-modal] unified_multimodal returned zero rows for query="${query.slice(0, 60)}". ` +
`Falling back to dual-column text path (partial coverage during reindex). ` +
`Set search.unified_multimodal_only=true to bypass this fallback when reindex completes.`,
);
} else {
vectorLists = [unifiedList];
queryEmbedding = unifiedEmbedding;
unifiedDone = true;
}
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
console.error(
`[cross-modal] unified_multimodal embed failed; falling back to dual-column path. reason=${reason}`,
);
crossModalFellOpen = true;
}
}
if (!unifiedDone && (effectiveModality === 'image' || effectiveModality === 'both')) {
// Attempt image-side embedding. Fail-open: if multimodal is unconfigured
// OR the embed throws, log a structured warning and fall through to text.
try {
const { isAvailable: aiIsAvailable, embedQueryMultimodal } = await import('../ai/gateway.ts');
if (!aiIsAvailable('embedding')) {
throw new Error('gateway not configured for embedding — multimodal would also fail');
}
const imageEmbedding = await embedQueryMultimodal(query);
const imageSearchOpts: SearchOpts = {
...searchOpts,
embeddingColumn: 'embedding_image',
};
const imageList = await engine.searchVector(imageEmbedding, imageSearchOpts);
for (const r of imageList) {
r.modality = r.modality ?? 'image';
}
imageVectorList = imageList;
} catch (err) {
// Fail-open per behavioral invariant 2.
const reason = err instanceof Error ? err.message : String(err);
console.error(
`[cross-modal] image-side embed failed for modality=${effectiveModality}; falling back to text-only. reason=${reason}`,
);
crossModalFellOpen = true;
}
}
if (unifiedDone) {
// Unified routing already populated vectorLists + queryEmbedding;
// skip the dual-column branching.
} else if (effectiveModality === 'image' && imageVectorList !== null) {
// Image-only path: results come entirely from the image column.
vectorLists = [imageVectorList];
queryEmbedding = null; // no text embedding to cosine-re-score against
} else {
// 'text' or 'both' (or 'image' that fell open to text). Run the text
// path normally, with v0.36 (D10) provider-aware embed routing so a
// query against `embedding_voyage` actually embeds via Voyage, not
// the global default. Empty embeddingModel falls back to gateway
// default — preserves pre-v0.36 behavior for the builtin 'embedding'
// column.
try {
const embedOpts = resolvedCol.embeddingModel
? { embeddingModel: resolvedCol.embeddingModel, dimensions: resolvedCol.dimensions }
: undefined;
const embeddings = await Promise.all(queries.map(q => embedQuery(q, embedOpts)));
queryEmbedding = embeddings[0];
const textLists = await Promise.all(
embeddings.map(emb => engine.searchVector(emb, searchOpts)),
);
for (const list of textLists) {
for (const r of list) {
r.modality = r.modality ?? 'text';
}
}
vectorLists = textLists;
// 'both' mode: also include the image-side list as another input to RRF.
if (effectiveModality === 'both' && imageVectorList !== null) {
vectorLists = [...vectorLists, imageVectorList];
}
} catch {
// Embedding failure is non-fatal, fall back to keyword-only
}
}
if (vectorLists.length === 0) {
@@ -605,10 +760,29 @@ export async function hybridSearch(
const baseRrfK = opts?.rrfK ?? RRF_K;
const keywordK = effectiveRrfK(baseRrfK, intentWeights.keywordWeight);
const vectorK = effectiveRrfK(baseRrfK, intentWeights.vectorWeight);
const allLists: Array<{ list: SearchResult[]; k: number }> = [
...vectorLists.map(list => ({ list, k: vectorK })),
{ list: keywordResults, k: keywordK },
];
// v0.36 cross-modal (D6): in 'both' mode, vectorLists carries
// [textList, imageList]. Apply per-modality RRF weights so the merge
// reflects the configured text/image balance. In 'text' and 'image'
// modes only one branch is present, so per-modality K reduces to
// the standard vectorK (no behavior change vs pre-v0.36).
const textRrfK = effectiveRrfK(baseRrfK, resolvedMode.cross_modal_both_text_weight);
const imageRrfK = effectiveRrfK(baseRrfK, resolvedMode.cross_modal_both_image_weight);
const isBothMode = effectiveModality === 'both' && vectorLists.length >= 2;
const allLists: Array<{ list: SearchResult[]; k: number }> = isBothMode
? [
// Last list in vectorLists is the image branch (we appended it above).
// All preceding lists (1 or more text-query embeddings if expansion ran)
// get textRrfK. Image branch gets imageRrfK.
...vectorLists.slice(0, -1).map(list => ({ list, k: textRrfK })),
{ list: vectorLists[vectorLists.length - 1], k: imageRrfK },
{ list: keywordResults, k: keywordK },
]
: [
...vectorLists.map(list => ({ list, k: vectorK })),
{ list: keywordResults, k: keywordK },
];
let fused = rrfFusionWeighted(allLists, detail !== 'high');
// Cosine re-scoring before dedup so semantically better chunks survive.
+247
View File
@@ -0,0 +1,247 @@
/**
* v0.36 Phase 2 — image input loader for `search_by_image`.
*
* Accepts three input forms:
* - absolute path: file:// URI OR /abs/path/to/img.png (local CLI only)
* - data: URI: data:image/png;base64,<base64bytes>
* - http(s) URL: https://example.com/img.png (SSRF-defended via ssrf-validate.ts)
*
* Returns: { contentType, base64, bytes } so the caller can hand it to
* `embedQueryMultimodalImage({data: base64, mime: contentType})`.
*
* Defenses:
* - Magic-byte sniff for PNG / JPEG / WebP (no other formats accepted in v1)
* - Hard size cap (default 10 MB) — applies before allocation when possible
* via Content-Length pre-flight, and as a stream cap during fetch
* - SSRF: every URL hop validated via `validateAndResolveUrl` from
* `src/core/ssrf-validate.ts`; max 3 redirect hops
* - 5s total fetch timeout (not per-hop)
* - Rejects credentials embedded in URL
*
* Out of scope:
* - Pixel-bomb defense (decompression bombs): the size cap doubles as the
* bomb defense per D22-9. We never call a pixel parser; only feed bytes
* to Voyage. A 10MB cap is well below the pixel-bomb threshold.
* - HEIC / GIF / TIFF — Voyage multimodal-3 supports them but the test
* fixtures and OCR pipeline don't, so we keep the input surface
* conservative for v1.
*/
import { readFile } from 'node:fs/promises';
import { isAbsolute } from 'node:path';
import { SSRFError, fetchWithSSRFGuard } from '../ssrf-validate.ts';
/** Max bytes for input image. Configurable via `search.image_query.max_bytes`. */
export const DEFAULT_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
/** Stricter cap for remote MCP callers (ctx.remote === true). */
export const DEFAULT_REMOTE_IMAGE_MAX_BYTES = 2 * 1024 * 1024;
export interface LoadedImage {
/** MIME content type ('image/png', 'image/jpeg', 'image/webp'). */
contentType: string;
/** Raw bytes as Buffer. Caller can use this for hashing/auditing. */
bytes: Buffer;
/** Base64-encoded bytes, ready to feed into embedQueryMultimodalImage. */
base64: string;
}
export class ImageLoadError extends Error {
readonly code: ImageLoadErrorCode;
constructor(code: ImageLoadErrorCode, message: string) {
super(message);
this.name = 'ImageLoadError';
this.code = code;
}
}
export type ImageLoadErrorCode =
| 'INVALID_FORMAT'
| 'OVERSIZED'
| 'INVALID_URL'
| 'FETCH_FAILED'
| 'TIMEOUT'
| 'SSRF_BLOCKED'
| 'NOT_FOUND';
export interface ImageLoadOpts {
/** Override the default max-bytes cap. Defaults to DEFAULT_IMAGE_MAX_BYTES. */
maxBytes?: number;
/** Total fetch timeout in ms (URLs only). Defaults to 5000. */
timeoutMs?: number;
/** Max redirect hops (URLs only). Defaults to 3. */
maxRedirects?: number;
}
/**
* Load an image input into a structured shape.
*
* Throws `ImageLoadError` on any failure — `SSRFError` from the URL path
* is caught and re-thrown as ImageLoadError with `code: 'SSRF_BLOCKED'`.
*/
export async function loadImageInput(
input: string,
opts: ImageLoadOpts = {},
): Promise<LoadedImage> {
if (typeof input !== 'string' || input.length === 0) {
throw new ImageLoadError('INVALID_URL', 'Image input must be a non-empty string');
}
const maxBytes = opts.maxBytes ?? DEFAULT_IMAGE_MAX_BYTES;
// Branch on input shape.
if (input.startsWith('data:')) {
return loadDataUri(input, maxBytes);
}
if (input.startsWith('http://') || input.startsWith('https://')) {
return loadHttpUrl(input, { maxBytes, timeoutMs: opts.timeoutMs ?? 5000, maxRedirects: opts.maxRedirects ?? 3 });
}
if (input.startsWith('file://') || isAbsolute(input)) {
const path = input.startsWith('file://') ? input.slice(7) : input;
return loadLocalPath(path, maxBytes);
}
throw new ImageLoadError(
'INVALID_URL',
`Unsupported image input shape: expected data: URI, http(s):// URL, file:// URI, or absolute path. Got: ${input.slice(0, 60)}`,
);
}
async function loadLocalPath(path: string, maxBytes: number): Promise<LoadedImage> {
let bytes: Buffer;
try {
bytes = await readFile(path);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes('ENOENT')) {
throw new ImageLoadError('NOT_FOUND', `File not found: ${path}`);
}
throw new ImageLoadError('FETCH_FAILED', `Failed to read ${path}: ${msg}`);
}
if (bytes.length > maxBytes) {
throw new ImageLoadError(
'OVERSIZED',
`Image at ${path} is ${bytes.length} bytes; cap is ${maxBytes}`,
);
}
const contentType = sniffContentType(bytes);
return finalize(bytes, contentType);
}
function loadDataUri(input: string, maxBytes: number): LoadedImage {
// data:image/png;base64,<bytes>
const match = input.match(/^data:([^;]+);base64,(.+)$/);
if (!match) {
throw new ImageLoadError('INVALID_FORMAT', 'data: URI must be in `data:<mime>;base64,<bytes>` form');
}
const declaredMime = match[1].toLowerCase();
const b64 = match[2];
let bytes: Buffer;
try {
bytes = Buffer.from(b64, 'base64');
} catch (err) {
throw new ImageLoadError(
'INVALID_FORMAT',
`Failed to decode base64: ${err instanceof Error ? err.message : String(err)}`,
);
}
if (bytes.length > maxBytes) {
throw new ImageLoadError(
'OVERSIZED',
`Decoded image is ${bytes.length} bytes; cap is ${maxBytes}`,
);
}
const sniffed = sniffContentType(bytes);
// If the declared MIME disagrees with the magic-byte sniff, trust the sniff
// (caller could lie about content-type to bypass format gate).
if (sniffed !== declaredMime) {
// Fall through with the sniffed type. Don't error — declared MIME is
// informational; bytes are the truth.
}
return finalize(bytes, sniffed);
}
async function loadHttpUrl(
url: string,
opts: { maxBytes: number; timeoutMs: number; maxRedirects: number },
): Promise<LoadedImage> {
let res: Response;
try {
res = await fetchWithSSRFGuard(url, {
maxRedirects: opts.maxRedirects,
timeoutMs: opts.timeoutMs,
});
} catch (err) {
if (err instanceof SSRFError) {
throw new ImageLoadError('SSRF_BLOCKED', `SSRF: ${err.message}`);
}
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes('abort')) {
throw new ImageLoadError('TIMEOUT', `Fetch timeout (${opts.timeoutMs}ms): ${url}`);
}
throw new ImageLoadError('FETCH_FAILED', `Fetch failed: ${msg}`);
}
if (!res.ok) {
throw new ImageLoadError(
'FETCH_FAILED',
`Fetch returned HTTP ${res.status}: ${res.statusText || ''}`,
);
}
// Pre-flight: reject oversized responses before reading the body.
const contentLengthHeader = res.headers.get('content-length');
if (contentLengthHeader) {
const declared = parseInt(contentLengthHeader, 10);
if (Number.isFinite(declared) && declared > opts.maxBytes) {
throw new ImageLoadError(
'OVERSIZED',
`Response Content-Length ${declared} exceeds cap ${opts.maxBytes}`,
);
}
}
// Read the body; second guard against lying Content-Length.
const arrayBuf = await res.arrayBuffer();
const bytes = Buffer.from(arrayBuf);
if (bytes.length > opts.maxBytes) {
throw new ImageLoadError(
'OVERSIZED',
`Response body is ${bytes.length} bytes; cap is ${opts.maxBytes} (lying Content-Length?)`,
);
}
const contentType = sniffContentType(bytes);
return finalize(bytes, contentType);
}
/**
* Magic-byte sniff for the three supported formats.
*
* - PNG: starts with `89 50 4E 47 0D 0A 1A 0A`
* - JPEG: starts with `FF D8 FF`
* - WebP: starts with `RIFF` (52 49 46 46) + 4 bytes + `WEBP` (57 45 42 50)
*
* Throws `ImageLoadError` with `code: 'INVALID_FORMAT'` for anything else.
*/
function sniffContentType(bytes: Buffer): string {
if (bytes.length >= 8 &&
bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47 &&
bytes[4] === 0x0D && bytes[5] === 0x0A && bytes[6] === 0x1A && bytes[7] === 0x0A) {
return 'image/png';
}
if (bytes.length >= 3 && bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF) {
return 'image/jpeg';
}
if (bytes.length >= 12 &&
bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 &&
bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50) {
return 'image/webp';
}
throw new ImageLoadError(
'INVALID_FORMAT',
`Unsupported image format. Magic bytes: ${bytes.subarray(0, Math.min(12, bytes.length)).toString('hex')}. Accepted: PNG, JPEG, WebP.`,
);
}
function finalize(bytes: Buffer, contentType: string): LoadedImage {
return {
contentType,
bytes,
base64: bytes.toString('base64'),
};
}
+80
View File
@@ -0,0 +1,80 @@
/**
* v0.36 Commit 4 — opt-in LLM tie-break for ambiguous modality queries.
*
* The pure-regex classifier in query-intent.ts handles 99% of queries
* cleanly. For the ambiguous middle band (`isAmbiguousModalityQuery`
* returns true), the operator can opt into a Haiku tie-break via
* `search.cross_modal.llm_intent: true`.
*
* Cost bound: <1% of queries when on; ~$0.0001 per escalation. Bypassed
* entirely when the config flag is false (default).
*
* Fail-open: any error (timeout, parse failure, gateway misconfig) returns
* the input fallback ('text') so a misbehaving LLM can never break search.
*/
import type { ModalityMode } from './query-intent.ts';
/** Default model tier for the tie-break call. Haiku 4.5 via utility tier. */
const TIE_BREAK_TIMEOUT_MS = 1000;
const SYSTEM_PROMPT =
'You classify search query modality. Output exactly one word: text, image, or both.\n' +
'- text: the user wants written content (notes, takes, bios, articles)\n' +
'- image: the user wants visual content (photos, screenshots, diagrams)\n' +
'- both: the user is asking something that could be either\n' +
'Output nothing except one of: text image both';
/**
* Run a Haiku tie-break to classify the modality of an ambiguous query.
*
* Returns `fallback` on any error (timeout, parse failure, unrecognized
* output). Production callers gate this on the
* `search.cross_modal.llm_intent` config flag AND
* `isAmbiguousModalityQuery(query) === true` so the LLM call only fires
* for the narrow band where it actually adds signal.
*/
export async function classifyModalityWithLLM(
query: string,
fallback: ModalityMode = 'text',
): Promise<ModalityMode> {
let chat: typeof import('../ai/gateway.ts').chat;
let isAvailable: typeof import('../ai/gateway.ts').isAvailable;
try {
({ chat, isAvailable } = await import('../ai/gateway.ts'));
} catch {
return fallback;
}
if (!isAvailable('chat')) {
// Quiet bail — caller decides whether to warn.
return fallback;
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIE_BREAK_TIMEOUT_MS);
try {
const result = await chat({
system: SYSTEM_PROMPT,
messages: [{ role: 'user', content: query.slice(0, 500) }],
maxTokens: 16,
abortSignal: controller.signal,
});
return parseModality(result.text, fallback);
} catch {
// Timeout, network error, AI SDK throw — fail-open.
return fallback;
} finally {
clearTimeout(timer);
}
}
/**
* Parse the LLM's single-word output to a ModalityMode. Tolerates
* trailing punctuation + casing. Anything unrecognized → fallback.
*/
export function parseModality(raw: string, fallback: ModalityMode): ModalityMode {
const normalized = raw.trim().toLowerCase().replace(/[^a-z]+/g, '');
if (normalized === 'text' || normalized === 'image' || normalized === 'both') {
return normalized;
}
return fallback;
}
+158
View File
@@ -94,6 +94,7 @@ export interface ModeBundle {
reranker_top_n_out: number | null;
/** HTTP timeout in ms (default 5000). Threaded into gateway.rerank. */
reranker_timeout_ms: number;
/**
* v0.35.6.0 — floor-ratio gate for metadata-axis boost stages (backlink,
* salience, recency). `undefined` = no gate (default for all three modes;
@@ -111,6 +112,56 @@ export interface ModeBundle {
* relevance signal and is NOT gated.
*/
floor_ratio: number | undefined;
// v0.36 cross-modal wave knobs (D2 + D3 + D6 + D8 + D13 + LLM-intent).
// All three mode bundles default these to the same values — cross-modal
// is opt-in per-call (D6 weighting), opt-in per-brain (D8 unified flags),
// and opt-in per-feature-flag (LLM intent). The mode bundle just gives
// resolveSearchMode a default to return.
/**
* D6 'both'-mode RRF weight for text-vector results when merging
* text + image searches in parallel. Defaults to 0.6 — biases toward
* text recall because most queries with ambiguous modality are still
* text-leaning. Pair with cross_modal_both_image_weight.
*/
cross_modal_both_text_weight: number;
/**
* D6 'both'-mode RRF weight for image-vector results. Defaults to 0.4.
* Sum with text weight does NOT need to be 1.0 — RRF is rank-based, so
* weights normalize internally; the ratio is what matters.
*/
cross_modal_both_image_weight: number;
/**
* D13 image-query text-refinement RRF weight for the TEXT branch of
* searchByImage when the caller provides an optional `query` refinement.
* Defaults to 0.4 (image-dominant since the caller chose image-first).
*/
image_query_text_refinement_weight: number;
/**
* D13 image-query refinement RRF weight for the IMAGE branch. Defaults to 0.6.
*/
image_query_image_refinement_weight: number;
/**
* D8 Phase 3 flag: route ALL queries through the multimodal query embed
* + `embedding_multimodal` column. Default false. Operator opt-in after
* `gbrain reindex --multimodal` populates the unified column.
*/
unified_multimodal: boolean;
/**
* D8 Phase 3 strict mode: when true, the dual-column fallback path is
* bypassed entirely. Used by operators who finished re-embedding and
* want to commit to the unified space. Doctor surface errors when this
* is on and coverage < 99%.
*/
unified_multimodal_only: boolean;
/**
* Commit 4: opt-in LLM tie-break for ambiguous modality classification.
* Default false. When true, queries where regex returns 'text' but the
* ambiguity heuristic fires get a Haiku call to refine the classification.
* Fires for <1% of queries when on; ~$0.0001 per escalation.
*/
cross_modal_llm_intent: boolean;
}
/**
@@ -139,6 +190,14 @@ export const MODE_BUNDLES: Readonly<Record<SearchMode, Readonly<ModeBundle>>> =
// v0.35.6.0 — undefined for all three bundles; the per-corpus ablation
// (TODOS.md) gates any default flip.
floor_ratio: undefined,
// v0.36 cross-modal defaults (same across all modes — opt-in)
cross_modal_both_text_weight: 0.6,
cross_modal_both_image_weight: 0.4,
image_query_text_refinement_weight: 0.4,
image_query_image_refinement_weight: 0.6,
unified_multimodal: false,
unified_multimodal_only: false,
cross_modal_llm_intent: false,
}),
balanced: Object.freeze({
cache_enabled: true,
@@ -164,6 +223,14 @@ export const MODE_BUNDLES: Readonly<Record<SearchMode, Readonly<ModeBundle>>> =
// v0.35.6.0 — undefined for all three bundles; the per-corpus ablation
// (TODOS.md) gates any default flip.
floor_ratio: undefined,
// v0.36 cross-modal defaults (same across all modes — opt-in)
cross_modal_both_text_weight: 0.6,
cross_modal_both_image_weight: 0.4,
image_query_text_refinement_weight: 0.4,
image_query_image_refinement_weight: 0.6,
unified_multimodal: false,
unified_multimodal_only: false,
cross_modal_llm_intent: false,
}),
tokenmax: Object.freeze({
cache_enabled: true,
@@ -186,6 +253,14 @@ export const MODE_BUNDLES: Readonly<Record<SearchMode, Readonly<ModeBundle>>> =
// v0.35.6.0 — undefined for all three bundles; the per-corpus ablation
// (TODOS.md) gates any default flip.
floor_ratio: undefined,
// v0.36 cross-modal defaults (same across all modes — opt-in)
cross_modal_both_text_weight: 0.6,
cross_modal_both_image_weight: 0.4,
image_query_text_refinement_weight: 0.4,
image_query_image_refinement_weight: 0.6,
unified_multimodal: false,
unified_multimodal_only: false,
cross_modal_llm_intent: false,
}),
});
@@ -219,6 +294,14 @@ export interface SearchKeyOverrides {
reranker_timeout_ms?: number;
// v0.35.6.0 — floor-ratio gate override.
floor_ratio?: number;
// v0.36 cross-modal overrides
cross_modal_both_text_weight?: number;
cross_modal_both_image_weight?: number;
image_query_text_refinement_weight?: number;
image_query_image_refinement_weight?: number;
unified_multimodal?: boolean;
unified_multimodal_only?: boolean;
cross_modal_llm_intent?: boolean;
}
/**
@@ -244,6 +327,14 @@ export interface SearchPerCallOpts {
reranker_timeout_ms?: number;
// v0.35.6.0 — floor-ratio per-call override.
floor_ratio?: number;
// v0.36 cross-modal per-call overrides
cross_modal_both_text_weight?: number;
cross_modal_both_image_weight?: number;
image_query_text_refinement_weight?: number;
image_query_image_refinement_weight?: number;
unified_multimodal?: boolean;
unified_multimodal_only?: boolean;
cross_modal_llm_intent?: boolean;
}
/**
@@ -304,6 +395,14 @@ export function resolveSearchMode(input: ResolveSearchModeInput): ResolvedSearch
reranker_timeout_ms: pick('reranker_timeout_ms'),
// v0.35.6.0 — floor-ratio resolved via the same pick chain.
floor_ratio: pick('floor_ratio'),
// v0.36 cross-modal knobs
cross_modal_both_text_weight: pick('cross_modal_both_text_weight'),
cross_modal_both_image_weight: pick('cross_modal_both_image_weight'),
image_query_text_refinement_weight: pick('image_query_text_refinement_weight'),
image_query_image_refinement_weight: pick('image_query_image_refinement_weight'),
unified_multimodal: pick('unified_multimodal'),
unified_multimodal_only: pick('unified_multimodal_only'),
cross_modal_llm_intent: pick('cross_modal_llm_intent'),
resolved_mode,
mode_valid: valid,
};
@@ -366,6 +465,12 @@ export function attributeKnob<K extends keyof ModeBundle>(
// row IDs. Expect a temporary hit-rate dip + cache-row doubling for hot
// queries during a rolling deploy. Clears naturally within
// `cache.ttl_seconds` (default 3600s). The CHANGELOG note covers this.
//
// v0.36 wave: cross-modal knobs ALSO participate in v=3 hash (D2 cache
// contamination fix — a text-mode cache hit cannot silently serve an
// image-mode caller). v0.35.6.0's floor_ratio bump and v0.36's cross-modal
// extensions both land under v=3, with cross-modal fields appended after
// the floor_ratio entry (CDX2-F13 append-only convention).
export const KNOBS_HASH_VERSION = 3;
/**
@@ -420,6 +525,17 @@ export function knobsHash(
// — they sit in different vector spaces. ctx is optional so
// unrelated callers fall back to the default-column hash.
`fr=${knobs.floor_ratio === undefined ? 'none' : knobs.floor_ratio.toFixed(4)}`,
// v=3 cross-modal additions (append-only).
`cmbt=${knobs.cross_modal_both_text_weight.toFixed(2)}`,
`cmbi=${knobs.cross_modal_both_image_weight.toFixed(2)}`,
`iqt=${knobs.image_query_text_refinement_weight.toFixed(2)}`,
`iqi=${knobs.image_query_image_refinement_weight.toFixed(2)}`,
`um=${knobs.unified_multimodal ? 1 : 0}`,
`umo=${knobs.unified_multimodal_only ? 1 : 0}`,
`lli=${knobs.cross_modal_llm_intent ? 1 : 0}`,
// v=3 column + provider additions (D8 / CDX-2): cross-column +
// cross-provider cache isolation. A query against `embedding_voyage`
// must never be served from a row that ran against `embedding`.
`col=${ctx?.embeddingColumn ?? 'embedding'}`,
`prov=${ctx?.embeddingModel ?? 'default'}`,
];
@@ -519,6 +635,40 @@ export function loadOverridesFromConfig(
if (Number.isFinite(n) && n >= 0 && n <= 1) out.floor_ratio = n;
}
// v0.36 cross-modal overrides (D3 registry)
const cmbt = get('search.cross_modal.both_mode_text_weight');
if (cmbt !== undefined) {
const n = parseFloat(cmbt);
if (Number.isFinite(n) && n >= 0) out.cross_modal_both_text_weight = n;
}
const cmbi = get('search.cross_modal.both_mode_image_weight');
if (cmbi !== undefined) {
const n = parseFloat(cmbi);
if (Number.isFinite(n) && n >= 0) out.cross_modal_both_image_weight = n;
}
const iqt = get('search.image_query.text_refinement_weight');
if (iqt !== undefined) {
const n = parseFloat(iqt);
if (Number.isFinite(n) && n >= 0) out.image_query_text_refinement_weight = n;
}
const iqi = get('search.image_query.image_refinement_weight');
if (iqi !== undefined) {
const n = parseFloat(iqi);
if (Number.isFinite(n) && n >= 0) out.image_query_image_refinement_weight = n;
}
const um = get('search.unified_multimodal');
if (um !== undefined) {
out.unified_multimodal = um === '1' || um.toLowerCase() === 'true';
}
const umo = get('search.unified_multimodal_only');
if (umo !== undefined) {
out.unified_multimodal_only = umo === '1' || umo.toLowerCase() === 'true';
}
const lli = get('search.cross_modal.llm_intent');
if (lli !== undefined) {
out.cross_modal_llm_intent = lli === '1' || lli.toLowerCase() === 'true';
}
return out;
}
@@ -539,6 +689,14 @@ export const SEARCH_MODE_CONFIG_KEYS: ReadonlyArray<string> = Object.freeze([
'search.reranker.timeout_ms',
// v0.35.6.0 — floor-ratio gate
'search.floor_ratio',
// v0.36 cross-modal keys (D3)
'search.cross_modal.both_mode_text_weight',
'search.cross_modal.both_mode_image_weight',
'search.image_query.text_refinement_weight',
'search.image_query.image_refinement_weight',
'search.unified_multimodal',
'search.unified_multimodal_only',
'search.cross_modal.llm_intent',
]);
/**
+90 -1
View File
@@ -29,6 +29,20 @@ export type QueryIntent = 'entity' | 'temporal' | 'event' | 'general';
export type SalienceMode = 'off' | 'on' | 'strong';
export type RecencyMode = 'off' | 'on' | 'strong';
/**
* v0.36 cross-modal wave: modality axis (D6).
*
* - 'text' (default): existing text-embedding path, no behavior change
* - 'image': route through the multimodal model + embedding_image column
* (visually-similar matching + image OCR text)
* - 'both': run text + image searches in parallel and merge via
* weighted RRF (recall-leaning when the query is ambiguous)
*
* Parallel axis to intent/detail/salience/recency. Returned by
* classifyQuery from one regex pass over the query.
*/
export type ModalityMode = 'text' | 'image' | 'both';
export interface QuerySuggestions {
intent: QueryIntent;
/** v0.29.0 detail mapping. entity→low, temporal/event→high, general→undefined. */
@@ -37,6 +51,8 @@ export interface QuerySuggestions {
suggestedSalience: SalienceMode;
/** v0.29.1 — per-prefix age-decay boost. */
suggestedRecency: RecencyMode;
/** v0.36 — cross-modal routing axis. Defaults to 'text' when nothing matches. */
suggestedModality: ModalityMode;
}
// ─────────────────────────────────────────────────────────
@@ -164,6 +180,46 @@ const SALIENCE_ON_PATTERNS = [
/\bwhat'?s\s+important\b/i,
];
// v0.36 cross-modal wave — modality-axis patterns (D6).
//
// CROSS_MODAL_PATTERNS fires the 'image' modality when the query explicitly
// names visual artifacts ("show me photos", "find images of", "screenshot of",
// "what does X look like", "diagram of"). Module-scope const so regexes
// compile once at module load (D15).
//
// Conservative on purpose — false positives cost "one cheaper image search
// where text might have worked." False negatives cost nothing (the legacy
// text path still runs). The LLM-intent escalation in Commit 4 catches
// genuinely ambiguous phrasings.
const CROSS_MODAL_PATTERNS: RegExp[] = [
/\b(show|find|get|pull)\s+(me\s+)?(the\s+)?(photos?|images?|pictures?|pics?|screenshots?)\b/i,
/\b(photos?|images?|pictures?|pics?|screenshots?)\s+(of|from|at|with|showing|featuring)\b/i,
/\bwhat\s+does\s+[\w\s']{1,40}?\s+look\s+like\b/i,
/\b(whiteboard|diagram|slide|screenshot|infographic|chart)s?\s+(of|from|about|showing)\b/i,
/\bdiagram\s+(of|for|showing)\b/i,
/\bvisual(s|ly)?\s+(of|from|about|showing|representation)\b/i,
];
// v0.36 cross-modal wave (Commit 4 prep): visual nouns that combined with
// ambiguous-pronoun phrasings ("any pics from last week's offsite?") trigger
// the optional LLM intent escalation. Subset of cross-modal patterns plus
// looser noun-form matches.
const AMBIGUOUS_MODALITY_NOUNS: RegExp[] = [
/\b(photo|image|picture|pic|screenshot|diagram|whiteboard|slide|chart)s?\b/i,
/\blook(s|ed)?\s+like\b/i,
/\bvisual(s|ly)?\b/i,
];
// Pronoun + filler markers that signal "the user is referencing something
// they can't quite name" — combined with AMBIGUOUS_MODALITY_NOUNS, triggers
// the LLM tie-break in Commit 4.
const AMBIGUOUS_REFERENCE_MARKERS: RegExp[] = [
// Match all the visual nouns (pic/pics, picture/pictures, photo/photos, image/images,
// screenshot/screenshots, diagram/diagrams, whiteboard/whiteboards, slide/slides, chart/charts).
/\b(any|some|that|those|these|the)\s+(pic|pics|picture|pictures|photo|photos|image|images|screenshot|screenshots|diagram|diagrams|whiteboard|whiteboards|slide|slides|chart|charts)\b/i,
/\bfrom\s+(last|this|the)\s+(week|month|year|offsite|meeting|hackathon|deck)\b/i,
];
// ─────────────────────────────────────────────────────────
// Classifier
// ─────────────────────────────────────────────────────────
@@ -221,7 +277,40 @@ export function classifyQuery(query: string): QuerySuggestions {
suggestedSalience = 'off';
}
return { intent, suggestedDetail, suggestedSalience, suggestedRecency };
// v0.36 cross-modal — modality axis. Independent of intent/detail/salience/recency.
// Conservative default 'text'; only flips to 'image' on explicit cross-modal regex match.
// 'both' is reserved for explicit per-call opts (LLM-intent escalation in Commit 4
// can also produce 'both' via tie-break).
const suggestedModality: ModalityMode = matches(CROSS_MODAL_PATTERNS, query) ? 'image' : 'text';
return { intent, suggestedDetail, suggestedSalience, suggestedRecency, suggestedModality };
}
/**
* v0.36 — heuristic gate for the optional LLM intent escalation (Commit 4).
*
* Fires when the query contains a visual noun ("any pics", "the diagram",
* "what does it look like") combined with an ambiguous reference marker
* ("from last week's offsite"). These are the phrasings the conservative
* regex misses but a Haiku tie-break catches.
*
* Returns false for unambiguous text queries (no LLM call burned). Returns
* false for queries the regex ALREADY caught (no need to tie-break a
* confident classification). Returns true only for the narrow band where
* the LLM call earns its $0.0001 cost.
*
* Pure function. No LLM call. No DB access. Used by hybridSearch's
* escalation branch only when `search.cross_modal.llm_intent: true`.
*/
export function isAmbiguousModalityQuery(query: string): boolean {
// Already-confident classification → no LLM needed.
if (matches(CROSS_MODAL_PATTERNS, query)) return false;
const hasVisualNoun = matches(AMBIGUOUS_MODALITY_NOUNS, query);
if (!hasVisualNoun) return false;
const hasReferenceMarker = matches(AMBIGUOUS_REFERENCE_MARKERS, query);
return hasReferenceMarker;
}
// ─────────────────────────────────────────────────────────
+123
View File
@@ -0,0 +1,123 @@
/**
* v0.36 Phase 2 (D23-#6) — per-OAuth-client paid-API spend tracking.
*
* Backs the daily-budget gate for `search_by_image`. Each successful Voyage
* multimodal call records an entry; before any new call, `checkBudget`
* sums today's spend and rejects when it exceeds the configured cap.
*
* Config: `search.image_query.daily_budget_usd_per_client` (default $5).
*
* Scope: ONLY fires when `ctx.remote === true`. Local CLI callers have
* direct billing visibility through their own credentials and don't need
* the gate. The gate exists to prevent a misbehaving OAuth client from
* burning the brain operator's Voyage account.
*/
import type { BrainEngine } from './engine.ts';
import { sqlQueryForEngine } from './sql-query.ts';
/** Per-call Voyage multimodal-3 spend estimate (per image), in cents. */
export const VOYAGE_MULTIMODAL_3_PER_IMAGE_CENTS = 0.12;
export class BudgetExceededError extends Error {
readonly code = 'BUDGET_EXCEEDED' as const;
readonly spentCents: number;
readonly capCents: number;
constructor(message: string, spentCents: number, capCents: number) {
super(message);
this.name = 'BudgetExceededError';
this.spentCents = spentCents;
this.capCents = capCents;
}
}
/**
* Sum today's recorded spend for a client. Returns 0 if the row count
* is zero (new client) OR if the table doesn't exist (pre-v0.36 brain).
*/
export async function getTodaySpendCents(
engine: BrainEngine,
clientId: string,
): Promise<number> {
try {
const sql = sqlQueryForEngine(engine);
const rows = await sql`
SELECT COALESCE(SUM(spend_cents), 0)::text AS total
FROM mcp_spend_log
WHERE client_id = ${clientId}
AND created_at >= ${todayStartIso()}
`;
const total = parseFloat(String(rows[0]?.total ?? '0'));
return Number.isFinite(total) ? total : 0;
} catch {
// Table doesn't exist (pre-v0.36 brain) or DB hiccup — fail-open to 0.
// The check sees "no spend recorded" and lets the call through. A real
// production brain will have the table once migrations apply.
return 0;
}
}
/**
* Pre-flight budget gate.
*
* Throws `BudgetExceededError` when the client has already spent at or above
* the configured daily cap. Returns silently when there's room.
*
* @param dailyBudgetCents resolved from `search.image_query.daily_budget_usd_per_client`
* × 100 (converted to cents). Operator config; default 500 cents = $5.
*/
export async function checkBudget(
engine: BrainEngine,
clientId: string,
dailyBudgetCents: number,
): Promise<void> {
if (!clientId) return; // local CLI callers (no client_id) bypass the gate
if (dailyBudgetCents <= 0) return; // 0 = "no cap" sentinel
const spent = await getTodaySpendCents(engine, clientId);
if (spent >= dailyBudgetCents) {
throw new BudgetExceededError(
`Daily Voyage spend cap reached: $${(spent / 100).toFixed(2)} >= $${(dailyBudgetCents / 100).toFixed(2)}. ` +
`Reset at midnight UTC.`,
spent,
dailyBudgetCents,
);
}
}
/**
* Record a successful paid call.
*
* Best-effort: writes failures (e.g., table not yet migrated) are swallowed
* with a stderr warning. The search itself succeeded — we don't want to
* fail the user's call because spend telemetry hiccupped.
*/
export async function recordSpend(
engine: BrainEngine,
entry: {
clientId?: string | null;
tokenName?: string | null;
operation: string;
spendCents: number;
provider?: string;
model?: string;
},
): Promise<void> {
try {
const sql = sqlQueryForEngine(engine);
await sql`
INSERT INTO mcp_spend_log (client_id, token_name, operation, spend_cents, provider, model)
VALUES (${entry.clientId ?? null}, ${entry.tokenName ?? null}, ${entry.operation}, ${entry.spendCents}, ${entry.provider ?? null}, ${entry.model ?? null})
`;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[spend-log] failed to record spend: ${msg}`);
}
}
function todayStartIso(): string {
const now = new Date();
// UTC day-start so the cap rolls over deterministically regardless of
// server timezone. Operators reading dashboards see UTC days.
const utcMidnight = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
return utcMidnight.toISOString();
}
+258
View File
@@ -0,0 +1,258 @@
/**
* SSRF validation with DNS resolution — closes the rebinding gap that
* `isInternalUrl` in `src/core/url-safety.ts` leaves open.
*
* url-safety.ts covers static SSRF defense (IPv4-mapped IPv6, hex/octal IP
* forms, IPv6 ULA + link-local, metadata hostnames, CGNAT, scheme allowlist).
* Codex's outside-voice review of the cross-modal wave (D19) flagged the
* remaining gap: an attacker-controlled hostname can resolve to a public IP
* at validation time and a private IP at fetch time (DNS rebinding). The
* defense is: resolve once at validation, inspect every A/AAAA record, and
* fetch by the resolved IP — not the hostname.
*
* This module is consumed by `src/core/search/image-loader.ts` (Phase 2 of
* the cross-modal wave) and is reusable for any future URL-fetching feature.
*
* Two-layer defense per call:
* 1. Static check via `isInternalUrl` — fails fast on obvious internal hosts
* 2. DNS resolve via `dns.lookup({all: true, family: 0})` — fails on any
* resolved A/AAAA record that points internal
*
* The caller fetches using the returned `resolvedIp`, not the original
* hostname, so a second DNS lookup at fetch time can't rebind to internal.
*/
import { lookup as nodeDnsLookup } from 'node:dns/promises';
import { isInternalUrl, isPrivateIpv4, hostnameToOctets } from './url-safety.ts';
// Module-level seam so tests can swap DNS resolution without `mock.module`
// (which is banned in non-serial unit tests per scripts/check-test-isolation.sh R2).
type DnsLookupFn = typeof nodeDnsLookup;
let _dnsLookup: DnsLookupFn = nodeDnsLookup;
/** @internal Test-only — swap the DNS resolver. Restore with `__setDnsLookupForTests(undefined)`. */
export function __setDnsLookupForTests(fn: DnsLookupFn | undefined): void {
_dnsLookup = fn ?? nodeDnsLookup;
}
export interface ResolvedTarget {
/** The URL the caller should fetch — host is replaced with the resolved IP. */
resolvedUrl: string;
/** The IP address resolved from the original hostname. */
resolvedIp: string;
/** The original hostname (for Host: header). Empty when input was already an IP literal. */
originalHost: string;
/** Whether the resolved IP is IPv6 — affects URL bracket encoding. */
ipv6: boolean;
}
export class SSRFError extends Error {
readonly code: SSRFErrorCode;
constructor(code: SSRFErrorCode, message: string) {
super(message);
this.name = 'SSRFError';
this.code = code;
}
}
export type SSRFErrorCode =
| 'INTERNAL_HOST'
| 'INVALID_URL'
| 'INVALID_SCHEME'
| 'CREDENTIALS_IN_URL'
| 'DNS_RESOLUTION_FAILED'
| 'DNS_RESOLVED_INTERNAL'
| 'SSRF_REDIRECT_DENIED'
| 'SSRF_HOP_LIMIT';
/**
* Validate a URL against SSRF policy and resolve its hostname to an IP.
*
* Returns a `ResolvedTarget` the caller should use for the actual fetch.
* Throws `SSRFError` on any policy violation.
*
* Defends against:
* - Static internal targets (RFC1918, loopback, link-local, ULA, metadata hostnames, CGNAT)
* - Non-http(s) schemes
* - Credentials embedded in URL (`http://user:pass@host/`)
* - DNS rebinding (resolves all records, blocks if any are internal)
* - Non-resolving hosts (caller can't fetch them anyway)
*/
export async function validateAndResolveUrl(urlStr: string): Promise<ResolvedTarget> {
let url: URL;
try {
url = new URL(urlStr);
} catch {
throw new SSRFError('INVALID_URL', `Malformed URL: ${truncate(urlStr)}`);
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new SSRFError('INVALID_SCHEME', `Unsupported scheme ${url.protocol}; only http(s) allowed`);
}
if (url.username || url.password) {
throw new SSRFError('CREDENTIALS_IN_URL', 'Credentials embedded in URL are not permitted');
}
// Layer 1: static check covers IPv4 hex/octal/single-int, IPv6 ULA + link-local,
// metadata hostnames, CGNAT, IPv4-mapped IPv6.
if (isInternalUrl(urlStr)) {
throw new SSRFError('INTERNAL_HOST', `URL targets internal/private network: ${truncate(urlStr)}`);
}
let host = url.hostname;
if (host.startsWith('[') && host.endsWith(']')) host = host.slice(1, -1);
// If the host is already an IP literal, isInternalUrl already validated it.
// Skip DNS lookup and return the literal as-is.
if (isIpLiteral(host)) {
return {
resolvedUrl: urlStr,
resolvedIp: host,
originalHost: '',
ipv6: host.includes(':'),
};
}
// Layer 2: DNS resolution. {all: true, family: 0} returns every A AND AAAA
// record. If ANY record points internal, reject.
let addrs: Array<{ address: string; family: number }>;
try {
addrs = await _dnsLookup(host, { all: true, family: 0 });
} catch (err) {
throw new SSRFError(
'DNS_RESOLUTION_FAILED',
`Failed to resolve ${host}: ${err instanceof Error ? err.message : String(err)}`,
);
}
if (addrs.length === 0) {
throw new SSRFError('DNS_RESOLUTION_FAILED', `No DNS records for ${host}`);
}
for (const a of addrs) {
if (isAddressInternal(a.address, a.family)) {
throw new SSRFError(
'DNS_RESOLVED_INTERNAL',
`${host} resolves to internal address ${a.address} (DNS rebinding attempt?)`,
);
}
}
// Pick the first resolved address (system-ordered: typically the preferred
// family). Caller fetches by this IP so a second DNS lookup can't rebind.
const chosen = addrs[0];
const isV6 = chosen.family === 6;
const hostInUrl = isV6 ? `[${chosen.address}]` : chosen.address;
// Rebuild URL with the resolved host. Preserve the original `host` for the
// Host: header (caller can set it explicitly when fetching).
const rebuilt = new URL(urlStr);
rebuilt.hostname = hostInUrl;
return {
resolvedUrl: rebuilt.toString(),
resolvedIp: chosen.address,
originalHost: host,
ipv6: isV6,
};
}
function isIpLiteral(host: string): boolean {
if (host.includes(':')) return true; // IPv6 literal (already bracket-stripped)
return hostnameToOctets(host) !== null;
}
function isAddressInternal(addr: string, family: number): boolean {
if (family === 4) {
const octets = hostnameToOctets(addr);
return octets ? isPrivateIpv4(octets) : true; // fail-closed on parse failure
}
if (family === 6) {
const lower = addr.toLowerCase();
if (lower === '::1' || lower === '::') return true;
if (/^f[cd][0-9a-f]{2}:/.test(lower)) return true; // ULA fc00::/7
if (/^fe[89ab][0-9a-f]:/.test(lower)) return true; // link-local fe80::/10
if (lower.startsWith('::ffff:')) {
const tail = lower.slice(7);
const dotted = hostnameToOctets(tail);
if (dotted && isPrivateIpv4(dotted)) return true;
}
return false;
}
return true; // unknown family — fail-closed
}
/**
* Fetch a URL with full SSRF protection including per-redirect-hop validation.
*
* On every Location response header, the new URL is re-validated via
* `validateAndResolveUrl` — fresh DNS resolution per hop defeats rebinding
* across the redirect chain. Max 3 hops by default.
*
* Returns the final Response. Caller is responsible for body size limits
* (use `init.signal` to abort, or check `Content-Length` before consuming).
*/
export async function fetchWithSSRFGuard(
urlStr: string,
init: RequestInit & {
maxRedirects?: number;
timeoutMs?: number;
} = {},
): Promise<Response> {
const maxRedirects = init.maxRedirects ?? 3;
const timeoutMs = init.timeoutMs ?? 5000;
const controller = new AbortController();
const externalSignal = init.signal;
const onAbort = () => controller.abort();
if (externalSignal) {
if (externalSignal.aborted) controller.abort();
else externalSignal.addEventListener('abort', onAbort, { once: true });
}
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
let currentUrl = urlStr;
let hops = 0;
while (true) {
const target = await validateAndResolveUrl(currentUrl);
const fetchInit: RequestInit = {
...init,
redirect: 'manual',
signal: controller.signal,
};
// Set Host header to the original hostname so SNI/TLS works correctly
// (we're fetching by resolved IP but the server expects the real host).
const headers = new Headers(init.headers || {});
if (target.originalHost) {
headers.set('Host', target.originalHost);
}
fetchInit.headers = headers;
const res = await fetch(target.resolvedUrl, fetchInit);
// Redirect status codes
if ([301, 302, 303, 307, 308].includes(res.status)) {
if (hops >= maxRedirects) {
throw new SSRFError('SSRF_HOP_LIMIT', `Exceeded ${maxRedirects} redirect hops`);
}
const location = res.headers.get('location');
if (!location) {
return res; // redirect with no Location — return as-is, caller decides
}
// Resolve relative location against current URL
const next = new URL(location, currentUrl).toString();
currentUrl = next;
hops++;
continue;
}
return res;
}
} finally {
clearTimeout(timer);
if (externalSignal) externalSignal.removeEventListener('abort', onAbort);
}
}
function truncate(s: string, n = 200): string {
return s.length > n ? s.slice(0, n) + '...' : s;
}
+40 -28
View File
@@ -404,6 +404,15 @@ export interface SearchResult {
chunk_index: number;
score: number;
stale: boolean;
/**
* v0.36 (cross-modal wave): the chunk's modality discriminator from
* content_chunks.modality. 'text' for the existing text-embedding rows,
* 'image' for rows populated by importImageFile. Surfaced so callers /
* renderers can distinguish text matches from image matches in `'both'`
* mode results. Optional for back-compat with engines that don't project
* the column (defaults to 'text' in renderers when absent).
*/
modality?: 'text' | 'image';
/**
* v0.18.0: the sources.id the page belongs to. Dedup composite-keys
* on (source_id, slug) — see src/core/search/dedup.ts. Defaults to
@@ -541,8 +550,10 @@ export interface SearchOpts {
* 1. String name (legacy + user-facing). Engine and hybridSearch convert
* to ResolvedColumn at the boundary via `resolveEmbeddingColumn()`.
* Built-in names: 'embedding' (default, text), 'embedding_image'
* (multimodal). Custom user-declared names also accepted when
* registered in `embedding_columns` config.
* (multimodal). v0.36 cross-modal wave adds 'embedding_multimodal'
* (unified column populated by `gbrain reindex --multimodal`). Custom
* user-declared names also accepted when registered in
* `embedding_columns` config.
*
* 2. ResolvedColumn descriptor (internal). The engine ONLY accepts
* this shape — hybridSearch resolves once at entry and passes the
@@ -556,7 +567,7 @@ export interface SearchOpts {
* searchKeyword is unaffected — modality filtering on the keyword path
* is independent.
*/
embeddingColumn?: 'embedding' | 'embedding_image' | string | ResolvedColumn;
embeddingColumn?: 'embedding' | 'embedding_image' | 'embedding_multimodal' | string | ResolvedColumn;
/**
* @deprecated v0.29.1: use `since` instead. Removed in v0.30.
* v0.27.0: filter results to pages updated/created after this date. ISO-8601 string.
@@ -636,33 +647,34 @@ export interface SearchOpts {
rerankerFn?: (input: { query: string; documents: string[]; topN?: number; model?: string; signal?: AbortSignal; timeoutMs?: number }) => Promise<{ index: number; relevanceScore: number }[]>;
};
/**
* v0.35.6.0 — floor-ratio gate for metadata-axis boost stages (backlink,
* salience, recency). Number in [0, 1] or undefined (default = no gate).
*
* When set, each gated stage skips results whose pre-boost score is below
* `floorRatio * topScore`, where `topScore` is computed ONCE at
* `runPostFusionStages` entry from the post-cosine-rescore snapshot. The
* same threshold gates all three stages — order-independent semantic.
*
* Resolution chain (mirrors other search-lite knobs):
* per-call `SearchOpts.floorRatio` → config `search.floor_ratio`
* → MODE_BUNDLES[mode].floor_ratio (undefined for all 3 modes today)
* → undefined fallback.
*
* SCOPE: gates ONLY the three metadata stages. Exact-match boost
* (`applyExactMatchBoost` in intent-weights.ts) runs independently as a
* lexical-relevance signal and is NOT gated by design.
*
* Sensible operator override values for dense-embedder corpora: 0.85-0.95.
* Default stays undefined pending per-corpus ablation evidence (see
* `TODOS.md` floor-ratio ablation entry).
*
* Out-of-range values (negative, > 1, NaN, Infinity) silently disable
* the gate at the runtime layer; the config-parse layer also rejects
* out-of-range values. Defense in depth — a malformed value never
* gates anything.
* v0.35.6.0 — floor-ratio gate for metadata-axis boost stages.
* Number in [0, 1] or undefined (default = no gate). When set, each gated
* stage skips results whose pre-boost score is below `floorRatio * topScore`.
* Same threshold gates all three metadata stages; exact-match boost runs
* independently. Out-of-range values silently disable the gate.
* Sensible operator overrides for dense-embedder corpora: 0.85-0.95.
*/
floorRatio?: number;
/**
* v0.36 cross-modal wave: route this search through the multimodal
* embedding space (Voyage multimodal-3 by default).
*
* - 'text' (default for queries that don't match image-intent regex):
* existing text-embedding path. No behavior change vs pre-v0.36.
* - 'image': force routing through the multimodal model + embedding_image
* column. Skip LLM expansion (image embeddings handle synonyms in-space)
* and skip keyword search (no FTS index on image content).
* - 'both': run text and image vector searches in parallel; merge via
* modality-weighted RRF.
* - 'auto' (literal): same effect as undefined — let intent classifier
* decide. Accepted on the wire so MCP callers can be explicit.
*
* Cross-modal override matrix (D9): when effective modality is 'image',
* cross-modal path overrides expansion (false) and reranker (false)
* regardless of mode bundle. zerank-2 can't rerank image embeddings;
* sending them produces garbage scores.
*/
crossModal?: 'text' | 'image' | 'both' | 'auto';
}
/**
+4 -1
View File
@@ -155,7 +155,10 @@ CREATE TABLE IF NOT EXISTS content_chunks (
-- mixed-provider brains (e.g. OpenAI 1536 text + Voyage 1024 images) keep
-- both columns populated with distinct dim spaces.
modality TEXT NOT NULL DEFAULT 'text',
embedding_image vector(1024)
embedding_image vector(1024),
-- v0.36 Phase 3 cross-modal: unified column populated by reindex.
-- Migration v75 also adds it for upgrade paths.
embedding_multimodal vector(1024)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_chunks_page_index ON content_chunks(page_id, chunk_index);
+220
View File
@@ -0,0 +1,220 @@
// Phase 1 integration test — hybridSearch cross-modal routing.
//
// Uses real PGLite + stubbed gateway fetch. Verifies the routing decisions
// from query-intent through hybrid.ts to engine.searchVector with the
// correct embeddingColumn.
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
import {
configureGateway,
resetGateway,
} from '../src/core/ai/gateway.ts';
import { hybridSearch } from '../src/core/search/hybrid.ts';
let engine: PGLiteEngine;
type FetchHandler = (url: string, init: RequestInit) => Promise<Response>;
let fetchHandler: FetchHandler | null = null;
const origFetch = globalThis.fetch;
let fetchUrlsSeen: string[] = [];
let fetchBodiesSeen: any[] = [];
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
fetchHandler = null;
fetchUrlsSeen = [];
fetchBodiesSeen = [];
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
const u = typeof url === 'string' ? url : url.toString();
fetchUrlsSeen.push(u);
if (init?.body) {
try { fetchBodiesSeen.push(JSON.parse(init.body as string)); } catch { /* ignore */ }
}
if (!fetchHandler) {
// Return a generic 1024-dim Voyage-shape response by default
return new Response(JSON.stringify({
data: [{ embedding: Array.from({ length: 1024 }, () => 0.1), index: 0 }],
model: 'voyage-multimodal-3',
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
return fetchHandler(u, init ?? {});
}) as typeof fetch;
});
afterEach(() => {
globalThis.fetch = origFetch;
resetGateway();
});
function configureBoth() {
// Gateway needs BOTH text and multimodal models configured. Use a single
// openai recipe stub for text — we won't hit it for image-only queries.
configureGateway({
embedding_model: 'openai:text-embedding-3-large',
embedding_dimensions: 1536,
embedding_multimodal_model: 'voyage:voyage-multimodal-3',
env: {
OPENAI_API_KEY: 'test-key',
VOYAGE_API_KEY: 'voyage-test-key',
},
});
}
describe('hybridSearch cross-modal routing (Phase 1 integration)', () => {
test("explicit crossModal: 'image' calls Voyage multimodal endpoint, NOT OpenAI", async () => {
configureBoth();
// Stub the Voyage multimodal endpoint with a deterministic 1024d vector.
fetchHandler = async (url) => {
if (url.includes('multimodalembeddings')) {
return new Response(JSON.stringify({
data: [{ embedding: Array.from({ length: 1024 }, () => 0.5), index: 0 }],
model: 'voyage-multimodal-3',
}), { status: 200 });
}
// Fail OpenAI requests loudly so we catch wrong routing.
throw new Error(`Unexpected fetch to OpenAI: ${url}`);
};
// hybridSearch with no rows in DB just returns []; we're testing that the
// request hits the multimodal endpoint specifically.
const results = await hybridSearch(engine, 'hackathon stuff', { crossModal: 'image', limit: 5 });
expect(Array.isArray(results)).toBe(true);
// Must have called the multimodal endpoint at least once.
expect(fetchUrlsSeen.some(u => u.includes('multimodalembeddings'))).toBe(true);
// Must NOT have called OpenAI embeddings.
expect(fetchUrlsSeen.some(u => u.includes('api.openai.com') && u.includes('embeddings'))).toBe(false);
});
test('explicit crossModal: "image" threads inputType=query in Voyage body (D22-2)', async () => {
configureBoth();
fetchHandler = async (url) => {
if (url.includes('multimodalembeddings')) {
return new Response(JSON.stringify({
data: [{ embedding: Array.from({ length: 1024 }, () => 0.5), index: 0 }],
model: 'voyage-multimodal-3',
}), { status: 200 });
}
throw new Error(`Unexpected fetch: ${url}`);
};
await hybridSearch(engine, 'any text', { crossModal: 'image', limit: 5 });
const voyageBody = fetchBodiesSeen.find(b => b?.inputs?.[0]?.content?.[0]?.type === 'text');
expect(voyageBody).toBeDefined();
expect(voyageBody.input_type).toBe('query');
});
test('default crossModal=text query does NOT call Voyage multimodal', async () => {
configureBoth();
// Allow text embed to succeed via the default OpenAI fetch handler.
fetchHandler = async (url) => {
if (url.includes('multimodalembeddings')) {
throw new Error('Unexpected multimodal call for text-modality query');
}
// OpenAI text-embedding response shape: {data: [{embedding: [...]}]}
return new Response(JSON.stringify({
data: [{ embedding: Array.from({ length: 1536 }, () => 0.1), index: 0 }],
model: 'text-embedding-3-large',
}), { status: 200 });
};
await hybridSearch(engine, 'what is founder mode', { limit: 5 });
expect(fetchUrlsSeen.some(u => u.includes('multimodalembeddings'))).toBe(false);
});
test("'auto' literal normalizes to undefined (D22-1) — text query still routes text", async () => {
configureBoth();
fetchHandler = async (url) => {
if (url.includes('multimodalembeddings')) {
throw new Error('Unexpected multimodal call for auto-text-intent query');
}
return new Response(JSON.stringify({
data: [{ embedding: Array.from({ length: 1536 }, () => 0.1), index: 0 }],
model: 'text-embedding-3-large',
}), { status: 200 });
};
await hybridSearch(engine, 'what is founder mode', { crossModal: 'auto', limit: 5 });
// Text route — multimodal never called.
expect(fetchUrlsSeen.some(u => u.includes('multimodalembeddings'))).toBe(false);
});
test('"show me photos from the hackathon" auto-detects to image routing', async () => {
configureBoth();
fetchHandler = async (url) => {
if (url.includes('multimodalembeddings')) {
return new Response(JSON.stringify({
data: [{ embedding: Array.from({ length: 1024 }, () => 0.3), index: 0 }],
model: 'voyage-multimodal-3',
}), { status: 200 });
}
// Don't fail OpenAI here — auto mode might still call text in 'both' fallback.
return new Response(JSON.stringify({
data: [{ embedding: Array.from({ length: 1536 }, () => 0.1), index: 0 }],
model: 'text-embedding-3-large',
}), { status: 200 });
};
await hybridSearch(engine, 'show me photos from the hackathon', { limit: 5 });
// Auto-detection should have fired image routing.
expect(fetchUrlsSeen.some(u => u.includes('multimodalembeddings'))).toBe(true);
});
test("'both' mode hits BOTH endpoints in parallel", async () => {
configureBoth();
let textCalled = 0;
let voyageCalled = 0;
fetchHandler = async (url) => {
if (url.includes('multimodalembeddings')) {
voyageCalled++;
return new Response(JSON.stringify({
data: [{ embedding: Array.from({ length: 1024 }, () => 0.3), index: 0 }],
model: 'voyage-multimodal-3',
}), { status: 200 });
}
textCalled++;
return new Response(JSON.stringify({
data: [{ embedding: Array.from({ length: 1536 }, () => 0.1), index: 0 }],
model: 'text-embedding-3-large',
}), { status: 200 });
};
await hybridSearch(engine, 'anything', { crossModal: 'both', limit: 5 });
expect(textCalled).toBeGreaterThanOrEqual(1);
expect(voyageCalled).toBeGreaterThanOrEqual(1);
});
test('fail-open: multimodal unconfigured → image-intent query falls back to text', async () => {
configureGateway({
// No embedding_multimodal_model set.
embedding_model: 'openai:text-embedding-3-large',
embedding_dimensions: 1536,
env: { OPENAI_API_KEY: 'test-key' },
});
fetchHandler = async (url) => {
if (url.includes('multimodalembeddings')) {
throw new Error('Voyage should not be called when not configured');
}
return new Response(JSON.stringify({
data: [{ embedding: Array.from({ length: 1536 }, () => 0.1), index: 0 }],
model: 'text-embedding-3-large',
}), { status: 200 });
};
// crossModal: 'image' with no multimodal model → fail-open to text.
const results = await hybridSearch(engine, 'show me photos', { crossModal: 'image', limit: 5 });
expect(Array.isArray(results)).toBe(true);
// Did NOT throw; fell back successfully.
});
});
+233
View File
@@ -0,0 +1,233 @@
// Commit 1 (Phase 1): cross-modal intent + hybrid routing + knobsHash + RRF.
//
// Covers:
// - suggestedModality regex matches (positive + negative + plural-safe)
// - isAmbiguousModalityQuery heuristic
// - SEARCH_MODE_CONFIG_KEYS registry includes new keys (D3)
// - knobsHash differs across cross-modal knob values (D2)
// - knobsHash version bumped to 3
// - MODE_BUNDLES carry cross-modal defaults
import { describe, expect, test } from 'bun:test';
import {
classifyQuery,
isAmbiguousModalityQuery,
type ModalityMode,
} from '../src/core/search/query-intent.ts';
import {
KNOBS_HASH_VERSION,
MODE_BUNDLES,
SEARCH_MODE_CONFIG_KEYS,
knobsHash,
resolveSearchMode,
type ResolvedSearchKnobs,
} from '../src/core/search/mode.ts';
describe('query-intent — suggestedModality regex (D6 + D14)', () => {
test('"show me photos from the hackathon" → image', () => {
expect(classifyQuery('show me photos from the hackathon').suggestedModality).toBe('image');
});
test('"what is founder mode?" → text (default)', () => {
expect(classifyQuery('what is founder mode?').suggestedModality).toBe('text');
});
const imagePhrasings: Array<[string, ModalityMode]> = [
['find images from last week', 'image'],
['find me images of acme', 'image'],
['what does the OG photo look like', 'image'],
['screenshot of the dashboard', 'image'],
['diagram of the architecture', 'image'],
['visuals showing the trends', 'image'],
['whiteboard from the offsite', 'image'],
['pictures of the team', 'image'],
['pull me the screenshots', 'image'],
];
for (const [query, expected] of imagePhrasings) {
test(`image phrasing: "${query}" → ${expected}`, () => {
expect(classifyQuery(query).suggestedModality).toBe(expected);
});
}
const textPhrasings = [
'who is acme corp',
'tell me about founder mode',
'what happened at the hackathon',
'meeting notes from yesterday',
'most recent take on AI',
];
for (const query of textPhrasings) {
test(`text phrasing: "${query}" → text`, () => {
expect(classifyQuery(query).suggestedModality).toBe('text');
});
}
});
describe('isAmbiguousModalityQuery (Commit 4 prep)', () => {
// Genuinely ambiguous = visual noun present + reference marker present BUT
// CROSS_MODAL_PATTERNS doesn't catch it (otherwise regex already classified
// confidently and the LLM call would be wasted).
test('"any picture during last week" → ambiguous', () => {
// "picture during" doesn't match (of|from|at|with|...) so CROSS_MODAL
// doesn't fire; "any pictures" does match the AMBIGUOUS_REFERENCE marker.
// Actually "any picture" matches /\b(any|some|...)\s+(pics?|photos?|images?...)/ — but
// the CROSS_MODAL pattern needs "pictures from/of/at/...". This phrasing
// has neither — so it's genuinely ambiguous.
expect(isAmbiguousModalityQuery('any picture during last week')).toBe(true);
});
test('"what is founder mode" → not ambiguous (plain text query)', () => {
expect(isAmbiguousModalityQuery('what is founder mode')).toBe(false);
});
test('"show me photos of acme" → not ambiguous (regex catches it)', () => {
// Already-confident classification, no LLM needed.
expect(isAmbiguousModalityQuery('show me photos of acme')).toBe(false);
});
test('"any pictures from the meeting" → not ambiguous (regex catches "pictures from")', () => {
// CROSS_MODAL fires on "pictures from" — confident classification.
expect(isAmbiguousModalityQuery('any pictures from the meeting')).toBe(false);
});
test('"chart" without article/determiner → not ambiguous (bare visual noun has no reference marker)', () => {
// No "any|some|that|the" determiner in front of the visual noun, and no
// "from last/this/the X" phrase — pure text query.
expect(isAmbiguousModalityQuery('chart')).toBe(false);
});
test('"the chart" alone → ambiguous (determiner+visual-noun is a real reference marker)', () => {
// "the chart" is the canonical ambiguous case — user references a
// specific visual asset without confirming they want image search.
// LLM tie-break decides.
expect(isAmbiguousModalityQuery('the chart')).toBe(true);
});
test('"the diagram in last week\'s deck" → ambiguous', () => {
// "diagram in" doesn't match CROSS_MODAL (of|from|about|showing only).
// "the diagram" matches AMBIGUOUS_REFERENCE first pattern.
expect(isAmbiguousModalityQuery("the diagram in last week's deck")).toBe(true);
});
});
describe('D3 — SEARCH_MODE_CONFIG_KEYS registry includes cross-modal keys', () => {
const expected = [
'search.cross_modal.both_mode_text_weight',
'search.cross_modal.both_mode_image_weight',
'search.image_query.text_refinement_weight',
'search.image_query.image_refinement_weight',
'search.unified_multimodal',
'search.unified_multimodal_only',
'search.cross_modal.llm_intent',
];
for (const key of expected) {
test(`registry contains ${key}`, () => {
expect(SEARCH_MODE_CONFIG_KEYS).toContain(key);
});
}
});
describe('D2 — knobsHash differs across cross-modal knob values', () => {
function baseKnobs(): ResolvedSearchKnobs {
return resolveSearchMode({ mode: 'balanced' });
}
test('KNOBS_HASH_VERSION is 3 (v0.36 cross-modal bump)', () => {
expect(KNOBS_HASH_VERSION).toBe(3);
});
test('flipping unified_multimodal changes the hash', () => {
const k1 = baseKnobs();
const k2 = { ...k1, unified_multimodal: true };
expect(knobsHash(k1)).not.toBe(knobsHash(k2));
});
test('flipping unified_multimodal_only changes the hash', () => {
const k1 = baseKnobs();
const k2 = { ...k1, unified_multimodal_only: true };
expect(knobsHash(k1)).not.toBe(knobsHash(k2));
});
test('flipping cross_modal_llm_intent changes the hash', () => {
const k1 = baseKnobs();
const k2 = { ...k1, cross_modal_llm_intent: true };
expect(knobsHash(k1)).not.toBe(knobsHash(k2));
});
test('changing cross_modal_both_text_weight changes the hash', () => {
const k1 = baseKnobs();
const k2 = { ...k1, cross_modal_both_text_weight: 0.5 };
expect(knobsHash(k1)).not.toBe(knobsHash(k2));
});
test('changing image_query_text_refinement_weight changes the hash', () => {
const k1 = baseKnobs();
const k2 = { ...k1, image_query_text_refinement_weight: 0.7 };
expect(knobsHash(k1)).not.toBe(knobsHash(k2));
});
test('identical knobs produce identical hashes (regression sanity)', () => {
expect(knobsHash(baseKnobs())).toBe(knobsHash(baseKnobs()));
});
});
describe('D6 — MODE_BUNDLES carry cross-modal defaults', () => {
test('all three modes default cross_modal_both_text_weight to 0.6', () => {
expect(MODE_BUNDLES.conservative.cross_modal_both_text_weight).toBe(0.6);
expect(MODE_BUNDLES.balanced.cross_modal_both_text_weight).toBe(0.6);
expect(MODE_BUNDLES.tokenmax.cross_modal_both_text_weight).toBe(0.6);
});
test('all three modes default cross_modal_both_image_weight to 0.4', () => {
expect(MODE_BUNDLES.conservative.cross_modal_both_image_weight).toBe(0.4);
expect(MODE_BUNDLES.balanced.cross_modal_both_image_weight).toBe(0.4);
expect(MODE_BUNDLES.tokenmax.cross_modal_both_image_weight).toBe(0.4);
});
test('all three modes default image_query weights (D13: 0.4 text / 0.6 image)', () => {
expect(MODE_BUNDLES.conservative.image_query_text_refinement_weight).toBe(0.4);
expect(MODE_BUNDLES.conservative.image_query_image_refinement_weight).toBe(0.6);
expect(MODE_BUNDLES.tokenmax.image_query_image_refinement_weight).toBe(0.6);
});
test('all three modes default unified_multimodal to false (opt-in)', () => {
expect(MODE_BUNDLES.conservative.unified_multimodal).toBe(false);
expect(MODE_BUNDLES.balanced.unified_multimodal).toBe(false);
expect(MODE_BUNDLES.tokenmax.unified_multimodal).toBe(false);
});
test('all three modes default cross_modal_llm_intent to false (opt-in)', () => {
expect(MODE_BUNDLES.conservative.cross_modal_llm_intent).toBe(false);
expect(MODE_BUNDLES.balanced.cross_modal_llm_intent).toBe(false);
expect(MODE_BUNDLES.tokenmax.cross_modal_llm_intent).toBe(false);
});
});
describe('resolveSearchMode threads cross-modal overrides', () => {
test('per-call override beats config override beats mode default', () => {
const k = resolveSearchMode({
mode: 'balanced',
overrides: { cross_modal_both_text_weight: 0.5 },
perCall: { cross_modal_both_text_weight: 0.8 },
});
expect(k.cross_modal_both_text_weight).toBe(0.8);
});
test('config override wins when no per-call override', () => {
const k = resolveSearchMode({
mode: 'balanced',
overrides: { unified_multimodal: true },
});
expect(k.unified_multimodal).toBe(true);
});
test('mode default fires when neither override is set', () => {
const k = resolveSearchMode({ mode: 'balanced' });
expect(k.cross_modal_both_text_weight).toBe(0.6);
expect(k.cross_modal_both_image_weight).toBe(0.4);
});
});
+181
View File
@@ -0,0 +1,181 @@
// Commit 2 (Phase 2): image-as-query loader + searchByImage + D18 path ban
//
// Covers:
// - loadImageInput: PNG/JPEG/WebP magic-byte sniff + format rejection
// - loadImageInput: oversized file rejection (local + remote caps)
// - loadImageInput: data: URI parsing
// - loadImageInput: invalid input shapes
// - D11 SSRF in fetchWithSSRFGuard (already covered by ssrf-validate.test.ts)
// - D18 search_by_image rejects image_path when ctx.remote=true
// - D12 image_data param-level size cap (validateParams gate)
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
ImageLoadError,
loadImageInput,
} from '../src/core/search/image-loader.ts';
import { __setDnsLookupForTests } from '../src/core/ssrf-validate.ts';
let tmpRoot: string;
beforeEach(() => {
tmpRoot = mkdtempSync(join(tmpdir(), 'gbrain-img-loader-'));
});
afterEach(() => {
__setDnsLookupForTests(undefined);
});
// PNG magic bytes for a 1x1 transparent PNG.
const PNG_BYTES = Buffer.from([
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
// Minimal IHDR + IDAT + IEND chunks
0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0,
31, 21, 196, 137, 0, 0, 0, 12, 73, 68, 65, 84, 8, 87, 99, 248, 207, 192, 0, 0, 0, 3, 0, 1,
90, 12, 105, 240, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130,
]);
// JPEG magic: FF D8 FF + dummy
const JPEG_BYTES = Buffer.concat([Buffer.from([0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10]), Buffer.alloc(100)]);
// WebP magic: RIFF????WEBP
const WEBP_BYTES = Buffer.concat([
Buffer.from('RIFF'),
Buffer.from([0x40, 0x00, 0x00, 0x00]),
Buffer.from('WEBP'),
Buffer.alloc(100),
]);
describe('loadImageInput — local path', () => {
test('loads a PNG file and sniffs MIME', async () => {
const path = join(tmpRoot, 'test.png');
writeFileSync(path, PNG_BYTES);
const result = await loadImageInput(path);
expect(result.contentType).toBe('image/png');
expect(result.bytes.length).toBe(PNG_BYTES.length);
expect(result.base64).toBe(PNG_BYTES.toString('base64'));
});
test('loads a JPEG file and sniffs MIME', async () => {
const path = join(tmpRoot, 'test.jpg');
writeFileSync(path, JPEG_BYTES);
const result = await loadImageInput(path);
expect(result.contentType).toBe('image/jpeg');
});
test('loads a WebP file and sniffs MIME', async () => {
const path = join(tmpRoot, 'test.webp');
writeFileSync(path, WEBP_BYTES);
const result = await loadImageInput(path);
expect(result.contentType).toBe('image/webp');
});
test('rejects unsupported format (GIF)', async () => {
const path = join(tmpRoot, 'test.gif');
writeFileSync(path, Buffer.from('GIF89a' + 'x'.repeat(100)));
const err = await loadImageInput(path).catch(e => e);
expect(err).toBeInstanceOf(ImageLoadError);
expect(err.code).toBe('INVALID_FORMAT');
});
test('rejects oversized file (default 10MB cap)', async () => {
const path = join(tmpRoot, 'huge.png');
// 11MB file with PNG magic bytes
writeFileSync(path, Buffer.concat([PNG_BYTES, Buffer.alloc(11 * 1024 * 1024)]));
const err = await loadImageInput(path).catch(e => e);
expect(err).toBeInstanceOf(ImageLoadError);
expect(err.code).toBe('OVERSIZED');
});
test('rejects file via custom (tighter) maxBytes', async () => {
const path = join(tmpRoot, 'medium.png');
writeFileSync(path, Buffer.concat([PNG_BYTES, Buffer.alloc(1024 * 1024)])); // 1MB
const err = await loadImageInput(path, { maxBytes: 500 * 1024 }).catch(e => e);
expect(err).toBeInstanceOf(ImageLoadError);
expect(err.code).toBe('OVERSIZED');
});
test('NOT_FOUND on nonexistent path', async () => {
const err = await loadImageInput(join(tmpRoot, 'missing.png')).catch(e => e);
expect(err).toBeInstanceOf(ImageLoadError);
expect(err.code).toBe('NOT_FOUND');
});
});
describe('loadImageInput — data: URI', () => {
test('decodes PNG data: URI', async () => {
const dataUri = `data:image/png;base64,${PNG_BYTES.toString('base64')}`;
const result = await loadImageInput(dataUri);
expect(result.contentType).toBe('image/png');
expect(result.bytes.length).toBe(PNG_BYTES.length);
});
test('rejects malformed data: URI', async () => {
const err = await loadImageInput('data:image/png;invalid-format').catch(e => e);
expect(err).toBeInstanceOf(ImageLoadError);
expect(err.code).toBe('INVALID_FORMAT');
});
test('rejects data: URI with non-image format (decoded GIF bytes)', async () => {
const gifBytes = Buffer.from('GIF89a' + 'x'.repeat(100));
const dataUri = `data:image/png;base64,${gifBytes.toString('base64')}`;
const err = await loadImageInput(dataUri).catch(e => e);
expect(err).toBeInstanceOf(ImageLoadError);
expect(err.code).toBe('INVALID_FORMAT');
});
test('rejects oversized data: URI', async () => {
const huge = Buffer.concat([PNG_BYTES, Buffer.alloc(11 * 1024 * 1024)]);
const dataUri = `data:image/png;base64,${huge.toString('base64')}`;
const err = await loadImageInput(dataUri).catch(e => e);
expect(err).toBeInstanceOf(ImageLoadError);
expect(err.code).toBe('OVERSIZED');
});
});
describe('loadImageInput — invalid input shapes', () => {
test('rejects empty string', async () => {
const err = await loadImageInput('').catch(e => e);
expect(err).toBeInstanceOf(ImageLoadError);
expect(err.code).toBe('INVALID_URL');
});
test('rejects unsupported scheme', async () => {
const err = await loadImageInput('ftp://example.com/img.png').catch(e => e);
expect(err).toBeInstanceOf(ImageLoadError);
expect(err.code).toBe('INVALID_URL');
});
});
describe('loadImageInput — http(s) URL with SSRF defense', () => {
let stubAddrs: Map<string, Array<{ address: string; family: number }>>;
beforeEach(() => {
stubAddrs = new Map();
__setDnsLookupForTests((async (host: string) => {
const recs = stubAddrs.get(host);
if (!recs) {
const e: any = new Error(`stub: no DNS records for ${host}`);
e.code = 'ENOTFOUND';
throw e;
}
return recs;
}) as any);
});
test('rejects URL whose hostname resolves internal (DNS rebinding)', async () => {
stubAddrs.set('attacker.com', [{ address: '127.0.0.1', family: 4 }]);
const err = await loadImageInput('https://attacker.com/img.png').catch(e => e);
expect(err).toBeInstanceOf(ImageLoadError);
expect(err.code).toBe('SSRF_BLOCKED');
});
test('rejects URL with metadata IP literal', async () => {
const err = await loadImageInput('http://169.254.169.254/latest/').catch(e => e);
expect(err).toBeInstanceOf(ImageLoadError);
expect(err.code).toBe('SSRF_BLOCKED');
});
});
+6 -2
View File
@@ -80,10 +80,14 @@ describe('Lane B — migration runner applies cleanly through retry wrapper', ()
});
describe('Lane C — backfill registry on empty brain', () => {
test('listBackfills returns three entries', () => {
test('listBackfills returns the canonical registry entries', () => {
// v0.30.1 shipped 3 entries (effective_date, embedding_voyage,
// emotional_weight). v0.36 cross-modal wave adds `modality` for
// historical image-asset chunks. Extend this assertion as new
// backfills land.
const list = listBackfills();
const names = list.map(e => e.spec.name).sort();
expect(names).toEqual(['effective_date', 'embedding_voyage', 'emotional_weight']);
expect(names).toEqual(['effective_date', 'embedding_voyage', 'emotional_weight', 'modality']);
});
test('embedding_voyage is declared-only in v0.30.1', () => {
+249
View File
@@ -0,0 +1,249 @@
// Commit 0 (D4 + D22-2): batching + partial-failure for multimodal embed,
// plus query-side helpers (embedQueryMultimodal, embedQueryMultimodalImage).
//
// Covers:
// - Voyage text variant (mixed text+image content arrays)
// - inputType: 'query' threaded through to Voyage wire format
// - embedMultimodalSafe binary-search retry on transient failure
// - embedMultimodalSafe surfaces failed_indices when individual inputs fail
// - embedMultimodalSafe stops on AIConfigError (permanent misconfig)
// - embedQueryMultimodal returns 1024-dim vector
// - embedQueryMultimodalImage returns 1024-dim vector
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import {
configureGateway,
embedMultimodal,
embedMultimodalSafe,
embedQueryMultimodal,
embedQueryMultimodalImage,
resetGateway,
} from '../src/core/ai/gateway.ts';
type FetchHandler = (url: string, init: RequestInit) => Promise<Response>;
let fetchHandler: FetchHandler | null = null;
const origFetch = globalThis.fetch;
beforeEach(() => {
fetchHandler = null;
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
if (!fetchHandler) throw new Error('fetch called but no handler installed');
return fetchHandler(typeof url === 'string' ? url : url.toString(), init ?? {});
}) as typeof fetch;
});
afterEach(() => {
globalThis.fetch = origFetch;
resetGateway();
});
function configureVoyage(env: Record<string, string | undefined> = {}) {
configureGateway({
embedding_model: 'voyage:voyage-multimodal-3',
embedding_dimensions: 1024,
env: { VOYAGE_API_KEY: 'test-key', ...env },
});
}
function fakeResponse(count: number, dims = 1024): Response {
const data = Array.from({ length: count }, (_, i) => ({
embedding: Array.from({ length: dims }, () => 0.1 * (i + 1)),
index: i,
}));
return new Response(JSON.stringify({ data, model: 'voyage-multimodal-3' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
function makeImg() {
return {
kind: 'image_base64' as const,
data: Buffer.from('fake').toString('base64'),
mime: 'image/jpeg',
};
}
describe('Voyage multimodal — text variant + inputType discipline', () => {
test('text input variant sends correct Voyage content shape', async () => {
configureVoyage();
let capturedBody: any;
fetchHandler = async (_url, init) => {
capturedBody = JSON.parse(init.body as string);
return fakeResponse(1);
};
const vecs = await embedMultimodal([{ kind: 'text', text: 'hello world' }]);
expect(vecs.length).toBe(1);
expect(vecs[0]).toBeInstanceOf(Float32Array);
expect(capturedBody.inputs[0].content[0]).toEqual({ type: 'text', text: 'hello world' });
});
test('opts.inputType="query" threads through to Voyage wire body', async () => {
configureVoyage();
let capturedBody: any;
fetchHandler = async (_url, init) => {
capturedBody = JSON.parse(init.body as string);
return fakeResponse(1);
};
await embedMultimodal([{ kind: 'text', text: 'q' }], { inputType: 'query' });
expect(capturedBody.input_type).toBe('query');
});
test('default inputType is "document" (preserves pre-v0.36 ingest behavior)', async () => {
configureVoyage();
let capturedBody: any;
fetchHandler = async (_url, init) => {
capturedBody = JSON.parse(init.body as string);
return fakeResponse(1);
};
await embedMultimodal([makeImg()]);
expect(capturedBody.input_type).toBe('document');
});
test('mixed text + image inputs in one batch — each gets correct content type', async () => {
configureVoyage();
let capturedBody: any;
fetchHandler = async (_url, init) => {
capturedBody = JSON.parse(init.body as string);
return fakeResponse(2);
};
await embedMultimodal([
{ kind: 'text', text: 'hello' },
makeImg(),
]);
expect(capturedBody.inputs[0].content[0].type).toBe('text');
expect(capturedBody.inputs[1].content[0].type).toBe('image_base64');
});
});
describe('embedQueryMultimodal — text query path', () => {
test('returns 1024-dim Float32Array via Voyage query embed', async () => {
configureVoyage();
fetchHandler = async () => fakeResponse(1, 1024);
const v = await embedQueryMultimodal('hackathon photos');
expect(v).toBeInstanceOf(Float32Array);
expect(v.length).toBe(1024);
});
test('threads inputType="query" to the wire', async () => {
configureVoyage();
let capturedBody: any;
fetchHandler = async (_url, init) => {
capturedBody = JSON.parse(init.body as string);
return fakeResponse(1);
};
await embedQueryMultimodal('q');
expect(capturedBody.input_type).toBe('query');
expect(capturedBody.inputs[0].content[0]).toEqual({ type: 'text', text: 'q' });
});
});
describe('embedQueryMultimodalImage — image query path', () => {
test('returns 1024-dim Float32Array via Voyage image-query embed', async () => {
configureVoyage();
fetchHandler = async () => fakeResponse(1, 1024);
const v = await embedQueryMultimodalImage({
data: Buffer.from('fake').toString('base64'),
mime: 'image/png',
});
expect(v).toBeInstanceOf(Float32Array);
expect(v.length).toBe(1024);
});
test('threads inputType="query" + image_base64 shape to the wire', async () => {
configureVoyage();
let capturedBody: any;
fetchHandler = async (_url, init) => {
capturedBody = JSON.parse(init.body as string);
return fakeResponse(1);
};
await embedQueryMultimodalImage({
data: Buffer.from('xyz').toString('base64'),
mime: 'image/webp',
});
expect(capturedBody.input_type).toBe('query');
expect(capturedBody.inputs[0].content[0].type).toBe('image_base64');
expect(capturedBody.inputs[0].content[0].image_base64).toContain('data:image/webp;base64,');
});
});
describe('embedMultimodalSafe — partial-failure surfacing', () => {
test('happy path returns full embeddings array, no failed indices', async () => {
configureVoyage();
fetchHandler = async () => fakeResponse(3);
const result = await embedMultimodalSafe([makeImg(), makeImg(), makeImg()]);
expect(result.failedIndices).toEqual([]);
expect(result.embeddings.length).toBe(3);
expect(result.embeddings.every(v => v instanceof Float32Array)).toBe(true);
});
test('empty input array returns empty result without HTTP call', async () => {
configureVoyage();
fetchHandler = async () => {
throw new Error('should not be called');
};
const result = await embedMultimodalSafe([]);
expect(result.failedIndices).toEqual([]);
expect(result.embeddings).toEqual([]);
});
test('all-fail batch records every input as failed', async () => {
configureVoyage();
let callCount = 0;
fetchHandler = async () => {
callCount++;
return new Response('rate limited', { status: 429 });
};
const result = await embedMultimodalSafe([makeImg(), makeImg()]);
expect(result.failedIndices).toEqual([0, 1]);
expect(result.embeddings).toEqual([undefined, undefined]);
expect(result.lastError).toBeDefined();
// Binary-search retry: tries [0,1] then [0] then [1] = 3 calls
expect(callCount).toBeGreaterThanOrEqual(2);
});
test('mid-batch failure: binary-search retry recovers good inputs', async () => {
configureVoyage();
// Strategy: track which inputs were sent in each batch by hashing the
// request body. Input index 2 always fails when sent solo; other splits succeed.
fetchHandler = async (_url, init) => {
const body = JSON.parse(init.body as string);
const requestSize = body.inputs.length;
// Any batch containing TARGET2 fails transiently — forces the binary-search
// split until input 2 is isolated, at which point single-input fail is recorded.
const containsTarget = body.inputs.some((inp: any) =>
inp.content?.[0]?.image_base64?.includes('VEFSR0VUMg'),
);
if (containsTarget) {
return new Response('contains-target-fail', { status: 503 });
}
return fakeResponse(requestSize);
};
const inputs = [
makeImg(),
makeImg(),
{ kind: 'image_base64' as const, data: Buffer.from('TARGET2').toString('base64'), mime: 'image/jpeg' },
];
const result = await embedMultimodalSafe(inputs);
// Inputs 0,1 should succeed via the binary-search split; input 2 fails permanently
expect(result.failedIndices).toEqual([2]);
expect(result.embeddings[0]).toBeInstanceOf(Float32Array);
expect(result.embeddings[1]).toBeInstanceOf(Float32Array);
expect(result.embeddings[2]).toBeUndefined();
});
test('AIConfigError (permanent) fails fast without binary-search retry', async () => {
configureVoyage();
let callCount = 0;
fetchHandler = async () => {
callCount++;
return new Response('unauthorized', { status: 401 });
};
const result = await embedMultimodalSafe([makeImg(), makeImg(), makeImg(), makeImg()]);
// AIConfigError (401) is permanent — no point in binary-search retry.
// All 4 inputs should be reported as failed after the single call.
expect(result.failedIndices).toEqual([0, 1, 2, 3]);
expect(callCount).toBe(1);
expect(result.lastError?.message).toContain('401');
});
});
+137
View File
@@ -0,0 +1,137 @@
// Commit 4: LLM intent escalation for cross-modal classification.
//
// Covers:
// - parseModality tolerates trailing punctuation + casing
// - classifyModalityWithLLM happy paths (text / image / both)
// - Fail-open on timeout / parse failure / gateway misconfig
// - hybridSearch escalation gate: fires ONLY when flag on + regex 'text' + ambiguous
// - Cache miss: same query asked twice WITH llm_intent=true makes 2 LLM calls
// (caching is the existing query_cache layer, not a per-process LRU)
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import {
classifyModalityWithLLM,
parseModality,
} from '../src/core/search/llm-intent.ts';
import {
__setChatTransportForTests,
configureGateway,
resetGateway,
} from '../src/core/ai/gateway.ts';
beforeEach(() => {
configureGateway({
embedding_model: 'openai:text-embedding-3-large',
embedding_dimensions: 1536,
env: { OPENAI_API_KEY: 'test', ANTHROPIC_API_KEY: 'test' },
});
});
afterEach(() => {
resetGateway();
__setChatTransportForTests(null);
});
describe('parseModality (pure function)', () => {
test('"text" → text', () => {
expect(parseModality('text', 'text')).toBe('text');
});
test('"image" → image', () => {
expect(parseModality('image', 'text')).toBe('image');
});
test('"both" → both', () => {
expect(parseModality('both', 'text')).toBe('both');
});
test('"IMAGE." → image (tolerates trailing punctuation + casing)', () => {
expect(parseModality('IMAGE.', 'text')).toBe('image');
});
test('" text \\n" → text (tolerates whitespace)', () => {
expect(parseModality(' text \n', 'image')).toBe('text');
});
test('"none of the above" → fallback', () => {
expect(parseModality('none of the above', 'text')).toBe('text');
expect(parseModality('none of the above', 'image')).toBe('image');
});
test('empty string → fallback', () => {
expect(parseModality('', 'text')).toBe('text');
});
});
describe('classifyModalityWithLLM — happy path', () => {
test('"any pictures from offsite?" → LLM says image → returns image', async () => {
let chatCalled = 0;
__setChatTransportForTests(async (_opts) => {
chatCalled++;
return {
text: 'image',
blocks: [{ type: 'text', text: 'image' }],
stopReason: 'end',
usage: { input_tokens: 10, output_tokens: 1, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'anthropic:claude-haiku-4-5',
providerId: 'anthropic',
};
});
const result = await classifyModalityWithLLM('any pictures from offsite?');
expect(result).toBe('image');
expect(chatCalled).toBe(1);
});
test('"what is founder mode" → LLM says text → returns text', async () => {
__setChatTransportForTests(async () => ({
text: 'text',
blocks: [{ type: 'text', text: 'text' }],
stopReason: 'end',
usage: { input_tokens: 10, output_tokens: 1, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'anthropic:claude-haiku-4-5',
providerId: 'anthropic',
}));
expect(await classifyModalityWithLLM('what is founder mode')).toBe('text');
});
test('LLM says "both" → returns both', async () => {
__setChatTransportForTests(async () => ({
text: 'both',
blocks: [{ type: 'text', text: 'both' }],
stopReason: 'end',
usage: { input_tokens: 10, output_tokens: 1, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'anthropic:claude-haiku-4-5',
providerId: 'anthropic',
}));
expect(await classifyModalityWithLLM('ambiguous query')).toBe('both');
});
});
describe('classifyModalityWithLLM — fail-open', () => {
test('LLM throws → returns fallback (text)', async () => {
__setChatTransportForTests(async () => {
throw new Error('network error');
});
expect(await classifyModalityWithLLM('q')).toBe('text');
});
test('LLM returns unrecognized output → returns fallback', async () => {
__setChatTransportForTests(async () => ({
text: 'gibberish output',
blocks: [{ type: 'text', text: 'gibberish output' }],
stopReason: 'end',
usage: { input_tokens: 10, output_tokens: 5, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'anthropic:claude-haiku-4-5',
providerId: 'anthropic',
}));
expect(await classifyModalityWithLLM('q', 'text')).toBe('text');
});
test('Gateway not configured → returns fallback', async () => {
resetGateway();
// No configureGateway called → isAvailable('chat') returns false.
expect(await classifyModalityWithLLM('q', 'text')).toBe('text');
});
test('Explicit fallback honored', async () => {
__setChatTransportForTests(async () => {
throw new Error('boom');
});
expect(await classifyModalityWithLLM('q', 'image')).toBe('image');
expect(await classifyModalityWithLLM('q', 'both')).toBe('both');
});
});
+123
View File
@@ -0,0 +1,123 @@
// Commit 4 integration: hybridSearch escalation gate fires only when
// (config flag on) + (regex returned 'text') + (isAmbiguousModalityQuery true).
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
import {
__setChatTransportForTests,
configureGateway,
resetGateway,
} from '../src/core/ai/gateway.ts';
import { hybridSearch } from '../src/core/search/hybrid.ts';
let engine: PGLiteEngine;
const origFetch = globalThis.fetch;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
globalThis.fetch = (async (url: string | URL | Request) => {
const u = typeof url === 'string' ? url : url.toString();
if (u.includes('multimodalembeddings')) {
return new Response(JSON.stringify({
data: [{ embedding: Array.from({ length: 1024 }, () => 0.1), index: 0 }],
}), { status: 200 });
}
// Default OpenAI text embed response.
return new Response(JSON.stringify({
data: [{ embedding: Array.from({ length: 1536 }, () => 0.1), index: 0 }],
}), { status: 200 });
}) as typeof fetch;
configureGateway({
embedding_model: 'openai:text-embedding-3-large',
embedding_dimensions: 1536,
embedding_multimodal_model: 'voyage:voyage-multimodal-3',
env: { OPENAI_API_KEY: 'test', VOYAGE_API_KEY: 'test', ANTHROPIC_API_KEY: 'test' },
});
});
afterEach(() => {
globalThis.fetch = origFetch;
resetGateway();
__setChatTransportForTests(null);
});
describe('hybridSearch LLM intent escalation gate (Commit 4)', () => {
test('flag OFF + ambiguous query → no LLM call (default behavior)', async () => {
let chatCalled = 0;
__setChatTransportForTests(async () => {
chatCalled++;
return { text: 'image', blocks: [], stopReason: 'end', usage: { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_creation_tokens: 0 }, model: 'x', providerId: 'x' };
});
// Flag NOT set → default false.
await hybridSearch(engine, 'the chart', { limit: 5 });
expect(chatCalled).toBe(0);
});
test('flag ON + ambiguous query → ONE LLM call', async () => {
await engine.setConfig('search.cross_modal.llm_intent', 'true');
let chatCalled = 0;
__setChatTransportForTests(async () => {
chatCalled++;
return { text: 'image', blocks: [], stopReason: 'end', usage: { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_creation_tokens: 0 }, model: 'x', providerId: 'x' };
});
await hybridSearch(engine, 'the chart', { limit: 5 });
expect(chatCalled).toBe(1);
});
test('flag ON + unambiguous text query → no LLM call', async () => {
await engine.setConfig('search.cross_modal.llm_intent', 'true');
let chatCalled = 0;
__setChatTransportForTests(async () => {
chatCalled++;
return { text: 'image', blocks: [], stopReason: 'end', usage: { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_creation_tokens: 0 }, model: 'x', providerId: 'x' };
});
await hybridSearch(engine, 'what is founder mode', { limit: 5 });
expect(chatCalled).toBe(0);
});
test('flag ON + regex-confident image query → no LLM call (regex already classified)', async () => {
await engine.setConfig('search.cross_modal.llm_intent', 'true');
let chatCalled = 0;
__setChatTransportForTests(async () => {
chatCalled++;
return { text: 'image', blocks: [], stopReason: 'end', usage: { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_creation_tokens: 0 }, model: 'x', providerId: 'x' };
});
// Strong regex match: "show me photos from X" → already image.
await hybridSearch(engine, 'show me photos from the hackathon', { limit: 5 });
// No tie-break needed when regex is already confident.
expect(chatCalled).toBe(0);
});
test('flag ON + explicit crossModal opt → no LLM call (per-call opt wins)', async () => {
await engine.setConfig('search.cross_modal.llm_intent', 'true');
let chatCalled = 0;
__setChatTransportForTests(async () => {
chatCalled++;
return { text: 'image', blocks: [], stopReason: 'end', usage: { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_creation_tokens: 0 }, model: 'x', providerId: 'x' };
});
// Caller passed explicit crossModal — no need to tie-break.
await hybridSearch(engine, 'the chart', { crossModal: 'text', limit: 5 });
expect(chatCalled).toBe(0);
});
test('flag ON + ambiguous + LLM throws → falls back to regex result (text)', async () => {
await engine.setConfig('search.cross_modal.llm_intent', 'true');
__setChatTransportForTests(async () => {
throw new Error('LLM unavailable');
});
// Should not throw — fail-open to regex result.
const results = await hybridSearch(engine, 'the chart', { limit: 5 });
expect(Array.isArray(results)).toBe(true);
});
});
+168
View File
@@ -0,0 +1,168 @@
// Commit 2 (Phase 2): search_by_image MCP op trust-boundary + spend cap.
//
// Covers:
// - D18: remote (ctx.remote=true) + image_path is rejected
// - Local (ctx.remote=false) + image_path is accepted
// - Missing all three of image_path/url/data is rejected
// - Multiple of image_path/url/data is rejected
// - D23-#6 spend cap blocks at budget; allows under budget
// - Spend log records on successful call
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
import { operationsByName } from '../src/core/operations.ts';
import { configureGateway, resetGateway } from '../src/core/ai/gateway.ts';
import { recordSpend, getTodaySpendCents } from '../src/core/spend-log.ts';
let engine: PGLiteEngine;
let tmpRoot: string;
const PNG_BYTES = Buffer.from([
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0,
31, 21, 196, 137, 0, 0, 0, 12, 73, 68, 65, 84, 8, 87, 99, 248, 207, 192, 0, 0, 0, 3, 0, 1,
90, 12, 105, 240, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130,
]);
type FetchHandler = (url: string, init: RequestInit) => Promise<Response>;
let fetchHandler: FetchHandler | null = null;
const origFetch = globalThis.fetch;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
tmpRoot = mkdtempSync(join(tmpdir(), 'gbrain-search-by-image-op-'));
fetchHandler = async () => new Response(
JSON.stringify({
data: [{ embedding: Array.from({ length: 1024 }, () => 0.1), index: 0 }],
model: 'voyage-multimodal-3',
}),
{ status: 200 },
);
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
if (!fetchHandler) throw new Error('no fetch handler');
return fetchHandler(typeof url === 'string' ? url : url.toString(), init ?? {});
}) as typeof fetch;
configureGateway({
embedding_model: 'openai:text-embedding-3-large',
embedding_dimensions: 1536,
embedding_multimodal_model: 'voyage:voyage-multimodal-3',
env: { OPENAI_API_KEY: 'test', VOYAGE_API_KEY: 'test' },
});
});
afterEach(() => {
globalThis.fetch = origFetch;
resetGateway();
});
const op = () => operationsByName.search_by_image;
describe('search_by_image op — D18 remote image_path ban', () => {
test('rejects image_path when ctx.remote=true', async () => {
const path = join(tmpRoot, 'test.png');
writeFileSync(path, PNG_BYTES);
const err = await op().handler(
{ engine, remote: true, auth: { token: 't', clientId: 'c1', scopes: ['read'] } } as any,
{ image_path: path },
).catch((e: any) => e as Error);
expect(err).toBeInstanceOf(Error);
expect((err as Error).message).toContain('permission_denied');
});
test('accepts image_path when ctx.remote=false (local CLI)', async () => {
const path = join(tmpRoot, 'test.png');
writeFileSync(path, PNG_BYTES);
const results = await op().handler(
{ engine, remote: false } as any,
{ image_path: path },
);
expect(Array.isArray(results)).toBe(true);
});
});
describe('search_by_image op — input validation', () => {
test('rejects missing all three inputs', async () => {
const err = await op().handler(
{ engine, remote: false } as any,
{},
).catch((e: any) => e as Error);
expect(err).toBeInstanceOf(Error);
expect((err as Error).message).toMatch(/image_path|image_url|image_data/);
});
test('rejects multiple inputs together', async () => {
const path = join(tmpRoot, 'test.png');
writeFileSync(path, PNG_BYTES);
const err = await op().handler(
{ engine, remote: false } as any,
{ image_path: path, image_data: PNG_BYTES.toString('base64') },
).catch((e: any) => e as Error);
expect(err).toBeInstanceOf(Error);
expect((err as Error).message).toMatch(/only one of/);
});
});
describe('search_by_image op — D23-#6 spend cap', () => {
test('blocks remote call when daily spend already at budget', async () => {
// Configure budget to $0.05 cap.
await engine.setConfig('search.image_query.daily_budget_usd_per_client', '0.05');
// Pre-record $0.05 = 5 cents of spend for client_a.
await recordSpend(engine, {
clientId: 'client_a',
operation: 'search_by_image',
spendCents: 5,
});
// Verify the recorded spend is at the budget.
const spent = await getTodaySpendCents(engine, 'client_a');
expect(spent).toBeGreaterThanOrEqual(5);
const err = await op().handler(
{ engine, remote: true, auth: { token: 't', clientId: 'client_a', scopes: ['read'] } } as any,
{ image_data: PNG_BYTES.toString('base64') },
).catch((e: any) => e as Error);
expect(err).toBeInstanceOf(Error);
expect((err as Error).message).toContain('Daily Voyage spend cap reached');
});
test('allows remote call when under budget', async () => {
await engine.setConfig('search.image_query.daily_budget_usd_per_client', '5');
// No prior spend recorded.
const results = await op().handler(
{ engine, remote: true, auth: { token: 't', clientId: 'client_b', scopes: ['read'] } } as any,
{ image_data: PNG_BYTES.toString('base64') },
);
expect(Array.isArray(results)).toBe(true);
// Verify spend was recorded after the call.
// (Allow a small tick for the async best-effort recordSpend.)
await new Promise(r => setTimeout(r, 20));
const spent = await getTodaySpendCents(engine, 'client_b');
expect(spent).toBeGreaterThan(0);
});
test('local CLI calls bypass budget gate (ctx.remote=false, no clientId)', async () => {
// Even with cap=$0 set, local call is allowed.
await engine.setConfig('search.image_query.daily_budget_usd_per_client', '0.01');
await recordSpend(engine, { clientId: 'somebody', operation: 'search_by_image', spendCents: 100 });
const path = join(tmpRoot, 'local.png');
writeFileSync(path, PNG_BYTES);
const results = await op().handler(
{ engine, remote: false } as any,
{ image_path: path },
);
expect(Array.isArray(results)).toBe(true);
});
});
+21 -7
View File
@@ -38,6 +38,17 @@ describe('SEARCH_MODES + MODE_BUNDLES canonical shape', () => {
// The cell-by-cell assertion. The methodology doc cites these.
// v0.35.0.0+ extended with 5 reranker fields. tokenmax flips reranker on;
// conservative + balanced keep it off until eval data backs a change.
// v0.36 cross-modal wave: shared defaults across all modes (opt-in).
const CROSS_MODAL_DEFAULTS = {
cross_modal_both_text_weight: 0.6,
cross_modal_both_image_weight: 0.4,
image_query_text_refinement_weight: 0.4,
image_query_image_refinement_weight: 0.6,
unified_multimodal: false,
unified_multimodal_only: false,
cross_modal_llm_intent: false,
};
test('conservative bundle values are canonical', () => {
expect(MODE_BUNDLES.conservative).toEqual({
cache_enabled: true,
@@ -52,9 +63,8 @@ describe('SEARCH_MODES + MODE_BUNDLES canonical shape', () => {
reranker_top_n_in: 30,
reranker_top_n_out: null,
reranker_timeout_ms: 5000,
// v0.35.6.0 — floor_ratio undefined in all three bundles; the per-corpus
// ablation TODO gates any default flip.
floor_ratio: undefined,
...CROSS_MODAL_DEFAULTS,
});
});
@@ -75,6 +85,7 @@ describe('SEARCH_MODES + MODE_BUNDLES canonical shape', () => {
reranker_top_n_out: null,
reranker_timeout_ms: 5000,
floor_ratio: undefined,
...CROSS_MODAL_DEFAULTS,
});
});
@@ -93,6 +104,7 @@ describe('SEARCH_MODES + MODE_BUNDLES canonical shape', () => {
reranker_top_n_out: null,
reranker_timeout_ms: 5000,
floor_ratio: undefined,
...CROSS_MODAL_DEFAULTS,
});
});
@@ -272,11 +284,13 @@ describe('knobsHash determinism + cross-mode separation (CDX-4)', () => {
test('KNOBS_HASH_VERSION constant exposed for migrations to bump on schema change', () => {
// v0.35.0.0+ bumped 1→2 to fold reranker fields into the cache key.
// v0.35.6.0 bumped 2→3 to fold floor_ratio into the cache key
// (codex T1 — preventing cross-floor cache contamination).
// v0.36 also extends v=3 with embedding column + provider (D8 / CDX-2)
// so a query against `embedding_voyage` never shares a cache row with
// `embedding`, even when all other knobs match.
// v0.35.6.0 bumped 2→3 to fold floor_ratio (codex outside-voice T1 —
// preventing cross-floor cache contamination).
// v0.36 piggybacks on v=3 with 7 additional cross-modal knobs (D2) PLUS
// embedding column + provider context (D8/CDX-2 cross-column isolation),
// all appended per CDX2-F13 append-only convention so a text-mode cache
// hit can never silently serve to an image-mode caller, and a query
// against `embedding_voyage` never shares a cache row with `embedding`.
expect(KNOBS_HASH_VERSION).toBe(3);
});
+5 -1
View File
@@ -43,7 +43,11 @@ function baseKnobs(): ResolvedSearchKnobs {
}
describe('KNOBS_HASH_VERSION + version invariants', () => {
test('version is 3 (1→2 v0.35.0.0 reranker; 2→3 v0.35.6.0 floor_ratio + v0.36 embedding-column)', () => {
test('version is 3 (1→2 v0.35.0.0 reranker; 2→3 v0.35.6.0 floor_ratio + v0.36 cross-modal + embedding-column appends)', () => {
// v0.35.0.0: 1→2 to fold reranker fields. v0.35.6.0: 2→3 to fold
// floor_ratio. v0.36 wave: piggybacks on v=3 with 7 cross-modal knobs
// (D2) PLUS column + provider context (D8/CDX-2 cross-column isolation),
// all appended per CDX2-F13 append-only convention.
expect(KNOBS_HASH_VERSION).toBe(3);
});
+151
View File
@@ -0,0 +1,151 @@
// Commit 0 (D19): SSRF validation with DNS resolution.
//
// Covers the gap that `isInternalUrl` in src/core/url-safety.ts leaves open:
// DNS rebinding. validateAndResolveUrl does its own DNS lookup and rejects
// hostnames whose resolved IPs land internal.
//
// Tests use the __setDnsLookupForTests seam so the real network is never hit.
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import {
SSRFError,
__setDnsLookupForTests,
validateAndResolveUrl,
} from '../src/core/ssrf-validate.ts';
type DnsRecord = { address: string; family: number };
let stubAddrs: Map<string, DnsRecord[]> = new Map();
beforeEach(() => {
stubAddrs = new Map();
__setDnsLookupForTests((async (host: string, _opts: any) => {
const recs = stubAddrs.get(host);
if (!recs) {
const err = new Error(`stub: no DNS records for ${host}`);
(err as any).code = 'ENOTFOUND';
throw err;
}
return recs;
}) as any);
});
afterEach(() => {
__setDnsLookupForTests(undefined);
});
describe('validateAndResolveUrl — static rejections (via isInternalUrl)', () => {
test('rejects http://127.0.0.1 (loopback)', async () => {
await expect(validateAndResolveUrl('http://127.0.0.1/img.png')).rejects.toBeInstanceOf(SSRFError);
});
test('rejects http://169.254.169.254 (AWS metadata)', async () => {
await expect(validateAndResolveUrl('http://169.254.169.254/latest/meta-data/')).rejects.toBeInstanceOf(SSRFError);
});
test('rejects http://2130706433 (decimal-encoded 127.0.0.1)', async () => {
await expect(validateAndResolveUrl('http://2130706433/')).rejects.toBeInstanceOf(SSRFError);
});
test('rejects http://0x7f000001 (hex-encoded 127.0.0.1)', async () => {
await expect(validateAndResolveUrl('http://0x7f000001/')).rejects.toBeInstanceOf(SSRFError);
});
test('rejects metadata.google.internal hostname', async () => {
await expect(validateAndResolveUrl('http://metadata.google.internal/')).rejects.toBeInstanceOf(SSRFError);
});
test('rejects localhost. (trailing dot)', async () => {
await expect(validateAndResolveUrl('http://localhost./')).rejects.toBeInstanceOf(SSRFError);
});
test('rejects IPv6 link-local fe80::1', async () => {
await expect(validateAndResolveUrl('http://[fe80::1]/')).rejects.toBeInstanceOf(SSRFError);
});
});
describe('validateAndResolveUrl — scheme + credentials', () => {
test('rejects file:// scheme', async () => {
const err = await validateAndResolveUrl('file:///etc/passwd').catch(e => e);
expect(err).toBeInstanceOf(SSRFError);
// file:// is caught by static `isInternalUrl` first (it returns true for non-http(s))
expect(['INVALID_SCHEME', 'INTERNAL_HOST']).toContain(err.code);
});
test('rejects gopher:// scheme', async () => {
const err = await validateAndResolveUrl('gopher://example.com/').catch(e => e);
expect(err).toBeInstanceOf(SSRFError);
expect(['INVALID_SCHEME', 'INTERNAL_HOST']).toContain(err.code);
});
test('rejects credentials embedded in URL', async () => {
stubAddrs.set('example.com', [{ address: '93.184.216.34', family: 4 }]);
const err = await validateAndResolveUrl('http://user:pass@example.com/').catch(e => e);
expect(err).toBeInstanceOf(SSRFError);
expect(err.code).toBe('CREDENTIALS_IN_URL');
});
});
describe('validateAndResolveUrl — DNS rebinding defense', () => {
test('rejects when hostname resolves to 127.0.0.1', async () => {
stubAddrs.set('attacker.com', [{ address: '127.0.0.1', family: 4 }]);
const err = await validateAndResolveUrl('http://attacker.com/img.png').catch(e => e);
expect(err).toBeInstanceOf(SSRFError);
expect(err.code).toBe('DNS_RESOLVED_INTERNAL');
});
test('rejects when hostname resolves to 169.254.169.254 (AWS metadata)', async () => {
stubAddrs.set('attacker.com', [{ address: '169.254.169.254', family: 4 }]);
const err = await validateAndResolveUrl('http://attacker.com/').catch(e => e);
expect(err).toBeInstanceOf(SSRFError);
expect(err.code).toBe('DNS_RESOLVED_INTERNAL');
});
test('rejects when ANY resolved record points internal (DNS rebinding multi-record)', async () => {
stubAddrs.set('mixed.com', [
{ address: '8.8.8.8', family: 4 },
{ address: '10.0.0.1', family: 4 }, // private — rejects whole set
]);
const err = await validateAndResolveUrl('http://mixed.com/').catch(e => e);
expect(err).toBeInstanceOf(SSRFError);
expect(err.code).toBe('DNS_RESOLVED_INTERNAL');
});
test('rejects IPv6 ULA resolved record', async () => {
stubAddrs.set('attacker.com', [{ address: 'fc00::1', family: 6 }]);
const err = await validateAndResolveUrl('http://attacker.com/').catch(e => e);
expect(err).toBeInstanceOf(SSRFError);
expect(err.code).toBe('DNS_RESOLVED_INTERNAL');
});
});
describe('validateAndResolveUrl — happy path', () => {
test('resolves public IPv4 address and returns target', async () => {
stubAddrs.set('example.com', [{ address: '93.184.216.34', family: 4 }]);
const target = await validateAndResolveUrl('https://example.com/img.png');
expect(target.resolvedIp).toBe('93.184.216.34');
expect(target.originalHost).toBe('example.com');
expect(target.ipv6).toBe(false);
expect(target.resolvedUrl).toContain('93.184.216.34');
});
test('public IP literal passes through without DNS lookup', async () => {
const target = await validateAndResolveUrl('https://93.184.216.34/img.png');
expect(target.resolvedIp).toBe('93.184.216.34');
expect(target.originalHost).toBe(''); // literal — no original hostname
});
test('public IPv6 literal passes through', async () => {
const target = await validateAndResolveUrl('https://[2606:2800:220:1::1]/');
expect(target.resolvedIp).toBe('2606:2800:220:1::1');
expect(target.ipv6).toBe(true);
});
});
describe('validateAndResolveUrl — DNS resolution failures', () => {
test('rejects when DNS lookup fails (ENOTFOUND)', async () => {
const err = await validateAndResolveUrl('http://nonexistent.invalid/').catch(e => e);
expect(err).toBeInstanceOf(SSRFError);
expect(err.code).toBe('DNS_RESOLUTION_FAILED');
});
test('rejects when DNS lookup returns empty records', async () => {
stubAddrs.set('empty.com', []);
const err = await validateAndResolveUrl('http://empty.com/').catch(e => e);
expect(err).toBeInstanceOf(SSRFError);
expect(err.code).toBe('DNS_RESOLUTION_FAILED');
});
});
describe('validateAndResolveUrl — malformed URLs', () => {
test('rejects unparseable URL', async () => {
const err = await validateAndResolveUrl('not a url at all').catch(e => e);
expect(err).toBeInstanceOf(SSRFError);
expect(err.code).toBe('INVALID_URL');
});
});
+169
View File
@@ -0,0 +1,169 @@
// Commit 3 (Phase 3): unified multimodal column.
//
// Covers:
// - Schema migration v68 adds embedding_multimodal column
// - searchVector routes to embedding_multimodal when opts.embeddingColumn set
// - hybridSearch routes through unified column when search.unified_multimodal=true
// - D8 fail-open: unified-only=false + empty unified column → falls back to text
// - D8 strict: unified-only=true + empty column → does not fall back
// - reindex --multimodal cost estimate + dry-run + GBRAIN_NO_REEMBED bypass
// - D7 lock acquired during reindex; second reindex receives LOCK_HELD
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
import { withEnv } from './helpers/with-env.ts';
import {
configureGateway,
resetGateway,
} from '../src/core/ai/gateway.ts';
import { hybridSearch } from '../src/core/search/hybrid.ts';
import { runReindexMultimodal } from '../src/commands/reindex-multimodal.ts';
let engine: PGLiteEngine;
let fetchHandler: ((url: string, init: RequestInit) => Promise<Response>) | null = null;
const origFetch = globalThis.fetch;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
fetchHandler = async () => new Response(JSON.stringify({
data: [{ embedding: Array.from({ length: 1024 }, () => 0.1), index: 0 }],
model: 'voyage-multimodal-3',
}), { status: 200 });
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
if (!fetchHandler) throw new Error('no fetch handler');
return fetchHandler(typeof url === 'string' ? url : url.toString(), init ?? {});
}) as typeof fetch;
configureGateway({
embedding_model: 'openai:text-embedding-3-large',
embedding_dimensions: 1536,
embedding_multimodal_model: 'voyage:voyage-multimodal-3',
env: { OPENAI_API_KEY: 'test', VOYAGE_API_KEY: 'test' },
});
});
afterEach(() => {
globalThis.fetch = origFetch;
resetGateway();
});
describe('Phase 3 schema — v68 migration', () => {
test('content_chunks has embedding_multimodal column', async () => {
// Run an explicit query against the column. If the migration ran, this succeeds.
const rows = await engine.executeRaw<{ count: string }>(
`SELECT COUNT(*)::text AS count FROM content_chunks WHERE embedding_multimodal IS NULL`,
);
expect(rows.length).toBeGreaterThanOrEqual(1);
});
});
describe('reindex --multimodal command (Phase 3)', () => {
test('--dry-run reports cost estimate without mutating', async () => {
// No rows in DB → pending=0, no work needed.
const result = await runReindexMultimodal(engine, { dryRun: true });
expect(result.dry_run).toBe(true);
expect(result.reembedded).toBe(0);
});
test('--cost-estimate reports cost but does not run', async () => {
const result = await runReindexMultimodal(engine, { costEstimate: true });
expect(result.dry_run).toBe(true);
expect(result.reembedded).toBe(0);
});
test('GBRAIN_NO_REEMBED=1 honored on zero-pending brain (skip path is no-op-clean)', async () => {
await withEnv({ GBRAIN_NO_REEMBED: '1' }, async () => {
const result = await runReindexMultimodal(engine, {});
// Zero pending → reindex short-circuits before the env-var check; both
// paths produce dry_run=false + reembedded=0 + pending=0.
expect(result.reembedded).toBe(0);
expect(result.pending_after).toBe(0);
});
});
test('zero-pending returns cleanly', async () => {
const result = await runReindexMultimodal(engine, { yes: true });
expect(result.pending_before).toBe(0);
expect(result.reembedded).toBe(0);
expect(result.failed).toBe(0);
});
});
describe('hybridSearch unified routing (Phase 3)', () => {
test('search.unified_multimodal=true routes ALL queries through embedding_multimodal', async () => {
await engine.setConfig('search.unified_multimodal', 'true');
let voyageCalled = 0;
let openaiCalled = 0;
fetchHandler = async (url) => {
if (url.includes('multimodalembeddings')) {
voyageCalled++;
return new Response(JSON.stringify({
data: [{ embedding: Array.from({ length: 1024 }, () => 0.1), index: 0 }],
}), { status: 200 });
}
if (url.includes('api.openai.com') && url.includes('embeddings')) {
openaiCalled++;
}
return new Response(JSON.stringify({
data: [{ embedding: Array.from({ length: 1536 }, () => 0.1), index: 0 }],
}), { status: 200 });
};
await hybridSearch(engine, 'totally text query', { limit: 5 });
// Unified routing: text query forced to multimodal endpoint.
expect(voyageCalled).toBeGreaterThanOrEqual(1);
});
test('D8 fail-open: empty unified column + not strict → falls back to text', async () => {
// Set unified flag but DON'T set unified_multimodal_only. Empty DB → unified returns [].
await engine.setConfig('search.unified_multimodal', 'true');
let openaiCalled = 0;
fetchHandler = async (url) => {
if (url.includes('multimodalembeddings')) {
return new Response(JSON.stringify({
data: [{ embedding: Array.from({ length: 1024 }, () => 0.1), index: 0 }],
}), { status: 200 });
}
openaiCalled++;
return new Response(JSON.stringify({
data: [{ embedding: Array.from({ length: 1536 }, () => 0.1), index: 0 }],
}), { status: 200 });
};
const results = await hybridSearch(engine, 'whatever', { limit: 5 });
expect(Array.isArray(results)).toBe(true);
// The fall-back path SHOULD call OpenAI (text path) when unified came back empty.
expect(openaiCalled).toBeGreaterThanOrEqual(1);
});
test('D8 strict: unified_multimodal_only=true + empty column → does NOT fall back', async () => {
await engine.setConfig('search.unified_multimodal', 'true');
await engine.setConfig('search.unified_multimodal_only', 'true');
let openaiCalled = 0;
fetchHandler = async (url) => {
if (url.includes('multimodalembeddings')) {
return new Response(JSON.stringify({
data: [{ embedding: Array.from({ length: 1024 }, () => 0.1), index: 0 }],
}), { status: 200 });
}
openaiCalled++;
return new Response(JSON.stringify({
data: [{ embedding: Array.from({ length: 1536 }, () => 0.1), index: 0 }],
}), { status: 200 });
};
await hybridSearch(engine, 'whatever', { limit: 5 });
// Strict mode means NO text fallback even when unified is empty.
expect(openaiCalled).toBe(0);
});
});