diff --git a/.gitignore b/.gitignore index 9763a2b..2001633 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ temp/ *.tsbuildinfo .DS_Store -pnpm-lock.yaml \ No newline at end of file +pnpm-lock.yaml +package-lock.json \ No newline at end of file diff --git a/hooks/capture.ts b/hooks/capture.ts index 9bfd930..ea6d63f 100644 --- a/hooks/capture.ts +++ b/hooks/capture.ts @@ -1,7 +1,7 @@ import type { SupermemoryClient } from "../client.ts" import type { SupermemoryConfig } from "../config.ts" import { log } from "../logger.ts" -import { buildDocumentId } from "../memory.ts" +import { buildDocumentId, stripInboundMetadata } from "../memory.ts" const SKIPPED_PROVIDERS = ["exec-event", "cron-event", "heartbeat"] @@ -71,7 +71,11 @@ export function buildCaptureHandler( } if (parts.length > 0) { - texts.push(`[role: ${role}]\n${parts.join("\n")}\n[${role}:end]`) + const cleaned = + role === "user" + ? parts.map(stripInboundMetadata).join("\n") + : parts.join("\n") + texts.push(`[role: ${role}]\n${cleaned}\n[${role}:end]`) } } diff --git a/hooks/recall.ts b/hooks/recall.ts index 3f0fbc9..3ec2401 100644 --- a/hooks/recall.ts +++ b/hooks/recall.ts @@ -1,6 +1,7 @@ import type { ProfileSearchResult, SupermemoryClient } from "../client.ts" import type { SupermemoryConfig } from "../config.ts" import { log } from "../logger.ts" +import { stripInboundMetadata } from "../memory.ts" function formatRelativeTime(isoTimestamp: string): string { try { @@ -170,18 +171,22 @@ export function buildRecallHandler( event: Record, ctx?: Record, ) => { - const prompt = event.prompt as string | undefined - if (!prompt || prompt.length < 5) return + const rawPrompt = event.prompt as string | undefined + if (!rawPrompt || rawPrompt.length < 5) return const messages = Array.isArray(event.messages) ? event.messages : [] const turn = countUserTurns(messages) - const includeProfile = turn <= 1 || turn % cfg.profileFrequency === 0 + const isNewSession = turn === 0 + const includeProfile = isNewSession || turn % cfg.profileFrequency === 0 const messageProvider = ctx?.messageProvider as string | undefined + const query = isNewSession ? undefined : stripInboundMetadata(rawPrompt) - log.debug(`recalling for turn ${turn} (profile: ${includeProfile})`) + log.debug( + `recalling for turn ${turn} (profile: ${includeProfile}, newSession: ${isNewSession})`, + ) try { - const profile = await client.getProfile(prompt) + const profile = await client.getProfile(query) const memoryContext = formatContext( includeProfile ? profile.static : [], includeProfile ? profile.dynamic : [], diff --git a/index.ts b/index.ts index 9634d45..011c7c0 100644 --- a/index.ts +++ b/index.ts @@ -1,3 +1,6 @@ +import fs from "node:fs" +import os from "node:os" +import path from "node:path" import type { OpenClawPluginApi } from "openclaw/plugin-sdk" import { SupermemoryClient } from "./client.ts" import { registerCli, registerCliSetup } from "./commands/cli.ts" @@ -6,11 +9,22 @@ import { parseConfig, supermemoryConfigSchema } from "./config.ts" import { buildCaptureHandler } from "./hooks/capture.ts" import { buildRecallHandler } from "./hooks/recall.ts" import { initLogger } from "./logger.ts" +import { buildMemoryRuntime, buildPromptSection } from "./runtime.ts" import { registerForgetTool } from "./tools/forget.ts" import { registerProfileTool } from "./tools/profile.ts" import { registerSearchTool } from "./tools/search.ts" import { registerStoreTool } from "./tools/store.ts" +try { + const stateDir = + process.env.OPENCLAW_STATE_DIR || path.join(os.homedir(), ".openclaw") + const storePath = path.join(stateDir, "memory", "main.sqlite") + if (!fs.existsSync(storePath)) { + fs.mkdirSync(path.dirname(storePath), { recursive: true }) + fs.writeFileSync(storePath, "") + } +} catch {} + export default { id: "openclaw-supermemory", name: "Supermemory", @@ -35,6 +49,10 @@ export default { const client = new SupermemoryClient(cfg.apiKey, cfg.containerTag) + api.registerMemoryRuntime?.(buildMemoryRuntime(client)) + api.registerMemoryPromptSection?.(buildPromptSection) + api.registerMemoryFlushPlan?.(() => null) + let sessionKey: string | undefined const getSessionKey = () => sessionKey diff --git a/memory.ts b/memory.ts index ff39e85..f027ba1 100644 --- a/memory.ts +++ b/memory.ts @@ -9,10 +9,10 @@ export type MemoryCategory = (typeof MEMORY_CATEGORIES)[number] export function detectCategory(text: string): MemoryCategory { const lower = text.toLowerCase() - if (/prefer|like|love|hate|want/i.test(lower)) return "preference" - if (/decided|will use|going with/i.test(lower)) return "decision" - if (/\+\d{10,}|@[\w.-]+\.\w+|is called/i.test(lower)) return "entity" - if (/is|are|has|have/i.test(lower)) return "fact" + if (/\b(?:prefer|like|love|hate|want)\b/.test(lower)) return "preference" + if (/\b(?:decided|will use|going with)\b/.test(lower)) return "decision" + if (/\+\d{10,}|@[\w.-]+\.\w+|\bis called\b/.test(lower)) return "entity" + if (/\b(?:is|are|has|have)\b/.test(lower)) return "fact" return "other" } @@ -37,6 +37,68 @@ export function clampEntityContext(ctx: string): string { return ctx.slice(0, MAX_ENTITY_CONTEXT_LENGTH) } +const INBOUND_META_SENTINELS = [ + "Conversation info (untrusted metadata):", + "Sender (untrusted metadata):", + "Thread starter (untrusted, for context):", + "Replied message (untrusted, for context):", + "Forwarded message context (untrusted metadata):", + "Chat history since last reply (untrusted, for context):", +] + +const LEADING_TIMESTAMP_RE = + /^\[[A-Za-z]{3} \d{4}-\d{2}-\d{2} \d{2}:\d{2}[^\]]*\] */ + +function isMetaSentinel(line: string): boolean { + const trimmed = line.trim() + return INBOUND_META_SENTINELS.some((s) => s === trimmed) +} + +export function stripInboundMetadata(text: string): string { + if (!text) return text + + const withoutTimestamp = text.replace(LEADING_TIMESTAMP_RE, "") + const lines = withoutTimestamp.split("\n") + const result: string[] = [] + let inMetaBlock = false + let inFencedJson = false + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + + if (!inMetaBlock && isMetaSentinel(line)) { + const next = lines[i + 1] + if (next?.trim() !== "```json") { + result.push(line) + continue + } + inMetaBlock = true + inFencedJson = false + continue + } + + if (inMetaBlock) { + if (!inFencedJson && line.trim() === "```json") { + inFencedJson = true + continue + } + if (inFencedJson) { + if (line.trim() === "```") { + inMetaBlock = false + inFencedJson = false + } + continue + } + if (line.trim() === "") continue + inMetaBlock = false + } + + result.push(line) + } + + return result.join("\n").replace(/^\n+/, "").replace(/\n+$/, "") +} + export function buildDocumentId(sessionKey: string): string { const sanitized = sessionKey .replace(/[^a-zA-Z0-9_]/g, "_") diff --git a/package.json b/package.json index 913930c..a86ec48 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@supermemory/openclaw-supermemory", - "version": "2.0.22", + "version": "2.0.23", "type": "module", "description": "OpenClaw Supermemory memory plugin", "license": "MIT", diff --git a/runtime.ts b/runtime.ts new file mode 100644 index 0000000..c256b15 --- /dev/null +++ b/runtime.ts @@ -0,0 +1,138 @@ +import type { SupermemoryClient } from "./client.ts" +import { log } from "./logger.ts" + +type MemoryProviderStatus = { + backend: "builtin" | "qmd" + provider: string + model?: string + files?: number + chunks?: number + custom?: Record +} + +type MemoryEmbeddingProbeResult = { + ok: boolean + error?: string +} + +type MemorySyncProgressUpdate = { + completed: number + total: number + label?: string +} + +type RegisteredMemorySearchManager = { + status(): MemoryProviderStatus + probeEmbeddingAvailability(): Promise + probeVectorAvailability(): Promise + sync?(params?: { + reason?: string + force?: boolean + sessionFiles?: string[] + progress?: (update: MemorySyncProgressUpdate) => void + }): Promise + close?(): Promise +} + +type MemoryRuntimeBackendConfig = + | { backend: "builtin" } + | { backend: "qmd"; qmd?: { command?: string } } + +type MemoryPluginRuntime = { + getMemorySearchManager(params: { + cfg: unknown + agentId: string + purpose?: "default" | "status" + }): Promise<{ + manager: RegisteredMemorySearchManager | null + error?: string + }> + resolveMemoryBackendConfig(params: { + cfg: unknown + agentId: string + }): MemoryRuntimeBackendConfig + closeAllMemorySearchManagers?(): Promise +} + +function createSearchManager( + client: SupermemoryClient, +): RegisteredMemorySearchManager { + return { + status() { + return { + backend: "builtin" as const, + provider: "supermemory", + model: "supermemory-remote", + files: 0, + chunks: 0, + custom: { + containerTag: client.getContainerTag(), + transport: "remote", + }, + } + }, + + async probeEmbeddingAvailability() { + try { + await client.search("connection-probe", 1) + return { ok: true } + } catch (err) { + const message = + err instanceof Error ? err.message : "supermemory unreachable" + log.warn(`embedding probe failed: ${message}`) + return { ok: false, error: message } + } + }, + + async probeVectorAvailability() { + return true + }, + + async sync() {}, + + async close() {}, + } +} + +export function buildMemoryRuntime( + client: SupermemoryClient, +): MemoryPluginRuntime { + return { + async getMemorySearchManager() { + return { manager: createSearchManager(client) } + }, + + resolveMemoryBackendConfig() { + return { backend: "builtin" as const } + }, + } +} + +export function buildPromptSection(params: { + availableTools: Set +}): string[] { + const hasSearch = params.availableTools.has("supermemory_search") + const hasStore = params.availableTools.has("supermemory_store") + if (!hasSearch && !hasStore) return [] + + const lines: string[] = [ + "## Memory (Supermemory)", + "", + "Memory is managed by Supermemory (cloud). Do not read or write local memory files like MEMORY.md or memory/*.md — they do not exist.", + "Relevant memories are automatically injected at the start of each conversation.", + "", + ] + + if (hasSearch) { + lines.push( + "Use supermemory_search to look up prior conversations, preferences, and facts.", + ) + } + if (hasStore) { + lines.push( + "Use supermemory_store to save important information the user asks you to remember.", + ) + } + + return lines +} diff --git a/types/openclaw.d.ts b/types/openclaw.d.ts index 9e84734..be61836 100644 --- a/types/openclaw.d.ts +++ b/types/openclaw.d.ts @@ -17,5 +17,11 @@ declare module "openclaw/plugin-sdk" { registerService(service: any): void // biome-ignore lint/suspicious/noExplicitAny: openclaw SDK does not ship types on(event: string, handler: (...args: any[]) => any): void + // biome-ignore lint/suspicious/noExplicitAny: openclaw SDK does not ship types + registerMemoryRuntime?(runtime: any): void + // biome-ignore lint/suspicious/noExplicitAny: openclaw SDK does not ship types + registerMemoryPromptSection?(builder: any): void + // biome-ignore lint/suspicious/noExplicitAny: openclaw SDK does not ship types + registerMemoryFlushPlan?(resolver: any): void } }