@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 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
@@ -333,6 +333,7 @@ async function runSetup(): Promise<void> {
333
333
  userId,
334
334
  autoContext: true,
335
335
  autoTrace: { enabled: false, extract: ["procedure"] },
336
+ emotionalContext: false,
336
337
  syncMemories: true,
337
338
  sources: [],
338
339
  maxResults: 10,
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);
@@ -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
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperspell/openclaw-hyperspell",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "description": "OpenClaw Hyperspell memory plugin",
6
6
  "license": "MIT",