Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 31 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,22 +231,29 @@ Embeddings power similarity search for memories and the user profile. Configure

**Remote (OpenAI-compatible):** set both `embeddingApiUrl` and `embeddingApiKey`. The plugin then calls `{embeddingApiUrl}/embeddings` with a Bearer token. `embeddingApiKey` accepts the same secret formats as `memoryApiKey` (`literal`, `env://…`, `file://…`).

| Key | Role |
| --------------------- | ----------------------------------------------------------------------------------------- |
| `embeddingModel` | Hugging Face id (local) or API model name (remote). Default: `Xenova/nomic-embed-text-v1` |
| `embeddingDimensions` | Optional override; usually omit — dimensions are looked up from a built-in map |
| `embeddingApiUrl` | Base URL for an OpenAI-compatible embeddings API (no trailing path beyond `/v1`) |
| `embeddingApiKey` | API key for that endpoint (required together with `embeddingApiUrl`) |
| Key | Role |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `embeddingModel` | Hugging Face id (local) or API model name (remote). Default: `Xenova/nomic-embed-text-v1` |
| `embeddingDimensions` | Optional override; usually omit — dimensions are looked up from a built-in map |
| `embeddingPooling` | Local pooling: `"mean"` (default), `"cls"`, or `"last_token"`. Unset → small known-model preset or `"mean"` |
| `embeddingQueryPrefix` | Prefix for query-task embeddings. Unset → model preset or Nomic when `embeddingUseTaskPrefixes` is true. Explicit `""` disables |
| `embeddingDocumentPrefix` | Prefix for document-task embeddings (same resolution rules as `embeddingQueryPrefix`) |
| `embeddingUseTaskPrefixes` | Opt-in Nomic `search_query:` / `search_document:` prefixes when custom/preset prefixes are unset. Default `false` |
| `embeddingDtype` | Optional local ONNX dtype override (e.g. `"q8"`, `"fp32"`). When set, passed to transformers.js `pipeline({ dtype })` |
| `embeddingApiUrl` | Base URL for an OpenAI-compatible embeddings API (no trailing path beyond `/v1`) |
| `embeddingApiKey` | API key for that endpoint (required together with `embeddingApiUrl`) |

Recommended local models:

| Model | Dims | Notes |
| ------------------------------------ | ---- | ----------------------------------- |
| `Xenova/nomic-embed-text-v1` | 768 | Default; multilingual, 8192 context |
| `Xenova/jina-embeddings-v2-base-en` | 768 | English-only, 8192 context |
| `Xenova/jina-embeddings-v2-small-en` | 512 | Faster, 8192 context |
| `Xenova/all-MiniLM-L6-v2` | 384 | Very fast, 512 context |
| `Xenova/all-mpnet-base-v2` | 768 | Good quality, 512 context |
| Model | Dims | Notes |
| ------------------------------------ | ---- | ------------------------------------------------- |
| `Xenova/nomic-embed-text-v1` | 768 | Default; multilingual, 8192 context; mean pooling |
| `Xenova/jina-embeddings-v2-base-en` | 768 | English-only, 8192 context |
| `Xenova/jina-embeddings-v2-small-en` | 512 | Faster, 8192 context |
| `Xenova/all-MiniLM-L6-v2` | 384 | Very fast, 512 context |
| `Xenova/all-mpnet-base-v2` | 768 | Good quality, 512 context |
| `Xenova/bge-m3` | 1024 | Multilingual; auto CLS pooling |
| `intfloat/multilingual-e5-large` | 1024 | Auto `query:` / `passage:` prefixes |

Example — remote OpenAI embeddings:

