@hyperspell/openclaw-hyperspell 0.12.0 → 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.
- package/README.md +37 -1
- package/dist/client.js +341 -0
- package/dist/commands/setup.js +569 -0
- package/dist/commands/slash.js +159 -0
- package/dist/config.js +349 -0
- package/{graph/cron.ts → dist/graph/cron.js} +11 -15
- package/dist/graph/index.js +4 -0
- package/dist/graph/ops.js +221 -0
- package/dist/graph/state.js +68 -0
- package/dist/graph/tools.js +87 -0
- package/dist/hooks/auto-context.js +199 -0
- package/dist/hooks/auto-trace.js +141 -0
- package/dist/hooks/emotional-state.js +155 -0
- package/dist/hooks/memory-sync.js +54 -0
- package/dist/hooks/startup-orientation.js +156 -0
- package/dist/index.js +132 -0
- package/dist/lib/browser.js +29 -0
- package/dist/lib/sender.js +173 -0
- package/dist/lib/voice-id.js +22 -0
- package/dist/logger.js +32 -0
- package/dist/sync/markdown.js +151 -0
- package/dist/tools/remember.js +97 -0
- package/dist/tools/search.js +87 -0
- package/openclaw.plugin.json +10 -1
- package/package.json +7 -12
- package/client.ts +0 -566
- package/commands/setup.ts +0 -673
- package/commands/slash.ts +0 -198
- package/config.test.ts +0 -202
- package/config.ts +0 -497
- package/graph/index.ts +0 -5
- package/graph/ops.ts +0 -259
- package/graph/state.ts +0 -79
- package/graph/tools.ts +0 -117
- package/hooks/auto-context.ts +0 -272
- package/hooks/auto-trace.test.ts +0 -81
- package/hooks/auto-trace.ts +0 -197
- package/hooks/emotional-state.test.ts +0 -160
- package/hooks/emotional-state.ts +0 -179
- package/hooks/memory-sync.ts +0 -65
- package/index.ts +0 -152
- package/lib/browser.ts +0 -31
- package/lib/sender.test.ts +0 -234
- package/lib/sender.ts +0 -234
- package/lib/voice-id.ts +0 -39
- package/logger.ts +0 -41
- package/sync/markdown.ts +0 -186
- package/tools/remember.ts +0 -132
- package/tools/search.ts +0 -131
- 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
|
+
}
|