@hyperspell/openclaw-hyperspell 0.8.1 → 0.9.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/client.ts +132 -0
- package/commands/setup.ts +1 -0
- package/config.ts +6 -0
- package/hooks/emotional-state.ts +97 -0
- package/index.ts +7 -0
- package/openclaw.plugin.json +17 -0
- package/package.json +1 -1
package/client.ts
CHANGED
|
@@ -37,6 +37,25 @@ export type Connection = {
|
|
|
37
37
|
provider: HyperspellSource;
|
|
38
38
|
};
|
|
39
39
|
|
|
40
|
+
export type EmotionalStateResponse = {
|
|
41
|
+
resourceId: string
|
|
42
|
+
summary: string
|
|
43
|
+
extractedAt: string
|
|
44
|
+
sessionId: string | null
|
|
45
|
+
relationshipId: string | null
|
|
46
|
+
status: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type EmotionalStateLatest = {
|
|
50
|
+
resourceId: string
|
|
51
|
+
summary: string
|
|
52
|
+
extractedAt: string
|
|
53
|
+
sessionId: string | null
|
|
54
|
+
relationshipId: string | null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const API_BASE_URL = "https://api.hyperspell.com"
|
|
58
|
+
|
|
40
59
|
export class HyperspellClient {
|
|
41
60
|
private client: Hyperspell;
|
|
42
61
|
private config: HyperspellConfig;
|
|
@@ -52,6 +71,17 @@ export class HyperspellClient {
|
|
|
52
71
|
);
|
|
53
72
|
}
|
|
54
73
|
|
|
74
|
+
private rawHeaders(): Record<string, string> {
|
|
75
|
+
const headers: Record<string, string> = {
|
|
76
|
+
"Content-Type": "application/json",
|
|
77
|
+
Authorization: `Bearer ${this.config.apiKey}`,
|
|
78
|
+
};
|
|
79
|
+
if (this.config.userId) {
|
|
80
|
+
headers["X-As-User"] = this.config.userId;
|
|
81
|
+
}
|
|
82
|
+
return headers;
|
|
83
|
+
}
|
|
84
|
+
|
|
55
85
|
async search(
|
|
56
86
|
query: string,
|
|
57
87
|
options?: { limit?: number; sources?: HyperspellSource[]; after?: string; before?: string },
|
|
@@ -325,4 +355,106 @@ export class HyperspellClient {
|
|
|
325
355
|
log.debugResponse("connections.list", { count: connections.length });
|
|
326
356
|
return connections;
|
|
327
357
|
}
|
|
358
|
+
|
|
359
|
+
// -- Emotional State (raw fetch -- not in public SDK) -----------------------
|
|
360
|
+
|
|
361
|
+
async storeEmotionalState(
|
|
362
|
+
conversation: string,
|
|
363
|
+
options?: {
|
|
364
|
+
sessionId?: string;
|
|
365
|
+
relationshipId?: string;
|
|
366
|
+
metadata?: Record<string, string | number | boolean>;
|
|
367
|
+
},
|
|
368
|
+
): Promise<EmotionalStateResponse> {
|
|
369
|
+
log.debugRequest("emotional-state.store", {
|
|
370
|
+
conversationLength: conversation.length,
|
|
371
|
+
relationshipId: options?.relationshipId,
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
const body: Record<string, unknown> = { conversation };
|
|
375
|
+
if (options?.sessionId) body.session_id = options.sessionId;
|
|
376
|
+
if (options?.relationshipId) body.relationship_id = options.relationshipId;
|
|
377
|
+
if (options?.metadata) body.metadata = options.metadata;
|
|
378
|
+
|
|
379
|
+
const res = await fetch(`${API_BASE_URL}/emotional-state`, {
|
|
380
|
+
method: "POST",
|
|
381
|
+
headers: this.rawHeaders(),
|
|
382
|
+
body: JSON.stringify(body),
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
if (!res.ok) {
|
|
386
|
+
const text = await res.text().catch(() => "");
|
|
387
|
+
throw new Error(`POST /emotional-state failed (${res.status}): ${text}`);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const data = await res.json();
|
|
391
|
+
const result: EmotionalStateResponse = {
|
|
392
|
+
resourceId: data.resource_id,
|
|
393
|
+
summary: data.summary,
|
|
394
|
+
extractedAt: data.extracted_at,
|
|
395
|
+
sessionId: data.session_id ?? null,
|
|
396
|
+
relationshipId: data.relationship_id ?? null,
|
|
397
|
+
status: data.status,
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
log.debugResponse("emotional-state.store", { resourceId: result.resourceId });
|
|
401
|
+
return result;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async getEmotionalState(relationshipId?: string): Promise<EmotionalStateLatest | null> {
|
|
405
|
+
log.debugRequest("emotional-state.get", { relationshipId });
|
|
406
|
+
|
|
407
|
+
const url = new URL(`${API_BASE_URL}/emotional-state`);
|
|
408
|
+
if (relationshipId) url.searchParams.set("relationship_id", relationshipId);
|
|
409
|
+
|
|
410
|
+
const res = await fetch(url.toString(), {
|
|
411
|
+
method: "GET",
|
|
412
|
+
headers: this.rawHeaders(),
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
if (!res.ok) {
|
|
416
|
+
const text = await res.text().catch(() => "");
|
|
417
|
+
throw new Error(`GET /emotional-state failed (${res.status}): ${text}`);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const data = await res.json();
|
|
421
|
+
if (data === null) {
|
|
422
|
+
log.debugResponse("emotional-state.get", { found: false });
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const result: EmotionalStateLatest = {
|
|
427
|
+
resourceId: data.resource_id,
|
|
428
|
+
summary: data.summary,
|
|
429
|
+
extractedAt: data.extracted_at,
|
|
430
|
+
sessionId: data.session_id ?? null,
|
|
431
|
+
relationshipId: data.relationship_id ?? null,
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
log.debugResponse("emotional-state.get", { found: true, resourceId: result.resourceId });
|
|
435
|
+
return result;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async deleteEmotionalState(relationshipId?: string): Promise<{ deletedCount: number }> {
|
|
439
|
+
log.debugRequest("emotional-state.delete", { relationshipId });
|
|
440
|
+
|
|
441
|
+
const url = new URL(`${API_BASE_URL}/emotional-state`);
|
|
442
|
+
if (relationshipId) url.searchParams.set("relationship_id", relationshipId);
|
|
443
|
+
|
|
444
|
+
const res = await fetch(url.toString(), {
|
|
445
|
+
method: "DELETE",
|
|
446
|
+
headers: this.rawHeaders(),
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
if (!res.ok) {
|
|
450
|
+
const text = await res.text().catch(() => "");
|
|
451
|
+
throw new Error(`DELETE /emotional-state failed (${res.status}): ${text}`);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const data = await res.json();
|
|
455
|
+
const result = { deletedCount: data.deleted_count };
|
|
456
|
+
|
|
457
|
+
log.debugResponse("emotional-state.delete", result);
|
|
458
|
+
return result;
|
|
459
|
+
}
|
|
328
460
|
}
|
package/commands/setup.ts
CHANGED
package/config.ts
CHANGED
|
@@ -30,6 +30,8 @@ export type HyperspellConfig = {
|
|
|
30
30
|
userId?: string;
|
|
31
31
|
autoContext: boolean;
|
|
32
32
|
autoTrace: AutoTraceConfig;
|
|
33
|
+
emotionalContext: boolean;
|
|
34
|
+
relationshipId?: string;
|
|
33
35
|
syncMemories: boolean;
|
|
34
36
|
sources: HyperspellSource[];
|
|
35
37
|
maxResults: number;
|
|
@@ -43,6 +45,8 @@ const ALLOWED_KEYS = [
|
|
|
43
45
|
"userId",
|
|
44
46
|
"autoContext",
|
|
45
47
|
"autoTrace",
|
|
48
|
+
"emotionalContext",
|
|
49
|
+
"relationshipId",
|
|
46
50
|
"syncMemories",
|
|
47
51
|
"sources",
|
|
48
52
|
"maxResults",
|
|
@@ -168,6 +172,8 @@ export function parseConfig(raw: unknown): HyperspellConfig {
|
|
|
168
172
|
| Record<string, string | number | boolean>
|
|
169
173
|
| undefined,
|
|
170
174
|
},
|
|
175
|
+
emotionalContext: (cfg.emotionalContext as boolean) ?? false,
|
|
176
|
+
relationshipId: cfg.relationshipId as string | undefined,
|
|
171
177
|
syncMemories: (cfg.syncMemories as boolean) ?? false,
|
|
172
178
|
sources: parseSources(cfg.sources as string | string[] | undefined),
|
|
173
179
|
maxResults: (cfg.maxResults as number) ?? 10,
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { HyperspellClient } from "../client.ts";
|
|
2
|
+
import type { HyperspellConfig } from "../config.ts";
|
|
3
|
+
import { log } from "../logger.ts";
|
|
4
|
+
|
|
5
|
+
type Message = { role?: string; content?: string | unknown };
|
|
6
|
+
|
|
7
|
+
const MIN_MESSAGES = 3;
|
|
8
|
+
const MIN_CONVERSATION_LENGTH = 100;
|
|
9
|
+
|
|
10
|
+
function messagesToTranscript(messages: unknown[]): string {
|
|
11
|
+
return (messages as Message[])
|
|
12
|
+
.filter((m) => m.role && m.content)
|
|
13
|
+
.map((m) => {
|
|
14
|
+
const content =
|
|
15
|
+
typeof m.content === "string" ? m.content : JSON.stringify(m.content);
|
|
16
|
+
return `${m.role}: ${content}`;
|
|
17
|
+
})
|
|
18
|
+
.join("\n");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Fetch emotional state at session start and inject into context.
|
|
23
|
+
* Runs on `before_agent_start`.
|
|
24
|
+
*/
|
|
25
|
+
export function buildEmotionalStateFetchHandler(
|
|
26
|
+
client: HyperspellClient,
|
|
27
|
+
cfg: HyperspellConfig,
|
|
28
|
+
) {
|
|
29
|
+
return async (_event: Record<string, unknown>) => {
|
|
30
|
+
try {
|
|
31
|
+
const state = await client.getEmotionalState(cfg.relationshipId);
|
|
32
|
+
|
|
33
|
+
if (!state) {
|
|
34
|
+
log.debug("emotional-context: no prior emotional state found");
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
log.debug(`emotional-context: injecting state from ${state.extractedAt}`);
|
|
39
|
+
|
|
40
|
+
const context = [
|
|
41
|
+
"<hyperspell-emotional-context>",
|
|
42
|
+
"The following captures the emotional register of your relationship with this user from your last interaction. Let it inform your tone — don't reference it explicitly.",
|
|
43
|
+
"",
|
|
44
|
+
state.summary,
|
|
45
|
+
"</hyperspell-emotional-context>",
|
|
46
|
+
].join("\n");
|
|
47
|
+
|
|
48
|
+
return { prependContext: context };
|
|
49
|
+
} catch (err) {
|
|
50
|
+
log.error("emotional-context fetch failed", err);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Extract and store emotional state at session end.
|
|
58
|
+
* Runs on `agent_end` — fire-and-forget.
|
|
59
|
+
*/
|
|
60
|
+
export function buildEmotionalStateStoreHandler(
|
|
61
|
+
client: HyperspellClient,
|
|
62
|
+
cfg: HyperspellConfig,
|
|
63
|
+
) {
|
|
64
|
+
return async (event: Record<string, unknown>) => {
|
|
65
|
+
if (event.success === false) {
|
|
66
|
+
log.debug("emotional-state: skipping — agent ended with error");
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const messages = event.messages as unknown[] | undefined;
|
|
71
|
+
if (!messages || messages.length < MIN_MESSAGES) {
|
|
72
|
+
log.debug(
|
|
73
|
+
`emotional-state: skipping — too few messages (${messages?.length ?? 0})`,
|
|
74
|
+
);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const transcript = messagesToTranscript(messages);
|
|
79
|
+
if (transcript.length < MIN_CONVERSATION_LENGTH) {
|
|
80
|
+
log.debug(
|
|
81
|
+
`emotional-state: skipping — conversation too short (${transcript.length} chars)`,
|
|
82
|
+
);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const result = await client.storeEmotionalState(transcript, {
|
|
88
|
+
relationshipId: cfg.relationshipId,
|
|
89
|
+
metadata: { source: "openclaw_agent_end" },
|
|
90
|
+
});
|
|
91
|
+
log.info(`emotional-state: stored ${result.resourceId}`);
|
|
92
|
+
} catch (err) {
|
|
93
|
+
// Fire-and-forget — never let this break the session
|
|
94
|
+
log.error("emotional-state store failed", err);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
}
|
package/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ 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 { buildEmotionalStateFetchHandler, buildEmotionalStateStoreHandler } from "./hooks/emotional-state.ts"
|
|
8
9
|
import { buildFileSyncHandler, syncMemoriesOnStartup } from "./hooks/memory-sync.ts"
|
|
9
10
|
import { initLogger } from "./logger.ts"
|
|
10
11
|
import { registerRememberTool } from "./tools/remember.ts"
|
|
@@ -83,6 +84,12 @@ export default {
|
|
|
83
84
|
registerSearchTool(api, client, cfg);
|
|
84
85
|
registerRememberTool(api, client, cfg);
|
|
85
86
|
|
|
87
|
+
// Register emotional context hooks (fetch on start, store on end)
|
|
88
|
+
if (cfg.emotionalContext) {
|
|
89
|
+
api.on("before_agent_start", buildEmotionalStateFetchHandler(client, cfg));
|
|
90
|
+
api.on("agent_end", buildEmotionalStateStoreHandler(client, cfg));
|
|
91
|
+
}
|
|
92
|
+
|
|
86
93
|
// Register auto-context hook
|
|
87
94
|
if (cfg.autoContext) {
|
|
88
95
|
const autoContextHandler = buildAutoContextHandler(client, cfg);
|
package/openclaw.plugin.json
CHANGED
|
@@ -27,6 +27,17 @@
|
|
|
27
27
|
"help": "Automatically send conversation traces to Hyperspell for memory extraction (procedural, mood) at the end of each session",
|
|
28
28
|
"advanced": true
|
|
29
29
|
},
|
|
30
|
+
"emotionalContext": {
|
|
31
|
+
"label": "Emotional Context",
|
|
32
|
+
"help": "Maintain emotional continuity across sessions — remembers not just what happened, but how it felt",
|
|
33
|
+
"advanced": true
|
|
34
|
+
},
|
|
35
|
+
"relationshipId": {
|
|
36
|
+
"label": "Relationship ID",
|
|
37
|
+
"placeholder": "partner-anna",
|
|
38
|
+
"help": "Optional identifier for per-relationship emotional state (e.g. different registers for different people)",
|
|
39
|
+
"advanced": true
|
|
40
|
+
},
|
|
30
41
|
"sources": {
|
|
31
42
|
"label": "Sources",
|
|
32
43
|
"placeholder": "notion,slack,google_drive",
|
|
@@ -82,6 +93,12 @@
|
|
|
82
93
|
"metadata": { "type": "object" }
|
|
83
94
|
}
|
|
84
95
|
},
|
|
96
|
+
"emotionalContext": {
|
|
97
|
+
"type": "boolean"
|
|
98
|
+
},
|
|
99
|
+
"relationshipId": {
|
|
100
|
+
"type": "string"
|
|
101
|
+
},
|
|
85
102
|
"sources": {
|
|
86
103
|
"type": "string"
|
|
87
104
|
},
|