@hyperspell/openclaw-hyperspell 0.11.0 → 0.12.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/hooks/auto-context.ts +25 -2
- package/hooks/auto-trace.test.ts +81 -0
- package/hooks/auto-trace.ts +65 -9
- package/hooks/emotional-state.test.ts +160 -0
- package/hooks/emotional-state.ts +93 -11
- package/index.ts +13 -2
- package/openclaw.plugin.json +8 -1
- package/package.json +2 -2
package/hooks/auto-context.ts
CHANGED
|
@@ -8,6 +8,25 @@ import {
|
|
|
8
8
|
} from "../lib/sender.ts"
|
|
9
9
|
import { log } from "../logger.ts"
|
|
10
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
|
+
|
|
11
30
|
function formatRelativeTime(isoTimestamp: string): string {
|
|
12
31
|
try {
|
|
13
32
|
const dt = new Date(isoTimestamp)
|
|
@@ -91,7 +110,10 @@ export function buildAutoContextHandler(
|
|
|
91
110
|
log.debug(`auto-context: searching for "${prompt.slice(0, 50)}..."`)
|
|
92
111
|
|
|
93
112
|
try {
|
|
94
|
-
const results = await client.search(prompt, {
|
|
113
|
+
const results = await client.search(prompt, {
|
|
114
|
+
limit: cfg.maxResults,
|
|
115
|
+
filter: EXCLUDE_SESSION_END_FILTER,
|
|
116
|
+
})
|
|
95
117
|
const formatted = formatHighlightBullets(
|
|
96
118
|
results,
|
|
97
119
|
cfg.maxResults,
|
|
@@ -141,6 +163,7 @@ async function multiUserSearch(
|
|
|
141
163
|
? client.search(prompt, {
|
|
142
164
|
limit: cfg.maxResults,
|
|
143
165
|
userId: resolved!.userId,
|
|
166
|
+
filter: EXCLUDE_SESSION_END_FILTER,
|
|
144
167
|
})
|
|
145
168
|
: null
|
|
146
169
|
|
|
@@ -153,7 +176,7 @@ async function multiUserSearch(
|
|
|
153
176
|
? client.search(prompt, {
|
|
154
177
|
limit: sharedLimit,
|
|
155
178
|
userId: multiUser.sharedUserId,
|
|
156
|
-
filter: scopeFilter,
|
|
179
|
+
filter: mergeWithExclude(scopeFilter),
|
|
157
180
|
})
|
|
158
181
|
: null
|
|
159
182
|
|
|
@@ -0,0 +1,81 @@
|
|
|
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
CHANGED
|
@@ -4,17 +4,78 @@ import { resolveUser } from "../lib/sender.ts";
|
|
|
4
4
|
import { log } from "../logger.ts";
|
|
5
5
|
|
|
6
6
|
type Message = { role?: string; content?: string | unknown };
|
|
7
|
+
type ContentItem = { type?: string; text?: string } & Record<string, unknown>;
|
|
7
8
|
|
|
8
9
|
const MIN_MESSAGES = 3;
|
|
9
10
|
const MIN_CONVERSATION_LENGTH = 100;
|
|
10
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
|
+
|
|
11
73
|
/**
|
|
12
74
|
* Convert event.messages into OpenClaw JSONL format for the trace API.
|
|
13
75
|
*/
|
|
14
76
|
function messagesToJSONL(messages: unknown[], sessionId: string): string {
|
|
15
77
|
const lines: string[] = [];
|
|
16
78
|
|
|
17
|
-
// Session header
|
|
18
79
|
lines.push(
|
|
19
80
|
JSON.stringify({
|
|
20
81
|
type: "session",
|
|
@@ -24,21 +85,16 @@ function messagesToJSONL(messages: unknown[], sessionId: string): string {
|
|
|
24
85
|
}),
|
|
25
86
|
);
|
|
26
87
|
|
|
27
|
-
// Message entries
|
|
28
88
|
for (const raw of messages as Message[]) {
|
|
29
89
|
if (!raw.role || !raw.content) continue;
|
|
90
|
+
if (raw.role === "system") continue;
|
|
30
91
|
|
|
31
|
-
const content =
|
|
32
|
-
|
|
33
|
-
? [{ type: "text", text: raw.content }]
|
|
34
|
-
: Array.isArray(raw.content)
|
|
35
|
-
? raw.content
|
|
36
|
-
: [{ type: "text", text: JSON.stringify(raw.content) }];
|
|
92
|
+
const content = sanitizeContent(raw.content);
|
|
93
|
+
if (content.length === 0) continue;
|
|
37
94
|
|
|
38
95
|
const id = crypto.randomUUID().slice(0, 8);
|
|
39
96
|
|
|
40
97
|
if (raw.role === "tool" || raw.role === "toolResult") {
|
|
41
|
-
// Tool results use a different structure
|
|
42
98
|
lines.push(
|
|
43
99
|
JSON.stringify({
|
|
44
100
|
type: "message",
|
|
@@ -0,0 +1,160 @@
|
|
|
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
|
+
});
|
package/hooks/emotional-state.ts
CHANGED
|
@@ -1,37 +1,92 @@
|
|
|
1
1
|
import type { HyperspellClient } from "../client.ts";
|
|
2
2
|
import type { HyperspellConfig } from "../config.ts";
|
|
3
3
|
import { log } from "../logger.ts";
|
|
4
|
+
import { sanitizeTraceText } from "./auto-trace.ts";
|
|
4
5
|
|
|
5
6
|
type Message = { role?: string; content?: string | unknown };
|
|
7
|
+
type AgentContext = { sessionKey?: string };
|
|
6
8
|
|
|
7
9
|
const MIN_MESSAGES = 3;
|
|
8
10
|
const MIN_CONVERSATION_LENGTH = 100;
|
|
9
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Sessions where emotional context has already been injected this run.
|
|
14
|
+
* Emotional state doesn't change within a session (it's extracted at
|
|
15
|
+
* agent_end and surfaces on the *next* session), so re-fetching and
|
|
16
|
+
* re-injecting on every turn is pure cost — one API call and a few
|
|
17
|
+
* hundred tokens of repeated wrapper per turn.
|
|
18
|
+
*
|
|
19
|
+
* Lifecycle:
|
|
20
|
+
* - first before_agent_start in a session: fetch, inject, mark.
|
|
21
|
+
* - subsequent turns in same session: skip (return undefined).
|
|
22
|
+
* - after_compaction: clear the mark so the next turn re-injects (the
|
|
23
|
+
* initial injection may have been compacted out of history).
|
|
24
|
+
* - session_end: clean up to prevent unbounded Set growth.
|
|
25
|
+
*/
|
|
26
|
+
const injectedSessions = new Set<string>();
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Extract readable text from a message content, unwrapping the common
|
|
30
|
+
* `[{ type: "text", text: "..." }]` array shape so sanitizeTraceText can
|
|
31
|
+
* operate on real text (not JSON-stringified content where newlines would
|
|
32
|
+
* be escaped and regex line-anchors wouldn't match).
|
|
33
|
+
*/
|
|
34
|
+
function contentToText(content: unknown): string {
|
|
35
|
+
if (typeof content === "string") return content;
|
|
36
|
+
if (Array.isArray(content)) {
|
|
37
|
+
const texts: string[] = [];
|
|
38
|
+
for (const item of content) {
|
|
39
|
+
if (
|
|
40
|
+
item &&
|
|
41
|
+
typeof item === "object" &&
|
|
42
|
+
(item as { type?: unknown }).type === "text" &&
|
|
43
|
+
typeof (item as { text?: unknown }).text === "string"
|
|
44
|
+
) {
|
|
45
|
+
texts.push((item as { text: string }).text);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (texts.length > 0) return texts.join("\n");
|
|
49
|
+
}
|
|
50
|
+
return "";
|
|
51
|
+
}
|
|
52
|
+
|
|
10
53
|
function messagesToTranscript(messages: unknown[]): string {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
.
|
|
54
|
+
const lines: string[] = [];
|
|
55
|
+
for (const m of messages as Message[]) {
|
|
56
|
+
if (!m.role || !m.content) continue;
|
|
57
|
+
if (m.role === "system") continue;
|
|
58
|
+
const raw = contentToText(m.content);
|
|
59
|
+
if (!raw) continue;
|
|
60
|
+
const cleaned = sanitizeTraceText(raw);
|
|
61
|
+
if (cleaned.length === 0) continue;
|
|
62
|
+
lines.push(`${m.role}: ${cleaned}`);
|
|
63
|
+
}
|
|
64
|
+
return lines.join("\n");
|
|
19
65
|
}
|
|
20
66
|
|
|
21
67
|
/**
|
|
22
|
-
* Fetch emotional state
|
|
23
|
-
*
|
|
68
|
+
* Fetch emotional state on the first agent turn of a session and inject into
|
|
69
|
+
* context. On later turns of the same session, return undefined — the
|
|
70
|
+
* injection from the first turn is already in the conversation history.
|
|
71
|
+
*
|
|
72
|
+
* Runs on `before_agent_start` (which fires every turn).
|
|
24
73
|
*/
|
|
25
74
|
export function buildEmotionalStateFetchHandler(
|
|
26
75
|
client: HyperspellClient,
|
|
27
76
|
cfg: HyperspellConfig,
|
|
28
77
|
) {
|
|
29
|
-
return async (_event: Record<string, unknown
|
|
78
|
+
return async (_event: Record<string, unknown>, ctx?: AgentContext) => {
|
|
79
|
+
const sessionKey = ctx?.sessionKey;
|
|
80
|
+
if (sessionKey && injectedSessions.has(sessionKey)) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
30
84
|
try {
|
|
31
85
|
const state = await client.getEmotionalState(cfg.relationshipId);
|
|
32
86
|
|
|
33
87
|
if (!state) {
|
|
34
88
|
log.debug("emotional-context: no prior emotional state found");
|
|
89
|
+
if (sessionKey) injectedSessions.add(sessionKey);
|
|
35
90
|
return;
|
|
36
91
|
}
|
|
37
92
|
|
|
@@ -45,6 +100,7 @@ export function buildEmotionalStateFetchHandler(
|
|
|
45
100
|
"</hyperspell-emotional-context>",
|
|
46
101
|
].join("\n");
|
|
47
102
|
|
|
103
|
+
if (sessionKey) injectedSessions.add(sessionKey);
|
|
48
104
|
return { prependContext: context };
|
|
49
105
|
} catch (err) {
|
|
50
106
|
log.error("emotional-context fetch failed", err);
|
|
@@ -53,6 +109,32 @@ export function buildEmotionalStateFetchHandler(
|
|
|
53
109
|
};
|
|
54
110
|
}
|
|
55
111
|
|
|
112
|
+
/**
|
|
113
|
+
* After compaction, the emotional-context block from the first turn may have
|
|
114
|
+
* been trimmed out of history. Clear the cache so the next turn re-injects.
|
|
115
|
+
*/
|
|
116
|
+
export function buildEmotionalStateCompactionHandler() {
|
|
117
|
+
return async (_event: Record<string, unknown>, ctx?: AgentContext) => {
|
|
118
|
+
const sessionKey = ctx?.sessionKey;
|
|
119
|
+
if (sessionKey && injectedSessions.delete(sessionKey)) {
|
|
120
|
+
log.debug(
|
|
121
|
+
`emotional-context: cache cleared after compaction (session=${sessionKey})`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Remove session from the inject-once cache when the session ends, to keep the
|
|
129
|
+
* Set from growing unbounded over process lifetime.
|
|
130
|
+
*/
|
|
131
|
+
export function buildEmotionalStateSessionCleanupHandler() {
|
|
132
|
+
return async (_event: Record<string, unknown>, ctx?: AgentContext) => {
|
|
133
|
+
const sessionKey = ctx?.sessionKey;
|
|
134
|
+
if (sessionKey) injectedSessions.delete(sessionKey);
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
56
138
|
/**
|
|
57
139
|
* Extract and store emotional state at session end.
|
|
58
140
|
* Runs on `agent_end` — fire-and-forget.
|
package/index.ts
CHANGED
|
@@ -5,7 +5,12 @@ import { registerCliCommands } from "./commands/setup.ts"
|
|
|
5
5
|
import { parseConfig, hyperspellConfigSchema, getWorkspaceDir } from "./config.ts"
|
|
6
6
|
import { buildAutoContextHandler } from "./hooks/auto-context.ts"
|
|
7
7
|
import { buildAutoTraceHandler } from "./hooks/auto-trace.ts"
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
buildEmotionalStateCompactionHandler,
|
|
10
|
+
buildEmotionalStateFetchHandler,
|
|
11
|
+
buildEmotionalStateSessionCleanupHandler,
|
|
12
|
+
buildEmotionalStateStoreHandler,
|
|
13
|
+
} from "./hooks/emotional-state.ts"
|
|
9
14
|
import { buildFileSyncHandler, syncMemoriesOnStartup } from "./hooks/memory-sync.ts"
|
|
10
15
|
import { initLogger } from "./logger.ts"
|
|
11
16
|
import { createRememberToolFactory } from "./tools/remember.ts"
|
|
@@ -88,9 +93,15 @@ export default {
|
|
|
88
93
|
name: "hyperspell_remember",
|
|
89
94
|
});
|
|
90
95
|
|
|
91
|
-
// Register emotional context hooks
|
|
96
|
+
// Register emotional context hooks.
|
|
97
|
+
// - fetch: inject once per session on first turn (cached thereafter)
|
|
98
|
+
// - compaction: clear cache so the next turn re-injects after trim
|
|
99
|
+
// - session cleanup: drop Set entry on session end
|
|
100
|
+
// - store: extract new emotional state from the finished session
|
|
92
101
|
if (cfg.emotionalContext) {
|
|
93
102
|
api.on("before_agent_start", buildEmotionalStateFetchHandler(client, cfg));
|
|
103
|
+
api.on("after_compaction", buildEmotionalStateCompactionHandler());
|
|
104
|
+
api.on("session_end", buildEmotionalStateSessionCleanupHandler());
|
|
94
105
|
api.on("agent_end", buildEmotionalStateStoreHandler(client, cfg));
|
|
95
106
|
}
|
|
96
107
|
|
package/openclaw.plugin.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "openclaw-hyperspell",
|
|
3
3
|
"name": "Hyperspell",
|
|
4
4
|
"description": "Context and memory for your AI agents — search across Notion, Slack, Google Drive, and more",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.12.0",
|
|
6
6
|
"kind": "memory",
|
|
7
7
|
"uiHints": {
|
|
8
8
|
"apiKey": {
|
|
@@ -69,6 +69,10 @@
|
|
|
69
69
|
"label": "Multi-User Support",
|
|
70
70
|
"help": "Per-sender context isolation for household or shared-device setups. Maps senders to user IDs; supports optional role-based privacy scopes (private/family/parent-only/etc).",
|
|
71
71
|
"advanced": true
|
|
72
|
+
},
|
|
73
|
+
"dreaming": {
|
|
74
|
+
"label": "Dreaming",
|
|
75
|
+
"help": "Optional dreaming config consumed by memory-core when this plugin owns the memory slot. Lets memory-core's dreaming engine consolidate local session transcripts into workspace/MEMORY.md on cron."
|
|
72
76
|
}
|
|
73
77
|
},
|
|
74
78
|
"configSchema": {
|
|
@@ -161,6 +165,9 @@
|
|
|
161
165
|
}
|
|
162
166
|
}
|
|
163
167
|
}
|
|
168
|
+
},
|
|
169
|
+
"dreaming": {
|
|
170
|
+
"type": "object"
|
|
164
171
|
}
|
|
165
172
|
},
|
|
166
173
|
"required": []
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperspell/openclaw-hyperspell",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "OpenClaw Hyperspell memory plugin",
|
|
6
6
|
"license": "MIT",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"check-types": "tsc --noEmit",
|
|
44
44
|
"lint": "bunx @biomejs/biome ci .",
|
|
45
45
|
"lint:fix": "bunx @biomejs/biome check --write .",
|
|
46
|
-
"test": "node --test --experimental-strip-types lib/sender.test.ts config.test.ts"
|
|
46
|
+
"test": "node --test --experimental-strip-types lib/sender.test.ts config.test.ts hooks/auto-trace.test.ts hooks/emotional-state.test.ts"
|
|
47
47
|
},
|
|
48
48
|
"openclaw": {
|
|
49
49
|
"extensions": [
|