@hyperspell/openclaw-hyperspell 0.11.1 → 0.13.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.
Files changed (50) hide show
  1. package/README.md +37 -1
  2. package/dist/client.js +341 -0
  3. package/dist/commands/setup.js +569 -0
  4. package/dist/commands/slash.js +159 -0
  5. package/dist/config.js +349 -0
  6. package/{graph/cron.ts → dist/graph/cron.js} +11 -15
  7. package/dist/graph/index.js +4 -0
  8. package/dist/graph/ops.js +221 -0
  9. package/dist/graph/state.js +68 -0
  10. package/dist/graph/tools.js +87 -0
  11. package/dist/hooks/auto-context.js +199 -0
  12. package/dist/hooks/auto-trace.js +141 -0
  13. package/dist/hooks/emotional-state.js +155 -0
  14. package/dist/hooks/memory-sync.js +54 -0
  15. package/dist/hooks/startup-orientation.js +156 -0
  16. package/dist/index.js +132 -0
  17. package/dist/lib/browser.js +29 -0
  18. package/dist/lib/sender.js +173 -0
  19. package/dist/lib/voice-id.js +22 -0
  20. package/dist/logger.js +32 -0
  21. package/dist/sync/markdown.js +151 -0
  22. package/dist/tools/remember.js +97 -0
  23. package/dist/tools/search.js +87 -0
  24. package/openclaw.plugin.json +17 -1
  25. package/package.json +7 -12
  26. package/client.ts +0 -566
  27. package/commands/setup.ts +0 -673
  28. package/commands/slash.ts +0 -198
  29. package/config.test.ts +0 -202
  30. package/config.ts +0 -497
  31. package/graph/index.ts +0 -5
  32. package/graph/ops.ts +0 -259
  33. package/graph/state.ts +0 -79
  34. package/graph/tools.ts +0 -117
  35. package/hooks/auto-context.ts +0 -272
  36. package/hooks/auto-trace.test.ts +0 -81
  37. package/hooks/auto-trace.ts +0 -197
  38. package/hooks/emotional-state.test.ts +0 -160
  39. package/hooks/emotional-state.ts +0 -179
  40. package/hooks/memory-sync.ts +0 -65
  41. package/index.ts +0 -152
  42. package/lib/browser.ts +0 -31
  43. package/lib/sender.test.ts +0 -234
  44. package/lib/sender.ts +0 -234
  45. package/lib/voice-id.ts +0 -39
  46. package/logger.ts +0 -41
  47. package/sync/markdown.ts +0 -186
  48. package/tools/remember.ts +0 -132
  49. package/tools/search.ts +0 -131
  50. package/types/openclaw.d.ts +0 -76
