@hyperspell/openclaw-hyperspell 0.1.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 +98 -0
- package/client.ts +113 -0
- package/commands/slash.ts +139 -0
- package/config.ts +119 -0
- package/hooks/auto-context.ts +76 -0
- package/index.ts +48 -0
- package/lib/browser.ts +27 -0
- package/logger.ts +41 -0
- package/openclaw.plugin.json +52 -0
- package/package.json +48 -0
- package/tools/remember.ts +44 -0
- package/tools/search.ts +72 -0
- package/types/openclaw.d.ts +42 -0
package/README.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# OpenClaw Hyperspell Plugin
|
|
2
|
+
|
|
3
|
+
OpenClaw plugin for [Hyperspell](https://hyperspell.com) - Context and memory for your AI agents.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
openclaw plugins install @hyperspell/openclaw-hyperspell
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Configuration
|
|
12
|
+
|
|
13
|
+
Add to your `openclaw.json`:
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
{
|
|
17
|
+
"plugins": {
|
|
18
|
+
"openclaw-hyperspell": {
|
|
19
|
+
"apiKey": "${HYPERSPELL_API_KEY}",
|
|
20
|
+
"autoContext": true
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Or set the environment variable:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
export HYPERSPELL_API_KEY=hs_...
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Options
|
|
33
|
+
|
|
34
|
+
| Option | Type | Default | Description |
|
|
35
|
+
|--------|------|---------|-------------|
|
|
36
|
+
| `apiKey` | string | `${HYPERSPELL_API_KEY}` | Hyperspell API key |
|
|
37
|
+
| `userId` | string | - | User ID to scope searches (for non-JWT API keys) |
|
|
38
|
+
| `autoContext` | boolean | `true` | Auto-inject relevant memories before each AI turn |
|
|
39
|
+
| `sources` | string | - | Comma-separated sources to search (e.g., `notion,slack`) |
|
|
40
|
+
| `maxResults` | number | `10` | Maximum memories per context injection |
|
|
41
|
+
| `debug` | boolean | `false` | Enable verbose logging |
|
|
42
|
+
|
|
43
|
+
## Slash Commands
|
|
44
|
+
|
|
45
|
+
### `/context <query>`
|
|
46
|
+
|
|
47
|
+
Search your memories for relevant context.
|
|
48
|
+
|
|
49
|
+
```
|
|
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
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### `/remember <text>`
|
|
64
|
+
|
|
65
|
+
Save something to memory.
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
/remember Meeting with Alice: discussed Q1 budget, need to follow up on headcount
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## AI Tools
|
|
72
|
+
|
|
73
|
+
The plugin registers tools that the AI can use autonomously:
|
|
74
|
+
|
|
75
|
+
- **hyperspell_search** - Search through connected sources
|
|
76
|
+
- **hyperspell_remember** - Save information to memory
|
|
77
|
+
|
|
78
|
+
## Auto-Context
|
|
79
|
+
|
|
80
|
+
When `autoContext: true` (default), the plugin automatically:
|
|
81
|
+
|
|
82
|
+
1. Intercepts each user message before the AI responds
|
|
83
|
+
2. Searches Hyperspell for relevant memories
|
|
84
|
+
3. Injects matching context into the AI's prompt
|
|
85
|
+
|
|
86
|
+
This ensures the AI always has access to relevant information from your connected sources.
|
|
87
|
+
|
|
88
|
+
## Available Sources
|
|
89
|
+
|
|
90
|
+
- `collections` - User-created collections
|
|
91
|
+
- `notion` - Notion pages and databases
|
|
92
|
+
- `slack` - Slack messages
|
|
93
|
+
- `google_calendar` - Google Calendar events
|
|
94
|
+
- `google_mail` - Gmail messages
|
|
95
|
+
- `google_drive` - Google Drive files
|
|
96
|
+
- `box` - Box files
|
|
97
|
+
- `vault` - Vault documents
|
|
98
|
+
- `web_crawler` - Crawled web pages
|
package/client.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import Hyperspell from "hyperspell"
|
|
2
|
+
import type { HyperspellConfig, HyperspellSource } from "./config.ts"
|
|
3
|
+
import { log } from "./logger.ts"
|
|
4
|
+
|
|
5
|
+
export type SearchResult = {
|
|
6
|
+
resourceId: string
|
|
7
|
+
title: string | null
|
|
8
|
+
source: HyperspellSource
|
|
9
|
+
score: number | null
|
|
10
|
+
url: string | null
|
|
11
|
+
createdAt: string | null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type Integration = {
|
|
15
|
+
id: string
|
|
16
|
+
name: string
|
|
17
|
+
provider: HyperspellSource
|
|
18
|
+
icon: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class HyperspellClient {
|
|
22
|
+
private client: Hyperspell
|
|
23
|
+
private config: HyperspellConfig
|
|
24
|
+
|
|
25
|
+
constructor(config: HyperspellConfig) {
|
|
26
|
+
this.config = config
|
|
27
|
+
this.client = new Hyperspell({
|
|
28
|
+
apiKey: config.apiKey,
|
|
29
|
+
})
|
|
30
|
+
log.info("client initialized")
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async search(
|
|
34
|
+
query: string,
|
|
35
|
+
options?: { limit?: number; sources?: HyperspellSource[] },
|
|
36
|
+
): Promise<SearchResult[]> {
|
|
37
|
+
const limit = options?.limit ?? this.config.maxResults
|
|
38
|
+
const sources =
|
|
39
|
+
options?.sources ?? (this.config.sources.length > 0 ? this.config.sources : undefined)
|
|
40
|
+
|
|
41
|
+
log.debugRequest("memories.search", { query, limit, sources })
|
|
42
|
+
|
|
43
|
+
const response = await this.client.memories.search({
|
|
44
|
+
query,
|
|
45
|
+
sources,
|
|
46
|
+
options: {
|
|
47
|
+
max_results: limit,
|
|
48
|
+
},
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
const results: SearchResult[] = response.documents.map((doc) => ({
|
|
52
|
+
resourceId: doc.resource_id,
|
|
53
|
+
title: doc.title ?? null,
|
|
54
|
+
source: doc.source as HyperspellSource,
|
|
55
|
+
score: doc.score ?? null,
|
|
56
|
+
url: doc.metadata?.url as string | null ?? null,
|
|
57
|
+
createdAt: doc.metadata?.created_at as string | null ?? null,
|
|
58
|
+
}))
|
|
59
|
+
|
|
60
|
+
log.debugResponse("memories.search", { count: results.length })
|
|
61
|
+
return results
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async addMemory(
|
|
65
|
+
text: string,
|
|
66
|
+
options?: { title?: string; metadata?: Record<string, string | number | boolean> },
|
|
67
|
+
): Promise<{ resourceId: string }> {
|
|
68
|
+
log.debugRequest("memories.add", {
|
|
69
|
+
textLength: text.length,
|
|
70
|
+
title: options?.title,
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
const result = await this.client.memories.add({
|
|
74
|
+
text,
|
|
75
|
+
title: options?.title,
|
|
76
|
+
metadata: {
|
|
77
|
+
...options?.metadata,
|
|
78
|
+
openclaw_source: "command",
|
|
79
|
+
},
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
log.debugResponse("memories.add", { resourceId: result.resource_id })
|
|
83
|
+
return { resourceId: result.resource_id }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async listIntegrations(): Promise<Integration[]> {
|
|
87
|
+
log.debugRequest("integrations.list", {})
|
|
88
|
+
|
|
89
|
+
const response = await this.client.integrations.list()
|
|
90
|
+
|
|
91
|
+
const integrations: Integration[] = response.integrations.map((int) => ({
|
|
92
|
+
id: int.id,
|
|
93
|
+
name: int.name,
|
|
94
|
+
provider: int.provider as HyperspellSource,
|
|
95
|
+
icon: int.icon,
|
|
96
|
+
}))
|
|
97
|
+
|
|
98
|
+
log.debugResponse("integrations.list", { count: integrations.length })
|
|
99
|
+
return integrations
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async getConnectUrl(integrationId: string): Promise<{ url: string; expiresAt: string }> {
|
|
103
|
+
log.debugRequest("integrations.connect", { integrationId })
|
|
104
|
+
|
|
105
|
+
const response = await this.client.integrations.connect(integrationId)
|
|
106
|
+
|
|
107
|
+
log.debugResponse("integrations.connect", { url: response.url })
|
|
108
|
+
return {
|
|
109
|
+
url: response.url,
|
|
110
|
+
expiresAt: response.expires_at,
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"
|
|
2
|
+
import type { HyperspellClient } from "../client.ts"
|
|
3
|
+
import type { HyperspellConfig } from "../config.ts"
|
|
4
|
+
import { openInBrowser } from "../lib/browser.ts"
|
|
5
|
+
import { log } from "../logger.ts"
|
|
6
|
+
|
|
7
|
+
function truncate(text: string, maxLength: number): string {
|
|
8
|
+
if (text.length <= maxLength) return text
|
|
9
|
+
return `${text.slice(0, maxLength)}…`
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function formatScore(score: number | null): string {
|
|
13
|
+
if (score === null) return ""
|
|
14
|
+
return ` (${Math.round(score * 100)}%)`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function registerCommands(
|
|
18
|
+
api: OpenClawPluginApi,
|
|
19
|
+
client: HyperspellClient,
|
|
20
|
+
_cfg: HyperspellConfig,
|
|
21
|
+
): void {
|
|
22
|
+
// /context <query> - Search memories and show summaries
|
|
23
|
+
api.registerCommand({
|
|
24
|
+
name: "context",
|
|
25
|
+
description: "Search your memories for relevant context",
|
|
26
|
+
acceptsArgs: true,
|
|
27
|
+
requireAuth: true,
|
|
28
|
+
handler: async (ctx: { args?: string }) => {
|
|
29
|
+
const query = ctx.args?.trim()
|
|
30
|
+
if (!query) {
|
|
31
|
+
return { text: "Usage: /context <search query>" }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
log.debug(`/context command: "${query}"`)
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const results = await client.search(query, { limit: 5 })
|
|
38
|
+
|
|
39
|
+
if (results.length === 0) {
|
|
40
|
+
return { text: `No memories found for: "${query}"` }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const lines = results.map((r, i) => {
|
|
44
|
+
const title = r.title ? truncate(r.title, 60) : `[${r.source}]`
|
|
45
|
+
const score = formatScore(r.score)
|
|
46
|
+
return `${i + 1}. ${title}${score}`
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
text: `Found ${results.length} memories:\n\n${lines.join("\n")}`,
|
|
51
|
+
}
|
|
52
|
+
} catch (err) {
|
|
53
|
+
log.error("/context failed", err)
|
|
54
|
+
return { text: "Failed to search memories. Check logs for details." }
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
})
|
|
58
|
+
|
|
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
|
+
// /remember <text> - Add a new memory
|
|
113
|
+
api.registerCommand({
|
|
114
|
+
name: "remember",
|
|
115
|
+
description: "Save something to memory",
|
|
116
|
+
acceptsArgs: true,
|
|
117
|
+
requireAuth: true,
|
|
118
|
+
handler: async (ctx: { args?: string }) => {
|
|
119
|
+
const text = ctx.args?.trim()
|
|
120
|
+
if (!text) {
|
|
121
|
+
return { text: "Usage: /remember <text to remember>" }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
log.debug(`/remember command: "${truncate(text, 50)}"`)
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
await client.addMemory(text, {
|
|
128
|
+
metadata: { source: "openclaw_command" },
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
const preview = truncate(text, 60)
|
|
132
|
+
return { text: `Remembered: "${preview}"` }
|
|
133
|
+
} catch (err) {
|
|
134
|
+
log.error("/remember failed", err)
|
|
135
|
+
return { text: "Failed to save memory. Check logs for details." }
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
})
|
|
139
|
+
}
|
package/config.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
export type HyperspellSource =
|
|
2
|
+
| "collections"
|
|
3
|
+
| "reddit"
|
|
4
|
+
| "notion"
|
|
5
|
+
| "slack"
|
|
6
|
+
| "google_calendar"
|
|
7
|
+
| "google_mail"
|
|
8
|
+
| "box"
|
|
9
|
+
| "google_drive"
|
|
10
|
+
| "vault"
|
|
11
|
+
| "web_crawler"
|
|
12
|
+
|
|
13
|
+
export type HyperspellConfig = {
|
|
14
|
+
apiKey: string
|
|
15
|
+
userId?: string
|
|
16
|
+
autoContext: boolean
|
|
17
|
+
sources: HyperspellSource[]
|
|
18
|
+
maxResults: number
|
|
19
|
+
debug: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const ALLOWED_KEYS = [
|
|
23
|
+
"apiKey",
|
|
24
|
+
"userId",
|
|
25
|
+
"autoContext",
|
|
26
|
+
"sources",
|
|
27
|
+
"maxResults",
|
|
28
|
+
"debug",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
const VALID_SOURCES: HyperspellSource[] = [
|
|
32
|
+
"collections",
|
|
33
|
+
"reddit",
|
|
34
|
+
"notion",
|
|
35
|
+
"slack",
|
|
36
|
+
"google_calendar",
|
|
37
|
+
"google_mail",
|
|
38
|
+
"box",
|
|
39
|
+
"google_drive",
|
|
40
|
+
"vault",
|
|
41
|
+
"web_crawler",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
function assertAllowedKeys(
|
|
45
|
+
value: Record<string, unknown>,
|
|
46
|
+
allowed: string[],
|
|
47
|
+
label: string,
|
|
48
|
+
): void {
|
|
49
|
+
const unknown = Object.keys(value).filter((k) => !allowed.includes(k))
|
|
50
|
+
if (unknown.length > 0) {
|
|
51
|
+
throw new Error(`${label} has unknown keys: ${unknown.join(", ")}`)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function resolveEnvVars(value: string): string {
|
|
56
|
+
return value.replace(/\$\{([^}]+)\}/g, (_, envVar: string) => {
|
|
57
|
+
const envValue = process.env[envVar]
|
|
58
|
+
if (!envValue) {
|
|
59
|
+
throw new Error(`Environment variable ${envVar} is not set`)
|
|
60
|
+
}
|
|
61
|
+
return envValue
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function parseSources(raw: string | undefined): HyperspellSource[] {
|
|
66
|
+
if (!raw || raw.trim() === "") {
|
|
67
|
+
return []
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const sources = raw
|
|
71
|
+
.split(",")
|
|
72
|
+
.map((s) => s.trim().toLowerCase())
|
|
73
|
+
.filter((s) => s.length > 0) as HyperspellSource[]
|
|
74
|
+
|
|
75
|
+
for (const source of sources) {
|
|
76
|
+
if (!VALID_SOURCES.includes(source)) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`Invalid source "${source}". Valid sources: ${VALID_SOURCES.join(", ")}`,
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return sources
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function parseConfig(raw: unknown): HyperspellConfig {
|
|
87
|
+
const cfg =
|
|
88
|
+
raw && typeof raw === "object" && !Array.isArray(raw)
|
|
89
|
+
? (raw as Record<string, unknown>)
|
|
90
|
+
: {}
|
|
91
|
+
|
|
92
|
+
if (Object.keys(cfg).length > 0) {
|
|
93
|
+
assertAllowedKeys(cfg, ALLOWED_KEYS, "hyperspell config")
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const apiKey =
|
|
97
|
+
typeof cfg.apiKey === "string" && cfg.apiKey.length > 0
|
|
98
|
+
? resolveEnvVars(cfg.apiKey)
|
|
99
|
+
: process.env.HYPERSPELL_API_KEY
|
|
100
|
+
|
|
101
|
+
if (!apiKey) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
"hyperspell: apiKey is required (set in plugin config or HYPERSPELL_API_KEY env var)",
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
apiKey,
|
|
109
|
+
userId: cfg.userId as string | undefined,
|
|
110
|
+
autoContext: (cfg.autoContext as boolean) ?? true,
|
|
111
|
+
sources: parseSources(cfg.sources as string | undefined),
|
|
112
|
+
maxResults: (cfg.maxResults as number) ?? 10,
|
|
113
|
+
debug: (cfg.debug as boolean) ?? false,
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export const hyperspellConfigSchema = {
|
|
118
|
+
parse: parseConfig,
|
|
119
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { HyperspellClient, SearchResult } from "../client.ts"
|
|
2
|
+
import type { HyperspellConfig } from "../config.ts"
|
|
3
|
+
import { log } from "../logger.ts"
|
|
4
|
+
|
|
5
|
+
function formatRelativeTime(isoTimestamp: string): string {
|
|
6
|
+
try {
|
|
7
|
+
const dt = new Date(isoTimestamp)
|
|
8
|
+
const now = new Date()
|
|
9
|
+
const seconds = (now.getTime() - dt.getTime()) / 1000
|
|
10
|
+
const minutes = seconds / 60
|
|
11
|
+
const hours = seconds / 3600
|
|
12
|
+
const days = seconds / 86400
|
|
13
|
+
|
|
14
|
+
if (minutes < 30) return "just now"
|
|
15
|
+
if (minutes < 60) return `${Math.floor(minutes)}mins ago`
|
|
16
|
+
if (hours < 24) return `${Math.floor(hours)} hrs ago`
|
|
17
|
+
if (days < 7) return `${Math.floor(days)}d ago`
|
|
18
|
+
|
|
19
|
+
const month = dt.toLocaleString("en", { month: "short" })
|
|
20
|
+
if (dt.getFullYear() === now.getFullYear()) {
|
|
21
|
+
return `${dt.getDate()} ${month}`
|
|
22
|
+
}
|
|
23
|
+
return `${dt.getDate()} ${month}, ${dt.getFullYear()}`
|
|
24
|
+
} catch {
|
|
25
|
+
return ""
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function formatContext(results: SearchResult[], maxResults: number): string | null {
|
|
30
|
+
const limited = results.slice(0, maxResults)
|
|
31
|
+
|
|
32
|
+
if (limited.length === 0) return null
|
|
33
|
+
|
|
34
|
+
const lines = limited.map((r) => {
|
|
35
|
+
const title = r.title ?? `[${r.source}]`
|
|
36
|
+
const timeStr = r.createdAt ? formatRelativeTime(r.createdAt) : ""
|
|
37
|
+
const pct = r.score != null ? `[${Math.round(r.score * 100)}%]` : ""
|
|
38
|
+
const prefix = timeStr ? `[${timeStr}]` : ""
|
|
39
|
+
return `- ${prefix} ${title} ${pct}`.trim()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const intro =
|
|
43
|
+
"The following is context from the user's connected sources. Reference it only when relevant to the conversation."
|
|
44
|
+
const disclaimer =
|
|
45
|
+
"Use this context naturally when relevant — including indirect connections — but don't force it into every response or make assumptions beyond what's stated."
|
|
46
|
+
|
|
47
|
+
return `<hyperspell-context>\n${intro}\n\n## Relevant Memories (with relevance %)\n${lines.join("\n")}\n\n${disclaimer}\n</hyperspell-context>`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function buildAutoContextHandler(
|
|
51
|
+
client: HyperspellClient,
|
|
52
|
+
cfg: HyperspellConfig,
|
|
53
|
+
) {
|
|
54
|
+
return async (event: Record<string, unknown>) => {
|
|
55
|
+
const prompt = event.prompt as string | undefined
|
|
56
|
+
if (!prompt || prompt.length < 5) return
|
|
57
|
+
|
|
58
|
+
log.debug(`auto-context: searching for "${prompt.slice(0, 50)}..."`)
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const results = await client.search(prompt, { limit: cfg.maxResults })
|
|
62
|
+
const context = formatContext(results, cfg.maxResults)
|
|
63
|
+
|
|
64
|
+
if (!context) {
|
|
65
|
+
log.debug("auto-context: no relevant memories found")
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
log.debug(`auto-context: injecting ${results.length} memories`)
|
|
70
|
+
return { prependContext: context }
|
|
71
|
+
} catch (err) {
|
|
72
|
+
log.error("auto-context failed", err)
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"
|
|
2
|
+
import { HyperspellClient } from "./client.ts"
|
|
3
|
+
import { registerCommands } from "./commands/slash.ts"
|
|
4
|
+
import { parseConfig, hyperspellConfigSchema } from "./config.ts"
|
|
5
|
+
import { buildAutoContextHandler } from "./hooks/auto-context.ts"
|
|
6
|
+
import { initLogger } from "./logger.ts"
|
|
7
|
+
import { registerRememberTool } from "./tools/remember.ts"
|
|
8
|
+
import { registerSearchTool } from "./tools/search.ts"
|
|
9
|
+
|
|
10
|
+
export default {
|
|
11
|
+
id: "openclaw-hyperspell",
|
|
12
|
+
name: "Hyperspell",
|
|
13
|
+
description: "OpenClaw powered by Hyperspell - RAG-as-a-service for your connected sources",
|
|
14
|
+
kind: "memory" as const,
|
|
15
|
+
configSchema: hyperspellConfigSchema,
|
|
16
|
+
|
|
17
|
+
register(api: OpenClawPluginApi) {
|
|
18
|
+
const cfg = parseConfig(api.pluginConfig)
|
|
19
|
+
|
|
20
|
+
initLogger(api.logger, cfg.debug)
|
|
21
|
+
|
|
22
|
+
const client = new HyperspellClient(cfg)
|
|
23
|
+
|
|
24
|
+
// Register AI tools
|
|
25
|
+
registerSearchTool(api, client, cfg)
|
|
26
|
+
registerRememberTool(api, client, cfg)
|
|
27
|
+
|
|
28
|
+
// Register auto-context hook
|
|
29
|
+
if (cfg.autoContext) {
|
|
30
|
+
const autoContextHandler = buildAutoContextHandler(client, cfg)
|
|
31
|
+
api.on("before_agent_start", autoContextHandler)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Register slash commands
|
|
35
|
+
registerCommands(api, client, cfg)
|
|
36
|
+
|
|
37
|
+
// Register service for lifecycle management
|
|
38
|
+
api.registerService({
|
|
39
|
+
id: "openclaw-hyperspell",
|
|
40
|
+
start: () => {
|
|
41
|
+
api.logger.info("hyperspell: connected")
|
|
42
|
+
},
|
|
43
|
+
stop: () => {
|
|
44
|
+
api.logger.info("hyperspell: stopped")
|
|
45
|
+
},
|
|
46
|
+
})
|
|
47
|
+
},
|
|
48
|
+
}
|
package/lib/browser.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { exec } from "node:child_process"
|
|
2
|
+
import { platform } from "node:os"
|
|
3
|
+
|
|
4
|
+
export function openInBrowser(url: string): Promise<void> {
|
|
5
|
+
return new Promise((resolve, reject) => {
|
|
6
|
+
let command: string
|
|
7
|
+
|
|
8
|
+
switch (platform()) {
|
|
9
|
+
case "darwin":
|
|
10
|
+
command = `open "${url}"`
|
|
11
|
+
break
|
|
12
|
+
case "win32":
|
|
13
|
+
command = `start "" "${url}"`
|
|
14
|
+
break
|
|
15
|
+
default:
|
|
16
|
+
command = `xdg-open "${url}"`
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
exec(command, (error) => {
|
|
20
|
+
if (error) {
|
|
21
|
+
reject(error)
|
|
22
|
+
} else {
|
|
23
|
+
resolve()
|
|
24
|
+
}
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
}
|
package/logger.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
type Logger = {
|
|
2
|
+
info: (message: string, ...args: unknown[]) => void
|
|
3
|
+
warn: (message: string, ...args: unknown[]) => void
|
|
4
|
+
error: (message: string, ...args: unknown[]) => void
|
|
5
|
+
debug: (message: string, ...args: unknown[]) => void
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
let _logger: Logger = console
|
|
9
|
+
let _debug = false
|
|
10
|
+
|
|
11
|
+
export function initLogger(logger: Logger, debug: boolean): void {
|
|
12
|
+
_logger = logger
|
|
13
|
+
_debug = debug
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const log = {
|
|
17
|
+
info: (message: string, ...args: unknown[]) => {
|
|
18
|
+
_logger.info(`hyperspell: ${message}`, ...args)
|
|
19
|
+
},
|
|
20
|
+
warn: (message: string, ...args: unknown[]) => {
|
|
21
|
+
_logger.warn(`hyperspell: ${message}`, ...args)
|
|
22
|
+
},
|
|
23
|
+
error: (message: string, ...args: unknown[]) => {
|
|
24
|
+
_logger.error(`hyperspell: ${message}`, ...args)
|
|
25
|
+
},
|
|
26
|
+
debug: (message: string, ...args: unknown[]) => {
|
|
27
|
+
if (_debug) {
|
|
28
|
+
_logger.debug(`hyperspell: ${message}`, ...args)
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
debugRequest: (method: string, params: unknown) => {
|
|
32
|
+
if (_debug) {
|
|
33
|
+
_logger.debug(`hyperspell: [${method}] request`, params)
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
debugResponse: (method: string, result: unknown) => {
|
|
37
|
+
if (_debug) {
|
|
38
|
+
_logger.debug(`hyperspell: [${method}] response`, result)
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "openclaw-hyperspell",
|
|
3
|
+
"kind": "memory",
|
|
4
|
+
"uiHints": {
|
|
5
|
+
"apiKey": {
|
|
6
|
+
"label": "Hyperspell API Key",
|
|
7
|
+
"sensitive": true,
|
|
8
|
+
"placeholder": "hs_...",
|
|
9
|
+
"help": "Your API key from app.hyperspell.com (or use ${HYPERSPELL_API_KEY})"
|
|
10
|
+
},
|
|
11
|
+
"userId": {
|
|
12
|
+
"label": "User ID",
|
|
13
|
+
"placeholder": "user_123",
|
|
14
|
+
"help": "Optional user ID to scope searches. Required for non-JWT API keys.",
|
|
15
|
+
"advanced": true
|
|
16
|
+
},
|
|
17
|
+
"autoContext": {
|
|
18
|
+
"label": "Auto-Context",
|
|
19
|
+
"help": "Inject relevant memories before every AI turn"
|
|
20
|
+
},
|
|
21
|
+
"sources": {
|
|
22
|
+
"label": "Sources",
|
|
23
|
+
"placeholder": "notion,slack,google_drive",
|
|
24
|
+
"help": "Comma-separated list of sources to search. Leave empty for all sources.",
|
|
25
|
+
"advanced": true
|
|
26
|
+
},
|
|
27
|
+
"maxResults": {
|
|
28
|
+
"label": "Max Results",
|
|
29
|
+
"placeholder": "10",
|
|
30
|
+
"help": "Maximum memories injected into context per turn",
|
|
31
|
+
"advanced": true
|
|
32
|
+
},
|
|
33
|
+
"debug": {
|
|
34
|
+
"label": "Debug Logging",
|
|
35
|
+
"help": "Enable verbose debug logs for API calls and responses",
|
|
36
|
+
"advanced": true
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"configSchema": {
|
|
40
|
+
"type": "object",
|
|
41
|
+
"additionalProperties": false,
|
|
42
|
+
"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" }
|
|
49
|
+
},
|
|
50
|
+
"required": []
|
|
51
|
+
}
|
|
52
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hyperspell/openclaw-hyperspell",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "OpenClaw Hyperspell memory plugin",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"main": "./index.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"*.ts",
|
|
10
|
+
"commands/",
|
|
11
|
+
"hooks/",
|
|
12
|
+
"tools/",
|
|
13
|
+
"lib/",
|
|
14
|
+
"types/",
|
|
15
|
+
"openclaw.plugin.json",
|
|
16
|
+
"README.md"
|
|
17
|
+
],
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "https://github.com/hyperspell/openclaw-hyperspell.git"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"openclaw",
|
|
24
|
+
"hyperspell",
|
|
25
|
+
"memory",
|
|
26
|
+
"rag",
|
|
27
|
+
"ai",
|
|
28
|
+
"plugin"
|
|
29
|
+
],
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"hyperspell": "^0.30.0",
|
|
32
|
+
"@sinclair/typebox": "^0.34.0"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"openclaw": ">=2026.1.29"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"check-types": "tsc --noEmit",
|
|
39
|
+
"lint": "bunx @biomejs/biome ci .",
|
|
40
|
+
"lint:fix": "bunx @biomejs/biome check --write ."
|
|
41
|
+
},
|
|
42
|
+
"openclaw": {
|
|
43
|
+
"extensions": ["./index.ts"]
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"typescript": "^5.9.3"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Type } from "@sinclair/typebox"
|
|
2
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"
|
|
3
|
+
import type { HyperspellClient } from "../client.ts"
|
|
4
|
+
import type { HyperspellConfig } from "../config.ts"
|
|
5
|
+
import { log } from "../logger.ts"
|
|
6
|
+
|
|
7
|
+
export function registerRememberTool(
|
|
8
|
+
api: OpenClawPluginApi,
|
|
9
|
+
client: HyperspellClient,
|
|
10
|
+
_cfg: HyperspellConfig,
|
|
11
|
+
): void {
|
|
12
|
+
api.registerTool(
|
|
13
|
+
{
|
|
14
|
+
name: "hyperspell_remember",
|
|
15
|
+
label: "Memory Store",
|
|
16
|
+
description: "Save important information to the user's memory.",
|
|
17
|
+
parameters: Type.Object({
|
|
18
|
+
text: Type.String({ description: "Information to remember" }),
|
|
19
|
+
title: Type.Optional(
|
|
20
|
+
Type.String({ description: "Optional title for the memory" }),
|
|
21
|
+
),
|
|
22
|
+
}),
|
|
23
|
+
async execute(
|
|
24
|
+
_toolCallId: string,
|
|
25
|
+
params: { text: string; title?: string },
|
|
26
|
+
) {
|
|
27
|
+
log.debug(`remember tool: "${params.text.slice(0, 50)}..."`)
|
|
28
|
+
|
|
29
|
+
await client.addMemory(params.text, {
|
|
30
|
+
title: params.title,
|
|
31
|
+
metadata: { source: "openclaw_tool" },
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const preview =
|
|
35
|
+
params.text.length > 80 ? `${params.text.slice(0, 80)}…` : params.text
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
content: [{ type: "text" as const, text: `Stored: "${preview}"` }],
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
{ name: "hyperspell_remember" },
|
|
43
|
+
)
|
|
44
|
+
}
|
package/tools/search.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { Type } from "@sinclair/typebox"
|
|
2
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"
|
|
3
|
+
import type { HyperspellClient } from "../client.ts"
|
|
4
|
+
import type { HyperspellConfig } from "../config.ts"
|
|
5
|
+
import { log } from "../logger.ts"
|
|
6
|
+
|
|
7
|
+
export function registerSearchTool(
|
|
8
|
+
api: OpenClawPluginApi,
|
|
9
|
+
client: HyperspellClient,
|
|
10
|
+
_cfg: HyperspellConfig,
|
|
11
|
+
): void {
|
|
12
|
+
api.registerTool(
|
|
13
|
+
{
|
|
14
|
+
name: "hyperspell_search",
|
|
15
|
+
label: "Memory Search",
|
|
16
|
+
description:
|
|
17
|
+
"Search through the user's connected sources (Notion, Slack, Gmail, Google Drive, etc.) for relevant information.",
|
|
18
|
+
parameters: Type.Object({
|
|
19
|
+
query: Type.String({ description: "Search query" }),
|
|
20
|
+
limit: Type.Optional(
|
|
21
|
+
Type.Number({ description: "Max results (default: 5)" }),
|
|
22
|
+
),
|
|
23
|
+
}),
|
|
24
|
+
async execute(
|
|
25
|
+
_toolCallId: string,
|
|
26
|
+
params: { query: string; limit?: number },
|
|
27
|
+
) {
|
|
28
|
+
const limit = params.limit ?? 5
|
|
29
|
+
log.debug(`search tool: query="${params.query}" limit=${limit}`)
|
|
30
|
+
|
|
31
|
+
const results = await client.search(params.query, { limit })
|
|
32
|
+
|
|
33
|
+
if (results.length === 0) {
|
|
34
|
+
return {
|
|
35
|
+
content: [
|
|
36
|
+
{ type: "text" as const, text: "No relevant memories found." },
|
|
37
|
+
],
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
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")
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
content: [
|
|
53
|
+
{
|
|
54
|
+
type: "text" as const,
|
|
55
|
+
text: `Found ${results.length} memories:\n\n${text}`,
|
|
56
|
+
},
|
|
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
|
+
},
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
{ name: "hyperspell_search" },
|
|
71
|
+
)
|
|
72
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
declare module "openclaw/plugin-sdk" {
|
|
2
|
+
export interface OpenClawPluginApi {
|
|
3
|
+
pluginConfig: unknown
|
|
4
|
+
logger: {
|
|
5
|
+
info: (message: string, ...args: unknown[]) => void
|
|
6
|
+
warn: (message: string, ...args: unknown[]) => void
|
|
7
|
+
error: (message: string, ...args: unknown[]) => void
|
|
8
|
+
debug: (message: string, ...args: unknown[]) => void
|
|
9
|
+
}
|
|
10
|
+
registerCommand(options: {
|
|
11
|
+
name: string
|
|
12
|
+
description: string
|
|
13
|
+
acceptsArgs: boolean
|
|
14
|
+
requireAuth: boolean
|
|
15
|
+
handler: (ctx: { args?: string; senderId?: string; channel?: string }) => Promise<{ text: string }>
|
|
16
|
+
}): void
|
|
17
|
+
registerTool<T = unknown>(
|
|
18
|
+
options: {
|
|
19
|
+
name: string
|
|
20
|
+
label: string
|
|
21
|
+
description: string
|
|
22
|
+
parameters: unknown
|
|
23
|
+
execute: (
|
|
24
|
+
toolCallId: string,
|
|
25
|
+
params: T,
|
|
26
|
+
) => Promise<{
|
|
27
|
+
content: Array<{ type: "text"; text: string }>
|
|
28
|
+
details?: Record<string, unknown>
|
|
29
|
+
}>
|
|
30
|
+
},
|
|
31
|
+
meta: { name: string },
|
|
32
|
+
): void
|
|
33
|
+
on(event: string, handler: (event: Record<string, unknown>, ctx?: Record<string, unknown>) => Promise<{ prependContext?: string } | void> | void): void
|
|
34
|
+
registerService(options: {
|
|
35
|
+
id: string
|
|
36
|
+
start: () => void
|
|
37
|
+
stop: () => void
|
|
38
|
+
}): void
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function stringEnum<T extends string>(values: readonly T[]): unknown
|
|
42
|
+
}
|