@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.
package/config.test.ts ADDED
@@ -0,0 +1,202 @@
1
+ import assert from "node:assert/strict"
2
+ import { test } from "node:test"
3
+ import { parseConfig } from "./config.ts"
4
+
5
+ const base = {
6
+ apiKey: "test-key",
7
+ userId: "u1",
8
+ }
9
+
10
+ test("parseConfig — no multiUser returns no scoping", () => {
11
+ const cfg = parseConfig(base)
12
+ assert.equal(cfg.multiUser, undefined)
13
+ })
14
+
15
+ test("parseConfig — multiUser without scoping is valid", () => {
16
+ const cfg = parseConfig({
17
+ ...base,
18
+ multiUser: {
19
+ senderMap: { "u1-phone": { userId: "u1", name: "U1" } },
20
+ sharedUserId: "shared",
21
+ },
22
+ })
23
+ assert.equal(cfg.multiUser?.scoping, undefined)
24
+ assert.equal(cfg.multiUser?.sharedUserId, "shared")
25
+ })
26
+
27
+ test("parseConfig — scoping disabled returns no scoping", () => {
28
+ const cfg = parseConfig({
29
+ ...base,
30
+ multiUser: {
31
+ senderMap: { "u1-phone": { userId: "u1", name: "U1" } },
32
+ scoping: {
33
+ enabled: false,
34
+ defaultScope: "private",
35
+ scopes: ["private"],
36
+ roles: {},
37
+ users: {},
38
+ },
39
+ },
40
+ })
41
+ assert.equal(cfg.multiUser?.scoping, undefined)
42
+ })
43
+
44
+ test("parseConfig — empty scopes array throws", () => {
45
+ assert.throws(
46
+ () =>
47
+ parseConfig({
48
+ ...base,
49
+ multiUser: {
50
+ senderMap: { "u1-phone": { userId: "u1", name: "U1" } },
51
+ scoping: {
52
+ enabled: true,
53
+ defaultScope: "private",
54
+ scopes: [],
55
+ roles: {},
56
+ users: {},
57
+ },
58
+ },
59
+ }),
60
+ /scoping\.scopes/,
61
+ )
62
+ })
63
+
64
+ test("parseConfig — defaultScope not in scopes throws", () => {
65
+ assert.throws(
66
+ () =>
67
+ parseConfig({
68
+ ...base,
69
+ multiUser: {
70
+ senderMap: { "u1-phone": { userId: "u1", name: "U1" } },
71
+ scoping: {
72
+ enabled: true,
73
+ defaultScope: "nonexistent",
74
+ scopes: ["private", "family"],
75
+ roles: {},
76
+ users: {},
77
+ },
78
+ },
79
+ }),
80
+ /defaultScope/,
81
+ )
82
+ })
83
+
84
+ test("parseConfig — role canRead with unknown scope throws", () => {
85
+ assert.throws(
86
+ () =>
87
+ parseConfig({
88
+ ...base,
89
+ multiUser: {
90
+ senderMap: { "u1-phone": { userId: "u1", name: "U1" } },
91
+ scoping: {
92
+ enabled: true,
93
+ defaultScope: "private",
94
+ scopes: ["private", "family"],
95
+ roles: {
96
+ weird: { canRead: ["private", "ghost"], defaultWriteScope: "private" },
97
+ },
98
+ users: {},
99
+ },
100
+ },
101
+ }),
102
+ /canRead.*ghost/,
103
+ )
104
+ })
105
+
106
+ test("parseConfig — role defaultWriteScope not in scopes throws", () => {
107
+ assert.throws(
108
+ () =>
109
+ parseConfig({
110
+ ...base,
111
+ multiUser: {
112
+ senderMap: { "u1-phone": { userId: "u1", name: "U1" } },
113
+ scoping: {
114
+ enabled: true,
115
+ defaultScope: "private",
116
+ scopes: ["private", "family"],
117
+ roles: {
118
+ weird: { canRead: ["family"], defaultWriteScope: "nonexistent" },
119
+ },
120
+ users: {},
121
+ },
122
+ },
123
+ }),
124
+ /defaultWriteScope/,
125
+ )
126
+ })
127
+
128
+ test("parseConfig — canWriteScopes with unknown scope throws", () => {
129
+ assert.throws(
130
+ () =>
131
+ parseConfig({
132
+ ...base,
133
+ multiUser: {
134
+ senderMap: { "u1-phone": { userId: "u1", name: "U1" } },
135
+ scoping: {
136
+ enabled: true,
137
+ defaultScope: "private",
138
+ scopes: ["private", "family"],
139
+ roles: {
140
+ weird: {
141
+ canRead: ["*"],
142
+ defaultWriteScope: "private",
143
+ canWriteScopes: ["ghost"],
144
+ },
145
+ },
146
+ users: {},
147
+ },
148
+ },
149
+ }),
150
+ /canWriteScopes.*ghost/,
151
+ )
152
+ })
153
+
154
+ test("parseConfig — user role keying into unknown role throws", () => {
155
+ assert.throws(
156
+ () =>
157
+ parseConfig({
158
+ ...base,
159
+ multiUser: {
160
+ senderMap: { "u1-phone": { userId: "u1", name: "U1" } },
161
+ scoping: {
162
+ enabled: true,
163
+ defaultScope: "private",
164
+ scopes: ["private", "family"],
165
+ roles: {
166
+ parent: { canRead: ["*"], defaultWriteScope: "private" },
167
+ },
168
+ users: { u1: { role: "nonexistent" } },
169
+ },
170
+ },
171
+ }),
172
+ /does not key into scoping\.roles/,
173
+ )
174
+ })
175
+
176
+ test("parseConfig — valid scoping parses", () => {
177
+ const cfg = parseConfig({
178
+ ...base,
179
+ multiUser: {
180
+ senderMap: { "u1-phone": { userId: "u1", name: "U1" } },
181
+ scoping: {
182
+ enabled: true,
183
+ defaultScope: "private",
184
+ scopes: ["private", "family", "parent_only"],
185
+ roles: {
186
+ parent: { canRead: ["*"], defaultWriteScope: "private" },
187
+ kid: { canRead: ["family"], defaultWriteScope: "family" },
188
+ },
189
+ users: {
190
+ u1: { role: "parent" },
191
+ },
192
+ collections: { family: "household" },
193
+ voiceId: { enabled: true, confidenceThreshold: 0.8 },
194
+ },
195
+ },
196
+ })
197
+ assert.equal(cfg.multiUser?.scoping?.enabled, true)
198
+ assert.deepEqual(cfg.multiUser?.scoping?.scopes, ["private", "family", "parent_only"])
199
+ assert.equal(cfg.multiUser?.scoping?.roles.parent?.defaultWriteScope, "private")
200
+ assert.equal(cfg.multiUser?.scoping?.collections?.family, "household")
201
+ assert.equal(cfg.multiUser?.scoping?.voiceId?.confidenceThreshold, 0.8)
202
+ })
package/config.ts CHANGED
@@ -25,6 +25,55 @@ export type AutoTraceConfig = {
25
25
  metadata?: Record<string, string | number | boolean>;
26
26
  };
