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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
}
},
"scripts": {
"build": "bunx tsc && bun scripts/copy-web-assets.mjs",
"build": "rm -rf dist && bunx tsc && bun scripts/copy-web-assets.mjs",
"dev": "tsc --watch",
"typecheck": "tsc --noEmit",
"format": "prettier --write \"src/**/*.{ts,js,css,html}\"",
Expand Down
69 changes: 53 additions & 16 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,43 @@ 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";

export function isStructuredSummaryPromptMessage(userMessage: string): boolean {
// This is the plugin's own structured-summary request. OpenCode echoes it
// through chat.message like a normal user message, but capturing it would
// create self-referential memories about the memory prompt instead of the
// user's conversation.
return userMessage.includes("Analyze this conversation.") && userMessage.includes('type="skip"');
}

export function configureOpencodeHostTransport(ctx: {
readonly client: unknown;
readonly serverUrl?: string | URL;
}): void {
resetHostFetch();
const hostConfig = getHostClientConfig(ctx);
if (hostConfig.fetch) {
setHostFetch(hostConfig.fetch);
} else {
log("OpenCode host fetch unavailable; falling back to global fetch", {
clientKeys: hostConfig.clientKeys,
sdkConfigCount: hostConfig.sdkConfigCount,
});
}

const serverUrl = hostConfig.baseUrl ?? ctx.serverUrl;
if (serverUrl) {
setV2Client(createV2Client(serverUrl));
}
}

function logAutoCaptureProviderStatus(): void {
if (!CONFIG.autoCaptureEnabled || CONFIG.autoCaptureProviderStatus.ready) return;
Expand Down Expand Up @@ -52,11 +89,10 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => {
})();
}

configureOpencodeHostTransport(ctx);

(async () => {
try {
const { setConnectedProviders, setV2Client, createV2Client } =
await import("./services/ai/opencode-provider.js");
setV2Client(createV2Client(ctx.serverUrl));
const providerResult = await ctx.client.provider.list();
if (providerResult.data?.connected) {
setConnectedProviders(providerResult.data.connected);
Expand Down Expand Up @@ -175,6 +211,10 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => {
const userMessage = textParts.map((p) => p.text).join("\n");
if (!userMessage.trim()) return;

if (isStructuredSummaryPromptMessage(userMessage)) {
return;
}

userPromptManager.savePrompt(input.sessionID, output.message.id, directory, userMessage);

const messagesResponse = await ctx.client.session.messages({
Expand Down Expand Up @@ -274,19 +314,16 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => {
limit: tool.schema.number().optional(),
scope: tool.schema.enum(["project", "all-projects"]).optional(),
},
async execute(
args: {
mode?: "add" | "search" | "profile" | "list" | "forget" | "help";
content?: string;
query?: string;
tags?: string;
type?: MemoryType;
memoryId?: string;
limit?: number;
scope?: MemoryScope;
},
toolCtx: { sessionID: string }
) {
async execute(args: {
mode?: "add" | "search" | "profile" | "list" | "forget" | "help";
content?: string;
query?: string;
tags?: string;
type?: MemoryType;
memoryId?: string;
limit?: number;
scope?: MemoryScope;
}) {
if (!isConfigured()) {
return JSON.stringify({
success: false,
Expand Down
48 changes: 48 additions & 0 deletions src/services/ai/opencode-diagnostics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
export interface FetchEndpoint {
readonly label: string;
readonly url: string;
}

export function diagnosticUrl(url: string): string {
try {
const parsed = new URL(url);
parsed.search = "";
return parsed.toString();
} catch {
return url.split("?")[0] ?? url;
}
}

export function responseStatus(res: Response): string {
const statusTextByCode: Record<number, string> = {
500: "Internal Server Error",
502: "Bad Gateway",
};
return `${res.status} ${res.statusText || statusTextByCode[res.status] || "Unknown Status"}`;
}

function redactedBody(text: string): string {
return text ? "<redacted response body>" : "<empty body>";
}

export async function readJson<T>(res: Response, endpoint: FetchEndpoint): Promise<T> {
const text = await res.text();
const url = diagnosticUrl(endpoint.url);
if (!res.ok) {
throw new Error(
`opencode-mem: opencode ${endpoint.label} failed at ${url} (${responseStatus(res)}): ${redactedBody(text)}`
);
}
if (!text) {
throw new Error(
`opencode-mem: opencode ${endpoint.label} at ${url} returned an empty response body`
);
}
try {
return JSON.parse(text) as T;
} catch {
throw new Error(
`opencode-mem: opencode ${endpoint.label} at ${url} returned non-JSON body: ${redactedBody(text)}`
);
}
}
57 changes: 57 additions & 0 deletions src/services/ai/opencode-host-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
export type HostClientConfig = {
readonly baseUrl: string | undefined;
readonly fetch: typeof fetch | undefined;
readonly clientKeys: readonly string[];
readonly sdkConfigCount: number;
};

export function getHostClientConfig(ctx: { readonly client: unknown }): HostClientConfig {
const client = toRecord(ctx.client);
if (!client) {
return { baseUrl: undefined, fetch: undefined, clientKeys: [], sdkConfigCount: 0 };
}

const configs = sdkConfigs(client);
const baseUrl = configs.find((config) => typeof config["baseUrl"] === "string")?.["baseUrl"];
const customFetch = configs.find((config) => isFetch(config["fetch"]))?.["fetch"];

return {
baseUrl: typeof baseUrl === "string" ? baseUrl : undefined,
fetch: isFetch(customFetch) ? customFetch : undefined,
clientKeys: Object.keys(client),
sdkConfigCount: configs.length,
};
}

function sdkConfigs(client: Record<string, unknown>): Record<string, unknown>[] {
const configs: Record<string, unknown>[] = [];
const nestedClients = Object.values(client).flatMap((value): Record<string, unknown>[] => {
const record = toRecord(value);
return record ? [record] : [];
});
const candidates: Record<string, unknown>[] = [client, ...nestedClients];

for (const candidate of candidates) {
const sdkClient = toRecord(candidate["_client"]);
const getConfig = sdkClient?.["getConfig"];
if (!isConfigGetter(getConfig)) continue;

const config = toRecord(getConfig.call(sdkClient));
if (config) configs.push(config);
}

return configs;
}

function toRecord(value: unknown): Record<string, unknown> | undefined {
if (typeof value !== "object" || value === null) return undefined;
return value as Record<string, unknown>;
}

function isConfigGetter(value: unknown): value is (this: unknown) => unknown {
return typeof value === "function";
}

function isFetch(value: unknown): value is typeof fetch {
return typeof value === "function";
}
Loading
Loading