Skip to content
Open
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
5 changes: 5 additions & 0 deletions CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,7 @@ Controls semantic search for cross-session memories.
| `local_dtype` | `string` | — | Local provider only. ONNX model dtype passed to the transformers.js feature-extraction pipeline (`auto`, `fp32`, `fp16`, `q8`, `int8`, `uint8`, `q4`, `bnb4`, `q4f16`, `q2`, `q2f16`, `q1`, `q1f16`). Omitted keeps the default `fp32` behavior. |
| `endpoint` | `string` | — | Required for `"openai-compatible"`. |
| `api_key` | `string` | — | Optional API key for remote endpoints. |
| `headers` | `Record<string, string>` | — | User-level only. Additional headers for `"openai-compatible"`; use `{env:VAR}` or `{file:path}` for secrets. A custom `Authorization` header takes precedence over `api_key`. Project config cannot set this field. |

When `provider: "off"`:

Expand All @@ -577,6 +578,10 @@ When `provider: "off"`:

> **Not every provider offers embeddings.** OpenRouter and Anthropic's public API do not expose `/embeddings`; use OpenAI, Voyage, Together, LM Studio, or the bundled `"local"` provider instead. `doctor` will flag 404/405 responses and show the actual error.

> **Authentication is explicit.** Magic Context cannot reuse or delegate to the host's OpenCode/Pi/OMP model-provider credentials or OAuth session. An `"openai-compatible"` embedding backend therefore needs its own `endpoint` and, when required, `api_key` or user-level `headers`. Header values are treated as secrets: project config cannot supply them, and status/doctor diagnostics do not print them.

> **Windows + Bun:** the in-process `"local"` provider is disabled only when the plugin host is Bun on Windows because `onnxruntime-node` can crash the entire host process before JavaScript can recover. Semantic embedding calls degrade to unavailable; FTS5 keyword search and context management continue. Configure `"openai-compatible"` for semantic search or `"off"` to make the fallback explicit. Windows under Node and Bun on macOS/Linux are not disabled by this guard. `doctor` reports this as a warning rather than a local-provider pass.

