// agentmemory-loader.ts — self-updating loader for the agentmemory capture plugin. // // WHY THIS EXISTS (docs: event-like plugin auto-update, no NATS, no daemon): // OpenCode reads plugin files from disk at SESSION START. So "always up to date" only // needs the file to be current by the next launch — not a live push. This loader does a // single CONDITIONAL GET (ETag/If-None-Match) against the ai-stack plugin URL on boot: // • 200 → new version: cache the source + its ETag, then import & delegate to it. // • 304 → unchanged: import the cached copy (one cheap round-trip, no download). // • offline / error → fall back to the last cached copy (never breaks a session). // Result: the capture plugin refreshes EXACTLY when the server has a new version, with // no polling loop, no client daemon, and no extra infrastructure. Installed ONCE; the // heavy capture logic lives in cache and updates itself. // // OpenCode bundles Bun, which imports .ts at runtime (import type is erased), so the // fetched .ts is dynamically importable as-is. import type { Plugin } from "@opencode-ai/plugin"; import { promises as fs } from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; // The stable, ETag'd URL the ai-stack serves the latest capture plugin at. Overridable // so self-hosters can point elsewhere; defaults to the public onboarding host. const PLUGIN_URL = process.env.AGENTMEMORY_PLUGIN_URL || "https://static-ai-stack.dc7.io/agentmemory-capture.ts"; const CACHE_DIR = path.join(os.homedir(), ".cache", "agentmemory", "plugin"); const SRC_FILE = path.join(CACHE_DIR, "agentmemory-capture.ts"); // Staging file for a newly-fetched version. MUST keep a .ts extension — Bun imports an // unknown extension (e.g. .ts.new) as a text string, not a module, which would make // validation always fail. const STAGING_FILE = path.join(CACHE_DIR, "agentmemory-capture.staging.ts"); const ETAG_FILE = path.join(CACHE_DIR, "agentmemory-capture.etag"); const DEBUG = process.env.OPENCODE_AGENTMEMORY_DEBUG === "1"; const log = (...a: unknown[]) => { if (DEBUG) console.error("[agentmemory-loader]", ...a); }; async function readIf(file: string): Promise { try { return await fs.readFile(file, "utf8"); } catch { return ""; } } // Refresh the cached source via a conditional GET. Returns true if a usable source is // on disk afterwards (fresh OR cached). Never throws. async function importFactory(file: string): Promise<((ctx: unknown) => unknown) | null> { // Cache-bust so a just-written file is re-read within the process. const mod = await import(`file://${file}?v=${Date.now()}`); // Prefer the default export (OpenCode's canonical plugin entrypoint), then fall back to // named exports for older packages that only export by name. const f = mod.default || mod.AgentmemoryCapturePlugin || mod.plugin; return typeof f === "function" ? f : null; } // Fetch the latest plugin (conditional GET). If a NEW version arrives, validate it in a // staging file and only PROMOTE it to the live cache if it imports to a real plugin — // so a broken published version can never take down a session (we keep last-good). async function refresh(): Promise { const etag = await readIf(ETAG_FILE); let res: Response; try { res = await fetch(PLUGIN_URL, { headers: etag ? { "If-None-Match": etag } : {}, signal: AbortSignal.timeout(4000), }); } catch (e) { log("fetch failed, keeping cache:", (e as Error).message); return; } if (res.status === 304) { log("304 not-modified; using cache"); return; } if (!res.ok) { log("fetch not ok", res.status, "; keeping cache"); return; } const body = await res.text(); if (!body || !body.includes("Plugin")) { log("suspicious body; keeping cache"); return; } await fs.mkdir(CACHE_DIR, { recursive: true }); await fs.writeFile(STAGING_FILE, body, "utf8"); try { const f = await importFactory(STAGING_FILE); if (!f) throw new Error("no usable plugin export"); await fs.rename(STAGING_FILE, SRC_FILE); // promote const newEtag = res.headers.get("etag"); if (newEtag) await fs.writeFile(ETAG_FILE, newEtag, "utf8"); log("updated capture plugin from server", newEtag || "(no etag)"); } catch (e) { await fs.rm(STAGING_FILE, { force: true }); log("new version failed to validate; keeping last-good cache:", (e as Error).message); } } const AgentmemoryLoaderPlugin: Plugin = async (ctx) => { await refresh(); if (!(await readIf(SRC_FILE))) { log("no capture plugin available (offline + no cache); no-op this session"); return {}; } try { const factory = await importFactory(SRC_FILE); if (!factory) { log("cached module has no plugin export"); return {}; } return await factory(ctx); } catch (e) { log("failed to load cached capture plugin:", (e as Error).message); return {}; } }; export default AgentmemoryLoaderPlugin; export const AgentmemoryCapturePlugin = AgentmemoryLoaderPlugin;