@hyperspell/openclaw-hyperspell 0.9.0 → 0.10.0
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 +139 -59
- package/commands/slash.ts +15 -7
- package/config.ts +49 -0
- package/graph/ops.ts +34 -28
- package/graph/tools.ts +1 -1
- package/hooks/auto-context.ts +163 -10
- package/hooks/auto-trace.ts +12 -2
- package/hooks/memory-sync.ts +5 -3
- package/index.ts +12 -6
- package/lib/sender.ts +76 -0
- package/package.json +1 -1
- package/sync/markdown.ts +4 -1
- package/tools/remember.ts +56 -48
- package/tools/search.ts +86 -82
- package/types/openclaw.d.ts +16 -0
package/hooks/auto-context.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { HyperspellClient, SearchResult } from "../client.ts"
|
|
2
2
|
import type { HyperspellConfig } from "../config.ts"
|
|
3
|
+
import { resolveUser } from "../lib/sender.ts"
|
|
3
4
|
import { log } from "../logger.ts"
|
|
4
5
|
|
|
5
6
|
function formatRelativeTime(isoTimestamp: string): string {
|
|
@@ -26,7 +27,15 @@ function formatRelativeTime(isoTimestamp: string): string {
|
|
|
26
27
|
}
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Format a list of search results as per-highlight bullets, filtered by relevance threshold.
|
|
32
|
+
* Returns null if nothing passes the threshold.
|
|
33
|
+
*/
|
|
34
|
+
function formatHighlightBullets(
|
|
35
|
+
results: SearchResult[],
|
|
36
|
+
maxResults: number,
|
|
37
|
+
threshold: number,
|
|
38
|
+
): string | null {
|
|
30
39
|
const sections: string[] = []
|
|
31
40
|
|
|
32
41
|
for (const r of results.slice(0, maxResults)) {
|
|
@@ -44,39 +53,183 @@ function formatContext(results: SearchResult[], maxResults: number, threshold: n
|
|
|
44
53
|
}
|
|
45
54
|
|
|
46
55
|
if (sections.length === 0) return null
|
|
56
|
+
return sections.join("\n\n")
|
|
57
|
+
}
|
|
47
58
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
59
|
+
const INTRO =
|
|
60
|
+
"The following is context from the user's connected sources. Reference it only when relevant to the conversation."
|
|
61
|
+
const DISCLAIMER =
|
|
62
|
+
"Use this context naturally when relevant — including indirect connections — but don't force it into every response or make assumptions beyond what's stated."
|
|
52
63
|
|
|
53
|
-
|
|
64
|
+
function wrapSingle(body: string): string {
|
|
65
|
+
return `<hyperspell-context>\n${INTRO}\n\n${body}\n\n${DISCLAIMER}\n</hyperspell-context>`
|
|
54
66
|
}
|
|
55
67
|
|
|
56
68
|
export function buildAutoContextHandler(
|
|
57
69
|
client: HyperspellClient,
|
|
58
70
|
cfg: HyperspellConfig,
|
|
59
71
|
) {
|
|
60
|
-
return async (
|
|
72
|
+
return async (
|
|
73
|
+
event: Record<string, unknown>,
|
|
74
|
+
ctx?: Record<string, unknown>,
|
|
75
|
+
) => {
|
|
61
76
|
const prompt = event.prompt as string | undefined
|
|
62
77
|
if (!prompt || prompt.length < 5) return
|
|
63
78
|
|
|
79
|
+
// Multi-user path
|
|
80
|
+
if (cfg.multiUser) {
|
|
81
|
+
const resolved = resolveUser(ctx, cfg)
|
|
82
|
+
return multiUserSearch(client, cfg, prompt, resolved)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Single-user path — preserves main's highlights + threshold behavior
|
|
64
86
|
log.debug(`auto-context: searching for "${prompt.slice(0, 50)}..."`)
|
|
65
87
|
|
|
66
88
|
try {
|
|
67
89
|
const results = await client.search(prompt, { limit: cfg.maxResults })
|
|
68
|
-
const
|
|
90
|
+
const formatted = formatHighlightBullets(
|
|
91
|
+
results,
|
|
92
|
+
cfg.maxResults,
|
|
93
|
+
cfg.relevanceThreshold,
|
|
94
|
+
)
|
|
69
95
|
|
|
70
|
-
if (!
|
|
96
|
+
if (!formatted) {
|
|
71
97
|
log.debug("auto-context: no relevant memories found")
|
|
72
98
|
return
|
|
73
99
|
}
|
|
74
100
|
|
|
75
101
|
log.debug(`auto-context: injecting ${results.length} memories`)
|
|
76
|
-
return { prependContext:
|
|
102
|
+
return { prependContext: wrapSingle(formatted) }
|
|
77
103
|
} catch (err) {
|
|
78
104
|
log.error("auto-context failed", err)
|
|
79
105
|
return
|
|
80
106
|
}
|
|
81
107
|
}
|
|
82
108
|
}
|
|
109
|
+
|
|
110
|
+
async function multiUserSearch(
|
|
111
|
+
client: HyperspellClient,
|
|
112
|
+
cfg: HyperspellConfig,
|
|
113
|
+
prompt: string,
|
|
114
|
+
resolved:
|
|
115
|
+
| { userId: string; name: string; context?: string; resolved: boolean }
|
|
116
|
+
| undefined,
|
|
117
|
+
) {
|
|
118
|
+
const multiUser = cfg.multiUser!
|
|
119
|
+
const isKnownSender = !!resolved?.resolved
|
|
120
|
+
const includeShared = multiUser.includeSharedInSearch
|
|
121
|
+
|
|
122
|
+
log.debug(
|
|
123
|
+
`auto-context: searching for "${prompt.slice(0, 50)}..." user=${resolved?.userId ?? "unknown"}`,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
// Build parallel searches — personal (known senders only) + shared
|
|
127
|
+
const personalSearch = isKnownSender
|
|
128
|
+
? client.search(prompt, {
|
|
129
|
+
limit: cfg.maxResults,
|
|
130
|
+
userId: resolved!.userId,
|
|
131
|
+
})
|
|
132
|
+
: null
|
|
133
|
+
|
|
134
|
+
// Always search shared for unknown senders, even if includeSharedInSearch is false
|
|
135
|
+
const sharedLimit = isKnownSender
|
|
136
|
+
? Math.ceil(cfg.maxResults / 2)
|
|
137
|
+
: cfg.maxResults
|
|
138
|
+
const sharedSearch =
|
|
139
|
+
includeShared || !isKnownSender
|
|
140
|
+
? client.search(prompt, {
|
|
141
|
+
limit: sharedLimit,
|
|
142
|
+
userId: multiUser.sharedUserId,
|
|
143
|
+
})
|
|
144
|
+
: null
|
|
145
|
+
|
|
146
|
+
const searches = [personalSearch, sharedSearch].filter(Boolean) as Promise<
|
|
147
|
+
SearchResult[]
|
|
148
|
+
>[]
|
|
149
|
+
const settled = await Promise.allSettled(searches)
|
|
150
|
+
|
|
151
|
+
let idx = 0
|
|
152
|
+
let personalResults: SearchResult[] = []
|
|
153
|
+
let sharedResults: SearchResult[] = []
|
|
154
|
+
|
|
155
|
+
if (personalSearch) {
|
|
156
|
+
const r = settled[idx++]
|
|
157
|
+
if (r.status === "fulfilled") {
|
|
158
|
+
personalResults = r.value
|
|
159
|
+
} else {
|
|
160
|
+
log.error("auto-context: personal search failed", r.reason)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (sharedSearch) {
|
|
164
|
+
const r = settled[idx++]
|
|
165
|
+
if (r.status === "fulfilled") {
|
|
166
|
+
sharedResults = r.value
|
|
167
|
+
} else {
|
|
168
|
+
log.error("auto-context: shared search failed", r.reason)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const sections: string[] = []
|
|
173
|
+
|
|
174
|
+
// User identity preamble
|
|
175
|
+
if (isKnownSender && resolved) {
|
|
176
|
+
const contextLine = resolved.context ? ` ${resolved.context}` : ""
|
|
177
|
+
sections.push(`You are speaking with ${resolved.name}.${contextLine}`)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Personal section (threshold-filtered per PR #11/#12 format)
|
|
181
|
+
if (isKnownSender && personalResults.length > 0 && resolved) {
|
|
182
|
+
const formatted = formatHighlightBullets(
|
|
183
|
+
personalResults,
|
|
184
|
+
cfg.maxResults,
|
|
185
|
+
cfg.relevanceThreshold,
|
|
186
|
+
)
|
|
187
|
+
if (formatted) {
|
|
188
|
+
sections.push(
|
|
189
|
+
`<personal-context>\nMemories from ${resolved.name}'s personal sources and history.\n\n${formatted}\n</personal-context>`,
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Shared section
|
|
195
|
+
if (sharedResults.length > 0) {
|
|
196
|
+
const sharedDisplayLimit = isKnownSender
|
|
197
|
+
? Math.ceil(cfg.maxResults / 2)
|
|
198
|
+
: cfg.maxResults
|
|
199
|
+
const formatted = formatHighlightBullets(
|
|
200
|
+
sharedResults,
|
|
201
|
+
sharedDisplayLimit,
|
|
202
|
+
cfg.relevanceThreshold,
|
|
203
|
+
)
|
|
204
|
+
if (formatted) {
|
|
205
|
+
sections.push(
|
|
206
|
+
`<shared-context>\nShared memories available to all users.\n\n${formatted}\n</shared-context>`,
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// If only the identity preamble is present (no memory sections), still inject identity
|
|
212
|
+
const haveMemorySections = sections.some(
|
|
213
|
+
(s) => s.startsWith("<personal-context>") || s.startsWith("<shared-context>"),
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
if (!haveMemorySections) {
|
|
217
|
+
log.debug("auto-context: no relevant memories found")
|
|
218
|
+
if (isKnownSender && resolved) {
|
|
219
|
+
const contextLine = resolved.context ? ` ${resolved.context}` : ""
|
|
220
|
+
return {
|
|
221
|
+
prependContext: `<hyperspell-context>\nYou are speaking with ${resolved.name}.${contextLine}\n</hyperspell-context>`,
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const totalCount = personalResults.length + sharedResults.length
|
|
228
|
+
log.debug(
|
|
229
|
+
`auto-context: injecting ${totalCount} memories (${personalResults.length} personal, ${sharedResults.length} shared)`,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
prependContext: `<hyperspell-context>\n${sections.join("\n\n")}\n\n${DISCLAIMER}\n</hyperspell-context>`,
|
|
234
|
+
}
|
|
235
|
+
}
|
package/hooks/auto-trace.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { HyperspellClient } from "../client.ts";
|
|
2
2
|
import type { HyperspellConfig } from "../config.ts";
|
|
3
|
+
import { resolveUser } from "../lib/sender.ts";
|
|
3
4
|
import { log } from "../logger.ts";
|
|
4
5
|
|
|
5
6
|
type Message = { role?: string; content?: string | unknown };
|
|
@@ -75,7 +76,10 @@ export function buildAutoTraceHandler(
|
|
|
75
76
|
client: HyperspellClient,
|
|
76
77
|
cfg: HyperspellConfig,
|
|
77
78
|
) {
|
|
78
|
-
return async (
|
|
79
|
+
return async (
|
|
80
|
+
event: Record<string, unknown>,
|
|
81
|
+
ctx?: Record<string, unknown>,
|
|
82
|
+
) => {
|
|
79
83
|
if (event.success === false) {
|
|
80
84
|
log.debug("auto-trace: skipping — agent ended with error");
|
|
81
85
|
return;
|
|
@@ -109,15 +113,21 @@ export function buildAutoTraceHandler(
|
|
|
109
113
|
? String(firstUser.content).slice(0, 80).replace(/\n/g, " ")
|
|
110
114
|
: undefined;
|
|
111
115
|
|
|
116
|
+
// Resolve sender → userId so traces land in the right user's space.
|
|
117
|
+
// Falls back to sharedUserId for unknown senders (or undefined in single-user mode).
|
|
118
|
+
const resolved = resolveUser(ctx, cfg);
|
|
119
|
+
const userId = resolved?.userId;
|
|
120
|
+
|
|
112
121
|
try {
|
|
113
122
|
const result = await client.sendTrace(history, {
|
|
114
123
|
sessionId,
|
|
115
124
|
title,
|
|
116
125
|
extract: cfg.autoTrace.extract,
|
|
117
126
|
metadata: cfg.autoTrace.metadata,
|
|
127
|
+
userId,
|
|
118
128
|
});
|
|
119
129
|
log.info(
|
|
120
|
-
`auto-trace: sent ${result.resourceId} (${messages.length} messages)`,
|
|
130
|
+
`auto-trace: sent ${result.resourceId} (${messages.length} messages${userId ? `, user=${userId}` : ""})`,
|
|
121
131
|
);
|
|
122
132
|
} catch (err) {
|
|
123
133
|
// Fire-and-forget — never break the session
|
package/hooks/memory-sync.ts
CHANGED
|
@@ -8,9 +8,10 @@ import { syncMarkdownFile, syncAllMemoryFiles } from "../sync/markdown.ts"
|
|
|
8
8
|
/**
|
|
9
9
|
* Build a handler for file change events that syncs markdown files to Hyperspell
|
|
10
10
|
*/
|
|
11
|
-
export function buildFileSyncHandler(client: HyperspellClient,
|
|
11
|
+
export function buildFileSyncHandler(client: HyperspellClient, cfg: HyperspellConfig) {
|
|
12
12
|
const workspaceDir = getWorkspaceDir()
|
|
13
13
|
const memoryDir = path.join(workspaceDir, "memory")
|
|
14
|
+
const syncUserId = cfg.multiUser?.sharedUserId
|
|
14
15
|
|
|
15
16
|
return async (event: Record<string, unknown>) => {
|
|
16
17
|
const filePath = event.file_path as string | undefined
|
|
@@ -25,7 +26,7 @@ export function buildFileSyncHandler(client: HyperspellClient, _cfg: HyperspellC
|
|
|
25
26
|
log.info(`Memory file changed: ${fileName}`)
|
|
26
27
|
|
|
27
28
|
try {
|
|
28
|
-
const result = await syncMarkdownFile(client, filePath)
|
|
29
|
+
const result = await syncMarkdownFile(client, filePath, { userId: syncUserId })
|
|
29
30
|
if (result.success) {
|
|
30
31
|
log.info(`Synced ${fileName} -> ${result.resourceId}`)
|
|
31
32
|
} else {
|
|
@@ -43,10 +44,11 @@ export function buildFileSyncHandler(client: HyperspellClient, _cfg: HyperspellC
|
|
|
43
44
|
export async function syncMemoriesOnStartup(
|
|
44
45
|
client: HyperspellClient,
|
|
45
46
|
workspaceDir: string,
|
|
47
|
+
options?: { userId?: string },
|
|
46
48
|
): Promise<void> {
|
|
47
49
|
log.info("Syncing existing memory files...")
|
|
48
50
|
|
|
49
|
-
const result = await syncAllMemoryFiles(client, workspaceDir)
|
|
51
|
+
const result = await syncAllMemoryFiles(client, workspaceDir, { userId: options?.userId })
|
|
50
52
|
|
|
51
53
|
if (result.synced > 0) {
|
|
52
54
|
log.info(`Synced ${result.synced} memory files`)
|
package/index.ts
CHANGED
|
@@ -8,8 +8,8 @@ import { buildAutoTraceHandler } from "./hooks/auto-trace.ts"
|
|
|
8
8
|
import { buildEmotionalStateFetchHandler, buildEmotionalStateStoreHandler } from "./hooks/emotional-state.ts"
|
|
9
9
|
import { buildFileSyncHandler, syncMemoriesOnStartup } from "./hooks/memory-sync.ts"
|
|
10
10
|
import { initLogger } from "./logger.ts"
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
11
|
+
import { createRememberToolFactory } from "./tools/remember.ts"
|
|
12
|
+
import { createSearchToolFactory } from "./tools/search.ts"
|
|
13
13
|
import { registerNetworkTools } from "./graph/index.ts"
|
|
14
14
|
|
|
15
15
|
export default {
|
|
@@ -80,9 +80,13 @@ export default {
|
|
|
80
80
|
|
|
81
81
|
const client = new HyperspellClient(cfg);
|
|
82
82
|
|
|
83
|
-
// Register AI tools
|
|
84
|
-
|
|
85
|
-
|
|
83
|
+
// Register AI tools (factory pattern for sender context)
|
|
84
|
+
api.registerTool(createSearchToolFactory(client, cfg), {
|
|
85
|
+
name: "hyperspell_search",
|
|
86
|
+
});
|
|
87
|
+
api.registerTool(createRememberToolFactory(client, cfg), {
|
|
88
|
+
name: "hyperspell_remember",
|
|
89
|
+
});
|
|
86
90
|
|
|
87
91
|
// Register emotional context hooks (fetch on start, store on end)
|
|
88
92
|
if (cfg.emotionalContext) {
|
|
@@ -124,7 +128,9 @@ export default {
|
|
|
124
128
|
// Sync memories on startup if enabled
|
|
125
129
|
if (cfg.syncMemories) {
|
|
126
130
|
const workspaceDir = getWorkspaceDir();
|
|
127
|
-
await syncMemoriesOnStartup(client, workspaceDir
|
|
131
|
+
await syncMemoriesOnStartup(client, workspaceDir, {
|
|
132
|
+
userId: cfg.multiUser?.sharedUserId,
|
|
133
|
+
});
|
|
128
134
|
}
|
|
129
135
|
},
|
|
130
136
|
stop: () => {
|
package/lib/sender.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { HyperspellConfig } from "../config.ts"
|
|
2
|
+
import { log } from "../logger.ts"
|
|
3
|
+
|
|
4
|
+
export interface ResolvedUser {
|
|
5
|
+
userId: string
|
|
6
|
+
name: string
|
|
7
|
+
context?: string
|
|
8
|
+
/** True if the sender was matched in senderMap; false if falling back to sharedUserId */
|
|
9
|
+
resolved: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Resolve the sender's Hyperspell user from hook or tool context.
|
|
14
|
+
*
|
|
15
|
+
* Matches sender handles in the multiUser.senderMap against the sessionKey
|
|
16
|
+
* using substring matching. Handles are sorted longest-first to prevent
|
|
17
|
+
* partial matches (e.g., Discord ID "123" matching inside "1234567").
|
|
18
|
+
*
|
|
19
|
+
* Falls back to sharedUserId for unrecognized senders.
|
|
20
|
+
*/
|
|
21
|
+
export function resolveUser(
|
|
22
|
+
ctx: Record<string, unknown> | undefined,
|
|
23
|
+
cfg: HyperspellConfig,
|
|
24
|
+
): ResolvedUser | undefined {
|
|
25
|
+
const multiUser = cfg.multiUser
|
|
26
|
+
if (!multiUser) {
|
|
27
|
+
// Single-user mode: return config.userId if set
|
|
28
|
+
return cfg.userId ? { userId: cfg.userId, name: cfg.userId, resolved: true } : undefined
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Try direct senderId lookup (available in slash command contexts;
|
|
32
|
+
// tool factory contexts do NOT include senderId — they have sessionKey instead)
|
|
33
|
+
const senderId = (ctx?.senderId as string) ?? (ctx?.requesterSenderId as string) ?? undefined
|
|
34
|
+
if (senderId && multiUser.senderMap[senderId]) {
|
|
35
|
+
const profile = multiUser.senderMap[senderId]
|
|
36
|
+
log.debug(`sender resolved via senderId: ${senderId} -> ${profile.userId}`)
|
|
37
|
+
return { ...profile, resolved: true }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Try sessionKey substring matching (works in both hook and tool factory contexts)
|
|
41
|
+
const sessionKey = ctx?.sessionKey as string | undefined
|
|
42
|
+
if (sessionKey) {
|
|
43
|
+
const sortedEntries = Object.entries(multiUser.senderMap)
|
|
44
|
+
.sort(([a], [b]) => b.length - a.length)
|
|
45
|
+
for (const [handle, profile] of sortedEntries) {
|
|
46
|
+
if (sessionKey.includes(handle)) {
|
|
47
|
+
log.debug(`sender resolved via sessionKey: ${handle} -> ${profile.userId}`)
|
|
48
|
+
return { ...profile, resolved: true }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Fallback: use sharedUserId for unknown senders
|
|
54
|
+
log.debug("sender unresolved, falling back to sharedUserId")
|
|
55
|
+
return {
|
|
56
|
+
userId: multiUser.sharedUserId,
|
|
57
|
+
name: multiUser.sharedUserId,
|
|
58
|
+
resolved: false,
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Get all unique userIds from the multiUser config (for knowledge graph scanning).
|
|
64
|
+
*/
|
|
65
|
+
export function getAllUserIds(cfg: HyperspellConfig): string[] {
|
|
66
|
+
if (!cfg.multiUser) {
|
|
67
|
+
return cfg.userId ? [cfg.userId] : []
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const userIds = new Set<string>()
|
|
71
|
+
for (const profile of Object.values(cfg.multiUser.senderMap)) {
|
|
72
|
+
userIds.add(profile.userId)
|
|
73
|
+
}
|
|
74
|
+
userIds.add(cfg.multiUser.sharedUserId)
|
|
75
|
+
return [...userIds]
|
|
76
|
+
}
|
package/package.json
CHANGED
package/sync/markdown.ts
CHANGED
|
@@ -122,6 +122,7 @@ export function getMemoryFiles(workspaceDir: string): string[] {
|
|
|
122
122
|
export async function syncMarkdownFile(
|
|
123
123
|
client: HyperspellClient,
|
|
124
124
|
filePath: string,
|
|
125
|
+
options?: { userId?: string },
|
|
125
126
|
): Promise<{ success: boolean; resourceId?: string; error?: string }> {
|
|
126
127
|
const file = readMarkdownFile(filePath)
|
|
127
128
|
if (!file) {
|
|
@@ -141,6 +142,7 @@ export async function syncMarkdownFile(
|
|
|
141
142
|
openclaw_source: "memory_sync",
|
|
142
143
|
file_path: filePath,
|
|
143
144
|
},
|
|
145
|
+
userId: options?.userId,
|
|
144
146
|
})
|
|
145
147
|
|
|
146
148
|
// Update frontmatter with new resource ID if it changed or was newly created
|
|
@@ -162,6 +164,7 @@ export async function syncMarkdownFile(
|
|
|
162
164
|
export async function syncAllMemoryFiles(
|
|
163
165
|
client: HyperspellClient,
|
|
164
166
|
workspaceDir: string,
|
|
167
|
+
options?: { userId?: string },
|
|
165
168
|
): Promise<{ synced: number; failed: number; errors: string[] }> {
|
|
166
169
|
const files = getMemoryFiles(workspaceDir)
|
|
167
170
|
let synced = 0
|
|
@@ -169,7 +172,7 @@ export async function syncAllMemoryFiles(
|
|
|
169
172
|
const errors: string[] = []
|
|
170
173
|
|
|
171
174
|
for (const filePath of files) {
|
|
172
|
-
const result = await syncMarkdownFile(client, filePath)
|
|
175
|
+
const result = await syncMarkdownFile(client, filePath, { userId: options?.userId })
|
|
173
176
|
if (result.success) {
|
|
174
177
|
synced++
|
|
175
178
|
log.info(`Synced: ${path.basename(filePath)} -> ${result.resourceId}`)
|
package/tools/remember.ts
CHANGED
|
@@ -1,60 +1,68 @@
|
|
|
1
1
|
import { Type } from "@sinclair/typebox"
|
|
2
|
-
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"
|
|
3
2
|
import type { HyperspellClient } from "../client.ts"
|
|
4
3
|
import type { HyperspellConfig } from "../config.ts"
|
|
4
|
+
import { resolveUser } from "../lib/sender.ts"
|
|
5
5
|
import { log } from "../logger.ts"
|
|
6
6
|
|
|
7
|
-
export function
|
|
8
|
-
api: OpenClawPluginApi,
|
|
7
|
+
export function createRememberToolFactory(
|
|
9
8
|
client: HyperspellClient,
|
|
10
|
-
|
|
11
|
-
)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
)
|
|
30
|
-
|
|
9
|
+
cfg: HyperspellConfig,
|
|
10
|
+
) {
|
|
11
|
+
return (ctx: Record<string, unknown>) => ({
|
|
12
|
+
name: "hyperspell_remember",
|
|
13
|
+
label: "Memory Store",
|
|
14
|
+
description: "Save important information to the user's memory.",
|
|
15
|
+
parameters: Type.Object({
|
|
16
|
+
text: Type.String({ description: "Information to remember" }),
|
|
17
|
+
title: Type.Optional(
|
|
18
|
+
Type.String({ description: "Optional title for the memory" }),
|
|
19
|
+
),
|
|
20
|
+
date: Type.Optional(
|
|
21
|
+
Type.String({ description: "Date of the memory (ISO 8601 or YYYY-MM-DD). Helps ranking and enables date-range filtering. Defaults to now if omitted." }),
|
|
22
|
+
),
|
|
23
|
+
userId: Type.Optional(
|
|
24
|
+
Type.String({
|
|
25
|
+
description:
|
|
26
|
+
"Store for a specific user or 'shared' for everyone. Omit to store for current sender.",
|
|
27
|
+
}),
|
|
28
|
+
),
|
|
29
|
+
}),
|
|
30
|
+
async execute(
|
|
31
|
+
_toolCallId: string,
|
|
32
|
+
params: { text: string; title?: string; date?: string; userId?: string },
|
|
33
|
+
) {
|
|
34
|
+
// Resolve userId: explicit param > sender resolution > config default
|
|
35
|
+
const resolved = resolveUser(ctx, cfg)
|
|
36
|
+
const userId = params.userId ?? resolved?.userId
|
|
37
|
+
log.debug(
|
|
38
|
+
`remember tool: "${params.text.slice(0, 50)}..." date=${params.date ?? "now"} userId=${userId}`,
|
|
39
|
+
)
|
|
31
40
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
41
|
+
try {
|
|
42
|
+
await client.addMemory(params.text, {
|
|
43
|
+
title: params.title,
|
|
44
|
+
date: params.date,
|
|
45
|
+
metadata: { source: "openclaw_tool" },
|
|
46
|
+
userId,
|
|
47
|
+
})
|
|
38
48
|
|
|
39
|
-
|
|
40
|
-
|
|
49
|
+
const preview =
|
|
50
|
+
params.text.length > 80 ? `${params.text.slice(0, 80)}…` : params.text
|
|
41
51
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
} catch (err) {
|
|
46
|
-
log.error("remember tool failed", err)
|
|
47
|
-
return {
|
|
48
|
-
content: [
|
|
49
|
-
{
|
|
50
|
-
type: "text" as const,
|
|
51
|
-
text: `Failed to store memory: ${err instanceof Error ? err.message : String(err)}`,
|
|
52
|
-
},
|
|
53
|
-
],
|
|
54
|
-
}
|
|
52
|
+
return {
|
|
53
|
+
content: [{ type: "text" as const, text: `Stored: "${preview}"` }],
|
|
55
54
|
}
|
|
56
|
-
}
|
|
55
|
+
} catch (err) {
|
|
56
|
+
log.error("remember tool failed", err)
|
|
57
|
+
return {
|
|
58
|
+
content: [
|
|
59
|
+
{
|
|
60
|
+
type: "text" as const,
|
|
61
|
+
text: `Failed to store memory: ${err instanceof Error ? err.message : String(err)}`,
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
}
|
|
65
|
+
}
|
|
57
66
|
},
|
|
58
|
-
|
|
59
|
-
)
|
|
67
|
+
})
|
|
60
68
|
}
|