@supermemory/openclaw-supermemory 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.ts ADDED
@@ -0,0 +1,205 @@
1
+ import Supermemory from "supermemory"
2
+ import {
3
+ getRequestIntegrity,
4
+ sanitizeContent,
5
+ validateApiKeyFormat,
6
+ validateContainerTag,
7
+ } from "./lib/validate.js"
8
+ import { log } from "./logger.ts"
9
+
10
+ export type SearchResult = {
11
+ id: string
12
+ content: string
13
+ memory?: string
14
+ similarity?: number
15
+ metadata?: Record<string, unknown>
16
+ }
17
+
18
+ export type ProfileSearchResult = {
19
+ memory?: string
20
+ updatedAt?: string
21
+ similarity?: number
22
+ [key: string]: unknown
23
+ }
24
+
25
+ export type ProfileResult = {
26
+ static: string[]
27
+ dynamic: string[]
28
+ searchResults: ProfileSearchResult[]
29
+ }
30
+
31
+ function limitText(text: string, max: number): string {
32
+ return text.length > max ? `${text.slice(0, max)}…` : text
33
+ }
34
+
35
+ export class SupermemoryClient {
36
+ private client: Supermemory
37
+ private containerTag: string
38
+
39
+ constructor(apiKey: string, containerTag: string) {
40
+ const keyCheck = validateApiKeyFormat(apiKey)
41
+ if (!keyCheck.valid) {
42
+ throw new Error(`invalid API key: ${keyCheck.reason}`)
43
+ }
44
+
45
+ const tagCheck = validateContainerTag(containerTag)
46
+ if (!tagCheck.valid) {
47
+ log.warn(`container tag warning: ${tagCheck.reason}`)
48
+ }
49
+
50
+ const integrityHeaders = getRequestIntegrity(apiKey, containerTag)
51
+
52
+ this.client = new Supermemory({
53
+ apiKey,
54
+ defaultHeaders: integrityHeaders,
55
+ })
56
+ this.containerTag = containerTag
57
+ log.info(`initialized (container: ${containerTag})`)
58
+ }
59
+
60
+ async addMemory(
61
+ content: string,
62
+ metadata?: Record<string, string | number | boolean>,
63
+ customId?: string,
64
+ ): Promise<{ id: string }> {
65
+ const cleaned = sanitizeContent(content)
66
+
67
+ log.debugRequest("add", {
68
+ contentLength: cleaned.length,
69
+ customId,
70
+ metadata,
71
+ })
72
+
73
+ const result = await this.client.add({
74
+ content: cleaned,
75
+ containerTag: this.containerTag,
76
+ ...(metadata && { metadata }),
77
+ ...(customId && { customId }),
78
+ })
79
+
80
+ log.debugResponse("add", { id: result.id })
81
+ return { id: result.id }
82
+ }
83
+
84
+ async search(query: string, limit = 5): Promise<SearchResult[]> {
85
+ log.debugRequest("search.memories", {
86
+ query,
87
+ limit,
88
+ containerTag: this.containerTag,
89
+ })
90
+
91
+ const response = await this.client.search.memories({
92
+ q: query,
93
+ containerTag: this.containerTag,
94
+ limit,
95
+ })
96
+
97
+ const results: SearchResult[] = (response.results ?? []).map((r) => ({
98
+ id: r.id,
99
+ content: r.memory ?? "",
100
+ memory: r.memory,
101
+ similarity: r.similarity,
102
+ metadata: r.metadata ?? undefined,
103
+ }))
104
+
105
+ log.debugResponse("search.memories", { count: results.length })
106
+ return results
107
+ }
108
+
109
+ async getProfile(query?: string): Promise<ProfileResult> {
110
+ log.debugRequest("profile", { containerTag: this.containerTag, query })
111
+
112
+ const response = await this.client.profile({
113
+ containerTag: this.containerTag,
114
+ ...(query && { q: query }),
115
+ })
116
+
117
+ log.debugResponse("profile.raw", response)
118
+
119
+ const result: ProfileResult = {
120
+ static: response.profile?.static ?? [],
121
+ dynamic: response.profile?.dynamic ?? [],
122
+ searchResults: (response.searchResults?.results ??
123
+ []) as ProfileSearchResult[],
124
+ }
125
+
126
+ log.debugResponse("profile", {
127
+ staticCount: result.static.length,
128
+ dynamicCount: result.dynamic.length,
129
+ searchCount: result.searchResults.length,
130
+ })
131
+ return result
132
+ }
133
+
134
+ async deleteMemory(id: string): Promise<void> {
135
+ log.debugRequest("memories.delete", { id })
136
+ await this.client.memories.delete(id)
137
+ log.debugResponse("memories.delete", { success: true })
138
+ }
139
+
140
+ async forgetByQuery(
141
+ query: string,
142
+ ): Promise<{ success: boolean; message: string }> {
143
+ log.debugRequest("forgetByQuery", { query })
144
+
145
+ const results = await this.search(query, 5)
146
+ if (results.length === 0) {
147
+ return { success: false, message: "No matching memory found to forget." }
148
+ }
149
+
150
+ const target = results[0]
151
+ await this.deleteMemory(target.id)
152
+
153
+ const preview = limitText(target.content || target.memory || "", 100)
154
+ return { success: true, message: `Forgot: "${preview}"` }
155
+ }
156
+
157
+ async wipeAllMemories(): Promise<{ deletedCount: number }> {
158
+ log.debugRequest("wipe", { containerTag: this.containerTag })
159
+
160
+ const allIds: string[] = []
161
+ let page = 1
162
+
163
+ while (true) {
164
+ const response = await this.client.documents.list({
165
+ containerTags: [this.containerTag],
166
+ limit: 100,
167
+ page,
168
+ })
169
+
170
+ if (!response.memories || response.memories.length === 0) break
171
+
172
+ for (const doc of response.memories) {
173
+ if (doc.id) allIds.push(doc.id)
174
+ }
175
+
176
+ if (
177
+ !response.pagination?.totalPages ||
178
+ page >= response.pagination.totalPages
179
+ )
180
+ break
181
+ page++
182
+ }
183
+
184
+ if (allIds.length === 0) {
185
+ log.debug("wipe: no documents found")
186
+ return { deletedCount: 0 }
187
+ }
188
+
189
+ log.debug(`wipe: found ${allIds.length} documents, deleting in batches`)
190
+
191
+ let deletedCount = 0
192
+ for (let i = 0; i < allIds.length; i += 100) {
193
+ const batch = allIds.slice(i, i + 100)
194
+ await this.client.documents.deleteBulk({ ids: batch })
195
+ deletedCount += batch.length
196
+ }
197
+
198
+ log.debugResponse("wipe", { deletedCount })
199
+ return { deletedCount }
200
+ }
201
+
202
+ getContainerTag(): string {
203
+ return this.containerTag
204
+ }
205
+ }
@@ -0,0 +1,96 @@
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"
2
+ import type { SupermemoryClient } from "../client.ts"
3
+ import type { SupermemoryConfig } from "../config.ts"
4
+ import { log } from "../logger.ts"
5
+
6
+ export function registerCli(
7
+ api: OpenClawPluginApi,
8
+ client: SupermemoryClient,
9
+ _cfg: SupermemoryConfig,
10
+ ): void {
11
+ api.registerCli(
12
+ // biome-ignore lint/suspicious/noExplicitAny: openclaw SDK does not ship types
13
+ ({ program }: { program: any }) => {
14
+ const cmd = program
15
+ .command("supermemory")
16
+ .description("Supermemory long-term memory commands")
17
+
18
+ cmd
19
+ .command("search")
20
+ .argument("<query>", "Search query")
21
+ .option("--limit <n>", "Max results", "5")
22
+ .action(async (query: string, opts: { limit: string }) => {
23
+ const limit = Number.parseInt(opts.limit, 10) || 5
24
+ log.debug(`cli search: query="${query}" limit=${limit}`)
25
+
26
+ const results = await client.search(query, limit)
27
+
28
+ if (results.length === 0) {
29
+ console.log("No memories found.")
30
+ return
31
+ }
32
+
33
+ for (const r of results) {
34
+ const score = r.similarity
35
+ ? ` (${(r.similarity * 100).toFixed(0)}%)`
36
+ : ""
37
+ console.log(`- ${r.content || r.memory || ""}${score}`)
38
+ }
39
+ })
40
+
41
+ cmd
42
+ .command("profile")
43
+ .option("--query <q>", "Optional query to focus the profile")
44
+ .action(async (opts: { query?: string }) => {
45
+ log.debug(`cli profile: query="${opts.query ?? "(none)"}"`)
46
+
47
+ const profile = await client.getProfile(opts.query)
48
+
49
+ if (profile.static.length === 0 && profile.dynamic.length === 0) {
50
+ console.log("No profile information available yet.")
51
+ return
52
+ }
53
+
54
+ if (profile.static.length > 0) {
55
+ console.log("Stable Preferences:")
56
+ for (const f of profile.static) console.log(` - ${f}`)
57
+ }
58
+
59
+ if (profile.dynamic.length > 0) {
60
+ console.log("Recent Context:")
61
+ for (const f of profile.dynamic) console.log(` - ${f}`)
62
+ }
63
+ })
64
+
65
+ cmd
66
+ .command("wipe")
67
+ .description("Delete ALL memories for this container tag")
68
+ .action(async () => {
69
+ const tag = client.getContainerTag()
70
+ const readline = await import("node:readline")
71
+ const rl = readline.createInterface({
72
+ input: process.stdin,
73
+ output: process.stdout,
74
+ })
75
+
76
+ const answer = await new Promise<string>((resolve) => {
77
+ rl.question(
78
+ `This will permanently delete all memories in "${tag}". Type "yes" to confirm: `,
79
+ resolve,
80
+ )
81
+ })
82
+ rl.close()
83
+
84
+ if (answer.trim().toLowerCase() !== "yes") {
85
+ console.log("Aborted.")
86
+ return
87
+ }
88
+
89
+ log.debug(`cli wipe: container="${tag}"`)
90
+ const result = await client.wipeAllMemories()
91
+ console.log(`Wiped ${result.deletedCount} memories from "${tag}".`)
92
+ })
93
+ },
94
+ { commands: ["supermemory"] },
95
+ )
96
+ }
@@ -0,0 +1,80 @@
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"
2
+ import type { SupermemoryClient } from "../client.ts"
3
+ import type { SupermemoryConfig } from "../config.ts"
4
+ import { log } from "../logger.ts"
5
+ import { buildDocumentId, detectCategory } from "../memory.ts"
6
+
7
+ export function registerCommands(
8
+ api: OpenClawPluginApi,
9
+ client: SupermemoryClient,
10
+ _cfg: SupermemoryConfig,
11
+ getSessionKey: () => string | undefined,
12
+ ): void {
13
+ api.registerCommand({
14
+ name: "remember",
15
+ description: "Save something to memory",
16
+ acceptsArgs: true,
17
+ requireAuth: true,
18
+ handler: async (ctx: { args?: string }) => {
19
+ const text = ctx.args?.trim()
20
+ if (!text) {
21
+ return { text: "Usage: /remember <text to remember>" }
22
+ }
23
+
24
+ log.debug(`/remember command: "${text.slice(0, 50)}"`)
25
+
26
+ try {
27
+ const category = detectCategory(text)
28
+ const sk = getSessionKey()
29
+ await client.addMemory(
30
+ text,
31
+ { type: category, source: "openclaw_command" },
32
+ sk ? buildDocumentId(sk) : undefined,
33
+ )
34
+
35
+ const preview = text.length > 60 ? `${text.slice(0, 60)}…` : text
36
+ return { text: `Remembered: "${preview}"` }
37
+ } catch (err) {
38
+ log.error("/remember failed", err)
39
+ return { text: "Failed to save memory. Check logs for details." }
40
+ }
41
+ },
42
+ })
43
+
44
+ api.registerCommand({
45
+ name: "recall",
46
+ description: "Search your memories",
47
+ acceptsArgs: true,
48
+ requireAuth: true,
49
+ handler: async (ctx: { args?: string }) => {
50
+ const query = ctx.args?.trim()
51
+ if (!query) {
52
+ return { text: "Usage: /recall <search query>" }
53
+ }
54
+
55
+ log.debug(`/recall command: "${query}"`)
56
+
57
+ try {
58
+ const results = await client.search(query, 5)
59
+
60
+ if (results.length === 0) {
61
+ return { text: `No memories found for: "${query}"` }
62
+ }
63
+
64
+ const lines = results.map((r, i) => {
65
+ const score = r.similarity
66
+ ? ` (${(r.similarity * 100).toFixed(0)}%)`
67
+ : ""
68
+ return `${i + 1}. ${r.content || r.memory || ""}${score}`
69
+ })
70
+
71
+ return {
72
+ text: `Found ${results.length} memories:\n\n${lines.join("\n")}`,
73
+ }
74
+ } catch (err) {
75
+ log.error("/recall failed", err)
76
+ return { text: "Failed to search memories. Check logs for details." }
77
+ }
78
+ },
79
+ })
80
+ }
package/config.ts ADDED
@@ -0,0 +1,99 @@
1
+ import { hostname } from "node:os"
2
+
3
+ export type CaptureMode = "everything" | "all"
4
+
5
+ export type SupermemoryConfig = {
6
+ apiKey: string
7
+ containerTag: string
8
+ autoRecall: boolean
9
+ autoCapture: boolean
10
+ maxRecallResults: number
11
+ profileFrequency: number
12
+ captureMode: CaptureMode
13
+ debug: boolean
14
+ }
15
+
16
+ const ALLOWED_KEYS = [
17
+ "apiKey",
18
+ "containerTag",
19
+ "autoRecall",
20
+ "autoCapture",
21
+ "maxRecallResults",
22
+ "profileFrequency",
23
+ "captureMode",
24
+ "debug",
25
+ ]
26
+
27
+ function assertAllowedKeys(
28
+ value: Record<string, unknown>,
29
+ allowed: string[],
30
+ label: string,
31
+ ): void {
32
+ const unknown = Object.keys(value).filter((k) => !allowed.includes(k))
33
+ if (unknown.length > 0) {
34
+ throw new Error(`${label} has unknown keys: ${unknown.join(", ")}`)
35
+ }
36
+ }
37
+
38
+ function resolveEnvVars(value: string): string {
39
+ return value.replace(/\$\{([^}]+)\}/g, (_, envVar: string) => {
40
+ const envValue = process.env[envVar]
41
+ if (!envValue) {
42
+ throw new Error(`Environment variable ${envVar} is not set`)
43
+ }
44
+ return envValue
45
+ })
46
+ }
47
+
48
+ function sanitizeTag(raw: string): string {
49
+ return raw
50
+ .replace(/[^a-zA-Z0-9_]/g, "_")
51
+ .replace(/_+/g, "_")
52
+ .replace(/^_|_$/g, "")
53
+ }
54
+
55
+ function defaultContainerTag(): string {
56
+ return sanitizeTag(`openclaw_${hostname()}`)
57
+ }
58
+
59
+ export function parseConfig(raw: unknown): SupermemoryConfig {
60
+ const cfg =
61
+ raw && typeof raw === "object" && !Array.isArray(raw)
62
+ ? (raw as Record<string, unknown>)
63
+ : {}
64
+
65
+ if (Object.keys(cfg).length > 0) {
66
+ assertAllowedKeys(cfg, ALLOWED_KEYS, "supermemory config")
67
+ }
68
+
69
+ const apiKey =
70
+ typeof cfg.apiKey === "string" && cfg.apiKey.length > 0
71
+ ? resolveEnvVars(cfg.apiKey)
72
+ : process.env.SUPERMEMORY_OPENCLAW_API_KEY
73
+
74
+ if (!apiKey) {
75
+ throw new Error(
76
+ "supermemory: apiKey is required (set in plugin config or SUPERMEMORY_OPENCLAW_API_KEY env var)",
77
+ )
78
+ }
79
+
80
+ return {
81
+ apiKey,
82
+ containerTag: cfg.containerTag
83
+ ? sanitizeTag(cfg.containerTag as string)
84
+ : defaultContainerTag(),
85
+ autoRecall: (cfg.autoRecall as boolean) ?? true,
86
+ autoCapture: (cfg.autoCapture as boolean) ?? true,
87
+ maxRecallResults: (cfg.maxRecallResults as number) ?? 10,
88
+ profileFrequency: (cfg.profileFrequency as number) ?? 50,
89
+ captureMode:
90
+ cfg.captureMode === "everything"
91
+ ? ("everything" as const)
92
+ : ("all" as const),
93
+ debug: (cfg.debug as boolean) ?? false,
94
+ }
95
+ }
96
+
97
+ export const supermemoryConfigSchema = {
98
+ parse: parseConfig,
99
+ }
@@ -0,0 +1,99 @@
1
+ import type { SupermemoryClient } from "../client.ts"
2
+ import type { SupermemoryConfig } from "../config.ts"
3
+ import { log } from "../logger.ts"
4
+ import { buildDocumentId } from "../memory.ts"
5
+
6
+ function getLastTurn(messages: unknown[]): unknown[] {
7
+ let lastUserIdx = -1
8
+ for (let i = messages.length - 1; i >= 0; i--) {
9
+ const msg = messages[i]
10
+ if (
11
+ msg &&
12
+ typeof msg === "object" &&
13
+ (msg as Record<string, unknown>).role === "user"
14
+ ) {
15
+ lastUserIdx = i
16
+ break
17
+ }
18
+ }
19
+ return lastUserIdx >= 0 ? messages.slice(lastUserIdx) : messages
20
+ }
21
+
22
+ export function buildCaptureHandler(
23
+ client: SupermemoryClient,
24
+ cfg: SupermemoryConfig,
25
+ getSessionKey: () => string | undefined,
26
+ ) {
27
+ return async (event: Record<string, unknown>) => {
28
+ if (
29
+ !event.success ||
30
+ !Array.isArray(event.messages) ||
31
+ event.messages.length === 0
32
+ )
33
+ return
34
+
35
+ const lastTurn = getLastTurn(event.messages)
36
+
37
+ const texts: string[] = []
38
+ for (const msg of lastTurn) {
39
+ if (!msg || typeof msg !== "object") continue
40
+ const msgObj = msg as Record<string, unknown>
41
+ const role = msgObj.role
42
+ if (role !== "user" && role !== "assistant") continue
43
+
44
+ const content = msgObj.content
45
+
46
+ const parts: string[] = []
47
+
48
+ if (typeof content === "string") {
49
+ parts.push(content)
50
+ } else if (Array.isArray(content)) {
51
+ for (const block of content) {
52
+ if (!block || typeof block !== "object") continue
53
+ const b = block as Record<string, unknown>
54
+ if (b.type === "text" && typeof b.text === "string") {
55
+ parts.push(b.text)
56
+ }
57
+ }
58
+ }
59
+
60
+ if (parts.length > 0) {
61
+ texts.push(`[role: ${role}]\n${parts.join("\n")}\n[${role}:end]`)
62
+ }
63
+ }
64
+
65
+ const captured =
66
+ cfg.captureMode === "all"
67
+ ? texts
68
+ .map((t) =>
69
+ t
70
+ .replace(
71
+ /<supermemory-context>[\s\S]*?<\/supermemory-context>\s*/g,
72
+ "",
73
+ )
74
+ .trim(),
75
+ )
76
+ .filter((t) => t.length >= 10)
77
+ : texts
78
+
79
+ if (captured.length === 0) return
80
+
81
+ const content = captured.join("\n\n")
82
+ const sk = getSessionKey()
83
+ const customId = sk ? buildDocumentId(sk) : undefined
84
+
85
+ log.debug(
86
+ `capturing ${captured.length} texts (${content.length} chars) → ${customId ?? "no-session-key"}`,
87
+ )
88
+
89
+ try {
90
+ await client.addMemory(
91
+ content,
92
+ { source: "openclaw", timestamp: new Date().toISOString() },
93
+ customId,
94
+ )
95
+ } catch (err) {
96
+ log.error("capture failed", err)
97
+ }
98
+ }
99
+ }