@hyperspell/openclaw-hyperspell 0.10.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/client.ts CHANGED
@@ -1,5 +1,10 @@
1
1
  import Hyperspell from "hyperspell";
2
- import type { HyperspellConfig, HyperspellSource } from "./config.ts";
2
+ import type {
3
+ HyperspellConfig,
4
+ HyperspellSource,
5
+ ScopeName,
6
+ } from "./config.ts";
7
+ import { normalizeScope } from "./config.ts";
3
8
  import { log } from "./logger.ts";
4
9
 
5
10
  export type Highlight = {
@@ -95,6 +100,7 @@ export class HyperspellClient {
95
100
  after?: string;
96
101
  before?: string;
97
102
  userId?: string;
103
+ filter?: Record<string, unknown>;
98
104
  },
99
105
  ): Promise<SearchResult[]> {
100
106
  const limit = options?.limit ?? this.config.maxResults;
@@ -109,6 +115,7 @@ export class HyperspellClient {
109
115
  after: options?.after,
110
116
  before: options?.before,
111
117
  userId: options?.userId,
118
+ filter: options?.filter,
112
119
  });
113
120
 
114
121
  const response = await this.client.memories.search(
@@ -119,6 +126,7 @@ export class HyperspellClient {
119
126
  max_results: limit,
120
127
  ...(options?.after ? { after: options.after } : {}),
121
128
  ...(options?.before ? { before: options.before } : {}),
129
+ ...(options?.filter ? { filter: options.filter } : {}),
122
130
  },
123
131
  },
124
132
  this.requestOptions(options?.userId),
@@ -155,6 +163,7 @@ export class HyperspellClient {
155
163
  after?: string;
156
164
  before?: string;
157
165
  userId?: string;
166
+ filter?: Record<string, unknown>;
158
167
  },
159
168
  ): Promise<Record<string, unknown>> {
160
169
  const limit = options?.limit ?? this.config.maxResults;
@@ -169,6 +178,7 @@ export class HyperspellClient {
169
178
  after: options?.after,
170
179
  before: options?.before,
171
180
  userId: options?.userId,
181
+ filter: options?.filter,
172
182
  });
173
183
 
174
184
  const response = await this.client.memories.search(
@@ -179,6 +189,7 @@ export class HyperspellClient {
179
189
  max_results: limit,
180
190
  ...(options?.after ? { after: options.after } : {}),
181
191
  ...(options?.before ? { before: options.before } : {}),
192
+ ...(options?.filter ? { filter: options.filter } : {}),
182
193
  },
183
194
  },
184
195
  this.requestOptions(options?.userId),
@@ -197,6 +208,7 @@ export class HyperspellClient {
197
208
  limit?: number;
198
209
  sources?: HyperspellSource[];
199
210
  userId?: string;
211
+ filter?: Record<string, unknown>;
200
212
  },
201
213
  ): Promise<SearchWithAnswerResult> {
202
214
  const limit = options?.limit ?? this.config.maxResults;
@@ -209,6 +221,7 @@ export class HyperspellClient {
209
221
  limit,
210
222
  sources,
211
223
  userId: options?.userId,
224
+ filter: options?.filter,
212
225
  });
213
226
 
