@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/.github/workflows/ci.yml +31 -0
- package/README.md +89 -0
- package/biome.json +78 -0
- package/bun.lock +1468 -0
- package/client.ts +205 -0
- package/commands/cli.ts +96 -0
- package/commands/slash.ts +80 -0
- package/config.ts +99 -0
- package/hooks/capture.ts +99 -0
- package/hooks/recall.ts +166 -0
- package/index.ts +64 -0
- package/lib/validate.d.ts +28 -0
- package/lib/validate.js +1 -0
- package/logger.ts +54 -0
- package/memory.ts +25 -0
- package/openclaw.plugin.json +63 -0
- package/package.json +28 -0
- package/tools/forget.ts +58 -0
- package/tools/profile.ts +68 -0
- package/tools/search.ts +70 -0
- package/tools/store.ts +54 -0
- package/tsconfig.json +24 -0
- package/types/openclaw.d.ts +24 -0
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
|
+
}
|
package/commands/cli.ts
ADDED
|
@@ -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
|
+
}
|
package/hooks/capture.ts
ADDED
|
@@ -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
|
+
}
|