1
0

3 Komitmen 4f1e3deb9b ... 703371b723

Pembuat SHA1 Pesan Tanggal
  Claude 703371b723 fix(launcher): resolve dependencies beside symlinked dist 2 hari lalu
  Claude 03c43a2926 fix(embed): honor native Gemini key (omit-index + factory fallback) 1 Minggu lalu
  Claude ab6440dc0e fix(embed): re-select documents whose later chunks failed to embed (i-xeekgx6h) 2 minggu lalu

+ 5 - 2
bin/qmd

@@ -32,9 +32,12 @@ fi
 # builds that use npm would be incorrectly routed to bun, causing ABI
 # mismatches with better-sqlite3 / sqlite-vec (see #381).
 if [ -f "$DIR/package-lock.json" ]; then
-  exec node "$DIR/dist/cli/qmd.js" "$@"
+  # Keep the package-visible path when Oivo relocates dist/ behind a symlink.
+  # ESM otherwise resolves imported packages beside the real dist target and
+  # misses this checkout's node_modules (fast-glob, picomatch, ...).
+  exec node --preserve-symlinks --preserve-symlinks-main "$DIR/dist/cli/qmd.js" "$@"
 elif [ -f "$DIR/bun.lock" ] || [ -f "$DIR/bun.lockb" ]; then
   exec bun "$DIR/dist/cli/qmd.js" "$@"
 else
-  exec node "$DIR/dist/cli/qmd.js" "$@"
+  exec node --preserve-symlinks --preserve-symlinks-main "$DIR/dist/cli/qmd.js" "$@"
 fi

+ 13 - 2
dist/cli/qmd.js

