diff --git a/src/index.ts b/src/index.ts index 5aa1b72..12bf94b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,14 +16,8 @@ import { log } from "./services/logger.js"; import type { MemoryType } from "./types/index.js"; import { getLanguageName } from "./services/language-detector.js"; import type { MemoryScope } from "./services/client.js"; -import { - createV2Client, - resetHostFetch, - setConnectedProviders, - setHostFetch, - setV2Client, -} from "./services/ai/opencode-provider.js"; import { getHostClientConfig } from "./services/ai/opencode-host-config.js"; +import { loadOpencodeProvider } from "./services/ai/opencode-provider-loader.js"; export function isStructuredSummaryPromptMessage(userMessage: string): boolean { // This is the plugin's own structured-summary request. OpenCode echoes it @@ -33,10 +27,12 @@ export function isStructuredSummaryPromptMessage(userMessage: string): boolean { return userMessage.includes("Analyze this conversation.") && userMessage.includes('type="skip"'); } -export function configureOpencodeHostTransport(ctx: { +export async function configureOpencodeHostTransport(ctx: { readonly client: unknown; readonly serverUrl?: string | URL; -}): void { +}): Promise { + const { createV2Client, resetHostFetch, setHostFetch, setV2Client } = + await loadOpencodeProvider(); resetHostFetch(); const hostConfig = getHostClientConfig(ctx); if (hostConfig.fetch) { @@ -89,12 +85,13 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => { })(); } - configureOpencodeHostTransport(ctx); + await configureOpencodeHostTransport(ctx); (async () => { try { const providerResult = await ctx.client.provider.list(); if (providerResult.data?.connected) { + const { setConnectedProviders } = await loadOpencodeProvider(); setConnectedProviders(providerResult.data.connected); log("opencode providers connected", { list: providerResult.data.connected, diff --git a/src/services/ai/opencode-provider-loader.ts b/src/services/ai/opencode-provider-loader.ts new file mode 100644 index 0000000..b5441dc --- /dev/null +++ b/src/services/ai/opencode-provider-loader.ts @@ -0,0 +1,6 @@ +export async function loadOpencodeProvider(): Promise { + const providerModule: OpencodeProviderModule = await import("./opencode-provider.js"); + return providerModule; +} + +type OpencodeProviderModule = typeof import("./opencode-provider.js"); diff --git a/src/services/ai/opencode-provider.ts b/src/services/ai/opencode-provider.ts index 28ba55e..a979cce 100644 --- a/src/services/ai/opencode-provider.ts +++ b/src/services/ai/opencode-provider.ts @@ -24,13 +24,14 @@ */ import type { z } from "zod"; -import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2/client"; +import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"; import { diagnosticUrl, readJson, responseStatus, type FetchEndpoint, } from "./opencode-diagnostics.js"; +import { createLazyV2Client } from "./opencode-sdk-client.js"; let _connectedProviders: Set = new Set(); let _v2Client: OpencodeClient | undefined; @@ -64,7 +65,7 @@ export function getV2Client(): OpencodeClient | undefined { export function createV2Client(serverUrl: URL | string): OpencodeClient { const baseUrl = typeof serverUrl === "string" ? serverUrl : serverUrl.toString(); _v2BaseUrl = baseUrl; - return createOpencodeClient({ baseUrl }); + return createLazyV2Client(baseUrl); } export interface StructuredOutputOptions { diff --git a/src/services/ai/opencode-sdk-client.ts b/src/services/ai/opencode-sdk-client.ts new file mode 100644 index 0000000..d7b0a72 --- /dev/null +++ b/src/services/ai/opencode-sdk-client.ts @@ -0,0 +1,39 @@ +import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"; + +type CreateOpencodeClient = (config: { readonly baseUrl: string }) => OpencodeClient; + +function getOpencodeSdkClientSpecifier(): string { + return ["@opencode-ai/sdk", "/v2/client"].join(""); +} + +async function createSdkClient(baseUrl: string): Promise { + const sdk = (await import(getOpencodeSdkClientSpecifier())) as { + readonly createOpencodeClient: CreateOpencodeClient; + }; + return sdk.createOpencodeClient({ baseUrl }); +} + +export function createLazyV2Client(baseUrl: string): OpencodeClient { + let sdkClientPromise: Promise | undefined; + const getSdkClient = (): Promise => { + sdkClientPromise ??= createSdkClient(baseUrl); + return sdkClientPromise; + }; + + return { + session: { + create: async (...args: Parameters) => { + const client = await getSdkClient(); + return client.session.create(...args); + }, + prompt: async (...args: Parameters) => { + const client = await getSdkClient(); + return client.session.prompt(...args); + }, + delete: async (...args: Parameters) => { + const client = await getSdkClient(); + return client.session.delete(...args); + }, + }, + } as OpencodeClient; +} diff --git a/src/services/ai/profile-llm-client.ts b/src/services/ai/profile-llm-client.ts index 14b6d04..300a6ba 100644 --- a/src/services/ai/profile-llm-client.ts +++ b/src/services/ai/profile-llm-client.ts @@ -1,5 +1,6 @@ import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"; import { CONFIG } from "../../config.js"; +import { loadOpencodeProvider } from "./opencode-provider-loader.js"; let _cachedClient: OpencodeClient | null = null; let _cachedProvider: string | null = null; @@ -17,7 +18,7 @@ export async function getOpenCodeClient(): Promise { return _cachedClient; } - const { isProviderConnected, getV2Client } = await import("./opencode-provider.js"); + const { isProviderConnected, getV2Client } = await loadOpencodeProvider(); if (!isProviderConnected(provider)) { throw new Error( diff --git a/src/services/auto-capture.ts b/src/services/auto-capture.ts index 2819e2a..af22cca 100644 --- a/src/services/auto-capture.ts +++ b/src/services/auto-capture.ts @@ -4,6 +4,7 @@ import { getTags } from "./tags.js"; import { log } from "./logger.js"; import { CONFIG } from "../config.js"; import { userPromptManager, type UserPrompt } from "./user-prompt/user-prompt-manager.js"; +import { loadOpencodeProvider } from "./ai/opencode-provider-loader.js"; interface ToolCallInfo { name: string; @@ -349,7 +350,7 @@ async function generateSummary( } const { isProviderConnected, getV2Client, generateStructuredOutput } = - await import("./ai/opencode-provider.js"); + await loadOpencodeProvider(); // "inherit" resolves to the model opencode used for the captured prompt // (recorded by the chat.params hook). Without a concrete model id, the diff --git a/src/services/user-memory-learning.ts b/src/services/user-memory-learning.ts index bd571c1..d2120f7 100644 --- a/src/services/user-memory-learning.ts +++ b/src/services/user-memory-learning.ts @@ -7,6 +7,7 @@ import type { UserPrompt } from "./user-prompt/user-prompt-manager.js"; import { userProfileManager } from "./user-profile/user-profile-manager.js"; import { sortProfileItems } from "../utils/profile.js"; import type { UserProfile, UserProfileData } from "./user-profile/types.js"; +import { loadOpencodeProvider } from "./ai/opencode-provider-loader.js"; let isLearningRunning = false; @@ -448,7 +449,7 @@ async function analyzeUserProfile( if (CONFIG.opencodeProvider && CONFIG.opencodeModel) { log("user-profile-learning: trying opencode provider"); try { - const { generateStructuredOutput } = await import("./ai/opencode-provider.js"); + const { generateStructuredOutput } = await loadOpencodeProvider(); const { getOpenCodeClient } = await import("./ai/profile-llm-client.js"); log("user-profile-learning: opencode provider diag", { @@ -692,7 +693,7 @@ If no clear chains, return { "paths": [] }.`; if (CONFIG.opencodeProvider && CONFIG.opencodeModel) { try { const { z } = await import("zod"); - const { generateStructuredOutput } = await import("./ai/opencode-provider.js"); + const { generateStructuredOutput } = await loadOpencodeProvider(); const { getOpenCodeClient } = await import("./ai/profile-llm-client.js"); let v2Client; diff --git a/src/services/user-profile/ai-cleanup.ts b/src/services/user-profile/ai-cleanup.ts index 14f1d52..fbbf969 100644 --- a/src/services/user-profile/ai-cleanup.ts +++ b/src/services/user-profile/ai-cleanup.ts @@ -1,6 +1,7 @@ import type { UserProfileData } from "./types.js"; import { CONFIG } from "../../config.js"; import { log } from "../logger.js"; +import { loadOpencodeProvider } from "../ai/opencode-provider-loader.js"; export interface AICleanupResult { cleaned: UserProfileData; @@ -191,7 +192,7 @@ async function callAICleanup( // Use opencode internal session when opencodeProvider is configured (same pattern as auto-capture) if (CONFIG.opencodeProvider && CONFIG.opencodeModel) { try { - const { getV2Client } = await import("../ai/opencode-provider.js"); + const { getV2Client } = await loadOpencodeProvider(); const v2Client = getV2Client(); if (v2Client) { const result = await callViaOpencodeWithClient(v2Client, prompt); diff --git a/src/services/user-profile/user-profile-manager.ts b/src/services/user-profile/user-profile-manager.ts index bf366f5..05aa617 100644 --- a/src/services/user-profile/user-profile-manager.ts +++ b/src/services/user-profile/user-profile-manager.ts @@ -8,6 +8,7 @@ import { safeArray } from "./profile-utils.js"; import { EmbeddingService } from "../embedding.js"; import { log } from "../logger.js"; import { cosineSimilarityNumbers, l2Normalize } from "../../utils/math.js"; +import { loadOpencodeProvider } from "../ai/opencode-provider-loader.js"; const CENTROID_EMA_WEIGHT = 0.85; const CENTROID_EMA_WEIGHT_COMPLEMENT = 0.15; @@ -1303,7 +1304,7 @@ Answer JSON only: { "duplicate": true|false, "reason": "one sentence explanation if (CONFIG.opencodeProvider && CONFIG.opencodeModel) { try { const { z } = await import("zod"); - const { generateStructuredOutput } = await import("../ai/opencode-provider.js"); + const { generateStructuredOutput } = await loadOpencodeProvider(); const { getOpenCodeClient } = await import("../ai/profile-llm-client.js"); let v2Client; @@ -1503,7 +1504,7 @@ Answer JSON only: { "conflict": true|false, "reason": "one sentence explanation" if (CONFIG.opencodeProvider && CONFIG.opencodeModel) { try { const { z } = await import("zod"); - const { generateStructuredOutput } = await import("../ai/opencode-provider.js"); + const { generateStructuredOutput } = await loadOpencodeProvider(); const { getOpenCodeClient } = await import("../ai/profile-llm-client.js"); let v2Client; @@ -1757,7 +1758,7 @@ Generate a concise, abstract description of the user's general behavioral tenden systemPrompt: string, userPrompt: string ): Promise { - const { generateStructuredOutput } = await import("../ai/opencode-provider.js"); + const { generateStructuredOutput } = await loadOpencodeProvider(); const { getOpenCodeClient } = await import("../ai/profile-llm-client.js"); let v2Client; diff --git a/tests/auto-capture.test.ts b/tests/auto-capture.test.ts index a3690b3..46e3b20 100644 --- a/tests/auto-capture.test.ts +++ b/tests/auto-capture.test.ts @@ -21,8 +21,10 @@ const promptManagerUrl = new URL( ).href; const loggerUrl = new URL("../src/services/logger.js", import.meta.url).href; const languageUrl = new URL("../src/services/language-detector.js", import.meta.url).href; -const opencodeProviderUrl = new URL("../src/services/ai/opencode-provider.js", import.meta.url) - .href; +const opencodeProviderLoaderUrl = new URL( + "../src/services/ai/opencode-provider-loader.js", + import.meta.url +).href; function runScenario() { const dir = mkdtempSync(join(tmpdir(), "opencode-mem-auto-capture-")); @@ -153,17 +155,19 @@ mock.module(${JSON.stringify(languageUrl)}, () => ({ detectLanguage: () => "en", getLanguageName: () => "English", })); -mock.module(${JSON.stringify(opencodeProviderUrl)}, () => ({ - isProviderConnected: () => true, - getV2Client: () => ({}), - generateStructuredOutput: async ({ userPrompt }) => { - summaryPrompts.push(userPrompt); - return { - summary: userPrompt.includes("First request") ? "summary-first" : "summary-second", - type: "discussion", - tags: [], - }; - }, +mock.module(${JSON.stringify(opencodeProviderLoaderUrl)}, () => ({ + loadOpencodeProvider: async () => ({ + isProviderConnected: () => true, + getV2Client: () => ({}), + generateStructuredOutput: async ({ userPrompt }) => { + summaryPrompts.push(userPrompt); + return { + summary: userPrompt.includes("First request") ? "summary-first" : "summary-second", + type: "discussion", + tags: [], + }; + }, + }), })); const { performAutoCapture } = await import(${JSON.stringify(autoCaptureUrl)}); diff --git a/tests/plugin-bundle-boundary.test.ts b/tests/plugin-bundle-boundary.test.ts index af1b739..c183868 100644 --- a/tests/plugin-bundle-boundary.test.ts +++ b/tests/plugin-bundle-boundary.test.ts @@ -1,4 +1,16 @@ -import { describe, expect, it } from "bun:test"; +import { afterEach, describe, expect, it } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); describe("OpenCode plugin loader bundle boundary", () => { it("does not pull local embedding transformer internals into the plugin-loader bundle", async () => { @@ -16,4 +28,43 @@ describe("OpenCode plugin loader bundle boundary", () => { // Guard against the old backend silently coming back too. expect(output).not.toContain("node_modules/@xenova/transformers"); }); + + it("does not pull opencode SDK or OIDC internals into the plugin-loader bundle", async () => { + const result = await Bun.build({ + entrypoints: ["./dist/plugin.js"], + target: "bun", + packages: "bundle", + }); + + expect(result.success).toBe(true); + const output = await result.outputs[0]!.text(); + expect(output).not.toContain("@opencode-ai/sdk/v2/client"); + expect(output).not.toContain("node_modules/@opencode-ai/sdk"); + expect(output).not.toContain("@vercel/oidc"); + expect(output).not.toContain("getVercelOidcToken"); + }); + + it("resolves the provider module from a single-file bundled lazy loader", async () => { + const result = await Bun.build({ + entrypoints: ["./dist/services/ai/opencode-provider-loader.js"], + target: "bun", + packages: "bundle", + }); + + expect(result.success).toBe(true); + const output = await result.outputs[0]!.text(); + expect(output).not.toContain("@opencode-ai/sdk/v2/client"); + expect(output).not.toContain("@vercel/oidc"); + + const dir = mkdtempSync(join(tmpdir(), "opencode-mem-provider-loader-")); + tempDirs.push(dir); + const bundlePath = join(dir, "provider-loader-bundle.mjs"); + writeFileSync(bundlePath, output); + + const mod = await import(`${pathToFileURL(bundlePath).href}?cachebust=${Date.now()}`); + const provider = await mod.loadOpencodeProvider(); + + expect(typeof provider.generateStructuredOutput).toBe("function"); + expect(typeof provider.createV2Client).toBe("function"); + }); }); diff --git a/tests/plugin-host-config.test.ts b/tests/plugin-host-config.test.ts index cd8e698..2fafacd 100644 --- a/tests/plugin-host-config.test.ts +++ b/tests/plugin-host-config.test.ts @@ -81,7 +81,7 @@ describe("OpenCode host client config", () => { globalThis.fetch = fallbackFetch; try { - configureOpencodeHostTransport({ + await configureOpencodeHostTransport({ client: { provider: { list: async () => ({ data: { connected: [] } }) } }, serverUrl: "http://localhost:4096", });