Skip to content
Closed
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
@@ -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",
Expand Down
13 changes: 12 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -497,6 +505,8 @@ async function login(): Promise<number> {
return 0;
}

clearAuthAttempted();
clearLoggedOutMarker();
const result = await startAuthFlow();

if (result.success) {
Expand Down Expand Up @@ -643,6 +653,7 @@ async function status(): Promise<number> {
}

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.");
Expand Down
8 changes: 6 additions & 2 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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;
Expand Down
77 changes: 72 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<boolean> | null = null;

const ensureAuthenticated = async (): Promise<boolean> => {
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();

Expand Down
33 changes: 32 additions & 1 deletion src/services/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -162,7 +193,7 @@ export function startAuthFlow(timeoutMs = AUTH_TIMEOUT): Promise<AuthResult> {
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()}`;

Expand Down
25 changes: 0 additions & 25 deletions src/services/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(promise: Promise<T>, ms: number): Promise<T> {
return Promise.race([
promise,
Expand Down
3 changes: 2 additions & 1 deletion src/services/compaction.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down
24 changes: 24 additions & 0 deletions src/services/entity-context.ts
Original file line number Diff line number Diff line change
@@ -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`;
Loading