diff --git a/package.json b/package.json index 3e9234c..06136ab 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", diff --git a/src/cli.ts b/src/cli.ts index 3073493..d500ffa 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,7 +4,15 @@ import { join } from "node:path"; import { homedir } from "node:os"; import * as readline from "node:readline"; import { stripJsoncComments } from "./services/jsonc.js"; -import { startAuthFlow, clearCredentials, loadCredentials, CREDENTIALS_FILE } from "./services/auth.js"; +import { + CREDENTIALS_FILE, + clearAuthAttempted, + clearCredentials, + clearLoggedOutMarker, + loadCredentials, + markLoggedOut, + startAuthFlow, +} from "./services/auth.js"; import { CONFIG, CONFIG_FILE, SUPERMEMORY_API_KEY, getApiBaseUrl, isConfigured, writeInstallDefaults } from "./config.js"; import { SupermemoryClient } from "./services/client.js"; import { getTags } from "./services/tags.js"; @@ -497,6 +505,8 @@ async function login(): Promise { return 0; } + clearAuthAttempted(); + clearLoggedOutMarker(); const result = await startAuthFlow(); if (result.success) { @@ -643,6 +653,7 @@ async function status(): Promise { } function logout(): number { + markLoggedOut(); if (clearCredentials()) { console.log("✓ Logged out. Credentials cleared."); console.log("This only logs out this local OpenCode install. To revoke the account-level OpenCode integration key, disconnect it from the Supermemory integrations page."); 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 7230ce7..4188c52 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,16 +5,23 @@ import { tool } from "@opencode-ai/plugin"; import { PROJECT_ENTITY_CONTEXT, USER_ENTITY_CONTEXT, - supermemoryClient, -} from "./services/client.js"; +} from "./services/entity-context.js"; +import { supermemoryClient } from "./services/client.js"; import { formatContextForPrompt } from "./services/context.js"; 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 { + clearAuthAttempted, + hasAuthAttempted, + isLoggedOut, + markAuthAttempted, + startAuthFlow, +} from "./services/auth.js"; import type { MemoryScope, MemoryType } from "./types/index.js"; const CODE_BLOCK_PATTERN = /```[\s\S]*?```/g; @@ -83,16 +90,76 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { return modelLimits.get(`${providerID}/${modelID}`); }; - const compactionHook = isConfigured() && ctx.client + let compactionHook = isConfigured() && ctx.client ? createCompactionHook(ctx as CompactionContext, tags, { threshold: CONFIG.compactionThreshold, getModelLimit, }) : null; + let authAttempt: Promise | null = null; + + const ensureAuthenticated = async (): Promise => { + if (isConfigured()) return true; + if (isLoggedOut() || hasAuthAttempted()) return false; + + authAttempt ??= (async () => { + try { + markAuthAttempted(); + const result = await startAuthFlow(); + if (!result.success) { + log("Browser auth failed", { error: result.error }); + return false; + } + + reloadApiKey(); + clearAuthAttempted(); + if (!compactionHook && ctx.client) { + compactionHook = createCompactionHook(ctx as CompactionContext, tags, { + threshold: CONFIG.compactionThreshold, + getModelLimit, + }); + } + log("Browser auth complete"); + return isConfigured(); + } catch (error) { + log("Browser auth error", { error: String(error) }); + return false; + } finally { + authAttempt = null; + } + })(); + + return authAttempt; + }; + + const addAuthNotice = ( + input: { sessionID: string }, + output: { message: { id: string }; parts: Part[] }, + message: string + ): void => { + output.parts.unshift({ + id: `prt_supermemory-auth-${Date.now()}`, + sessionID: input.sessionID, + messageID: output.message.id, + type: "text", + text: message, + synthetic: true, + }); + }; return { "chat.message": async (input, output) => { - if (!isConfigured()) return; + if (!isConfigured()) { + const authenticated = await ensureAuthenticated(); + if (!authenticated) { + addAuthNotice( + input, + output, + "[SUPERMEMORY] Memory is installed but not active yet. Complete browser authentication, run /supermemory-login, or set SUPERMEMORY_API_KEY." + ); + return; + } + } const start = Date.now(); diff --git a/src/services/auth.ts b/src/services/auth.ts index 8d1f5c2..6089b98 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -8,6 +8,8 @@ import { openUrl } from "./openUrl.js"; const CREDENTIALS_DIR = join(homedir(), ".supermemory-opencode"); export const CREDENTIALS_FILE = join(CREDENTIALS_DIR, "credentials.json"); +const AUTH_ATTEMPTED_FILE = join(CREDENTIALS_DIR, ".auth-attempted"); +const LOGGED_OUT_FILE = join(CREDENTIALS_DIR, ".logged-out"); const AUTH_BASE_URL = process.env.SUPERMEMORY_AUTH_URL || "https://app.supermemory.ai/auth/agent-connect"; const AUTH_TIMEOUT = Number(process.env.SUPERMEMORY_AUTH_TIMEOUT) || 5 * 60_000; const CLIENT_NAME = "opencode"; @@ -51,6 +53,8 @@ export function saveCredentials(apiKey: string, apiBaseUrl?: string): void { const normalizedApiBaseUrl = normalizeApiBaseUrl(apiBaseUrl); if (normalizedApiBaseUrl) credentials.apiBaseUrl = normalizedApiBaseUrl; writeFileSync(CREDENTIALS_FILE, JSON.stringify(credentials, null, 2), { mode: 0o600 }); + clearAuthAttempted(); + clearLoggedOutMarker(); } export function clearCredentials(): boolean { @@ -59,6 +63,33 @@ export function clearCredentials(): boolean { return true; } +export function hasAuthAttempted(): boolean { + return existsSync(AUTH_ATTEMPTED_FILE); +} + +export function markAuthAttempted(): void { + mkdirSync(CREDENTIALS_DIR, { recursive: true, mode: 0o700 }); + writeFileSync(AUTH_ATTEMPTED_FILE, new Date().toISOString()); +} + +export function clearAuthAttempted(): void { + if (existsSync(AUTH_ATTEMPTED_FILE)) rmSync(AUTH_ATTEMPTED_FILE); +} + +export function isLoggedOut(): boolean { + return existsSync(LOGGED_OUT_FILE); +} + +export function markLoggedOut(): void { + mkdirSync(CREDENTIALS_DIR, { recursive: true, mode: 0o700 }); + writeFileSync(LOGGED_OUT_FILE, new Date().toISOString()); + clearAuthAttempted(); +} + +export function clearLoggedOutMarker(): void { + if (existsSync(LOGGED_OUT_FILE)) rmSync(LOGGED_OUT_FILE); +} + export interface AuthResult { success: boolean; apiKey?: string; @@ -162,7 +193,7 @@ export function startAuthFlow(timeoutMs = AUTH_TIMEOUT): Promise { hostname: `opencode - ${hostname()}`, os: `${platform()}-${arch()}`, cwd: process.cwd(), - cli_version: "2.0.6", + cli_version: "2.0.8", }); const authUrl = `${AUTH_BASE_URL}?${params.toString()}`; diff --git a/src/services/client.ts b/src/services/client.ts index 0ac1a53..d46aff0 100644 --- a/src/services/client.ts +++ b/src/services/client.ts @@ -11,31 +11,6 @@ const TIMEOUT_MS = 30000; const MAX_CONVERSATION_CHARS = 100_000; const OPENCODE_SOURCE = "opencode"; -export const USER_ENTITY_CONTEXT = `Developer coding session transcript for a persistent user profile. - -EXTRACT: -- User preferences: preferred languages, frameworks, libraries, editors, workflows, and communication style -- Stable habits: testing style, code review expectations, formatting preferences, privacy preferences -- Repeated personal decisions: tools the user consistently chooses or avoids -- Long-lived learnings: concepts the user learned or wants remembered across projects - -SKIP: -- One-off assistant suggestions the user did not accept -- Low-level implementation details that only matter inside the current repository`; - -export const PROJECT_ENTITY_CONTEXT = `Project/codebase knowledge from OpenCode coding sessions. - -EXTRACT: -- Architecture: repo structure, services, modules, data flow, and integration boundaries -- Conventions: naming, component patterns, API patterns, testing practices, and style rules -- Decisions: chosen approaches, tradeoffs, migrations, and rejected alternatives -- Setup: commands, environment requirements, deployment notes, and debugging workflows -- Implementation lessons: bugs fixed, root causes, and reusable project-specific context - -SKIP: -- Verbatim assistant explanations unless they became an accepted project decision -- Transient command output with no lasting project value`; - function withTimeout(promise: Promise, ms: number): Promise { return Promise.race([ promise, diff --git a/src/services/compaction.ts b/src/services/compaction.ts index 253d265..924c8b8 100644 --- a/src/services/compaction.ts +++ b/src/services/compaction.ts @@ -1,7 +1,8 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; -import { PROJECT_ENTITY_CONTEXT, supermemoryClient } from "./client.js"; +import { PROJECT_ENTITY_CONTEXT } from "./entity-context.js"; +import { supermemoryClient } from "./client.js"; import { log } from "./logger.js"; import { CONFIG } from "../config.js"; diff --git a/src/services/entity-context.ts b/src/services/entity-context.ts new file mode 100644 index 0000000..1aa9147 --- /dev/null +++ b/src/services/entity-context.ts @@ -0,0 +1,24 @@ +export const USER_ENTITY_CONTEXT = `Developer coding session transcript for a persistent user profile. + +EXTRACT: +- User preferences: preferred languages, frameworks, libraries, editors, workflows, and communication style +- Stable habits: testing style, code review expectations, formatting preferences, privacy preferences +- Repeated personal decisions: tools the user consistently chooses or avoids +- Long-lived learnings: concepts the user learned or wants remembered across projects + +SKIP: +- One-off assistant suggestions the user did not accept +- Low-level implementation details that only matter inside the current repository`; + +export const PROJECT_ENTITY_CONTEXT = `Project/codebase knowledge from OpenCode coding sessions. + +EXTRACT: +- Architecture: repo structure, services, modules, data flow, and integration boundaries +- Conventions: naming, component patterns, API patterns, testing practices, and style rules +- Decisions: chosen approaches, tradeoffs, migrations, and rejected alternatives +- Setup: commands, environment requirements, deployment notes, and debugging workflows +- Implementation lessons: bugs fixed, root causes, and reusable project-specific context + +SKIP: +- Verbatim assistant explanations unless they became an accepted project decision +- Transient command output with no lasting project value`;