@hyperspell/openclaw-hyperspell 0.5.0 → 0.7.2

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
@@ -1,206 +1,273 @@
1
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"
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
12
 
13
13
  export type KnowledgeGraphConfig = {
14
- enabled: boolean
15
- scanIntervalMinutes: number
16
- batchSize: number
17
- }
14
+ enabled: boolean;
15
+ scanIntervalMinutes: number;
16
+ batchSize: number;
17
+ };
18
+
19
+ export type AutoTraceConfig = {
20
+ enabled: boolean;
21
+ extract: Array<"procedure" | "memory" | "mood">;
22
+ metadata?: Record<string, string | number | boolean>;
23
+ };
18
24
 
19
25
  export type HyperspellConfig = {
20
- apiKey: string
21
- userId?: string
22
- autoContext: boolean
23
- syncMemories: boolean
24
- sources: HyperspellSource[]
25
- maxResults: number
26
- debug: boolean
27
- knowledgeGraph: KnowledgeGraphConfig
28
- }
26
+ apiKey: string;
27
+ userId?: string;
28
+ autoContext: boolean;
29
+ autoTrace: AutoTraceConfig;
30
+ syncMemories: boolean;
31
+ sources: HyperspellSource[];
32
+ maxResults: number;
33
+ relevanceThreshold: number;
34
+ debug: boolean;
35
+ knowledgeGraph: KnowledgeGraphConfig;
36
+ };
29
37
 
30
38
  const ALLOWED_KEYS = [
31
- "apiKey",
32
- "userId",
33
- "autoContext",
34
- "syncMemories",
35
- "sources",
36
- "maxResults",
37
- "debug",
38
- "knowledgeGraph",
39
- ]
39
+ "apiKey",
40
+ "userId",
41
+ "autoContext",
42
+ "autoTrace",
43
+ "syncMemories",
44
+ "sources",
45
+ "maxResults",
46
+ "relevanceThreshold",
47
+ "debug",
48
+ "knowledgeGraph",
49
+ ];
40
50
 
41
51
  const VALID_SOURCES: HyperspellSource[] = [
42
- "collections",
43
- "reddit",
44
- "notion",
45
- "slack",
46
- "google_calendar",
47
- "google_mail",
48
- "box",
49
- "google_drive",
50
- "vault",
51
- "web_crawler",
52
- ]
52
+ "collections",
53
+ "reddit",
54
+ "notion",
55
+ "slack",
56
+ "google_calendar",
57
+ "google_mail",
58
+ "box",
59
+ "google_drive",
60
+ "vault",
61
+ "web_crawler",
62
+ ];
53
63
 
54
64
  function assertAllowedKeys(
55
- value: Record<string, unknown>,
56
- allowed: string[],
57
- label: string,
65
+ value: Record<string, unknown>,
66
+ allowed: string[],
67
+ label: string,
58
68
  ): void {
59
- const unknown = Object.keys(value).filter((k) => !allowed.includes(k))
60
- if (unknown.length > 0) {
61
- throw new Error(`${label} has unknown keys: ${unknown.join(", ")}`)
62
- }
69
+ const unknown = Object.keys(value).filter((k) => !allowed.includes(k));
70
+ if (unknown.length > 0) {
71
+ throw new Error(`${label} has unknown keys: ${unknown.join(", ")}`);
72
+ }
63
73
  }
64
74
 
65
75
  function resolveEnvVars(value: string): string {
66
- return value.replace(/\$\{([^}]+)\}/g, (_, envVar: string) => {
67
- const envValue = process.env[envVar]
68
- if (!envValue) {
69
- throw new Error(`Environment variable ${envVar} is not set`)
70
- }
71
- return envValue
72
- })
76
+ return value.replace(/\$\{([^}]+)\}/g, (_, envVar: string) => {
77
+ const envValue = process.env[envVar];
78
+ if (!envValue) {
79
+ throw new Error(`Environment variable ${envVar} is not set`);
80
+ }
81
+ return envValue;
82
+ });
73
83
  }
