@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/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
- for await (const mem of client.listMemories()) {
100
- if (stateManager.isProcessed(mem.resourceId)) continue
101
- if (mem.metadata?.graph_entity === "true" || mem.metadata?.graph_entity === true) continue
102
- if ((mem.metadata?.status as string) !== "completed") continue
103
-
104
- let summary = ""
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
- unprocessed.push({
121
- resourceId: mem.resourceId,
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 }],
@@ -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
- function formatContext(results: SearchResult[], maxResults: number, threshold: number): string | null {
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
- const intro =
49
- "The following is context from the user's connected sources. Reference it only when relevant to the conversation."
50
- const disclaimer =
51
- "Use this context naturally when relevant — including indirect connections — but don't force it into every response or make assumptions beyond what's stated."
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
- return `<hyperspell-context>\n${intro}\n\n${sections.join("\n\n")}\n\n${disclaimer}\n</hyperspell-context>`
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 (event: Record<string, unknown>) => {
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 context = formatContext(results, cfg.maxResults, cfg.relevanceThreshold)
90
+ const formatted = formatHighlightBullets(
91
+ results,
92
+ cfg.maxResults,
93
+ cfg.relevanceThreshold,
94
+ )
69
95
 
70
- if (!context) {
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: context }
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
+ }
@@ -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 (event: Record<string, unknown>) => {
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
+ }
@@ -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, _cfg: HyperspellConfig) {
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 { registerRememberTool } from "./tools/remember.ts"
11
- import { registerSearchTool } from "./tools/search.ts"
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
- registerSearchTool(api, client, cfg);
84
- registerRememberTool(api, client, cfg);
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
+ }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperspell/openclaw-hyperspell",
3
- "version": "0.8.1",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "description": "OpenClaw Hyperspell memory plugin",
6
6
  "license": "MIT",
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}`)