@hyperspell/openclaw-hyperspell 0.12.0 → 0.13.1
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 +568 -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 +143 -0
- package/dist/hooks/emotional-state.js +155 -0
- package/dist/hooks/memory-sync.js +54 -0
- package/dist/hooks/startup-orientation.js +236 -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 +25 -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,143 @@
|
|
|
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(/<hyperspell-recent-interactions>[\s\S]*?<\/hyperspell-recent-interactions>\n?/g, "");
|
|
17
|
+
out = out.replace(/<hyperspell-unfinished-loops>[\s\S]*?<\/hyperspell-unfinished-loops>\n?/g, "");
|
|
18
|
+
out = out.replace(/Sender \(untrusted metadata\):\s*```json[\s\S]*?```\n?/g, "");
|
|
19
|
+
out = out.replace(/\[Bootstrap pending\][\s\S]*?(?=\n{2,}|\nSystem:|\nSender|$)/g, "");
|
|
20
|
+
out = out.replace(/\[Startup context loaded by runtime\][\s\S]*?(?:\n\n|$)/g, "");
|
|
21
|
+
out = out.replace(/\[Untrusted daily memory:[^\]]*\][\s\S]*?END_QUOTED_NOTES\n?/g, "");
|
|
22
|
+
out = out.replace(/^System(?:\s+\(untrusted\))?:\s*\[[^\]]+\][^\n]*\n?/gm, "");
|
|
23
|
+
out = out.replace(/\n{3,}/g, "\n\n").trim();
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
function sanitizeContent(content) {
|
|
27
|
+
const items = typeof content === "string"
|
|
28
|
+
? [{ type: "text", text: content }]
|
|
29
|
+
: Array.isArray(content)
|
|
30
|
+
? content
|
|
31
|
+
: [{ type: "text", text: JSON.stringify(content) }];
|
|
32
|
+
const cleaned = [];
|
|
33
|
+
for (const item of items) {
|
|
34
|
+
if (item?.type === "text" && typeof item.text === "string") {
|
|
35
|
+
const text = sanitizeTraceText(item.text);
|
|
36
|
+
if (text.length > 0)
|
|
37
|
+
cleaned.push({ ...item, text });
|
|
38
|
+
}
|
|
39
|
+
else if (item) {
|
|
40
|
+
cleaned.push(item);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return cleaned;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Convert event.messages into OpenClaw JSONL format for the trace API.
|
|
47
|
+
*/
|
|
48
|
+
function messagesToJSONL(messages, sessionId) {
|
|
49
|
+
const lines = [];
|
|
50
|
+
lines.push(JSON.stringify({
|
|
51
|
+
type: "session",
|
|
52
|
+
version: 3,
|
|
53
|
+
id: sessionId,
|
|
54
|
+
timestamp: new Date().toISOString(),
|
|
55
|
+
}));
|
|
56
|
+
for (const raw of messages) {
|
|
57
|
+
if (!raw.role || !raw.content)
|
|
58
|
+
continue;
|
|
59
|
+
if (raw.role === "system")
|
|
60
|
+
continue;
|
|
61
|
+
const content = sanitizeContent(raw.content);
|
|
62
|
+
if (content.length === 0)
|
|
63
|
+
continue;
|
|
64
|
+
const id = crypto.randomUUID().slice(0, 8);
|
|
65
|
+
if (raw.role === "tool" || raw.role === "toolResult") {
|
|
66
|
+
lines.push(JSON.stringify({
|
|
67
|
+
type: "message",
|
|
68
|
+
id,
|
|
69
|
+
timestamp: new Date().toISOString(),
|
|
70
|
+
message: {
|
|
71
|
+
role: "toolResult",
|
|
72
|
+
toolCallId: raw.toolCallId ?? id,
|
|
73
|
+
toolName: raw.toolName ?? "unknown",
|
|
74
|
+
content,
|
|
75
|
+
isError: raw.isError ?? false,
|
|
76
|
+
},
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
lines.push(JSON.stringify({
|
|
81
|
+
type: "message",
|
|
82
|
+
id,
|
|
83
|
+
timestamp: new Date().toISOString(),
|
|
84
|
+
message: { role: raw.role, content },
|
|
85
|
+
}));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return lines.join("\n");
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Extract and store conversation trace at session end.
|
|
92
|
+
* Runs on `agent_end` — fire-and-forget.
|
|
93
|
+
*/
|
|
94
|
+
export function buildAutoTraceHandler(client, cfg) {
|
|
95
|
+
return async (event, ctx) => {
|
|
96
|
+
if (event.success === false) {
|
|
97
|
+
log.debug("auto-trace: skipping — agent ended with error");
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const messages = event.messages;
|
|
101
|
+
if (!messages || messages.length < MIN_MESSAGES) {
|
|
102
|
+
log.debug(`auto-trace: skipping — too few messages (${messages?.length ?? 0})`);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
// Quick content length check
|
|
106
|
+
const estimate = messages
|
|
107
|
+
.filter((m) => m.content)
|
|
108
|
+
.reduce((acc, m) => acc + String(m.content).length, 0);
|
|
109
|
+
if (estimate < MIN_CONVERSATION_LENGTH) {
|
|
110
|
+
log.debug(`auto-trace: skipping — conversation too short (${estimate} chars)`);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const sessionId = event.sessionId ?? crypto.randomUUID();
|
|
114
|
+
const history = messagesToJSONL(messages, sessionId);
|
|
115
|
+
// Title from first user message
|
|
116
|
+
const firstUser = messages.find((m) => m.role === "user");
|
|
117
|
+
const title = firstUser?.content
|
|
118
|
+
? String(firstUser.content).slice(0, 80).replace(/\n/g, " ")
|
|
119
|
+
: undefined;
|
|
120
|
+
// Resolve sender → userId so traces land in the right user's space.
|
|
121
|
+
// Falls back to sharedUserId for unknown senders (or undefined in single-user mode).
|
|
122
|
+
const resolved = resolveUser(ctx, cfg);
|
|
123
|
+
const userId = resolved?.userId;
|
|
124
|
+
try {
|
|
125
|
+
const result = await client.sendTrace(history, {
|
|
126
|
+
sessionId,
|
|
127
|
+
title,
|
|
128
|
+
extract: cfg.autoTrace.extract,
|
|
129
|
+
metadata: cfg.autoTrace.metadata,
|
|
130
|
+
userId,
|
|
131
|
+
// Auto-trace captures full conversation text — the most sensitive class
|
|
132
|
+
// of memory. Default to private; users opt into family-visible recall
|
|
133
|
+
// via explicit /remember.
|
|
134
|
+
scope: "private",
|
|
135
|
+
});
|
|
136
|
+
log.info(`auto-trace: sent ${result.resourceId} (${messages.length} messages${userId ? `, user=${userId}` : ""})`);
|
|
137
|
+
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
// Fire-and-forget — never break the session
|
|
140
|
+
log.error("auto-trace failed", err);
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
}
|
|
@@ -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,236 @@
|
|
|
1
|
+
import { resolveUser } from "../lib/sender.js";
|
|
2
|
+
import { log } from "../logger.js";
|
|
3
|
+
const MAX_ATTEMPTS = 2;
|
|
4
|
+
const RECENT_BUFFER_LIMIT = 100;
|
|
5
|
+
/**
|
|
6
|
+
* Sessions where the orientation block was already injected (or where we
|
|
7
|
+
* deliberately decided not to inject — unknown sender, exhausted retries).
|
|
8
|
+
* Re-checked at the top of every turn; on hit, we skip.
|
|
9
|
+
*
|
|
10
|
+
* Lifecycle mirrors the emotional-context hook: `after_compaction` clears
|
|
11
|
+
* (the original injection may have been trimmed out of history) and
|
|
12
|
+
* `session_end` cleans up to keep both structures bounded.
|
|
13
|
+
*/
|
|
14
|
+
const injectedSessions = new Set();
|
|
15
|
+
/**
|
|
16
|
+
* Counts attempts for sessions where every call has failed so far. We only
|
|
17
|
+
* count failures here, so a session that succeeds on retry will never appear.
|
|
18
|
+
* Capped at MAX_ATTEMPTS — past that we give up and add to injectedSessions.
|
|
19
|
+
*/
|
|
20
|
+
const failedAttempts = new Map();
|
|
21
|
+
function formatRelativeTime(iso) {
|
|
22
|
+
if (!iso)
|
|
23
|
+
return "";
|
|
24
|
+
try {
|
|
25
|
+
const dt = new Date(iso);
|
|
26
|
+
const now = new Date();
|
|
27
|
+
const hours = (now.getTime() - dt.getTime()) / 3_600_000;
|
|
28
|
+
if (hours < 1)
|
|
29
|
+
return "just now";
|
|
30
|
+
if (hours < 24)
|
|
31
|
+
return `${Math.floor(hours)}h ago`;
|
|
32
|
+
const days = hours / 24;
|
|
33
|
+
if (days < 7)
|
|
34
|
+
return `${Math.floor(days)}d ago`;
|
|
35
|
+
const month = dt.toLocaleString("en", { month: "short" });
|
|
36
|
+
return `${dt.getDate()} ${month}`;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return "";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function formatRecentInteractions(results) {
|
|
43
|
+
if (results.length === 0)
|
|
44
|
+
return null;
|
|
45
|
+
const lines = results.map((r) => {
|
|
46
|
+
const when = formatRelativeTime(r.createdAt);
|
|
47
|
+
const title = r.title || `[${r.source}]`;
|
|
48
|
+
const prefix = when ? `[${when}] ` : "";
|
|
49
|
+
const top = r.highlights[0]?.text?.replace(/\n/g, " ").slice(0, 140);
|
|
50
|
+
const tail = top ? ` — ${top}` : "";
|
|
51
|
+
return `- ${prefix}${title}${tail}`;
|
|
52
|
+
});
|
|
53
|
+
return lines.join("\n");
|
|
54
|
+
}
|
|
55
|
+
function formatUnfinishedLoops(results) {
|
|
56
|
+
const bullets = [];
|
|
57
|
+
for (const r of results) {
|
|
58
|
+
const top = r.highlights[0];
|
|
59
|
+
if (!top)
|
|
60
|
+
continue;
|
|
61
|
+
const title = r.title || `[${r.source}]`;
|
|
62
|
+
bullets.push(`- ${title}: ${top.text.replace(/\n/g, " ")}`);
|
|
63
|
+
}
|
|
64
|
+
return bullets.length > 0 ? bullets.join("\n") : null;
|
|
65
|
+
}
|
|
66
|
+
function isoDaysAgo(days) {
|
|
67
|
+
const d = new Date();
|
|
68
|
+
d.setUTCDate(d.getUTCDate() - days);
|
|
69
|
+
return d;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Resolve the userId to use for personal searches. In multi-user mode, skip
|
|
73
|
+
* entirely for unknown senders — orientation is "what have *I* been doing", and
|
|
74
|
+
* an unresolved caller has no personal space to orient to. In single-user mode,
|
|
75
|
+
* return undefined so the client falls back to its configured userId.
|
|
76
|
+
*/
|
|
77
|
+
function personalUserId(cfg, ctx) {
|
|
78
|
+
if (!cfg.multiUser)
|
|
79
|
+
return { skip: false, userId: undefined };
|
|
80
|
+
const resolved = resolveUser(ctx, cfg);
|
|
81
|
+
if (!resolved?.resolved)
|
|
82
|
+
return { skip: true };
|
|
83
|
+
return { skip: false, userId: resolved.userId };
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Pull recent agent_end traces via listMemories, sorted chronologically.
|
|
87
|
+
*
|
|
88
|
+
* Why list, not search: a date-window + relevance search ranks results by
|
|
89
|
+
* lexical similarity to a generic query, which is approximately random
|
|
90
|
+
* within a 7-day slice and can easily exclude yesterday in favor of a
|
|
91
|
+
* 5-day-old session. listMemories gives us true chronological recall.
|
|
92
|
+
*
|
|
93
|
+
* The SDK's list endpoint doesn't expose date or metadata filters in our
|
|
94
|
+
* wrapper, so we filter client-side. Buffer is capped at
|
|
95
|
+
* RECENT_BUFFER_LIMIT to bound wire cost; if a user has more than that
|
|
96
|
+
* many traces in the cutoff window we'll still get the newest ones,
|
|
97
|
+
* since the underlying API returns recency-ordered pages.
|
|
98
|
+
*/
|
|
99
|
+
async function fetchRecentTraces(client, cutoff, limit, userId) {
|
|
100
|
+
const buffer = [];
|
|
101
|
+
let scanned = 0;
|
|
102
|
+
const cutoffMs = cutoff.getTime();
|
|
103
|
+
for await (const memory of client.listMemories({
|
|
104
|
+
source: "trace",
|
|
105
|
+
userId,
|
|
106
|
+
pageSize: 50,
|
|
107
|
+
})) {
|
|
108
|
+
scanned++;
|
|
109
|
+
if (scanned > RECENT_BUFFER_LIMIT)
|
|
110
|
+
break;
|
|
111
|
+
const meta = memory.metadata;
|
|
112
|
+
if (meta.openclaw_source !== "agent_end")
|
|
113
|
+
continue;
|
|
114
|
+
const createdRaw = meta.created_at;
|
|
115
|
+
if (typeof createdRaw !== "string")
|
|
116
|
+
continue;
|
|
117
|
+
const createdMs = new Date(createdRaw).getTime();
|
|
118
|
+
if (Number.isNaN(createdMs) || createdMs < cutoffMs)
|
|
119
|
+
continue;
|
|
120
|
+
buffer.push({
|
|
121
|
+
resourceId: memory.resourceId,
|
|
122
|
+
title: memory.title,
|
|
123
|
+
source: memory.source,
|
|
124
|
+
score: null,
|
|
125
|
+
url: null,
|
|
126
|
+
createdAt: createdRaw,
|
|
127
|
+
highlights: [],
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
buffer.sort((a, b) => {
|
|
131
|
+
const at = a.createdAt ? new Date(a.createdAt).getTime() : 0;
|
|
132
|
+
const bt = b.createdAt ? new Date(b.createdAt).getTime() : 0;
|
|
133
|
+
return bt - at;
|
|
134
|
+
});
|
|
135
|
+
return buffer.slice(0, limit);
|
|
136
|
+
}
|
|
137
|
+
export function buildStartupOrientationHandler(client, cfg) {
|
|
138
|
+
const so = cfg.startupOrientation;
|
|
139
|
+
return async (_event, ctx) => {
|
|
140
|
+
const sessionKey = ctx?.sessionKey;
|
|
141
|
+
if (sessionKey && injectedSessions.has(sessionKey))
|
|
142
|
+
return;
|
|
143
|
+
const tries = (sessionKey && failedAttempts.get(sessionKey)) || 0;
|
|
144
|
+
if (tries >= MAX_ATTEMPTS) {
|
|
145
|
+
log.debug(`startup-orientation: giving up after ${tries} failed attempt(s)`);
|
|
146
|
+
if (sessionKey) {
|
|
147
|
+
injectedSessions.add(sessionKey);
|
|
148
|
+
failedAttempts.delete(sessionKey);
|
|
149
|
+
}
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const { skip, userId } = personalUserId(cfg, ctx);
|
|
153
|
+
if (skip) {
|
|
154
|
+
log.debug("startup-orientation: skipping — unknown sender in multi-user mode");
|
|
155
|
+
if (sessionKey)
|
|
156
|
+
injectedSessions.add(sessionKey);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const cutoff = isoDaysAgo(so.recentDays);
|
|
160
|
+
const [recentSettled, loopsSettled] = await Promise.allSettled([
|
|
161
|
+
fetchRecentTraces(client, cutoff, so.recentLimit, userId),
|
|
162
|
+
client.search(so.loopsQuery, {
|
|
163
|
+
limit: so.loopsLimit,
|
|
164
|
+
userId,
|
|
165
|
+
}),
|
|
166
|
+
]);
|
|
167
|
+
const recentOk = recentSettled.status === "fulfilled";
|
|
168
|
+
const loopsOk = loopsSettled.status === "fulfilled";
|
|
169
|
+
const recent = recentOk ? recentSettled.value : [];
|
|
170
|
+
const loops = loopsOk ? loopsSettled.value : [];
|
|
171
|
+
if (!recentOk) {
|
|
172
|
+
log.error("startup-orientation: recent listMemories failed", recentSettled.reason);
|
|
173
|
+
}
|
|
174
|
+
if (!loopsOk) {
|
|
175
|
+
log.error("startup-orientation: loops search failed", loopsSettled.reason);
|
|
176
|
+
}
|
|
177
|
+
if (!recentOk && !loopsOk) {
|
|
178
|
+
if (sessionKey)
|
|
179
|
+
failedAttempts.set(sessionKey, tries + 1);
|
|
180
|
+
log.debug(`startup-orientation: both calls failed (attempt ${tries + 1}/${MAX_ATTEMPTS}); will retry next turn`);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (sessionKey) {
|
|
184
|
+
injectedSessions.add(sessionKey);
|
|
185
|
+
failedAttempts.delete(sessionKey);
|
|
186
|
+
}
|
|
187
|
+
const recentBody = formatRecentInteractions(recent);
|
|
188
|
+
const loopsBody = formatUnfinishedLoops(loops);
|
|
189
|
+
if (!recentBody && !loopsBody) {
|
|
190
|
+
log.debug("startup-orientation: nothing to inject");
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const blocks = [];
|
|
194
|
+
if (recentBody) {
|
|
195
|
+
blocks.push([
|
|
196
|
+
"<hyperspell-recent-interactions>",
|
|
197
|
+
`Your last ${so.recentDays} days of conversations with this user, most-recent-first. Use for situational continuity — don't quote verbatim.`,
|
|
198
|
+
"",
|
|
199
|
+
recentBody,
|
|
200
|
+
"</hyperspell-recent-interactions>",
|
|
201
|
+
].join("\n"));
|
|
202
|
+
}
|
|
203
|
+
if (loopsBody) {
|
|
204
|
+
blocks.push([
|
|
205
|
+
"<hyperspell-unfinished-loops>",
|
|
206
|
+
"Possible open threads — promises made, questions pending, work in progress. Low-confidence retrieval; treat as prompts to consider, not facts to act on.",
|
|
207
|
+
"",
|
|
208
|
+
loopsBody,
|
|
209
|
+
"</hyperspell-unfinished-loops>",
|
|
210
|
+
].join("\n"));
|
|
211
|
+
}
|
|
212
|
+
log.debug(`startup-orientation: injecting recent=${recent.length} loops=${loops.length}`);
|
|
213
|
+
return { prependContext: blocks.join("\n\n") };
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
export function buildStartupOrientationCompactionHandler() {
|
|
217
|
+
return async (_event, ctx) => {
|
|
218
|
+
const sessionKey = ctx?.sessionKey;
|
|
219
|
+
if (!sessionKey)
|
|
220
|
+
return;
|
|
221
|
+
const dropped = injectedSessions.delete(sessionKey);
|
|
222
|
+
failedAttempts.delete(sessionKey);
|
|
223
|
+
if (dropped) {
|
|
224
|
+
log.debug(`startup-orientation: cache cleared after compaction (session=${sessionKey})`);
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
export function buildStartupOrientationSessionCleanupHandler() {
|
|
229
|
+
return async (_event, ctx) => {
|
|
230
|
+
const sessionKey = ctx?.sessionKey;
|
|
231
|
+
if (!sessionKey)
|
|
232
|
+
return;
|
|
233
|
+
injectedSessions.delete(sessionKey);
|
|
234
|
+
failedAttempts.delete(sessionKey);
|
|
235
|
+
};
|
|
236
|
+
}
|