@hyperspell/openclaw-hyperspell 0.15.0 → 0.16.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/dist/client.js CHANGED
@@ -294,12 +294,17 @@ export class HyperspellClient {
294
294
  if (messages.length === 0)
295
295
  return { count: 0 };
296
296
  const source = (options?.source ?? "vault").toLowerCase();
297
+ const metadata = options?.metadata;
297
298
  const body = {
298
299
  source,
299
300
  messages: messages.map((m) => ({
300
301
  resource_id: m.resourceId,
301
302
  message_id: m.messageId,
302
303
  content: m.content,
304
+ // Per-message metadata (Hyperspell #1921): tags hot-buffer rows so
305
+ // retrieval can identify/filter them like /memories/add rows. Persists
306
+ // on the live hot row and is unioned onto the consolidated Resource.
307
+ ...(metadata ? { metadata } : {}),
303
308
  })),
304
309
  };
305
310
  log.debugRequest("messages.create", {
@@ -395,6 +400,43 @@ export class HyperspellClient {
395
400
  log.debugResponse("emotional-state.get", { found: true, resourceId: result.resourceId });
396
401
  return result;
397
402
  }
403
+ /**
404
+ * The most recent emotional states (newest first), up to `limit` — for
405
+ * surfacing the recent ARC of how a relationship has felt, not just one
406
+ * snapshot. Returns `null` when the backend doesn't expose
407
+ * `/emotional-state/recent` yet (404) so callers can fall back to
408
+ * `getEmotionalState`. Other non-OK responses throw.
409
+ */
410
+ async getRecentEmotionalStates(relationshipId, limit = 3) {
411
+ log.debugRequest("emotional-state.recent", { relationshipId, limit });
412
+ const url = new URL(`${API_BASE_URL}/emotional-state/recent`);
413
+ if (relationshipId)
414
+ url.searchParams.set("relationship_id", relationshipId);
415
+ url.searchParams.set("limit", String(limit));
416
+ const res = await fetch(url.toString(), {
417
+ method: "GET",
418
+ headers: this.rawHeaders(),
419
+ });
420
+ if (res.status === 404) {
421
+ // Endpoint not deployed yet — signal the caller to fall back.
422
+ log.debug("emotional-state.recent unavailable (404) — caller falls back to latest");
423
+ return null;
424
+ }
425
+ if (!res.ok) {
426
+ const text = await res.text().catch(() => "");
427
+ throw new Error(`GET /emotional-state/recent failed (${res.status}): ${text}`);
428
+ }
429
+ const data = (await res.json());
430
+ const list = (data ?? []).map((d) => ({
431
+ resourceId: d.resource_id,
432
+ summary: d.summary ?? "",
433
+ extractedAt: d.extracted_at ?? "",
434
+ sessionId: d.session_id ?? null,
435
+ relationshipId: d.relationship_id ?? null,
436
+ }));
437
+ log.debugResponse("emotional-state.recent", { count: list.length });
438
+ return list;
439
+ }
398
440
  async deleteEmotionalState(relationshipId) {
399
441
  log.debugRequest("emotional-state.delete", { relationshipId });
400
442
  const url = new URL(`${API_BASE_URL}/emotional-state`);
@@ -1,5 +1,7 @@
1
1
  import { buildScopeFilter, getCanReadScopes, resolveUser, } from "../lib/sender.js";
2
2
  import { excludeFilterFor, mergeWithExclude } from "../lib/filters.js";
3
+ import { classifySearchError, logSearchError } from "../lib/search-error.js";
4
+ import { resolveCurrentSessionId } from "../lib/session.js";
3
5
  import { log } from "../logger.js";
4
6
  function formatRelativeTime(isoTimestamp) {
5
7
  try {
@@ -54,23 +56,47 @@ const DISCLAIMER = "Draw on it when relevant — including indirect connections
54
56
  function wrapSingle(body) {
55
57
  return `<hyperspell-context>\n${INTRO}\n\n${body}\n\n${DISCLAIMER}\n</hyperspell-context>`;
56
58
  }
59
+ /**
60
+ * Drop results belonging to the CURRENT session. The hot buffer writes the live
61
+ * conversation to the vault every turn (`resourceId = sessionId`), and those
62
+ * rows are the highest-scoring matches for whatever the agent is currently
63
+ * saying — so without this the agent gets its own just-written turns injected
64
+ * back as "recalled memory," crowding out older context (issue #42).
65
+ *
66
+ * Purely subtractive: with no resolvable session id we return results unchanged,
67
+ * and a wrong id can only fail to hide the echo — never hide genuine
68
+ * cross-session memories, which live under a different `resource_id`.
69
+ */
70
+ export function dropCurrentSession(results, currentSessionId) {
71
+ if (!currentSessionId)
72
+ return results;
73
+ const kept = results.filter((r) => r.resourceId !== currentSessionId);
74
+ const dropped = results.length - kept.length;
75
+ if (dropped > 0) {
76
+ log.debug(`auto-context: excluded ${dropped} result(s) from current session ${currentSessionId}`);
77
+ }
78
+ return kept;
79
+ }
57
80
  export function buildAutoContextHandler(client, cfg) {
58
81
  return async (event, ctx) => {
59
82
  const prompt = event.prompt;
60
83
  if (!prompt || prompt.length < 5)
61
84
  return;
85
+ // The live session's own just-written turns must not be surfaced back as
86
+ // "recalled memory" (issue #42) — resolve its id once and exclude it below.
87
+ const currentSessionId = resolveCurrentSessionId(event, ctx);
62
88
  // Multi-user path
63
89
  if (cfg.multiUser) {
64
90
  const resolved = resolveUser(ctx, cfg);
65
- return multiUserSearch(client, cfg, prompt, resolved);
91
+ return multiUserSearch(client, cfg, prompt, resolved, currentSessionId);
66
92
  }
67
93
  // Single-user path — preserves main's highlights + threshold behavior
68
94
  log.debug(`auto-context: searching for "${prompt.slice(0, 50)}..."`);
69
95
  try {
70
- const results = await client.search(prompt, {
96
+ const results = dropCurrentSession(await client.search(prompt, {
71
97
  limit: cfg.maxResults,
72
98
  filter: excludeFilterFor(cfg),
73
- });
99
+ }), currentSessionId);
74
100
  const formatted = formatHighlightBullets(results, cfg.maxResults, cfg.relevanceThreshold);
75
101
  if (!formatted) {
76
102
  log.debug("auto-context: no relevant memories found");
@@ -80,12 +106,16 @@ export function buildAutoContextHandler(client, cfg) {
80
106
  return { prependContext: wrapSingle(formatted) };
81
107
  }
82
108
  catch (err) {
83
- log.error("auto-context failed", err);
109
+ // A transient backend throttle (429 / Retry-After) must not be swallowed
110
+ // as a generic failure — log it at warn, distinguished from real errors,
111
+ // so the degraded auto-context is observable (issue #39). The hook stays
112
+ // silent (no injection) either way; the agent didn't explicitly ask.
113
+ logSearchError(log, "auto-context", classifySearchError(err), err);
84
114
  return;
85
115
  }
86
116
  };
87
117
  }
88
- async function multiUserSearch(client, cfg, prompt, resolved) {
118
+ async function multiUserSearch(client, cfg, prompt, resolved, currentSessionId) {
89
119
  const multiUser = cfg.multiUser;
90
120
  const isKnownSender = !!resolved?.resolved;
91
121
  const includeShared = multiUser.includeSharedInSearch;
@@ -126,19 +156,19 @@ async function multiUserSearch(client, cfg, prompt, resolved) {
126
156
  if (personalSearch) {
127
157
  const r = settled[idx++];
128
158
  if (r.status === "fulfilled") {
129
- personalResults = r.value;
159
+ personalResults = dropCurrentSession(r.value, currentSessionId);
130
160
  }
131
161
  else {
132
- log.error("auto-context: personal search failed", r.reason);
162
+ logSearchError(log, "auto-context: personal search", classifySearchError(r.reason), r.reason);
133
163
  }
134
164
  }
135
165
  if (sharedSearch) {
136
166
  const r = settled[idx++];
137
167
  if (r.status === "fulfilled") {
138
- sharedResults = r.value;
168
+ sharedResults = dropCurrentSession(r.value, currentSessionId);
139
169
  }
140
170
  else {
141
- log.error("auto-context: shared search failed", r.reason);
171
+ logSearchError(log, "auto-context: shared search", classifySearchError(r.reason), r.reason);
142
172
  }
143
173
  }
144
174
  const sections = [];
@@ -1,7 +1,26 @@
1
1
  import { log } from "../logger.js";
2
2
  import { sanitizeTraceText } from "./auto-trace.js";
3
+ /** How many recent registers to surface as the "arc" at session start. */
4
+ const EMOTIONAL_ARC_LIMIT = 3;
3
5
  const MIN_MESSAGES = 3;
4
6
  const MIN_CONVERSATION_LENGTH = 100;
7
+ /**
8
+ * Only REAL human conversations should shape her emotional register. Automated
9
+ * runs — cron check-ins, heartbeats, internal memory passes — are not "how the
10
+ * relationship feels"; counting them lets a throwaway "how's your afternoon?"
11
+ * heartbeat overwrite the register from a deep conversation (the whipsaw).
12
+ * `ctx.trigger` is one of cron|heartbeat|manual|memory|overflow|user; we store
13
+ * only for user-driven turns (and `overflow`, a continuation of a user run).
14
+ */
15
+ const NON_CONVERSATIONAL_TRIGGERS = new Set(["cron", "heartbeat", "memory"]);
16
+ /**
17
+ * Debounce window: don't re-extract the register on every turn of an active
18
+ * conversation (each store is a backend LLM call, and the latest already carries
19
+ * the full transcript). One snapshot per ~few minutes of real talk is plenty.
20
+ */
21
+ const STORE_DEBOUNCE_MS = 3 * 60 * 1000;
22
+ /** relationshipId → last successful store time (ms). Module-scoped, per process. */
23
+ const lastStoreAt = new Map();
5
24
  /**
6
25
  * Sessions where emotional context has already been injected this run.
7
26
  * Emotional state doesn't change within a session (it's extracted at
@@ -58,6 +77,75 @@ function messagesToTranscript(messages) {
58
77
  }
59
78
  return lines.join("\n");
60
79
  }
80
+ /**
81
+ * True if a fetched emotional-state `summary` is actually the raw transcript
82
+ * placeholder returned during the async extraction window (status=pending),
83
+ * rather than a distilled emotional register. Real summaries are second-person
84
+ * prose ("Your relationship with this user…"); the placeholder echoes the input
85
+ * conversation, which has role-prefixed lines.
86
+ */
87
+ export function looksLikeRawTranscript(summary) {
88
+ return /(^|\n)\s*(user|assistant)\s*:/i.test(summary);
89
+ }
90
+ /** Compact relative time for the arc labels (e.g. "just now", "3h ago", "2d ago"). */
91
+ function relativeWhen(iso) {
92
+ if (!iso)
93
+ return "";
94
+ try {
95
+ const mins = (Date.now() - new Date(iso).getTime()) / 60000;
96
+ if (Number.isNaN(mins))
97
+ return "";
98
+ if (mins < 2)
99
+ return "just now";
100
+ if (mins < 60)
101
+ return `${Math.floor(mins)}m ago`;
102
+ const hrs = mins / 60;
103
+ if (hrs < 24)
104
+ return `${Math.floor(hrs)}h ago`;
105
+ const days = hrs / 24;
106
+ if (days < 7)
107
+ return `${Math.floor(days)}d ago`;
108
+ return new Date(iso).toLocaleDateString("en", { month: "short", day: "numeric" });
109
+ }
110
+ catch {
111
+ return "";
112
+ }
113
+ }
114
+ /**
115
+ * Prefer the recent ARC (last N registers) so a single shallow read can't
116
+ * misrepresent the relationship. Falls back to the single latest when the
117
+ * backend doesn't expose `/emotional-state/recent` yet (returns null) or errors
118
+ * — so this works before AND after that endpoint deploys.
119
+ */
120
+ async function fetchRecentOrLatest(client, cfg) {
121
+ try {
122
+ const recent = await client.getRecentEmotionalStates(cfg.relationshipId, EMOTIONAL_ARC_LIMIT);
123
+ if (recent !== null)
124
+ return recent; // endpoint available (may be empty)
125
+ }
126
+ catch (err) {
127
+ log.debug("emotional-context: /recent unavailable — falling back to latest", err);
128
+ }
129
+ const single = await client.getEmotionalState(cfg.relationshipId);
130
+ return single ? [single] : [];
131
+ }
132
+ /** Build the injected emotional-context block from one or more registers. */
133
+ function buildEmotionalContext(states) {
134
+ const intro = states.length > 1
135
+ ? "How your relationship with this user has felt across your recent conversations, most recent first. Let the trajectory inform your tone — don't reference it explicitly."
136
+ : "The emotional register of your relationship with this user from your last interaction. Let it inform your tone — don't reference it explicitly.";
137
+ const lines = states.map((s) => {
138
+ const when = relativeWhen(s.extractedAt);
139
+ return when ? `- [${when}] ${s.summary}` : `- ${s.summary}`;
140
+ });
141
+ return [
142
+ "<hyperspell-emotional-context>",
143
+ intro,
144
+ "",
145
+ ...lines,
146
+ "</hyperspell-emotional-context>",
147
+ ].join("\n");
148
+ }
61
149
  /**
62
150
  * Fetch emotional state on the first agent turn of a session and inject into
63
151
  * context. On later turns of the same session, return undefined — the
@@ -72,21 +160,25 @@ export function buildEmotionalStateFetchHandler(client, cfg) {
72
160
  return;
73
161
  }
74
162
  try {
75
- const state = await client.getEmotionalState(cfg.relationshipId);
76
- if (!state) {
163
+ const states = await fetchRecentOrLatest(client, cfg);
164
+ // Drop raw-transcript placeholders: extraction is async, so for ~10s
165
+ // after a store the register can be the RAW input transcript, not the
166
+ // distilled feeling. Injecting that is useless and pollutes tone.
167
+ const usable = states.filter((s) => s.summary && !looksLikeRawTranscript(s.summary));
168
+ if (usable.length === 0) {
169
+ if (states.length > 0) {
170
+ // State(s) exist but are all still extracting — don't cache, so a
171
+ // later turn re-fetches once extraction completes.
172
+ log.debug("emotional-context: state(s) still extracting — skipping injection this turn");
173
+ return;
174
+ }
77
175
  log.debug("emotional-context: no prior emotional state found");
78
176
  if (sessionKey)
79
177
  injectedSessions.add(sessionKey);
80
178
  return;
81
179
  }
82
- log.debug(`emotional-context: injecting state from ${state.extractedAt}`);
83
- const context = [
84
- "<hyperspell-emotional-context>",
85
- "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.",
86
- "",
87
- state.summary,
88
- "</hyperspell-emotional-context>",
89
- ].join("\n");
180
+ log.debug(`emotional-context: injecting ${usable.length} recent register(s)`);
181
+ const context = buildEmotionalContext(usable);
90
182
  if (sessionKey)
91
183
  injectedSessions.add(sessionKey);
92
184
  return { prependContext: context };
@@ -125,11 +217,18 @@ export function buildEmotionalStateSessionCleanupHandler() {
125
217
  * Runs on `agent_end` — fire-and-forget.
126
218
  */
127
219
  export function buildEmotionalStateStoreHandler(client, cfg) {
128
- return async (event) => {
220
+ return async (event, ctx) => {
129
221
  if (event.success === false) {
130
222
  log.debug("emotional-state: skipping — agent ended with error");
131
223
  return;
132
224
  }
225
+ // Only real human conversations count — skip cron/heartbeat/memory runs so
226
+ // an automated check-in can't overwrite the register from a real talk.
227
+ const trigger = ctx?.trigger;
228
+ if (trigger && NON_CONVERSATIONAL_TRIGGERS.has(trigger)) {
229
+ log.debug(`emotional-state: skipping — non-conversational trigger (${trigger})`);
230
+ return;
231
+ }
133
232
  const messages = event.messages;
134
233
  if (!messages || messages.length < MIN_MESSAGES) {
135
234
  log.debug(`emotional-state: skipping — too few messages (${messages?.length ?? 0})`);
@@ -140,11 +239,19 @@ export function buildEmotionalStateStoreHandler(client, cfg) {
140
239
  log.debug(`emotional-state: skipping — conversation too short (${transcript.length} chars)`);
141
240
  return;
142
241
  }
242
+ // Debounce: at most one snapshot per STORE_DEBOUNCE_MS of active talk.
243
+ const relId = cfg.relationshipId ?? "";
244
+ const since = Date.now() - (lastStoreAt.get(relId) ?? 0);
245
+ if (since < STORE_DEBOUNCE_MS) {
246
+ log.debug(`emotional-state: skipping — debounced (${Math.round(since / 1000)}s since last store)`);
247
+ return;
248
+ }
143
249
  try {
144
250
  const result = await client.storeEmotionalState(transcript, {
145
251
  relationshipId: cfg.relationshipId,
146
252
  metadata: { source: "openclaw_agent_end" },
147
253
  });
254
+ lastStoreAt.set(relId, Date.now());
148
255
  log.info(`emotional-state: stored ${result.resourceId}`);
149
256
  }
150
257
  catch (err) {
Binary file
@@ -2,6 +2,8 @@ import { resolveUser } from "../lib/sender.js";
2
2
  import { log } from "../logger.js";
3
3
  const MAX_ATTEMPTS = 2;
4
4
  const RECENT_BUFFER_LIMIT = 100;
5
+ /** Hot-buffer conversation resources are keyed by the session id (a UUID). */
6
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
5
7
  /**
6
8
  * Sessions where the orientation block was already injected (or where we
7
9
  * deliberately decided not to inject — unknown sender, exhausted retries).
@@ -134,6 +136,59 @@ async function fetchRecentTraces(client, cutoff, limit, userId) {
134
136
  });
135
137
  return buffer.slice(0, limit);
136
138
  }
139
+ /**
140
+ * Pull recent conversation sessions from the hot buffer's vault resources — the
141
+ * modern source of "recent interactions" (the agent_end-trace path only works
142
+ * when auto-trace is on, which it usually isn't). The hot buffer writes one
143
+ * session-grouped Resource per conversation: `resource_id` = the session id (a
144
+ * UUID), untagged (no `openclaw_source`), with a generated title. We rely on
145
+ * `listMemories` returning resources newest-first (verified live) and take the
146
+ * newest `limit` conversation resources — their metadata carries no date to
147
+ * filter on, so order is our recency signal.
148
+ *
149
+ * Excludes: tagged rows (memory_sync_section / command / agent_end), non-UUID
150
+ * resources (synced docs), automated cron sessions, and untitled rows.
151
+ */
152
+ async function fetchRecentConversations(client, limit, userId) {
153
+ const out = [];
154
+ const seenTitles = new Set();
155
+ let scanned = 0;
156
+ for await (const memory of client.listMemories({
157
+ source: "vault",
158
+ userId,
159
+ pageSize: 50,
160
+ })) {
161
+ scanned++;
162
+ if (scanned > RECENT_BUFFER_LIMIT)
163
+ break;
164
+ if (!UUID_RE.test(memory.resourceId))
165
+ continue;
166
+ if (memory.metadata?.openclaw_source)
167
+ continue;
168
+ const title = memory.title ?? "";
169
+ if (title.length === 0 || /^\[cron:/i.test(title))
170
+ continue;
171
+ // The backend sometimes generates the same title for distinct sessions
172
+ // (e.g. repeated daily-summary chats); collapse those so the block isn't
173
+ // padded with duplicates.
174
+ const titleKey = title.trim().toLowerCase();
175
+ if (seenTitles.has(titleKey))
176
+ continue;
177
+ seenTitles.add(titleKey);
178
+ out.push({
179
+ resourceId: memory.resourceId,
180
+ title: memory.title,
181
+ source: memory.source,
182
+ score: null,
183
+ url: null,
184
+ createdAt: null,
185
+ highlights: [],
186
+ });
187
+ if (out.length >= limit)
188
+ break; // newest-first → first N are most recent
189
+ }
190
+ return out;
191
+ }
137
192
  export function buildStartupOrientationHandler(client, cfg) {
138
193
  const so = cfg.startupOrientation;
139
194
  return async (_event, ctx) => {
@@ -156,9 +211,20 @@ export function buildStartupOrientationHandler(client, cfg) {
156
211
  injectedSessions.add(sessionKey);
157
212
  return;
158
213
  }
159
- const cutoff = isoDaysAgo(so.recentDays);
214
+ // Source recent-interactions from wherever the session record actually
215
+ // lives. Prefer the hot buffer (modern path: clean session-grouped vault
216
+ // resources, present whenever the hot buffer is on — including auto-trace-
217
+ // off agents). Fall back to agent_end traces only when there's no hot
218
+ // buffer but auto-trace is on. Otherwise skip: there's nothing to fetch,
219
+ // and the trace-source list is expensive (observed ~12s + failing/turn),
220
+ // blocking before_agent_start and slowing the reply.
221
+ const recentFetch = cfg.hotBuffer.enabled
222
+ ? fetchRecentConversations(client, so.recentLimit, userId)
223
+ : cfg.autoTrace.enabled
224
+ ? fetchRecentTraces(client, isoDaysAgo(so.recentDays), so.recentLimit, userId)
225
+ : Promise.resolve([]);
160
226
  const [recentSettled, loopsSettled] = await Promise.allSettled([
161
- fetchRecentTraces(client, cutoff, so.recentLimit, userId),
227
+ recentFetch,
162
228
  client.search(so.loopsQuery, {
163
229
  limit: so.loopsLimit,
164
230
  userId,
package/dist/index.js CHANGED
@@ -8,7 +8,7 @@ import { buildEmotionalStateCompactionHandler, buildEmotionalStateFetchHandler,
8
8
  import { buildFileSyncHandler, syncMemoriesOnStartup } from "./hooks/memory-sync.js";
9
9
  import { buildHotBufferHandler, buildHotBufferSessionCleanupHandler, } from "./hooks/hot-buffer.js";
10
10
  import { buildStartupOrientationCompactionHandler, buildStartupOrientationHandler, buildStartupOrientationSessionCleanupHandler, } from "./hooks/startup-orientation.js";
11
- import { initLogger } from "./logger.js";
11
+ import { initLogger, log } from "./logger.js";
12
12
  import { createRememberToolFactory } from "./tools/remember.js";
13
13
  import { createSearchToolFactory } from "./tools/search.js";
14
14
  import { registerNetworkTools } from "./graph/index.js";
@@ -74,29 +74,45 @@ export default {
74
74
  api.registerTool(createRememberToolFactory(client, cfg), {
75
75
  name: "hyperspell_remember",
76
76
  });
77
- // Register emotional context hooks.
78
- // - fetch: inject once per session on first turn (cached thereafter)
79
- // - compaction: clear cache so the next turn re-injects after trim
80
- // - session cleanup: drop Set entry on session end
81
- // - store: extract new emotional state from the finished session
77
+ const startHandlers = [];
82
78
  if (cfg.emotionalContext) {
83
- api.on("before_agent_start", buildEmotionalStateFetchHandler(client, cfg));
79
+ startHandlers.push(buildEmotionalStateFetchHandler(client, cfg));
84
80
  api.on("after_compaction", buildEmotionalStateCompactionHandler());
85
81
  api.on("session_end", buildEmotionalStateSessionCleanupHandler());
86
82
  api.on("agent_end", buildEmotionalStateStoreHandler(client, cfg));
87
83
  }
88
- // Register auto-context hook
89
84
  if (cfg.autoContext) {
90
- const autoContextHandler = buildAutoContextHandler(client, cfg);
91
- api.on("before_agent_start", autoContextHandler);
85
+ startHandlers.push(buildAutoContextHandler(client, cfg));
92
86
  }
93
- // Register startup-orientation hooks: recent-interactions + unfinished-loops
94
- // injected once per session on first turn. Lifecycle mirrors emotional-context.
95
87
  if (cfg.startupOrientation.enabled) {
96
- api.on("before_agent_start", buildStartupOrientationHandler(client, cfg));
88
+ // recent-interactions reads conversation sessions from the hot buffer
89
+ // when it's on, else falls back to auto-trace's agent_end traces. With
90
+ // BOTH off there's no source to read — warn so the operator knows the
91
+ // block will be empty rather than wondering why there's no continuity.
92
+ if (!cfg.hotBuffer.enabled && !cfg.autoTrace.enabled) {
93
+ log.warn("startup-orientation is enabled but neither hotBuffer nor autoTrace is on — recent-interactions will be empty (no conversation source to read).");
94
+ }
95
+ startHandlers.push(buildStartupOrientationHandler(client, cfg));
97
96
  api.on("after_compaction", buildStartupOrientationCompactionHandler());
98
97
  api.on("session_end", buildStartupOrientationSessionCleanupHandler());
99
98
  }
99
+ if (startHandlers.length > 0) {
100
+ api.on("before_agent_start", async (event, ctx) => {
101
+ const results = await Promise.all(startHandlers.map((h) => Promise.resolve()
102
+ .then(() => h(event, ctx))
103
+ .catch((err) => {
104
+ // One injector failing must not break the others or the turn.
105
+ log.error("session-start handler failed", err);
106
+ return undefined;
107
+ })));
108
+ const parts = results
109
+ .map((r) => r?.prependContext)
110
+ .filter((p) => typeof p === "string" && p.length > 0);
111
+ return parts.length > 0
112
+ ? { prependContext: parts.join("\n\n") }
113
+ : undefined;
114
+ });
115
+ }
100
116
  // Register auto-trace hook (send conversations to Hyperspell on session end)
101
117
  if (cfg.autoTrace.enabled) {
102
118
  api.on("agent_end", buildAutoTraceHandler(client, cfg));
@@ -12,17 +12,21 @@
12
12
  * NOT surface via generic retrieval — replaying whole sanitized transcripts back
13
13
  * into context creates a self-amplifying pollution loop. Exclude them here.
14
14
  *
15
- * THE #40 TENSION: hot-buffer rows written via `POST /messages` carry NO
16
- * `openclaw_source`, and the backend evaluates absent-field metadata predicates
17
- * in SQL three-valued logic `metadata->>'openclaw_source'` is NULL for a
18
- * missing key, and `NULL != 'agent_end'` is NULL (not TRUE) so this filter
19
- * also drops every untagged hot-buffer row. We could not work around that at the
20
- * filter layer: `docs/filter-dialect-test.mjs` against the live backend showed
21
- * that NO `openclaw_source` predicate returns untagged rows ($exists/$or/$nin/
22
- * $not all fail), AND that `POST /messages` silently ignores a `metadata` field,
23
- * so the rows can't be positively tagged either. See `excludeFilterFor` for the
24
- * fix we ship (gate on auto-trace), and issue #40 for the backend follow-up
25
- * (make `/messages` accept metadata, or make the filter NULL-tolerant).
15
+ * Since backend Hyperspell #1921, `$ne` follows MongoDB absent-field semantics:
16
+ * it KEEPS rows whose `openclaw_source` is absent (untagged hot-buffer rows) and
17
+ * drops only `agent_end`. So applying this filter is now SAFE for hot rows — the
18
+ * old #40 tension (where `NULL != 'agent_end'` dropped untagged rows) is resolved
19
+ * at the backend.
20
+ *
21
+ * We nonetheless GATE the filter on auto-trace (see `excludeFilterFor`) for
22
+ * PERFORMANCE, not correctness: a `{$ne}` predicate measurably slows the vector
23
+ * search (~1s observed live), and when auto-trace is off there are no `agent_end`
24
+ * rows to hide, so the filter would cost latency on every turn for zero benefit.
25
+ *
26
+ * Do NOT instead try to positively tag hot rows via `POST /messages` metadata to
27
+ * identify them: a `/messages` write carrying `metadata` is accepted (200) but
28
+ * the row becomes NON-retrievable (verified live, post-#1921) — see the
29
+ * hot-buffer hook, which writes content only.
26
30
  *
27
31
  * NOTE: an earlier version checked the top-level `source` field for
28
32
  * "openclaw_agent_end" — wrong on BOTH counts (the tag lives in metadata under
@@ -32,17 +36,15 @@ export const EXCLUDE_SESSION_END_FILTER = {
32
36
  openclaw_source: { $ne: "agent_end" },
33
37
  };
34
38
  /**
35
- * The exclude clause to apply for a given config — or `undefined` to skip
36
- * filtering entirely (issue #40, Option 4 the only viable plugin-side fix).
37
- * `openclaw_source: "agent_end"` rows are written ONLY by the auto-trace hook;
38
- * when auto-trace is disabled there are none to hide, so we skip the filter
39
- * entirely which is also the ONLY way to keep untagged hot-buffer rows
40
- * visible, since (per the dialect test) no filter and no write-tag can do it.
39
+ * The exclude clause to apply for a given config — or `undefined` to skip the
40
+ * filter. `agent_end` rows are written ONLY by the auto-trace hook, so when
41
+ * auto-trace is OFF there are none to hide and we skip the filter to avoid its
42
+ * ~1s/search latency cost (pure overhead otherwise verified live against an
43
+ * auto-trace-off agent with zero `agent_end` rows).
41
44
  *
42
- * LIMITATION: when auto-trace IS enabled, this still applies `$ne agent_end`,
43
- * which drops untagged hot-buffer rows along with the traces. There is no
44
- * plugin-side fix for that combination today; it needs the backend change
45
- * tracked in #40. (The common single-feature install has auto-trace off.)
45
+ * When auto-trace is ON we apply `{$ne:"agent_end"}`, which post-#1921 drops the
46
+ * traces while KEEPING untagged hot-buffer rows so that path is correct now
47
+ * (the old #40 hot-row-drop is fixed by the backend, not by gating).
46
48
  */
47
49
  export function excludeFilterFor(cfg) {
48
50
  return cfg.autoTrace.enabled ? EXCLUDE_SESSION_END_FILTER : undefined;
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Shared classification of read-path (search/retrieval) failures, used by every
3
+ * retrieval path (the `hyperspell_search` tool + the auto-context hook) so both
4
+ * surface a transient backend throttle the same way — mirroring how
5
+ * `lib/filters.ts` keeps filtering consistent across them.
6
+ *
7
+ * Why this exists (issue #39): under load the Hyperspell backend sheds requests
8
+ * with a `Retry-After` header advertising a multi-second cooldown (~55s observed)
9
+ * — surfaced as a 429 and, when shedding hard, attached to 5xx responses too.
10
+ * The `hyperspell` SDK already retries 429/5xx with bounded exponential backoff
11
+ * (maxRetries=2), but no in-turn backoff can ride out a ~55s cooldown, so the
12
+ * call still throws. The bug is what happens NEXT: the read path collapsed the
13
+ * error to "Search failed" / injected nothing, so a session reads it as "no
14
+ * memories" and confabulates around an empty result.
15
+ *
16
+ * The fix is to DISTINGUISH a throttle (429 / `Retry-After`) from a generic
17
+ * transient 5xx and a permanent 4xx, surface it EXPLICITLY to the agent, and log
18
+ * it at `warn` with the cooldown so the throttle is observable. We deliberately
19
+ * do NOT add our own retry — the SDK already does, and a longer in-turn sleep
20
+ * would just hang the turn.
21
+ */
22
+ /** Pull a numeric HTTP status off an unknown thrown value (SDK `APIError.status`). */
23
+ function statusOf(err) {
24
+ const s = err?.status;
25
+ return typeof s === "number" ? s : undefined;
26
+ }
27
+ /**
28
+ * Read the `Retry-After` header (in seconds) off a thrown SDK `APIError`.
29
+ * `APIError.headers` is a `Headers` instance; we also tolerate a plain object so
30
+ * this stays robust to SDK shape changes and is trivial to unit-test. Per RFC,
31
+ * `Retry-After` is either delta-seconds or an HTTP-date — handle both.
32
+ */
33
+ function retryAfterSecondsOf(err) {
34
+ const headers = err?.headers;
35
+ let raw;
36
+ if (headers && typeof headers.get === "function") {
37
+ raw = headers.get("retry-after");
38
+ }
39
+ else if (headers && typeof headers === "object") {
40
+ const obj = headers;
41
+ raw = obj["retry-after"] ?? obj["Retry-After"];
42
+ }
43
+ const trimmed = raw?.trim();
44
+ if (!trimmed)
45
+ return undefined;
46
+ const asSeconds = Number(trimmed);
47
+ if (Number.isFinite(asSeconds))
48
+ return Math.max(0, Math.round(asSeconds));
49
+ const asDate = Date.parse(trimmed);
50
+ if (!Number.isNaN(asDate)) {
51
+ return Math.max(0, Math.round((asDate - Date.now()) / 1000));
52
+ }
53
+ return undefined;
54
+ }
55
+ /**
56
+ * Cap, in seconds, beyond which we will NOT echo a literal Retry-After value to
57
+ * the agent. A backend advertising minutes is plausible; one advertising
58
+ * "~99999999999s" (or a far-future HTTP-date) is not, and interpolating it
59
+ * verbatim next to "try again shortly" is exactly the misleading signal #39
60
+ * exists to avoid. Above this we fall back to generic wording. The accurate
61
+ * value is still logged.
62
+ */
63
+ const MAX_SURFACED_RETRY_SECONDS = 600;
64
+ export function classifySearchError(err) {
65
+ const status = statusOf(err);
66
+ const retryAfterSeconds = retryAfterSecondsOf(err);
67
+ const detail = err instanceof Error ? err.message : String(err);
68
+ // A 429, or any status carrying Retry-After (the backend attaches it to the
69
+ // 5xx it sheds under load too), means an active cooldown window — flag it as
70
+ // a throttle regardless of the exact status code.
71
+ if (status === 429 || retryAfterSeconds !== undefined) {
72
+ return { kind: "throttled", status, retryAfterSeconds, detail };
73
+ }
74
+ if (status !== undefined && status >= 500) {
75
+ return { kind: "transient", status, detail };
76
+ }
77
+ if (status !== undefined && status >= 400) {
78
+ return { kind: "client", status, detail };
79
+ }
80
+ return { kind: "unknown", detail };
81
+ }
82
+ /**
83
+ * Agent-facing text for a failed `hyperspell_search` tool call. For a throttle or
84
+ * transient error the wording is deliberate: it tells the agent this is a backend
85
+ * condition and NOT an empty memory, so it doesn't conclude "nothing was found"
86
+ * (issue #39's core symptom).
87
+ */
88
+ export function searchErrorToolText(info) {
89
+ switch (info.kind) {
90
+ case "throttled": {
91
+ const s = info.retryAfterSeconds;
92
+ if (s !== undefined && s <= MAX_SURFACED_RETRY_SECONDS) {
93
+ return `Memory search is temporarily rate-limited by the backend (retry in ~${s}s). This is a transient throttle, NOT an empty memory — do not conclude nothing was found; try again shortly.`;
94
+ }
95
+ // Unknown or implausibly long cooldown — don't echo an absurd number.
96
+ return `Memory search is temporarily rate-limited by the backend. This is a transient throttle, NOT an empty memory — do not conclude nothing was found; retry later rather than assuming nothing exists.`;
97
+ }
98
+ case "transient":
99
+ return `Memory search hit a transient backend error (HTTP ${info.status}) and automatic retries were exhausted. This is NOT an empty memory — do not conclude nothing was found; try again shortly.`;
100
+ default:
101
+ return `Search failed: ${info.detail}`;
102
+ }
103
+ }
104
+ /** One-line summary for logs. */
105
+ function summarize(info) {
106
+ const parts = [`kind=${info.kind}`];
107
+ if (info.status !== undefined)
108
+ parts.push(`status=${info.status}`);
109
+ if (info.retryAfterSeconds !== undefined)
110
+ parts.push(`retry-after≈${info.retryAfterSeconds}s`);
111
+ return parts.join(" ");
112
+ }
113
+ /**
114
+ * Log a read-path failure consistently across both retrieval paths. Throttles and
115
+ * transient 5xx are expected-under-load and log at `warn` (with the cooldown, so
116
+ * blips are observable); genuine errors log at `error` with the raw cause.
117
+ */
118
+ export function logSearchError(log, where, info, err) {
119
+ if (info.kind === "throttled" || info.kind === "transient") {
120
+ log.warn(`${where}: backend unavailable — ${summarize(info)}`);
121
+ }
122
+ else {
123
+ log.error(`${where} failed (${summarize(info)})`, err);
124
+ }
125
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Resolve the CURRENT session's id — the value the hot buffer keys its rows by
3
+ * (`resourceId = event.sessionId`, see `hooks/hot-buffer.ts`). The auto-context
4
+ * hook uses this to exclude the live session's own just-written turns from its
5
+ * retrieval, so the agent isn't fed its own recent messages back as "recalled
6
+ * memory" (issue #42).
7
+ *
8
+ * The wrinkle: hooks see `ctx.sessionKey`, which is a COMPOSITE — e.g.
9
+ * `agent:main:cron:<cronId>:run:<sessionId>` — not the bare `sessionId` the hot
10
+ * buffer writes. So we prefer an explicit `sessionId` field when present and
11
+ * otherwise recover the bare id from the `:run:` suffix (or a trailing UUID).
12
+ *
13
+ * Returns `undefined` when no id can be determined — callers MUST treat that as
14
+ * "don't exclude" so retrieval degrades to its prior behavior rather than
15
+ * dropping anything by accident. The exclusion is purely subtractive: a wrong or
16
+ * missing id can only fail to hide the echo, never hide real cross-session
17
+ * memories (those live under a different resource_id).
18
+ */
19
+ const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
20
+ const RUN_SEP = ":run:";
21
+ export function resolveCurrentSessionId(event, ctx) {
22
+ // Prefer an explicit sessionId — this is exactly what the hot buffer uses.
23
+ const direct = (typeof event?.sessionId === "string" && event.sessionId) ||
24
+ (typeof ctx?.sessionId === "string" && ctx.sessionId);
25
+ if (direct)
26
+ return direct;
27
+ const key = ctx?.sessionKey;
28
+ if (typeof key !== "string" || key.length === 0)
29
+ return undefined;
30
+ // Composite key — the bare session id is the `:run:` suffix.
31
+ const runIdx = key.lastIndexOf(RUN_SEP);
32
+ if (runIdx >= 0) {
33
+ const tail = key.slice(runIdx + RUN_SEP.length);
34
+ if (tail.length > 0)
35
+ return tail;
36
+ }
37
+ // No `:run:` segment — accept the key only if it IS (ends with) a bare UUID;
38
+ // anything else (a phone handle, etc.) can't match a resource_id, so bail out
39
+ // rather than return a value that would silently never match.
40
+ const m = key.match(UUID_RE);
41
+ if (m && key.endsWith(m[0]))
42
+ return m[0];
43
+ return undefined;
44
+ }
@@ -1,5 +1,6 @@
1
1
  import { Type } from "@sinclair/typebox";
2
2
  import { mergeWithExclude } from "../lib/filters.js";
3
+ import { classifySearchError, logSearchError, searchErrorToolText, } from "../lib/search-error.js";
3
4
  import { buildScopeFilter, getCanReadScopes, resolveUser } from "../lib/sender.js";
4
5
  import { log } from "../logger.js";
5
6
  export function createSearchToolFactory(client, cfg) {
@@ -76,14 +77,13 @@ export function createSearchToolFactory(client, cfg) {
76
77
  };
77
78
  }
78
79
  catch (err) {
79
- log.error("search tool failed", err);
80
+ // Distinguish a transient backend throttle (429 / Retry-After) from a
81
+ // real failure and surface it explicitly, so the agent doesn't read a
82
+ // backend hiccup as "no memories" and confabulate around it (issue #39).
83
+ const info = classifySearchError(err);
84
+ logSearchError(log, "search tool", info, err);
80
85
  return {
81
- content: [
82
- {
83
- type: "text",
84
- text: `Search failed: ${err instanceof Error ? err.message : String(err)}`,
85
- },
86
- ],
86
+ content: [{ type: "text", text: searchErrorToolText(info) }],
87
87
  };
88
88
  }
89
89
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperspell/openclaw-hyperspell",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "type": "module",
5
5
  "description": "OpenClaw Hyperspell memory plugin",
6
6
  "license": "MIT",
@@ -38,7 +38,7 @@
38
38
  "check-types": "tsc --noEmit",
39
39
  "lint": "bunx @biomejs/biome ci .",
40
40
  "lint:fix": "bunx @biomejs/biome check --write .",
41
- "test": "node --test --experimental-strip-types lib/sender.test.ts lib/filters.test.ts config.test.ts sync/markdown.test.ts hooks/auto-trace.test.ts hooks/hot-buffer.test.ts hooks/emotional-state.test.ts hooks/startup-orientation.test.ts"
41
+ "test": "node --test --experimental-strip-types client.test.ts lib/sender.test.ts lib/filters.test.ts lib/search-error.test.ts lib/session.test.ts tools/search.test.ts config.test.ts sync/markdown.test.ts hooks/auto-trace.test.ts hooks/auto-context.test.ts hooks/hot-buffer.test.ts hooks/emotional-state.test.ts hooks/startup-orientation.test.ts"
42
42
  },
43
43
  "openclaw": {
44
44
  "extensions": [