74
84
 
75
85
  function parseSources(raw: string | string[] | undefined): HyperspellSource[] {
76
- if (!raw) {
77
- return []
78
- }
79
-
80
- // Handle array input
81
- if (Array.isArray(raw)) {
82
- const sources = raw
83
- .map((s) => String(s).trim().toLowerCase())
84
- .filter((s) => s.length > 0) as HyperspellSource[]
85
-
86
- for (const source of sources) {
87
- if (!VALID_SOURCES.includes(source)) {
88
- throw new Error(
89
- `Invalid source "${source}". Valid sources: ${VALID_SOURCES.join(", ")}`,
90
- )
91
- }
92
- }
93
-
94
- return sources
95
- }
96
-
97
- // Handle string input (comma-separated)
98
- if (typeof raw === "string" && raw.trim() === "") {
99
- return []
100
- }
101
-
102
- const sources = raw
103
- .split(",")
104
- .map((s) => s.trim().toLowerCase())
105
- .filter((s) => s.length > 0) as HyperspellSource[]
106
-
107
- for (const source of sources) {
108
- if (!VALID_SOURCES.includes(source)) {
109
- throw new Error(
110
- `Invalid source "${source}". Valid sources: ${VALID_SOURCES.join(", ")}`,
111
- )
112
- }
113
- }
114
-
115
- return sources
86
+ if (!raw) {
87
+ return [];
88
+ }
89
+
90
+ // Handle array input
91
+ if (Array.isArray(raw)) {
92
+ const sources = raw
93
+ .map((s) => String(s).trim().toLowerCase())
94
+ .filter((s) => s.length > 0) as HyperspellSource[];
95
+
96
+ for (const source of sources) {
97
+ if (!VALID_SOURCES.includes(source)) {
98
+ throw new Error(
99
+ `Invalid source "${source}". Valid sources: ${VALID_SOURCES.join(", ")}`,
100
+ );
101
+ }
102
+ }
103
+
104
+ return sources;
105
+ }
106
+
107
+ // Handle string input (comma-separated)
108
+ if (typeof raw === "string" && raw.trim() === "") {
109
+ return [];
110
+ }
111
+
112
+ const sources = raw
113
+ .split(",")
114
+ .map((s) => s.trim().toLowerCase())
115
+ .filter((s) => s.length > 0) as HyperspellSource[];
116
+
117
+ for (const source of sources) {
118
+ if (!VALID_SOURCES.includes(source)) {
119
+ throw new Error(
120
+ `Invalid source "${source}". Valid sources: ${VALID_SOURCES.join(", ")}`,
121
+ );
122
+ }
123
+ }
124
+
125
+ return sources;
116
126
  }
117
127
 