214
227
  const response = await this.client.memories.search(
@@ -218,6 +231,7 @@ export class HyperspellClient {
218
231
  answer: true,
219
232
  options: {
220
233
  max_results: limit,
234
+ ...(options?.filter ? { filter: options.filter } : {}),
221
235
  },
222
236
  },
223
237
  this.requestOptions(options?.userId),
@@ -253,6 +267,7 @@ export class HyperspellClient {
253
267
  date?: string;
254
268
  metadata?: Record<string, string | number | boolean>;
255
269
  userId?: string;
270
+ scope?: ScopeName;
256
271
  },
257
272
  ): Promise<{ resourceId: string }> {
258
273
  log.debugRequest("memories.add", {
@@ -262,6 +277,7 @@ export class HyperspellClient {
262
277
  collection: options?.collection,
263
278
  date: options?.date,
264
279
  userId: options?.userId,
280
+ scope: options?.scope,
265
281
  });
266
282
 
267
283
  const result = await this.client.memories.add(
@@ -272,8 +288,12 @@ export class HyperspellClient {
272
288
  collection: options?.collection,
273
289
  date: options?.date,
274
290
  metadata: {
275
- ...options?.metadata,
276
291
  openclaw_source: "command",
292
+ ...options?.metadata,
293
+ ...(options?.userId ? { openclaw_user: options.userId } : {}),
294
+ ...(options?.scope
295
+ ? { openclaw_scope: normalizeScope(options.scope) }
296
+ : {}),
277
297
  },
278
298
  },
279
299
  this.requestOptions(options?.userId),
@@ -380,6 +400,7 @@ export class HyperspellClient {
380
400
  extract?: Array<"procedure" | "memory" | "mood">;
381
401
  metadata?: Record<string, string | number | boolean>;
382
402
  userId?: string;
403
+ scope?: ScopeName;
383
404
  },
384
405
  ): Promise<{ resourceId: string; status: string }> {
385
406
  log.debugRequest("sessions.add", {
@@ -387,6 +408,7 @@ export class HyperspellClient {
387
408
  sessionId: options?.sessionId,
388
409
  extract: options?.extract,
389
410
  userId: options?.userId,
411
+ scope: options?.scope,
390
412
  });
391
413
 
392
414
  const result = await this.client.sessions.add(
@@ -402,8 +424,12 @@ export class HyperspellClient {
402
424
  "procedure" | "memory"
403
425
  >,
404
426
  metadata: {
405
- ...options?.metadata,
406
427
  openclaw_source: "agent_end",
428
+ ...options?.metadata,
429
+ ...(options?.userId ? { openclaw_user: options.userId } : {}),
430
+ ...(options?.scope
431
+ ? { openclaw_scope: normalizeScope(options.scope) }
432
+ : {}),
407
433
  },
408
434
  },
409
435
  this.requestOptions(options?.userId),
package/commands/slash.ts CHANGED
@@ -1,11 +1,29 @@
1
1
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk"
2
2
  import type { HyperspellClient } from "../client.ts"
3
- import type { HyperspellConfig } from "../config.ts"
3
+ import type { CanReadScope, HyperspellConfig } from "../config.ts"
4
4
  import { getWorkspaceDir } from "../config.ts"
5
- import { resolveUser } from "../lib/sender.ts"
5
+ import {
6
+ buildScopeFilter,
7
+ getCanReadScopes,
8
+ getDefaultWriteScope,
9
+ resolveRole,
10
+ resolveUser,
11
+ routeWrite,
12
+ } from "../lib/sender.ts"
6
13
  import { log } from "../logger.ts"
7
14
  import { syncAllMemoryFiles } from "../sync/markdown.ts"
8
15
 
16
+ /**
17
+ * Strip a `#scope-name` prefix from free text. Returns the scope and the
18
+ * remainder. Used by /remember and /getcontext to let users narrow or route
19
+ * via a single keystroke.
20
+ */
21
+ function parseScopePrefix(text: string): { scope?: string; rest: string } {
22
+ const m = text.match(/^#(\S+)\s+(.*)$/)
23
+ if (!m) return { rest: text }
24
+ return { scope: m[1], rest: m[2] }
25
+ }
26
+
9
27
  function truncate(text: string, maxLength: number): string {
10
28
  if (text.length <= maxLength) return text
11
29
  return `${text.slice(0, maxLength)}…`
@@ -28,17 +46,33 @@ export function registerCommands(
28
46
  acceptsArgs: true,
29
47
  requireAuth: true,
30
48
  handler: async (ctx: { args?: string; senderId?: string; channel?: string }) => {
31
- const query = ctx.args?.trim()
32
- if (!query) {
33
- return { text: "Usage: /getcontext <search query>" }
49
+ const rawArgs = ctx.args?.trim()
50
+ if (!rawArgs) {
51
+ return { text: "Usage: /getcontext [#scope] <search query>" }
34
52
  }
53
+ const { scope: requestedScope, rest: query } = parseScopePrefix(rawArgs)
35
54
 
36
55
  const resolved = resolveUser(ctx as Record<string, unknown>, cfg)
37
56
  const userId = resolved?.userId
38
- log.debug(`/getcontext command: "${query}" userId=${userId}`)
57
+
58
+ // Build scope filter: intersect requested scope (if any) with caller's canRead.
59
+ let filter: Record<string, unknown> | undefined
60
+ if (cfg.multiUser?.scoping) {
61
+ const canRead = getCanReadScopes(resolved, cfg)
62
+ const allowed: CanReadScope[] = requestedScope
63
+ ? canRead.includes("*") || canRead.includes(requestedScope)
64
+ ? [requestedScope]
65
+ : []
66
+ : canRead
67
+ filter = buildScopeFilter(allowed, resolved?.userId ?? "")
68
+ }
69
+
70
+ log.debug(
71
+ `/getcontext command: "${query}" userId=${userId} scope=${requestedScope ?? "any"}`,
72
+ )
39
73
 
40
74
  try {
41
- const results = await client.search(query, { limit: 5, userId })
75
+ const results = await client.search(query, { limit: 5, userId, filter })
42
76
 
43
77
  if (results.length === 0) {
44
78
  return { text: `No memories found for: "${query}"` }
@@ -67,23 +101,60 @@ export function registerCommands(
67
101
  acceptsArgs: true,
68
102
  requireAuth: true,
69
103
  handler: async (ctx: { args?: string; senderId?: string; channel?: string }) => {
70
- const text = ctx.args?.trim()
71
- if (!text) {
72
- return { text: "Usage: /remember <text to remember>" }
104
+ const rawArgs = ctx.args?.trim()
105
+ if (!rawArgs) {
106
+ return { text: "Usage: /remember [#scope] <text to remember>" }
73
107
  }
108
+ const { scope: requestedScope, rest: text } = parseScopePrefix(rawArgs)
74
109
 
75
110
  const resolved = resolveUser(ctx as Record<string, unknown>, cfg)
76
- const userId = resolved?.userId
77
- log.debug(`/remember command: "${truncate(text, 50)}" userId=${userId}`)
111
+ const scopingEnabled = !!cfg.multiUser?.scoping
112
+ const availableScopes = cfg.multiUser?.scoping?.scopes ?? []
113
+
114
+ // Scope resolution: prefix > role default > global default > "private"
115
+ const scope = scopingEnabled
116
+ ? (requestedScope ?? getDefaultWriteScope(resolved, cfg))
117
+ : "private"
118
+
119
+ // Validate scope is in declared vocabulary
120
+ if (
121
+ scopingEnabled &&
122
+ requestedScope &&
123
+ availableScopes.length > 0 &&
124
+ !availableScopes.includes(scope)
125
+ ) {
126
+ return {
127
+ text: `Unknown scope "${scope}". Available: ${availableScopes.join(", ")}.`,
128
+ }
129
+ }
130
+
131
+ // canWriteScopes enforcement
132
+ if (scopingEnabled) {
133
+ const role = resolveRole(resolved, cfg)
134
+ if (role?.canWriteScopes && !role.canWriteScopes.includes(scope)) {
135
+ return { text: `You cannot write to scope "${scope}".` }
136
+ }
137
+ }
138
+
139
+ const { userId, collection } = scopingEnabled
140
+ ? routeWrite(resolved, scope, cfg)
141
+ : { userId: resolved?.userId, collection: undefined }
142
+
143
+ log.debug(
144
+ `/remember command: "${truncate(text, 50)}" userId=${userId} scope=${scope}`,
145
+ )
78
146
 
79
147
  try {
80
148
  await client.addMemory(text, {
81
149
  metadata: { source: "openclaw_command" },
150
+ collection,
82
151
  userId,
152
+ scope: scopingEnabled ? scope : undefined,
83
153
  })
84
154
 
85
155
  const preview = truncate(text, 60)
86
- return { text: `Remembered: "${preview}"` }
156
+ const scopeHint = scopingEnabled ? ` [${scope}]` : ""
157
+ return { text: `Remembered${scopeHint}: "${preview}"` }
87
158
  } catch (err) {
88
159
  log.error("/remember failed", err)
89
160
  return { text: "Failed to save memory. Check logs for details." }
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
@@ -29,14 +29,51 @@ export type UserProfile = {
29
29
  userId: string;
30
30
  name: string;
31
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;
32
58
  };
33
59
 
34
60
  export type MultiUserConfig = {
35
61
  senderMap: Record<string, UserProfile>;
36
62
  sharedUserId: string;
37
63
  includeSharedInSearch: boolean;
64
+ scoping?: ScopingConfig;
38
65
  };
39
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
+
40
77
  export type HyperspellConfig = {
41
78
  apiKey: string;
42
79
  userId?: string;
@@ -149,6 +186,130 @@ function parseSources(raw: string | string[] | undefined): HyperspellSource[] {
149
186
  return sources;
150
187
  }
151
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
+
152
313
  function parseMultiUser(raw: unknown): MultiUserConfig | undefined {
153
314
  if (!raw || typeof raw !== "object") return undefined;
154
315
  const mu = raw as Record<string, unknown>;
@@ -164,6 +325,7 @@ function parseMultiUser(raw: unknown): MultiUserConfig | undefined {
164
325
  userId: p.userId,
165
326
  name: p.name,
166
327
  context: typeof p.context === "string" ? p.context : undefined,
328
+ role: typeof p.role === "string" ? p.role : undefined,
167
329
  };
168
330
  }
169
331
  }
@@ -180,6 +342,7 @@ function parseMultiUser(raw: unknown): MultiUserConfig | undefined {
180
342
  typeof mu.includeSharedInSearch === "boolean"
181
343
  ? mu.includeSharedInSearch
182
344
  : true,
345
+ scoping: parseScoping(mu.scoping),
183
346
  };
184
347
  }
185
348