27
27
 
28
+ export type UserProfile = {
29
+ userId: string;
30
+ name: string;
31
+ context?: string;
32
+ role?: string;
33
+ };
34
+
35
+ export type ScopeName = string;
36
+ export type CanReadScope = ScopeName | "*" | "self";
37
+
38
+ export type Role = {
39
+ canRead: CanReadScope[];
40
+ defaultWriteScope: ScopeName;
41
+ canWriteScopes?: ScopeName[];
42
+ };
43
+
44
+ export type VoiceIdConfig = {
45
+ enabled: boolean;
46
+ adapter?: string;
47
+ confidenceThreshold?: number;
48
+ };
49
+
50
+ export type ScopingConfig = {
51
+ enabled: boolean;
52
+ defaultScope: ScopeName;
53
+ scopes: ScopeName[];
54
+ roles: Record<string, Role>;
55
+ users: Record<string, { role: string }>;
56
+ collections?: Record<ScopeName, string>;
57
+ voiceId?: VoiceIdConfig;
58
+ };
59
+
60
+ export type MultiUserConfig = {
61
+ senderMap: Record<string, UserProfile>;
62
+ sharedUserId: string;
63
+ includeSharedInSearch: boolean;
64
+ scoping?: ScopingConfig;
65
+ };
66
+
67
+ /**
68
+ * Convert user-facing scope names (which may contain hyphens) to SDK-safe
69
+ * metadata values (alphanumeric + underscore only). Must be applied at every
70
+ * boundary where scopes cross into Hyperspell metadata — writes and reads —
71
+ * or filters will silently miss.
72
+ */
73
+ export function normalizeScope(scope: ScopeName): string {
74
+ return scope.replace(/[^a-zA-Z0-9_]/g, "_");
75
+ }
76
+
28
77
  export type HyperspellConfig = {
29
78
  apiKey: string;
30
79
  userId?: string;
@@ -38,6 +87,7 @@ export type HyperspellConfig = {
38
87
  relevanceThreshold: number;
39
88
  debug: boolean;
40
89
  knowledgeGraph: KnowledgeGraphConfig;
90
+ multiUser?: MultiUserConfig;
41
91
  };
42
92
 
43
93
  const ALLOWED_KEYS = [
@@ -53,6 +103,7 @@ const ALLOWED_KEYS = [
53
103
  "relevanceThreshold",
54
104
  "debug",
55
105
  "knowledgeGraph",
106
+ "multiUser",
56
107
  ];
57
108
 
58
109
  const VALID_SOURCES: HyperspellSource[] = [
@@ -135,6 +186,166 @@ function parseSources(raw: string | string[] | undefined): HyperspellSource[] {
135
186
  return sources;
136
187
  }
137
188
 
189
+ function parseScoping(raw: unknown): ScopingConfig | undefined {
190
+ if (!raw || typeof raw !== "object") return undefined;
191
+ const sc = raw as Record<string, unknown>;
192
+
193
+ if (sc.enabled !== true) return undefined;
194
+
195
+ const scopes = Array.isArray(sc.scopes)
196
+ ? (sc.scopes.filter((s) => typeof s === "string") as ScopeName[])
197
+ : [];
198
+ if (scopes.length === 0) {
199
+ throw new Error("scoping.scopes must be a non-empty array of scope names");
200
+ }
201
+
202
+ const defaultScope =
203
+ typeof sc.defaultScope === "string" ? sc.defaultScope : "private";
204
+ if (!scopes.includes(defaultScope)) {
205
+ throw new Error(
206
+ `scoping.defaultScope "${defaultScope}" must be one of scopes: ${scopes.join(", ")}`,
207
+ );
208
+ }
209
+
210
+ const rolesRaw =
211
+ sc.roles && typeof sc.roles === "object"
212
+ ? (sc.roles as Record<string, unknown>)
213
+ : {};
214
+ const roles: Record<string, Role> = {};
215
+ for (const [roleName, rRaw] of Object.entries(rolesRaw)) {
216
+ if (!rRaw || typeof rRaw !== "object") continue;
217
+ const r = rRaw as Record<string, unknown>;
218
+
219
+ const canRead = Array.isArray(r.canRead)
220
+ ? (r.canRead.filter((s) => typeof s === "string") as CanReadScope[])
221
+ : [];
222
+ for (const s of canRead) {
223
+ if (s === "*" || s === "self") continue;
224
+ if (!scopes.includes(s)) {
225
+ throw new Error(
226
+ `scoping.roles.${roleName}.canRead contains unknown scope "${s}"`,
227
+ );
228
+ }
229
+ }
230
+
231
+ const defaultWriteScope =
232
+ typeof r.defaultWriteScope === "string"
233
+ ? r.defaultWriteScope
234
+ : defaultScope;
235
+ if (!scopes.includes(defaultWriteScope)) {
236
+ throw new Error(
237
+ `scoping.roles.${roleName}.defaultWriteScope "${defaultWriteScope}" must be one of scopes: ${scopes.join(", ")}`,
238
+ );
239
+ }
240
+
241
+ let canWriteScopes: ScopeName[] | undefined;
242
+ if (Array.isArray(r.canWriteScopes)) {
243
+ canWriteScopes = r.canWriteScopes.filter(
244
+ (s): s is ScopeName => typeof s === "string",
245
+ );
246
+ for (const s of canWriteScopes) {
247
+ if (!scopes.includes(s)) {
248
+ throw new Error(
249
+ `scoping.roles.${roleName}.canWriteScopes contains unknown scope "${s}"`,
250
+ );
251
+ }
252
+ }
253
+ }
254
+
255
+ roles[roleName] = { canRead, defaultWriteScope, canWriteScopes };
256
+ }
257
+
258
+ const usersRaw =
259
+ sc.users && typeof sc.users === "object"
260
+ ? (sc.users as Record<string, unknown>)
261
+ : {};
262
+ const users: Record<string, { role: string }> = {};
263
+ for (const [uid, uRaw] of Object.entries(usersRaw)) {
264
+ if (!uRaw || typeof uRaw !== "object") continue;
265
+ const u = uRaw as Record<string, unknown>;
266
+ if (typeof u.role !== "string") continue;
267
+ if (!roles[u.role]) {
268
+ throw new Error(
269
+ `scoping.users.${uid}.role "${u.role}" does not key into scoping.roles`,
270
+ );
271
+ }
272
+ users[uid] = { role: u.role };
273
+ }
274
+
275
+ const collectionsRaw =
276
+ sc.collections && typeof sc.collections === "object"
277
+ ? (sc.collections as Record<string, unknown>)
278
+ : undefined;
279
+ let collections: Record<ScopeName, string> | undefined;
280
+ if (collectionsRaw) {
281
+ collections = {};
282
+ for (const [scopeName, coll] of Object.entries(collectionsRaw)) {
283
+ if (typeof coll === "string" && scopes.includes(scopeName)) {
284
+ collections[scopeName] = coll;
285
+ }
286
+ }
287
+ }
288
+
289
+ let voiceId: VoiceIdConfig | undefined;
290
+ if (sc.voiceId && typeof sc.voiceId === "object") {
291
+ const v = sc.voiceId as Record<string, unknown>;
292
+ voiceId = {
293
+ enabled: v.enabled === true,
294
+ adapter: typeof v.adapter === "string" ? v.adapter : undefined,
295
+ confidenceThreshold:
296
+ typeof v.confidenceThreshold === "number"
297
+ ? v.confidenceThreshold
298
+ : undefined,
299
+ };
300
+ }
301
+
302
+ return {
303
+ enabled: true,
304
+ defaultScope,
305
+ scopes,
306
+ roles,
307
+ users,
308
+ collections,
309
+ voiceId,
310
+ };
311
+ }
312
+
313
+ function parseMultiUser(raw: unknown): MultiUserConfig | undefined {
314
+ if (!raw || typeof raw !== "object") return undefined;
315
+ const mu = raw as Record<string, unknown>;
316
+
317
+ const senderMap: Record<string, UserProfile> = {};
318
+ const rawMap = mu.senderMap as Record<string, unknown> | undefined;
319
+ if (rawMap && typeof rawMap === "object") {
320
+ for (const [handle, profile] of Object.entries(rawMap)) {
321
+ if (profile && typeof profile === "object") {
322
+ const p = profile as Record<string, unknown>;
323
+ if (typeof p.userId === "string" && typeof p.name === "string") {
324
+ senderMap[handle] = {
325
+ userId: p.userId,
326
+ name: p.name,
327
+ context: typeof p.context === "string" ? p.context : undefined,
328
+ role: typeof p.role === "string" ? p.role : undefined,
329
+ };
330
+ }
331
+ }
332
+ }
333
+ }
334
+
335
+ if (Object.keys(senderMap).length === 0) return undefined;
336
+
337
+ return {
338
+ senderMap,
339
+ sharedUserId:
340
+ typeof mu.sharedUserId === "string" ? mu.sharedUserId : "shared",
341
+ includeSharedInSearch:
342
+ typeof mu.includeSharedInSearch === "boolean"
343
+ ? mu.includeSharedInSearch
344
+ : true,
345
+ scoping: parseScoping(mu.scoping),
346
+ };
347
+ }
348
+
138
349
  export function parseConfig(raw: unknown): HyperspellConfig {
139
350
  const cfg =
140
351
  raw && typeof raw === "object" && !Array.isArray(raw)
@@ -184,6 +395,7 @@ export function parseConfig(raw: unknown): HyperspellConfig {
184
395
  scanIntervalMinutes: (kgRaw.scanIntervalMinutes as number) ?? 60,
185
396
  batchSize: (kgRaw.batchSize as number) ?? 20,
186
397
  },
398
+ multiUser: parseMultiUser(cfg.multiUser),
187
399
  };
188
400
  }
189
401
 
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 }],