@@ -12,8 +12,19 @@ import { disposeDefaultLlamaCpp, getDefaultLlamaCpp, setDefaultLlamaCpp, LlamaCp
 import { formatSearchResults, formatDocuments, escapeXml, escapeCSV, } from "./formatter.js";
 import { getCollection as getCollectionFromYaml, listCollections as yamlListCollections, getDefaultCollectionNames, addContext as yamlAddContext, removeContext as yamlRemoveContext, removeCollection as yamlRemoveCollectionFn, renameCollection as yamlRenameCollectionFn, setGlobalContext, listAllContexts, setConfigIndexName, loadConfig, } from "../collections.js";
 import { getEmbeddedQmdSkillContent, getEmbeddedQmdSkillFiles } from "../embedded-skills.js";
-import { createEmbeddingProvider, ModelMismatchError, } from "../embedding/index.js";
+import { createEmbeddingProvider, loadConfigFile, ModelMismatchError, } from "../embedding/index.js";
 import { commercialApiHold } from "../model-policy.js";
+function describeEmbeddingConfig() {
+    if (process.env.QMD_EMBED_ENDPOINT?.trim())
+        return "configured (QMD_EMBED_ENDPOINT)";
+    const cfg = loadConfigFile();
+    if (cfg.embedProvider?.endpoint?.trim())
+        return "configured (config.json)";
+    if ((process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY || "").trim()) {
+        return "configured (native GEMINI_API_KEY)";
+    }
+    return "HOLD (no QMD_EMBED_ENDPOINT / config.json endpoint / GEMINI_API_KEY)";
+}
 // Enable production mode - allows using default database path
 // Tests must set INDEX_PATH or use createStore() with explicit path
 enableProductionMode();
@@ -347,7 +358,7 @@ async function showStatus() {
     }
     console.log(`\n${c.bold}Learned model policy${c.reset}`);
     console.log("  Runtime:     commercial API only");
-    console.log(`  Embeddings:  ${process.env.QMD_EMBED_ENDPOINT ? "configured" : "HOLD (QMD_EMBED_ENDPOINT missing)"}`);
+    console.log(`  Embeddings:  ${describeEmbeddingConfig()}`);
     console.log("  Local model: disabled");
     // Tips section
     const tips = [];

+ 4 - 0
dist/embedding/factory.d.ts

@@ -52,6 +52,10 @@ export type CreateEmbeddingProviderOptions = {
  * logging and tests.
  */
 export declare function resolveProviderKind(opts?: CreateEmbeddingProviderOptions): ProviderKind;
+/** Google Gemini OpenAI-compat embeddings base (qmd appends `/v1/embeddings`). */
+export declare const GEMINI_OPENAI_EMBED_ENDPOINT = "https://generativelanguage.googleapis.com/v1beta/openai";
+export declare const GEMINI_EMBED_MODEL = "gemini-embedding-001";
+export declare function isGeminiEmbeddingsHost(endpoint: string): boolean;
 /**
  * Factory entry point — returns the appropriate `EmbeddingProvider`.
  * Throws if `openai` kind is requested but no endpoint is configured.

+ 35 - 7
dist/embedding/factory.js

@@ -83,6 +83,21 @@ export function resolveProviderKind(opts = {}) {
     // Commercial-only default. Missing endpoint is handled as typed HOLD by the factory.
     return "openai";
 }
+/** Google Gemini OpenAI-compat embeddings base (qmd appends `/v1/embeddings`). */
+export const GEMINI_OPENAI_EMBED_ENDPOINT = "https://generativelanguage.googleapis.com/v1beta/openai";
+export const GEMINI_EMBED_MODEL = "gemini-embedding-001";
+export function isGeminiEmbeddingsHost(endpoint) {
+    try {
+        return new URL(endpoint).hostname.toLowerCase().endsWith("generativelanguage.googleapis.com");
+    }
+    catch {
+        return false;
+    }
+}
+function nativeGeminiKey(env) {
+    const key = (env.GEMINI_API_KEY || env.GOOGLE_API_KEY || "").trim();
+    return key || undefined;
+}
 /**
  * Factory entry point — returns the appropriate `EmbeddingProvider`.
  * Throws if `openai` kind is requested but no endpoint is configured.
@@ -91,23 +106,36 @@ export function createEmbeddingProvider(opts = {}) {
     const env = opts.env ?? process.env;
     const cfg = loadConfigFile(opts.configPath);
     resolveProviderKind(opts);
-    // OpenAI
-    const endpoint = opts.openai?.endpoint ??
+    const geminiKey = nativeGeminiKey(env);
+    // OpenAI (or Gemini's OpenAI-compat layer, using the native Gemini key)
+    let endpoint = opts.openai?.endpoint ??
         env.QMD_EMBED_ENDPOINT ??
         cfg.embedProvider?.endpoint;
     if (!endpoint || endpoint.trim() === "") {
-        throw commercialApiHold('commercial provider requires an endpoint. ' +
-            "Set QMD_EMBED_ENDPOINT env var, or `embedProvider.endpoint` in " +
-            "~/.config/qmd/config.json, or pass `openai.endpoint`.");
+        if (geminiKey) {
+            endpoint = GEMINI_OPENAI_EMBED_ENDPOINT;
+        }
+        else {
+            throw commercialApiHold('commercial provider requires an endpoint. ' +
+                "Set QMD_EMBED_ENDPOINT env var, or `embedProvider.endpoint` in " +
+                "~/.config/qmd/config.json, or pass `openai.endpoint`. " +
+                "A GEMINI_API_KEY / GOOGLE_API_KEY is also enough — qmd then uses Gemini native embeddings.");
+        }
     }
     assertCommercialEndpoint(endpoint);
-    const apiKey = opts.openai?.apiKey ??
+    let apiKey = opts.openai?.apiKey ??
         env.QMD_EMBED_API_KEY ??
         cfg.embedProvider?.apiKey;
-    const modelId = opts.openai?.modelId ??
+    if (!apiKey && isGeminiEmbeddingsHost(endpoint)) {
+        apiKey = geminiKey;
+    }
+    let modelId = opts.openai?.modelId ??
         env.QMD_EMBED_MODEL_ID ??
         cfg.embedProvider?.modelId ??
         "embeddinggemma";
+    if (isGeminiEmbeddingsHost(endpoint) && modelId === "embeddinggemma") {
+        modelId = GEMINI_EMBED_MODEL;
+    }
     const upstreamModel = opts.openai?.upstreamModel ??
         env.QMD_EMBED_UPSTREAM_MODEL ??
         cfg.embedProvider?.upstreamModel;

+ 2 - 2
dist/embedding/index.d.ts

@@ -2,5 +2,5 @@
  * embedding/index.ts - re-exports for the embedding provider abstraction.
  */
 export { type EmbeddingProvider, type ProviderKind, type ProviderEmbedding, type ProviderEmbedOptions, type ProviderHealth, ModelMismatchError, assertModelCompatible, } from "./provider.js";
-export { OpenAIEmbeddingsProvider, CircuitBreaker, CircuitOpenError, HttpError, isRetryableStatus, chunkArray, type OpenAIProviderConfig, type CircuitState, DEFAULT_BATCH_SIZE, DEFAULT_TIMEOUT_MS, RETRY_BACKOFFS_MS, } from "./openai.js";
-export { createEmbeddingProvider, resolveProviderKind, assertCommercialEndpoint, loadConfigFile, defaultConfigPath, type CreateEmbeddingProviderOptions, type EmbedProviderConfigFile, } from "./factory.js";
+export { OpenAIEmbeddingsProvider, CircuitBreaker, CircuitOpenError, HttpError, isRetryableStatus, chunkArray, resolveOpenAIEmbeddingIndex, type OpenAIProviderConfig, type CircuitState, DEFAULT_BATCH_SIZE, DEFAULT_TIMEOUT_MS, RETRY_BACKOFFS_MS, } from "./openai.js";
+export { createEmbeddingProvider, resolveProviderKind, assertCommercialEndpoint, loadConfigFile, defaultConfigPath, isGeminiEmbeddingsHost, GEMINI_OPENAI_EMBED_ENDPOINT, GEMINI_EMBED_MODEL, type CreateEmbeddingProviderOptions, type EmbedProviderConfigFile, } from "./factory.js";

+ 2 - 2
dist/embedding/index.js

@@ -2,5 +2,5 @@
  * embedding/index.ts - re-exports for the embedding provider abstraction.
  */
 export { ModelMismatchError, assertModelCompatible, } from "./provider.js";
-export { OpenAIEmbeddingsProvider, CircuitBreaker, CircuitOpenError, HttpError, isRetryableStatus, chunkArray, DEFAULT_BATCH_SIZE, DEFAULT_TIMEOUT_MS, RETRY_BACKOFFS_MS, } from "./openai.js";
-export { createEmbeddingProvider, resolveProviderKind, assertCommercialEndpoint, loadConfigFile, defaultConfigPath, } from "./factory.js";
+export { OpenAIEmbeddingsProvider, CircuitBreaker, CircuitOpenError, HttpError, isRetryableStatus, chunkArray, resolveOpenAIEmbeddingIndex, DEFAULT_BATCH_SIZE, DEFAULT_TIMEOUT_MS, RETRY_BACKOFFS_MS, } from "./openai.js";
+export { createEmbeddingProvider, resolveProviderKind, assertCommercialEndpoint, loadConfigFile, defaultConfigPath, isGeminiEmbeddingsHost, GEMINI_OPENAI_EMBED_ENDPOINT, GEMINI_EMBED_MODEL, } from "./factory.js";

+ 6 - 2
dist/embedding/openai.d.ts

@@ -3,7 +3,9 @@
  *
  * Talks to any endpoint that implements `POST /v1/embeddings` with the OpenAI
  * shape: request `{model, input: string|string[]}`, response
- * `{data: [{embedding: number[], index: number}, ...]}`.
+ * `{data: [{embedding: number[], index?: number}, ...]}`.
+ * `index` is optional: Gemini's OpenAI-compat layer omits it and returns
+ * embeddings in input order (session 7fcfc297). Missing index → array position.
  *
  * Used by qmd to delegate embeddings to an approved commercial HTTPS API.
  *
@@ -122,7 +124,7 @@ export type OpenAIEmbeddingsResponse = {
     model?: string;
     data: Array<{
         object?: string;
-        index: number;
+        index?: number;
         embedding: number[];
     }>;
     usage?: {
@@ -130,6 +132,8 @@ export type OpenAIEmbeddingsResponse = {
         total_tokens?: number;
     };
 };
+/** Map an OpenAI-compat embedding row onto the input slot. Missing `index` = row order. */
+export declare function resolveOpenAIEmbeddingIndex(index: unknown, position: number, inputCount: number): number;
 /**
  * Circuit breaker state — exported for tests
  */

+ 18 - 10
dist/embedding/openai.js

@@ -3,7 +3,9 @@
  *
  * Talks to any endpoint that implements `POST /v1/embeddings` with the OpenAI
  * shape: request `{model, input: string|string[]}`, response
- * `{data: [{embedding: number[], index: number}, ...]}`.
+ * `{data: [{embedding: number[], index?: number}, ...]}`.
+ * `index` is optional: Gemini's OpenAI-compat layer omits it and returns
+ * embeddings in input order (session 7fcfc297). Missing index → array position.
  *
  * Used by qmd to delegate embeddings to an approved commercial HTTPS API.
  *
@@ -78,6 +80,14 @@ export const CIRCUIT_WINDOW_MS = 60_000;
 export const CIRCUIT_OPEN_DURATION_MS = 5 * 60_000;
 export const CIRCUIT_FAILURE_RATE_THRESHOLD = 0.5;
 export const CIRCUIT_MIN_SAMPLES = 4;
+/** Map an OpenAI-compat embedding row onto the input slot. Missing `index` = row order. */
+export function resolveOpenAIEmbeddingIndex(index, position, inputCount) {
+    const idx = typeof index === "number" ? index : position;
+    if (!Number.isInteger(idx) || idx < 0 || idx >= inputCount) {
+        throw new Error(`OpenAIEmbeddingsProvider: data item index out of range (${String(index)}, expected 0..${inputCount - 1})`);
+    }
+    return idx;
+}
 // ─────────────────────────── Helpers ─────────────────────────────────────────
 function defaultSleep(ms) {
     return new Promise((resolve) => setTimeout(resolve, ms));
@@ -823,18 +833,16 @@ export class OpenAIEmbeddingsProvider {
             if (!parsed || !Array.isArray(parsed.data)) {
                 throw new Error(`OpenAIEmbeddingsProvider: response missing "data" array (got ${typeof parsed})`);
             }
-            // Sort by index to match input order (in case server returns out-of-order).
+            // Prefer `index` when present (out-of-order servers). Gemini's
+            // OpenAI-compat layer omits `index` and returns rows in input order.
             const out = new Array(texts.length);
-            for (const item of parsed.data) {
-                if (typeof item.index !== "number" ||
-                    item.index < 0 ||
-                    item.index >= texts.length) {
-                    throw new Error(`OpenAIEmbeddingsProvider: data item index out of range (${item.index}, expected 0..${texts.length - 1})`);
-                }
+            for (let i = 0; i < parsed.data.length; i++) {
+                const item = parsed.data[i];
+                const idx = resolveOpenAIEmbeddingIndex(item.index, i, texts.length);
                 if (!Array.isArray(item.embedding)) {
-                    throw new Error(`OpenAIEmbeddingsProvider: data[${item.index}].embedding is not an array`);
+                    throw new Error(`OpenAIEmbeddingsProvider: data[${idx}].embedding is not an array`);
                 }
-                out[item.index] = item.embedding;
+                out[idx] = item.embedding;
             }
             // Sanity check — every slot must be filled
             for (let i = 0; i < texts.length; i++) {

+ 7 - 0
dist/store.d.ts

@@ -370,6 +370,13 @@ export type EmbedOptions = {
      */
     collection?: string;
 };
+/**
+ * Record how many chunks a document was split into. Called at chunk time, before
+ * the embeddings are attempted — see {@link PENDING_EMBEDDING_PREDICATE}.
+ * `INSERT OR REPLACE` because a chunkStrategy change legitimately changes the
+ * count, and the newest chunking is the one the vectors will match.
+ */
+export declare function recordDocumentChunkCount(db: Database, hash: string, chunks: number, chunkedAt: string): void;
 /**
  * Generate vector embeddings for documents that need them.
  * Pure function — no console output, no db lifecycle management.

+ 77 - 12
dist/store.js

@@ -709,6 +709,25 @@ function initializeDatabase(db) {
       embedded_at TEXT NOT NULL,
       PRIMARY KEY (hash, seq)
     )
+  `);
+    // How many chunks a document was split into, recorded at chunk time (i-xeekgx6h).
+    //
+    // Without it "is this document embedded?" can only be asked as "does chunk 0
+    // exist?", and chunk inserts are per-chunk best-effort — so a document whose
+    // chunk 0 embedded and whose chunk 3 did not looked complete forever, and the
+    // 30-minute embed cron never picked it up again. A partially embedded document
+    // does not fail loudly: it answers semantic search with plausible-but-incomplete
+    // results.
+    //
+    // Written BEFORE the chunks are embedded, not after, so that a run which dies
+    // mid-document still leaves the expectation behind for the next run to compare
+    // against.
+    db.exec(`
+    CREATE TABLE IF NOT EXISTS document_chunk_counts (
+      hash TEXT PRIMARY KEY,
+      chunks INTEGER NOT NULL,
+      chunked_at TEXT NOT NULL
+    )
   `);
     // Store collections — makes the DB self-contained (no external config needed)
     db.exec(`
@@ -1029,6 +1048,49 @@ function resolveEmbedOptions(options) {
         maxBatchBytes: validatePositiveIntegerOption("maxBatchBytes", options?.maxBatchBytes, DEFAULT_EMBED_MAX_BATCH_BYTES),
     };
 }
+/**
+ * What "still needs embedding" means, in ONE place (i-xeekgx6h).
+ *
+ * Two conditions, and the second is the one that was missing:
+ *
+ *   1. no vector at seq 0 — the document was never embedded at all. A run that
+ *      fails outright leaves this true, which is why whole-document failures
+ *      always self-healed on the next pass.
+ *   2. fewer vectors than the document has chunks — it was embedded PARTIALLY.
+ *      Chunk inserts are per-chunk best-effort ("so a single bad chunk doesn't
+ *      drag down the rest"), so an upstream that fails mid-document leaves
+ *      chunk 0 present and later chunks missing. Under condition 1 alone that
+ *      document is complete forever and its missing chunks are unreachable.
+ *
+ * Documents embedded BEFORE this table existed have no `document_chunk_counts`
+ * row, so condition 2 cannot fire for them and they are NOT re-embedded — a
+ * whole-corpus re-embed on upgrade would cost more than the holes it heals.
+ * They are repaired the next time they are re-chunked, or by `--force`.
+ *
+ * Requires the caller's FROM clause to alias documents as `d`, and to LEFT JOIN
+ * both `content_vectors v ... AND v.seq = 0` and `document_chunk_counts dcc`.
+ * Keeping the predicate here rather than inline is deliberate: this bug existed
+ * because the list query and the count query each carried their own copy.
+ */
+const PENDING_EMBEDDING_JOINS = `
+      LEFT JOIN content_vectors v ON d.hash = v.hash AND v.seq = 0
+      LEFT JOIN document_chunk_counts dcc ON dcc.hash = d.hash`;
+const PENDING_EMBEDDING_PREDICATE = `(
+        v.hash IS NULL
+        OR (
+          dcc.chunks IS NOT NULL
+          AND (SELECT COUNT(*) FROM content_vectors cv WHERE cv.hash = d.hash) < dcc.chunks
+        )
+      )`;
+/**
+ * Record how many chunks a document was split into. Called at chunk time, before
+ * the embeddings are attempted — see {@link PENDING_EMBEDDING_PREDICATE}.
+ * `INSERT OR REPLACE` because a chunkStrategy change legitimately changes the
+ * count, and the newest chunking is the one the vectors will match.
+ */
+export function recordDocumentChunkCount(db, hash, chunks, chunkedAt) {
+    db.prepare(`INSERT OR REPLACE INTO document_chunk_counts (hash, chunks, chunked_at) VALUES (?, ?, ?)`).run(hash, chunks, chunkedAt);
+}
 function getPendingEmbeddingDocs(db, collection) {
     // `MIN(d.collection)` deterministically picks one collection per hash when
     // the same content is indexed in multiple collections (SQLite tie-breaks
@@ -1045,9 +1107,8 @@ function getPendingEmbeddingDocs(db, collection) {
         return db.prepare(`
       SELECT d.hash, MIN(d.path) as path, MIN(d.collection) as collection, length(CAST(c.doc AS BLOB)) as bytes
       FROM documents d
-      JOIN content c ON d.hash = c.hash
-      LEFT JOIN content_vectors v ON d.hash = v.hash AND v.seq = 0
-      WHERE d.active = 1 AND v.hash IS NULL AND d.collection = ?
+      JOIN content c ON d.hash = c.hash${PENDING_EMBEDDING_JOINS}
+      WHERE d.active = 1 AND ${PENDING_EMBEDDING_PREDICATE} AND d.collection = ?
       GROUP BY d.hash
       ORDER BY MIN(d.path)
     `).all(collection);
@@ -1055,9 +1116,8 @@ function getPendingEmbeddingDocs(db, collection) {
     return db.prepare(`
     SELECT d.hash, MIN(d.path) as path, MIN(d.collection) as collection, length(CAST(c.doc AS BLOB)) as bytes
     FROM documents d
-    JOIN content c ON d.hash = c.hash
-    LEFT JOIN content_vectors v ON d.hash = v.hash AND v.seq = 0
-    WHERE d.active = 1 AND v.hash IS NULL
+    JOIN content c ON d.hash = c.hash${PENDING_EMBEDDING_JOINS}
+    WHERE d.active = 1 AND ${PENDING_EMBEDDING_PREDICATE}
     GROUP BY d.hash
     ORDER BY MIN(d.path)
   `).all();
@@ -1287,6 +1347,10 @@ export async function generateEmbeddings(store, options) {
                 const perCollectionStrategy = collectionStrategies.get(doc.collection);
                 const chunkStrategy = perCollectionStrategy ?? options?.chunkStrategy;
                 const chunks = await chunkDocumentByTokens(doc.body, undefined, undefined, undefined, doc.path, chunkStrategy, session.signal, chunkTokenizer);
+                // Record the expectation BEFORE embedding anything (i-xeekgx6h). If this
+                // run only gets through chunk 0 of 4, the next run compares 1 < 4 and
+                // re-selects the document instead of reading a present chunk 0 as "done".
+                recordDocumentChunkCount(db, doc.hash, chunks.length, now);
                 for (let seq = 0; seq < chunks.length; seq++) {
                     batchChunks.push({
                         hash: doc.hash,
@@ -1608,20 +1672,21 @@ export function handelize(path) {
 export function getHashesNeedingEmbedding(db, collection) {
     // i-ofojj7dy — optional collection filter. Restricts the count to hashes
     // whose documents are in the named collection.
+    // Same predicate as getPendingEmbeddingDocs, from the same constants — the
+    // two carrying independent copies is how the partial-embedding hole survived
+    // (i-xeekgx6h).
     if (collection !== undefined) {
         const result = db.prepare(`
       SELECT COUNT(DISTINCT d.hash) as count
-      FROM documents d
-      LEFT JOIN content_vectors v ON d.hash = v.hash AND v.seq = 0
-      WHERE d.active = 1 AND v.hash IS NULL AND d.collection = ?
+      FROM documents d${PENDING_EMBEDDING_JOINS}
+      WHERE d.active = 1 AND ${PENDING_EMBEDDING_PREDICATE} AND d.collection = ?
     `).get(collection);
         return result.count;
     }
     const result = db.prepare(`
     SELECT COUNT(DISTINCT d.hash) as count
-    FROM documents d
-    LEFT JOIN content_vectors v ON d.hash = v.hash AND v.seq = 0
-    WHERE d.active = 1 AND v.hash IS NULL
+    FROM documents d${PENDING_EMBEDDING_JOINS}
+    WHERE d.active = 1 AND ${PENDING_EMBEDDING_PREDICATE}
   `).get();
     return result.count;
 }

+ 12 - 1
src/cli/qmd.ts

@@ -101,6 +101,7 @@ import {
 import { getEmbeddedQmdSkillContent, getEmbeddedQmdSkillFiles } from "../embedded-skills.js";
 import {
   createEmbeddingProvider,
+  loadConfigFile,
   type EmbeddingProvider,
   type ProviderKind,
   type CreateEmbeddingProviderOptions,
@@ -108,6 +109,16 @@ import {
 } from "../embedding/index.js";
 import { commercialApiHold } from "../model-policy.js";
 
+function describeEmbeddingConfig(): string {
+  if (process.env.QMD_EMBED_ENDPOINT?.trim()) return "configured (QMD_EMBED_ENDPOINT)";
+  const cfg = loadConfigFile();
+  if (cfg.embedProvider?.endpoint?.trim()) return "configured (config.json)";
+  if ((process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY || "").trim()) {
+    return "configured (native GEMINI_API_KEY)";
+  }
+  return "HOLD (no QMD_EMBED_ENDPOINT / config.json endpoint / GEMINI_API_KEY)";
+}
+
 // Enable production mode - allows using default database path
 // Tests must set INDEX_PATH or use createStore() with explicit path
 enableProductionMode();
@@ -467,7 +478,7 @@ async function showStatus(): Promise<void> {
 
   console.log(`\n${c.bold}Learned model policy${c.reset}`);
   console.log("  Runtime:     commercial API only");
-  console.log(`  Embeddings:  ${process.env.QMD_EMBED_ENDPOINT ? "configured" : "HOLD (QMD_EMBED_ENDPOINT missing)"}`);
+  console.log(`  Embeddings:  ${describeEmbeddingConfig()}`);
   console.log("  Local model: disabled");
 
   // Tips section

+ 40 - 9
src/embedding/factory.ts

@@ -131,6 +131,24 @@ export function resolveProviderKind(opts: CreateEmbeddingProviderOptions = {}):
   return "openai";
 }
 
+/** Google Gemini OpenAI-compat embeddings base (qmd appends `/v1/embeddings`). */
+export const GEMINI_OPENAI_EMBED_ENDPOINT =
+  "https://generativelanguage.googleapis.com/v1beta/openai";
+export const GEMINI_EMBED_MODEL = "gemini-embedding-001";
+
+export function isGeminiEmbeddingsHost(endpoint: string): boolean {
+  try {
+    return new URL(endpoint).hostname.toLowerCase().endsWith("generativelanguage.googleapis.com");
+  } catch {
+    return false;
+  }
+}
+
+function nativeGeminiKey(env: Record<string, string | undefined>): string | undefined {
+  const key = (env.GEMINI_API_KEY || env.GOOGLE_API_KEY || "").trim();
+  return key || undefined;
+}
+
 /**
  * Factory entry point — returns the appropriate `EmbeddingProvider`.
  * Throws if `openai` kind is requested but no endpoint is configured.
@@ -142,30 +160,43 @@ export function createEmbeddingProvider(
   const cfg = loadConfigFile(opts.configPath);
   resolveProviderKind(opts);
 
-  // OpenAI
-  const endpoint =
+  const geminiKey = nativeGeminiKey(env);
+
+  // OpenAI (or Gemini's OpenAI-compat layer, using the native Gemini key)
+  let endpoint =
     opts.openai?.endpoint ??
     env.QMD_EMBED_ENDPOINT ??
     cfg.embedProvider?.endpoint;
   if (!endpoint || endpoint.trim() === "") {
-    throw commercialApiHold(
-      'commercial provider requires an endpoint. ' +
-      "Set QMD_EMBED_ENDPOINT env var, or `embedProvider.endpoint` in " +
-      "~/.config/qmd/config.json, or pass `openai.endpoint`.",
-    );
+    if (geminiKey) {
+      endpoint = GEMINI_OPENAI_EMBED_ENDPOINT;
+    } else {
+      throw commercialApiHold(
+        'commercial provider requires an endpoint. ' +
+        "Set QMD_EMBED_ENDPOINT env var, or `embedProvider.endpoint` in " +
+        "~/.config/qmd/config.json, or pass `openai.endpoint`. " +
+        "A GEMINI_API_KEY / GOOGLE_API_KEY is also enough — qmd then uses Gemini native embeddings.",
+      );
+    }
   }
   assertCommercialEndpoint(endpoint);
 
-  const apiKey =
+  let apiKey =
     opts.openai?.apiKey ??
     env.QMD_EMBED_API_KEY ??
     cfg.embedProvider?.apiKey;
+  if (!apiKey && isGeminiEmbeddingsHost(endpoint)) {
+    apiKey = geminiKey;
+  }
 
-  const modelId =
+  let modelId =
     opts.openai?.modelId ??
     env.QMD_EMBED_MODEL_ID ??
     cfg.embedProvider?.modelId ??
     "embeddinggemma";
+  if (isGeminiEmbeddingsHost(endpoint) && modelId === "embeddinggemma") {
+    modelId = GEMINI_EMBED_MODEL;
+  }
 
   const upstreamModel =
     opts.openai?.upstreamModel ??

+ 4 - 0
src/embedding/index.ts

@@ -19,6 +19,7 @@ export {
   HttpError,
   isRetryableStatus,
   chunkArray,
+  resolveOpenAIEmbeddingIndex,
   type OpenAIProviderConfig,
   type CircuitState,
   DEFAULT_BATCH_SIZE,
@@ -32,6 +33,9 @@ export {
   assertCommercialEndpoint,
   loadConfigFile,
   defaultConfigPath,
+  isGeminiEmbeddingsHost,
+  GEMINI_OPENAI_EMBED_ENDPOINT,
+  GEMINI_EMBED_MODEL,
   type CreateEmbeddingProviderOptions,
   type EmbedProviderConfigFile,
 } from "./factory.js";

+ 26 - 15
src/embedding/openai.ts

@@ -3,7 +3,9 @@
  *
  * Talks to any endpoint that implements `POST /v1/embeddings` with the OpenAI
  * shape: request `{model, input: string|string[]}`, response
- * `{data: [{embedding: number[], index: number}, ...]}`.
+ * `{data: [{embedding: number[], index?: number}, ...]}`.
+ * `index` is optional: Gemini's OpenAI-compat layer omits it and returns
+ * embeddings in input order (session 7fcfc297). Missing index → array position.
  *
  * Used by qmd to delegate embeddings to an approved commercial HTTPS API.
  *
@@ -146,7 +148,7 @@ export type OpenAIEmbeddingsResponse = {
   model?: string;
   data: Array<{
     object?: string;
-    index: number;
+    index?: number;
     embedding: number[];
   }>;
   usage?: {
@@ -155,6 +157,21 @@ export type OpenAIEmbeddingsResponse = {
   };
 };
 
+/** Map an OpenAI-compat embedding row onto the input slot. Missing `index` = row order. */
+export function resolveOpenAIEmbeddingIndex(
+  index: unknown,
+  position: number,
+  inputCount: number,
+): number {
+  const idx = typeof index === "number" ? index : position;
+  if (!Number.isInteger(idx) || idx < 0 || idx >= inputCount) {
+    throw new Error(
+      `OpenAIEmbeddingsProvider: data item index out of range (${String(index)}, expected 0..${inputCount - 1})`,
+    );
+  }
+  return idx;
+}
+
 /**
  * Circuit breaker state — exported for tests
  */
@@ -1009,24 +1026,18 @@ export class OpenAIEmbeddingsProvider implements EmbeddingProvider {
         );
       }
 
-      // Sort by index to match input order (in case server returns out-of-order).
+      // Prefer `index` when present (out-of-order servers). Gemini's
+      // OpenAI-compat layer omits `index` and returns rows in input order.
       const out: number[][] = new Array(texts.length);
-      for (const item of parsed.data) {
-        if (
-          typeof item.index !== "number" ||
-          item.index < 0 ||
-          item.index >= texts.length
-        ) {
-          throw new Error(
-            `OpenAIEmbeddingsProvider: data item index out of range (${item.index}, expected 0..${texts.length - 1})`,
-          );
-        }
+      for (let i = 0; i < parsed.data.length; i++) {
+        const item = parsed.data[i]!;
+        const idx = resolveOpenAIEmbeddingIndex(item.index, i, texts.length);
         if (!Array.isArray(item.embedding)) {
           throw new Error(
-            `OpenAIEmbeddingsProvider: data[${item.index}].embedding is not an array`,
+            `OpenAIEmbeddingsProvider: data[${idx}].embedding is not an array`,
           );
         }
-        out[item.index] = item.embedding;
+        out[idx] = item.embedding;
       }
 
       // Sanity check — every slot must be filled

+ 84 - 12
src/store.ts

@@ -878,6 +878,26 @@ function initializeDatabase(db: Database): void {
     )
   `);
 
+  // How many chunks a document was split into, recorded at chunk time (i-xeekgx6h).
+  //
+  // Without it "is this document embedded?" can only be asked as "does chunk 0
+  // exist?", and chunk inserts are per-chunk best-effort — so a document whose
+  // chunk 0 embedded and whose chunk 3 did not looked complete forever, and the
+  // 30-minute embed cron never picked it up again. A partially embedded document
+  // does not fail loudly: it answers semantic search with plausible-but-incomplete
+  // results.
+  //
+  // Written BEFORE the chunks are embedded, not after, so that a run which dies
+  // mid-document still leaves the expectation behind for the next run to compare
+  // against.
+  db.exec(`
+    CREATE TABLE IF NOT EXISTS document_chunk_counts (
+      hash TEXT PRIMARY KEY,
+      chunks INTEGER NOT NULL,
+      chunked_at TEXT NOT NULL
+    )
+  `);
+
   // Store collections — makes the DB self-contained (no external config needed)
   db.exec(`
     CREATE TABLE IF NOT EXISTS store_collections (
@@ -1435,6 +1455,54 @@ function resolveEmbedOptions(options?: EmbedOptions): Required<Pick<EmbedOptions
   };
 }
 
+/**
+ * What "still needs embedding" means, in ONE place (i-xeekgx6h).
+ *
+ * Two conditions, and the second is the one that was missing:
+ *
+ *   1. no vector at seq 0 — the document was never embedded at all. A run that
+ *      fails outright leaves this true, which is why whole-document failures
+ *      always self-healed on the next pass.
+ *   2. fewer vectors than the document has chunks — it was embedded PARTIALLY.
+ *      Chunk inserts are per-chunk best-effort ("so a single bad chunk doesn't
+ *      drag down the rest"), so an upstream that fails mid-document leaves
+ *      chunk 0 present and later chunks missing. Under condition 1 alone that
+ *      document is complete forever and its missing chunks are unreachable.
+ *
+ * Documents embedded BEFORE this table existed have no `document_chunk_counts`
+ * row, so condition 2 cannot fire for them and they are NOT re-embedded — a
+ * whole-corpus re-embed on upgrade would cost more than the holes it heals.
+ * They are repaired the next time they are re-chunked, or by `--force`.
+ *
+ * Requires the caller's FROM clause to alias documents as `d`, and to LEFT JOIN
+ * both `content_vectors v ... AND v.seq = 0` and `document_chunk_counts dcc`.
+ * Keeping the predicate here rather than inline is deliberate: this bug existed
+ * because the list query and the count query each carried their own copy.
+ */
+const PENDING_EMBEDDING_JOINS = `
+      LEFT JOIN content_vectors v ON d.hash = v.hash AND v.seq = 0
+      LEFT JOIN document_chunk_counts dcc ON dcc.hash = d.hash`;
+
+const PENDING_EMBEDDING_PREDICATE = `(
+        v.hash IS NULL
+        OR (
+          dcc.chunks IS NOT NULL
+          AND (SELECT COUNT(*) FROM content_vectors cv WHERE cv.hash = d.hash) < dcc.chunks
+        )
+      )`;
+
+/**
+ * Record how many chunks a document was split into. Called at chunk time, before
+ * the embeddings are attempted — see {@link PENDING_EMBEDDING_PREDICATE}.
+ * `INSERT OR REPLACE` because a chunkStrategy change legitimately changes the
+ * count, and the newest chunking is the one the vectors will match.
+ */
+export function recordDocumentChunkCount(db: Database, hash: string, chunks: number, chunkedAt: string): void {
+  db.prepare(
+    `INSERT OR REPLACE INTO document_chunk_counts (hash, chunks, chunked_at) VALUES (?, ?, ?)`,
+  ).run(hash, chunks, chunkedAt);
+}
+
 function getPendingEmbeddingDocs(db: Database, collection?: string): PendingEmbeddingDoc[] {
   // `MIN(d.collection)` deterministically picks one collection per hash when
   // the same content is indexed in multiple collections (SQLite tie-breaks
@@ -1451,9 +1519,8 @@ function getPendingEmbeddingDocs(db: Database, collection?: string): PendingEmbe
     return db.prepare(`
       SELECT d.hash, MIN(d.path) as path, MIN(d.collection) as collection, length(CAST(c.doc AS BLOB)) as bytes
       FROM documents d
-      JOIN content c ON d.hash = c.hash
-      LEFT JOIN content_vectors v ON d.hash = v.hash AND v.seq = 0
-      WHERE d.active = 1 AND v.hash IS NULL AND d.collection = ?
+      JOIN content c ON d.hash = c.hash${PENDING_EMBEDDING_JOINS}
+      WHERE d.active = 1 AND ${PENDING_EMBEDDING_PREDICATE} AND d.collection = ?
       GROUP BY d.hash
       ORDER BY MIN(d.path)
     `).all(collection) as PendingEmbeddingDoc[];
@@ -1461,9 +1528,8 @@ function getPendingEmbeddingDocs(db: Database, collection?: string): PendingEmbe
   return db.prepare(`
     SELECT d.hash, MIN(d.path) as path, MIN(d.collection) as collection, length(CAST(c.doc AS BLOB)) as bytes
     FROM documents d
-    JOIN content c ON d.hash = c.hash
-    LEFT JOIN content_vectors v ON d.hash = v.hash AND v.seq = 0
-    WHERE d.active = 1 AND v.hash IS NULL
+    JOIN content c ON d.hash = c.hash${PENDING_EMBEDDING_JOINS}
+    WHERE d.active = 1 AND ${PENDING_EMBEDDING_PREDICATE}
     GROUP BY d.hash
     ORDER BY MIN(d.path)
   `).all() as PendingEmbeddingDoc[];
@@ -1740,6 +1806,11 @@ export async function generateEmbeddings(
           chunkTokenizer,
         );
 
+        // Record the expectation BEFORE embedding anything (i-xeekgx6h). If this
+        // run only gets through chunk 0 of 4, the next run compares 1 < 4 and
+        // re-selects the document instead of reading a present chunk 0 as "done".
+        recordDocumentChunkCount(db, doc.hash, chunks.length, now);
+
         for (let seq = 0; seq < chunks.length; seq++) {
           batchChunks.push({
             hash: doc.hash,
@@ -2214,20 +2285,21 @@ export type IndexStatus = {
 export function getHashesNeedingEmbedding(db: Database, collection?: string): number {
   // i-ofojj7dy — optional collection filter. Restricts the count to hashes
   // whose documents are in the named collection.
+  // Same predicate as getPendingEmbeddingDocs, from the same constants — the
+  // two carrying independent copies is how the partial-embedding hole survived
+  // (i-xeekgx6h).
   if (collection !== undefined) {
     const result = db.prepare(`
       SELECT COUNT(DISTINCT d.hash) as count
-      FROM documents d
-      LEFT JOIN content_vectors v ON d.hash = v.hash AND v.seq = 0
-      WHERE d.active = 1 AND v.hash IS NULL AND d.collection = ?
+      FROM documents d${PENDING_EMBEDDING_JOINS}
+      WHERE d.active = 1 AND ${PENDING_EMBEDDING_PREDICATE} AND d.collection = ?
     `).get(collection) as { count: number };
     return result.count;
   }
   const result = db.prepare(`
     SELECT COUNT(DISTINCT d.hash) as count
-    FROM documents d
-    LEFT JOIN content_vectors v ON d.hash = v.hash AND v.seq = 0
-    WHERE d.active = 1 AND v.hash IS NULL
+    FROM documents d${PENDING_EMBEDDING_JOINS}
+    WHERE d.active = 1 AND ${PENDING_EMBEDDING_PREDICATE}
   `).get() as { count: number };
   return result.count;
 }

+ 176 - 0
test/embed-partial-chunks.test.ts

@@ -0,0 +1,176 @@
+/**
+ * embed-partial-chunks.test.ts — a document that embedded chunk 0 and lost its
+ * later chunks must be re-selected on the next pass (i-xeekgx6h).
+ *
+ * Before the fix, "does this document need embedding?" was asked as "is there a
+ * vector at seq 0?", while chunk inserts are per-chunk best-effort. Compose the
+ * two and a document whose first chunk landed during an upstream outage is
+ * complete FOREVER: the 30-minute embed cron never selects it again, and its
+ * missing chunks are unreachable. Nothing errors — semantic search just answers
+ * from a document with holes in it.
+ *
+ * The negative control matters as much as the fix here: a fully embedded corpus
+ * must NOT be re-selected, or the repair costs a full re-embed every pass.
+ *
+ * In-memory SQLite + stub provider — no node-llama-cpp.
+ */
+
+import { describe, test, expect, beforeEach, afterEach } from "vitest";
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+import {
+  createStore,
+  generateEmbeddings,
+  getHashesNeedingEmbedding,
+  type Store,
+} from "../src/store.js";
+import type {
+  EmbeddingProvider,
+  ProviderEmbedding,
+  ProviderHealth,
+} from "../src/embedding/provider.js";
+
+/**
+ * Stub provider that can drop chunks the way an unreachable upstream does.
+ *
+ * `failBatchTextsFrom` is an index into the texts handed to `embedBatch` across
+ * the whole run: everything at or after it comes back `null`, which is exactly
+ * the contract `generateEmbeddings` treats as "this chunk has no embedding" and
+ * skips inserting. `embed()` (the single-text dimension probe) always succeeds —
+ * a failing probe aborts the run before any chunk is written, which is a
+ * different, already-safe path.
+ */
+class DroppingProvider implements EmbeddingProvider {
+  readonly kind = "openai" as const;
+  readonly modelId = "stub-embed";
+  readonly dim = 4;
+  batchTextsSeen = 0;
+  constructor(private readonly failBatchTextsFrom = Number.POSITIVE_INFINITY) {}
+  getModelId(): string { return this.modelId; }
+  getDimensions(): number | undefined { return this.dim; }
+  async healthcheck(): Promise<ProviderHealth> {
+    return { ok: true, model: this.modelId, dimensions: this.dim };
+  }
+  async embed(text: string): Promise<ProviderEmbedding | null> {
+    return { embedding: this.fakeEmbed(text), model: this.modelId };
+  }
+  async embedBatch(texts: string[]): Promise<(ProviderEmbedding | null)[]> {
+    return texts.map((t) => {
+      const index = this.batchTextsSeen++;
+      if (index >= this.failBatchTextsFrom) return null;
+      return { embedding: this.fakeEmbed(t), model: this.modelId };
+    });
+  }
+  async dispose(): Promise<void> {}
+  private fakeEmbed(text: string): number[] {
+    return Array.from({ length: this.dim }, (_, i) => (text.length + i) * 0.01);
+  }
+}
+
+let workDir: string;
+let store: Store;
+
+/** Long enough to chunk into several pieces (~900 tokens/chunk, ~3 chars/token). */
+function multiChunkBody(marker: string): string {
+  const paragraph = `${marker} paragraph about indexing, retrieval and embeddings. `.repeat(40);
+  return Array.from({ length: 8 }, (_, i) => `## Section ${i}\n\n${paragraph}`).join("\n\n");
+}
+
+function vectorCount(hash: string): number {
+  return (store.db.prepare(`SELECT COUNT(*) as n FROM content_vectors WHERE hash = ?`).get(hash) as { n: number }).n;
+}
+
+function recordedChunkCount(hash: string): number | undefined {
+  const row = store.db.prepare(`SELECT chunks FROM document_chunk_counts WHERE hash = ?`).get(hash) as { chunks: number } | undefined;
+  return row?.chunks;
+}
+
+beforeEach(() => {
+  workDir = mkdtempSync(join(tmpdir(), "qmd-partial-chunks-test-"));
+  process.env.INDEX_PATH = join(workDir, "index.sqlite");
+  store = createStore(process.env.INDEX_PATH);
+
+  const now = "2026-08-15T00:00:00Z";
+  store.db
+    .prepare(`INSERT INTO content (hash, doc, created_at) VALUES (?, ?, ?)`)
+    .run("hashPartial", multiChunkBody("alpha"), now);
+  store.db
+    .prepare(`INSERT INTO documents (hash, collection, path, title, created_at, modified_at, active) VALUES (?, ?, ?, ?, ?, ?, ?)`)
+    .run("hashPartial", "alpha", "a.md", "a.md", now, now, 1);
+});
+
+afterEach(() => {
+  try { store.close(); } catch { /* ignore */ }
+  delete process.env.INDEX_PATH;
+  rmSync(workDir, { recursive: true, force: true });
+});
+
+describe("partially embedded documents are re-selected (i-xeekgx6h)", () => {
+  test("chunk 0 embedded + later chunks dropped => document is STILL pending", async () => {
+    // Upstream dies after the first chunk of the document.
+    const dropping = new DroppingProvider(1);
+    await generateEmbeddings(store, { embedProvider: dropping });
+
+    const expected = recordedChunkCount("hashPartial");
+    expect(expected).toBeDefined();
+    expect(expected!).toBeGreaterThan(1); // the body really did chunk into several pieces
+    expect(vectorCount("hashPartial")).toBe(1); // ... and only chunk 0 survived
+
+    // THE assertion. Before the fix this was 0: chunk 0 exists, so the document
+    // read as complete and its missing chunks were unreachable forever.
+    expect(getHashesNeedingEmbedding(store.db)).toBe(1);
+  });
+
+  test("a later pass with a healthy upstream fills the missing chunks", async () => {
+    await generateEmbeddings(store, { embedProvider: new DroppingProvider(1) });
+    const expected = recordedChunkCount("hashPartial")!;
+    expect(vectorCount("hashPartial")).toBe(1);
+
+    const healed = await generateEmbeddings(store, { embedProvider: new DroppingProvider() });
+
+    expect(healed.docsProcessed).toBe(1);
+    expect(vectorCount("hashPartial")).toBe(expected);
+    expect(getHashesNeedingEmbedding(store.db)).toBe(0);
+  });
+
+  test("CONTROL: a fully embedded document is NOT re-selected", async () => {
+    const first = await generateEmbeddings(store, { embedProvider: new DroppingProvider() });
+    expect(first.docsProcessed).toBe(1);
+    expect(getHashesNeedingEmbedding(store.db)).toBe(0);
+
+    // The whole point of the control: without it, "re-select partial documents"
+    // could be satisfied by re-selecting EVERYTHING, i.e. re-embedding the corpus
+    // on every cron tick.
+    const second = await generateEmbeddings(store, { embedProvider: new DroppingProvider() });
+    expect(second.docsProcessed).toBe(0);
+    expect(second.chunksEmbedded).toBe(0);
+  });
+
+  test("CONTROL: a legacy document with no recorded chunk count is left alone", async () => {
+    await generateEmbeddings(store, { embedProvider: new DroppingProvider() });
+
+    // Simulate a document embedded before document_chunk_counts existed, whose
+    // later chunks are missing: no expectation is recorded, so it cannot be
+    // detected as partial. It is deliberately NOT re-selected — a whole-corpus
+    // re-embed on upgrade would cost more than the holes it heals; `--force`
+    // (or the next re-chunk) is the repair path.
+    store.db.prepare(`DELETE FROM document_chunk_counts WHERE hash = ?`).run("hashPartial");
+    store.db.prepare(`DELETE FROM content_vectors WHERE hash = ? AND seq > 0`).run("hashPartial");
+
+    expect(vectorCount("hashPartial")).toBe(1);
+    expect(recordedChunkCount("hashPartial")).toBeUndefined();
+    expect(getHashesNeedingEmbedding(store.db)).toBe(0);
+  });
+
+  test("the collection-filtered count agrees with the unfiltered one", async () => {
+    // The two queries drifting apart is what produced this bug: they each
+    // carried their own copy of the pending predicate.
+    await generateEmbeddings(store, { embedProvider: new DroppingProvider(1) });
+
+    expect(getHashesNeedingEmbedding(store.db)).toBe(1);
+    expect(getHashesNeedingEmbedding(store.db, "alpha")).toBe(1);
+    expect(getHashesNeedingEmbedding(store.db, "nonexistent")).toBe(0);
+  });
+});

+ 21 - 0
test/embedding-factory.test.ts

@@ -122,6 +122,27 @@ describe("resolveProviderKind", () => {
 // ─────────────────────────── createEmbeddingProvider ─────────────────────────
 
 describe("createEmbeddingProvider", () => {
+  test("native GEMINI_API_KEY with no endpoint uses Gemini OpenAI-compat", () => {
+    const p = createEmbeddingProvider({
+      env: { GEMINI_API_KEY: "AIzaSy-test-key" },
+      configPath,
+    }) as OpenAIEmbeddingsProvider & { endpoint: string; apiKey: string };
+    expect(p).toBeInstanceOf(OpenAIEmbeddingsProvider);
+    expect(p["endpoint"]).toBe("https://generativelanguage.googleapis.com/v1beta/openai");
+    expect(p["apiKey"]).toBe("AIzaSy-test-key");
+    expect(p.getModelId()).toBe("gemini-embedding-001");
+  });
+
+  test("GEMINI_API_KEY is not sent to a non-Gemini endpoint", () => {
+    const p = createEmbeddingProvider({
+      env: { QMD_EMBED_ENDPOINT: "https://ai.example.com", GEMINI_API_KEY: "AIzaSy-test-key" },
+      configPath,
+    }) as OpenAIEmbeddingsProvider & { endpoint: string; apiKey: string };
+    expect(p["endpoint"]).toBe("https://ai.example.com");
+    expect(p["apiKey"]).toBeUndefined();
+    expect(p.getModelId()).toBe("embeddinggemma");
+  });
+
   test("openai kind w/ endpoint env → OpenAIEmbeddingsProvider", () => {
     const p = createEmbeddingProvider({
       env: { QMD_EMBED_ENDPOINT: "https://ai.example.com" },

+ 22 - 0
test/embedding-openai.test.ts

@@ -513,6 +513,28 @@ describe("OpenAIEmbeddingsProvider — malformed responses", () => {
     expect(r).toBeNull();
   });
 
+  test("missing index uses row order (Gemini OpenAI-compat)", async () => {
+    const { fetchImpl } = makeFetchSequence([
+      () =>
+        mockResponse(200, {
+          object: "list",
+          model: "gemini-embedding-001",
+          data: [
+            { object: "embedding", embedding: [0.1, 0.2] },
+            { object: "embedding", embedding: [0.7, 0.8] },
+          ],
+        }),
+    ]);
+    const p = new OpenAIEmbeddingsProvider({
+      endpoint: "https://generativelanguage.googleapis.com/v1beta/openai",
+      fetchImpl,
+    });
+    const result = await p.embedBatch(["zero", "one"]);
+    expect(result.length).toBe(2);
+    expect(result[0]!.embedding).toEqual([0.1, 0.2]);
+    expect(result[1]!.embedding).toEqual([0.7, 0.8]);
+  });
+
   test("response handles out-of-order data array (sorts by index)", async () => {
     const { fetchImpl } = makeFetchSequence([
       () =>

+ 28 - 0
test/launcher-symlinked-dist.test.sh

@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+root=$(mktemp -d)
+cleanup() { rm -rf "$root"; }
+trap cleanup EXIT
+
+package="$root/package"
+external_dist="$root/relocated-dist"
+mkdir -p "$package/bin" "$package/node_modules/launcher-probe" "$external_dist/cli"
+cp bin/qmd "$package/bin/qmd"
+touch "$package/package-lock.json"
+ln -s "$external_dist" "$package/dist"
+
+cat >"$package/node_modules/launcher-probe/package.json" <<'JSON'
+{"name":"launcher-probe","type":"module","exports":"./index.js"}
+JSON
+cat >"$package/node_modules/launcher-probe/index.js" <<'JS'
+export const marker = 'symlinked-dist-dependency-resolved';
+JS
+cat >"$external_dist/cli/qmd.js" <<'JS'
+import { marker } from 'launcher-probe';
+console.log(marker);
+JS
+
+output=$("$package/bin/qmd" --version)
+test "$output" = 'symlinked-dist-dependency-resolved'
+printf 'launcher symlinked-dist dependency resolution: OK\n'