@hyperspell/openclaw-hyperspell 0.1.1 → 0.3.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.
@@ -0,0 +1,536 @@
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
+ import { syncAllMemoryFiles, getMemoryFiles } from "../sync/markdown.ts"
9
+ import { HyperspellClient } from "../client.ts"
10
+ import { getWorkspaceDir } from "../config.ts"
11
+
12
+ /**
13
+ * Resolve OpenClaw state directory, matching OpenClaw's logic.
14
+ * Checks OPENCLAW_STATE_DIR env var, falls back to ~/.openclaw
15
+ */
16
+ function resolveStateDir(): string {
17
+ const override = process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim()
18
+ if (override) {
19
+ return override.startsWith("~")
20
+ ? override.replace(/^~(?=$|[\\/])/, homedir())
21
+ : path.resolve(override)
22
+ }
23
+ return path.join(homedir(), ".openclaw")
24
+ }
25
+
26
+ /**
27
+ * Resolve OpenClaw config path, matching OpenClaw's logic.
28
+ * Checks OPENCLAW_CONFIG_PATH env var, falls back to $STATE_DIR/openclaw.json
29
+ */
30
+ function resolveConfigPath(): string {
31
+ const override = process.env.OPENCLAW_CONFIG_PATH?.trim() || process.env.CLAWDBOT_CONFIG_PATH?.trim()
32
+ if (override) {
33
+ return override.startsWith("~")
34
+ ? override.replace(/^~(?=$|[\\/])/, homedir())
35
+ : path.resolve(override)
36
+ }
37
+ return path.join(resolveStateDir(), "openclaw.json")
38
+ }
39
+
40
+ async function fetchConnectionSources(client: Hyperspell, userId: string): Promise<string[]> {
41
+ try {
42
+ const userClient = new Hyperspell({
43
+ apiKey: client.apiKey,
44
+ userID: userId,
45
+ })
46
+ const response = await userClient.connections.list()
47
+ const providers = response.connections.map((conn) => conn.provider)
48
+ // Add vault and deduplicate
49
+ const sources = [...new Set(["vault", ...providers])]
50
+ return sources
51
+ } catch (_error) {
52
+ return ["vault"]
53
+ }
54
+ }
55
+
56
+ function updateConfigSources(configPath: string, sources: string[]): void {
57
+ if (!fs.existsSync(configPath)) return
58
+
59
+ const content = fs.readFileSync(configPath, "utf-8")
60
+ const config = JSON.parse(content)
61
+
62
+ const pluginConfig = config?.plugins?.entries?.["openclaw-hyperspell"]?.config
63
+ if (pluginConfig) {
64
+ pluginConfig.sources = sources.join(",")
65
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n")
66
+ }
67
+ }
68
+
69
+ function openUrl(url: string): Promise<void> {
70
+ return new Promise((resolve, reject) => {
71
+ let command: string
72
+ switch (platform()) {
73
+ case "darwin":
74
+ command = `open "${url}"`
75
+ break
76
+ case "win32":
77
+ command = `start "" "${url}"`
78
+ break
79
+ default:
80
+ command = `xdg-open "${url}"`
81
+ }
82
+ exec(command, (error) => {
83
+ if (error) reject(error)
84
+ else resolve()
85
+ })
86
+ })
87
+ }
88
+
89
+ async function runSetup(): Promise<void> {
90
+ p.intro("Hyperspell Setup")
91
+
92
+ // Step 1: Check if they have an account
93
+ const hasAccount = await p.confirm({
94
+ message: "Do you already have a Hyperspell account?",
95
+ })
96
+
97
+ if (p.isCancel(hasAccount)) {
98
+ p.cancel("Setup cancelled")
99
+ return
100
+ }
101
+
102
+ if (!hasAccount) {
103
+ p.note(
104
+ "1. Go to https://app.hyperspell.com to create a free account\n" +
105
+ "2. Create a new app for your AI agent\n" +
106
+ "3. Select which integrations you want to connect (Notion, Slack, etc.)",
107
+ "Create an account",
108
+ )
109
+
110
+ const openSignup = await p.confirm({
111
+ message: "Open app.hyperspell.com in your browser?",
112
+ })
113
+
114
+ if (p.isCancel(openSignup)) {
115
+ p.cancel("Setup cancelled")
116
+ return
117
+ }
118
+
119
+ if (openSignup) {
120
+ await openUrl("https://app.hyperspell.com")
121
+ p.log.info("Browser opened. Come back when you've created your account.")
122
+ }
123
+
124
+ await p.confirm({
125
+ message: "Ready to continue?",
126
+ active: "Yes",
127
+ inactive: "No",
128
+ })
129
+ }
130
+
131
+ // Step 2: Get API Key
132
+ p.note(
133
+ "1. Go to your app in https://app.hyperspell.com\n" +
134
+ "2. Navigate to Settings > API Keys\n" +
135
+ "3. Create a new API key",
136
+ "API Key",
137
+ )
138
+
139
+ const apiKey = await p.text({
140
+ message: "Paste your API key",
141
+ placeholder: "hs_...",
142
+ validate: (value) => {
143
+ if (!value) return "API key is required"
144
+ },
145
+ })
146
+
147
+ if (p.isCancel(apiKey)) {
148
+ p.cancel("Setup cancelled")
149
+ return
150
+ }
151
+
152
+ // Validate API key
153
+ const s = p.spinner()
154
+ s.start("Validating API key")
155
+
156
+ let client: Hyperspell
157
+ try {
158
+ client = new Hyperspell({ apiKey })
159
+ await client.integrations.list()
160
+ s.stop("API key is valid")
161
+ } catch (_error) {
162
+ s.stop("API key validation failed")
163
+ p.log.error("Please check that your API key is correct and try again.")
164
+ return
165
+ }
166
+
167
+ // Step 3: User ID
168
+ p.note(
169
+ "Hyperspell is a multi-tenant memory platform. Each user's memories\n" +
170
+ "are stored separately, identified by a User ID.\n\n" +
171
+ "For a personal agent, use your email address or username.",
172
+ "User ID",
173
+ )
174
+
175
+ const systemUser = userInfo().username
176
+ const userId = await p.text({
177
+ message: "Enter a User ID for this agent",
178
+ placeholder: systemUser || "your-email@example.com",
179
+ defaultValue: systemUser,
180
+ })
181
+
182
+ if (p.isCancel(userId)) {
183
+ p.cancel("Setup cancelled")
184
+ return
185
+ }
186
+
187
+ // Step 4: List and connect integrations
188
+ let integrations: Awaited<ReturnType<typeof client.integrations.list>>
189
+ try {
190
+ integrations = await client.integrations.list()
191
+ } catch (_error) {
192
+ integrations = { integrations: [] }
193
+ }
194
+
195
+ if (integrations.integrations.length === 0) {
196
+ p.note(
197
+ "No integrations are configured in your app yet.\n" +
198
+ "Go to https://app.hyperspell.com to add integrations,\n" +
199
+ "then use /connect <source> to connect them.",
200
+ "Connect Your Apps",
201
+ )
202
+ } else {
203
+ const integrationList = integrations.integrations
204
+ .map((int) => `• ${int.name} (${int.provider})`)
205
+ .join("\n")
206
+
207
+ // Get a user token for the connect page
208
+ let connectUrl: string
209
+ try {
210
+ const tokenResponse = await client.auth.userToken({ user_id: userId })
211
+ connectUrl = `https://connect.hyperspell.com?token=${tokenResponse.token}`
212
+ } catch (_error) {
213
+ p.log.error("Could not generate connect URL. You can connect apps later using /connect.")
214
+ connectUrl = ""
215
+ }
216
+
217
+ p.note(
218
+ `Available integrations:\n${integrationList}` +
219
+ (connectUrl ? `\n\nConnect your accounts at:\n${connectUrl}` : ""),
220
+ "Connect Your Apps",
221
+ )
222
+
223
+ if (connectUrl) {
224
+ const openConnect = await p.confirm({
225
+ message: "Open connection page in your browser?",
226
+ })
227
+
228
+ if (!p.isCancel(openConnect) && openConnect) {
229
+ await openUrl(connectUrl)
230
+ p.log.info("Browser opened. Connect your accounts and come back when done.")
231
+
232
+ await p.confirm({
233
+ message: "Finished connecting accounts?",
234
+ active: "Yes",
235
+ inactive: "Not yet",
236
+ })
237
+ }
238
+ }
239
+ }
240
+
241
+ // Fetch connected sources
242
+ const s1 = p.spinner()
243
+ s1.start("Fetching connected sources")
244
+ const sources = await fetchConnectionSources(client, userId)
245
+ s1.stop(`Found ${sources.length} sources: ${sources.join(", ")}`)
246
+
247
+ // Step 5: Ask about memory sync
248
+ p.note(
249
+ "OpenClaw can automatically sync markdown files in your workspace's\n" +
250
+ "memory/ directory with Hyperspell. This allows you to:\n\n" +
251
+ "• Store notes and context that persist across sessions\n" +
252
+ "• Have the AI reference your local documentation\n" +
253
+ "• Keep local files in sync with Hyperspell's search",
254
+ "Memory Sync",
255
+ )
256
+
257
+ const syncMemories = await p.confirm({
258
+ message: "Enable automatic memory sync for markdown files?",
259
+ initialValue: true,
260
+ })
261
+
262
+ if (p.isCancel(syncMemories)) {
263
+ p.cancel("Setup cancelled")
264
+ return
265
+ }
266
+
267
+ // Step 5: Save configuration
268
+ const s2 = p.spinner()
269
+ s2.start("Saving configuration")
270
+
271
+ try {
272
+ const configPath = resolveConfigPath()
273
+ const openclawDir = path.dirname(configPath)
274
+ const envPath = path.join(openclawDir, ".env")
275
+
276
+ // Ensure directory exists
277
+ if (!fs.existsSync(openclawDir)) {
278
+ fs.mkdirSync(openclawDir, { recursive: true })
279
+ }
280
+
281
+ // Read existing config or create new one
282
+ let config: Record<string, unknown> = {}
283
+ if (fs.existsSync(configPath)) {
284
+ const existing = fs.readFileSync(configPath, "utf-8")
285
+ config = JSON.parse(existing)
286
+ }
287
+
288
+ // Merge in plugin configuration
289
+ if (!config.plugins) {
290
+ config.plugins = {}
291
+ }
292
+ const plugins = config.plugins as Record<string, unknown>
293
+ if (!plugins.entries) {
294
+ plugins.entries = {}
295
+ }
296
+ const entries = plugins.entries as Record<string, unknown>
297
+ entries["openclaw-hyperspell"] = {
298
+ enabled: true,
299
+ config: {
300
+ apiKey: "${HYPERSPELL_API_KEY}",
301
+ userId,
302
+ sources: sources.join(","),
303
+ autoContext: true,
304
+ syncMemories,
305
+ },
306
+ }
307
+
308
+ // Write config
309
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n")
310
+
311
+ // Write or append to .env
312
+ const envLine = `HYPERSPELL_API_KEY=${apiKey}`
313
+ if (fs.existsSync(envPath)) {
314
+ const envContent = fs.readFileSync(envPath, "utf-8")
315
+ if (envContent.includes("HYPERSPELL_API_KEY=")) {
316
+ // Replace existing line
317
+ const updated = envContent.replace(/^HYPERSPELL_API_KEY=.*$/m, envLine)
318
+ fs.writeFileSync(envPath, updated)
319
+ } else {
320
+ // Append new line
321
+ fs.appendFileSync(envPath, (envContent.endsWith("\n") ? "" : "\n") + envLine + "\n")
322
+ }
323
+ } else {
324
+ fs.writeFileSync(envPath, envLine + "\n")
325
+ }
326
+
327
+ s2.stop("Configuration saved")
328
+
329
+ p.note(
330
+ `Config: ${configPath}\n` +
331
+ `API Key: ${envPath}`,
332
+ "Files updated",
333
+ )
334
+ } catch (error) {
335
+ s2.stop("Failed to save configuration")
336
+
337
+ // Fall back to showing manual instructions
338
+ const configJson = JSON.stringify(
339
+ {
340
+ plugins: {
341
+ entries: {
342
+ "openclaw-hyperspell": {
343
+ enabled: true,
344
+ config: {
345
+ apiKey: "${HYPERSPELL_API_KEY}",
346
+ userId,
347
+ sources: sources.join(","),
348
+ autoContext: true,
349
+ syncMemories,
350
+ },
351
+ },
352
+ },
353
+ },
354
+ },
355
+ null,
356
+ 2,
357
+ )
358
+
359
+ p.note(
360
+ `Add to your openclaw.json:\n\n${configJson}\n\n` +
361
+ `Set the environment variable:\n export HYPERSPELL_API_KEY=${apiKey}`,
362
+ "Manual configuration required",
363
+ )
364
+ }
365
+
366
+ // Step 7: Sync existing memories if enabled
367
+ if (syncMemories) {
368
+ const workspaceDir = getWorkspaceDir()
369
+ const memoryFiles = getMemoryFiles(workspaceDir)
370
+
371
+ if (memoryFiles.length > 0) {
372
+ const s3 = p.spinner()
373
+ s3.start(`Syncing ${memoryFiles.length} memory file(s)`)
374
+
375
+ const hyperspellClient = new HyperspellClient({
376
+ apiKey,
377
+ userId,
378
+ autoContext: true,
379
+ syncMemories: true,
380
+ sources: [],
381
+ maxResults: 10,
382
+ debug: false,
383
+ })
384
+
385
+ const result = await syncAllMemoryFiles(hyperspellClient, workspaceDir)
386
+
387
+ if (result.failed > 0) {
388
+ s3.stop(`Synced ${result.synced} files, ${result.failed} failed`)
389
+ for (const error of result.errors) {
390
+ p.log.error(` ${error}`)
391
+ }
392
+ } else {
393
+ s3.stop(`Synced ${result.synced} memory files`)
394
+ }
395
+ } else {
396
+ p.log.info("No memory files found in memory/ directory")
397
+ }
398
+ }
399
+
400
+ const syncNote = syncMemories
401
+ ? "\n\nMemory sync is enabled — markdown files in memory/ will be\n" +
402
+ "automatically synced to Hyperspell when they change."
403
+ : ""
404
+
405
+ p.note(
406
+ "/getcontext <query> Search your memories for relevant context\n" +
407
+ "/remember <text> Save something directly to your vault\n\n" +
408
+ "To connect more apps, run: openclaw openclaw-hyperspell connect\n\n" +
409
+ "Auto-context is enabled by default — relevant memories are\n" +
410
+ "automatically injected before each AI response." +
411
+ syncNote,
412
+ "How to use Hyperspell",
413
+ )
414
+
415
+ p.outro("Setup complete!")
416
+ }
417
+
418
+ async function runConnect(pluginConfig: unknown): Promise<void> {
419
+ const config = pluginConfig as Record<string, unknown> | undefined
420
+
421
+ p.intro("Hyperspell Connect")
422
+
423
+ if (!config?.apiKey) {
424
+ p.log.error("Not configured")
425
+ p.note("Run 'openclaw openclaw-hyperspell setup' to configure Hyperspell first.")
426
+ p.outro("")
427
+ return
428
+ }
429
+
430
+ const s = p.spinner()
431
+ s.start("Generating connect URL")
432
+
433
+ let client: Hyperspell
434
+ let userId: string
435
+ try {
436
+ client = new Hyperspell({ apiKey: config.apiKey as string })
437
+ userId = (config.userId as string) || userInfo().username || "user"
438
+ const tokenResponse = await client.auth.userToken({ user_id: userId })
439
+ const connectUrl = `https://connect.hyperspell.com?token=${tokenResponse.token}`
440
+
441
+ s.stop("Connect URL ready")
442
+
443
+ await openUrl(connectUrl)
444
+ p.log.success("Browser opened to connect.hyperspell.com")
445
+
446
+ await p.confirm({
447
+ message: "Finished connecting accounts?",
448
+ active: "Yes",
449
+ inactive: "Not yet",
450
+ })
451
+
452
+ // Fetch and update sources
453
+ const s2 = p.spinner()
454
+ s2.start("Updating sources configuration")
455
+
456
+ const sources = await fetchConnectionSources(client, userId)
457
+ const configPath = resolveConfigPath()
458
+ updateConfigSources(configPath, sources)
459
+
460
+ s2.stop(`Sources updated: ${sources.join(", ")}`)
461
+
462
+ p.outro("Done! Restart OpenClaw to apply changes.")
463
+ } catch (_error) {
464
+ s.stop("Failed to generate connect URL")
465
+ p.log.error("Check your API key and try again.")
466
+ p.outro("")
467
+ }
468
+ }
469
+
470
+ async function runStatus(pluginConfig: unknown): Promise<void> {
471
+ const config = pluginConfig as Record<string, unknown> | undefined
472
+
473
+ p.intro("Hyperspell Status")
474
+
475
+ if (!config?.apiKey) {
476
+ p.log.warn("Not configured")
477
+ p.note("Run 'openclaw openclaw-hyperspell setup' to configure Hyperspell.")
478
+ p.outro("")
479
+ return
480
+ }
481
+
482
+ p.log.success("Configured")
483
+ p.log.info(`User ID: ${config.userId || "(not set)"}`)
484
+ p.log.info(`Auto-Context: ${config.autoContext !== false ? "Enabled" : "Disabled"}`)
485
+ p.log.info(`Memory Sync: ${config.syncMemories ? "Enabled" : "Disabled"}`)
486
+ p.log.info(`Sources Filter: ${config.sources || "(all sources)"}`)
487
+ p.log.info(`Max Results: ${config.maxResults || 10}`)
488
+
489
+ const s = p.spinner()
490
+ s.start("Testing connection")
491
+
492
+ try {
493
+ const client = new Hyperspell({ apiKey: config.apiKey as string })
494
+ const integrations = await client.integrations.list()
495
+ s.stop(`Connection OK (${integrations.integrations.length} integrations available)`)
496
+
497
+ if (integrations.integrations.length > 0) {
498
+ const list = integrations.integrations
499
+ .map((int) => `• ${int.name} (${int.provider})`)
500
+ .join("\n")
501
+ p.note(list, "Available integrations")
502
+ }
503
+ } catch (_error) {
504
+ s.stop("Connection failed")
505
+ p.log.error("Check your API key and try again.")
506
+ }
507
+
508
+ p.outro("")
509
+ }
510
+
511
+ export function registerCliCommands(program: Command, pluginConfig: unknown): void {
512
+ const hyperspellCmd = program
513
+ .command("openclaw-hyperspell")
514
+ .description("Hyperspell — Memory and context for your AI agent")
515
+
516
+ hyperspellCmd
517
+ .command("setup")
518
+ .description("Interactive setup wizard for Hyperspell")
519
+ .action(async () => {
520
+ await runSetup()
521
+ })
522
+
523
+ hyperspellCmd
524
+ .command("status")
525
+ .description("Show Hyperspell connection status")
526
+ .action(async () => {
527
+ await runStatus(pluginConfig)
528
+ })
529
+
530
+ hyperspellCmd
531
+ .command("connect")
532
+ .description("Open the Hyperspell connect page to link your accounts")
533
+ .action(async () => {
534
+ await runConnect(pluginConfig)
535
+ })
536
+ }
package/commands/slash.ts CHANGED
@@ -1,8 +1,9 @@
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"
4
+ import { getWorkspaceDir } from "../config.ts"
5
5
  import { log } from "../logger.ts"
