@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.
@@ -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 { HyperspellConfig } from "../config.ts"
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
- // Single-user mode: return config.userId if set
28
- return cfg.userId ? { userId: cfg.userId, name: cfg.userId, resolved: true } : undefined
27
+ return cfg.userId
28
+ ? { userId: cfg.userId, name: cfg.userId, resolved: true }
29
+ : undefined
29
30
  }
30
31
 
31
- // Try direct senderId lookup (available in slash command contexts;
32
- // tool factory contexts do NOT include senderId — they have sessionKey instead)
33
- const senderId = (ctx?.senderId as string) ?? (ctx?.requesterSenderId as string) ?? undefined
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 (works in both hook and tool factory contexts)
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
- .sort(([a], [b]) => b.length - a.length)
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(`sender resolved via sessionKey: ${handle} -> ${profile.userId}`)
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
+ }
@@ -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
+ }
@@ -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.10.0",
3
+ "version": "0.11.1",
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 hooks/auto-trace.test.ts hooks/emotional-state.test.ts"
46
47
  },
47
48
  "openclaw": {
48
49
  "extensions": [
package/tools/remember.ts CHANGED
@@ -1,13 +1,24 @@
1
1
  import { Type } from "@sinclair/typebox"
2
2
  import type { HyperspellClient } from "../client.ts"
3
3
  import type { HyperspellConfig } from "../config.ts"
4
- import { resolveUser } from "../lib/sender.ts"
4
+ import {
5
+ getDefaultWriteScope,
6
+ resolveRole,
7
+ resolveUser,
8
+ routeWrite,
9
+ } from "../lib/sender.ts"
5
10
  import { log } from "../logger.ts"
6
11
 
7
12
  export function createRememberToolFactory(
8
13
  client: HyperspellClient,
9
14
  cfg: HyperspellConfig,
10
15
  ) {
16
+ const scopingEnabled = !!cfg.multiUser?.scoping
17
+ const availableScopes = cfg.multiUser?.scoping?.scopes ?? []
18
+ const scopeDescription = scopingEnabled
19
+ ? `Privacy scope for this memory. Available: ${availableScopes.join(", ")}. Defaults to the user's role default.`
20
+ : "Privacy scope (only used when scoping is enabled in config)."
21
+
11
22
  return (ctx: Record<string, unknown>) => ({
12
23
  name: "hyperspell_remember",
13
24
  label: "Memory Store",
@@ -26,24 +37,77 @@ export function createRememberToolFactory(
26
37
  "Store for a specific user or 'shared' for everyone. Omit to store for current sender.",
27
38
  }),
28
39
  ),
40
+ scope: Type.Optional(
41
+ Type.String({ description: scopeDescription }),
42
+ ),
29
43
  }),
30
44
  async execute(
31
45
  _toolCallId: string,
32
- params: { text: string; title?: string; date?: string; userId?: string },
46
+ params: { text: string; title?: string; date?: string; userId?: string; scope?: string },
33
47
  ) {
34
- // Resolve userId: explicit param > sender resolution > config default
35
48
  const resolved = resolveUser(ctx, cfg)
36
- const userId = params.userId ?? resolved?.userId
49
+
50
+ // Scope resolution: explicit param > role default > global default > "private"
51
+ const scope =
52
+ params.scope ??
53
+ (scopingEnabled ? getDefaultWriteScope(resolved, cfg) : "private")
54
+
55
+ // Validate scope is in declared vocabulary (if scoping is enabled)
56
+ if (
57
+ scopingEnabled &&
58
+ availableScopes.length > 0 &&
59
+ !availableScopes.includes(scope)
60
+ ) {
61
+ return {
62
+ content: [
63
+ {
64
+ type: "text" as const,
65
+ text: `Unknown scope "${scope}". Available: ${availableScopes.join(", ")}.`,
66
+ },
67
+ ],
68
+ }
69
+ }
70
+
71
+ // canWriteScopes enforcement (role-level deny list)
72
+ if (scopingEnabled) {
73
+ const role = resolveRole(resolved, cfg)
74
+ if (role?.canWriteScopes && !role.canWriteScopes.includes(scope)) {
75
+ return {
76
+ content: [
77
+ {
78
+ type: "text" as const,
79
+ text: `You cannot write to scope "${scope}".`,
80
+ },
81
+ ],
82
+ }
83
+ }
84
+ }
85
+
86
+ // Route: explicit userId override takes precedence; otherwise derive from scope
87
+ let userId: string | undefined
88
+ let collection: string | undefined
89
+ if (params.userId) {
90
+ userId = params.userId
91
+ } else if (scopingEnabled) {
92
+ const routed = routeWrite(resolved, scope, cfg)
93
+ userId = routed.userId
94
+ collection = routed.collection
95
+ } else {
96
+ userId = resolved?.userId
97
+ }
98
+
37
99
  log.debug(
38
- `remember tool: "${params.text.slice(0, 50)}..." date=${params.date ?? "now"} userId=${userId}`,
100
+ `remember tool: "${params.text.slice(0, 50)}..." date=${params.date ?? "now"} userId=${userId} scope=${scope}`,
39
101
  )
40
102
 
41
103
  try {
42
104
  await client.addMemory(params.text, {
43
105
  title: params.title,
44
106
  date: params.date,
107
+ collection,
45
108
  metadata: { source: "openclaw_tool" },
46
109
  userId,
110
+ scope: scopingEnabled ? scope : undefined,
47
111
  })
48
112
 
49
113
  const preview =