@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.
Files changed (50) hide show
  1. package/README.md +37 -1
  2. package/dist/client.js +341 -0
  3. package/dist/commands/setup.js +568 -0
  4. package/dist/commands/slash.js +159 -0
  5. package/dist/config.js +349 -0
  6. package/{graph/cron.ts → dist/graph/cron.js} +11 -15
  7. package/dist/graph/index.js +4 -0
  8. package/dist/graph/ops.js +221 -0
  9. package/dist/graph/state.js +68 -0
  10. package/dist/graph/tools.js +87 -0
  11. package/dist/hooks/auto-context.js +199 -0
  12. package/dist/hooks/auto-trace.js +143 -0
  13. package/dist/hooks/emotional-state.js +155 -0
  14. package/dist/hooks/memory-sync.js +54 -0
  15. package/dist/hooks/startup-orientation.js +236 -0
  16. package/dist/index.js +132 -0
  17. package/dist/lib/browser.js +29 -0
  18. package/dist/lib/sender.js +173 -0
  19. package/dist/lib/voice-id.js +22 -0
  20. package/dist/logger.js +32 -0
  21. package/dist/sync/markdown.js +151 -0
  22. package/dist/tools/remember.js +97 -0
  23. package/dist/tools/search.js +87 -0
  24. package/openclaw.plugin.json +25 -1
  25. package/package.json +7 -12
  26. package/client.ts +0 -566
  27. package/commands/setup.ts +0 -673
  28. package/commands/slash.ts +0 -198
  29. package/config.test.ts +0 -202
  30. package/config.ts +0 -497
  31. package/graph/index.ts +0 -5
  32. package/graph/ops.ts +0 -259
  33. package/graph/state.ts +0 -79
  34. package/graph/tools.ts +0 -117
  35. package/hooks/auto-context.ts +0 -272
  36. package/hooks/auto-trace.test.ts +0 -81
  37. package/hooks/auto-trace.ts +0 -197
  38. package/hooks/emotional-state.test.ts +0 -160
  39. package/hooks/emotional-state.ts +0 -179
  40. package/hooks/memory-sync.ts +0 -65
  41. package/index.ts +0 -152
  42. package/lib/browser.ts +0 -31
  43. package/lib/sender.test.ts +0 -234
  44. package/lib/sender.ts +0 -234
  45. package/lib/voice-id.ts +0 -39
  46. package/logger.ts +0 -41
  47. package/sync/markdown.ts +0 -186
  48. package/tools/remember.ts +0 -132
  49. package/tools/search.ts +0 -131
  50. package/types/openclaw.d.ts +0 -76
@@ -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
- }
@@ -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
- });
@@ -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
- });