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
162 changes: 95 additions & 67 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { performAutoCapture } from "./services/auto-capture.js";
import { performUserProfileLearning } from "./services/user-memory-learning.js";
import { userPromptManager } from "./services/user-prompt/user-prompt-manager.js";
import { startWebServer, WebServer } from "./services/web-server.js";
import { embeddingService } from "./services/embedding.js";

import { isConfigured, CONFIG, initConfig } from "./config.js";
import { log } from "./services/logger.js";
Expand All @@ -23,19 +24,44 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => {
let webServer: WebServer | null = null;
let idleTimeout: Timer | null = null;

if (!isConfigured()) {
}

const GLOBAL_PLUGIN_WARMUP_KEY = Symbol.for("opencode-mem.plugin.warmedup");
const GLOBAL_PLUGIN_WARMUP_PROMISE_KEY = Symbol.for("opencode-mem.plugin.warmupPromise");
const GLOBAL_PLUGIN_WARMUP_TIMEOUT_MS = 60_000;

if (!(globalThis as any)[GLOBAL_PLUGIN_WARMUP_KEY] && isConfigured()) {
try {
await memoryClient.warmup();
(globalThis as any)[GLOBAL_PLUGIN_WARMUP_KEY] = true;
} catch (error) {
log("Plugin warmup failed", { error: String(error) });
}
}
const startBackgroundWarmup = () => {
if (!isConfigured()) return;

const globalState = globalThis as any;
if (globalState[GLOBAL_PLUGIN_WARMUP_KEY]) return;
if (globalState[GLOBAL_PLUGIN_WARMUP_PROMISE_KEY]) return;

const warmupState: { promise: Promise<void> | null } = { promise: null };
warmupState.promise = (async () => {
try {
await Promise.race([
memoryClient.warmup(),
new Promise((_, reject) => {
setTimeout(
() => reject(new Error("Background warmup timed out")),
GLOBAL_PLUGIN_WARMUP_TIMEOUT_MS
);
}),
]);
globalState[GLOBAL_PLUGIN_WARMUP_KEY] = true;
} catch (error) {
log("Plugin warmup failed", { error: String(error) });
if (String(error).includes("Background warmup timed out")) {
embeddingService.resetWarmupState();
}
} finally {
if (globalState[GLOBAL_PLUGIN_WARMUP_PROMISE_KEY] === warmupState.promise) {
globalState[GLOBAL_PLUGIN_WARMUP_PROMISE_KEY] = null;
}
}
})();
Comment thread
huilang021x marked this conversation as resolved.

globalState[GLOBAL_PLUGIN_WARMUP_PROMISE_KEY] = warmupState.promise;
};

// Wire opencode state path and provider list — fire-and-forget to avoid blocking init
// These calls can hang if opencode isn't fully bootstrapped yet
Expand All @@ -57,76 +83,77 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => {
})();

