@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/lib/sender.ts
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CanReadScope,
|
|
3
|
+
HyperspellConfig,
|
|
4
|
+
Role,
|
|
5
|
+
ScopeName,
|
|
6
|
+
} from "../config.ts"
|
|
7
|
+
import { normalizeScope } from "../config.ts"
|
|
8
|
+
import { log } from "../logger.ts"
|
|
9
|
+
import { getVoiceIdentifier } from "./voice-id.ts"
|
|
10
|
+
|
|
11
|
+
export interface ResolvedUser {
|
|
12
|
+
userId: string
|
|
13
|
+
name: string
|
|
14
|
+
context?: string
|
|
15
|
+
/** Profile-level role override from senderMap. Falls back to scoping.users[userId].role. */
|
|
16
|
+
role?: string
|
|
17
|
+
/** True if the sender was matched in senderMap; false if falling back to sharedUserId */
|
|
18
|
+
resolved: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function matchFromSenderMap(
|
|
22
|
+
ctx: Record<string, unknown> | undefined,
|
|
23
|
+
cfg: HyperspellConfig,
|
|
24
|
+
): ResolvedUser | undefined {
|
|
25
|
+
const multiUser = cfg.multiUser
|
|
26
|
+
if (!multiUser) {
|
|
27
|
+
return cfg.userId
|
|
28
|
+
? { userId: cfg.userId, name: cfg.userId, resolved: true }
|
|
29
|
+
: undefined
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Try direct senderId lookup (slash command contexts)
|
|
33
|
+
const senderId =
|
|
34
|
+
(ctx?.senderId as string) ??
|
|
35
|
+
(ctx?.requesterSenderId as string) ??
|
|
36
|
+
undefined
|
|
37
|
+
if (senderId && multiUser.senderMap[senderId]) {
|
|
38
|
+
const profile = multiUser.senderMap[senderId]
|
|
39
|
+
log.debug(`sender resolved via senderId: ${senderId} -> ${profile.userId}`)
|
|
40
|
+
return { ...profile, resolved: true }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Try sessionKey substring matching (longest-first to avoid partial matches)
|
|
44
|
+
const sessionKey = ctx?.sessionKey as string | undefined
|
|
45
|
+
if (sessionKey) {
|
|
46
|
+
const sortedEntries = Object.entries(multiUser.senderMap).sort(
|
|
47
|
+
([a], [b]) => b.length - a.length,
|
|
48
|
+
)
|
|
49
|
+
for (const [handle, profile] of sortedEntries) {
|
|
50
|
+
if (sessionKey.includes(handle)) {
|
|
51
|
+
log.debug(
|
|
52
|
+
`sender resolved via sessionKey: ${handle} -> ${profile.userId}`,
|
|
53
|
+
)
|
|
54
|
+
return { ...profile, resolved: true }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Fallback: use sharedUserId for unknown senders
|
|
60
|
+
log.debug("sender unresolved, falling back to sharedUserId")
|
|
61
|
+
return {
|
|
62
|
+
userId: multiUser.sharedUserId,
|
|
63
|
+
name: multiUser.sharedUserId,
|
|
64
|
+
resolved: false,
|
|
65
|
+
}
|
|
66
|
+
}
|
|
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
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Get all unique userIds from the multiUser config (for knowledge graph scanning).
|
|
116
|
+
*/
|
|
117
|
+
export function getAllUserIds(cfg: HyperspellConfig): string[] {
|
|
118
|
+
if (!cfg.multiUser) {
|
|
119
|
+
return cfg.userId ? [cfg.userId] : []
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const userIds = new Set<string>()
|
|
123
|
+
for (const profile of Object.values(cfg.multiUser.senderMap)) {
|
|
124
|
+
userIds.add(profile.userId)
|
|
125
|
+
}
|
|
126
|
+
userIds.add(cfg.multiUser.sharedUserId)
|
|
127
|
+
return [...userIds]
|
|
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": [
|
package/sync/markdown.ts
CHANGED
|
@@ -122,6 +122,7 @@ export function getMemoryFiles(workspaceDir: string): string[] {
|
|
|
122
122
|
export async function syncMarkdownFile(
|
|
123
123
|
client: HyperspellClient,
|
|
124
124
|
filePath: string,
|
|
125
|
+
options?: { userId?: string },
|
|
125
126
|
): Promise<{ success: boolean; resourceId?: string; error?: string }> {
|
|
126
127
|
const file = readMarkdownFile(filePath)
|
|
127
128
|
if (!file) {
|
|
@@ -141,6 +142,7 @@ export async function syncMarkdownFile(
|
|
|
141
142
|
openclaw_source: "memory_sync",
|
|
142
143
|
file_path: filePath,
|
|
143
144
|
},
|
|
145
|
+
userId: options?.userId,
|
|
144
146
|
})
|
|
145
147
|
|
|
146
148
|
// Update frontmatter with new resource ID if it changed or was newly created
|
|
@@ -162,6 +164,7 @@ export async function syncMarkdownFile(
|
|
|
162
164
|
export async function syncAllMemoryFiles(
|
|
163
165
|
client: HyperspellClient,
|
|
164
166
|
workspaceDir: string,
|
|
167
|
+
options?: { userId?: string },
|
|
165
168
|
): Promise<{ synced: number; failed: number; errors: string[] }> {
|
|
166
169
|
const files = getMemoryFiles(workspaceDir)
|
|
167
170
|
let synced = 0
|
|
@@ -169,7 +172,7 @@ export async function syncAllMemoryFiles(
|
|
|
169
172
|
const errors: string[] = []
|
|
170
173
|
|
|
171
174
|
for (const filePath of files) {
|
|
172
|
-
const result = await syncMarkdownFile(client, filePath)
|
|
175
|
+
const result = await syncMarkdownFile(client, filePath, { userId: options?.userId })
|
|
173
176
|
if (result.success) {
|
|
174
177
|
synced++
|
|
175
178
|
log.info(`Synced: ${path.basename(filePath)} -> ${result.resourceId}`)
|
package/tools/remember.ts
CHANGED
|
@@ -1,60 +1,132 @@
|
|
|
1
1
|
import { Type } from "@sinclair/typebox"
|
|
2
|
-
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"
|
|
3
2
|
import type { HyperspellClient } from "../client.ts"
|
|
4
3
|
import type { HyperspellConfig } from "../config.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
|
-
export function
|
|
8
|
-
api: OpenClawPluginApi,
|
|
12
|
+
export function createRememberToolFactory(
|
|
9
13
|
client: HyperspellClient,
|
|
10
|
-
|
|
11
|
-
)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
parameters: Type.Object({
|
|
18
|
-
text: Type.String({ description: "Information to remember" }),
|
|
19
|
-
title: Type.Optional(
|
|
20
|
-
Type.String({ description: "Optional title for the memory" }),
|
|
21
|
-
),
|
|
22
|
-
date: Type.Optional(
|
|
23
|
-
Type.String({ description: "Date of the memory (ISO 8601 or YYYY-MM-DD). Helps ranking and enables date-range filtering. Defaults to now if omitted." }),
|
|
24
|
-
),
|
|
25
|
-
}),
|
|
26
|
-
async execute(
|
|
27
|
-
_toolCallId: string,
|
|
28
|
-
params: { text: string; title?: string; date?: string },
|
|
29
|
-
) {
|
|
30
|
-
log.debug(`remember tool: "${params.text.slice(0, 50)}..." date=${params.date ?? "now"}`)
|
|
14
|
+
cfg: HyperspellConfig,
|
|
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)."
|
|
31
21
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
22
|
+
return (ctx: Record<string, unknown>) => ({
|
|
23
|
+
name: "hyperspell_remember",
|
|
24
|
+
label: "Memory Store",
|
|
25
|
+
description: "Save important information to the user's memory.",
|
|
26
|
+
parameters: Type.Object({
|
|
27
|
+
text: Type.String({ description: "Information to remember" }),
|
|
28
|
+
title: Type.Optional(
|
|
29
|
+
Type.String({ description: "Optional title for the memory" }),
|
|
30
|
+
),
|
|
31
|
+
date: Type.Optional(
|
|
32
|
+
Type.String({ description: "Date of the memory (ISO 8601 or YYYY-MM-DD). Helps ranking and enables date-range filtering. Defaults to now if omitted." }),
|
|
33
|
+
),
|
|
34
|
+
userId: Type.Optional(
|
|
35
|
+
Type.String({
|
|
36
|
+
description:
|
|
37
|
+
"Store for a specific user or 'shared' for everyone. Omit to store for current sender.",
|
|
38
|
+
}),
|
|
39
|
+
),
|
|
40
|
+
scope: Type.Optional(
|
|
41
|
+
Type.String({ description: scopeDescription }),
|
|
42
|
+
),
|
|
43
|
+
}),
|
|
44
|
+
async execute(
|
|
45
|
+
_toolCallId: string,
|
|
46
|
+
params: { text: string; title?: string; date?: string; userId?: string; scope?: string },
|
|
47
|
+
) {
|
|
48
|
+
const resolved = resolveUser(ctx, cfg)
|
|
38
49
|
|
|
39
|
-
|
|
40
|
-
|
|
50
|
+
// Scope resolution: explicit param > role default > global default > "private"
|
|
51
|
+
const scope =
|
|
52
|
+
params.scope ??
|
|
53
|
+
(scopingEnabled ? getDefaultWriteScope(resolved, cfg) : "private")
|
|
41
54
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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)) {
|
|
47
75
|
return {
|
|
48
76
|
content: [
|
|
49
77
|
{
|
|
50
78
|
type: "text" as const,
|
|
51
|
-
text: `
|
|
79
|
+
text: `You cannot write to scope "${scope}".`,
|
|
52
80
|
},
|
|
53
81
|
],
|
|
54
82
|
}
|
|
55
83
|
}
|
|
56
|
-
}
|
|
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
|
+
|
|
99
|
+
log.debug(
|
|
100
|
+
`remember tool: "${params.text.slice(0, 50)}..." date=${params.date ?? "now"} userId=${userId} scope=${scope}`,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
await client.addMemory(params.text, {
|
|
105
|
+
title: params.title,
|
|
106
|
+
date: params.date,
|
|
107
|
+
collection,
|
|
108
|
+
metadata: { source: "openclaw_tool" },
|
|
109
|
+
userId,
|
|
110
|
+
scope: scopingEnabled ? scope : undefined,
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
const preview =
|
|
114
|
+
params.text.length > 80 ? `${params.text.slice(0, 80)}…` : params.text
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
content: [{ type: "text" as const, text: `Stored: "${preview}"` }],
|
|
118
|
+
}
|
|
119
|
+
} catch (err) {
|
|
120
|
+
log.error("remember tool failed", err)
|
|
121
|
+
return {
|
|
122
|
+
content: [
|
|
123
|
+
{
|
|
124
|
+
type: "text" as const,
|
|
125
|
+
text: `Failed to store memory: ${err instanceof Error ? err.message : String(err)}`,
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
}
|
|
129
|
+
}
|
|
57
130
|
},
|
|
58
|
-
|
|
59
|
-
)
|
|
131
|
+
})
|
|
60
132
|
}
|