118
128
  export function parseConfig(raw: unknown): HyperspellConfig {
119
- const cfg =
120
- raw && typeof raw === "object" && !Array.isArray(raw)
121
- ? (raw as Record<string, unknown>)
122
- : {}
123
-
124
- if (Object.keys(cfg).length > 0) {
125
- assertAllowedKeys(cfg, ALLOWED_KEYS, "hyperspell config")
126
- }
127
-
128
- const apiKey =
129
- typeof cfg.apiKey === "string" && cfg.apiKey.length > 0
130
- ? resolveEnvVars(cfg.apiKey)
131
- : process.env.HYPERSPELL_API_KEY
132
-
133
- if (!apiKey) {
134
- throw new Error(
135
- "hyperspell: apiKey is required (set in plugin config or HYPERSPELL_API_KEY env var)",
136
- )
137
- }
138
-
139
- const kgRaw = (cfg.knowledgeGraph ?? {}) as Record<string, unknown>
140
-
141
- return {
142
- apiKey,
143
- userId: cfg.userId as string | undefined,
144
- autoContext: (cfg.autoContext as boolean) ?? true,
145
- syncMemories: (cfg.syncMemories as boolean) ?? false,
146
- sources: parseSources(cfg.sources as string | string[] | undefined),
147
- maxResults: (cfg.maxResults as number) ?? 10,
148
- debug: (cfg.debug as boolean) ?? false,
149
- knowledgeGraph: {
150
- enabled: (kgRaw.enabled as boolean) ?? false,
151
- scanIntervalMinutes: (kgRaw.scanIntervalMinutes as number) ?? 60,
152
- batchSize: (kgRaw.batchSize as number) ?? 20,
153
- },
154
- }
129
+ const cfg =
130
+ raw && typeof raw === "object" && !Array.isArray(raw)
131
+ ? (raw as Record<string, unknown>)
132
+ : {};
133
+
134
+ if (Object.keys(cfg).length > 0) {
135
+ assertAllowedKeys(cfg, ALLOWED_KEYS, "hyperspell config");
136
+ }
137
+
138
+ const apiKey =
139
+ typeof cfg.apiKey === "string" && cfg.apiKey.length > 0
140
+ ? resolveEnvVars(cfg.apiKey)
141
+ : process.env.HYPERSPELL_API_KEY;
142
+
143
+ if (!apiKey) {
144
+ throw new Error(
145
+ "hyperspell: apiKey is required (set in plugin config or HYPERSPELL_API_KEY env var)",
146
+ );
147
+ }
148
+
149
+ const kgRaw = (cfg.knowledgeGraph ?? {}) as Record<string, unknown>;
150
+ const atRaw = (cfg.autoTrace ?? {}) as Record<string, unknown>;
151
+
152
+ return {
153
+ apiKey,
154
+ userId: cfg.userId as string | undefined,
155
+ autoContext: (cfg.autoContext as boolean) ?? true,
156
+ autoTrace: {
157
+ enabled: (atRaw.enabled as boolean) ?? false,
158
+ extract: (atRaw.extract as Array<"procedure" | "memory" | "mood">) ?? [
159
+ "procedure",
160
+ ],
161
+ metadata: atRaw.metadata as
162
+ | Record<string, string | number | boolean>
163
+ | undefined,
164
+ },
165
+ syncMemories: (cfg.syncMemories as boolean) ?? false,
166
+ sources: parseSources(cfg.sources as string | string[] | undefined),
167
+ maxResults: (cfg.maxResults as number) ?? 10,
168
+ relevanceThreshold: (cfg.relevanceThreshold as number) ?? 0.6,
169
+ debug: (cfg.debug as boolean) ?? false,
170
+ knowledgeGraph: {
171
+ enabled: (kgRaw.enabled as boolean) ?? false,
172
+ scanIntervalMinutes: (kgRaw.scanIntervalMinutes as number) ?? 60,
173
+ batchSize: (kgRaw.batchSize as number) ?? 20,
174
+ },
175
+ };
155
176
  }
156
177
 
157
178
  export const hyperspellConfigSchema = {
158
- parse: parseConfig,
179
+ parse: parseConfig,
180
+ };
181
+
182
+ /**
183
+ * Resolve OpenClaw state directory (matches OpenClaw's own logic).
184
+ */
185
+ export function resolveStateDir(): string {
186
+ const { homedir } = require("node:os");
187
+ const path = require("node:path");
188
+
189
+ const override =
190
+ process.env.OPENCLAW_STATE_DIR?.trim() ||
191
+ process.env.CLAWDBOT_STATE_DIR?.trim();
192
+ if (override) {
193
+ return override.startsWith("~")
194
+ ? override.replace(/^~(?=$|[\\/])/, homedir())
195
+ : path.resolve(override);
196
+ }
197
+ return path.join(homedir(), ".openclaw");
198
+ }
199
+
200
+ /**
201
+ * Resolve OpenClaw config file path (matches OpenClaw's own logic).
202
+ */
203
+ export function resolveConfigPath(): string {
204
+ const path = require("node:path");
205
+
206
+ const override =
207
+ process.env.OPENCLAW_CONFIG_PATH?.trim() ||
208
+ process.env.CLAWDBOT_CONFIG_PATH?.trim();
209
+ if (override) {
210
+ const { homedir } = require("node:os");
211
+ return override.startsWith("~")
212
+ ? override.replace(/^~(?=$|[\\/])/, homedir())
213
+ : path.resolve(override);
214
+ }
215
+ return path.join(resolveStateDir(), "openclaw.json");
159
216
  }
