@hyperspell/openclaw-hyperspell 0.10.0 → 0.11.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/client.ts +29 -3
- package/commands/slash.ts +84 -13
- package/config.test.ts +202 -0
- package/config.ts +163 -0
- package/hooks/auto-context.ts +44 -7
- package/hooks/auto-trace.test.ts +81 -0
- package/hooks/auto-trace.ts +69 -9
- package/hooks/emotional-state.test.ts +160 -0
- package/hooks/emotional-state.ts +93 -11
- package/index.ts +13 -2
- package/lib/sender.test.ts +234 -0
- package/lib/sender.ts +178 -20
- package/lib/voice-id.ts +39 -0
- package/openclaw.plugin.json +37 -0
- package/package.json +3 -2
- package/tools/remember.ts +69 -5
- package/tools/search.ts +34 -5
package/hooks/auto-context.ts
CHANGED
|
@@ -1,8 +1,32 @@
|
|
|
1
1
|
import type { HyperspellClient, SearchResult } from "../client.ts"
|
|
2
|
-
import type { HyperspellConfig } from "../config.ts"
|
|
3
|
-
import {
|
|
2
|
+
import type { CanReadScope, HyperspellConfig } from "../config.ts"
|
|
3
|
+
import {
|
|
4
|
+
buildScopeFilter,
|
|
5
|
+
getCanReadScopes,
|
|
6
|
+
resolveUser,
|
|
7
|
+
type ResolvedUser,
|
|
8
|
+
} from "../lib/sender.ts"
|
|
4
9
|
import { log } from "../logger.ts"
|
|
5
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
|
+
|
|
6
30
|
function formatRelativeTime(isoTimestamp: string): string {
|
|
7
31
|
try {
|
|
8
32
|
const dt = new Date(isoTimestamp)
|
|
@@ -86,7 +110,10 @@ export function buildAutoContextHandler(
|
|
|
86
110
|
log.debug(`auto-context: searching for "${prompt.slice(0, 50)}..."`)
|
|
87
111
|
|
|
88
112
|
try {
|
|
89
|
-
const results = await client.search(prompt, {
|
|
113
|
+
const results = await client.search(prompt, {
|
|
114
|
+
limit: cfg.maxResults,
|
|
115
|
+
filter: EXCLUDE_SESSION_END_FILTER,
|
|
116
|
+
})
|
|
90
117
|
const formatted = formatHighlightBullets(
|
|
91
118
|
results,
|
|
92
119
|
cfg.maxResults,
|
|
@@ -111,16 +138,24 @@ async function multiUserSearch(
|
|
|
111
138
|
client: HyperspellClient,
|
|
112
139
|
cfg: HyperspellConfig,
|
|
113
140
|
prompt: string,
|
|
114
|
-
resolved:
|
|
115
|
-
| { userId: string; name: string; context?: string; resolved: boolean }
|
|
116
|
-
| undefined,
|
|
141
|
+
resolved: ResolvedUser | undefined,
|
|
117
142
|
) {
|
|
118
143
|
const multiUser = cfg.multiUser!
|
|
119
144
|
const isKnownSender = !!resolved?.resolved
|
|
120
145
|
const includeShared = multiUser.includeSharedInSearch
|
|
121
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
|
+
|
|
122
157
|
log.debug(
|
|
123
|
-
`auto-context: searching for "${prompt.slice(0, 50)}..." user=${resolved?.userId ?? "unknown"}`,
|
|
158
|
+
`auto-context: searching for "${prompt.slice(0, 50)}..." user=${resolved?.userId ?? "unknown"} canRead=${JSON.stringify(canRead)}`,
|
|
124
159
|
)
|
|
125
160
|
|
|
126
161
|
// Build parallel searches — personal (known senders only) + shared
|
|
@@ -128,6 +163,7 @@ async function multiUserSearch(
|
|
|
128
163
|
? client.search(prompt, {
|
|
129
164
|
limit: cfg.maxResults,
|
|
130
165
|
userId: resolved!.userId,
|
|
166
|
+
filter: EXCLUDE_SESSION_END_FILTER,
|
|
131
167
|
})
|
|
132
168
|
: null
|
|
133
169
|
|
|
@@ -140,6 +176,7 @@ async function multiUserSearch(
|
|
|
140
176
|
? client.search(prompt, {
|
|
141
177
|
limit: sharedLimit,
|
|
142
178
|
userId: multiUser.sharedUserId,
|
|
179
|
+
filter: mergeWithExclude(scopeFilter),
|
|
143
180
|
})
|
|
144
181
|
: null
|
|
145
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",
|
|
@@ -125,6 +181,10 @@ export function buildAutoTraceHandler(
|
|
|
125
181
|
extract: cfg.autoTrace.extract,
|
|
126
182
|
metadata: cfg.autoTrace.metadata,
|
|
127
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",
|
|
128
188
|
});
|
|
129
189
|
log.info(
|
|
130
190
|
`auto-trace: sent ${result.resourceId} (${messages.length} messages${userId ? `, user=${userId}` : ""})`,
|
|
@@ -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
|
|