@@ -0,0 +1,141 @@
1
+ import { resolveUser } from "../lib/sender.js";
2
+ import { log } from "../logger.js";
3
+ const MIN_MESSAGES = 3;
4
+ const MIN_CONVERSATION_LENGTH = 100;
5
+ /**
6
+ * Strip transport/injection metadata from a text blob before it's stored as a
7
+ * trace memory. Without this the auto-context and emotional-state hooks'
8
+ * prepended wrappers get captured verbatim, extracted as "memory content",
9
+ * and then surface again on the next session's retrieval — a self-amplifying
10
+ * pollution loop.
11
+ */
12
+ export function sanitizeTraceText(input) {
13
+ let out = input;
14
+ out = out.replace(/<hyperspell-context>[\s\S]*?<\/hyperspell-context>\n?/g, "");
15
+ out = out.replace(/<hyperspell-emotional-context>[\s\S]*?<\/hyperspell-emotional-context>\n?/g, "");
16
+ out = out.replace(/Sender \(untrusted metadata\):\s*```json[\s\S]*?```\n?/g, "");
17
+ out = out.replace(/\[Bootstrap pending\][\s\S]*?(?=\n{2,}|\nSystem:|\nSender|$)/g, "");
18
+ out = out.replace(/\[Startup context loaded by runtime\][\s\S]*?(?:\n\n|$)/g, "");
19
+ out = out.replace(/\[Untrusted daily memory:[^\]]*\][\s\S]*?END_QUOTED_NOTES\n?/g, "");
20
+ out = out.replace(/^System(?:\s+\(untrusted\))?:\s*\[[^\]]+\][^\n]*\n?/gm, "");
21
+ out = out.replace(/\n{3,}/g, "\n\n").trim();
22
+ return out;
23
+ }
24
+ function sanitizeContent(content) {
25
+ const items = typeof content === "string"
26
+ ? [{ type: "text", text: content }]
27
+ : Array.isArray(content)
28
+ ? content
29
+ : [{ type: "text", text: JSON.stringify(content) }];
30
+ const cleaned = [];
31
+ for (const item of items) {
32
+ if (item?.type === "text" && typeof item.text === "string") {
33
+ const text = sanitizeTraceText(item.text);
34
+ if (text.length > 0)
35
+ cleaned.push({ ...item, text });
36
+ }
37
+ else if (item) {
38
+ cleaned.push(item);
39
+ }
40
+ }
41
+ return cleaned;
42
+ }
43
+ /**
44
+ * Convert event.messages into OpenClaw JSONL format for the trace API.
45
+ */
46
+ function messagesToJSONL(messages, sessionId) {
47
+ const lines = [];
48
+ lines.push(JSON.stringify({
49
+ type: "session",
50
+ version: 3,
51
+ id: sessionId,
52
+ timestamp: new Date().toISOString(),
53
+ }));
54
+ for (const raw of messages) {
55
+ if (!raw.role || !raw.content)
56
+ continue;
57
+ if (raw.role === "system")
58
+ continue;
59
+ const content = sanitizeContent(raw.content);
60
+ if (content.length === 0)
61
+ continue;
62
+ const id = crypto.randomUUID().slice(0, 8);
63
+ if (raw.role === "tool" || raw.role === "toolResult") {
64
+ lines.push(JSON.stringify({
65
+ type: "message",
66
+ id,
67
+ timestamp: new Date().toISOString(),
68
+ message: {
69
+ role: "toolResult",
70
+ toolCallId: raw.toolCallId ?? id,
71
+ toolName: raw.toolName ?? "unknown",
72
+ content,
73
+ isError: raw.isError ?? false,
74
+ },
75
+ }));
76
+ }
77
+ else {
78
+ lines.push(JSON.stringify({
79
+ type: "message",
80
+ id,
81
+ timestamp: new Date().toISOString(),
82
+ message: { role: raw.role, content },
83
+ }));
84
+ }
85
+ }
86
+ return lines.join("\n");
87
+ }
88
+ /**
89
+ * Extract and store conversation trace at session end.
90
+ * Runs on `agent_end` — fire-and-forget.
91
+ */
92
+ export function buildAutoTraceHandler(client, cfg) {
93
+ return async (event, ctx) => {
94
+ if (event.success === false) {
95
+ log.debug("auto-trace: skipping — agent ended with error");
96
+ return;
97
+ }
98
+ const messages = event.messages;
99
+ if (!messages || messages.length < MIN_MESSAGES) {
100
+ log.debug(`auto-trace: skipping — too few messages (${messages?.length ?? 0})`);
101
+ return;
102
+ }
103
+ // Quick content length check
104
+ const estimate = messages
105
+ .filter((m) => m.content)
106
+ .reduce((acc, m) => acc + String(m.content).length, 0);
107
+ if (estimate < MIN_CONVERSATION_LENGTH) {
108
+ log.debug(`auto-trace: skipping — conversation too short (${estimate} chars)`);
109
+ return;
110
+ }
111
+ const sessionId = event.sessionId ?? crypto.randomUUID();
112
+ const history = messagesToJSONL(messages, sessionId);
113
+ // Title from first user message
114
+ const firstUser = messages.find((m) => m.role === "user");
115
+ const title = firstUser?.content
116
+ ? String(firstUser.content).slice(0, 80).replace(/\n/g, " ")
117
+ : undefined;
118
+ // Resolve sender → userId so traces land in the right user's space.
119
+ // Falls back to sharedUserId for unknown senders (or undefined in single-user mode).
120
+ const resolved = resolveUser(ctx, cfg);
121
+ const userId = resolved?.userId;
122
+ try {
123
+ const result = await client.sendTrace(history, {
124
+ sessionId,
125
+ title,
126
+ extract: cfg.autoTrace.extract,
127
+ metadata: cfg.autoTrace.metadata,
128
+ userId,
129
+ // Auto-trace captures full conversation text — the most sensitive class
130
+ // of memory. Default to private; users opt into family-visible recall
131
+ // via explicit /remember.
132
+ scope: "private",
133
+ });
134
+ log.info(`auto-trace: sent ${result.resourceId} (${messages.length} messages${userId ? `, user=${userId}` : ""})`);
135
+ }
136
+ catch (err) {
137
+ // Fire-and-forget — never break the session
138
+ log.error("auto-trace failed", err);
139
+ }
140
+ };
141
+ }
@@ -0,0 +1,155 @@
1
+ import { log } from "../logger.js";
2
+ import { sanitizeTraceText } from "./auto-trace.js";
3
+ const MIN_MESSAGES = 3;
4
+ const MIN_CONVERSATION_LENGTH = 100;
5
+ /**
6
+ * Sessions where emotional context has already been injected this run.
7
+ * Emotional state doesn't change within a session (it's extracted at
8
+ * agent_end and surfaces on the *next* session), so re-fetching and
9
+ * re-injecting on every turn is pure cost — one API call and a few
10
+ * hundred tokens of repeated wrapper per turn.
11
+ *
12
+ * Lifecycle:
13
+ * - first before_agent_start in a session: fetch, inject, mark.
14
+ * - subsequent turns in same session: skip (return undefined).
15
+ * - after_compaction: clear the mark so the next turn re-injects (the
16
+ * initial injection may have been compacted out of history).
17
+ * - session_end: clean up to prevent unbounded Set growth.
18
+ */
19
+ const injectedSessions = new Set();
20
+ /**
21
+ * Extract readable text from a message content, unwrapping the common
22
+ * `[{ type: "text", text: "..." }]` array shape so sanitizeTraceText can
23
+ * operate on real text (not JSON-stringified content where newlines would
24
+ * be escaped and regex line-anchors wouldn't match).
25
+ */
26
+ function contentToText(content) {
27
+ if (typeof content === "string")
28
+ return content;
29
+ if (Array.isArray(content)) {
30
+ const texts = [];
31
+ for (const item of content) {
32
+ if (item &&
33
+ typeof item === "object" &&
34
+ item.type === "text" &&
35
+ typeof item.text === "string") {
36
+ texts.push(item.text);
37
+ }
38
+ }
39
+ if (texts.length > 0)
40
+ return texts.join("\n");
41
+ }
42
+ return "";
43
+ }
44
+ function messagesToTranscript(messages) {
45
+ const lines = [];
46
+ for (const m of messages) {
47
+ if (!m.role || !m.content)
48
+ continue;
49
+ if (m.role === "system")
50
+ continue;
51
+ const raw = contentToText(m.content);
52
+ if (!raw)
53
+ continue;
54
+ const cleaned = sanitizeTraceText(raw);
55
+ if (cleaned.length === 0)
56
+ continue;
57
+ lines.push(`${m.role}: ${cleaned}`);
58
+ }
59
+ return lines.join("\n");
60
+ }
61
+ /**
62
+ * Fetch emotional state on the first agent turn of a session and inject into
63
+ * context. On later turns of the same session, return undefined — the
64
+ * injection from the first turn is already in the conversation history.
65
+ *
66
+ * Runs on `before_agent_start` (which fires every turn).
67
+ */
68
+ export function buildEmotionalStateFetchHandler(client, cfg) {
69
+ return async (_event, ctx) => {
70
+ const sessionKey = ctx?.sessionKey;
71
+ if (sessionKey && injectedSessions.has(sessionKey)) {
72
+ return;
73
+ }
74
+ try {
75
+ const state = await client.getEmotionalState(cfg.relationshipId);
76
+ if (!state) {
77
+ log.debug("emotional-context: no prior emotional state found");
78
+ if (sessionKey)
79
+ injectedSessions.add(sessionKey);
80
+ return;
81
+ }
82
+ log.debug(`emotional-context: injecting state from ${state.extractedAt}`);
83
+ const context = [
84
+ "<hyperspell-emotional-context>",
85
+ "The following captures the emotional register of your relationship with this user from your last interaction. Let it inform your tone — don't reference it explicitly.",
86
+ "",
87
+ state.summary,
88
+ "</hyperspell-emotional-context>",
89
+ ].join("\n");
90
+ if (sessionKey)
91
+ injectedSessions.add(sessionKey);
92
+ return { prependContext: context };
93
+ }
94
+ catch (err) {
95
+ log.error("emotional-context fetch failed", err);
96
+ return;
97
+ }
98
+ };
99
+ }
100
+ /**
101
+ * After compaction, the emotional-context block from the first turn may have
102
+ * been trimmed out of history. Clear the cache so the next turn re-injects.
103
+ */
104
+ export function buildEmotionalStateCompactionHandler() {
105
+ return async (_event, ctx) => {
106
+ const sessionKey = ctx?.sessionKey;
107
+ if (sessionKey && injectedSessions.delete(sessionKey)) {
108
+ log.debug(`emotional-context: cache cleared after compaction (session=${sessionKey})`);
109
+ }
110
+ };
111
+ }
112
+ /**
113
+ * Remove session from the inject-once cache when the session ends, to keep the
114
+ * Set from growing unbounded over process lifetime.
115
+ */
116
+ export function buildEmotionalStateSessionCleanupHandler() {
117
+ return async (_event, ctx) => {
118
+ const sessionKey = ctx?.sessionKey;
119
+ if (sessionKey)
120
+ injectedSessions.delete(sessionKey);
121
+ };
122
+ }
123
+ /**
124
+ * Extract and store emotional state at session end.
125
+ * Runs on `agent_end` — fire-and-forget.
126
+ */
127
+ export function buildEmotionalStateStoreHandler(client, cfg) {
128
+ return async (event) => {
129
+ if (event.success === false) {
130
+ log.debug("emotional-state: skipping — agent ended with error");
131
+ return;
132
+ }
133
+ const messages = event.messages;
134
+ if (!messages || messages.length < MIN_MESSAGES) {
135
+ log.debug(`emotional-state: skipping — too few messages (${messages?.length ?? 0})`);
136
+ return;
137
+ }
138
+ const transcript = messagesToTranscript(messages);
139
+ if (transcript.length < MIN_CONVERSATION_LENGTH) {
140
+ log.debug(`emotional-state: skipping — conversation too short (${transcript.length} chars)`);
141
+ return;
142
+ }
143
+ try {
144
+ const result = await client.storeEmotionalState(transcript, {
145
+ relationshipId: cfg.relationshipId,
146
+ metadata: { source: "openclaw_agent_end" },
147
+ });
148
+ log.info(`emotional-state: stored ${result.resourceId}`);
149
+ }
150
+ catch (err) {
151
+ // Fire-and-forget — never let this break the session
152
+ log.error("emotional-state store failed", err);
153
+ }
154
+ };
155
+ }
@@ -0,0 +1,54 @@
1
+ import * as path from "node:path";
2
+ import { getWorkspaceDir } from "../config.js";
3
+ import { log } from "../logger.js";
4
+ import { syncMarkdownFile, syncAllMemoryFiles } from "../sync/markdown.js";
5
+ /**
6
+ * Build a handler for file change events that syncs markdown files to Hyperspell
7
+ */
8
+ export function buildFileSyncHandler(client, cfg) {
9
+ const workspaceDir = getWorkspaceDir();
10
+ const memoryDir = path.join(workspaceDir, "memory");
11
+ const syncUserId = cfg.multiUser?.sharedUserId;
12
+ return async (event) => {
13
+ const filePath = event.file_path;
14
+ if (!filePath)
15
+ return;
16
+ // Only process markdown files in the workspace's memory directory
17
+ if (!filePath.startsWith(memoryDir) || !filePath.endsWith(".md")) {
18
+ return;
19
+ }
20
+ const fileName = path.basename(filePath);
21
+ log.info(`Memory file changed: ${fileName}`);
22
+ try {
23
+ const result = await syncMarkdownFile(client, filePath, { userId: syncUserId });
24
+ if (result.success) {
25
+ log.info(`Synced ${fileName} -> ${result.resourceId}`);
26
+ }
27
+ else {
28
+ log.error(`Failed to sync ${fileName}: ${result.error}`);
29
+ }
30
+ }
31
+ catch (err) {
32
+ log.error(`Error syncing ${fileName}`, err);
33
+ }
34
+ };
35
+ }
36
+ /**
37
+ * Sync all existing memory files on startup
38
+ */
39
+ export async function syncMemoriesOnStartup(client, workspaceDir, options) {
40
+ log.info("Syncing existing memory files...");
41
+ const result = await syncAllMemoryFiles(client, workspaceDir, { userId: options?.userId });
42
+ if (result.synced > 0) {
43
+ log.info(`Synced ${result.synced} memory files`);
44
+ }
45
+ if (result.failed > 0) {
46
+ log.error(`Failed to sync ${result.failed} files:`);
47
+ for (const error of result.errors) {
48
+ log.error(` - ${error}`);
49
+ }
50
+ }
51
+ if (result.synced === 0 && result.failed === 0) {
52
+ log.info("No memory files found in memory/ directory");
53
+ }
54
+ }
@@ -0,0 +1,156 @@
1
+ import { resolveUser } from "../lib/sender.js";
2
+ import { log } from "../logger.js";
3
+ const RECENT_FILTER = { openclaw_source: "agent_end" };
4
+ /**
5
+ * Track which sessions already received the orientation injection. The block is
6
+ * expensive (two search calls + a few hundred tokens of wrapper) and its
7
+ * contents don't change within a session, so inject-once is the right shape.
8
+ * Lifecycle mirrors the emotional-context hook: first turn injects, later turns
9
+ * skip, `after_compaction` clears (injection may have been trimmed), and
10
+ * `session_end` deletes to keep the Set bounded.
11
+ */
12
+ const injectedSessions = new Set();
13
+ function formatRelativeTime(iso) {
14
+ if (!iso)
15
+ return "";
16
+ try {
17
+ const dt = new Date(iso);
18
+ const now = new Date();
19
+ const hours = (now.getTime() - dt.getTime()) / 3_600_000;
20
+ if (hours < 1)
21
+ return "just now";
22
+ if (hours < 24)
23
+ return `${Math.floor(hours)}h ago`;
24
+ const days = hours / 24;
25
+ if (days < 7)
26
+ return `${Math.floor(days)}d ago`;
27
+ const month = dt.toLocaleString("en", { month: "short" });
28
+ return `${dt.getDate()} ${month}`;
29
+ }
30
+ catch {
31
+ return "";
32
+ }
33
+ }
34
+ function formatRecentInteractions(results) {
35
+ if (results.length === 0)
36
+ return null;
37
+ const lines = results.map((r) => {
38
+ const when = formatRelativeTime(r.createdAt);
39
+ const title = r.title ?? `[${r.source}]`;
40
+ const prefix = when ? `[${when}] ` : "";
41
+ const top = r.highlights[0]?.text?.replace(/\n/g, " ").slice(0, 140);
42
+ const tail = top ? ` — ${top}` : "";
43
+ return `- ${prefix}${title}${tail}`;
44
+ });
45
+ return lines.join("\n");
46
+ }
47
+ function formatUnfinishedLoops(results) {
48
+ const bullets = [];
49
+ for (const r of results) {
50
+ const top = r.highlights[0];
51
+ if (!top)
52
+ continue;
53
+ const title = r.title ?? `[${r.source}]`;
54
+ bullets.push(`- ${title}: ${top.text.replace(/\n/g, " ")}`);
55
+ }
56
+ return bullets.length > 0 ? bullets.join("\n") : null;
57
+ }
58
+ function isoDaysAgo(days) {
59
+ const d = new Date();
60
+ d.setUTCDate(d.getUTCDate() - days);
61
+ return d.toISOString();
62
+ }
63
+ /**
64
+ * Resolve the userId to use for personal searches. In multi-user mode, skip
65
+ * entirely for unknown senders — orientation is "what have *I* been doing", and
66
+ * an unresolved caller has no personal space to orient to. In single-user mode,
67
+ * return undefined so the client falls back to its configured userId.
68
+ */
69
+ function personalUserId(cfg, ctx) {
70
+ if (!cfg.multiUser)
71
+ return { skip: false, userId: undefined };
72
+ const resolved = resolveUser(ctx, cfg);
73
+ if (!resolved?.resolved)
74
+ return { skip: true };
75
+ return { skip: false, userId: resolved.userId };
76
+ }
77
+ export function buildStartupOrientationHandler(client, cfg) {
78
+ const so = cfg.startupOrientation;
79
+ return async (_event, ctx) => {
80
+ const sessionKey = ctx?.sessionKey;
81
+ if (sessionKey && injectedSessions.has(sessionKey))
82
+ return;
83
+ const { skip, userId } = personalUserId(cfg, ctx);
84
+ if (skip) {
85
+ log.debug("startup-orientation: skipping — unknown sender in multi-user mode");
86
+ if (sessionKey)
87
+ injectedSessions.add(sessionKey);
88
+ return;
89
+ }
90
+ const after = isoDaysAgo(so.recentDays);
91
+ const [recentSettled, loopsSettled] = await Promise.allSettled([
92
+ client.search(so.recentQuery, {
93
+ limit: so.recentLimit,
94
+ after,
95
+ filter: RECENT_FILTER,
96
+ userId,
97
+ }),
98
+ client.search(so.loopsQuery, {
99
+ limit: so.loopsLimit,
100
+ userId,
101
+ }),
102
+ ]);
103
+ const recent = recentSettled.status === "fulfilled" ? recentSettled.value : [];
104
+ if (recentSettled.status === "rejected") {
105
+ log.error("startup-orientation: recent search failed", recentSettled.reason);
106
+ }
107
+ const loops = loopsSettled.status === "fulfilled" ? loopsSettled.value : [];
108
+ if (loopsSettled.status === "rejected") {
109
+ log.error("startup-orientation: loops search failed", loopsSettled.reason);
110
+ }
111
+ const recentBody = formatRecentInteractions(recent);
112
+ const loopsBody = formatUnfinishedLoops(loops);
113
+ if (sessionKey)
114
+ injectedSessions.add(sessionKey);
115
+ if (!recentBody && !loopsBody) {
116
+ log.debug("startup-orientation: nothing to inject");
117
+ return;
118
+ }
119
+ const blocks = [];
120
+ if (recentBody) {
121
+ blocks.push([
122
+ "<hyperspell-recent-interactions>",
123
+ `Your last ${so.recentDays} days of conversations with this user, most-relevant-first. Use for situational continuity — don't quote verbatim.`,
124
+ "",
125
+ recentBody,
126
+ "</hyperspell-recent-interactions>",
127
+ ].join("\n"));
128
+ }
129
+ if (loopsBody) {
130
+ blocks.push([
131
+ "<hyperspell-unfinished-loops>",
132
+ "Possible open threads — promises made, questions pending, work in progress. Low-confidence retrieval; treat as prompts to consider, not facts to act on.",
133
+ "",
134
+ loopsBody,
135
+ "</hyperspell-unfinished-loops>",
136
+ ].join("\n"));
137
+ }
138
+ log.debug(`startup-orientation: injecting recent=${recent.length} loops=${loops.length}`);
139
+ return { prependContext: blocks.join("\n\n") };
140
+ };
141
+ }
142
+ export function buildStartupOrientationCompactionHandler() {
143
+ return async (_event, ctx) => {
144
+ const sessionKey = ctx?.sessionKey;
145
+ if (sessionKey && injectedSessions.delete(sessionKey)) {
146
+ log.debug(`startup-orientation: cache cleared after compaction (session=${sessionKey})`);
147
+ }
148
+ };
149
+ }
150
+ export function buildStartupOrientationSessionCleanupHandler() {
151
+ return async (_event, ctx) => {
152
+ const sessionKey = ctx?.sessionKey;
153
+ if (sessionKey)
154
+ injectedSessions.delete(sessionKey);
155
+ };
156
+ }
package/dist/index.js ADDED
@@ -0,0 +1,132 @@
1
+ import { HyperspellClient } from "./client.js";
2
+ import { registerCommands } from "./commands/slash.js";
3
+ import { registerCliCommands } from "./commands/setup.js";
4
+ import { parseConfig, hyperspellConfigSchema, getWorkspaceDir } from "./config.js";
5
+ import { buildAutoContextHandler } from "./hooks/auto-context.js";
6
+ import { buildAutoTraceHandler } from "./hooks/auto-trace.js";
7
+ import { buildEmotionalStateCompactionHandler, buildEmotionalStateFetchHandler, buildEmotionalStateSessionCleanupHandler, buildEmotionalStateStoreHandler, } from "./hooks/emotional-state.js";
8
+ import { buildFileSyncHandler, syncMemoriesOnStartup } from "./hooks/memory-sync.js";
9
+ import { buildStartupOrientationCompactionHandler, buildStartupOrientationHandler, buildStartupOrientationSessionCleanupHandler, } from "./hooks/startup-orientation.js";
10
+ import { initLogger } from "./logger.js";
11
+ import { createRememberToolFactory } from "./tools/remember.js";
12
+ import { createSearchToolFactory } from "./tools/search.js";
13
+ import { registerNetworkTools } from "./graph/index.js";
14
+ export default {
15
+ id: "openclaw-hyperspell",
16
+ name: "Hyperspell",
17
+ description: "Hyperspell gives your Molty context and memory from all your existing data",
18
+ kind: "memory",
19
+ configSchema: hyperspellConfigSchema,
20
+ register(api) {
21
+ // Register CLI commands (openclaw openclaw-hyperspell setup|status|connect)
22
+ api.registerCli((ctx) => {
23
+ registerCliCommands(ctx.program, api.pluginConfig);
24
+ }, { commands: ["openclaw-hyperspell"] });
25
+ // Check if configured
26
+ const rawConfig = api.pluginConfig;
27
+ const hasConfig = rawConfig?.apiKey || process.env.HYPERSPELL_API_KEY;
28
+ if (!hasConfig) {
29
+ api.logger.info("hyperspell: not configured - run 'openclaw openclaw-hyperspell setup'");
30
+ // Still register slash commands so they show up, but they'll return an error
31
+ api.registerCommand({
32
+ name: "getcontext",
33
+ description: "Search your memories for relevant context",
34
+ acceptsArgs: true,
35
+ requireAuth: false,
36
+ handler: async () => {
37
+ return {
38
+ text: "Hyperspell not configured. Run 'openclaw openclaw-hyperspell setup' first.",
39
+ };
40
+ },
41
+ });
42
+ api.registerCommand({
43
+ name: "remember",
44
+ description: "Save something to memory",
45
+ acceptsArgs: true,
46
+ requireAuth: false,
47
+ handler: async () => {
48
+ return {
49
+ text: "Hyperspell not configured. Run 'openclaw openclaw-hyperspell setup' first.",
50
+ };
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 {
60
+ text: "Hyperspell not configured. Run 'openclaw openclaw-hyperspell setup' first.",
61
+ };
62
+ },
63
+ });
64
+ return;
65
+ }
66
+ const cfg = parseConfig(api.pluginConfig);
67
+ initLogger(api.logger, cfg.debug);
68
+ const client = new HyperspellClient(cfg);
69
+ // Register AI tools (factory pattern for sender context)
70
+ api.registerTool(createSearchToolFactory(client, cfg), {
71
+ name: "hyperspell_search",
72
+ });
73
+ api.registerTool(createRememberToolFactory(client, cfg), {
74
+ name: "hyperspell_remember",
75
+ });
76
+ // Register emotional context hooks.
77
+ // - fetch: inject once per session on first turn (cached thereafter)
78
+ // - compaction: clear cache so the next turn re-injects after trim
79
+ // - session cleanup: drop Set entry on session end
80
+ // - store: extract new emotional state from the finished session
81
+ if (cfg.emotionalContext) {
82
+ api.on("before_agent_start", buildEmotionalStateFetchHandler(client, cfg));
83
+ api.on("after_compaction", buildEmotionalStateCompactionHandler());
84
+ api.on("session_end", buildEmotionalStateSessionCleanupHandler());
85
+ api.on("agent_end", buildEmotionalStateStoreHandler(client, cfg));
86
+ }
87
+ // Register auto-context hook
88
+ if (cfg.autoContext) {
89
+ const autoContextHandler = buildAutoContextHandler(client, cfg);
90
+ api.on("before_agent_start", autoContextHandler);
91
+ }
92
+ // Register startup-orientation hooks: recent-interactions + unfinished-loops
93
+ // injected once per session on first turn. Lifecycle mirrors emotional-context.
94
+ if (cfg.startupOrientation.enabled) {
95
+ api.on("before_agent_start", buildStartupOrientationHandler(client, cfg));
96
+ api.on("after_compaction", buildStartupOrientationCompactionHandler());
97
+ api.on("session_end", buildStartupOrientationSessionCleanupHandler());
98
+ }
99
+ // Register auto-trace hook (send conversations to Hyperspell on session end)
100
+ if (cfg.autoTrace.enabled) {
101
+ api.on("agent_end", buildAutoTraceHandler(client, cfg));
102
+ }
103
+ // Register memory sync hook
104
+ if (cfg.syncMemories) {
105
+ const fileSyncHandler = buildFileSyncHandler(client, cfg);
106
+ api.on("file_changed", fileSyncHandler);
107
+ }
108
+ // Register memory network tools
109
+ if (cfg.knowledgeGraph.enabled) {
110
+ registerNetworkTools(api, client, cfg);
111
+ }
112
+ // Register slash commands
113
+ registerCommands(api, client, cfg);
114
+ // Register service for lifecycle management
115
+ api.registerService({
116
+ id: "openclaw-hyperspell",
117
+ start: async () => {
118
+ api.logger.info("hyperspell: connected");
119
+ // Sync memories on startup if enabled
120
+ if (cfg.syncMemories) {
121
+ const workspaceDir = getWorkspaceDir();
122
+ await syncMemoriesOnStartup(client, workspaceDir, {
123
+ userId: cfg.multiUser?.sharedUserId,
124
+ });
125
+ }
126
+ },
127
+ stop: () => {
128
+ api.logger.info("hyperspell: stopped");
129
+ },
130
+ });
131
+ },
132
+ };
@@ -0,0 +1,29 @@
1
+ import { execFile } from "node:child_process";
2
+ import { platform } from "node:os";
3
+ export function openInBrowser(url) {
4
+ return new Promise((resolve, reject) => {
5
+ let file;
6
+ let args;
7
+ switch (platform()) {
8
+ case "darwin":
9
+ file = "open";
10
+ args = [url];
11
+ break;
12
+ case "win32":
13
+ file = "cmd";
14
+ args = ["/c", "start", "", url];
15
+ break;
16
+ default:
17
+ file = "xdg-open";
18
+ args = [url];
19
+ }
20
+ execFile(file, args, (error) => {
21
+ if (error) {
22
+ reject(error);
23
+ }
24
+ else {
25
+ resolve();
26
+ }
27
+ });
28
+ });
29
+ }