160
217
 
161
218
  /**
162
219
  * Get the workspace directory from OpenClaw config
163
220
  */
164
221
  export function getWorkspaceDir(): string {
165
- const { homedir } = require("node:os")
166
- const fs = require("node:fs")
167
- const path = require("node:path")
168
-
169
- // Resolve config path
170
- const override = process.env.OPENCLAW_CONFIG_PATH?.trim() || process.env.CLAWDBOT_CONFIG_PATH?.trim()
171
- let configPath: string
172
- if (override) {
173
- configPath = override.startsWith("~")
174
- ? override.replace(/^~(?=$|[\\/])/, homedir())
175
- : path.resolve(override)
176
- } else {
177
- const stateDir = process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim()
178
- const resolvedStateDir = stateDir
179
- ? (stateDir.startsWith("~") ? stateDir.replace(/^~(?=$|[\\/])/, homedir()) : path.resolve(stateDir))
180
- : path.join(homedir(), ".openclaw")
181
- configPath = path.join(resolvedStateDir, "openclaw.json")
182
- }
183
-
184
- // Read workspace from config
185
- if (fs.existsSync(configPath)) {
186
- try {
187
- const content = fs.readFileSync(configPath, "utf-8")
188
- const config = JSON.parse(content)
189
- const workspace = config?.agents?.defaults?.workspace
190
- if (workspace) {
191
- return workspace.startsWith("~")
192
- ? workspace.replace(/^~(?=$|[\\/])/, homedir())
193
- : workspace
194
- }
195
- } catch (_e) {
196
- // Fall back to default
197
- }
198
- }
199
-
200
- // Default workspace
201
- const stateDir = process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim()
202
- const resolvedStateDir = stateDir
203
- ? (stateDir.startsWith("~") ? stateDir.replace(/^~(?=$|[\\/])/, homedir()) : path.resolve(stateDir))
204
- : path.join(homedir(), ".openclaw")
205
- return path.join(resolvedStateDir, "workspace")
222
+ const { homedir } = require("node:os");
223
+ const fs = require("node:fs");
224
+ const path = require("node:path");
225
+
226
+ // Resolve config path
227
+ const override =
228
+ process.env.OPENCLAW_CONFIG_PATH?.trim() ||
229
+ process.env.CLAWDBOT_CONFIG_PATH?.trim();
230
+ let configPath: string;
231
+ if (override) {
232
+ configPath = override.startsWith("~")
233
+ ? override.replace(/^~(?=$|[\\/])/, homedir())
234
+ : path.resolve(override);
235
+ } else {
236
+ const stateDir =
237
+ process.env.OPENCLAW_STATE_DIR?.trim() ||
238
+ process.env.CLAWDBOT_STATE_DIR?.trim();
239
+ const resolvedStateDir = stateDir
240
+ ? stateDir.startsWith("~")
241
+ ? stateDir.replace(/^~(?=$|[\\/])/, homedir())
242
+ : path.resolve(stateDir)
243
+ : path.join(homedir(), ".openclaw");
244
+ configPath = path.join(resolvedStateDir, "openclaw.json");
245
+ }
246
+
247
+ // Read workspace from config
248
+ if (fs.existsSync(configPath)) {
249
+ try {
250
+ const content = fs.readFileSync(configPath, "utf-8");
251
+ const config = JSON.parse(content);
252
+ const workspace = config?.agents?.defaults?.workspace;
253
+ if (workspace) {
254
+ return workspace.startsWith("~")
255
+ ? workspace.replace(/^~(?=$|[\\/])/, homedir())
256
+ : workspace;
257
+ }
258
+ } catch (_e) {
259
+ // Fall back to default
260
+ }
261
+ }
262
+
263
+ // Default workspace
264
+ const stateDir =
265
+ process.env.OPENCLAW_STATE_DIR?.trim() ||
266
+ process.env.CLAWDBOT_STATE_DIR?.trim();
267
+ const resolvedStateDir = stateDir
268
+ ? stateDir.startsWith("~")
269
+ ? stateDir.replace(/^~(?=$|[\\/])/, homedir())
270
+ : path.resolve(stateDir)
271
+ : path.join(homedir(), ".openclaw");
272
+ return path.join(resolvedStateDir, "workspace");
206
273
  }