if (CONFIG.webServerEnabled) {
startWebServer({
port: CONFIG.webServerPort,
host: CONFIG.webServerHost,
enabled: CONFIG.webServerEnabled,
})
.then((server) => {
webServer = server;
const url = webServer.getUrl();

webServer.setOnTakeoverCallback(async () => {
if (ctx.client?.tui) {
ctx.client.tui
.showToast({
body: {
title: "Memory Explorer",
message: "Took over web server ownership",
variant: "success",
duration: 3000,
},
})
.catch(() => {});
}
});
try {
webServer = await startWebServer({
port: CONFIG.webServerPort,
host: CONFIG.webServerHost,
enabled: CONFIG.webServerEnabled,
});

if (webServer.isServerOwner()) {
if (ctx.client?.tui) {
ctx.client.tui
.showToast({
body: {
title: "Memory Explorer",
message: `Web UI started at ${url}`,
variant: "success",
duration: 5000,
},
})
.catch(() => {});
}
} else {
if (ctx.client?.tui) {
ctx.client.tui
.showToast({
body: {
title: "Memory Explorer",
message: `Web UI available at ${url}`,
variant: "info",
duration: 3000,
},
})
.catch(() => {});
}
const url = webServer.getUrl();

webServer.setOnTakeoverCallback(async () => {
if (ctx.client?.tui) {
ctx.client.tui
.showToast({
body: {
title: "Memory Explorer",
message: "Took over web server ownership",
variant: "success",
duration: 3000,
},
})
.catch(() => {});
}
})
.catch((error) => {
log("Web server failed to start", { error: String(error) });
});

if (webServer.isServerOwner()) {
if (ctx.client?.tui) {
ctx.client.tui
.showToast({
body: {
title: "Memory Explorer Error",
message: `Failed to start: ${String(error)}`,
variant: "error",
title: "Memory Explorer",
message: `Web UI started at ${url}`,
variant: "success",
duration: 5000,
},
})
.catch(() => {});
}
});
} else {
if (ctx.client?.tui) {
ctx.client.tui
.showToast({
body: {
title: "Memory Explorer",
message: `Web UI available at ${url}`,
variant: "info",
duration: 3000,
},
})
.catch(() => {});
}
}
} catch (error) {
log("Web server failed to start", { error: String(error) });

if (ctx.client?.tui) {
ctx.client.tui
.showToast({
body: {
title: "Memory Explorer Error",
message: `Failed to start: ${String(error)}`,
variant: "error",
duration: 5000,
},
})
.catch(() => {});
}
}
}

startBackgroundWarmup();

const shutdownHandler = async () => {
try {
if (webServer) {
Expand Down Expand Up @@ -275,6 +302,7 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => {

const needsWarmup = !(await memoryClient.isReady());
if (needsWarmup) {
startBackgroundWarmup();
return JSON.stringify({ success: false, error: "Memory system is initializing." });
}

Expand Down
71 changes: 60 additions & 11 deletions src/services/embedding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
export class EmbeddingService {
private pipe: any = null;
private initPromise: Promise<void> | null = null;
private initGeneration = 0;
private resetSignal = this.createResetSignal();
public isWarmedUp: boolean = false;
private cache: Map<string, Float32Array> = new Map();
private cachedModelName: string | null = null;
Expand All @@ -42,26 +44,76 @@ export class EmbeddingService {
return (globalThis as any)[GLOBAL_EMBEDDING_KEY];
}

private createResetSignal(): { promise: Promise<void>; resolve: () => void } {
let resolve!: () => void;
const promise = new Promise<void>((resolver) => {
resolve = resolver;
});

return { promise, resolve };
}

async warmup(progressCallback?: (progress: any) => void): Promise<void> {
if (this.isWarmedUp) return;
if (this.initPromise) return this.initPromise;
this.initPromise = this.initializeModel(progressCallback);
return this.initPromise;
while (!this.isWarmedUp) {
if (!this.initPromise) {
const generation = ++this.initGeneration;
const initPromise = this.initializeModel(generation, progressCallback).finally(() => {
if (this.initPromise === initPromise) {
this.initPromise = null;
}
});

this.initPromise = initPromise;
}

await Promise.race([this.initPromise, this.resetSignal.promise]);
}
}

resetWarmupState(): void {
const resetSignal = this.resetSignal;
this.resetSignal = this.createResetSignal();
this.initGeneration += 1;
this.isWarmedUp = false;
this.pipe = null;
if (this.initPromise) {
this.initPromise = null;
}

this.clearCache();
resetSignal.resolve();
}

private async initializeModel(progressCallback?: (progress: any) => void): Promise<void> {
private async initializeModel(
generation: number,
progressCallback?: (progress: any) => void
): Promise<void> {
try {
if (CONFIG.embeddingApiUrl && CONFIG.embeddingApiKey) {
if (generation !== this.initGeneration) {
return;
}

this.isWarmedUp = true;
return;
}

const { pipeline } = await ensureTransformersLoaded();
this.pipe = await pipeline("feature-extraction", CONFIG.embeddingModel, {
const pipe = await pipeline("feature-extraction", CONFIG.embeddingModel, {
progress_callback: progressCallback,
});

if (generation !== this.initGeneration) {
return;
}

this.pipe = pipe;
this.isWarmedUp = true;
} catch (error) {
this.initPromise = null;
if (generation !== this.initGeneration) {
return;
}

log("Failed to initialize embedding model", { error: String(error) });
throw error;
}
Expand All @@ -76,12 +128,9 @@ export class EmbeddingService {
const cached = this.cache.get(text);
if (cached) return cached;

if (!this.isWarmedUp && !this.initPromise) {
if (!this.isWarmedUp) {
await this.warmup();
}
if (this.initPromise) {
await this.initPromise;
}

let result: Float32Array;

Expand Down
Loading