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
17 changes: 7 additions & 10 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<void> {
const { createV2Client, resetHostFetch, setHostFetch, setV2Client } =
await loadOpencodeProvider();
resetHostFetch();
const hostConfig = getHostClientConfig(ctx);
if (hostConfig.fetch) {
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions src/services/ai/opencode-provider-loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export async function loadOpencodeProvider(): Promise<OpencodeProviderModule> {
const providerModule: OpencodeProviderModule = await import("./opencode-provider.js");
return providerModule;
}

type OpencodeProviderModule = typeof import("./opencode-provider.js");
5 changes: 3 additions & 2 deletions src/services/ai/opencode-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> = new Set();
let _v2Client: OpencodeClient | undefined;
Expand Down Expand Up @@ -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<T> {
Expand Down
39 changes: 39 additions & 0 deletions src/services/ai/opencode-sdk-client.ts
Original file line number Diff line number Diff line change
@@ -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<OpencodeClient> {
const sdk = (await import(getOpencodeSdkClientSpecifier())) as {
readonly createOpencodeClient: CreateOpencodeClient;
};
return sdk.createOpencodeClient({ baseUrl });
}

export function createLazyV2Client(baseUrl: string): OpencodeClient {
let sdkClientPromise: Promise<OpencodeClient> | undefined;
const getSdkClient = (): Promise<OpencodeClient> => {
sdkClientPromise ??= createSdkClient(baseUrl);
return sdkClientPromise;
};

return {
session: {
create: async (...args: Parameters<OpencodeClient["session"]["create"]>) => {
const client = await getSdkClient();
return client.session.create(...args);
},
prompt: async (...args: Parameters<OpencodeClient["session"]["prompt"]>) => {
const client = await getSdkClient();
return client.session.prompt(...args);
},
delete: async (...args: Parameters<OpencodeClient["session"]["delete"]>) => {
const client = await getSdkClient();
return client.session.delete(...args);
},
},
} as OpencodeClient;
}
3 changes: 2 additions & 1 deletion src/services/ai/profile-llm-client.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -17,7 +18,7 @@ export async function getOpenCodeClient(): Promise<OpencodeClient> {
return _cachedClient;
}

const { isProviderConnected, getV2Client } = await import("./opencode-provider.js");
const { isProviderConnected, getV2Client } = await loadOpencodeProvider();

if (!isProviderConnected(provider)) {
throw new Error(
Expand Down
3 changes: 2 additions & 1 deletion src/services/auto-capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions src/services/user-memory-learning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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", {
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion src/services/user-profile/ai-cleanup.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);
Expand Down
7 changes: 4 additions & 3 deletions src/services/user-profile/user-profile-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1757,7 +1758,7 @@ Generate a concise, abstract description of the user's general behavioral tenden
systemPrompt: string,
userPrompt: string
): Promise<string | null> {
const { generateStructuredOutput } = await import("../ai/opencode-provider.js");
const { generateStructuredOutput } = await loadOpencodeProvider();
const { getOpenCodeClient } = await import("../ai/profile-llm-client.js");

let v2Client;
Expand Down
30 changes: 17 additions & 13 deletions tests/auto-capture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-"));
Expand Down Expand Up @@ -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)});
Expand Down
53 changes: 52 additions & 1 deletion tests/plugin-bundle-boundary.test.ts
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand All @@ -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");
});
});
2 changes: 1 addition & 1 deletion tests/plugin-host-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
});
Expand Down
Loading