diff --git a/package.json b/package.json index 3e9234c..62b1201 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-supermemory", - "version": "2.0.7", + "version": "2.0.8", "description": "OpenCode plugin that gives coding agents persistent memory using Supermemory", "type": "module", "main": "dist/index.js", @@ -38,6 +38,7 @@ "opencode": { "type": "plugin", "hooks": [ + "auth", "chat.message", "event" ] diff --git a/src/config.ts b/src/config.ts index 58bb4e7..f28b224 100644 --- a/src/config.ts +++ b/src/config.ts @@ -5,7 +5,7 @@ import { stripJsoncComments } from "./services/jsonc.js"; import { loadCredentials } from "./services/auth.js"; const CONFIG_DIR = join(homedir(), ".config", "opencode"); -export const PLUGIN_VERSION = "2.0.7"; +export const PLUGIN_VERSION = "2.0.8"; const CONFIG_FILES = [ join(CONFIG_DIR, "supermemory.jsonc"), join(CONFIG_DIR, "supermemory.json"), @@ -104,7 +104,11 @@ function getApiKey(): string | undefined { return loadCredentials()?.apiKey; } -export const SUPERMEMORY_API_KEY = getApiKey(); +export let SUPERMEMORY_API_KEY = getApiKey(); + +export function reloadApiKey(): void { + SUPERMEMORY_API_KEY = getApiKey(); +} function normalizeBaseUrl(baseUrl: unknown): string | null { if (typeof baseUrl !== "string" || !baseUrl.trim()) return null; diff --git a/src/index.ts b/src/index.ts index e7e25c7..a54658a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,9 +8,11 @@ import { getTags } from "./services/tags.js"; import { stripPrivateContent, isFullyPrivate } from "./services/privacy.js"; import { createCompactionHook, type CompactionContext } from "./services/compaction.js"; -import { isConfigured, CONFIG, PLUGIN_VERSION } from "./config.js"; +import { isConfigured, CONFIG, PLUGIN_VERSION, reloadApiKey } from "./config.js"; import { log } from "./services/logger.js"; import { checkNpmUpdate, formatUpdateNotice } from "./services/version-check.js"; +import { createAuthSession } from "./services/auth.js"; +import { openUrl } from "./services/openUrl.js"; import type { MemoryScope, MemoryType } from "./types/index.js"; const CODE_BLOCK_PATTERN = /```[\s\S]*?```/g; @@ -46,6 +48,7 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { const { directory } = ctx; const tags = getTags(directory); const injectedSessions = new Set(); + let authFlowStarted = false; log("Plugin init", { directory, tags, configured: isConfigured() }); if (!isConfigured()) { @@ -86,9 +89,83 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { }) : null; + + async function startAuthFallback(): Promise { + if (isConfigured() || authFlowStarted) return null; + authFlowStarted = true; + + try { + const session = await createAuthSession(); + openUrl(session.authUrl).catch((error) => { + log("OpenCode auth fallback failed to open browser", { error: String(error) }); + }); + + session.callback().then((result) => { + if (result.success && result.apiKey) { + reloadApiKey(); + log("OpenCode auth fallback completed"); + } else { + log("OpenCode auth fallback failed", { error: result.error }); + } + }); + return session.authUrl; + } catch (error) { + log("OpenCode auth fallback failed to start", { error: String(error) }); + return null; + } + } + return { + auth: !isConfigured() + ? { + provider: "supermemory", + methods: [ + { + type: "oauth", + label: "Connect Supermemory", + async authorize() { + const session = await createAuthSession(); + return { + url: session.authUrl, + instructions: + "Connect Supermemory to enable persistent memory in OpenCode. Complete login in the browser window, then return to OpenCode.", + method: "auto" as const, + async callback() { + const result = await session.callback(); + if (!result.success || !result.apiKey) { + return { type: "failed" as const }; + } + + reloadApiKey(); + log("OpenCode auth hook completed"); + return { + type: "success" as const, + provider: "supermemory", + key: result.apiKey, + }; + }, + }; + }, + }, + ], + } + : undefined, + "chat.message": async (input, output) => { - if (!isConfigured()) return; + if (!isConfigured()) { + const authUrl = await startAuthFallback(); + const loginPart: Part = { + id: `prt_supermemory-auth-${Date.now()}`, + sessionID: input.sessionID, + messageID: output.message.id, + type: "text", + text: + `[SUPERMEMORY] Supermemory is installed but not connected. I opened the login page in your browser. Complete login there, then continue in OpenCode. If the browser did not open, use this link: ${authUrl ?? "run /supermemory-login"}.`, + synthetic: true, + }; + output.parts.unshift(loginPart); + return; + } const start = Date.now(); diff --git a/src/services/auth.ts b/src/services/auth.ts index 8d1f5c2..cd3a58a 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -65,10 +65,19 @@ export interface AuthResult { error?: string; } -export function startAuthFlow(timeoutMs = AUTH_TIMEOUT): Promise { - return new Promise((resolve) => { +export interface AuthSession { + authUrl: string; + callback: () => Promise; +} + +export function createAuthSession(timeoutMs = AUTH_TIMEOUT): Promise { + return new Promise((resolveSession) => { let resolved = false; const stateToken = randomBytes(16).toString("hex"); + let resolveAuth: (result: AuthResult) => void; + const result = new Promise((resolve) => { + resolveAuth = resolve; + }); const server = createServer((req: IncomingMessage, res: ServerResponse) => { if (resolved) return; @@ -94,7 +103,7 @@ export function startAuthFlow(timeoutMs = AUTH_TIMEOUT): Promise { resolved = true; clearTimeout(timer); server.close(); - resolve({ success: false, error: "Invalid auth state" }); + resolveAuth({ success: false, error: "Invalid auth state" }); return; } @@ -119,7 +128,7 @@ export function startAuthFlow(timeoutMs = AUTH_TIMEOUT): Promise { resolved = true; clearTimeout(timer); server.close(); - resolve({ success: true, apiKey }); + resolveAuth({ success: true, apiKey }); } else { res.writeHead(400, { "Content-Type": "text/html" }); res.end(` @@ -137,7 +146,7 @@ export function startAuthFlow(timeoutMs = AUTH_TIMEOUT): Promise { resolved = true; clearTimeout(timer); server.close(); - resolve({ success: false, error: "No API key received" }); + resolveAuth({ success: false, error: "No API key received" }); } } else { res.writeHead(404); @@ -149,7 +158,7 @@ export function startAuthFlow(timeoutMs = AUTH_TIMEOUT): Promise { if (!resolved) { resolved = true; clearTimeout(timer); - resolve({ success: false, error: err.message }); + resolveAuth({ success: false, error: err.message }); } }); @@ -166,15 +175,9 @@ export function startAuthFlow(timeoutMs = AUTH_TIMEOUT): Promise { }); const authUrl = `${AUTH_BASE_URL}?${params.toString()}`; - console.log("Opening browser for authentication..."); - console.log(`If it doesn't open, visit: ${authUrl}`); - openUrl(authUrl).catch((error) => { - if (!resolved) { - resolved = true; - clearTimeout(timer); - server.close(); - resolve({ success: false, error: `Failed to open browser: ${error.message}` }); - } + resolveSession({ + authUrl, + callback: () => result, }); }); @@ -182,8 +185,18 @@ export function startAuthFlow(timeoutMs = AUTH_TIMEOUT): Promise { if (!resolved) { resolved = true; server.close(); - resolve({ success: false, error: "Authentication timed out" }); + resolveAuth({ success: false, error: "Authentication timed out" }); } }, timeoutMs); }); } + +export async function startAuthFlow(timeoutMs = AUTH_TIMEOUT): Promise { + const session = await createAuthSession(timeoutMs); + console.log("Opening browser for authentication..."); + console.log(`If it doesn't open, visit: ${session.authUrl}`); + openUrl(session.authUrl).catch((error) => { + console.error(`Failed to open browser: ${error.message}`); + }); + return session.callback(); +} diff --git a/src/services/jsonc.ts b/src/services/jsonc.ts index 7959907..50a1c35 100644 --- a/src/services/jsonc.ts +++ b/src/services/jsonc.ts @@ -4,6 +4,7 @@ * Also removes trailing commas to support more relaxed JSONC format. */ export function stripJsoncComments(content: string): string { + content = content.replace(/^\uFEFF/, ""); let result = ""; let i = 0; let inString = false;