@hyperspell/openclaw-hyperspell 0.10.0 → 0.11.1
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 +29 -3
- package/commands/slash.ts +84 -13
- package/config.test.ts +202 -0
- package/config.ts +163 -0
- package/hooks/auto-context.ts +44 -7
- package/hooks/auto-trace.test.ts +81 -0
- package/hooks/auto-trace.ts +69 -9
- package/hooks/emotional-state.test.ts +160 -0
- package/hooks/emotional-state.ts +93 -11
- package/index.ts +13 -2
- package/lib/sender.test.ts +234 -0
- package/lib/sender.ts +178 -20
- package/lib/voice-id.ts +39 -0
- package/openclaw.plugin.json +37 -0
- package/package.json +3 -2
- package/tools/remember.ts +69 -5
- package/tools/search.ts +34 -5
package/client.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import Hyperspell from "hyperspell";
|
|
2
|
-
import type {
|
|
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 {
|
|
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
|
|
32
|
-
if (!
|
|
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
|
-
|
|
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
|
|
71
|
-
if (!
|
|
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
|
|
77
|
-
|
|
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
|
-
|
|
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
|
|