@hyperspell/openclaw-hyperspell 0.1.0 → 0.2.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/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # OpenClaw Hyperspell Plugin
2
2
 
3
+ ![Hyperspell x OpenClaw](https://github.com/user-attachments/assets/5ac86aab-0f0f-4f14-bde5-0652e625aa86)
4
+
3
5
  OpenClaw plugin for [Hyperspell](https://hyperspell.com) - Context and memory for your AI agents.
4
6
 
5
7
  ## Installation
@@ -8,19 +10,40 @@ OpenClaw plugin for [Hyperspell](https://hyperspell.com) - Context and memory fo
8
10
  openclaw plugins install @hyperspell/openclaw-hyperspell
9
11
  ```
10
12
 
11
- ## Configuration
13
+ ## Quick Start
14
+
15
+ Run the interactive setup wizard:
16
+
17
+ ```bash
18
+ openclaw openclaw-hyperspell setup
19
+ ```
20
+
21
+ The setup wizard will guide you through:
22
+ 1. Creating a Hyperspell account (if you don't have one)
23
+ 2. Configuring your API key
24
+ 3. Setting up your User ID for multi-tenant memory
25
+ 4. Connecting your apps (Notion, Slack, Google Drive, etc.)
26
+
27
+ ## Manual Configuration
12
28
 
13
- Add to your `openclaw.json`:
29
+ Alternatively, add to your `openclaw.json`:
14
30
 
15
31
  ```json
16
32
  {
17
33
  "plugins": {
18
- "openclaw-hyperspell": {
19
- "apiKey": "${HYPERSPELL_API_KEY}",
20
- "autoContext": true
34
+ "entries": {
35
+ "openclaw-hyperspell": {
36
+ "enabled": true,
37
+ "config": {
38
+ "apiKey": "${HYPERSPELL_API_KEY}",
39
+ "userId": "your-email",
40
+ "autoContext": true
41
+ }
42
+ }
21
43
  }
22
44
  }
23
45
  }
46
+
24
47
  ```
25
48
 
26
49
  Or set the environment variable:
@@ -29,12 +52,26 @@ Or set the environment variable:
29
52
  export HYPERSPELL_API_KEY=hs_...
30
53
  ```
31
54
 
55
+ ## CLI Commands
56
+
57
+ ### `openclaw openclaw-hyperspell setup`
58
+
59
+ Interactive setup wizard that walks you through configuration.
60
+
61
+ ### `openclaw openclaw-hyperspell status`
62
+
63
+ Check your current configuration and connection status.
64
+
65
+ ### `openclaw openclaw-hyperspell connect`
66
+
67
+ Open the Hyperspell connect page to link your accounts (Notion, Slack, Google Drive, etc.)
68
+
32
69
  ### Options
33
70
 
34
71
  | Option | Type | Default | Description |
35
72
  |--------|------|---------|-------------|
36
73
  | `apiKey` | string | `${HYPERSPELL_API_KEY}` | Hyperspell API key |
37
- | `userId` | string | - | User ID to scope searches (for non-JWT API keys) |
74
+ | `userId` | string | - | User ID (can be your email) |
38
75
  | `autoContext` | boolean | `true` | Auto-inject relevant memories before each AI turn |
39
76
  | `sources` | string | - | Comma-separated sources to search (e.g., `notion,slack`) |
40
77
  | `maxResults` | number | `10` | Maximum memories per context injection |
@@ -42,22 +79,12 @@ export HYPERSPELL_API_KEY=hs_...
42
79
 
43
80
  ## Slash Commands
44
81
 
45
- ### `/context <query>`
82
+ ### `/getcontext <query>`
46
83
 
47
84
  Search your memories for relevant context.
48
85
 
49
86
  ```
50
- /context Q1 budget planning
51
- ```
52
-
53
- ### `/connect <source>`
54
-
55
- Connect an account to Hyperspell. Opens the OAuth flow in your browser.
56
-
57
- ```
58
- /connect notion
59
- /connect slack
60
- /connect google_drive
87
+ /getcontext Q1 budget planning
61
88
  ```
62
89
 
63
90
  ### `/remember <text>`
@@ -87,12 +114,11 @@ This ensures the AI always has access to relevant information from your connecte
87
114
 
88
115
  ## Available Sources
89
116
 
90
- - `collections` - User-created collections
117
+ - `vault` - User-created memories
91
118
  - `notion` - Notion pages and databases
92
119
  - `slack` - Slack messages
93
120
  - `google_calendar` - Google Calendar events
94
121
  - `google_mail` - Gmail messages
95
122
  - `google_drive` - Google Drive files
96
123
  - `box` - Box files
97
- - `vault` - Vault documents
98
124
  - `web_crawler` - Crawled web pages
package/client.ts CHANGED
@@ -11,6 +11,11 @@ export type SearchResult = {
11
11
  createdAt: string | null
12
12
  }
13
13
 
14
+ export type SearchWithAnswerResult = {
15
+ answer: string | null
16
+ documents: SearchResult[]
17
+ }
18
+
14
19
  export type Integration = {
15
20
  id: string
16
21
  name: string
@@ -18,6 +23,13 @@ export type Integration = {
18
23
  icon: string
19
24
  }
20
25
 
26
+ export type Connection = {
27
+ id: string
28
+ integrationId: string
29
+ label: string | null
30
+ provider: HyperspellSource
31
+ }
32
+
21
33
  export class HyperspellClient {
22
34
  private client: Hyperspell
23
35
  private config: HyperspellConfig
@@ -26,8 +38,9 @@ export class HyperspellClient {
26
38
  this.config = config
27
39
  this.client = new Hyperspell({
28
40
  apiKey: config.apiKey,
41
+ userID: config.userId,
29
42
  })
30
- log.info("client initialized")
43
+ log.info(`client initialized${config.userId ? ` for user ${config.userId}` : ""}`)
31
44
  }
32
45
 
33
46
  async search(
@@ -61,6 +74,68 @@ export class HyperspellClient {
61
74
  return results
62
75
  }
63
76
 
77
+ async searchRaw(
78
+ query: string,
79
+ options?: { limit?: number; sources?: HyperspellSource[] },
80
+ ): Promise<Record<string, unknown>> {
81
+ const limit = options?.limit ?? this.config.maxResults
82
+ const sources =
83
+ options?.sources ?? (this.config.sources.length > 0 ? this.config.sources : undefined)
84
+
85
+ log.debugRequest("memories.search (raw)", { query, limit, sources })
86
+
87
+ const response = await this.client.memories.search({
88
+ query,
89
+ sources,
90
+ options: {
91
+ max_results: limit,
92
+ },
93
+ })
94
+
95
+ log.debugResponse("memories.search (raw)", { count: response.documents.length })
96
+
97
+ return response as unknown as Record<string, unknown>
98
+ }
99
+
100
+ async searchWithAnswer(
101
+ query: string,
102
+ options?: { limit?: number; sources?: HyperspellSource[] },
103
+ ): Promise<SearchWithAnswerResult> {
104
+ const limit = options?.limit ?? this.config.maxResults
105
+ const sources =
106
+ options?.sources ?? (this.config.sources.length > 0 ? this.config.sources : undefined)
107
+
108
+ log.debugRequest("memories.search (with answer)", { query, limit, sources })
109
+
110
+ const response = await this.client.memories.search({
111
+ query,
112
+ sources,
113
+ answer: true,
114
+ options: {
115
+ max_results: limit,
116
+ },
117
+ })
118
+
119
+ const documents: SearchResult[] = response.documents.map((doc) => ({
120
+ resourceId: doc.resource_id,
121
+ title: doc.title ?? null,
122
+ source: doc.source as HyperspellSource,
123
+ score: doc.score ?? null,
124
+ url: doc.metadata?.url as string | null ?? null,
125
+ createdAt: doc.metadata?.created_at as string | null ?? null,
126
+ }))
127
+
128
+ log.debugResponse("memories.search (with answer)", {
129
+ count: documents.length,
130
+ hasAnswer: !!response.answer,
131
+ })
132
+
133
+ return {
134
+ answer: response.answer ?? null,
135
+ documents,
136
+ }
137
+ }
138
+
64
139
  async addMemory(
65
140
  text: string,
66
141
  options?: { title?: string; metadata?: Record<string, string | number | boolean> },
@@ -110,4 +185,20 @@ export class HyperspellClient {
110
185
  expiresAt: response.expires_at,
111
186
  }
112
187
  }
188
+
189
+ async listConnections(): Promise<Connection[]> {
190
+ log.debugRequest("connections.list", {})
191
+
192
+ const response = await this.client.connections.list()
193
+
194
+ const connections: Connection[] = response.connections.map((conn) => ({
195
+ id: conn.id,
196
+ integrationId: conn.integration_id,
197
+ label: conn.label,
198
+ provider: conn.provider as HyperspellSource,
199
+ }))
200
+
201
+ log.debugResponse("connections.list", { count: connections.length })
202
+ return connections
203
+ }
113
204
  }
@@ -0,0 +1,470 @@
1
+ import { exec } from "node:child_process"
2
+ import * as fs from "node:fs"
3
+ import * as path from "node:path"
4
+ import { homedir, platform, userInfo } from "node:os"
5
+ import * as p from "@clack/prompts"
6
+ import type { Command } from "commander"
7
+ import Hyperspell from "hyperspell"
8
+
9
+ /**
10
+ * Resolve OpenClaw state directory, matching OpenClaw's logic.
11
+ * Checks OPENCLAW_STATE_DIR env var, falls back to ~/.openclaw
12
+ */
13
+ function resolveStateDir(): string {
14
+ const override = process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim()
15
+ if (override) {
16
+ return override.startsWith("~")
17
+ ? override.replace(/^~(?=$|[\\/])/, homedir())
18
+ : path.resolve(override)
19
+ }
20
+ return path.join(homedir(), ".openclaw")
21
+ }
22
+
23
+ /**
24
+ * Resolve OpenClaw config path, matching OpenClaw's logic.
25
+ * Checks OPENCLAW_CONFIG_PATH env var, falls back to $STATE_DIR/openclaw.json
26
+ */
27
+ function resolveConfigPath(): string {
28
+ const override = process.env.OPENCLAW_CONFIG_PATH?.trim() || process.env.CLAWDBOT_CONFIG_PATH?.trim()
29
+ if (override) {
30
+ return override.startsWith("~")
31
+ ? override.replace(/^~(?=$|[\\/])/, homedir())
32
+ : path.resolve(override)
33
+ }
34
+ return path.join(resolveStateDir(), "openclaw.json")
35
+ }
36
+
37
+ async function fetchConnectionSources(client: Hyperspell, userId: string): Promise<string[]> {
38
+ try {
39
+ const userClient = new Hyperspell({
40
+ apiKey: client.apiKey,
41
+ userID: userId,
42
+ })
43
+ const response = await userClient.connections.list()
44
+ const providers = response.connections.map((conn) => conn.provider)
45
+ // Add vault and deduplicate
46
+ const sources = [...new Set(["vault", ...providers])]
47
+ return sources
48
+ } catch (_error) {
49
+ return ["vault"]
50
+ }
51
+ }
52
+
53
+ function updateConfigSources(configPath: string, sources: string[]): void {
54
+ if (!fs.existsSync(configPath)) return
55
+
56
+ const content = fs.readFileSync(configPath, "utf-8")
57
+ const config = JSON.parse(content)
58
+
59
+ const pluginConfig = config?.plugins?.entries?.["openclaw-hyperspell"]?.config
60
+ if (pluginConfig) {
61
+ pluginConfig.sources = sources.join(",")
62
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n")
63
+ }
64
+ }
65
+
66
+ function openUrl(url: string): Promise<void> {
67
+ return new Promise((resolve, reject) => {
68
+ let command: string
69
+ switch (platform()) {
70
+ case "darwin":
71
+ command = `open "${url}"`
72
+ break
73
+ case "win32":
74
+ command = `start "" "${url}"`
75
+ break
76
+ default:
77
+ command = `xdg-open "${url}"`
78
+ }
79
+ exec(command, (error) => {
80
+ if (error) reject(error)
81
+ else resolve()
82
+ })
83
+ })
84
+ }
85
+
86
+ async function runSetup(): Promise<void> {
87
+ p.intro("Hyperspell Setup")
88
+
89
+ // Step 1: Check if they have an account
90
+ const hasAccount = await p.confirm({
91
+ message: "Do you already have a Hyperspell account?",
92
+ })
93
+
94
+ if (p.isCancel(hasAccount)) {
95
+ p.cancel("Setup cancelled")
96
+ return
97
+ }
98
+
99
+ if (!hasAccount) {
100
+ p.note(
101
+ "1. Go to https://app.hyperspell.com to create a free account\n" +
102
+ "2. Create a new app for your AI agent\n" +
103
+ "3. Select which integrations you want to connect (Notion, Slack, etc.)",
104
+ "Create an account",
105
+ )
106
+
107
+ const openSignup = await p.confirm({
108
+ message: "Open app.hyperspell.com in your browser?",
109
+ })
110
+
111
+ if (p.isCancel(openSignup)) {
112
+ p.cancel("Setup cancelled")
113
+ return
114
+ }
115
+
116
+ if (openSignup) {
117
+ await openUrl("https://app.hyperspell.com")
118
+ p.log.info("Browser opened. Come back when you've created your account.")
119
+ }
120
+
121
+ await p.confirm({
122
+ message: "Ready to continue?",
123
+ active: "Yes",
124
+ inactive: "No",
125
+ })
126
+ }
127
+
128
+ // Step 2: Get API Key
129
+ p.note(
130
+ "1. Go to your app in https://app.hyperspell.com\n" +
131
+ "2. Navigate to Settings > API Keys\n" +
132
+ "3. Create a new API key",
133
+ "API Key",
134
+ )
135
+
136
+ const apiKey = await p.text({
137
+ message: "Paste your API key",
138
+ placeholder: "hs_...",
139
+ validate: (value) => {
140
+ if (!value) return "API key is required"
141
+ },
142
+ })
143
+
144
+ if (p.isCancel(apiKey)) {
145
+ p.cancel("Setup cancelled")
146
+ return
147
+ }
148
+
149
+ // Validate API key
150
+ const s = p.spinner()
151
+ s.start("Validating API key")
152
+
153
+ let client: Hyperspell
154
+ try {
155
+ client = new Hyperspell({ apiKey })
156
+ await client.integrations.list()
157
+ s.stop("API key is valid")
158
+ } catch (_error) {
159
+ s.stop("API key validation failed")
160
+ p.log.error("Please check that your API key is correct and try again.")
161
+ return
162
+ }
163
+
164
+ // Step 3: User ID
165
+ p.note(
166
+ "Hyperspell is a multi-tenant memory platform. Each user's memories\n" +
167
+ "are stored separately, identified by a User ID.\n\n" +
168
+ "For a personal agent, use your email address or username.",
169
+ "User ID",
170
+ )
171
+
172
+ const systemUser = userInfo().username
173
+ const userId = await p.text({
174
+ message: "Enter a User ID for this agent",
175
+ placeholder: systemUser || "your-email@example.com",
176
+ defaultValue: systemUser,
177
+ })
178
+
179
+ if (p.isCancel(userId)) {
180
+ p.cancel("Setup cancelled")
181
+ return
182
+ }
183
+
184
+ // Step 4: List and connect integrations
185
+ let integrations: Awaited<ReturnType<typeof client.integrations.list>>
186
+ try {
187
+ integrations = await client.integrations.list()
188
+ } catch (_error) {
189
+ integrations = { integrations: [] }
190
+ }
191
+
192
+ if (integrations.integrations.length === 0) {
193
+ p.note(
194
+ "No integrations are configured in your app yet.\n" +
195
+ "Go to https://app.hyperspell.com to add integrations,\n" +
196
+ "then use /connect <source> to connect them.",
197
+ "Connect Your Apps",
198
+ )
199
+ } else {
200
+ const integrationList = integrations.integrations
201
+ .map((int) => `• ${int.name} (${int.provider})`)
202
+ .join("\n")
203
+
204
+ // Get a user token for the connect page
205
+ let connectUrl: string
206
+ try {
207
+ const tokenResponse = await client.auth.userToken({ user_id: userId })
208
+ connectUrl = `https://connect.hyperspell.com?token=${tokenResponse.token}`
209
+ } catch (_error) {
210
+ p.log.error("Could not generate connect URL. You can connect apps later using /connect.")
211
+ connectUrl = ""
212
+ }
213
+
214
+ p.note(
215
+ `Available integrations:\n${integrationList}` +
216
+ (connectUrl ? `\n\nConnect your accounts at:\n${connectUrl}` : ""),
217
+ "Connect Your Apps",
218
+ )
219
+
220
+ if (connectUrl) {
221
+ const openConnect = await p.confirm({
222
+ message: "Open connection page in your browser?",
223
+ })
224
+
225
+ if (!p.isCancel(openConnect) && openConnect) {
226
+ await openUrl(connectUrl)
227
+ p.log.info("Browser opened. Connect your accounts and come back when done.")
228
+
229
+ await p.confirm({
230
+ message: "Finished connecting accounts?",
231
+ active: "Yes",
232
+ inactive: "Not yet",
233
+ })
234
+ }
235
+ }
236
+ }
237
+
238
+ // Fetch connected sources
239
+ const s1 = p.spinner()
240
+ s1.start("Fetching connected sources")
241
+ const sources = await fetchConnectionSources(client, userId)
242
+ s1.stop(`Found ${sources.length} sources: ${sources.join(", ")}`)
243
+
244
+ // Step 5: Save configuration
245
+ const s2 = p.spinner()
246
+ s2.start("Saving configuration")
247
+
248
+ try {
249
+ const configPath = resolveConfigPath()
250
+ const openclawDir = path.dirname(configPath)
251
+ const envPath = path.join(openclawDir, ".env")
252
+
253
+ // Ensure directory exists
254
+ if (!fs.existsSync(openclawDir)) {
255
+ fs.mkdirSync(openclawDir, { recursive: true })
256
+ }
257
+
258
+ // Read existing config or create new one
259
+ let config: Record<string, unknown> = {}
260
+ if (fs.existsSync(configPath)) {
261
+ const existing = fs.readFileSync(configPath, "utf-8")
262
+ config = JSON.parse(existing)
263
+ }
264
+
265
+ // Merge in plugin configuration
266
+ if (!config.plugins) {
267
+ config.plugins = {}
268
+ }
269
+ const plugins = config.plugins as Record<string, unknown>
270
+ if (!plugins.entries) {
271
+ plugins.entries = {}
272
+ }
273
+ const entries = plugins.entries as Record<string, unknown>
274
+ entries["openclaw-hyperspell"] = {
275
+ enabled: true,
276
+ config: {
277
+ apiKey: "${HYPERSPELL_API_KEY}",
278
+ userId,
279
+ sources: sources.join(","),
280
+ autoContext: true,
281
+ },
282
+ }
283
+
284
+ // Write config
285
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n")
286
+
287
+ // Write or append to .env
288
+ const envLine = `HYPERSPELL_API_KEY=${apiKey}`
289
+ if (fs.existsSync(envPath)) {
290
+ const envContent = fs.readFileSync(envPath, "utf-8")
291
+ if (envContent.includes("HYPERSPELL_API_KEY=")) {
292
+ // Replace existing line
293
+ const updated = envContent.replace(/^HYPERSPELL_API_KEY=.*$/m, envLine)
294
+ fs.writeFileSync(envPath, updated)
295
+ } else {
296
+ // Append new line
297
+ fs.appendFileSync(envPath, (envContent.endsWith("\n") ? "" : "\n") + envLine + "\n")
298
+ }
299
+ } else {
300
+ fs.writeFileSync(envPath, envLine + "\n")
301
+ }
302
+
303
+ s2.stop("Configuration saved")
304
+
305
+ p.note(
306
+ `Config: ${configPath}\n` +
307
+ `API Key: ${envPath}`,
308
+ "Files updated",
309
+ )
310
+ } catch (error) {
311
+ s2.stop("Failed to save configuration")
312
+
313
+ // Fall back to showing manual instructions
314
+ const configJson = JSON.stringify(
315
+ {
316
+ plugins: {
317
+ entries: {
318
+ "openclaw-hyperspell": {
319
+ enabled: true,
320
+ config: {
321
+ apiKey: "${HYPERSPELL_API_KEY}",
322
+ userId,
323
+ sources: sources.join(","),
324
+ autoContext: true,
325
+ },
326
+ },
327
+ },
328
+ },
329
+ },
330
+ null,
331
+ 2,
332
+ )
333
+
334
+ p.note(
335
+ `Add to your openclaw.json:\n\n${configJson}\n\n` +
336
+ `Set the environment variable:\n export HYPERSPELL_API_KEY=${apiKey}`,
337
+ "Manual configuration required",
338
+ )
339
+ }
340
+
341
+ p.note(
342
+ "/getcontext <query> Search your memories for relevant context\n" +
343
+ "/remember <text> Save something directly to your vault\n\n" +
344
+ "To connect more apps, run: openclaw openclaw-hyperspell connect\n\n" +
345
+ "Auto-context is enabled by default — relevant memories are\n" +
346
+ "automatically injected before each AI response.",
347
+ "How to use Hyperspell",
348
+ )
349
+
350
+ p.outro("Setup complete!")
351
+ }
352
+
353
+ async function runConnect(pluginConfig: unknown): Promise<void> {
354
+ const config = pluginConfig as Record<string, unknown> | undefined
355
+
356
+ p.intro("Hyperspell Connect")
357
+
358
+ if (!config?.apiKey) {
359
+ p.log.error("Not configured")
360
+ p.note("Run 'openclaw openclaw-hyperspell setup' to configure Hyperspell first.")
361
+ p.outro("")
362
+ return
363
+ }
364
+
365
+ const s = p.spinner()
366
+ s.start("Generating connect URL")
367
+
368
+ let client: Hyperspell
369
+ let userId: string
370
+ try {
371
+ client = new Hyperspell({ apiKey: config.apiKey as string })
372
+ userId = (config.userId as string) || userInfo().username || "user"
373
+ const tokenResponse = await client.auth.userToken({ user_id: userId })
374
+ const connectUrl = `https://connect.hyperspell.com?token=${tokenResponse.token}`
375
+
376
+ s.stop("Connect URL ready")
377
+
378
+ await openUrl(connectUrl)
379
+ p.log.success("Browser opened to connect.hyperspell.com")
380
+
381
+ await p.confirm({
382
+ message: "Finished connecting accounts?",
383
+ active: "Yes",
384
+ inactive: "Not yet",
385
+ })
386
+
387
+ // Fetch and update sources
388
+ const s2 = p.spinner()
389
+ s2.start("Updating sources configuration")
390
+
391
+ const sources = await fetchConnectionSources(client, userId)
392
+ const configPath = resolveConfigPath()
393
+ updateConfigSources(configPath, sources)
394
+
395
+ s2.stop(`Sources updated: ${sources.join(", ")}`)
396
+
397
+ p.outro("Done! Restart OpenClaw to apply changes.")
398
+ } catch (_error) {
399
+ s.stop("Failed to generate connect URL")
400
+ p.log.error("Check your API key and try again.")
401
+ p.outro("")
402
+ }
403
+ }
404
+
405
+ async function runStatus(pluginConfig: unknown): Promise<void> {
406
+ const config = pluginConfig as Record<string, unknown> | undefined
407
+
408
+ p.intro("Hyperspell Status")
409
+
410
+ if (!config?.apiKey) {
411
+ p.log.warn("Not configured")
412
+ p.note("Run 'openclaw openclaw-hyperspell setup' to configure Hyperspell.")
413
+ p.outro("")
414
+ return
415
+ }
416
+
417
+ p.log.success("Configured")
418
+ p.log.info(`User ID: ${config.userId || "(not set)"}`)
419
+ p.log.info(`Auto-Context: ${config.autoContext !== false ? "Enabled" : "Disabled"}`)
420
+ p.log.info(`Sources Filter: ${config.sources || "(all sources)"}`)
421
+ p.log.info(`Max Results: ${config.maxResults || 10}`)
422
+
423
+ const s = p.spinner()
424
+ s.start("Testing connection")
425
+
426
+ try {
427
+ const client = new Hyperspell({ apiKey: config.apiKey as string })
428
+ const integrations = await client.integrations.list()
429
+ s.stop(`Connection OK (${integrations.integrations.length} integrations available)`)
430
+
431
+ if (integrations.integrations.length > 0) {
432
+ const list = integrations.integrations
433
+ .map((int) => `• ${int.name} (${int.provider})`)
434
+ .join("\n")
435
+ p.note(list, "Available integrations")
436
+ }
437
+ } catch (_error) {
438
+ s.stop("Connection failed")
439
+ p.log.error("Check your API key and try again.")
440
+ }
441
+
442
+ p.outro("")
443
+ }
444
+
445
+ export function registerCliCommands(program: Command, pluginConfig: unknown): void {
446
+ const hyperspellCmd = program
447
+ .command("openclaw-hyperspell")
448
+ .description("Hyperspell — Memory and context for your AI agent")
449
+
450
+ hyperspellCmd
451
+ .command("setup")
452
+ .description("Interactive setup wizard for Hyperspell")
453
+ .action(async () => {
454
+ await runSetup()
455
+ })
456
+
457
+ hyperspellCmd
458
+ .command("status")
459
+ .description("Show Hyperspell connection status")
460
+ .action(async () => {
461
+ await runStatus(pluginConfig)
462
+ })
463
+
464
+ hyperspellCmd
465
+ .command("connect")
466
+ .description("Open the Hyperspell connect page to link your accounts")
467
+ .action(async () => {
468
+ await runConnect(pluginConfig)
469
+ })
470
+ }
package/commands/slash.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk"
2
2
  import type { HyperspellClient } from "../client.ts"
3
3
  import type { HyperspellConfig } from "../config.ts"
4
- import { openInBrowser } from "../lib/browser.ts"
5
4
  import { log } from "../logger.ts"
6
5
 
7
6
  function truncate(text: string, maxLength: number): string {
@@ -19,19 +18,19 @@ export function registerCommands(
19
18
  client: HyperspellClient,
20
19
  _cfg: HyperspellConfig,
21
20
  ): void {
22
- // /context <query> - Search memories and show summaries
21
+ // /getcontext <query> - Search memories and show summaries
23
22
  api.registerCommand({
24
- name: "context",
23
+ name: "getcontext",
25
24
  description: "Search your memories for relevant context",
26
25
  acceptsArgs: true,
27
26
  requireAuth: true,
28
27
  handler: async (ctx: { args?: string }) => {
29
28
  const query = ctx.args?.trim()
30
29
  if (!query) {
31
- return { text: "Usage: /context <search query>" }
30
+ return { text: "Usage: /getcontext <search query>" }
32
31
  }
33
32
 
34
- log.debug(`/context command: "${query}"`)
33
+ log.debug(`/getcontext command: "${query}"`)
35
34
 
36
35
  try {
37
36
  const results = await client.search(query, { limit: 5 })
@@ -50,65 +49,12 @@ export function registerCommands(
50
49
  text: `Found ${results.length} memories:\n\n${lines.join("\n")}`,
51
50
  }
52
51
  } catch (err) {
53
- log.error("/context failed", err)
52
+ log.error("/getcontext failed", err)
54
53
  return { text: "Failed to search memories. Check logs for details." }
55
54
  }
56
55
  },
57
56
  })
58
57
 
59
- // /connect <source> - Open connection URL for an integration
60
- api.registerCommand({
61
- name: "connect",
62
- description: "Connect an account to Hyperspell",
63
- acceptsArgs: true,
64
- requireAuth: true,
65
- handler: async (ctx: { args?: string }) => {
66
- const source = ctx.args?.trim().toLowerCase()
67
- if (!source) {
68
- return { text: "Usage: /connect <source>\n\nExamples: /connect notion, /connect slack" }
69
- }
70
-
71
- log.debug(`/connect command: "${source}"`)
72
-
73
- try {
74
- const integrations = await client.listIntegrations()
75
-
76
- // Find matching integration by provider or name
77
- const integration = integrations.find(
78
- (int) =>
79
- int.provider.toLowerCase() === source ||
80
- int.name.toLowerCase() === source ||
81
- int.id.toLowerCase() === source,
82
- )
83
-
84
- if (!integration) {
85
- const available = integrations.map((i) => i.provider).join(", ")
86
- return {
87
- text: `Integration "${source}" not found.\n\nAvailable: ${available}`,
88
- }
89
- }
90
-
91
- const { url } = await client.getConnectUrl(integration.id)
92
-
93
- // Auto-open in browser
94
- try {
95
- await openInBrowser(url)
96
- return {
97
- text: `Opening ${integration.name} connection in your browser...`,
98
- }
99
- } catch {
100
- // Fall back to showing the URL if browser open fails
101
- return {
102
- text: `Connect your ${integration.name} account:\n${url}`,
103
- }
104
- }
105
- } catch (err) {
106
- log.error("/connect failed", err)
107
- return { text: "Failed to get connect URL. Check logs for details." }
108
- }
109
- },
110
- })
111
-
112
58
  // /remember <text> - Add a new memory
113
59
  api.registerCommand({
114
60
  name: "remember",
package/config.ts CHANGED
@@ -62,8 +62,30 @@ function resolveEnvVars(value: string): string {
62
62
  })
63
63
  }
64
64
 
65
- function parseSources(raw: string | undefined): HyperspellSource[] {
66
- if (!raw || raw.trim() === "") {
65
+ function parseSources(raw: string | string[] | undefined): HyperspellSource[] {
66
+ if (!raw) {
67
+ return []
68
+ }
69
+
70
+ // Handle array input
71
+ if (Array.isArray(raw)) {
72
+ const sources = raw
73
+ .map((s) => String(s).trim().toLowerCase())
74
+ .filter((s) => s.length > 0) as HyperspellSource[]
75
+
76
+ for (const source of sources) {
77
+ if (!VALID_SOURCES.includes(source)) {
78
+ throw new Error(
79
+ `Invalid source "${source}". Valid sources: ${VALID_SOURCES.join(", ")}`,
80
+ )
81
+ }
82
+ }
83
+
84
+ return sources
85
+ }
86
+
87
+ // Handle string input (comma-separated)
88
+ if (typeof raw === "string" && raw.trim() === "") {
67
89
  return []
68
90
  }
69
91
 
@@ -108,7 +130,7 @@ export function parseConfig(raw: unknown): HyperspellConfig {
108
130
  apiKey,
109
131
  userId: cfg.userId as string | undefined,
110
132
  autoContext: (cfg.autoContext as boolean) ?? true,
111
- sources: parseSources(cfg.sources as string | undefined),
133
+ sources: parseSources(cfg.sources as string | string[] | undefined),
112
134
  maxResults: (cfg.maxResults as number) ?? 10,
113
135
  debug: (cfg.debug as boolean) ?? false,
114
136
  }
package/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk"
2
2
  import { HyperspellClient } from "./client.ts"
3
3
  import { registerCommands } from "./commands/slash.ts"
4
+ import { registerCliCommands } from "./commands/setup.ts"
4
5
  import { parseConfig, hyperspellConfigSchema } from "./config.ts"
5
6
  import { buildAutoContextHandler } from "./hooks/auto-context.ts"
6
7
  import { initLogger } from "./logger.ts"
@@ -10,11 +11,47 @@ import { registerSearchTool } from "./tools/search.ts"
10
11
  export default {
11
12
  id: "openclaw-hyperspell",
12
13
  name: "Hyperspell",
13
- description: "OpenClaw powered by Hyperspell - RAG-as-a-service for your connected sources",
14
+ description: "Hyperspell gives your Molty context and memory from all your existing data",
14
15
  kind: "memory" as const,
15
16
  configSchema: hyperspellConfigSchema,
16
17
 
17
18
  register(api: OpenClawPluginApi) {
19
+ // Register CLI commands (openclaw openclaw-hyperspell setup|status|connect)
20
+ api.registerCli(
21
+ (ctx) => {
22
+ registerCliCommands(ctx.program, api.pluginConfig)
23
+ },
24
+ { commands: ["openclaw-hyperspell"] },
25
+ )
26
+
27
+ // Check if configured
28
+ const rawConfig = api.pluginConfig as Record<string, unknown> | undefined
29
+ const hasConfig = rawConfig?.apiKey || process.env.HYPERSPELL_API_KEY
30
+
31
+ if (!hasConfig) {
32
+ api.logger.info("hyperspell: not configured - run 'openclaw openclaw-hyperspell setup'")
33
+ // Still register slash commands so they show up, but they'll return an error
34
+ api.registerCommand({
35
+ name: "getcontext",
36
+ description: "Search your memories for relevant context",
37
+ acceptsArgs: true,
38
+ requireAuth: false,
39
+ handler: async () => {
40
+ return { text: "Hyperspell not configured. Run 'openclaw openclaw-hyperspell setup' first." }
41
+ },
42
+ })
43
+ api.registerCommand({
44
+ name: "remember",
45
+ description: "Save something to memory",
46
+ acceptsArgs: true,
47
+ requireAuth: false,
48
+ handler: async () => {
49
+ return { text: "Hyperspell not configured. Run 'openclaw openclaw-hyperspell setup' first." }
50
+ },
51
+ })
52
+ return
53
+ }
54
+
18
55
  const cfg = parseConfig(api.pluginConfig)
19
56
 
20
57
  initLogger(api.logger, cfg.debug)
@@ -11,12 +11,13 @@
11
11
  "userId": {
12
12
  "label": "User ID",
13
13
  "placeholder": "user_123",
14
- "help": "Optional user ID to scope searches. Required for non-JWT API keys.",
15
- "advanced": true
14
+ "help": "User ID (can be your email)",
15
+ "advanced": false
16
16
  },
17
17
  "autoContext": {
18
18
  "label": "Auto-Context",
19
- "help": "Inject relevant memories before every AI turn"
19
+ "help": "Inject relevant memories before every AI turn",
20
+ "advanced": true
20
21
  },
21
22
  "sources": {
22
23
  "label": "Sources",
@@ -40,13 +41,27 @@
40
41
  "type": "object",
41
42
  "additionalProperties": false,
42
43
  "properties": {
43
- "apiKey": { "type": "string" },
44
- "userId": { "type": "string" },
45
- "autoContext": { "type": "boolean" },
46
- "sources": { "type": "string" },
47
- "maxResults": { "type": "number", "minimum": 1, "maximum": 20 },
48
- "debug": { "type": "boolean" }
44
+ "apiKey": {
45
+ "type": "string"
46
+ },
47
+ "userId": {
48
+ "type": "string"
49
+ },
50
+ "autoContext": {
51
+ "type": "boolean"
52
+ },
53
+ "sources": {
54
+ "type": "string"
55
+ },
56
+ "maxResults": {
57
+ "type": "number",
58
+ "minimum": 1,
59
+ "maximum": 20
60
+ },
61
+ "debug": {
62
+ "type": "boolean"
63
+ }
49
64
  },
50
65
  "required": []
51
66
  }
52
- }
67
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperspell/openclaw-hyperspell",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "OpenClaw Hyperspell memory plugin",
6
6
  "license": "MIT",
@@ -28,8 +28,9 @@
28
28
  "plugin"
29
29
  ],
30
30
  "dependencies": {
31
- "hyperspell": "^0.30.0",
32
- "@sinclair/typebox": "^0.34.0"
31
+ "@clack/prompts": "^1.0.0",
32
+ "@sinclair/typebox": "^0.34.0",
33
+ "hyperspell": "^0.30.0"
33
34
  },
34
35
  "peerDependencies": {
35
36
  "openclaw": ">=2026.1.29"
@@ -40,9 +41,11 @@
40
41
  "lint:fix": "bunx @biomejs/biome check --write ."
41
42
  },
42
43
  "openclaw": {
43
- "extensions": ["./index.ts"]
44
+ "extensions": [
45
+ "./index.ts"
46
+ ]
44
47
  },
45
48
  "devDependencies": {
46
49
  "typescript": "^5.9.3"
47
50
  }
48
- }
51
+ }
package/tools/remember.ts CHANGED
@@ -26,16 +26,28 @@ export function registerRememberTool(
26
26
  ) {
27
27
  log.debug(`remember tool: "${params.text.slice(0, 50)}..."`)
28
28
 
29
- await client.addMemory(params.text, {
30
- title: params.title,
31
- metadata: { source: "openclaw_tool" },
32
- })
29
+ try {
30
+ await client.addMemory(params.text, {
31
+ title: params.title,
32
+ metadata: { source: "openclaw_tool" },
33
+ })
33
34
 
34
- const preview =
35
- params.text.length > 80 ? `${params.text.slice(0, 80)}…` : params.text
35
+ const preview =
36
+ params.text.length > 80 ? `${params.text.slice(0, 80)}…` : params.text
36
37
 
37
- return {
38
- content: [{ type: "text" as const, text: `Stored: "${preview}"` }],
38
+ return {
39
+ content: [{ type: "text" as const, text: `Stored: "${preview}"` }],
40
+ }
41
+ } catch (err) {
42
+ log.error("remember tool failed", err)
43
+ return {
44
+ content: [
45
+ {
46
+ type: "text" as const,
47
+ text: `Failed to store memory: ${err instanceof Error ? err.message : String(err)}`,
48
+ },
49
+ ],
50
+ }
39
51
  }
40
52
  },
41
53
  },
package/tools/search.ts CHANGED
@@ -28,42 +28,62 @@ export function registerSearchTool(
28
28
  const limit = params.limit ?? 5
29
29
  log.debug(`search tool: query="${params.query}" limit=${limit}`)
30
30
 
31
- const results = await client.search(params.query, { limit })
31
+ try {
32
+ const response = await client.searchRaw(params.query, { limit })
33
+ const documents = (response.documents ?? []) as Array<{
34
+ source: string
35
+ resource_id: string
36
+ score?: number
37
+ summary?: string
38
+ title?: string
39
+ metadata?: Record<string, unknown>
40
+ highlights?: Array<{ text: string }>
41
+ data?: Array<{ text: string }>
42
+ }>
32
43
 
33
- if (results.length === 0) {
34
- return {
35
- content: [
36
- { type: "text" as const, text: "No relevant memories found." },
37
- ],
44
+ if (documents.length === 0) {
45
+ return {
46
+ content: [
47
+ { type: "text" as const, text: "No relevant memories found." },
48
+ ],
49
+ }
38
50
  }
39
- }
40
51
 
41
- const text = results
42
- .map((r, i) => {
43
- const title = r.title ?? `[${r.source}]`
44
- const score = r.score
45
- ? ` (${Math.round(r.score * 100)}%)`
46
- : ""
47
- return `${i + 1}. ${title}${score}`
48
- })
49
- .join("\n")
52
+ const formattedDocs = documents
53
+ .map((doc, i) => {
54
+ const relevance = doc.score
55
+ ? `${Math.round(doc.score * 100)}%`
56
+ : "N/A"
57
+ const title = doc.title || "(untitled)"
58
+ const summary = doc.summary || "(no summary)"
59
+ return `${i + 1}. Source: ${doc.source}\n Title: ${title}\n Summary: ${summary}\n Relevance: ${relevance}`
60
+ })
61
+ .join("\n\n")
50
62
 
51
- return {
52
- content: [
53
- {
54
- type: "text" as const,
55
- text: `Found ${results.length} memories:\n\n${text}`,
63
+ const text = `Found ${documents.length} memories:\n\n${formattedDocs}`
64
+
65
+ return {
66
+ content: [
67
+ {
68
+ type: "text" as const,
69
+ text,
70
+ },
71
+ ],
72
+ details: {
73
+ count: documents.length,
74
+ documents,
56
75
  },
57
- ],
58
- details: {
59
- count: results.length,
60
- memories: results.map((r) => ({
61
- resourceId: r.resourceId,
62
- title: r.title,
63
- source: r.source,
64
- score: r.score,
65
- })),
66
- },
76
+ }
77
+ } catch (err) {
78
+ log.error("search tool failed", err)
79
+ return {
80
+ content: [
81
+ {
82
+ type: "text" as const,
83
+ text: `Search failed: ${err instanceof Error ? err.message : String(err)}`,
84
+ },
85
+ ],
86
+ }
67
87
  }
68
88
  },
69
89
  },
@@ -1,4 +1,20 @@
1
1
  declare module "openclaw/plugin-sdk" {
2
+ import type { Command } from "commander"
3
+
4
+ export interface OpenClawPluginCliContext {
5
+ program: Command
6
+ config: unknown
7
+ workspaceDir?: string
8
+ logger: {
9
+ info: (message: string, ...args: unknown[]) => void
10
+ warn: (message: string, ...args: unknown[]) => void
11
+ error: (message: string, ...args: unknown[]) => void
12
+ debug: (message: string, ...args: unknown[]) => void
13
+ }
14
+ }
15
+
16
+ export type OpenClawPluginCliRegistrar = (ctx: OpenClawPluginCliContext) => void | Promise<void>
17
+
2
18
  export interface OpenClawPluginApi {
3
19
  pluginConfig: unknown
4
20
  logger: {
@@ -14,6 +30,7 @@ declare module "openclaw/plugin-sdk" {
14
30
  requireAuth: boolean
15
31
  handler: (ctx: { args?: string; senderId?: string; channel?: string }) => Promise<{ text: string }>
16
32
  }): void
33
+ registerCli(registrar: OpenClawPluginCliRegistrar, opts?: { commands?: string[] }): void
17
34
  registerTool<T = unknown>(
18
35
  options: {
19
36
  name: string