@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.
package/config.ts CHANGED
@@ -14,6 +14,7 @@ export type HyperspellConfig = {
14
14
  apiKey: string
15
15
  userId?: string
16
16
  autoContext: boolean
17
+ syncMemories: boolean
17
18
  sources: HyperspellSource[]
18
19
  maxResults: number
19
20
  debug: boolean
@@ -23,6 +24,7 @@ const ALLOWED_KEYS = [
23
24
  "apiKey",
24
25
  "userId",
25
26
  "autoContext",
27
+ "syncMemories",
26
28
  "sources",
27
29
  "maxResults",
28
30
  "debug",
@@ -62,8 +64,30 @@ function resolveEnvVars(value: string): string {
62
64
  })
63
65
  }
64
66
 
65
- function parseSources(raw: string | undefined): HyperspellSource[] {
66
- if (!raw || raw.trim() === "") {
67
+ function parseSources(raw: string | string[] | undefined): HyperspellSource[] {
68
+ if (!raw) {
69
+ return []
70
+ }
71
+
72
+ // Handle array input
73
+ if (Array.isArray(raw)) {
74
+ const sources = raw
75
+ .map((s) => String(s).trim().toLowerCase())
76
+ .filter((s) => s.length > 0) as HyperspellSource[]
77
+
78
+ for (const source of sources) {
79
+ if (!VALID_SOURCES.includes(source)) {
80
+ throw new Error(
81
+ `Invalid source "${source}". Valid sources: ${VALID_SOURCES.join(", ")}`,
82
+ )
83
+ }
84
+ }
85
+
86
+ return sources
87
+ }
88
+
89
+ // Handle string input (comma-separated)
90
+ if (typeof raw === "string" && raw.trim() === "") {
67
91
  return []
68
92
  }
69
93
 
@@ -108,7 +132,8 @@ export function parseConfig(raw: unknown): HyperspellConfig {
108
132
  apiKey,
109
133
  userId: cfg.userId as string | undefined,
110
134
  autoContext: (cfg.autoContext as boolean) ?? true,
111
- sources: parseSources(cfg.sources as string | undefined),
135
+ syncMemories: (cfg.syncMemories as boolean) ?? false,
136
+ sources: parseSources(cfg.sources as string | string[] | undefined),
112
137
  maxResults: (cfg.maxResults as number) ?? 10,
113
138
  debug: (cfg.debug as boolean) ?? false,
114
139
  }
@@ -117,3 +142,50 @@ export function parseConfig(raw: unknown): HyperspellConfig {
117
142
  export const hyperspellConfigSchema = {
118
143
  parse: parseConfig,
119
144
  }
145
+
146
+ /**
147
+ * Get the workspace directory from OpenClaw config
148
+ */
149
+ export function getWorkspaceDir(): string {
150
+ const { homedir } = require("node:os")
151
+ const fs = require("node:fs")
152
+ const path = require("node:path")
153
+
154
+ // Resolve config path
155
+ const override = process.env.OPENCLAW_CONFIG_PATH?.trim() || process.env.CLAWDBOT_CONFIG_PATH?.trim()
156
+ let configPath: string
157
+ if (override) {
158
+ configPath = override.startsWith("~")
159
+ ? override.replace(/^~(?=$|[\\/])/, homedir())
160
+ : path.resolve(override)
161
+ } else {
162
+ const stateDir = process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim()
163
+ const resolvedStateDir = stateDir
164
+ ? (stateDir.startsWith("~") ? stateDir.replace(/^~(?=$|[\\/])/, homedir()) : path.resolve(stateDir))
165
+ : path.join(homedir(), ".openclaw")
166
+ configPath = path.join(resolvedStateDir, "openclaw.json")
167
+ }
168
+
169
+ // Read workspace from config
170
+ if (fs.existsSync(configPath)) {
171
+ try {
172
+ const content = fs.readFileSync(configPath, "utf-8")
173
+ const config = JSON.parse(content)
174
+ const workspace = config?.agents?.defaults?.workspace
175
+ if (workspace) {
176
+ return workspace.startsWith("~")
177
+ ? workspace.replace(/^~(?=$|[\\/])/, homedir())
178
+ : workspace
179
+ }
180
+ } catch (_e) {
181
+ // Fall back to default
182
+ }
183
+ }
184
+
185
+ // Default workspace
186
+ const stateDir = process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim()
187
+ const resolvedStateDir = stateDir
188
+ ? (stateDir.startsWith("~") ? stateDir.replace(/^~(?=$|[\\/])/, homedir()) : path.resolve(stateDir))
189
+ : path.join(homedir(), ".openclaw")
190
+ return path.join(resolvedStateDir, "workspace")
191
+ }
@@ -0,0 +1,63 @@
1
+ import * as path from "node:path"
2
+ import type { HyperspellClient } from "../client.ts"
3
+ import type { HyperspellConfig } from "../config.ts"
4
+ import { getWorkspaceDir } from "../config.ts"
5
+ import { log } from "../logger.ts"
6
+ import { syncMarkdownFile, syncAllMemoryFiles } from "../sync/markdown.ts"
7
+
8
+ /**
9
+ * Build a handler for file change events that syncs markdown files to Hyperspell
10
+ */
11
+ export function buildFileSyncHandler(client: HyperspellClient, _cfg: HyperspellConfig) {
12
+ const workspaceDir = getWorkspaceDir()
13
+ const memoryDir = path.join(workspaceDir, "memory")
14
+
15
+ return async (event: Record<string, unknown>) => {
16
+ const filePath = event.file_path as string | undefined
17
+ if (!filePath) return
18
+
19
+ // Only process markdown files in the workspace's memory directory
20
+ if (!filePath.startsWith(memoryDir) || !filePath.endsWith(".md")) {
21
+ return
22
+ }
23
+
24
+ const fileName = path.basename(filePath)
25
+ log.info(`Memory file changed: ${fileName}`)
26
+
27
+ try {
28
+ const result = await syncMarkdownFile(client, filePath)
29
+ if (result.success) {
30
+ log.info(`Synced ${fileName} -> ${result.resourceId}`)
31
+ } else {
32
+ log.error(`Failed to sync ${fileName}: ${result.error}`)
33
+ }
34
+ } catch (err) {
35
+ log.error(`Error syncing ${fileName}`, err)
36
+ }
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Sync all existing memory files on startup
42
+ */
43
+ export async function syncMemoriesOnStartup(
44
+ client: HyperspellClient,
45
+ workspaceDir: string,
46
+ ): Promise<void> {
47
+ log.info("Syncing existing memory files...")
48
+
49
+ const result = await syncAllMemoryFiles(client, workspaceDir)
50
+
51
+ if (result.synced > 0) {
52
+ log.info(`Synced ${result.synced} memory files`)
53
+ }
54
+ if (result.failed > 0) {
55
+ log.error(`Failed to sync ${result.failed} files:`)
56
+ for (const error of result.errors) {
57
+ log.error(` - ${error}`)
58
+ }
59
+ }
60
+ if (result.synced === 0 && result.failed === 0) {
61
+ log.info("No memory files found in memory/ directory")
62
+ }
63
+ }
package/index.ts CHANGED
@@ -1,8 +1,10 @@
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 { parseConfig, hyperspellConfigSchema } from "./config.ts"
4
+ import { registerCliCommands } from "./commands/setup.ts"
5
+ import { parseConfig, hyperspellConfigSchema, getWorkspaceDir } from "./config.ts"
5
6
  import { buildAutoContextHandler } from "./hooks/auto-context.ts"
7
+ import { buildFileSyncHandler, syncMemoriesOnStartup } from "./hooks/memory-sync.ts"
6
8
  import { initLogger } from "./logger.ts"
7
9
  import { registerRememberTool } from "./tools/remember.ts"
8
10
  import { registerSearchTool } from "./tools/search.ts"
@@ -10,11 +12,56 @@ import { registerSearchTool } from "./tools/search.ts"
10
12
  export default {
11
13
  id: "openclaw-hyperspell",
12
14
  name: "Hyperspell",
13
- description: "OpenClaw powered by Hyperspell - RAG-as-a-service for your connected sources",
15
+ description: "Hyperspell gives your Molty context and memory from all your existing data",
14
16
  kind: "memory" as const,
15
17
  configSchema: hyperspellConfigSchema,
16
18
 
17
19
  register(api: OpenClawPluginApi) {
20
+ // Register CLI commands (openclaw openclaw-hyperspell setup|status|connect)
21
+ api.registerCli(
22
+ (ctx) => {
23
+ registerCliCommands(ctx.program, api.pluginConfig)
24
+ },
25
+ { commands: ["openclaw-hyperspell"] },
26
+ )
27
+
28
+ // Check if configured
29
+ const rawConfig = api.pluginConfig as Record<string, unknown> | undefined
30
+ const hasConfig = rawConfig?.apiKey || process.env.HYPERSPELL_API_KEY
31
+
32
+ if (!hasConfig) {
33
+ api.logger.info("hyperspell: not configured - run 'openclaw openclaw-hyperspell setup'")
34
+ // Still register slash commands so they show up, but they'll return an error
35
+ api.registerCommand({
36
+ name: "getcontext",
37
+ description: "Search your memories for relevant context",
38
+ acceptsArgs: true,
39
+ requireAuth: false,
40
+ handler: async () => {
41
+ return { text: "Hyperspell not configured. Run 'openclaw openclaw-hyperspell setup' first." }
42
+ },
43
+ })
44
+ api.registerCommand({
45
+ name: "remember",
46
+ description: "Save something to memory",
47
+ acceptsArgs: true,
48
+ requireAuth: false,
49
+ handler: async () => {
50
+ return { text: "Hyperspell not configured. Run 'openclaw openclaw-hyperspell setup' first." }
51
+ },
52
+ })
53
+ api.registerCommand({
54
+ name: "sync",
55
+ description: "Sync memory/*.md files with Hyperspell",
56
+ acceptsArgs: false,
57
+ requireAuth: false,
58
+ handler: async () => {
59
+ return { text: "Hyperspell not configured. Run 'openclaw openclaw-hyperspell setup' first." }
60
+ },
61
+ })
62
+ return
63
+ }
64
+
18
65
  const cfg = parseConfig(api.pluginConfig)
19
66
 
20
67
  initLogger(api.logger, cfg.debug)
@@ -31,14 +78,26 @@ export default {
31
78
  api.on("before_agent_start", autoContextHandler)
32
79
  }
33
80
 
81
+ // Register memory sync hook
82
+ if (cfg.syncMemories) {
83
+ const fileSyncHandler = buildFileSyncHandler(client, cfg)
84
+ api.on("file_changed", fileSyncHandler)
85
+ }
86
+
34
87
  // Register slash commands
35
88
  registerCommands(api, client, cfg)
36
89
 
37
90
  // Register service for lifecycle management
38
91
  api.registerService({
39
92
  id: "openclaw-hyperspell",
40
- start: () => {
93
+ start: async () => {
41
94
  api.logger.info("hyperspell: connected")
95
+
96
+ // Sync memories on startup if enabled
97
+ if (cfg.syncMemories) {
98
+ const workspaceDir = getWorkspaceDir()
99
+ await syncMemoriesOnStartup(client, workspaceDir)
100
+ }
42
101
  },
43
102
  stop: () => {
44
103
  api.logger.info("hyperspell: stopped")
@@ -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.1",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "OpenClaw Hyperspell memory plugin",
6
6
  "license": "MIT",
@@ -17,7 +17,7 @@
17
17
  ],
18
18
  "repository": {
19
19
  "type": "git",
20
- "url": "https://github.com/hyperspell/openclaw-hyperspell.git"
20
+ "url": "git+https://github.com/hyperspell/openclaw-hyperspell.git"
21
21
  },
22
22
  "keywords": [
23
23
  "openclaw",
@@ -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,7 +41,9 @@
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"
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,6 +1,23 @@
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
20
+ workspaceDir?: string
4
21
  logger: {
5
22
  info: (message: string, ...args: unknown[]) => void
6
23
  warn: (message: string, ...args: unknown[]) => void
@@ -14,6 +31,7 @@ declare module "openclaw/plugin-sdk" {
14
31
  requireAuth: boolean
15
32
  handler: (ctx: { args?: string; senderId?: string; channel?: string }) => Promise<{ text: string }>
16
33
  }): void
34
+ registerCli(registrar: OpenClawPluginCliRegistrar, opts?: { commands?: string[] }): void
17
35
  registerTool<T = unknown>(
18
36
  options: {
19
37
  name: string