@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 +29 -3
- package/commands/slash.ts +84 -13
- package/config.test.ts +202 -0
- package/config.ts +163 -0
- package/hooks/auto-context.ts +20 -6
- package/hooks/auto-trace.ts +4 -0
- 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/hooks/auto-context.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import type { HyperspellClient, SearchResult } from "../client.ts"
|
|
2
|
-
import type { HyperspellConfig } from "../config.ts"
|
|
3
|
-
import {
|
|
2
|
+
import type { CanReadScope, HyperspellConfig } from "../config.ts"
|
|
3
|
+
import {
|
|
4
|
+
buildScopeFilter,
|
|
5
|
+
getCanReadScopes,
|
|
6
|
+
resolveUser,
|
|
7
|
+
type ResolvedUser,
|
|
8
|
+
} from "../lib/sender.ts"
|
|
4
9
|
import { log } from "../logger.ts"
|
|
5
10
|
|
|
6
11
|
function formatRelativeTime(isoTimestamp: string): string {
|
|
@@ -111,16 +116,24 @@ async function multiUserSearch(
|
|
|
111
116
|
client: HyperspellClient,
|
|
112
117
|
cfg: HyperspellConfig,
|
|
113
118
|
prompt: string,
|
|
114
|
-
resolved:
|
|
115
|
-
| { userId: string; name: string; context?: string; resolved: boolean }
|
|
116
|
-
| undefined,
|
|
119
|
+
resolved: ResolvedUser | undefined,
|
|
117
120
|
) {
|
|
118
121
|
const multiUser = cfg.multiUser!
|
|
119
122
|
const isKnownSender = !!resolved?.resolved
|
|
120
123
|
const includeShared = multiUser.includeSharedInSearch
|
|
121
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
|
+
|
|
122
135
|
log.debug(
|
|
123
|
-
`auto-context: searching for "${prompt.slice(0, 50)}..." user=${resolved?.userId ?? "unknown"}`,
|
|
136
|
+
`auto-context: searching for "${prompt.slice(0, 50)}..." user=${resolved?.userId ?? "unknown"} canRead=${JSON.stringify(canRead)}`,
|
|
124
137
|
)
|
|
125
138
|
|
|
126
139
|
// Build parallel searches — personal (known senders only) + shared
|
|
@@ -140,6 +153,7 @@ async function multiUserSearch(
|
|
|
140
153
|
? client.search(prompt, {
|
|
141
154
|
limit: sharedLimit,
|
|
142
155
|
userId: multiUser.sharedUserId,
|
|
156
|
+
filter: scopeFilter,
|
|
143
157
|
})
|
|
144
158
|
: null
|
|
145
159
|
|
package/hooks/auto-trace.ts
CHANGED
|
@@ -125,6 +125,10 @@ export function buildAutoTraceHandler(
|
|
|
125
125
|
extract: cfg.autoTrace.extract,
|
|
126
126
|
metadata: cfg.autoTrace.metadata,
|
|
127
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",
|
|
128
132
|
});
|
|
129
133
|
log.info(
|
|
130
134
|
`auto-trace: sent ${result.resourceId} (${messages.length} messages${userId ? `, user=${userId}` : ""})`,
|
|
@@ -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
|
+
})
|
package/lib/sender.ts
CHANGED
|
@@ -1,50 +1,56 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
CanReadScope,
|
|
3
|
+
HyperspellConfig,
|
|
4
|
+
Role,
|
|
5
|
+
ScopeName,
|
|
6
|
+
} from "../config.ts"
|
|
7
|
+
import { normalizeScope } from "../config.ts"
|
|
2
8
|
import { log } from "../logger.ts"
|
|
9
|
+
import { getVoiceIdentifier } from "./voice-id.ts"
|
|
3
10
|
|
|
4
11
|
export interface ResolvedUser {
|
|
5
12
|
userId: string
|
|
6
13
|
name: string
|
|
7
14
|
context?: string
|
|
15
|
+
/** Profile-level role override from senderMap. Falls back to scoping.users[userId].role. */
|
|
16
|
+
role?: string
|
|
8
17
|
/** True if the sender was matched in senderMap; false if falling back to sharedUserId */
|
|
9
18
|
resolved: boolean
|
|
10
19
|
}
|
|
11
20
|
|
|
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(
|
|
21
|
+
function matchFromSenderMap(
|
|
22
22
|
ctx: Record<string, unknown> | undefined,
|
|
23
23
|
cfg: HyperspellConfig,
|
|
24
24
|
): ResolvedUser | undefined {
|
|
25
25
|
const multiUser = cfg.multiUser
|
|
26
26
|
if (!multiUser) {
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
return cfg.userId
|
|
28
|
+
? { userId: cfg.userId, name: cfg.userId, resolved: true }
|
|
29
|
+
: undefined
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
// Try direct senderId lookup (
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
// Try direct senderId lookup (slash command contexts)
|
|
33
|
+
const senderId =
|
|
34
|
+
(ctx?.senderId as string) ??
|
|
35
|
+
(ctx?.requesterSenderId as string) ??
|
|
36
|
+
undefined
|
|
34
37
|
if (senderId && multiUser.senderMap[senderId]) {
|
|
35
38
|
const profile = multiUser.senderMap[senderId]
|
|
36
39
|
log.debug(`sender resolved via senderId: ${senderId} -> ${profile.userId}`)
|
|
37
40
|
return { ...profile, resolved: true }
|
|
38
41
|
}
|
|
39
42
|
|
|
40
|
-
// Try sessionKey substring matching (
|
|
43
|
+
// Try sessionKey substring matching (longest-first to avoid partial matches)
|
|
41
44
|
const sessionKey = ctx?.sessionKey as string | undefined
|
|
42
45
|
if (sessionKey) {
|
|
43
|
-
const sortedEntries = Object.entries(multiUser.senderMap)
|
|
44
|
-
|
|
46
|
+
const sortedEntries = Object.entries(multiUser.senderMap).sort(
|
|
47
|
+
([a], [b]) => b.length - a.length,
|
|
48
|
+
)
|
|
45
49
|
for (const [handle, profile] of sortedEntries) {
|
|
46
50
|
if (sessionKey.includes(handle)) {
|
|
47
|
-
log.debug(
|
|
51
|
+
log.debug(
|
|
52
|
+
`sender resolved via sessionKey: ${handle} -> ${profile.userId}`,
|
|
53
|
+
)
|
|
48
54
|
return { ...profile, resolved: true }
|
|
49
55
|
}
|
|
50
56
|
}
|
|
@@ -59,6 +65,52 @@ export function resolveUser(
|
|
|
59
65
|
}
|
|
60
66
|
}
|
|
61
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Synchronous sender resolution from sessionKey + senderMap. Use this for
|
|
70
|
+
* hooks and tools that don't receive audio — the voice-ID path is skipped.
|
|
71
|
+
*/
|
|
72
|
+
export function resolveUser(
|
|
73
|
+
ctx: Record<string, unknown> | undefined,
|
|
74
|
+
cfg: HyperspellConfig,
|
|
75
|
+
): ResolvedUser | undefined {
|
|
76
|
+
return matchFromSenderMap(ctx, cfg)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Asynchronous sender resolution. Checks voice-ID first (if enabled and
|
|
81
|
+
* audio is in ctx), then falls back to the synchronous path. Callers that
|
|
82
|
+
* never receive audio should use `resolveUser` to avoid unnecessary async
|
|
83
|
+
* overhead.
|
|
84
|
+
*/
|
|
85
|
+
export async function resolveUserAsync(
|
|
86
|
+
ctx: Record<string, unknown> | undefined,
|
|
87
|
+
cfg: HyperspellConfig,
|
|
88
|
+
): Promise<ResolvedUser | undefined> {
|
|
89
|
+
const voiceCfg = cfg.multiUser?.scoping?.voiceId
|
|
90
|
+
const audio = ctx?.audio as Buffer | string | undefined
|
|
91
|
+
if (voiceCfg?.enabled && audio && cfg.multiUser) {
|
|
92
|
+
try {
|
|
93
|
+
const identifier = getVoiceIdentifier(cfg)
|
|
94
|
+
const result = await identifier.identify(audio)
|
|
95
|
+
const threshold = voiceCfg.confidenceThreshold ?? 0.7
|
|
96
|
+
if (result && result.confidence >= threshold) {
|
|
97
|
+
// Find profile for the voice-identified userId
|
|
98
|
+
for (const profile of Object.values(cfg.multiUser.senderMap)) {
|
|
99
|
+
if (profile.userId === result.userId) {
|
|
100
|
+
log.debug(
|
|
101
|
+
`sender resolved via voice: ${result.userId} (conf=${result.confidence.toFixed(2)})`,
|
|
102
|
+
)
|
|
103
|
+
return { ...profile, resolved: true }
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
} catch (err) {
|
|
108
|
+
log.error("voice-id identification failed", err)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return matchFromSenderMap(ctx, cfg)
|
|
112
|
+
}
|
|
113
|
+
|
|
62
114
|
/**
|
|
63
115
|
* Get all unique userIds from the multiUser config (for knowledge graph scanning).
|
|
64
116
|
*/
|
|
@@ -74,3 +126,109 @@ export function getAllUserIds(cfg: HyperspellConfig): string[] {
|
|
|
74
126
|
userIds.add(cfg.multiUser.sharedUserId)
|
|
75
127
|
return [...userIds]
|
|
76
128
|
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Resolve the Role for a user. Looks first at the profile-level `role`
|
|
132
|
+
* override, then at `scoping.users[userId].role`. Returns undefined if
|
|
133
|
+
* scoping is disabled or the user has no role assignment.
|
|
134
|
+
*/
|
|
135
|
+
export function resolveRole(
|
|
136
|
+
user: ResolvedUser | undefined,
|
|
137
|
+
cfg: HyperspellConfig,
|
|
138
|
+
): Role | undefined {
|
|
139
|
+
const scoping = cfg.multiUser?.scoping
|
|
140
|
+
if (!scoping || !user) return undefined
|
|
141
|
+
const roleName = user.role ?? scoping.users[user.userId]?.role
|
|
142
|
+
if (!roleName) return undefined
|
|
143
|
+
return scoping.roles[roleName]
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Readable scopes for this user. If scoping is disabled, returns ["*"] so
|
|
148
|
+
* `buildScopeFilter` produces no filter and PR #6 behavior is preserved.
|
|
149
|
+
*/
|
|
150
|
+
export function getCanReadScopes(
|
|
151
|
+
user: ResolvedUser | undefined,
|
|
152
|
+
cfg: HyperspellConfig,
|
|
153
|
+
): CanReadScope[] {
|
|
154
|
+
if (!cfg.multiUser?.scoping) return ["*"]
|
|
155
|
+
const role = resolveRole(user, cfg)
|
|
156
|
+
return role?.canRead ?? []
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Default write scope for this user — explicit param > role default > global default.
|
|
161
|
+
*/
|
|
162
|
+
export function getDefaultWriteScope(
|
|
163
|
+
user: ResolvedUser | undefined,
|
|
164
|
+
cfg: HyperspellConfig,
|
|
165
|
+
): ScopeName {
|
|
166
|
+
const role = resolveRole(user, cfg)
|
|
167
|
+
return (
|
|
168
|
+
role?.defaultWriteScope ??
|
|
169
|
+
cfg.multiUser?.scoping?.defaultScope ??
|
|
170
|
+
"private"
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Build a MongoDB-style metadata filter for Hyperspell's `options.filter`.
|
|
176
|
+
*
|
|
177
|
+
* Contract:
|
|
178
|
+
* - `["*"]` (wildcard) → returns `undefined` (no filter).
|
|
179
|
+
* - `[]` (empty) → returns a **match-nothing** filter, NOT `undefined`. An
|
|
180
|
+
* empty `canRead` is a deliberate "no access" signal and must not silently
|
|
181
|
+
* return all results.
|
|
182
|
+
* - Otherwise: `$or` of named-scope clause and (if "self" present) an
|
|
183
|
+
* own-user clause keyed by `openclaw_user`.
|
|
184
|
+
*/
|
|
185
|
+
export function buildScopeFilter(
|
|
186
|
+
canRead: CanReadScope[],
|
|
187
|
+
userId: string,
|
|
188
|
+
): Record<string, unknown> | undefined {
|
|
189
|
+
if (canRead.includes("*")) return undefined
|
|
190
|
+
if (canRead.length === 0) {
|
|
191
|
+
// Deliberate "no access" — match nothing.
|
|
192
|
+
return { openclaw_scope: "__never__" }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const namedScopes = canRead.filter((s) => s !== "self" && s !== "*")
|
|
196
|
+
const includeSelf = canRead.includes("self")
|
|
197
|
+
|
|
198
|
+
const clauses: Array<Record<string, unknown>> = []
|
|
199
|
+
if (namedScopes.length > 0) {
|
|
200
|
+
clauses.push({
|
|
201
|
+
openclaw_scope: { $in: namedScopes.map((s) => normalizeScope(s)) },
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
if (includeSelf && userId) {
|
|
205
|
+
clauses.push({ openclaw_user: userId })
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (clauses.length === 0) {
|
|
209
|
+
return { openclaw_scope: "__never__" }
|
|
210
|
+
}
|
|
211
|
+
if (clauses.length === 1) {
|
|
212
|
+
return clauses[0]
|
|
213
|
+
}
|
|
214
|
+
return { $or: clauses }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Route a write: private scope stays in the user's own Hyperspell space
|
|
219
|
+
* (defense-in-depth); shared scopes go to `sharedUserId` with optional
|
|
220
|
+
* per-scope collection.
|
|
221
|
+
*/
|
|
222
|
+
export function routeWrite(
|
|
223
|
+
user: ResolvedUser | undefined,
|
|
224
|
+
scope: ScopeName,
|
|
225
|
+
cfg: HyperspellConfig,
|
|
226
|
+
): { userId: string | undefined; collection: string | undefined } {
|
|
227
|
+
const scoping = cfg.multiUser?.scoping
|
|
228
|
+
if (scope === "private") {
|
|
229
|
+
return { userId: user?.userId, collection: undefined }
|
|
230
|
+
}
|
|
231
|
+
const sharedUserId = cfg.multiUser?.sharedUserId ?? user?.userId
|
|
232
|
+
const collection = scoping?.collections?.[scope]
|
|
233
|
+
return { userId: sharedUserId, collection }
|
|
234
|
+
}
|
package/lib/voice-id.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { HyperspellConfig } from "../config.ts"
|
|
2
|
+
|
|
3
|
+
export interface VoiceIdResult {
|
|
4
|
+
userId: string
|
|
5
|
+
confidence: number
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface VoiceIdentifier {
|
|
9
|
+
identify(audio: Buffer | string): Promise<VoiceIdResult | null>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const NO_OP: VoiceIdentifier = {
|
|
13
|
+
async identify() {
|
|
14
|
+
return null
|
|
15
|
+
},
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const registry = new Map<string, VoiceIdentifier>()
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Register a voice-ID adapter implementation under a name that can be
|
|
22
|
+
* referenced from `cfg.multiUser.scoping.voiceId.adapter`. Call this from
|
|
23
|
+
* downstream code that wants to plug in a real speaker diarization model;
|
|
24
|
+
* Phase 1 ships with no adapters registered — the no-op is used.
|
|
25
|
+
*/
|
|
26
|
+
export function registerVoiceIdentifier(
|
|
27
|
+
name: string,
|
|
28
|
+
impl: VoiceIdentifier,
|
|
29
|
+
): void {
|
|
30
|
+
registry.set(name, impl)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function getVoiceIdentifier(cfg: HyperspellConfig): VoiceIdentifier {
|
|
34
|
+
const name = cfg.multiUser?.scoping?.voiceId?.adapter
|
|
35
|
+
if (name && registry.has(name)) {
|
|
36
|
+
return registry.get(name) as VoiceIdentifier
|
|
37
|
+
}
|
|
38
|
+
return NO_OP
|
|
39
|
+
}
|
package/openclaw.plugin.json
CHANGED
|
@@ -64,6 +64,11 @@
|
|
|
64
64
|
"label": "Memory Network",
|
|
65
65
|
"help": "Extract entities (people, projects, orgs, topics) from memories into structured markdown files. Requires a cron job for periodic scanning.",
|
|
66
66
|
"advanced": true
|
|
67
|
+
},
|
|
68
|
+
"multiUser": {
|
|
69
|
+
"label": "Multi-User Support",
|
|
70
|
+
"help": "Per-sender context isolation for household or shared-device setups. Maps senders to user IDs; supports optional role-based privacy scopes (private/family/parent-only/etc).",
|
|
71
|
+
"advanced": true
|
|
67
72
|
}
|
|
68
73
|
},
|
|
69
74
|
"configSchema": {
|
|
@@ -124,6 +129,38 @@
|
|
|
124
129
|
},
|
|
125
130
|
"batchSize": { "type": "number", "minimum": 5, "maximum": 100 }
|
|
126
131
|
}
|
|
132
|
+
},
|
|
133
|
+
"multiUser": {
|
|
134
|
+
"type": "object",
|
|
135
|
+
"additionalProperties": true,
|
|
136
|
+
"properties": {
|
|
137
|
+
"senderMap": { "type": "object" },
|
|
138
|
+
"sharedUserId": { "type": "string" },
|
|
139
|
+
"includeSharedInSearch": { "type": "boolean" },
|
|
140
|
+
"scoping": {
|
|
141
|
+
"type": "object",
|
|
142
|
+
"additionalProperties": true,
|
|
143
|
+
"properties": {
|
|
144
|
+
"enabled": { "type": "boolean" },
|
|
145
|
+
"defaultScope": { "type": "string" },
|
|
146
|
+
"scopes": {
|
|
147
|
+
"type": "array",
|
|
148
|
+
"items": { "type": "string" }
|
|
149
|
+
},
|
|
150
|
+
"roles": { "type": "object" },
|
|
151
|
+
"users": { "type": "object" },
|
|
152
|
+
"collections": { "type": "object" },
|
|
153
|
+
"voiceId": {
|
|
154
|
+
"type": "object",
|
|
155
|
+
"properties": {
|
|
156
|
+
"enabled": { "type": "boolean" },
|
|
157
|
+
"adapter": { "type": "string" },
|
|
158
|
+
"confidenceThreshold": { "type": "number" }
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
127
164
|
}
|
|
128
165
|
},
|
|
129
166
|
"required": []
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperspell/openclaw-hyperspell",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "OpenClaw Hyperspell memory plugin",
|
|
6
6
|
"license": "MIT",
|
|
@@ -42,7 +42,8 @@
|
|
|
42
42
|
"scripts": {
|
|
43
43
|
"check-types": "tsc --noEmit",
|
|
44
44
|
"lint": "bunx @biomejs/biome ci .",
|
|
45
|
-
"lint:fix": "bunx @biomejs/biome check --write ."
|
|
45
|
+
"lint:fix": "bunx @biomejs/biome check --write .",
|
|
46
|
+
"test": "node --test --experimental-strip-types lib/sender.test.ts config.test.ts"
|
|
46
47
|
},
|
|
47
48
|
"openclaw": {
|
|
48
49
|
"extensions": [
|