@@ -26,25 +26,31 @@ function formatRelativeTime(isoTimestamp: string): string {
26
26
  }
27
27
  }
28
28
 
29
- function formatContext(results: SearchResult[], maxResults: number): string | null {
30
- const limited = results.slice(0, maxResults)
29
+ function formatContext(results: SearchResult[], maxResults: number, threshold: number): string | null {
30
+ const sections: string[] = []
31
31
 
32
- if (limited.length === 0) return null
32
+ for (const r of results.slice(0, maxResults)) {
33
+ if ((r.score ?? 0) < threshold) continue
34
+
35
+ const aboveThreshold = r.highlights.filter((h) => h.score >= threshold)
36
+ if (aboveThreshold.length === 0) continue
33
37
 
34
- const lines = limited.map((r) => {
35
38
  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
- })
39
+ const bullets = aboveThreshold
40
+ .map((h) => `- ${h.text.replace(/\n/g, " ")} [${Math.round(h.score * 100)}%]`)
41
+ .join("\n")
42
+
43
+ sections.push(`### ${title} (resource_id: ${r.resourceId}, source: ${r.source})\n\n${bullets}`)
44
+ }
45
+
46
+ if (sections.length === 0) return null
41
47
 
42
48
  const intro =
43
49
  "The following is context from the user's connected sources. Reference it only when relevant to the conversation."
44
50
  const disclaimer =
45
51
  "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
52
 
47
- return `<hyperspell-context>\n${intro}\n\n## Relevant Memories (with relevance %)\n${lines.join("\n")}\n\n${disclaimer}\n</hyperspell-context>`
53
+ return `<hyperspell-context>\n${intro}\n\n${sections.join("\n\n")}\n\n${disclaimer}\n</hyperspell-context>`
48
54
  }
49
55
 
50
56
  export function buildAutoContextHandler(
@@ -59,7 +65,7 @@ export function buildAutoContextHandler(
59
65
 
60
66
  try {
61
67
  const results = await client.search(prompt, { limit: cfg.maxResults })
62
- const context = formatContext(results, cfg.maxResults)
68
+ const context = formatContext(results, cfg.maxResults, cfg.relevanceThreshold)
63
69
 
64
70
  if (!context) {
65
71
  log.debug("auto-context: no relevant memories found")
@@ -0,0 +1,127 @@
1
+ import type { HyperspellClient } from "../client.ts";
2
+ import type { HyperspellConfig } from "../config.ts";
3
+ import { log } from "../logger.ts";
4
+
5
+ type Message = { role?: string; content?: string | unknown };
6
+
7
+ const MIN_MESSAGES = 3;
8
+ const MIN_CONVERSATION_LENGTH = 100;
9
+
10
+ /**
11
+ * Convert event.messages into OpenClaw JSONL format for the trace API.
12
+ */
13
+ function messagesToJSONL(messages: unknown[], sessionId: string): string {
14
+ const lines: string[] = [];
15
+
16
+ // Session header
17
+ lines.push(
18
+ JSON.stringify({
19
+ type: "session",
20
+ version: 3,
21
+ id: sessionId,
22
+ timestamp: new Date().toISOString(),
23
+ }),
24
+ );
25
+
26
+ // Message entries
27
+ for (const raw of messages as Message[]) {
28
+ if (!raw.role || !raw.content) continue;
29
+
30
+ const content =
31
+ typeof raw.content === "string"
32
+ ? [{ type: "text", text: raw.content }]
33
+ : Array.isArray(raw.content)
34
+ ? raw.content
35
+ : [{ type: "text", text: JSON.stringify(raw.content) }];
36
+
37
+ const id = crypto.randomUUID().slice(0, 8);
38
+
39
+ if (raw.role === "tool" || raw.role === "toolResult") {
40
+ // Tool results use a different structure
41
+ lines.push(
42
+ JSON.stringify({
43
+ type: "message",
44
+ id,
45
+ timestamp: new Date().toISOString(),
46
+ message: {
47
+ role: "toolResult",
48
+ toolCallId: (raw as Record<string, unknown>).toolCallId ?? id,
49
+ toolName: (raw as Record<string, unknown>).toolName ?? "unknown",
50
+ content,
51
+ isError: (raw as Record<string, unknown>).isError ?? false,
52
+ },
53
+ }),
54
+ );
55
+ } else {
56
+ lines.push(
57
+ JSON.stringify({
58
+ type: "message",
59
+ id,
60
+ timestamp: new Date().toISOString(),
61
+ message: { role: raw.role, content },
62
+ }),
63
+ );
64
+ }
65
+ }
66
+
67
+ return lines.join("\n");
68
+ }
69
+
70
+ /**
71
+ * Extract and store conversation trace at session end.
72
+ * Runs on `agent_end` — fire-and-forget.
73
+ */
74
+ export function buildAutoTraceHandler(
75
+ client: HyperspellClient,
76
+ cfg: HyperspellConfig,
77
+ ) {
78
+ return async (event: Record<string, unknown>) => {
79
+ if (event.success === false) {
80
+ log.debug("auto-trace: skipping — agent ended with error");
81
+ return;
82
+ }
83
+
84
+ const messages = event.messages as unknown[] | undefined;
85
+ if (!messages || messages.length < MIN_MESSAGES) {
86
+ log.debug(
87
+ `auto-trace: skipping — too few messages (${messages?.length ?? 0})`,
88
+ );
89
+ return;
90
+ }
91
+
92
+ // Quick content length check
93
+ const estimate = (messages as Message[])
94
+ .filter((m) => m.content)
95
+ .reduce((acc, m) => acc + String(m.content).length, 0);
96
+ if (estimate < MIN_CONVERSATION_LENGTH) {
97
+ log.debug(
98
+ `auto-trace: skipping — conversation too short (${estimate} chars)`,
99
+ );
100
+ return;
101
+ }
102
+
103
+ const sessionId = (event.sessionId as string) ?? crypto.randomUUID();
104
+ const history = messagesToJSONL(messages, sessionId);
105
+
106
+ // Title from first user message
107
+ const firstUser = (messages as Message[]).find((m) => m.role === "user");
108
+ const title = firstUser?.content
109
+ ? String(firstUser.content).slice(0, 80).replace(/\n/g, " ")
110
+ : undefined;
111
+
112
+ try {
113
+ const result = await client.sendTrace(history, {
114
+ sessionId,
115
+ title,
116
+ extract: cfg.autoTrace.extract,
117
+ metadata: cfg.autoTrace.metadata,
118
+ });
119
+ log.info(
120
+ `auto-trace: sent ${result.resourceId} (${messages.length} messages)`,
121
+ );
122
+ } catch (err) {
123
+ // Fire-and-forget — never break the session
124
+ log.error("auto-trace failed", err);
125
+ }
126
+ };
127
+ }