Expand All @@ -258,7 +265,17 @@ Example — remote OpenAI embeddings:
}
```

Changing `embeddingModel` (or dimensions) can trigger re-embedding of stored memories on next startup. Prefer picking a model once and sticking with it for a given data directory.
Example — local bge-m3 (pooling defaults to CLS via preset; override if needed):

```jsonc
{
"embeddingModel": "Xenova/bge-m3",
// "embeddingPooling": "cls",
// "embeddingDtype": "q8",
}
```

Changing `embeddingModel`, dimensions, pooling, or task prefixes can require re-embedding stored memories so store and query vectors stay aligned. Prefer picking a model (and pooling/prefix settings) once and sticking with them for a given data directory.

**Unsupported — Intel Mac (`darwin/x64`):** Local persistence requires `@tursodatabase/database`, which does not publish an Intel Mac native binding. Fixed `onnxruntime-node` releases (`1.24.1+`, including the pinned `1.30.0`) also lack darwin/x64. Use an Apple Silicon Mac, Linux, or Windows, or a remote endpoint via `embeddingApiUrl` + `embeddingApiKey` (example above). On supported platforms, `opencode-mem` pins `onnxruntime-node@1.30.0` (Ort::Env teardown fix from `1.24.1` / #225) and loads transformers through a CJS resolve shim so OpenCode nested installs keep that binding. Transformers is resolved to an absolute path before that shim is installed so OpenCode's Bun `--compile` host does not fail with `Cannot find module '@huggingface/transformers' from ''`. After upgrading, clear OpenCode's nested plugin cache (`~/.cache/opencode/packages/opencode-mem@*`) and reinstall.

Expand Down
14 changes: 14 additions & 0 deletions opencode-mem.example.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,18 @@
// re-index existing memories so store and query vectors stay aligned.
"embeddingUseTaskPrefixes": false,

// Local pooling: "mean" (default) | "cls" | "last_token".
// Unset → model preset (bge-m3=cls, Qwen3-Embedding=last_token) or "mean".
// "embeddingPooling": "mean",

// Custom task prefixes (override presets / Nomic). Explicit "" disables a side.
// Changing pooling or prefixes requires re-embedding stored memories.
// "embeddingQueryPrefix": "query: ",
// "embeddingDocumentPrefix": "passage: ",

// Optional local ONNX dtype override (e.g. "q8", "fp32"). When unset, transformers.js defaults apply.
// "embeddingDtype": "q8",

// Auto-detected dimensions (no need to set manually)
// "embeddingDimensions": 768,

Expand All @@ -34,6 +46,8 @@
// "embeddingModel": "Xenova/jina-embeddings-v2-small-en", // 512 dims, faster, 8192 context
// "embeddingModel": "Xenova/all-MiniLM-L6-v2", // 384 dims, very fast, 512 context
// "embeddingModel": "Xenova/all-mpnet-base-v2", // 768 dims, good quality, 512 context
// "embeddingModel": "Xenova/bge-m3", // 1024 dims, CLS pooling (auto)
// "embeddingModel": "intfloat/multilingual-e5-large", // 1024 dims, query:/passage: prefixes (auto)

// Optional: OpenAI-compatible API for embeddings (both URL and key required)
// "embeddingApiUrl": "https://api.openai.com/v1",
Expand Down
156 changes: 153 additions & 3 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,26 @@ interface OpenCodeMemConfig {
embeddingApiKey?: string;
/** Opt-in Nomic task prefixes (`search_document:` / `search_query:`). Default false. */
embeddingUseTaskPrefixes?: boolean;
/**
* Local transformers.js pooling strategy. Default `"mean"`.
* Model presets (bge-m3 → cls, Qwen3-Embedding → last_token) apply when unset.
*/
embeddingPooling?: "mean" | "cls" | "last_token";
/**
* Prefix applied to query-task embeddings. When unset, model presets or
* `embeddingUseTaskPrefixes` (Nomic) may supply one. Explicit `""` disables.
*/
embeddingQueryPrefix?: string;
/**
* Prefix applied to document-task embeddings. Same resolution rules as
* `embeddingQueryPrefix`.
*/
embeddingDocumentPrefix?: string;
/**
* Optional dtype for the local ONNX pipeline (e.g. `"q8"`, `"fp32"`).
* When set, passed to `pipeline({ dtype })` and outranks auto-detection.
*/
embeddingDtype?: string;
similarityThreshold?: number;
maxMemories?: number;
maxProfileItems?: number;
Expand Down Expand Up @@ -158,6 +178,9 @@ const DEFAULTS: Required<
OpenCodeMemConfig,
| "embeddingApiUrl"
| "embeddingApiKey"
| "embeddingQueryPrefix"
| "embeddingDocumentPrefix"
| "embeddingDtype"
| "memoryModel"
| "memoryApiUrl"
| "memoryApiKey"
Expand All @@ -180,6 +203,9 @@ const DEFAULTS: Required<
> & {
embeddingApiUrl?: string;
embeddingApiKey?: string;
embeddingQueryPrefix?: string;
embeddingDocumentPrefix?: string;
embeddingDtype?: string;
memoryModel?: string;
memoryApiUrl?: string;
memoryApiKey?: string;
Expand Down Expand Up @@ -207,6 +233,7 @@ const DEFAULTS: Required<
embeddingModel: "Xenova/nomic-embed-text-v1",
embeddingDimensions: 768,
embeddingUseTaskPrefixes: false,
embeddingPooling: "mean",
similarityThreshold: 0.6,
maxMemories: 10,
maxProfileItems: 5,
Expand Down Expand Up @@ -341,6 +368,18 @@ const CONFIG_TEMPLATE = `{
// Opt-in Nomic task prefixes (search_document: / search_query:). After enabling,
// re-index existing memories so store and query vectors stay aligned.
// "embeddingUseTaskPrefixes": true,

// Local pooling: "mean" (default) | "cls" | "last_token".
// Unset → model preset (bge-m3=cls, Qwen3-Embedding=last_token) or "mean".
// "embeddingPooling": "mean",

// Custom task prefixes (override presets / Nomic). Explicit "" disables a side.
// Changing pooling or prefixes requires re-embedding stored memories.
// "embeddingQueryPrefix": "query: ",
// "embeddingDocumentPrefix": "passage: ",

// Optional local ONNX dtype override (e.g. "q8", "fp32"). When unset, transformers.js defaults apply.
// "embeddingDtype": "q8",

// Auto-detected dimensions (no need to set manually)
// "embeddingDimensions": 768,
Expand All @@ -350,6 +389,8 @@ const CONFIG_TEMPLATE = `{
// "embeddingModel": "Xenova/jina-embeddings-v2-small-en", // 512 dims, faster, 8192 context
// "embeddingModel": "Xenova/all-MiniLM-L6-v2", // 384 dims, very fast, 512 context
// "embeddingModel": "Xenova/all-mpnet-base-v2", // 768 dims, good quality, 512 context
// "embeddingModel": "Xenova/bge-m3", // 1024 dims, CLS pooling (auto)
// "embeddingModel": "intfloat/multilingual-e5-large", // 1024 dims, query:/passage: prefixes (auto)

// Optional: OpenAI-compatible API for embeddings (both URL and key required)
// "embeddingApiUrl": "https://api.openai.com/v1",
Expand Down Expand Up @@ -696,6 +737,91 @@ function ensureConfigExists(): void {

ensureConfigExists();

export type EmbeddingPooling = "mean" | "cls" | "last_token";

export type EmbeddingModelPreset = {
pooling: EmbeddingPooling;
/**
* When present (including `""`), used as the default query/document prefix
* when the corresponding config key is unset. Omitting both keeps Nomic
* opt-in via `embeddingUseTaskPrefixes` (nomic models).
*/
queryPrefix?: string;
documentPrefix?: string;
};

const QWEN3_EMBEDDING_QUERY_PREFIX =
"Instruct: Given a web search query, retrieve relevant passages that answer the query\nQuery: ";

/**
* Known local embedding model presets (substring match on `embeddingModel`).
* Explicit config keys always win over these defaults.
*/
export function getEmbeddingModelPreset(model: string): EmbeddingModelPreset | undefined {
const id = model.toLowerCase();
if (id.includes("bge-m3")) {
return { pooling: "cls", queryPrefix: "", documentPrefix: "" };
}
if (id.includes("multilingual-e5") || id.includes("e5-")) {
return { pooling: "mean", queryPrefix: "query: ", documentPrefix: "passage: " };
}
if (id.includes("qwen3-embedding")) {
return {
pooling: "last_token",
queryPrefix: QWEN3_EMBEDDING_QUERY_PREFIX,
documentPrefix: "",
};
}
if (id.includes("jina-embeddings")) {
return { pooling: "mean", queryPrefix: "", documentPrefix: "" };
}
if (id.includes("nomic-embed")) {
return { pooling: "mean" };
}
return undefined;
}

export function normalizeEmbeddingPooling(value: unknown): EmbeddingPooling {
if (value === "mean" || value === "cls" || value === "last_token") return value;
throw new Error(
`Invalid embeddingPooling config: ${String(value)} (expected "mean" | "cls" | "last_token")`
);
}

/** Dtypes accepted by transformers.js `pipeline({ dtype })`. */
export const LOCAL_ONNX_DTYPES = [
"auto",
"fp32",
"fp16",
"q8",
"int8",
"uint8",
"q4",
"bnb4",
"q4f16",
"q2",
"q2f16",
"q1",
"q1f16",
] as const;

export type LocalOnnxDtype = (typeof LOCAL_ONNX_DTYPES)[number];

export function normalizeEmbeddingDtype(value: unknown): LocalOnnxDtype | undefined {
if (value === undefined || value === null) return undefined;
if (typeof value !== "string") {
throw new Error(`Invalid embeddingDtype config: ${String(value)}`);
}
const trimmed = value.trim();
if (!trimmed) return undefined;
if ((LOCAL_ONNX_DTYPES as readonly string[]).includes(trimmed)) {
return trimmed as LocalOnnxDtype;
}
throw new Error(
`Invalid embeddingDtype config: ${trimmed} (expected one of ${LOCAL_ONNX_DTYPES.join(", ")})`
);
}

function getEmbeddingDimensions(model: string): number {
const dimensionMap: Record<string, number> = {
// Local Xenova models
Expand All @@ -711,10 +837,17 @@ function getEmbeddingDimensions(model: string): number {
"Xenova/all-mpnet-base-v2": 768,
"Xenova/bge-base-en-v1.5": 768,
"Xenova/bge-small-en-v1.5": 384,
"Xenova/bge-m3": 1024,
"Xenova/gte-small": 384,
"Xenova/GIST-small-Embedding-v0": 384,
"Xenova/text-embedding-ada-002": 1536,

// Other local / ONNX community models
"intfloat/multilingual-e5-large": 1024,
"intfloat/multilingual-e5-base": 768,
"intfloat/e5-large-v2": 1024,
"onnx-community/Qwen3-Embedding-0.6B-ONNX": 1024,

// OpenAI API models
"text-embedding-3-small": 1536,
"text-embedding-3-large": 3072,
Expand Down Expand Up @@ -831,9 +964,22 @@ function buildConfig(fileConfig: OpenCodeMemConfig) {
const memoryApiKey =
resolveSecretValue(fileConfig.memoryApiKey) ??
(memoryProvider === "atlas-cloud" ? process.env.ATLASCLOUD_API_KEY : undefined);
const embeddingModel = fileConfig.embeddingModel ?? DEFAULTS.embeddingModel;
const embeddingPreset = getEmbeddingModelPreset(embeddingModel);
const embeddingDimensions =
fileConfig.embeddingDimensions ??
getEmbeddingDimensions(fileConfig.embeddingModel ?? DEFAULTS.embeddingModel);
fileConfig.embeddingDimensions ?? getEmbeddingDimensions(embeddingModel);
const embeddingPooling = normalizeEmbeddingPooling(
fileConfig.embeddingPooling ?? embeddingPreset?.pooling ?? DEFAULTS.embeddingPooling
);
const embeddingQueryPrefix =
fileConfig.embeddingQueryPrefix !== undefined
? fileConfig.embeddingQueryPrefix
: embeddingPreset?.queryPrefix;
const embeddingDocumentPrefix =
fileConfig.embeddingDocumentPrefix !== undefined
? fileConfig.embeddingDocumentPrefix
: embeddingPreset?.documentPrefix;
const embeddingDtype = normalizeEmbeddingDtype(fileConfig.embeddingDtype);
const autoCaptureMaxContextBytes = normalizeAutoCaptureMaxContextBytes(
fileConfig.autoCaptureMaxContextBytes ?? DEFAULTS.autoCaptureMaxContextBytes
);
Expand All @@ -857,10 +1003,14 @@ function buildConfig(fileConfig: OpenCodeMemConfig) {
storagePath: expandPath(fileConfig.storagePath ?? DEFAULTS.storagePath),
userEmailOverride: fileConfig.userEmailOverride,
userNameOverride: fileConfig.userNameOverride,
embeddingModel: fileConfig.embeddingModel ?? DEFAULTS.embeddingModel,
embeddingModel,
embeddingDimensions,
embeddingUseTaskPrefixes:
fileConfig.embeddingUseTaskPrefixes ?? DEFAULTS.embeddingUseTaskPrefixes,
embeddingPooling,
embeddingQueryPrefix,
embeddingDocumentPrefix,
embeddingDtype,
embeddingApiUrl: fileConfig.embeddingApiUrl,
embeddingApiKey: fileConfig.embeddingApiUrl
? resolveSecretValue(fileConfig.embeddingApiKey ?? process.env.OPENAI_API_KEY)
Expand Down
Loading
Loading