6
+ import { syncAllMemoryFiles } from "../sync/markdown.ts"
6
7
 
7
8
  function truncate(text: string, maxLength: number): string {
8
9
  if (text.length <= maxLength) return text
@@ -56,59 +57,6 @@ export function registerCommands(
56
57
  },
57
58
  })
58
59
 
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
60
  // /remember <text> - Add a new memory
113
61
  api.registerCommand({
114
62
  name: "remember",
@@ -136,4 +84,36 @@ export function registerCommands(
136
84
  }
137
85
  },
138
86
  })
87
+
88
+ // /sync - Manually sync memory files
89
+ api.registerCommand({
90
+ name: "sync",
91
+ description: "Sync memory/*.md files with Hyperspell",
92
+ acceptsArgs: false,
93
+ requireAuth: true,
94
+ handler: async () => {
95
+ log.debug("/sync command")
96
+
97
+ try {
98
+ const workspaceDir = getWorkspaceDir()
99
+ const result = await syncAllMemoryFiles(client, workspaceDir)
100
+
101
+ if (result.synced === 0 && result.failed === 0) {
102
+ return { text: "No memory files found in memory/ directory." }
103
+ }
104
+
105
+ if (result.failed > 0) {
106
+ const errors = result.errors.map((e) => ` • ${e}`).join("\n")
107
+ return {
108
+ text: `Synced ${result.synced} files, ${result.failed} failed:\n${errors}`,
109
+ }
110
+ }
111
+
112
+ return { text: `Synced ${result.synced} memory file(s) to Hyperspell.` }
113
+ } catch (err) {
114
+ log.error("/sync failed", err)
115
+ return { text: "Failed to sync memory files. Check logs for details." }
116
+ }
117
+ },
118
+ })
139
119
  }