@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 CHANGED
@@ -1,5 +1,10 @@
1
1
  import Hyperspell from "hyperspell";
2
- import type { HyperspellConfig, HyperspellSource } from "./config.ts";
2
+ import type {
3
+ HyperspellConfig,
4
+ HyperspellSource,
5
+ ScopeName,
6
+ } from "./config.ts";
7
+ import { normalizeScope } from "./config.ts";
3
8
  import { log } from "./logger.ts";
4
9
 
5
10
  export type Highlight = {
@@ -82,29 +87,55 @@ export class HyperspellClient {
82
87
  return headers;
83
88
  }
84
89
 
90
+ private requestOptions(userId?: string) {
91
+ if (!userId) return undefined;
92
+ return { headers: { "X-As-User": userId } };
93
+ }
94
+
85
95
  async search(
86
96
  query: string,
87
- options?: { limit?: number; sources?: HyperspellSource[]; after?: string; before?: string },
97
+ options?: {
98
+ limit?: number;
99
+ sources?: HyperspellSource[];
100
+ after?: string;
101
+ before?: string;
102
+ userId?: string;
103
+ filter?: Record<string, unknown>;
104
+ },
88
105
  ): Promise<SearchResult[]> {
89
106
  const limit = options?.limit ?? this.config.maxResults;
90
107
  const sources =
91
108
  options?.sources ??
92
109
  (this.config.sources.length > 0 ? this.config.sources : undefined);
93
110
 
94
- log.debugRequest("memories.search", { query, limit, sources, after: options?.after, before: options?.before });
95
-
96
- const response = await this.client.memories.search({
111
+ log.debugRequest("memories.search", {
97
112
  query,
113
+ limit,
98
114
  sources,
99
- options: {
100
- max_results: limit,
101
- ...(options?.after ? { after: options.after } : {}),
102
- ...(options?.before ? { before: options.before } : {}),
103
- },
115
+ after: options?.after,
116
+ before: options?.before,
117
+ userId: options?.userId,
118
+ filter: options?.filter,
104
119
  });
105
120
 
121
+ const response = await this.client.memories.search(
122
+ {
123
+ query,
124
+ sources,
125
+ options: {
126
+ max_results: limit,
127
+ ...(options?.after ? { after: options.after } : {}),
128
+ ...(options?.before ? { before: options.before } : {}),
129
+ ...(options?.filter ? { filter: options.filter } : {}),
130
+ },
131
+ },
132
+ this.requestOptions(options?.userId),
133
+ );
134
+
106
135
  const results: SearchResult[] = response.documents.map((doc) => {
107
- const raw = doc as typeof doc & { highlights?: Array<{ id: string; score: number; text: string }> };
136
+ const raw = doc as typeof doc & {
137
+ highlights?: Array<{ id: string; score: number; text: string }>;
138
+ };
108
139
  return {
109
140
  resourceId: doc.resource_id,
110
141
  title: doc.title ?? null,
@@ -112,7 +143,11 @@ export class HyperspellClient {
112
143
  score: doc.score ?? null,
113
144
  url: (doc.metadata?.url as string | null) ?? null,
114
145
  createdAt: (doc.metadata?.created_at as string | null) ?? null,
115
- highlights: (raw.highlights ?? []).map((h) => ({ id: h.id, score: h.score, text: h.text })),
146
+ highlights: (raw.highlights ?? []).map((h) => ({
147
+ id: h.id,
148
+ score: h.score,
149
+ text: h.text,
150
+ })),
116
151
  };
117
152
  });
118
153
 
@@ -122,25 +157,44 @@ export class HyperspellClient {
122
157
 
123
158
  async searchRaw(
124
159
  query: string,
125
- options?: { limit?: number; sources?: HyperspellSource[]; after?: string; before?: string },
160
+ options?: {
161
+ limit?: number;
162
+ sources?: HyperspellSource[];
163
+ after?: string;
164
+ before?: string;
165
+ userId?: string;
166
+ filter?: Record<string, unknown>;
167
+ },
126
168
  ): Promise<Record<string, unknown>> {
127
169
  const limit = options?.limit ?? this.config.maxResults;
128
170
  const sources =
129
171
  options?.sources ??
130
172
  (this.config.sources.length > 0 ? this.config.sources : undefined);
131
173
 
132
- log.debugRequest("memories.search (raw)", { query, limit, sources, after: options?.after, before: options?.before });
133
-
134
- const response = await this.client.memories.search({
174
+ log.debugRequest("memories.search (raw)", {
135
175
  query,
176
+ limit,
136
177
  sources,
137
- options: {
138
- max_results: limit,
139
- ...(options?.after ? { after: options.after } : {}),
140
- ...(options?.before ? { before: options.before } : {}),
141
- },
178
+ after: options?.after,
179
+ before: options?.before,
180
+ userId: options?.userId,
181
+ filter: options?.filter,
142
182
  });
143
183
 
184
+ const response = await this.client.memories.search(
185
+ {
186
+ query,
187
+ sources,
188
+ options: {
189
+ max_results: limit,
190
+ ...(options?.after ? { after: options.after } : {}),
191
+ ...(options?.before ? { before: options.before } : {}),
192
+ ...(options?.filter ? { filter: options.filter } : {}),
193
+ },
194
+ },
195
+ this.requestOptions(options?.userId),
196
+ );
197
+
144
198
  log.debugResponse("memories.search (raw)", {
145
199
  count: response.documents.length,
146
200
  });
@@ -150,7 +204,12 @@ export class HyperspellClient {
150
204
 
151
205
  async searchWithAnswer(
152
206
  query: string,
153
- options?: { limit?: number; sources?: HyperspellSource[] },
207
+ options?: {
208
+ limit?: number;
209
+ sources?: HyperspellSource[];
210
+ userId?: string;
211
+ filter?: Record<string, unknown>;
212
+ },
154
213
  ): Promise<SearchWithAnswerResult> {
155
214
  const limit = options?.limit ?? this.config.maxResults;
156
215
  const sources =
@@ -161,16 +220,22 @@ export class HyperspellClient {
161
220
  query,
162
221
  limit,
163
222
  sources,
223
+ userId: options?.userId,
224
+ filter: options?.filter,
164
225
  });
165
226
 
166
- const response = await this.client.memories.search({
167
- query,
168
- sources,
169
- answer: true,
170
- options: {
171
- max_results: limit,
227
+ const response = await this.client.memories.search(
228
+ {
229
+ query,
230
+ sources,
231
+ answer: true,
232
+ options: {
233
+ max_results: limit,
234
+ ...(options?.filter ? { filter: options.filter } : {}),
235
+ },
172
236
  },
173
- });
237
+ this.requestOptions(options?.userId),
238
+ );
174
239
 
175
240
  const documents: SearchResult[] = response.documents.map((doc) => ({
176
241
  resourceId: doc.resource_id,
@@ -201,6 +266,8 @@ export class HyperspellClient {
201
266
  collection?: string;
202
267
  date?: string;
203
268
  metadata?: Record<string, string | number | boolean>;
269
+ userId?: string;
270
+ scope?: ScopeName;
204
271
  },
205
272
  ): Promise<{ resourceId: string }> {
206
273
  log.debugRequest("memories.add", {
@@ -209,19 +276,28 @@ export class HyperspellClient {
209
276
  resourceId: options?.resourceId,
210
277
  collection: options?.collection,
211
278
  date: options?.date,
279
+ userId: options?.userId,
280
+ scope: options?.scope,
212
281
  });
213
282
 
214
- const result = await this.client.memories.add({
215
- text,
216
- title: options?.title,
217
- resource_id: options?.resourceId,
218
- collection: options?.collection,
219
- date: options?.date,
220
- metadata: {
221
- ...options?.metadata,
222
- openclaw_source: "command",
283
+ const result = await this.client.memories.add(
284
+ {
285
+ text,
286
+ title: options?.title,
287
+ resource_id: options?.resourceId,
288
+ collection: options?.collection,
289
+ date: options?.date,
290
+ metadata: {
291
+ openclaw_source: "command",
292
+ ...options?.metadata,
293
+ ...(options?.userId ? { openclaw_user: options.userId } : {}),
294
+ ...(options?.scope
295
+ ? { openclaw_scope: normalizeScope(options.scope) }
296
+ : {}),
297
+ },
223
298
  },
224
- });
299
+ this.requestOptions(options?.userId),
300
+ );
225
301
 
226
302
  log.debugResponse("memories.add", { resourceId: result.resource_id });
227
303
  return { resourceId: result.resource_id };
@@ -245,10 +321,15 @@ export class HyperspellClient {
245
321
 
246
322
  async getConnectUrl(
247
323
  integrationId: string,
324
+ options?: { userId?: string },
248
325
  ): Promise<{ url: string; expiresAt: string }> {
249
326
  log.debugRequest("integrations.connect", { integrationId });
250
327
 
251
- const response = await this.client.integrations.connect(integrationId);
328
+ const response = await this.client.integrations.connect(
329
+ integrationId,
330
+ undefined,
331
+ this.requestOptions(options?.userId),
332
+ );
252
333
 
253
334
  log.debugResponse("integrations.connect", { url: response.url });
254
335
  return {
@@ -261,6 +342,7 @@ export class HyperspellClient {
261
342
  source?: HyperspellSource;
262
343
  collection?: string;
263
344
  pageSize?: number;
345
+ userId?: string;
264
346
  }): AsyncGenerator<{
265
347
  resourceId: string;
266
348
  source: HyperspellSource;
@@ -270,6 +352,7 @@ export class HyperspellClient {
270
352
  log.debugRequest("memories.list", {
271
353
  source: options?.source,
272
354
  collection: options?.collection,
355
+ userId: options?.userId,
273
356
  });
274
357
 
275
358
  const params: Record<string, unknown> = {
@@ -278,7 +361,10 @@ export class HyperspellClient {
278
361
  if (options?.source) params.source = options.source;
279
362
  if (options?.collection) params.collection = options.collection;
280
363
 
281
- for await (const memory of this.client.memories.list(params as any)) {
364
+ for await (const memory of this.client.memories.list(
365
+ params as any,
366
+ this.requestOptions(options?.userId),
367
+ )) {
282
368
  yield {
283
369
  resourceId: memory.resource_id,
284
370
  source: memory.source as HyperspellSource,
@@ -291,10 +377,15 @@ export class HyperspellClient {
291
377
  async getMemory(
292
378
  resourceId: string,
293
379
  source: HyperspellSource,
380
+ options?: { userId?: string },
294
381
  ): Promise<Record<string, unknown>> {
295
382
  log.debugRequest("memories.get", { resourceId, source });
296
383
 
297
- const response = await this.client.memories.get(resourceId, { source });
384
+ const response = await this.client.memories.get(
385
+ resourceId,
386
+ { source },
387
+ this.requestOptions(options?.userId),
388
+ );
298
389
  const raw = response as unknown as Record<string, unknown>;
299
390
 
300
391
  log.debugResponse("memories.get", { resourceId, hasData: "data" in raw });
@@ -308,30 +399,41 @@ export class HyperspellClient {
308
399
  title?: string;
309
400
  extract?: Array<"procedure" | "memory" | "mood">;
310
401
  metadata?: Record<string, string | number | boolean>;
402
+ userId?: string;
403
+ scope?: ScopeName;
311
404
  },
312
405
  ): Promise<{ resourceId: string; status: string }> {
313
406
  log.debugRequest("sessions.add", {
314
407
  historyLength: history.length,
315
408
  sessionId: options?.sessionId,
316
409
  extract: options?.extract,
410
+ userId: options?.userId,
411
+ scope: options?.scope,
317
412
  });
318
413
 
319
- const result = await this.client.sessions.add({
320
- history,
321
- session_id: options?.sessionId,
322
- title: options?.title,
323
- format: "openclaw",
324
- // Cast: SDK 0.35 typing accepts only ["procedure" | "memory"], but the
325
- // backend's mood extractor (hyperspell/hyperspell#581) accepts "mood".
326
- // Remove this cast once the OpenAPI spec is updated.
327
- extract: (options?.extract ?? ["procedure"]) as Array<
328
- "procedure" | "memory"
329
- >,
330
- metadata: {
331
- ...options?.metadata,
332
- openclaw_source: "agent_end",
414
+ const result = await this.client.sessions.add(
415
+ {
416
+ history,
417
+ session_id: options?.sessionId,
418
+ title: options?.title,
419
+ format: "openclaw",
420
+ // Cast: SDK 0.35 typing accepts only ["procedure" | "memory"], but the
421
+ // backend's mood extractor (hyperspell/hyperspell#581) accepts "mood".
422
+ // Remove this cast once the OpenAPI spec is updated.
423
+ extract: (options?.extract ?? ["procedure"]) as Array<
424
+ "procedure" | "memory"
425
+ >,
426
+ metadata: {
427
+ openclaw_source: "agent_end",
428
+ ...options?.metadata,
429
+ ...(options?.userId ? { openclaw_user: options.userId } : {}),
430
+ ...(options?.scope
431
+ ? { openclaw_scope: normalizeScope(options.scope) }
432
+ : {}),
433
+ },
333
434
  },
334
- });
435
+ this.requestOptions(options?.userId),
436
+ );
335
437
 
336
438
  log.debugResponse("sessions.add", {
337
439
  resourceId: result.resource_id,
@@ -340,10 +442,14 @@ export class HyperspellClient {
340
442
  return { resourceId: result.resource_id, status: result.status };
341
443
  }
342
444
 
343
- async listConnections(): Promise<Connection[]> {
344
- log.debugRequest("connections.list", {});
445
+ async listConnections(options?: {
446
+ userId?: string;
447
+ }): Promise<Connection[]> {
448
+ log.debugRequest("connections.list", { userId: options?.userId });
345
449
 
346
- const response = await this.client.connections.list();
450
+ const response = await this.client.connections.list(
451
+ this.requestOptions(options?.userId),
452
+ );
347
453
 
348
454
  const connections: Connection[] = response.connections.map((conn) => ({
349
455
  id: conn.id,
package/commands/slash.ts CHANGED
@@ -1,10 +1,29 @@
1
1
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk"
2
2
  import type { HyperspellClient } from "../client.ts"
3
- import type { HyperspellConfig } from "../config.ts"
3
+ import type { CanReadScope, HyperspellConfig } from "../config.ts"
4
4
  import { getWorkspaceDir } from "../config.ts"
5
+ import {
6
+ buildScopeFilter,
7
+ getCanReadScopes,
8
+ getDefaultWriteScope,
9
+ resolveRole,
10
+ resolveUser,
11
+ routeWrite,
12
+ } from "../lib/sender.ts"
5
13
  import { log } from "../logger.ts"
6
14
  import { syncAllMemoryFiles } from "../sync/markdown.ts"
7
15
 
16
+ /**
17
+ * Strip a `#scope-name` prefix from free text. Returns the scope and the
18
+ * remainder. Used by /remember and /getcontext to let users narrow or route
19
+ * via a single keystroke.
20
+ */
21
+ function parseScopePrefix(text: string): { scope?: string; rest: string } {
22
+ const m = text.match(/^#(\S+)\s+(.*)$/)
23
+ if (!m) return { rest: text }
24
+ return { scope: m[1], rest: m[2] }
25
+ }
26
+
8
27
  function truncate(text: string, maxLength: number): string {
9
28
  if (text.length <= maxLength) return text
10
29
  return `${text.slice(0, maxLength)}…`
@@ -18,7 +37,7 @@ function formatScore(score: number | null): string {
18
37
  export function registerCommands(
19
38
  api: OpenClawPluginApi,
20
39
  client: HyperspellClient,
21
- _cfg: HyperspellConfig,
40
+ cfg: HyperspellConfig,
22
41
  ): void {
23
42
  // /getcontext <query> - Search memories and show summaries
24
43
  api.registerCommand({
@@ -26,16 +45,34 @@ export function registerCommands(
26
45
  description: "Search your memories for relevant context",
27
46
  acceptsArgs: true,
28
47
  requireAuth: true,
29
- handler: async (ctx: { args?: string }) => {
30
- const query = ctx.args?.trim()
31
- if (!query) {
32
- return { text: "Usage: /getcontext <search query>" }
48
+ handler: async (ctx: { args?: string; senderId?: string; channel?: string }) => {
49
+ const rawArgs = ctx.args?.trim()
50
+ if (!rawArgs) {
51
+ return { text: "Usage: /getcontext [#scope] <search query>" }
52
+ }
53
+ const { scope: requestedScope, rest: query } = parseScopePrefix(rawArgs)
54
+
55
+ const resolved = resolveUser(ctx as Record<string, unknown>, cfg)
56
+ const userId = resolved?.userId
57
+
58
+ // Build scope filter: intersect requested scope (if any) with caller's canRead.
59
+ let filter: Record<string, unknown> | undefined
60
+ if (cfg.multiUser?.scoping) {
61
+ const canRead = getCanReadScopes(resolved, cfg)
62
+ const allowed: CanReadScope[] = requestedScope
63
+ ? canRead.includes("*") || canRead.includes(requestedScope)
64
+ ? [requestedScope]
65
+ : []
66
+ : canRead
67
+ filter = buildScopeFilter(allowed, resolved?.userId ?? "")
33
68
  }
34
69
 
35
- log.debug(`/getcontext command: "${query}"`)
70
+ log.debug(
71
+ `/getcontext command: "${query}" userId=${userId} scope=${requestedScope ?? "any"}`,
72
+ )
36
73
 
37
74
  try {
38
- const results = await client.search(query, { limit: 5 })
75
+ const results = await client.search(query, { limit: 5, userId, filter })
39
76
 
40
77
  if (results.length === 0) {
41
78
  return { text: `No memories found for: "${query}"` }
@@ -63,21 +100,61 @@ export function registerCommands(
63
100
  description: "Save something to memory",
64
101
  acceptsArgs: true,
65
102
  requireAuth: true,
66
- handler: async (ctx: { args?: string }) => {
67
- const text = ctx.args?.trim()
68
- if (!text) {
69
- return { text: "Usage: /remember <text to remember>" }
103
+ handler: async (ctx: { args?: string; senderId?: string; channel?: string }) => {
104
+ const rawArgs = ctx.args?.trim()
105
+ if (!rawArgs) {
106
+ return { text: "Usage: /remember [#scope] <text to remember>" }
107
+ }
108
+ const { scope: requestedScope, rest: text } = parseScopePrefix(rawArgs)
109
+
110
+ const resolved = resolveUser(ctx as Record<string, unknown>, cfg)
111
+ const scopingEnabled = !!cfg.multiUser?.scoping
112
+ const availableScopes = cfg.multiUser?.scoping?.scopes ?? []
113
+
114
+ // Scope resolution: prefix > role default > global default > "private"
115
+ const scope = scopingEnabled
116
+ ? (requestedScope ?? getDefaultWriteScope(resolved, cfg))
117
+ : "private"
118
+
119
+ // Validate scope is in declared vocabulary
120
+ if (
121
+ scopingEnabled &&
122
+ requestedScope &&
123
+ availableScopes.length > 0 &&
124
+ !availableScopes.includes(scope)
125
+ ) {
126
+ return {
127
+ text: `Unknown scope "${scope}". Available: ${availableScopes.join(", ")}.`,
128
+ }
70
129
  }
71
130
 
72
- log.debug(`/remember command: "${truncate(text, 50)}"`)
131
+ // canWriteScopes enforcement
132
+ if (scopingEnabled) {
133
+ const role = resolveRole(resolved, cfg)
134
+ if (role?.canWriteScopes && !role.canWriteScopes.includes(scope)) {
135
+ return { text: `You cannot write to scope "${scope}".` }
136
+ }
137
+ }
138
+
139
+ const { userId, collection } = scopingEnabled
140
+ ? routeWrite(resolved, scope, cfg)
141
+ : { userId: resolved?.userId, collection: undefined }
142
+
143
+ log.debug(
144
+ `/remember command: "${truncate(text, 50)}" userId=${userId} scope=${scope}`,
145
+ )
73
146
 
74
147
  try {
75
148
  await client.addMemory(text, {
76
149
  metadata: { source: "openclaw_command" },
150
+ collection,
151
+ userId,
152
+ scope: scopingEnabled ? scope : undefined,
77
153
  })
78
154
 
79
155
  const preview = truncate(text, 60)
80
- return { text: `Remembered: "${preview}"` }
156
+ const scopeHint = scopingEnabled ? ` [${scope}]` : ""
157
+ return { text: `Remembered${scopeHint}: "${preview}"` }
81
158
  } catch (err) {
82
159
  log.error("/remember failed", err)
83
160
  return { text: "Failed to save memory. Check logs for details." }
@@ -96,7 +173,9 @@ export function registerCommands(
96
173
 
97
174
  try {
98
175
  const workspaceDir = getWorkspaceDir()
99
- const result = await syncAllMemoryFiles(client, workspaceDir)
176
+ const result = await syncAllMemoryFiles(client, workspaceDir, {
177
+ userId: cfg.multiUser?.sharedUserId,
178
+ })
100
179
 
101
180
  if (result.synced === 0 && result.failed === 0) {
102
181
  return { text: "No memory files found in memory/ directory." }