> **Local provider — `local_dtype` (issue #259):** The default `Xenova/all-MiniLM-L6-v2` model is lightweight but performs poorly when matching queries in one language (e.g. Chinese) to memories in another (e.g. English). A multilingual model such as `Xenova/paraphrase-multilingual-MiniLM-L12-v2` fixes the recall, but its full-precision (`fp32`) ONNX weights are large (~448 MiB) and memory-hungry for a coding-agent process that may run parallel subagents. Set `embedding.local_dtype` to a quantized variant (e.g. `"q8"`) to load a smaller ONNX model (~113 MiB) with comparable retrieval quality and far lower peak RSS. The dtype is passed to the transformers.js `feature-extraction` pipeline and, because it changes the produced vectors, a non-default value folds into the embedding model identity — so switching dtype re-embeds your corpus rather than mixing incompatible vector spaces. Omit the field to keep the default `fp32` behavior; existing installs see zero change on upgrade.

---
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ Then create `magic-context.jsonc` with the one setting the historian needs:

- **Required:** `historian.model` must be a real `provider/model-id`. Without it, the plugin loads but historian runs fail, older history is not summarized, and repeated failures show a `Magic Context — history comparting needs attention` notice.
- **Optional:** `dreamer` and `sidekick` model/disable blocks. Omit them to leave periodic memory consolidation and `/ctx-aug` off.
- **Optional:** `embedding`. Omit it to use the local `Xenova/all-MiniLM-L6-v2`; turning embeddings off removes semantic/embedding-backed search, but keyword search and context management continue.
- **Optional:** `embedding`. Omit it to use the local `Xenova/all-MiniLM-L6-v2`; turning embeddings off removes semantic/embedding-backed search, but keyword search and context management continue. The local provider is unavailable when the plugin host is Bun on Windows; use `openai-compatible` or `off` there. Remote embeddings require their own endpoint/auth configuration—host-provider OAuth and credentials are not delegated.

User-level config is `~/.config/cortexkit/magic-context.jsonc` on macOS/Linux and `%USERPROFILE%\.config\cortexkit\magic-context.jsonc` on Windows (or `$XDG_CONFIG_HOME/cortexkit/magic-context.jsonc` when set). OpenCode Desktop users can use the dashboard's config editor or hand-edit that file; Desktop does not include the CLI setup wizard.

Expand Down
12 changes: 12 additions & 0 deletions assets/magic-context.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1398,6 +1398,18 @@
"description": "API key for remote embedding provider (optional)",
"type": "string"
},
"headers": {
"description": "USER-LEVEL ONLY custom HTTP headers for openai-compatible embedding requests. Use config variable substitution for secrets (for example Authorization: {env:EMBEDDING_AUTHORIZATION}). Custom Authorization takes precedence over api_key. Project config cannot set headers, and status/doctor diagnostics never print header values.",
"type": "object",
"propertyNames": {
"type": "string",
"minLength": 1
},
"additionalProperties": {
"type": "string",
"minLength": 1
}
},
"input_type": {
"description": "Default input_type for stored/indexed (passage) embeddings in the request body. Required by some openai-compatible providers (e.g. NVIDIA NIM). Omitted from the request when unset.",
"type": "string"
Expand Down
33 changes: 33 additions & 0 deletions packages/cli/src/commands/doctor-opencode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { runV22BackfillCommands } from "../lib/v22-backfill-commands";
import {
checkUserMemoriesDreamerCompatibility,
collectNpmReleaseAgeWarnings,
getOpenCodeLocalEmbeddingRuntimeDoctorWarning,
getUserNpmrcPath,
isPinnedOpenCodePluginSpecifier,
migrateLegacyAgentEnabledConfigForDoctor,
Expand All @@ -34,6 +35,38 @@ function migrate(input: Record<string, unknown>) {
return { config: input, logs, result };
}

describe("OpenCode doctor embedding runtime target", () => {
it("warns for a Windows OpenCode CLI even when doctor itself runs under Node", () => {
expect(
getOpenCodeLocalEmbeddingRuntimeDoctorWarning(
{
path: "C:\\Users\\test\\.opencode\\bin\\opencode.exe",
source: "home-bin",
kind: "cli",
version: "1.2.3",
active: true,
},
"win32",
),
).toContain("Bun on Windows");
});

it("does not classify OpenCode Desktop's Electron host as Bun", () => {
expect(
getOpenCodeLocalEmbeddingRuntimeDoctorWarning(
{
path: "C:\\Users\\test\\AppData\\Roaming\\ai.opencode.desktop",
source: "desktop",
kind: "desktop",
version: "unknown",
active: true,
},
"win32",
),
).toBeNull();
});
});

describe("doctor OpenCode legacy agent enabled migration", () => {
it("migrates legacy enabled fields with conflict rules and warning text", () => {
const { config, logs, result } = migrate({
Expand Down
33 changes: 29 additions & 4 deletions packages/cli/src/commands/doctor-opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { isCompactionEnabled } from "@magic-context/core/config/agent-disable";
import { substituteConfigVariables } from "@magic-context/core/config/variable";
import {
type EmbeddingProbeOutcome,
InvalidEmbeddingHeadersError,
parseEmbeddingHeaders,
probeEmbeddingEndpoint,
} from "@magic-context/core/features/magic-context/memory/embedding-probe";
import { getLiveMigrationBlockingProcesses } from "@magic-context/core/features/magic-context/storage-db";
Expand All @@ -34,6 +36,7 @@ import { collectDiagnostics } from "../lib/diagnostics-opencode";
import {
checkLocalEmbeddingRuntime,
formatLocalEmbeddingRuntimeDoctorWarning,
getLocalEmbeddingRuntimeDoctorWarning,
isLocalEmbeddingRuntimeBroken,
} from "../lib/embedding-runtime";
import { bundleIssueReport } from "../lib/logs-opencode";
Expand Down Expand Up @@ -406,11 +409,23 @@ async function runIssueFlow(): Promise<number> {
// resolver error in the log. Shared by the explicit-`local` branch AND the
// no-config / default-provider path (local is the default, so a missing config
// still means local embeddings).
function checkLocalEmbeddingRuntimeForDoctor(): {
export function getOpenCodeLocalEmbeddingRuntimeDoctorWarning(
installation: OpenCodeInstallationReport,
platform: NodeJS.Platform = process.platform,
): string | null {
return getLocalEmbeddingRuntimeDoctorWarning(platform, installation.kind === "cli");
}

function checkLocalEmbeddingRuntimeForDoctor(installation: OpenCodeInstallationReport): {
issues: number;
localRuntimeBroken?: boolean;
unverified?: boolean;
} {
const unavailableWarning = getOpenCodeLocalEmbeddingRuntimeDoctorWarning(installation);
if (unavailableWarning) {
log.warn(unavailableWarning);
return { issues: 0, unverified: true };
}
const runtime = checkLocalEmbeddingRuntime(getOpenCodePluginCacheRoots());
if (isLocalEmbeddingRuntimeBroken(runtime)) {
log.warn(formatLocalEmbeddingRuntimeDoctorWarning(runtime));
Expand All @@ -426,12 +441,13 @@ function checkLocalEmbeddingRuntimeForDoctor(): {

async function checkEmbeddingConfig(
magicContextConfigPath: string,
installation: OpenCodeInstallationReport,
): Promise<{ issues: number; localRuntimeBroken?: boolean; unverified?: boolean }> {
if (!existsSync(magicContextConfigPath)) {
// No config → local provider defaults apply. Still verify the local
// runtime: local is the DEFAULT, so "no config" means local embeddings,
// and a broken onnxruntime-node would silently fail (#128/#6).
return checkLocalEmbeddingRuntimeForDoctor();
return checkLocalEmbeddingRuntimeForDoctor(installation);
}

let rawText: string;
Expand Down Expand Up @@ -469,7 +485,7 @@ async function checkEmbeddingConfig(
}

if (provider === undefined || provider === "local") {
return checkLocalEmbeddingRuntimeForDoctor();
return checkLocalEmbeddingRuntimeForDoctor(installation);
}

if (provider !== "openai-compatible") {
Expand All @@ -482,6 +498,14 @@ async function checkEmbeddingConfig(
const endpoint = typeof embedding?.endpoint === "string" ? embedding.endpoint.trim() : "";
const model = typeof embedding?.model === "string" ? embedding.model.trim() : "";
const apiKey = typeof embedding?.api_key === "string" ? embedding.api_key : undefined;
let headers: Readonly<Record<string, string>> | undefined;
try {
headers = parseEmbeddingHeaders(embedding?.headers);
} catch (error) {
if (!(error instanceof InvalidEmbeddingHeadersError)) throw error;
log.error(error.message);
return { issues: 1 };
}
const inputType =
typeof embedding?.input_type === "string" ? embedding.input_type.trim() : undefined;
const truncateMode =
Expand Down Expand Up @@ -539,6 +563,7 @@ async function checkEmbeddingConfig(
endpoint,
model,
apiKey: apiKey,
...(headers ? { headers } : {}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: When header-only authentication returns 401/403, OpenCode doctor tells the user to check api_key or an environment variable even though the credential is in embedding.headers. Include custom headers and Authorization in the authentication failure guidance.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/cli/src/commands/doctor-opencode.ts, line 558:

<comment>When header-only authentication returns 401/403, OpenCode doctor tells the user to check `api_key` or an environment variable even though the credential is in `embedding.headers`. Include custom headers and `Authorization` in the authentication failure guidance.</comment>

<file context>
@@ -539,6 +555,7 @@ async function checkEmbeddingConfig(
             endpoint,
             model,
             apiKey: apiKey,
+            ...(headers ? { headers } : {}),
             ...(inputType ? { inputType } : {}),
             ...(truncateMode ? { truncate: truncateMode } : {}),
</file context>

...(inputType ? { inputType } : {}),
...(truncateMode ? { truncate: truncateMode } : {}),
timeoutMs: 10_000,
Expand Down Expand Up @@ -1251,7 +1276,7 @@ export async function runDoctor(
// 7b. Validate embedding configuration — runs a real probe against the
// configured endpoint so users catch misconfigured URL / missing env var /
// wrong provider issues before relying on semantic memory search.
const embeddingCheck = await checkEmbeddingConfig(paths.magicContextConfig);
const embeddingCheck = await checkEmbeddingConfig(paths.magicContextConfig, activeInstallation);
issues += embeddingCheck.issues;
if (embeddingCheck.issues > 0) failCount += embeddingCheck.issues;
else if (embeddingCheck.unverified) warnCount++;
Expand Down
122 changes: 122 additions & 0 deletions packages/cli/src/commands/doctor-pi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
import { tmpdir } from "node:os";
import { join } from "node:path";

import type { EmbeddingProbeOptions } from "@magic-context/core/features/magic-context/memory/embedding-probe";
import { LATEST_SUPPORTED_VERSION } from "@magic-context/core/features/magic-context/storage-db";
import { Database } from "@magic-context/core/shared/sqlite";
import { parse as parseJsonc } from "comment-json";
import { openExistingContextDatabase } from "../lib/database-access";
import type { PiDiagnosticReport } from "../lib/diagnostics-pi";
import { getLocalEmbeddingRuntimeDoctorWarning } from "../lib/embedding-runtime";
import type { PromptIO, PromptSpinner, SelectOption } from "../lib/prompts";
import { parseDoctorArgs, type RunDoctorOptions, runDoctor } from "./doctor-pi";

Expand Down Expand Up @@ -173,6 +175,7 @@ function baseOptions(root: string, cwd: string, prompts: MockPrompts): RunDoctor
}),
getPiVersion: () => "0.74.0",
getLatestNpmVersion: () => "0.1.0",
getLocalEmbeddingRuntimeDoctorWarning: () => null,
openExistingContextDatabase: () => createMockDb(),
now: () => new Date("2026-04-28T12:34:56Z"),
execFileSync: () => {
Expand Down Expand Up @@ -348,6 +351,33 @@ describe("Pi doctor", () => {
expect(output).toContain("PASS Embedding provider: local (native runtime present)");
});

it("warns instead of passing local embeddings under Bun on Windows", async () => {
// Given
const root = makeTempRoot();
const cwd = makeTempRoot("mc-pi-doctor-cwd-");
const agentDir = setEnv(root, cwd);
writeHealthyFiles(agentDir, cwd);
createInstalledPiPlugin(agentDir, true);
const prompts = new MockPrompts();

// When
const options = baseOptions(root, cwd, prompts);
if (!options.deps) throw new Error("expected doctor dependencies");
options.deps.getLocalEmbeddingRuntimeDoctorWarning = () =>
getLocalEmbeddingRuntimeDoctorWarning("win32", true);
const code = await runDoctor(options);

// Then
expect(code).toBe(0);
const output = prompts.messages.join("\n");
expect(output).toContain(
"WARN Embedding provider: local is unavailable under Bun on Windows",
);
expect(output).toContain("openai-compatible");
expect(output).toContain("embedding.provider=off");
expect(output).not.toContain("PASS Embedding provider: local (native runtime present)");
});

it("repairs missing package entry and missing user config in --force mode", async () => {
const root = makeTempRoot();
const cwd = makeTempRoot("mc-pi-doctor-cwd-");
Expand Down Expand Up @@ -696,6 +726,98 @@ describe("Pi doctor", () => {
expect(output).not.toContain("api_key=secret");
});

it("passes header-only auth and custom Authorization precedence to the embedding probe", async () => {
const root = makeTempRoot();
const cwd = makeTempRoot("mc-pi-doctor-cwd-");
const agentDir = setEnv(root, cwd);
writeHealthyFiles(agentDir, cwd);
writeFileSync(
join(root, ".config", "cortexkit", "magic-context.jsonc"),
JSON.stringify({
embedding: {
provider: "openai-compatible",
endpoint: "https://example.com/v1",
model: "text-embedding-3-small",
api_key: "fallback-key",
headers: {
Authorization: "Token custom-authorization",
"X-API-Key": "header-only-token",
},
},
}),
);
const prompts = new MockPrompts();
const options = baseOptions(root, cwd, prompts);
let probeOptions: EmbeddingProbeOptions | undefined;

const code = await runDoctor({
...options,
deps: {
...options.deps,
probeEmbeddingEndpoint: async (received) => {
probeOptions = received;
return { kind: "ok", status: 200, dimensions: 3 };
},
},
});

expect(code).toBe(0);
expect(probeOptions?.headers).toEqual({
Authorization: "Token custom-authorization",
"X-API-Key": "header-only-token",
});
expect(probeOptions?.apiKey).toBe("fallback-key");
});

it("fails closed on invalid embedding headers without probing or echoing values", async () => {
const root = makeTempRoot();
const cwd = makeTempRoot("mc-pi-doctor-cwd-");
const agentDir = setEnv(root, cwd);
writeHealthyFiles(agentDir, cwd);
const secret = "benign-name-secret-value";
writeFileSync(
join(root, ".config", "cortexkit", "magic-context.jsonc"),
JSON.stringify({
embedding: {
provider: "openai-compatible",
endpoint: "https://example.com/v1",
model: "text-embedding-3-small",
headers: { "Invalid Header": secret },
},
}),
);
const prompts = new MockPrompts();
const options = baseOptions(root, cwd, prompts);
let probeCalls = 0;
const errors: string[] = [];
const originalError = console.error;
console.error = (message?: unknown) => {
errors.push(String(message));
};

try {
const code = await runDoctor({
...options,
deps: {
...options.deps,
probeEmbeddingEndpoint: async () => {
probeCalls++;
return { kind: "ok", status: 200, dimensions: 3 };
},
},
});

expect(code).toBe(1);
} finally {
console.error = originalError;
}

const output = errors.join("\n");
expect(output).toContain("Invalid embedding.headers");
expect(output).not.toContain(secret);
expect(probeCalls).toBe(0);
});

it("sanitizes thrown embedding probe errors before printing them", async () => {
const root = makeTempRoot();
const cwd = makeTempRoot("mc-pi-doctor-cwd-");
Expand Down
Loading
Loading