@hyperspell/openclaw-hyperspell 0.9.0 → 0.11.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.
@@ -1,5 +1,11 @@
1
1
  import type { HyperspellClient, SearchResult } from "../client.ts"
2
- import type { HyperspellConfig } from "../config.ts"
2
+ import type { CanReadScope, HyperspellConfig } from "../config.ts"
3
+ import {
4
+ buildScopeFilter,
5
+ getCanReadScopes,
6
+ resolveUser,
7
+ type ResolvedUser,
8
+ } from "../lib/sender.ts"
3
9
  import { log } from "../logger.ts"
4
10
 
5
11
  function formatRelativeTime(isoTimestamp: string): string {
@@ -26,7 +32,15 @@ function formatRelativeTime(isoTimestamp: string): string {
26
32
  }
27
33
  }
28
34
 
29
- function formatContext(results: SearchResult[], maxResults: number, threshold: number): string | null {
35
+ /**
36
+ * Format a list of search results as per-highlight bullets, filtered by relevance threshold.
37
+ * Returns null if nothing passes the threshold.
38
+ */
39
+ function formatHighlightBullets(
40
+ results: SearchResult[],
41
+ maxResults: number,
42
+ threshold: number,
43
+ ): string | null {
30
44
  const sections: string[] = []
31
45
 
32
46
  for (const r of results.slice(0, maxResults)) {
@@ -44,39 +58,192 @@ function formatContext(results: SearchResult[], maxResults: number, threshold: n
44
58
  }
45
59
 
46
60
  if (sections.length === 0) return null
61
+ return sections.join("\n\n")
62
+ }
47
63
 
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."
64
+ const INTRO =
65
+ "The following is context from the user's connected sources. Reference it only when relevant to the conversation."
66
+ const DISCLAIMER =
67
+ "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
68
 
53
- return `<hyperspell-context>\n${intro}\n\n${sections.join("\n\n")}\n\n${disclaimer}\n</hyperspell-context>`
69
+ function wrapSingle(body: string): string {
70
+ return `<hyperspell-context>\n${INTRO}\n\n${body}\n\n${DISCLAIMER}\n</hyperspell-context>`
54
71
  }
55
72
 
56
73
  export function buildAutoContextHandler(
57
74
  client: HyperspellClient,
58
75
  cfg: HyperspellConfig,
59
76
  ) {
60
- return async (event: Record<string, unknown>) => {
77
+ return async (
78
+ event: Record<string, unknown>,
79
+ ctx?: Record<string, unknown>,
80
+ ) => {
61
81
  const prompt = event.prompt as string | undefined
62
82
  if (!prompt || prompt.length < 5) return
63
83
 
84
+ // Multi-user path
85
+ if (cfg.multiUser) {
86
+ const resolved = resolveUser(ctx, cfg)
87
+ return multiUserSearch(client, cfg, prompt, resolved)
88
+ }
89
+
90
+ // Single-user path — preserves main's highlights + threshold behavior
64
91
  log.debug(`auto-context: searching for "${prompt.slice(0, 50)}..."`)
65
92
 
66
93
  try {
67
94
  const results = await client.search(prompt, { limit: cfg.maxResults })
68
- const context = formatContext(results, cfg.maxResults, cfg.relevanceThreshold)
95
+ const formatted = formatHighlightBullets(
96
+ results,
97
+ cfg.maxResults,
98
+ cfg.relevanceThreshold,
99
+ )
69
100
 
70
- if (!context) {
101
+ if (!formatted) {
71
102
  log.debug("auto-context: no relevant memories found")
72
103
  return
73
104
  }
74
105
 
75
106
  log.debug(`auto-context: injecting ${results.length} memories`)
76
- return { prependContext: context }
107
+ return { prependContext: wrapSingle(formatted) }
77
108
  } catch (err) {
78
109
  log.error("auto-context failed", err)
79
110
  return
80
111
  }
81
112
  }
82
113
  }
114
+
115
+ async function multiUserSearch(
116
+ client: HyperspellClient,
117
+ cfg: HyperspellConfig,
118
+ prompt: string,
119
+ resolved: ResolvedUser | undefined,
120
+ ) {
121
+ const multiUser = cfg.multiUser!
122
+ const isKnownSender = !!resolved?.resolved
123
+ const includeShared = multiUser.includeSharedInSearch
124
+
125
+ // Determine scope filter for the shared-space search based on caller's role.
126
+ // Unknown senders fall back to least-sensitive scopes; absent scoping config →
127
+ // filter is undefined → PR #6 behavior preserved.
128
+ const canRead: CanReadScope[] = multiUser.scoping
129
+ ? isKnownSender
130
+ ? getCanReadScopes(resolved, cfg)
131
+ : ["family", "kid_shared"]
132
+ : ["*"]
133
+ const scopeFilter = buildScopeFilter(canRead, resolved?.userId ?? "")
134
+
135
+ log.debug(
136
+ `auto-context: searching for "${prompt.slice(0, 50)}..." user=${resolved?.userId ?? "unknown"} canRead=${JSON.stringify(canRead)}`,
137
+ )
138
+
139
+ // Build parallel searches — personal (known senders only) + shared
140
+ const personalSearch = isKnownSender
141
+ ? client.search(prompt, {
142
+ limit: cfg.maxResults,
143
+ userId: resolved!.userId,
144
+ })
145
+ : null
146
+
147
+ // Always search shared for unknown senders, even if includeSharedInSearch is false
148
+ const sharedLimit = isKnownSender
149
+ ? Math.ceil(cfg.maxResults / 2)
150
+ : cfg.maxResults
151
+ const sharedSearch =
152
+ includeShared || !isKnownSender
153
+ ? client.search(prompt, {
154
+ limit: sharedLimit,
155
+ userId: multiUser.sharedUserId,
156
+ filter: scopeFilter,
157
+ })
158
+ : null
159
+
160
+ const searches = [personalSearch, sharedSearch].filter(Boolean) as Promise<
161
+ SearchResult[]
162
+ >[]
163
+ const settled = await Promise.allSettled(searches)
164
+
165
+ let idx = 0
166
+ let personalResults: SearchResult[] = []
167
+ let sharedResults: SearchResult[] = []
168
+
169
+ if (personalSearch) {
170
+ const r = settled[idx++]
171
+ if (r.status === "fulfilled") {
172
+ personalResults = r.value
173
+ } else {
174
+ log.error("auto-context: personal search failed", r.reason)
175
+ }
176
+ }
177
+ if (sharedSearch) {
178
+ const r = settled[idx++]
179
+ if (r.status === "fulfilled") {
180
+ sharedResults = r.value
181
+ } else {
182
+ log.error("auto-context: shared search failed", r.reason)
183
+ }
184
+ }
185
+
186
+ const sections: string[] = []
187
+
188
+ // User identity preamble
189
+ if (isKnownSender && resolved) {
190
+ const contextLine = resolved.context ? ` ${resolved.context}` : ""
191
+ sections.push(`You are speaking with ${resolved.name}.${contextLine}`)
192
+ }
193
+
194
+ // Personal section (threshold-filtered per PR #11/#12 format)
195
+ if (isKnownSender && personalResults.length > 0 && resolved) {
196
+ const formatted = formatHighlightBullets(
197
+ personalResults,
198
+ cfg.maxResults,
199
+ cfg.relevanceThreshold,
200
+ )
201
+ if (formatted) {
202
+ sections.push(
203
+ `<personal-context>\nMemories from ${resolved.name}'s personal sources and history.\n\n${formatted}\n</personal-context>`,
204
+ )
205
+ }
206
+ }
207
+
208
+ // Shared section
209
+ if (sharedResults.length > 0) {
210
+ const sharedDisplayLimit = isKnownSender
211
+ ? Math.ceil(cfg.maxResults / 2)
212
+ : cfg.maxResults
213
+ const formatted = formatHighlightBullets(
214
+ sharedResults,
215
+ sharedDisplayLimit,
216
+ cfg.relevanceThreshold,
217
+ )
218
+ if (formatted) {
219
+ sections.push(
220
+ `<shared-context>\nShared memories available to all users.\n\n${formatted}\n</shared-context>`,
221
+ )
222
+ }
223
+ }
224
+
225
+ // If only the identity preamble is present (no memory sections), still inject identity
226
+ const haveMemorySections = sections.some(
227
+ (s) => s.startsWith("<personal-context>") || s.startsWith("<shared-context>"),
228
+ )
229
+
230
+ if (!haveMemorySections) {
231
+ log.debug("auto-context: no relevant memories found")
232
+ if (isKnownSender && resolved) {
233
+ const contextLine = resolved.context ? ` ${resolved.context}` : ""
234
+ return {
235
+ prependContext: `<hyperspell-context>\nYou are speaking with ${resolved.name}.${contextLine}\n</hyperspell-context>`,
236
+ }
237
+ }
238
+ return
239
+ }
240
+
241
+ const totalCount = personalResults.length + sharedResults.length
242
+ log.debug(
243
+ `auto-context: injecting ${totalCount} memories (${personalResults.length} personal, ${sharedResults.length} shared)`,
244
+ )
245
+
246
+ return {
247
+ prependContext: `<hyperspell-context>\n${sections.join("\n\n")}\n\n${DISCLAIMER}\n</hyperspell-context>`,
248
+ }
249
+ }
@@ -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,25 @@ 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,
128
+ // Auto-trace captures full conversation text — the most sensitive class
129
+ // of memory. Default to private; users opt into family-visible recall
130
+ // via explicit /remember.
131
+ scope: "private",
118
132
  });
119
133
  log.info(
120
- `auto-trace: sent ${result.resourceId} (${messages.length} messages)`,
134
+ `auto-trace: sent ${result.resourceId} (${messages.length} messages${userId ? `, user=${userId}` : ""})`,
121
135
  );
122
136
  } catch (err) {
123
137
  // Fire-and-forget — never break the session
@@ -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
@@ -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 { registerRememberTool } from "./tools/remember.ts"
12
- import { registerSearchTool } from "./tools/search.ts"
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
- registerSearchTool(api, client, cfg);
85
- 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
+ });
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: () => {
@@ -0,0 +1,234 @@
1
+ import assert from "node:assert/strict"
2
+ import { test } from "node:test"
3
+ import type { HyperspellConfig } from "../config.ts"
4
+ import {
5
+ buildScopeFilter,
6
+ getCanReadScopes,
7
+ getDefaultWriteScope,
8
+ resolveRole,
9
+ routeWrite,
10
+ } from "./sender.ts"
11
+
12
+ function cfg(overrides: Partial<HyperspellConfig["multiUser"]> = {}): HyperspellConfig {
13
+ return {
14
+ apiKey: "test",
15
+ autoContext: false,
16
+ autoTrace: { enabled: false, extract: ["procedure"] },
17
+ emotionalContext: false,
18
+ syncMemories: false,
19
+ sources: [],
20
+ maxResults: 5,
21
+ relevanceThreshold: 0.6,
22
+ debug: false,
23
+ knowledgeGraph: { enabled: false, scanIntervalMinutes: 60, batchSize: 20 },
24
+ multiUser: overrides.scoping
25
+ ? {
26
+ sharedUserId: "shared",
27
+ includeSharedInSearch: true,
28
+ senderMap: overrides.senderMap ?? {},
29
+ scoping: overrides.scoping,
30
+ }
31
+ : undefined,
32
+ }
33
+ }
34
+
35
+ test("buildScopeFilter — wildcard returns undefined", () => {
36
+ assert.equal(buildScopeFilter(["*"], "u1"), undefined)
37
+ })
38
+
39
+ test("buildScopeFilter — empty returns match-nothing filter (not undefined)", () => {
40
+ const f = buildScopeFilter([], "u1")
41
+ assert.deepEqual(f, { openclaw_scope: "__never__" })
42
+ })
43
+
44
+ test("buildScopeFilter — single named scope collapses $or", () => {
45
+ const f = buildScopeFilter(["family"], "u1")
46
+ assert.deepEqual(f, { openclaw_scope: { $in: ["family"] } })
47
+ })
48
+
49
+ test("buildScopeFilter — multiple named scopes", () => {
50
+ const f = buildScopeFilter(["family", "kid_shared"], "u1")
51
+ assert.deepEqual(f, { openclaw_scope: { $in: ["family", "kid_shared"] } })
52
+ })
53
+
54
+ test("buildScopeFilter — scopes plus self produces $or", () => {
55
+ const f = buildScopeFilter(["family", "self"], "u1")
56
+ assert.deepEqual(f, {
57
+ $or: [
58
+ { openclaw_scope: { $in: ["family"] } },
59
+ { openclaw_user: "u1" },
60
+ ],
61
+ })
62
+ })
63
+
64
+ test("buildScopeFilter — hyphenated scope normalized in metadata", () => {
65
+ const f = buildScopeFilter(["parent-only"], "u1")
66
+ assert.deepEqual(f, { openclaw_scope: { $in: ["parent_only"] } })
67
+ })
68
+
69
+ test("buildScopeFilter — self only (no named scopes)", () => {
70
+ const f = buildScopeFilter(["self"], "u1")
71
+ assert.deepEqual(f, { openclaw_user: "u1" })
72
+ })
73
+
74
+ test("buildScopeFilter — self only with empty userId → match-nothing", () => {
75
+ const f = buildScopeFilter(["self"], "")
76
+ // Empty userId cannot build a self clause; no named scopes either → match-nothing
77
+ assert.deepEqual(f, { openclaw_scope: "__never__" })
78
+ })
79
+
80
+ test("getCanReadScopes — scoping absent returns wildcard", () => {
81
+ const c = cfg()
82
+ const canRead = getCanReadScopes(
83
+ { userId: "u1", name: "U1", resolved: true },
84
+ c,
85
+ )
86
+ assert.deepEqual(canRead, ["*"])
87
+ })
88
+
89
+ test("getCanReadScopes — role from scoping.users", () => {
90
+ const c = cfg({
91
+ senderMap: { "u1-phone": { userId: "u1", name: "U1" } },
92
+ scoping: {
93
+ enabled: true,
94
+ defaultScope: "private",
95
+ scopes: ["private", "family"],
96
+ roles: {
97
+ kid: { canRead: ["family"], defaultWriteScope: "family" },
98
+ },
99
+ users: { u1: { role: "kid" } },
100
+ },
101
+ })
102
+ const canRead = getCanReadScopes(
103
+ { userId: "u1", name: "U1", resolved: true },
104
+ c,
105
+ )
106
+ assert.deepEqual(canRead, ["family"])
107
+ })
108
+
109
+ test("getCanReadScopes — missing role returns empty (no access)", () => {
110
+ const c = cfg({
111
+ senderMap: { "u1-phone": { userId: "u1", name: "U1" } },
112
+ scoping: {
113
+ enabled: true,
114
+ defaultScope: "private",
115
+ scopes: ["private", "family"],
116
+ roles: {
117
+ kid: { canRead: ["family"], defaultWriteScope: "family" },
118
+ },
119
+ users: {}, // u1 has no role assignment
120
+ },
121
+ })
122
+ const canRead = getCanReadScopes(
123
+ { userId: "u1", name: "U1", resolved: true },
124
+ c,
125
+ )
126
+ assert.deepEqual(canRead, [])
127
+ })
128
+
129
+ test("getDefaultWriteScope — role default wins", () => {
130
+ const c = cfg({
131
+ senderMap: { "u1-phone": { userId: "u1", name: "U1" } },
132
+ scoping: {
133
+ enabled: true,
134
+ defaultScope: "private",
135
+ scopes: ["private", "family"],
136
+ roles: {
137
+ kid: { canRead: ["family"], defaultWriteScope: "family" },
138
+ },
139
+ users: { u1: { role: "kid" } },
140
+ },
141
+ })
142
+ const scope = getDefaultWriteScope(
143
+ { userId: "u1", name: "U1", resolved: true },
144
+ c,
145
+ )
146
+ assert.equal(scope, "family")
147
+ })
148
+
149
+ test("getDefaultWriteScope — falls back to global default when no role", () => {
150
+ const c = cfg({
151
+ senderMap: { "u1-phone": { userId: "u1", name: "U1" } },
152
+ scoping: {
153
+ enabled: true,
154
+ defaultScope: "private",
155
+ scopes: ["private", "family"],
156
+ roles: {},
157
+ users: {},
158
+ },
159
+ })
160
+ const scope = getDefaultWriteScope(
161
+ { userId: "u1", name: "U1", resolved: true },
162
+ c,
163
+ )
164
+ assert.equal(scope, "private")
165
+ })
166
+
167
+ test("getDefaultWriteScope — profile-level role override", () => {
168
+ const c = cfg({
169
+ senderMap: { "u1-phone": { userId: "u1", name: "U1", role: "parent" } },
170
+ scoping: {
171
+ enabled: true,
172
+ defaultScope: "private",
173
+ scopes: ["private", "family"],
174
+ roles: {
175
+ parent: { canRead: ["*"], defaultWriteScope: "private" },
176
+ kid: { canRead: ["family"], defaultWriteScope: "family" },
177
+ },
178
+ users: { u1: { role: "kid" } }, // users table says kid but profile override is parent
179
+ },
180
+ })
181
+ const scope = getDefaultWriteScope(
182
+ { userId: "u1", name: "U1", resolved: true, role: "parent" },
183
+ c,
184
+ )
185
+ assert.equal(scope, "private")
186
+ })
187
+
188
+ test("resolveRole — returns undefined when scoping absent", () => {
189
+ const c = cfg()
190
+ const role = resolveRole(
191
+ { userId: "u1", name: "U1", resolved: true },
192
+ c,
193
+ )
194
+ assert.equal(role, undefined)
195
+ })
196
+
197
+ test("routeWrite — private goes to user's own space", () => {
198
+ const c = cfg({
199
+ senderMap: { "u1-phone": { userId: "u1", name: "U1" } },
200
+ scoping: {
201
+ enabled: true,
202
+ defaultScope: "private",
203
+ scopes: ["private", "family"],
204
+ roles: { kid: { canRead: ["family"], defaultWriteScope: "family" } },
205
+ users: { u1: { role: "kid" } },
206
+ },
207
+ })
208
+ const r = routeWrite(
209
+ { userId: "u1", name: "U1", resolved: true },
210
+ "private",
211
+ c,
212
+ )
213
+ assert.deepEqual(r, { userId: "u1", collection: undefined })
214
+ })
215
+
216
+ test("routeWrite — non-private goes to sharedUserId with optional collection", () => {
217
+ const c = cfg({
218
+ senderMap: { "u1-phone": { userId: "u1", name: "U1" } },
219
+ scoping: {
220
+ enabled: true,
221
+ defaultScope: "private",
222
+ scopes: ["private", "family"],
223
+ roles: { kid: { canRead: ["family"], defaultWriteScope: "family" } },
224
+ users: { u1: { role: "kid" } },
225
+ collections: { family: "household-shared" },
226
+ },
227
+ })
228
+ const r = routeWrite(
229
+ { userId: "u1", name: "U1", resolved: true },
230
+ "family",
231
+ c,
232
+ )
233
+ assert.deepEqual(r, { userId: "shared", collection: "household-shared" })
234
+ })