@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.
- 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 +17 -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
package/hooks/auto-context.ts
DELETED
|
@@ -1,272 +0,0 @@
|
|
|
1
|
-
import type { HyperspellClient, SearchResult } from "../client.ts"
|
|
2
|
-
import type { CanReadScope, HyperspellConfig } from "../config.ts"
|
|
3
|
-
import {
|
|
4
|
-
buildScopeFilter,
|
|
5
|
-
getCanReadScopes,
|
|
6
|
-
resolveUser,
|
|
7
|
-
type ResolvedUser,
|
|
8
|
-
} from "../lib/sender.ts"
|
|
9
|
-
import { log } from "../logger.ts"
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Memories produced by session-end hooks (auto-trace, emotional-state) are
|
|
13
|
-
* tagged `source: "openclaw_agent_end"`. The emotional-state hook already has
|
|
14
|
-
* a dedicated fetch path (`getEmotionalState` + `<hyperspell-emotional-context>`
|
|
15
|
-
* injection), so those memories should NOT also surface via generic retrieval —
|
|
16
|
-
* double-injection dilutes results and replays the conversation verbatim.
|
|
17
|
-
* Exclude them here at the search filter.
|
|
18
|
-
*/
|
|
19
|
-
const EXCLUDE_SESSION_END_FILTER: Record<string, unknown> = {
|
|
20
|
-
source: { $ne: "openclaw_agent_end" },
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function mergeWithExclude(
|
|
24
|
-
base?: Record<string, unknown>,
|
|
25
|
-
): Record<string, unknown> {
|
|
26
|
-
if (!base) return EXCLUDE_SESSION_END_FILTER
|
|
27
|
-
return { $and: [base, EXCLUDE_SESSION_END_FILTER] }
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function formatRelativeTime(isoTimestamp: string): string {
|
|
31
|
-
try {
|
|
32
|
-
const dt = new Date(isoTimestamp)
|
|
33
|
-
const now = new Date()
|
|
34
|
-
const seconds = (now.getTime() - dt.getTime()) / 1000
|
|
35
|
-
const minutes = seconds / 60
|
|
36
|
-
const hours = seconds / 3600
|
|
37
|
-
const days = seconds / 86400
|
|
38
|
-
|
|
39
|
-
if (minutes < 30) return "just now"
|
|
40
|
-
if (minutes < 60) return `${Math.floor(minutes)}mins ago`
|
|
41
|
-
if (hours < 24) return `${Math.floor(hours)} hrs ago`
|
|
42
|
-
if (days < 7) return `${Math.floor(days)}d ago`
|
|
43
|
-
|
|
44
|
-
const month = dt.toLocaleString("en", { month: "short" })
|
|
45
|
-
if (dt.getFullYear() === now.getFullYear()) {
|
|
46
|
-
return `${dt.getDate()} ${month}`
|
|
47
|
-
}
|
|
48
|
-
return `${dt.getDate()} ${month}, ${dt.getFullYear()}`
|
|
49
|
-
} catch {
|
|
50
|
-
return ""
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Format a list of search results as per-highlight bullets, filtered by relevance threshold.
|
|
56
|
-
* Returns null if nothing passes the threshold.
|
|
57
|
-
*/
|
|
58
|
-
function formatHighlightBullets(
|
|
59
|
-
results: SearchResult[],
|
|
60
|
-
maxResults: number,
|
|
61
|
-
threshold: number,
|
|
62
|
-
): string | null {
|
|
63
|
-
const sections: string[] = []
|
|
64
|
-
|
|
65
|
-
for (const r of results.slice(0, maxResults)) {
|
|
66
|
-
if ((r.score ?? 0) < threshold) continue
|
|
67
|
-
|
|
68
|
-
const aboveThreshold = r.highlights.filter((h) => h.score >= threshold)
|
|
69
|
-
if (aboveThreshold.length === 0) continue
|
|
70
|
-
|
|
71
|
-
const title = r.title ?? `[${r.source}]`
|
|
72
|
-
const bullets = aboveThreshold
|
|
73
|
-
.map((h) => `- ${h.text.replace(/\n/g, " ")} [${Math.round(h.score * 100)}%]`)
|
|
74
|
-
.join("\n")
|
|
75
|
-
|
|
76
|
-
sections.push(`### ${title} (resource_id: ${r.resourceId}, source: ${r.source})\n\n${bullets}`)
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
if (sections.length === 0) return null
|
|
80
|
-
return sections.join("\n\n")
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const INTRO =
|
|
84
|
-
"The following is context from the user's connected sources. Reference it only when relevant to the conversation."
|
|
85
|
-
const DISCLAIMER =
|
|
86
|
-
"Use this context naturally when relevant — including indirect connections — but don't force it into every response or make assumptions beyond what's stated."
|
|
87
|
-
|
|
88
|
-
function wrapSingle(body: string): string {
|
|
89
|
-
return `<hyperspell-context>\n${INTRO}\n\n${body}\n\n${DISCLAIMER}\n</hyperspell-context>`
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
export function buildAutoContextHandler(
|
|
93
|
-
client: HyperspellClient,
|
|
94
|
-
cfg: HyperspellConfig,
|
|
95
|
-
) {
|
|
96
|
-
return async (
|
|
97
|
-
event: Record<string, unknown>,
|
|
98
|
-
ctx?: Record<string, unknown>,
|
|
99
|
-
) => {
|
|
100
|
-
const prompt = event.prompt as string | undefined
|
|
101
|
-
if (!prompt || prompt.length < 5) return
|
|
102
|
-
|
|
103
|
-
// Multi-user path
|
|
104
|
-
if (cfg.multiUser) {
|
|
105
|
-
const resolved = resolveUser(ctx, cfg)
|
|
106
|
-
return multiUserSearch(client, cfg, prompt, resolved)
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// Single-user path — preserves main's highlights + threshold behavior
|
|
110
|
-
log.debug(`auto-context: searching for "${prompt.slice(0, 50)}..."`)
|
|
111
|
-
|
|
112
|
-
try {
|
|
113
|
-
const results = await client.search(prompt, {
|
|
114
|
-
limit: cfg.maxResults,
|
|
115
|
-
filter: EXCLUDE_SESSION_END_FILTER,
|
|
116
|
-
})
|
|
117
|
-
const formatted = formatHighlightBullets(
|
|
118
|
-
results,
|
|
119
|
-
cfg.maxResults,
|
|
120
|
-
cfg.relevanceThreshold,
|
|
121
|
-
)
|
|
122
|
-
|
|
123
|
-
if (!formatted) {
|
|
124
|
-
log.debug("auto-context: no relevant memories found")
|
|
125
|
-
return
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
log.debug(`auto-context: injecting ${results.length} memories`)
|
|
129
|
-
return { prependContext: wrapSingle(formatted) }
|
|
130
|
-
} catch (err) {
|
|
131
|
-
log.error("auto-context failed", err)
|
|
132
|
-
return
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
async function multiUserSearch(
|
|
138
|
-
client: HyperspellClient,
|
|
139
|
-
cfg: HyperspellConfig,
|
|
140
|
-
prompt: string,
|
|
141
|
-
resolved: ResolvedUser | undefined,
|
|
142
|
-
) {
|
|
143
|
-
const multiUser = cfg.multiUser!
|
|
144
|
-
const isKnownSender = !!resolved?.resolved
|
|
145
|
-
const includeShared = multiUser.includeSharedInSearch
|
|
146
|
-
|
|
147
|
-
// Determine scope filter for the shared-space search based on caller's role.
|
|
148
|
-
// Unknown senders fall back to least-sensitive scopes; absent scoping config →
|
|
149
|
-
// filter is undefined → PR #6 behavior preserved.
|
|
150
|
-
const canRead: CanReadScope[] = multiUser.scoping
|
|
151
|
-
? isKnownSender
|
|
152
|
-
? getCanReadScopes(resolved, cfg)
|
|
153
|
-
: ["family", "kid_shared"]
|
|
154
|
-
: ["*"]
|
|
155
|
-
const scopeFilter = buildScopeFilter(canRead, resolved?.userId ?? "")
|
|
156
|
-
|
|
157
|
-
log.debug(
|
|
158
|
-
`auto-context: searching for "${prompt.slice(0, 50)}..." user=${resolved?.userId ?? "unknown"} canRead=${JSON.stringify(canRead)}`,
|
|
159
|
-
)
|
|
160
|
-
|
|
161
|
-
// Build parallel searches — personal (known senders only) + shared
|
|
162
|
-
const personalSearch = isKnownSender
|
|
163
|
-
? client.search(prompt, {
|
|
164
|
-
limit: cfg.maxResults,
|
|
165
|
-
userId: resolved!.userId,
|
|
166
|
-
filter: EXCLUDE_SESSION_END_FILTER,
|
|
167
|
-
})
|
|
168
|
-
: null
|
|
169
|
-
|
|
170
|
-
// Always search shared for unknown senders, even if includeSharedInSearch is false
|
|
171
|
-
const sharedLimit = isKnownSender
|
|
172
|
-
? Math.ceil(cfg.maxResults / 2)
|
|
173
|
-
: cfg.maxResults
|
|
174
|
-
const sharedSearch =
|
|
175
|
-
includeShared || !isKnownSender
|
|
176
|
-
? client.search(prompt, {
|
|
177
|
-
limit: sharedLimit,
|
|
178
|
-
userId: multiUser.sharedUserId,
|
|
179
|
-
filter: mergeWithExclude(scopeFilter),
|
|
180
|
-
})
|
|
181
|
-
: null
|
|
182
|
-
|
|
183
|
-
const searches = [personalSearch, sharedSearch].filter(Boolean) as Promise<
|
|
184
|
-
SearchResult[]
|
|
185
|
-
>[]
|
|
186
|
-
const settled = await Promise.allSettled(searches)
|
|
187
|
-
|
|
188
|
-
let idx = 0
|
|
189
|
-
let personalResults: SearchResult[] = []
|
|
190
|
-
let sharedResults: SearchResult[] = []
|
|
191
|
-
|
|
192
|
-
if (personalSearch) {
|
|
193
|
-
const r = settled[idx++]
|
|
194
|
-
if (r.status === "fulfilled") {
|
|
195
|
-
personalResults = r.value
|
|
196
|
-
} else {
|
|
197
|
-
log.error("auto-context: personal search failed", r.reason)
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
if (sharedSearch) {
|
|
201
|
-
const r = settled[idx++]
|
|
202
|
-
if (r.status === "fulfilled") {
|
|
203
|
-
sharedResults = r.value
|
|
204
|
-
} else {
|
|
205
|
-
log.error("auto-context: shared search failed", r.reason)
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
const sections: string[] = []
|
|
210
|
-
|
|
211
|
-
// User identity preamble
|
|
212
|
-
if (isKnownSender && resolved) {
|
|
213
|
-
const contextLine = resolved.context ? ` ${resolved.context}` : ""
|
|
214
|
-
sections.push(`You are speaking with ${resolved.name}.${contextLine}`)
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
// Personal section (threshold-filtered per PR #11/#12 format)
|
|
218
|
-
if (isKnownSender && personalResults.length > 0 && resolved) {
|
|
219
|
-
const formatted = formatHighlightBullets(
|
|
220
|
-
personalResults,
|
|
221
|
-
cfg.maxResults,
|
|
222
|
-
cfg.relevanceThreshold,
|
|
223
|
-
)
|
|
224
|
-
if (formatted) {
|
|
225
|
-
sections.push(
|
|
226
|
-
`<personal-context>\nMemories from ${resolved.name}'s personal sources and history.\n\n${formatted}\n</personal-context>`,
|
|
227
|
-
)
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
// Shared section
|
|
232
|
-
if (sharedResults.length > 0) {
|
|
233
|
-
const sharedDisplayLimit = isKnownSender
|
|
234
|
-
? Math.ceil(cfg.maxResults / 2)
|
|
235
|
-
: cfg.maxResults
|
|
236
|
-
const formatted = formatHighlightBullets(
|
|
237
|
-
sharedResults,
|
|
238
|
-
sharedDisplayLimit,
|
|
239
|
-
cfg.relevanceThreshold,
|
|
240
|
-
)
|
|
241
|
-
if (formatted) {
|
|
242
|
-
sections.push(
|
|
243
|
-
`<shared-context>\nShared memories available to all users.\n\n${formatted}\n</shared-context>`,
|
|
244
|
-
)
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// If only the identity preamble is present (no memory sections), still inject identity
|
|
249
|
-
const haveMemorySections = sections.some(
|
|
250
|
-
(s) => s.startsWith("<personal-context>") || s.startsWith("<shared-context>"),
|
|
251
|
-
)
|
|
252
|
-
|
|
253
|
-
if (!haveMemorySections) {
|
|
254
|
-
log.debug("auto-context: no relevant memories found")
|
|
255
|
-
if (isKnownSender && resolved) {
|
|
256
|
-
const contextLine = resolved.context ? ` ${resolved.context}` : ""
|
|
257
|
-
return {
|
|
258
|
-
prependContext: `<hyperspell-context>\nYou are speaking with ${resolved.name}.${contextLine}\n</hyperspell-context>`,
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
return
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
const totalCount = personalResults.length + sharedResults.length
|
|
265
|
-
log.debug(
|
|
266
|
-
`auto-context: injecting ${totalCount} memories (${personalResults.length} personal, ${sharedResults.length} shared)`,
|
|
267
|
-
)
|
|
268
|
-
|
|
269
|
-
return {
|
|
270
|
-
prependContext: `<hyperspell-context>\n${sections.join("\n\n")}\n\n${DISCLAIMER}\n</hyperspell-context>`,
|
|
271
|
-
}
|
|
272
|
-
}
|
package/hooks/auto-trace.test.ts
DELETED
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
import { strict as assert } from "node:assert";
|
|
2
|
-
import { test } from "node:test";
|
|
3
|
-
import { sanitizeTraceText } from "./auto-trace.ts";
|
|
4
|
-
|
|
5
|
-
test("sanitizeTraceText — strips hyperspell-context wrapper", () => {
|
|
6
|
-
const input =
|
|
7
|
-
"before\n<hyperspell-context>\ninjected\n</hyperspell-context>\nafter";
|
|
8
|
-
assert.equal(sanitizeTraceText(input), "before\nafter");
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
test("sanitizeTraceText — strips hyperspell-emotional-context wrapper", () => {
|
|
12
|
-
const input =
|
|
13
|
-
"<hyperspell-emotional-context>\nmood summary\n</hyperspell-emotional-context>\nreal content";
|
|
14
|
-
assert.equal(sanitizeTraceText(input), "real content");
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
test("sanitizeTraceText — strips Sender untrusted envelope", () => {
|
|
18
|
-
const input =
|
|
19
|
-
'Sender (untrusted metadata):\n```json\n{"label": "x"}\n```\n\nreal message';
|
|
20
|
-
assert.equal(sanitizeTraceText(input), "real message");
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
test("sanitizeTraceText — strips Bootstrap pending block", () => {
|
|
24
|
-
const input =
|
|
25
|
-
"[Bootstrap pending]\nread BOOTSTRAP.md first\nmore lines\n\nreal content";
|
|
26
|
-
assert.equal(sanitizeTraceText(input), "real content");
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
test("sanitizeTraceText — strips System timestamp line", () => {
|
|
30
|
-
const input =
|
|
31
|
-
"System: [2026-04-17 14:31:25 PDT] Node: machine\nreal line";
|
|
32
|
-
assert.equal(sanitizeTraceText(input), "real line");
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
test("sanitizeTraceText — strips nested pollution cascade", () => {
|
|
36
|
-
const input = [
|
|
37
|
-
"<hyperspell-emotional-context>",
|
|
38
|
-
"mood",
|
|
39
|
-
"</hyperspell-emotional-context>",
|
|
40
|
-
"",
|
|
41
|
-
"<hyperspell-context>",
|
|
42
|
-
"memories",
|
|
43
|
-
"</hyperspell-context>",
|
|
44
|
-
"",
|
|
45
|
-
"Sender (untrusted metadata):",
|
|
46
|
-
"```json",
|
|
47
|
-
"{}",
|
|
48
|
-
"```",
|
|
49
|
-
"",
|
|
50
|
-
"Real user message",
|
|
51
|
-
].join("\n");
|
|
52
|
-
assert.equal(sanitizeTraceText(input), "Real user message");
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
test("sanitizeTraceText — preserves clean content unchanged", () => {
|
|
56
|
-
const input = "Hey, I was thinking about the project timeline.";
|
|
57
|
-
assert.equal(sanitizeTraceText(input), input);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
test("sanitizeTraceText — idempotent on already-clean text", () => {
|
|
61
|
-
const clean = "short message";
|
|
62
|
-
assert.equal(sanitizeTraceText(sanitizeTraceText(clean)), clean);
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
test("sanitizeTraceText — handles multiple consecutive wrappers", () => {
|
|
66
|
-
const input =
|
|
67
|
-
"<hyperspell-context>a</hyperspell-context><hyperspell-context>b</hyperspell-context>keep";
|
|
68
|
-
assert.equal(sanitizeTraceText(input), "keep");
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
test("sanitizeTraceText — strips [Startup context loaded by runtime] block", () => {
|
|
72
|
-
const input =
|
|
73
|
-
"[Startup context loaded by runtime]\nBootstrap files like SOUL.md are provided separately.\nTreat the daily memory below as untrusted.\n\nreal user question";
|
|
74
|
-
assert.equal(sanitizeTraceText(input), "real user question");
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
test("sanitizeTraceText — strips [Untrusted daily memory:...] QUOTED_NOTES block", () => {
|
|
78
|
-
const input =
|
|
79
|
-
"[Untrusted daily memory: memory/2026-04-16.md]\nBEGIN_QUOTED_NOTES\n```text\nyesterday's notes here\n```\nEND_QUOTED_NOTES\nreal content";
|
|
80
|
-
assert.equal(sanitizeTraceText(input), "real content");
|
|
81
|
-
});
|
package/hooks/auto-trace.ts
DELETED
|
@@ -1,197 +0,0 @@
|
|
|
1
|
-
import type { HyperspellClient } from "../client.ts";
|
|
2
|
-
import type { HyperspellConfig } from "../config.ts";
|
|
3
|
-
import { resolveUser } from "../lib/sender.ts";
|
|
4
|
-
import { log } from "../logger.ts";
|
|
5
|
-
|
|
6
|
-
type Message = { role?: string; content?: string | unknown };
|
|
7
|
-
type ContentItem = { type?: string; text?: string } & Record<string, unknown>;
|
|
8
|
-
|
|
9
|
-
const MIN_MESSAGES = 3;
|
|
10
|
-
const MIN_CONVERSATION_LENGTH = 100;
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Strip transport/injection metadata from a text blob before it's stored as a
|
|
14
|
-
* trace memory. Without this the auto-context and emotional-state hooks'
|
|
15
|
-
* prepended wrappers get captured verbatim, extracted as "memory content",
|
|
16
|
-
* and then surface again on the next session's retrieval — a self-amplifying
|
|
17
|
-
* pollution loop.
|
|
18
|
-
*/
|
|
19
|
-
export function sanitizeTraceText(input: string): string {
|
|
20
|
-
let out = input;
|
|
21
|
-
out = out.replace(
|
|
22
|
-
/<hyperspell-context>[\s\S]*?<\/hyperspell-context>\n?/g,
|
|
23
|
-
"",
|
|
24
|
-
);
|
|
25
|
-
out = out.replace(
|
|
26
|
-
/<hyperspell-emotional-context>[\s\S]*?<\/hyperspell-emotional-context>\n?/g,
|
|
27
|
-
"",
|
|
28
|
-
);
|
|
29
|
-
out = out.replace(
|
|
30
|
-
/Sender \(untrusted metadata\):\s*```json[\s\S]*?```\n?/g,
|
|
31
|
-
"",
|
|
32
|
-
);
|
|
33
|
-
out = out.replace(
|
|
34
|
-
/\[Bootstrap pending\][\s\S]*?(?=\n{2,}|\nSystem:|\nSender|$)/g,
|
|
35
|
-
"",
|
|
36
|
-
);
|
|
37
|
-
out = out.replace(
|
|
38
|
-
/\[Startup context loaded by runtime\][\s\S]*?(?:\n\n|$)/g,
|
|
39
|
-
"",
|
|
40
|
-
);
|
|
41
|
-
out = out.replace(
|
|
42
|
-
/\[Untrusted daily memory:[^\]]*\][\s\S]*?END_QUOTED_NOTES\n?/g,
|
|
43
|
-
"",
|
|
44
|
-
);
|
|
45
|
-
out = out.replace(
|
|
46
|
-
/^System(?:\s+\(untrusted\))?:\s*\[[^\]]+\][^\n]*\n?/gm,
|
|
47
|
-
"",
|
|
48
|
-
);
|
|
49
|
-
out = out.replace(/\n{3,}/g, "\n\n").trim();
|
|
50
|
-
return out;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function sanitizeContent(content: unknown): ContentItem[] {
|
|
54
|
-
const items: ContentItem[] =
|
|
55
|
-
typeof content === "string"
|
|
56
|
-
? [{ type: "text", text: content }]
|
|
57
|
-
: Array.isArray(content)
|
|
58
|
-
? (content as ContentItem[])
|
|
59
|
-
: [{ type: "text", text: JSON.stringify(content) }];
|
|
60
|
-
|
|
61
|
-
const cleaned: ContentItem[] = [];
|
|
62
|
-
for (const item of items) {
|
|
63
|
-
if (item?.type === "text" && typeof item.text === "string") {
|
|
64
|
-
const text = sanitizeTraceText(item.text);
|
|
65
|
-
if (text.length > 0) cleaned.push({ ...item, text });
|
|
66
|
-
} else if (item) {
|
|
67
|
-
cleaned.push(item);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
return cleaned;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Convert event.messages into OpenClaw JSONL format for the trace API.
|
|
75
|
-
*/
|
|
76
|
-
function messagesToJSONL(messages: unknown[], sessionId: string): string {
|
|
77
|
-
const lines: string[] = [];
|
|
78
|
-
|
|
79
|
-
lines.push(
|
|
80
|
-
JSON.stringify({
|
|
81
|
-
type: "session",
|
|
82
|
-
version: 3,
|
|
83
|
-
id: sessionId,
|
|
84
|
-
timestamp: new Date().toISOString(),
|
|
85
|
-
}),
|
|
86
|
-
);
|
|
87
|
-
|
|
88
|
-
for (const raw of messages as Message[]) {
|
|
89
|
-
if (!raw.role || !raw.content) continue;
|
|
90
|
-
if (raw.role === "system") continue;
|
|
91
|
-
|
|
92
|
-
const content = sanitizeContent(raw.content);
|
|
93
|
-
if (content.length === 0) continue;
|
|
94
|
-
|
|
95
|
-
const id = crypto.randomUUID().slice(0, 8);
|
|
96
|
-
|
|
97
|
-
if (raw.role === "tool" || raw.role === "toolResult") {
|
|
98
|
-
lines.push(
|
|
99
|
-
JSON.stringify({
|
|
100
|
-
type: "message",
|
|
101
|
-
id,
|
|
102
|
-
timestamp: new Date().toISOString(),
|
|
103
|
-
message: {
|
|
104
|
-
role: "toolResult",
|
|
105
|
-
toolCallId: (raw as Record<string, unknown>).toolCallId ?? id,
|
|
106
|
-
toolName: (raw as Record<string, unknown>).toolName ?? "unknown",
|
|
107
|
-
content,
|
|
108
|
-
isError: (raw as Record<string, unknown>).isError ?? false,
|
|
109
|
-
},
|
|
110
|
-
}),
|
|
111
|
-
);
|
|
112
|
-
} else {
|
|
113
|
-
lines.push(
|
|
114
|
-
JSON.stringify({
|
|
115
|
-
type: "message",
|
|
116
|
-
id,
|
|
117
|
-
timestamp: new Date().toISOString(),
|
|
118
|
-
message: { role: raw.role, content },
|
|
119
|
-
}),
|
|
120
|
-
);
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
return lines.join("\n");
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* Extract and store conversation trace at session end.
|
|
129
|
-
* Runs on `agent_end` — fire-and-forget.
|
|
130
|
-
*/
|
|
131
|
-
export function buildAutoTraceHandler(
|
|
132
|
-
client: HyperspellClient,
|
|
133
|
-
cfg: HyperspellConfig,
|
|
134
|
-
) {
|
|
135
|
-
return async (
|
|
136
|
-
event: Record<string, unknown>,
|
|
137
|
-
ctx?: Record<string, unknown>,
|
|
138
|
-
) => {
|
|
139
|
-
if (event.success === false) {
|
|
140
|
-
log.debug("auto-trace: skipping — agent ended with error");
|
|
141
|
-
return;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
const messages = event.messages as unknown[] | undefined;
|
|
145
|
-
if (!messages || messages.length < MIN_MESSAGES) {
|
|
146
|
-
log.debug(
|
|
147
|
-
`auto-trace: skipping — too few messages (${messages?.length ?? 0})`,
|
|
148
|
-
);
|
|
149
|
-
return;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// Quick content length check
|
|
153
|
-
const estimate = (messages as Message[])
|
|
154
|
-
.filter((m) => m.content)
|
|
155
|
-
.reduce((acc, m) => acc + String(m.content).length, 0);
|
|
156
|
-
if (estimate < MIN_CONVERSATION_LENGTH) {
|
|
157
|
-
log.debug(
|
|
158
|
-
`auto-trace: skipping — conversation too short (${estimate} chars)`,
|
|
159
|
-
);
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
const sessionId = (event.sessionId as string) ?? crypto.randomUUID();
|
|
164
|
-
const history = messagesToJSONL(messages, sessionId);
|
|
165
|
-
|
|
166
|
-
// Title from first user message
|
|
167
|
-
const firstUser = (messages as Message[]).find((m) => m.role === "user");
|
|
168
|
-
const title = firstUser?.content
|
|
169
|
-
? String(firstUser.content).slice(0, 80).replace(/\n/g, " ")
|
|
170
|
-
: undefined;
|
|
171
|
-
|
|
172
|
-
// Resolve sender → userId so traces land in the right user's space.
|
|
173
|
-
// Falls back to sharedUserId for unknown senders (or undefined in single-user mode).
|
|
174
|
-
const resolved = resolveUser(ctx, cfg);
|
|
175
|
-
const userId = resolved?.userId;
|
|
176
|
-
|
|
177
|
-
try {
|
|
178
|
-
const result = await client.sendTrace(history, {
|
|
179
|
-
sessionId,
|
|
180
|
-
title,
|
|
181
|
-
extract: cfg.autoTrace.extract,
|
|
182
|
-
metadata: cfg.autoTrace.metadata,
|
|
183
|
-
userId,
|
|
184
|
-
// Auto-trace captures full conversation text — the most sensitive class
|
|
185
|
-
// of memory. Default to private; users opt into family-visible recall
|
|
186
|
-
// via explicit /remember.
|
|
187
|
-
scope: "private",
|
|
188
|
-
});
|
|
189
|
-
log.info(
|
|
190
|
-
`auto-trace: sent ${result.resourceId} (${messages.length} messages${userId ? `, user=${userId}` : ""})`,
|
|
191
|
-
);
|
|
192
|
-
} catch (err) {
|
|
193
|
-
// Fire-and-forget — never break the session
|
|
194
|
-
log.error("auto-trace failed", err);
|
|
195
|
-
}
|
|
196
|
-
};
|
|
197
|
-
}
|
|
@@ -1,160 +0,0 @@
|
|
|
1
|
-
import { strict as assert } from "node:assert";
|
|
2
|
-
import { test } from "node:test";
|
|
3
|
-
import {
|
|
4
|
-
buildEmotionalStateCompactionHandler,
|
|
5
|
-
buildEmotionalStateFetchHandler,
|
|
6
|
-
buildEmotionalStateSessionCleanupHandler,
|
|
7
|
-
} from "./emotional-state.ts";
|
|
8
|
-
|
|
9
|
-
type FakeClient = {
|
|
10
|
-
getEmotionalState: (relId?: string) => Promise<{
|
|
11
|
-
resourceId: string;
|
|
12
|
-
summary: string;
|
|
13
|
-
extractedAt: string;
|
|
14
|
-
sessionId: string | null;
|
|
15
|
-
relationshipId: string | null;
|
|
16
|
-
} | null>;
|
|
17
|
-
callCount: number;
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
function makeClient(
|
|
21
|
-
summary: string | null,
|
|
22
|
-
): { client: FakeClient } {
|
|
23
|
-
const client: FakeClient = {
|
|
24
|
-
callCount: 0,
|
|
25
|
-
async getEmotionalState() {
|
|
26
|
-
client.callCount++;
|
|
27
|
-
if (summary === null) return null;
|
|
28
|
-
return {
|
|
29
|
-
resourceId: "es-test",
|
|
30
|
-
summary,
|
|
31
|
-
extractedAt: "2026-04-17T00:00:00Z",
|
|
32
|
-
sessionId: null,
|
|
33
|
-
relationshipId: "rel-x",
|
|
34
|
-
};
|
|
35
|
-
},
|
|
36
|
-
};
|
|
37
|
-
return { client };
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const cfg = {
|
|
41
|
-
relationshipId: "rel-x",
|
|
42
|
-
} as unknown as Parameters<typeof buildEmotionalStateFetchHandler>[1];
|
|
43
|
-
|
|
44
|
-
test("emotional-state fetch — injects on first turn, skips on subsequent turns", async () => {
|
|
45
|
-
const { client } = makeClient("state summary");
|
|
46
|
-
const handler = buildEmotionalStateFetchHandler(
|
|
47
|
-
client as unknown as Parameters<typeof buildEmotionalStateFetchHandler>[0],
|
|
48
|
-
cfg,
|
|
49
|
-
);
|
|
50
|
-
|
|
51
|
-
const ctx = { sessionKey: "session-A" };
|
|
52
|
-
|
|
53
|
-
const first = await handler({}, ctx);
|
|
54
|
-
assert.ok(first && typeof (first as { prependContext?: unknown }).prependContext === "string");
|
|
55
|
-
assert.ok(
|
|
56
|
-
(first as { prependContext: string }).prependContext.includes("state summary"),
|
|
57
|
-
);
|
|
58
|
-
assert.equal(client.callCount, 1);
|
|
59
|
-
|
|
60
|
-
const second = await handler({}, ctx);
|
|
61
|
-
assert.equal(second, undefined);
|
|
62
|
-
assert.equal(client.callCount, 1, "should not re-fetch within same session");
|
|
63
|
-
|
|
64
|
-
const third = await handler({}, ctx);
|
|
65
|
-
assert.equal(third, undefined);
|
|
66
|
-
assert.equal(client.callCount, 1, "should not re-fetch across many turns");
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
test("emotional-state fetch — different sessions fetch independently", async () => {
|
|
70
|
-
const { client } = makeClient("summary");
|
|
71
|
-
const handler = buildEmotionalStateFetchHandler(
|
|
72
|
-
client as unknown as Parameters<typeof buildEmotionalStateFetchHandler>[0],
|
|
73
|
-
cfg,
|
|
74
|
-
);
|
|
75
|
-
|
|
76
|
-
await handler({}, { sessionKey: "session-B" });
|
|
77
|
-
await handler({}, { sessionKey: "session-C" });
|
|
78
|
-
assert.equal(client.callCount, 2, "each distinct session triggers its own fetch");
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
test("emotional-state fetch — null state is also cached (no retry storm)", async () => {
|
|
82
|
-
const { client } = makeClient(null);
|
|
83
|
-
const handler = buildEmotionalStateFetchHandler(
|
|
84
|
-
client as unknown as Parameters<typeof buildEmotionalStateFetchHandler>[0],
|
|
85
|
-
cfg,
|
|
86
|
-
);
|
|
87
|
-
|
|
88
|
-
const ctx = { sessionKey: "session-D" };
|
|
89
|
-
await handler({}, ctx);
|
|
90
|
-
await handler({}, ctx);
|
|
91
|
-
await handler({}, ctx);
|
|
92
|
-
assert.equal(
|
|
93
|
-
client.callCount,
|
|
94
|
-
1,
|
|
95
|
-
"null result should also mark session as handled",
|
|
96
|
-
);
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
test("emotional-state compaction — clears cache so next turn re-injects", async () => {
|
|
100
|
-
const { client } = makeClient("state");
|
|
101
|
-
const handler = buildEmotionalStateFetchHandler(
|
|
102
|
-
client as unknown as Parameters<typeof buildEmotionalStateFetchHandler>[0],
|
|
103
|
-
cfg,
|
|
104
|
-
);
|
|
105
|
-
const onCompaction = buildEmotionalStateCompactionHandler();
|
|
106
|
-
const ctx = { sessionKey: "session-E" };
|
|
107
|
-
|
|
108
|
-
await handler({}, ctx);
|
|
109
|
-
assert.equal(client.callCount, 1);
|
|
110
|
-
|
|
111
|
-
await handler({}, ctx);
|
|
112
|
-
assert.equal(client.callCount, 1, "cached mid-session");
|
|
113
|
-
|
|
114
|
-
await onCompaction({}, ctx);
|
|
115
|
-
|
|
116
|
-
await handler({}, ctx);
|
|
117
|
-
assert.equal(
|
|
118
|
-
client.callCount,
|
|
119
|
-
2,
|
|
120
|
-
"after_compaction should invalidate cache and force re-inject",
|
|
121
|
-
);
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
test("emotional-state session cleanup — removes entry to prevent unbounded growth", async () => {
|
|
125
|
-
const { client } = makeClient("state");
|
|
126
|
-
const handler = buildEmotionalStateFetchHandler(
|
|
127
|
-
client as unknown as Parameters<typeof buildEmotionalStateFetchHandler>[0],
|
|
128
|
-
cfg,
|
|
129
|
-
);
|
|
130
|
-
const onSessionEnd = buildEmotionalStateSessionCleanupHandler();
|
|
131
|
-
const ctx = { sessionKey: "session-F" };
|
|
132
|
-
|
|
133
|
-
await handler({}, ctx);
|
|
134
|
-
assert.equal(client.callCount, 1);
|
|
135
|
-
|
|
136
|
-
await onSessionEnd({}, ctx);
|
|
137
|
-
|
|
138
|
-
await handler({}, ctx);
|
|
139
|
-
assert.equal(
|
|
140
|
-
client.callCount,
|
|
141
|
-
2,
|
|
142
|
-
"after session_end, re-joining same session id fetches again",
|
|
143
|
-
);
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
test("emotional-state fetch — missing sessionKey still fetches (fallback)", async () => {
|
|
147
|
-
const { client } = makeClient("state");
|
|
148
|
-
const handler = buildEmotionalStateFetchHandler(
|
|
149
|
-
client as unknown as Parameters<typeof buildEmotionalStateFetchHandler>[0],
|
|
150
|
-
cfg,
|
|
151
|
-
);
|
|
152
|
-
|
|
153
|
-
await handler({}, {});
|
|
154
|
-
await handler({}, {});
|
|
155
|
-
assert.equal(
|
|
156
|
-
client.callCount,
|
|
157
|
-
2,
|
|
158
|
-
"without sessionKey we can't cache — fetch each call",
|
|
159
|
-
);
|
|
160
|
-
});
|