@hyperspell/openclaw-hyperspell 0.13.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.
@@ -281,7 +281,6 @@ async function runSetup() {
281
281
  recentDays: 7,
282
282
  recentLimit: 5,
283
283
  loopsLimit: 3,
284
- recentQuery: "conversation session interaction",
285
284
  loopsQuery: "open tasks pending questions unfinished promised need to follow up",
286
285
  },
287
286
  });
package/dist/config.js CHANGED
@@ -25,6 +25,7 @@ const ALLOWED_KEYS = [
25
25
  "debug",
26
26
  "knowledgeGraph",
27
27
  "multiUser",
28
+ "dreaming",
28
29
  ];
29
30
  const VALID_SOURCES = [
30
31
  "reddit",
@@ -252,7 +253,6 @@ export function parseConfig(raw) {
252
253
  recentDays: soRaw.recentDays ?? 7,
253
254
  recentLimit: soRaw.recentLimit ?? 5,
254
255
  loopsLimit: soRaw.loopsLimit ?? 3,
255
- recentQuery: soRaw.recentQuery ?? "conversation session interaction",
256
256
  loopsQuery: soRaw.loopsQuery ??
257
257
  "open tasks pending questions unfinished promised need to follow up",
258
258
  },
@@ -13,6 +13,8 @@ export function sanitizeTraceText(input) {
13
13
  let out = input;
14
14
  out = out.replace(/<hyperspell-context>[\s\S]*?<\/hyperspell-context>\n?/g, "");
15
15
  out = out.replace(/<hyperspell-emotional-context>[\s\S]*?<\/hyperspell-emotional-context>\n?/g, "");
16
+ out = out.replace(/<hyperspell-recent-interactions>[\s\S]*?<\/hyperspell-recent-interactions>\n?/g, "");
17
+ out = out.replace(/<hyperspell-unfinished-loops>[\s\S]*?<\/hyperspell-unfinished-loops>\n?/g, "");
16
18
  out = out.replace(/Sender \(untrusted metadata\):\s*```json[\s\S]*?```\n?/g, "");
17
19
  out = out.replace(/\[Bootstrap pending\][\s\S]*?(?=\n{2,}|\nSystem:|\nSender|$)/g, "");
18
20
  out = out.replace(/\[Startup context loaded by runtime\][\s\S]*?(?:\n\n|$)/g, "");
@@ -1,15 +1,23 @@
1
1
  import { resolveUser } from "../lib/sender.js";
2
2
  import { log } from "../logger.js";
3
- const RECENT_FILTER = { openclaw_source: "agent_end" };
3
+ const MAX_ATTEMPTS = 2;
4
+ const RECENT_BUFFER_LIMIT = 100;
4
5
  /**
5
- * Track which sessions already received the orientation injection. The block is
6
- * expensive (two search calls + a few hundred tokens of wrapper) and its
7
- * contents don't change within a session, so inject-once is the right shape.
8
- * Lifecycle mirrors the emotional-context hook: first turn injects, later turns
9
- * skip, `after_compaction` clears (injection may have been trimmed), and
10
- * `session_end` deletes to keep the Set bounded.
6
+ * Sessions where the orientation block was already injected (or where we
7
+ * deliberately decided not to inject unknown sender, exhausted retries).
8
+ * Re-checked at the top of every turn; on hit, we skip.
9
+ *
10
+ * Lifecycle mirrors the emotional-context hook: `after_compaction` clears
11
+ * (the original injection may have been trimmed out of history) and
12
+ * `session_end` cleans up to keep both structures bounded.
11
13
  */
12
14
  const injectedSessions = new Set();
15
+ /**
16
+ * Counts attempts for sessions where every call has failed so far. We only
17
+ * count failures here, so a session that succeeds on retry will never appear.
18
+ * Capped at MAX_ATTEMPTS — past that we give up and add to injectedSessions.
19
+ */
20
+ const failedAttempts = new Map();
13
21
  function formatRelativeTime(iso) {
14
22
  if (!iso)
15
23
  return "";
@@ -36,7 +44,7 @@ function formatRecentInteractions(results) {
36
44
  return null;
37
45
  const lines = results.map((r) => {
38
46
  const when = formatRelativeTime(r.createdAt);
39
- const title = r.title ?? `[${r.source}]`;
47
+ const title = r.title || `[${r.source}]`;
40
48
  const prefix = when ? `[${when}] ` : "";
41
49
  const top = r.highlights[0]?.text?.replace(/\n/g, " ").slice(0, 140);
42
50
  const tail = top ? ` — ${top}` : "";
@@ -50,7 +58,7 @@ function formatUnfinishedLoops(results) {
50
58
  const top = r.highlights[0];
51
59
  if (!top)
52
60
  continue;
53
- const title = r.title ?? `[${r.source}]`;
61
+ const title = r.title || `[${r.source}]`;
54
62
  bullets.push(`- ${title}: ${top.text.replace(/\n/g, " ")}`);
55
63
  }
56
64
  return bullets.length > 0 ? bullets.join("\n") : null;
@@ -58,7 +66,7 @@ function formatUnfinishedLoops(results) {
58
66
  function isoDaysAgo(days) {
59
67
  const d = new Date();
60
68
  d.setUTCDate(d.getUTCDate() - days);
61
- return d.toISOString();
69
+ return d;
62
70
  }
63
71
  /**
64
72
  * Resolve the userId to use for personal searches. In multi-user mode, skip
@@ -74,12 +82,73 @@ function personalUserId(cfg, ctx) {
74
82
  return { skip: true };
75
83
  return { skip: false, userId: resolved.userId };
76
84
  }
85
+ /**
86
+ * Pull recent agent_end traces via listMemories, sorted chronologically.
87
+ *
88
+ * Why list, not search: a date-window + relevance search ranks results by
89
+ * lexical similarity to a generic query, which is approximately random
90
+ * within a 7-day slice and can easily exclude yesterday in favor of a
91
+ * 5-day-old session. listMemories gives us true chronological recall.
92
+ *
93
+ * The SDK's list endpoint doesn't expose date or metadata filters in our
94
+ * wrapper, so we filter client-side. Buffer is capped at
95
+ * RECENT_BUFFER_LIMIT to bound wire cost; if a user has more than that
96
+ * many traces in the cutoff window we'll still get the newest ones,
97
+ * since the underlying API returns recency-ordered pages.
98
+ */
99
+ async function fetchRecentTraces(client, cutoff, limit, userId) {
100
+ const buffer = [];
101
+ let scanned = 0;
102
+ const cutoffMs = cutoff.getTime();
103
+ for await (const memory of client.listMemories({
104
+ source: "trace",
105
+ userId,
106
+ pageSize: 50,
107
+ })) {
108
+ scanned++;
109
+ if (scanned > RECENT_BUFFER_LIMIT)
110
+ break;
111
+ const meta = memory.metadata;
112
+ if (meta.openclaw_source !== "agent_end")
113
+ continue;
114
+ const createdRaw = meta.created_at;
115
+ if (typeof createdRaw !== "string")
116
+ continue;
117
+ const createdMs = new Date(createdRaw).getTime();
118
+ if (Number.isNaN(createdMs) || createdMs < cutoffMs)
119
+ continue;
120
+ buffer.push({
121
+ resourceId: memory.resourceId,
122
+ title: memory.title,
123
+ source: memory.source,
124
+ score: null,
125
+ url: null,
126
+ createdAt: createdRaw,
127
+ highlights: [],
128
+ });
129
+ }
130
+ buffer.sort((a, b) => {
131
+ const at = a.createdAt ? new Date(a.createdAt).getTime() : 0;
132
+ const bt = b.createdAt ? new Date(b.createdAt).getTime() : 0;
133
+ return bt - at;
134
+ });
135
+ return buffer.slice(0, limit);
136
+ }
77
137
  export function buildStartupOrientationHandler(client, cfg) {
78
138
  const so = cfg.startupOrientation;
79
139
  return async (_event, ctx) => {
80
140
  const sessionKey = ctx?.sessionKey;
81
141
  if (sessionKey && injectedSessions.has(sessionKey))
82
142
  return;
143
+ const tries = (sessionKey && failedAttempts.get(sessionKey)) || 0;
144
+ if (tries >= MAX_ATTEMPTS) {
145
+ log.debug(`startup-orientation: giving up after ${tries} failed attempt(s)`);
146
+ if (sessionKey) {
147
+ injectedSessions.add(sessionKey);
148
+ failedAttempts.delete(sessionKey);
149
+ }
150
+ return;
151
+ }
83
152
  const { skip, userId } = personalUserId(cfg, ctx);
84
153
  if (skip) {
85
154
  log.debug("startup-orientation: skipping — unknown sender in multi-user mode");
@@ -87,31 +156,36 @@ export function buildStartupOrientationHandler(client, cfg) {
87
156
  injectedSessions.add(sessionKey);
88
157
  return;
89
158
  }
90
- const after = isoDaysAgo(so.recentDays);
159
+ const cutoff = isoDaysAgo(so.recentDays);
91
160
  const [recentSettled, loopsSettled] = await Promise.allSettled([
92
- client.search(so.recentQuery, {
93
- limit: so.recentLimit,
94
- after,
95
- filter: RECENT_FILTER,
96
- userId,
97
- }),
161
+ fetchRecentTraces(client, cutoff, so.recentLimit, userId),
98
162
  client.search(so.loopsQuery, {
99
163
  limit: so.loopsLimit,
100
164
  userId,
101
165
  }),
102
166
  ]);
103
- const recent = recentSettled.status === "fulfilled" ? recentSettled.value : [];
104
- if (recentSettled.status === "rejected") {
105
- log.error("startup-orientation: recent search failed", recentSettled.reason);
167
+ const recentOk = recentSettled.status === "fulfilled";
168
+ const loopsOk = loopsSettled.status === "fulfilled";
169
+ const recent = recentOk ? recentSettled.value : [];
170
+ const loops = loopsOk ? loopsSettled.value : [];
171
+ if (!recentOk) {
172
+ log.error("startup-orientation: recent listMemories failed", recentSettled.reason);
106
173
  }
107
- const loops = loopsSettled.status === "fulfilled" ? loopsSettled.value : [];
108
- if (loopsSettled.status === "rejected") {
174
+ if (!loopsOk) {
109
175
  log.error("startup-orientation: loops search failed", loopsSettled.reason);
110
176
  }
177
+ if (!recentOk && !loopsOk) {
178
+ if (sessionKey)
179
+ failedAttempts.set(sessionKey, tries + 1);
180
+ log.debug(`startup-orientation: both calls failed (attempt ${tries + 1}/${MAX_ATTEMPTS}); will retry next turn`);
181
+ return;
182
+ }
183
+ if (sessionKey) {
184
+ injectedSessions.add(sessionKey);
185
+ failedAttempts.delete(sessionKey);
186
+ }
111
187
  const recentBody = formatRecentInteractions(recent);
112
188
  const loopsBody = formatUnfinishedLoops(loops);
113
- if (sessionKey)
114
- injectedSessions.add(sessionKey);
115
189
  if (!recentBody && !loopsBody) {
116
190
  log.debug("startup-orientation: nothing to inject");
117
191
  return;
@@ -120,7 +194,7 @@ export function buildStartupOrientationHandler(client, cfg) {
120
194
  if (recentBody) {
121
195
  blocks.push([
122
196
  "<hyperspell-recent-interactions>",
123
- `Your last ${so.recentDays} days of conversations with this user, most-relevant-first. Use for situational continuity — don't quote verbatim.`,
197
+ `Your last ${so.recentDays} days of conversations with this user, most-recent-first. Use for situational continuity — don't quote verbatim.`,
124
198
  "",
125
199
  recentBody,
126
200
  "</hyperspell-recent-interactions>",
@@ -142,7 +216,11 @@ export function buildStartupOrientationHandler(client, cfg) {
142
216
  export function buildStartupOrientationCompactionHandler() {
143
217
  return async (_event, ctx) => {
144
218
  const sessionKey = ctx?.sessionKey;
145
- if (sessionKey && injectedSessions.delete(sessionKey)) {
219
+ if (!sessionKey)
220
+ return;
221
+ const dropped = injectedSessions.delete(sessionKey);
222
+ failedAttempts.delete(sessionKey);
223
+ if (dropped) {
146
224
  log.debug(`startup-orientation: cache cleared after compaction (session=${sessionKey})`);
147
225
  }
148
226
  };
@@ -150,7 +228,9 @@ export function buildStartupOrientationCompactionHandler() {
150
228
  export function buildStartupOrientationSessionCleanupHandler() {
151
229
  return async (_event, ctx) => {
152
230
  const sessionKey = ctx?.sessionKey;
153
- if (sessionKey)
154
- injectedSessions.delete(sessionKey);
231
+ if (!sessionKey)
232
+ return;
233
+ injectedSessions.delete(sessionKey);
234
+ failedAttempts.delete(sessionKey);
155
235
  };
156
236
  }
@@ -41,6 +41,11 @@
41
41
  "help": "Maintain emotional continuity across sessions — remembers not just what happened, but how it felt",
42
42
  "advanced": true
43
43
  },
44
+ "startupOrientation": {
45
+ "label": "Startup Orientation",
46
+ "help": "Inject recent-interactions and unfinished-loops blocks once per session at startup. Costs two extra calls + ~500–800 tokens of injection per session; off by default.",
47
+ "advanced": true
48
+ },
44
49
  "relationshipId": {
45
50
  "label": "Relationship ID",
46
51
  "placeholder": "partner-anna",
@@ -117,6 +122,16 @@
117
122
  "relationshipId": {
118
123
  "type": "string"
119
124
  },
125
+ "startupOrientation": {
126
+ "type": "object",
127
+ "properties": {
128
+ "enabled": { "type": "boolean" },
129
+ "recentDays": { "type": "number", "minimum": 1, "maximum": 90 },
130
+ "recentLimit": { "type": "number", "minimum": 1, "maximum": 20 },
131
+ "loopsLimit": { "type": "number", "minimum": 1, "maximum": 20 },
132
+ "loopsQuery": { "type": "string" }
133
+ }
134
+ },
120
135
  "sources": {
121
136
  "type": "string"
122
137
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperspell/openclaw-hyperspell",
3
- "version": "0.13.0",
3
+ "version": "0.13.1",
4
4
  "type": "module",
5
5
  "description": "OpenClaw Hyperspell memory plugin",
6
6
  "license": "MIT",