From 953dc4b897ca2c71c165a584ece538754071173a Mon Sep 17 00:00:00 2001 From: Kan Ninomiya Date: Thu, 19 Mar 2026 11:18:15 +0900 Subject: [PATCH 1/2] fix: use prt_ prefix for all part IDs (OpenCode v1.2.25+ compat) OpenCode v1.2.25+ validates that all part IDs must start with 'prt'. The chat.message hook was creating parts with IDs like 'supermemory-nudge-xxx' and 'supermemory-context-xxx', causing ZodError crashes on every session start. Changes: - Extract generatePartId/generateMessageId into shared ids.ts - Use generatePartId() in index.ts for nudge and context parts - Remove duplicated functions from compaction.ts Fixes supermemoryai/opencode-supermemory#28 Fixes supermemoryai/opencode-supermemory#29 Fixes supermemoryai/opencode-supermemory#32 --- src/index.ts | 5 +++-- src/services/compaction.ts | 13 +------------ src/services/ids.ts | 16 ++++++++++++++++ 3 files changed, 20 insertions(+), 14 deletions(-) create mode 100644 src/services/ids.ts diff --git a/src/index.ts b/src/index.ts index 7a5a441..def3cf0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ 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 { generatePartId } from "./services/ids.js"; import { isConfigured, CONFIG } from "./config.js"; import { log } from "./services/logger.js"; @@ -112,7 +113,7 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { if (detectMemoryKeyword(userMessage)) { log("chat.message: memory keyword detected"); const nudgePart: Part = { - id: `supermemory-nudge-${Date.now()}`, + id: generatePartId(), sessionID: input.sessionID, messageID: output.message.id, type: "text", @@ -157,7 +158,7 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { if (memoryContext) { const contextPart: Part = { - id: `supermemory-context-${Date.now()}`, + id: generatePartId(), sessionID: input.sessionID, messageID: output.message.id, type: "text", diff --git a/src/services/compaction.ts b/src/services/compaction.ts index 4701beb..7bc2f09 100644 --- a/src/services/compaction.ts +++ b/src/services/compaction.ts @@ -4,6 +4,7 @@ import { homedir } from "node:os"; import { supermemoryClient } from "./client.js"; import { log } from "./logger.js"; import { CONFIG } from "../config.js"; +import { generatePartId, generateMessageId } from "./ids.js"; const MESSAGE_STORAGE = join(homedir(), ".opencode", "messages"); const PART_STORAGE = join(homedir(), ".opencode", "parts"); @@ -152,18 +153,6 @@ function findNearestMessageWithFields(messageDir: string): StoredMessage | null return null; } -function generateMessageId(): string { - const timestamp = Date.now().toString(16); - const random = Math.random().toString(36).substring(2, 14); - return `msg_${timestamp}${random}`; -} - -function generatePartId(): string { - const timestamp = Date.now().toString(16); - const random = Math.random().toString(36).substring(2, 10); - return `prt_${timestamp}${random}`; -} - function injectHookMessage( sessionID: string, hookContent: string, diff --git a/src/services/ids.ts b/src/services/ids.ts new file mode 100644 index 0000000..7e7786e --- /dev/null +++ b/src/services/ids.ts @@ -0,0 +1,16 @@ +/** + * Shared ID generation utilities. + * OpenCode v1.2.25+ requires all part IDs to start with "prt". + */ + +export function generatePartId(): string { + const timestamp = Date.now().toString(16); + const random = Math.random().toString(36).substring(2, 10); + return `prt_${timestamp}${random}`; +} + +export function generateMessageId(): string { + const timestamp = Date.now().toString(16); + const random = Math.random().toString(36).substring(2, 14); + return `msg_${timestamp}${random}`; +} From d8d7e9fe1442d9dc6dd9bf13be7d059fa4dae222 Mon Sep 17 00:00:00 2001 From: Kan Ninomiya Date: Thu, 19 Mar 2026 11:20:14 +0900 Subject: [PATCH 2/2] feat: add Japanese keyword patterns and session-end auto-save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Japanese memory keywords: Added patterns like 覚えて, メモして, 保存して, 忘れないで etc. so the plugin recognizes Japanese memory trigger phrases. 2. Session-end auto-save: - Track user and assistant messages during the session - On session.deleted event, ingest the conversation into both project and user scopes via supermemory - Minimum 3 messages required to trigger save - Capped at 50 tracked messages / 50k chars to avoid unbounded memory growth --- src/config.ts | 13 ++++++++++ src/index.ts | 71 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/src/config.ts b/src/config.ts index 0aca56b..d467991 100644 --- a/src/config.ts +++ b/src/config.ts @@ -26,6 +26,7 @@ interface SupermemoryConfig { } const DEFAULT_KEYWORD_PATTERNS = [ + // English "remember", "memorize", "save\\s+this", @@ -42,6 +43,18 @@ const DEFAULT_KEYWORD_PATTERNS = [ "remember\\s+that", "never\\s+forget", "always\\s+remember", + // Japanese + "覚えて", + "記憶して", + "メモして", + "保存して", + "忘れないで", + "忘れるな", + "記録して", + "覚えておいて", + "メモっておいて", + "メモっといて", + "ノートして", ]; const DEFAULTS: Required> = { diff --git a/src/index.ts b/src/index.ts index def3cf0..a514a69 100644 --- a/src/index.ts +++ b/src/index.ts @@ -81,6 +81,51 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { }) : null; + // Track messages per session for auto-save on session end + const sessionMessages = new Map(); + + const collectMessage = (sessionID: string, text: string) => { + if (!text.trim()) return; + const messages = sessionMessages.get(sessionID) || []; + // Keep last N messages to avoid unbounded growth + const MAX_TRACKED = 50; + if (messages.length >= MAX_TRACKED) messages.shift(); + messages.push(text); + sessionMessages.set(sessionID, messages); + }; + + const saveSessionSummary = async (sessionID: string) => { + const messages = sessionMessages.get(sessionID); + if (!messages || messages.length < 3) { + log("session-save: too few messages, skipping", { sessionID, count: messages?.length || 0 }); + sessionMessages.delete(sessionID); + return; + } + + // Build a condensed transcript (limit size) + const MAX_CHARS = 50_000; + let transcript = messages.join("\n---\n"); + if (transcript.length > MAX_CHARS) { + transcript = transcript.slice(0, MAX_CHARS) + "\n...[truncated]"; + } + + const content = `[Session Conversation - ${new Date().toISOString()}]\n${transcript}`; + + try { + const result = await supermemoryClient.ingestConversation( + sessionID, + [{ role: "user", content }], + [tags.project, tags.user], + { type: "conversation", source: "session-end" } + ); + log("session-save: saved", { sessionID, success: result.success }); + } catch (err) { + log("session-save: error", { sessionID, error: String(err) }); + } + + sessionMessages.delete(sessionID); + }; + return { "chat.message": async (input, output) => { if (!isConfigured()) return; @@ -110,6 +155,9 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { textPartsCount: textParts.length, }); + // Track message for session-end auto-save + collectMessage(input.sessionID, userMessage); + if (detectMemoryKeyword(userMessage)) { log("chat.message: memory keyword detected"); const nudgePart: Part = { @@ -486,6 +534,29 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { }, event: async (input: { event: { type: string; properties?: unknown } }) => { + // Auto-save session conversations on session end + if (input.event.type === "session.deleted" && isConfigured()) { + const props = input.event.properties as Record | undefined; + const sessionInfo = props?.info as { id?: string } | undefined; + if (sessionInfo?.id) { + await saveSessionSummary(sessionInfo.id); + } + } + + // Track assistant messages for richer session summaries + if (input.event.type === "message.updated" && isConfigured()) { + const props = input.event.properties as Record | undefined; + const info = props?.info as { role?: string; sessionID?: string; finish?: boolean } | undefined; + if (info?.role === "assistant" && info?.finish && info?.sessionID) { + // Try to get assistant text from parts + const parts = props?.parts as Array<{ type: string; text?: string }> | undefined; + if (parts) { + const text = parts.filter(p => p.type === "text" && p.text).map(p => p.text!).join("\n"); + if (text) collectMessage(info.sessionID, `[assistant] ${text}`); + } + } + } + if (compactionHook) { await compactionHook.event(input); }