Skip to content
Merged
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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ temp/

*.tsbuildinfo
.DS_Store
pnpm-lock.yaml
pnpm-lock.yaml
package-lock.json
8 changes: 6 additions & 2 deletions hooks/capture.ts
Original file line number Diff line number Diff line change
@@ -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"]

Expand Down Expand Up @@ -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]`)
}
}

Expand Down
15 changes: 10 additions & 5 deletions hooks/recall.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -170,18 +171,22 @@ export function buildRecallHandler(
event: Record<string, unknown>,
ctx?: Record<string, unknown>,
) => {
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 : [],
Expand Down
18 changes: 18 additions & 0 deletions index.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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",
Expand All @@ -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

Expand Down
70 changes: 66 additions & 4 deletions memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}

Expand All @@ -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, "_")
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
138 changes: 138 additions & 0 deletions runtime.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>
}

type MemoryEmbeddingProbeResult = {
ok: boolean
error?: string
}

type MemorySyncProgressUpdate = {
completed: number
total: number
label?: string
}

type RegisteredMemorySearchManager = {
status(): MemoryProviderStatus
probeEmbeddingAvailability(): Promise<MemoryEmbeddingProbeResult>
probeVectorAvailability(): Promise<boolean>
sync?(params?: {
reason?: string
force?: boolean
sessionFiles?: string[]
progress?: (update: MemorySyncProgressUpdate) => void
}): Promise<void>
close?(): Promise<void>
}

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<void>
}

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>
}): 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
}
6 changes: 6 additions & 0 deletions types/openclaw.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Loading