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