@hyperspell/openclaw-hyperspell 0.8.1 → 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 +271 -59
- package/commands/setup.ts +1 -0
- package/commands/slash.ts +15 -7
- package/config.ts +55 -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/emotional-state.ts +97 -0
- package/hooks/memory-sync.ts +5 -3
- package/index.ts +19 -6
- package/lib/sender.ts +76 -0
- package/openclaw.plugin.json +17 -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/graph/ops.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import * as fs from "node:fs"
|
|
2
2
|
import * as path from "node:path"
|
|
3
3
|
import type { HyperspellClient } from "../client.ts"
|
|
4
|
+
import type { HyperspellConfig } from "../config.ts"
|
|
5
|
+
import { getAllUserIds } from "../lib/sender.ts"
|
|
4
6
|
import { log } from "../logger.ts"
|
|
5
7
|
import { NetworkStateManager } from "./state.ts"
|
|
6
8
|
|
|
@@ -93,38 +95,42 @@ export async function scanMemories(
|
|
|
93
95
|
client: HyperspellClient,
|
|
94
96
|
stateManager: NetworkStateManager,
|
|
95
97
|
batchSize: number,
|
|
98
|
+
cfg?: HyperspellConfig,
|
|
96
99
|
): Promise<ScannedMemory[]> {
|
|
97
100
|
const unprocessed: ScannedMemory[] = []
|
|
101
|
+
const userIds = cfg ? getAllUserIds(cfg) : [undefined]
|
|
102
|
+
|
|
103
|
+
outer: for (const userId of userIds) {
|
|
104
|
+
for await (const mem of client.listMemories({ userId })) {
|
|
105
|
+
if (stateManager.isProcessed(mem.resourceId)) continue
|
|
106
|
+
if (mem.metadata?.graph_entity === "true" || mem.metadata?.graph_entity === true) continue
|
|
107
|
+
if ((mem.metadata?.status as string) !== "completed") continue
|
|
108
|
+
|
|
109
|
+
let summary = ""
|
|
110
|
+
try {
|
|
111
|
+
const full = await client.getMemory(mem.resourceId, mem.source as import("../config.ts").HyperspellSource, { userId })
|
|
112
|
+
const data = full.data as unknown[] | undefined
|
|
113
|
+
|
|
114
|
+
const participants = full.participants as Array<{ name?: string; email?: string }> | undefined
|
|
115
|
+
const participantLine = participants?.length
|
|
116
|
+
? `Participants: ${participants.map((p) => `${p.name || ""} <${p.email || ""}>`).join(", ")}`
|
|
117
|
+
: ""
|
|
118
|
+
|
|
119
|
+
const dataSummary = data ? summarizeMemoryData(data, mem.source) : ""
|
|
120
|
+
summary = [participantLine, dataSummary].filter(Boolean).join("\n\n")
|
|
121
|
+
} catch {
|
|
122
|
+
summary = "(content unavailable)"
|
|
123
|
+
}
|
|
98
124
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
try {
|
|
106
|
-
const full = await client.getMemory(mem.resourceId, mem.source)
|
|
107
|
-
const data = full.data as unknown[] | undefined
|
|
108
|
-
|
|
109
|
-
const participants = full.participants as Array<{ name?: string; email?: string }> | undefined
|
|
110
|
-
const participantLine = participants?.length
|
|
111
|
-
? `Participants: ${participants.map((p) => `${p.name || ""} <${p.email || ""}>`).join(", ")}`
|
|
112
|
-
: ""
|
|
113
|
-
|
|
114
|
-
const dataSummary = data ? summarizeMemoryData(data, mem.source) : ""
|
|
115
|
-
summary = [participantLine, dataSummary].filter(Boolean).join("\n\n")
|
|
116
|
-
} catch {
|
|
117
|
-
summary = "(content unavailable)"
|
|
118
|
-
}
|
|
125
|
+
unprocessed.push({
|
|
126
|
+
resourceId: mem.resourceId,
|
|
127
|
+
source: mem.source,
|
|
128
|
+
title: mem.title,
|
|
129
|
+
summary: summary.slice(0, 1000),
|
|
130
|
+
})
|
|
119
131
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
source: mem.source,
|
|
123
|
-
title: mem.title,
|
|
124
|
-
summary: summary.slice(0, 1000),
|
|
125
|
-
})
|
|
126
|
-
|
|
127
|
-
if (unprocessed.length >= batchSize) break
|
|
132
|
+
if (unprocessed.length >= batchSize) break outer
|
|
133
|
+
}
|
|
128
134
|
}
|
|
129
135
|
|
|
130
136
|
return unprocessed
|
package/graph/tools.ts
CHANGED
|
@@ -32,7 +32,7 @@ export function registerNetworkTools(
|
|
|
32
32
|
async execute(_toolCallId: string, params: { batchSize?: number }) {
|
|
33
33
|
const batchSize = params.batchSize ?? cfg.knowledgeGraph.batchSize
|
|
34
34
|
try {
|
|
35
|
-
const memories = await scanMemories(client, stateManager, batchSize)
|
|
35
|
+
const memories = await scanMemories(client, stateManager, batchSize, cfg)
|
|
36
36
|
const text = formatScanResults(memories, stateManager.getProcessedCount(), stateManager.getLastScanAt())
|
|
37
37
|
return {
|
|
38
38
|
content: [{ type: "text" as const, text }],
|
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
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { HyperspellClient } from "../client.ts";
|
|
2
|
+
import type { HyperspellConfig } from "../config.ts";
|
|
3
|
+
import { log } from "../logger.ts";
|
|
4
|
+
|
|
5
|
+
type Message = { role?: string; content?: string | unknown };
|
|
6
|
+
|
|
7
|
+
const MIN_MESSAGES = 3;
|
|
8
|
+
const MIN_CONVERSATION_LENGTH = 100;
|
|
9
|
+
|
|
10
|
+
function messagesToTranscript(messages: unknown[]): string {
|
|
11
|
+
return (messages as Message[])
|
|
12
|
+
.filter((m) => m.role && m.content)
|
|
13
|
+
.map((m) => {
|
|
14
|
+
const content =
|
|
15
|
+
typeof m.content === "string" ? m.content : JSON.stringify(m.content);
|
|
16
|
+
return `${m.role}: ${content}`;
|
|
17
|
+
})
|
|
18
|
+
.join("\n");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Fetch emotional state at session start and inject into context.
|
|
23
|
+
* Runs on `before_agent_start`.
|
|
24
|
+
*/
|
|
25
|
+
export function buildEmotionalStateFetchHandler(
|
|
26
|
+
client: HyperspellClient,
|
|
27
|
+
cfg: HyperspellConfig,
|
|
28
|
+
) {
|
|
29
|
+
return async (_event: Record<string, unknown>) => {
|
|
30
|
+
try {
|
|
31
|
+
const state = await client.getEmotionalState(cfg.relationshipId);
|
|
32
|
+
|
|
33
|
+
if (!state) {
|
|
34
|
+
log.debug("emotional-context: no prior emotional state found");
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
log.debug(`emotional-context: injecting state from ${state.extractedAt}`);
|
|
39
|
+
|
|
40
|
+
const context = [
|
|
41
|
+
"<hyperspell-emotional-context>",
|
|
42
|
+
"The following captures the emotional register of your relationship with this user from your last interaction. Let it inform your tone — don't reference it explicitly.",
|
|
43
|
+
"",
|
|
44
|
+
state.summary,
|
|
45
|
+
"</hyperspell-emotional-context>",
|
|
46
|
+
].join("\n");
|
|
47
|
+
|
|
48
|
+
return { prependContext: context };
|
|
49
|
+
} catch (err) {
|
|
50
|
+
log.error("emotional-context fetch failed", err);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Extract and store emotional state at session end.
|
|
58
|
+
* Runs on `agent_end` — fire-and-forget.
|
|
59
|
+
*/
|
|
60
|
+
export function buildEmotionalStateStoreHandler(
|
|
61
|
+
client: HyperspellClient,
|
|
62
|
+
cfg: HyperspellConfig,
|
|
63
|
+
) {
|
|
64
|
+
return async (event: Record<string, unknown>) => {
|
|
65
|
+
if (event.success === false) {
|
|
66
|
+
log.debug("emotional-state: skipping — agent ended with error");
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const messages = event.messages as unknown[] | undefined;
|
|
71
|
+
if (!messages || messages.length < MIN_MESSAGES) {
|
|
72
|
+
log.debug(
|
|
73
|
+
`emotional-state: skipping — too few messages (${messages?.length ?? 0})`,
|
|
74
|
+
);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const transcript = messagesToTranscript(messages);
|
|
79
|
+
if (transcript.length < MIN_CONVERSATION_LENGTH) {
|
|
80
|
+
log.debug(
|
|
81
|
+
`emotional-state: skipping — conversation too short (${transcript.length} chars)`,
|
|
82
|
+
);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const result = await client.storeEmotionalState(transcript, {
|
|
88
|
+
relationshipId: cfg.relationshipId,
|
|
89
|
+
metadata: { source: "openclaw_agent_end" },
|
|
90
|
+
});
|
|
91
|
+
log.info(`emotional-state: stored ${result.resourceId}`);
|
|
92
|
+
} catch (err) {
|
|
93
|
+
// Fire-and-forget — never let this break the session
|
|
94
|
+
log.error("emotional-state store failed", err);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
}
|
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
|
@@ -5,10 +5,11 @@ import { registerCliCommands } from "./commands/setup.ts"
|
|
|
5
5
|
import { parseConfig, hyperspellConfigSchema, getWorkspaceDir } from "./config.ts"
|
|
6
6
|
import { buildAutoContextHandler } from "./hooks/auto-context.ts"
|
|
7
7
|
import { buildAutoTraceHandler } from "./hooks/auto-trace.ts"
|
|
8
|
+
import { buildEmotionalStateFetchHandler, buildEmotionalStateStoreHandler } from "./hooks/emotional-state.ts"
|
|
8
9
|
import { buildFileSyncHandler, syncMemoriesOnStartup } from "./hooks/memory-sync.ts"
|
|
9
10
|
import { initLogger } from "./logger.ts"
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
11
|
+
import { createRememberToolFactory } from "./tools/remember.ts"
|
|
12
|
+
import { createSearchToolFactory } from "./tools/search.ts"
|
|
12
13
|
import { registerNetworkTools } from "./graph/index.ts"
|
|
13
14
|
|
|
14
15
|
export default {
|
|
@@ -79,9 +80,19 @@ export default {
|
|
|
79
80
|
|
|
80
81
|
const client = new HyperspellClient(cfg);
|
|
81
82
|
|
|
82
|
-
// Register AI tools
|
|
83
|
-
|
|
84
|
-
|
|
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
|
+
});
|
|
90
|
+
|
|
91
|
+
// Register emotional context hooks (fetch on start, store on end)
|
|
92
|
+
if (cfg.emotionalContext) {
|
|
93
|
+
api.on("before_agent_start", buildEmotionalStateFetchHandler(client, cfg));
|
|
94
|
+
api.on("agent_end", buildEmotionalStateStoreHandler(client, cfg));
|
|
95
|
+
}
|
|
85
96
|
|
|
86
97
|
// Register auto-context hook
|
|
87
98
|
if (cfg.autoContext) {
|
|
@@ -117,7 +128,9 @@ export default {
|
|
|
117
128
|
// Sync memories on startup if enabled
|
|
118
129
|
if (cfg.syncMemories) {
|
|
119
130
|
const workspaceDir = getWorkspaceDir();
|
|
120
|
-
await syncMemoriesOnStartup(client, workspaceDir
|
|
131
|
+
await syncMemoriesOnStartup(client, workspaceDir, {
|
|
132
|
+
userId: cfg.multiUser?.sharedUserId,
|
|
133
|
+
});
|
|
121
134
|
}
|
|
122
135
|
},
|
|
123
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/openclaw.plugin.json
CHANGED
|
@@ -27,6 +27,17 @@
|
|
|
27
27
|
"help": "Automatically send conversation traces to Hyperspell for memory extraction (procedural, mood) at the end of each session",
|
|
28
28
|
"advanced": true
|
|
29
29
|
},
|
|
30
|
+
"emotionalContext": {
|
|
31
|
+
"label": "Emotional Context",
|
|
32
|
+
"help": "Maintain emotional continuity across sessions — remembers not just what happened, but how it felt",
|
|
33
|
+
"advanced": true
|
|
34
|
+
},
|
|
35
|
+
"relationshipId": {
|
|
36
|
+
"label": "Relationship ID",
|
|
37
|
+
"placeholder": "partner-anna",
|
|
38
|
+
"help": "Optional identifier for per-relationship emotional state (e.g. different registers for different people)",
|
|
39
|
+
"advanced": true
|
|
40
|
+
},
|
|
30
41
|
"sources": {
|
|
31
42
|
"label": "Sources",
|
|
32
43
|
"placeholder": "notion,slack,google_drive",
|
|
@@ -82,6 +93,12 @@
|
|
|
82
93
|
"metadata": { "type": "object" }
|
|
83
94
|
}
|
|
84
95
|
},
|
|
96
|
+
"emotionalContext": {
|
|
97
|
+
"type": "boolean"
|
|
98
|
+
},
|
|
99
|
+
"relationshipId": {
|
|
100
|
+
"type": "string"
|
|
101
|
+
},
|
|
85
102
|
"sources": {
|
|
86
103
|
"type": "string"
|
|
87
104
|
},
|
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}`)
|