@hyperspell/openclaw-hyperspell 0.14.2 → 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
@@ -270,6 +270,62 @@ export class HyperspellClient {
270
270
  });
271
271
  return { resourceId: result.resource_id, status: result.status };
272
272
  }
273
+ /**
274
+ * POST /messages — the real-time hot buffer. Rows are full-text searchable
275
+ * the instant they're inserted (a Postgres GENERATED tsvector, no embedding
276
+ * wait) and auto-consolidated server-side into vault Resources within ~60s.
277
+ * The existing search path already unions this buffer with vector results,
278
+ * so once we write here our turns become searchable immediately.
279
+ *
280
+ * Auth: api key + `X-As-User` (REQUIRED — the endpoint 422s without it).
281
+ * Upsert key is (app_id, user_id, resource_id, message_id), so re-posting an
282
+ * identical message_id updates `content` only — safe to retry, no duplicates.
283
+ *
284
+ * Server-side limits (return 422): per-message content 1..512,000 chars;
285
+ * batch 1..1,000 messages. Callers should pre-enforce these.
286
+ */
287
+ async sendMessages(messages, options) {
288
+ const userId = options?.userId ?? this.config.userId;
289
+ if (!userId) {
290
+ // X-As-User is mandatory for /messages. Fail loud rather than firing a
291
+ // request we know will 422.
292
+ throw new Error("sendMessages requires a userId (X-As-User) — none configured");
293
+ }
294
+ if (messages.length === 0)
295
+ return { count: 0 };
296
+ const source = (options?.source ?? "vault").toLowerCase();
297
+ const metadata = options?.metadata;
298
+ const body = {
299
+ source,
300
+ messages: messages.map((m) => ({
301
+ resource_id: m.resourceId,
302
+ message_id: m.messageId,
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 } : {}),
308
+ })),
309
+ };
310
+ log.debugRequest("messages.create", {
311
+ source,
312
+ count: messages.length,
313
+ userId,
314
+ });
315
+ const res = await fetch(`${API_BASE_URL}/messages`, {
316
+ method: "POST",
317
+ headers: { ...this.rawHeaders(), "X-As-User": userId },
318
+ body: JSON.stringify(body),
319
+ });
320
+ if (!res.ok) {
321
+ const text = await res.text().catch(() => "");
322
+ throw new Error(`POST /messages failed (${res.status}): ${text}`);
323
+ }
324
+ const data = await res.json();
325
+ const count = data?.count ?? messages.length;
326
+ log.debugResponse("messages.create", { count });
327
+ return { count };
328
+ }
273
329
  async listConnections(options) {
274
330
  log.debugRequest("connections.list", { userId: options?.userId });
275
331
  const response = await this.client.connections.list(this.requestOptions(options?.userId));
@@ -344,6 +400,43 @@ export class HyperspellClient {
344
400
  log.debugResponse("emotional-state.get", { found: true, resourceId: result.resourceId });
345
401
  return result;
346
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
+ }
347
440
  async deleteEmotionalState(relationshipId) {
348
441
  log.debugRequest("emotional-state.delete", { relationshipId });
349
442
  const url = new URL(`${API_BASE_URL}/emotional-state`);
@@ -269,6 +269,12 @@ async function runSetup() {
269
269
  userId,
270
270
  autoContext: true,
271
271
  autoTrace: { enabled: false, extract: ["procedure"] },
272
+ hotBuffer: {
273
+ enabled: false,
274
+ source: "vault",
275
+ writeUser: true,
276
+ writeAssistant: true,
277
+ },
272
278
  emotionalContext: false,
273
279
  syncMemories: true,
274
280
  syncMemoriesConfig: {
package/dist/config.js CHANGED
@@ -15,6 +15,7 @@ const ALLOWED_KEYS = [
15
15
  "userId",
16
16
  "autoContext",
17
17
  "autoTrace",
18
+ "hotBuffer",
18
19
  "emotionalContext",
19
20
  "relationshipId",
20
21
  "startupOrientation",
@@ -235,6 +236,11 @@ export function parseConfig(raw) {
235
236
  const kgRaw = (cfg.knowledgeGraph ?? {});
236
237
  const atRaw = (cfg.autoTrace ?? {});
237
238
  const soRaw = (cfg.startupOrientation ?? {});
239
+ const hbRaw = (cfg.hotBuffer ?? {});
240
+ if (cfg.hotBuffer && typeof cfg.hotBuffer === "object" && !Array.isArray(cfg.hotBuffer)) {
241
+ assertAllowedKeys(hbRaw, ["enabled", "source", "writeUser", "writeAssistant"], "hyperspell.hotBuffer");
242
+ }
243
+ const hbSource = parseSources(hbRaw.source)[0] ?? "vault";
238
244
  // syncMemories can be a boolean (legacy) or an object (new)
239
245
  const smRaw = cfg.syncMemories;
240
246
  const syncMemoriesEnabled = typeof smRaw === "boolean"
@@ -269,6 +275,14 @@ export function parseConfig(raw) {
269
275
  ],
270
276
  metadata: atRaw.metadata,
271
277
  },
278
+ hotBuffer: {
279
+ // Default OFF so shipping the plugin never changes existing installs'
280
+ // behavior; opt in per-install via plugin config.
281
+ enabled: hbRaw.enabled ?? false,
282
+ source: hbSource,
283
+ writeUser: hbRaw.writeUser ?? true,
284
+ writeAssistant: hbRaw.writeAssistant ?? true,
285
+ },
272
286
  emotionalContext: cfg.emotionalContext ?? false,
273
287
  relationshipId: cfg.relationshipId,
274
288
  startupOrientation: {
@@ -1,5 +1,7 @@
1
1
  import { buildScopeFilter, getCanReadScopes, resolveUser, } from "../lib/sender.js";
2
- import { EXCLUDE_SESSION_END_FILTER, mergeWithExclude } from "../lib/filters.js";
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 {
@@ -49,28 +51,52 @@ function formatHighlightBullets(results, maxResults, threshold) {
49
51
  return null;
50
52
  return sections.join("\n\n");
51
53
  }
52
- const INTRO = "The following is context from the user's connected sources. Reference it only when relevant to the conversation.";
53
- const DISCLAIMER = "Use this context naturally when relevant — including indirect connections — but don't force it into every response or make assumptions beyond what's stated.";
54
+ const INTRO = "The following is surfaced from the user's memory and connected sources, including past conversations. Reference it as recalled context, only when relevant to the conversation.";
55
+ const DISCLAIMER = "Draw on it when relevant — including indirect connections — but don't force it into every response or make assumptions beyond what's stated.";
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
- filter: EXCLUDE_SESSION_END_FILTER,
73
- });
98
+ filter: excludeFilterFor(cfg),
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;
@@ -104,7 +134,7 @@ async function multiUserSearch(client, cfg, prompt, resolved) {
104
134
  ? client.search(prompt, {
105
135
  limit: cfg.maxResults,
106
136
  userId: resolved.userId,
107
- filter: EXCLUDE_SESSION_END_FILTER,
137
+ filter: excludeFilterFor(cfg),
108
138
  })
109
139
  : null;
110
140
  // Always search shared for unknown senders, even if includeSharedInSearch is false
@@ -115,7 +145,7 @@ async function multiUserSearch(client, cfg, prompt, resolved) {
115
145
  ? client.search(prompt, {
116
146
  limit: sharedLimit,
117
147
  userId: multiUser.sharedUserId,
118
- filter: mergeWithExclude(scopeFilter),
148
+ filter: mergeWithExclude(scopeFilter, cfg),
119
149
  })
120
150
  : null;
121
151
  const searches = [personalSearch, sharedSearch].filter(Boolean);
@@ -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) {
@@ -0,0 +1,169 @@
1
+ import { resolveUser } from "../lib/sender.js";
2
+ import { log } from "../logger.js";
3
+ import { sanitizeTraceText } from "./auto-trace.js";
4
+ /** Server-side limits from POST /messages (return 422 if exceeded). */
5
+ const MAX_CONTENT_CHARS = 512_000;
6
+ const MAX_BATCH = 1_000;
7
+ const MAX_TOTAL_CHARS = 5_242_880;
8
+ /**
9
+ * Per-session set of message_ids already written to the hot buffer this run.
10
+ * `agent_end` fires once per turn with the *full* session history, so without
11
+ * this we'd re-post every prior message each turn. The server upserts (so it's
12
+ * harmless correctness-wise), but re-posting is wasteful — this keeps each turn
13
+ * to just its new messages. Cleared on session_end.
14
+ */
15
+ const sentBySession = new Map();
16
+ /**
17
+ * Flatten a message's content into a single sanitized text string. Mirrors the
18
+ * auto-trace sanitizer so the hot buffer never captures injected
19
+ * <hyperspell-context>/emotional/orientation wrappers (which would otherwise be
20
+ * consolidated as "memory" and pollute future retrieval).
21
+ */
22
+ function extractText(content) {
23
+ let raw;
24
+ if (typeof content === "string") {
25
+ raw = content;
26
+ }
27
+ else if (Array.isArray(content)) {
28
+ raw = content
29
+ .filter((i) => i?.type === "text" && typeof i.text === "string")
30
+ .map((i) => i.text)
31
+ .join("\n");
32
+ }
33
+ else {
34
+ raw = "";
35
+ }
36
+ return sanitizeTraceText(raw);
37
+ }
38
+ /**
39
+ * Deterministic, stable id for a message so retries upsert rather than
40
+ * duplicate. FNV-1a over role+text; prefixed with the role initial to keep
41
+ * user/assistant lines distinct even when content collides.
42
+ */
43
+ function messageId(role, text) {
44
+ let h = 0x811c9dc5;
45
+ const s = `${role} ${text}`;
46
+ for (let i = 0; i < s.length; i++) {
47
+ h ^= s.charCodeAt(i);
48
+ h = Math.imul(h, 0x01000193);
49
+ }
50
+ return `${role[0] ?? "x"}-${(h >>> 0).toString(16).padStart(8, "0")}`;
51
+ }
52
+ /**
53
+ * Write each completed turn to the Hyperspell hot buffer so it's instantly
54
+ * searchable. Runs on `agent_end` — fire-and-forget; a failure here must never
55
+ * break the turn.
56
+ */
57
+ export function buildHotBufferHandler(client, cfg) {
58
+ return async (event, ctx) => {
59
+ if (event.success === false) {
60
+ log.debug("hot-buffer: skipping — agent ended with error");
61
+ return;
62
+ }
63
+ const messages = event.messages;
64
+ if (!messages || messages.length === 0)
65
+ return;
66
+ // X-As-User is mandatory for /messages. Resolve the owner up front and
67
+ // skip (with a clear warning) rather than firing requests that 422.
68
+ const userId = resolveUser(ctx, cfg)?.userId;
69
+ if (!userId) {
70
+ log.warn("hot-buffer: no userId resolved (X-As-User required) — skipping write");
71
+ return;
72
+ }
73
+ // The session id lives on the hook CONTEXT, not the agent_end event
74
+ // (PluginHookAgentEndEvent = { messages, success, error?, durationMs? };
75
+ // PluginHookAgentContext carries sessionId). Reading event.sessionId
76
+ // always yielded undefined → a fresh random resourceId every turn, which
77
+ // (a) defeated the sentBySession dedup so each turn re-posted the entire
78
+ // growing transcript, and (b) scattered a session’s turns across
79
+ // per-turn resources, so nothing could exclude "the current session"
80
+ // (issue #42). Prefer ctx.sessionId; keep event/random as fallbacks.
81
+ const sessionId = ctx?.sessionId ??
82
+ event.sessionId ??
83
+ crypto.randomUUID();
84
+ const resourceId = sessionId;
85
+ const sent = sentBySession.get(sessionId) ?? new Set();
86
+ const pending = [];
87
+ const pendingIds = [];
88
+ for (const m of messages) {
89
+ const role = m.role;
90
+ if (role === "user") {
91
+ if (!cfg.hotBuffer.writeUser)
92
+ continue;
93
+ }
94
+ else if (role === "assistant") {
95
+ if (!cfg.hotBuffer.writeAssistant)
96
+ continue;
97
+ }
98
+ else {
99
+ // Skip system / tool / toolResult — only conversational turns.
100
+ continue;
101
+ }
102
+ let text = extractText(m.content);
103
+ if (text.length === 0)
104
+ continue;
105
+ if (text.length > MAX_CONTENT_CHARS) {
106
+ log.warn(`hot-buffer: truncating ${role} message ${text.length} -> ${MAX_CONTENT_CHARS} chars`);
107
+ text = text.slice(0, MAX_CONTENT_CHARS);
108
+ }
109
+ const id = messageId(role, text);
110
+ if (sent.has(id))
111
+ continue;
112
+ pending.push({ resourceId, messageId: id, content: text });
113
+ pendingIds.push(id);
114
+ }
115
+ if (pending.length === 0)
116
+ return;
117
+ // Chunk by count AND cumulative content size to stay under the 422 limits.
118
+ const batches = [];
119
+ let batch = [];
120
+ let batchChars = 0;
121
+ for (const item of pending) {
122
+ if (batch.length >= MAX_BATCH ||
123
+ (batch.length > 0 && batchChars + item.content.length > MAX_TOTAL_CHARS)) {
124
+ batches.push(batch);
125
+ batch = [];
126
+ batchChars = 0;
127
+ }
128
+ batch.push(item);
129
+ batchChars += item.content.length;
130
+ }
131
+ if (batch.length > 0)
132
+ batches.push(batch);
133
+ try {
134
+ let total = 0;
135
+ for (const b of batches) {
136
+ // Do NOT tag hot rows with metadata: a POST /messages write that
137
+ // carries `metadata` is accepted (200) but the row never becomes
138
+ // retrievable (verified live, post-Hyperspell #1921) — tagging
139
+ // silently breaks hot-buffer recall. The tag isn't needed anyway:
140
+ // untagged rows survive the unconditional {$ne:"agent_end"} exclude
141
+ // (absent-field semantics, #1921) AND are full-text searchable. So
142
+ // we write content only. (Backend follow-up: make /messages metadata
143
+ // not suppress indexing, then this can be reinstated.)
144
+ const result = await client.sendMessages(b, {
145
+ userId,
146
+ source: cfg.hotBuffer.source,
147
+ });
148
+ total += result.count;
149
+ }
150
+ // Only mark as sent after a successful write so a failure retries next turn.
151
+ for (const id of pendingIds)
152
+ sent.add(id);
153
+ sentBySession.set(sessionId, sent);
154
+ log.info(`hot-buffer: wrote ${total} message(s) to ${sessionId} (user=${userId})`);
155
+ }
156
+ catch (err) {
157
+ // Fire-and-forget — never break the turn.
158
+ log.error("hot-buffer write failed", err);
159
+ }
160
+ };
161
+ }
162
+ /** Drop the per-session sent-set on session end to avoid unbounded growth. */
163
+ export function buildHotBufferSessionCleanupHandler() {
164
+ return (event) => {
165
+ const sessionId = event.sessionId;
166
+ if (sessionId)
167
+ sentBySession.delete(sessionId);
168
+ };
169
+ }
@@ -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
@@ -6,8 +6,9 @@ import { buildAutoContextHandler } from "./hooks/auto-context.js";
6
6
  import { buildAutoTraceHandler } from "./hooks/auto-trace.js";
7
7
  import { buildEmotionalStateCompactionHandler, buildEmotionalStateFetchHandler, buildEmotionalStateSessionCleanupHandler, buildEmotionalStateStoreHandler, } from "./hooks/emotional-state.js";
8
8
  import { buildFileSyncHandler, syncMemoriesOnStartup } from "./hooks/memory-sync.js";
9
+ import { buildHotBufferHandler, buildHotBufferSessionCleanupHandler, } from "./hooks/hot-buffer.js";
9
10
  import { buildStartupOrientationCompactionHandler, buildStartupOrientationHandler, buildStartupOrientationSessionCleanupHandler, } from "./hooks/startup-orientation.js";
10
- import { initLogger } from "./logger.js";
11
+ import { initLogger, log } from "./logger.js";
11
12
  import { createRememberToolFactory } from "./tools/remember.js";
12
13
  import { createSearchToolFactory } from "./tools/search.js";
13
14
  import { registerNetworkTools } from "./graph/index.js";
@@ -73,33 +74,55 @@ export default {
73
74
  api.registerTool(createRememberToolFactory(client, cfg), {
74
75
  name: "hyperspell_remember",
75
76
  });
76
- // Register emotional context hooks.
77
- // - fetch: inject once per session on first turn (cached thereafter)
78
- // - compaction: clear cache so the next turn re-injects after trim
79
- // - session cleanup: drop Set entry on session end
80
- // - store: extract new emotional state from the finished session
77
+ const startHandlers = [];
81
78
  if (cfg.emotionalContext) {
82
- api.on("before_agent_start", buildEmotionalStateFetchHandler(client, cfg));
79
+ startHandlers.push(buildEmotionalStateFetchHandler(client, cfg));
83
80
  api.on("after_compaction", buildEmotionalStateCompactionHandler());
84
81
  api.on("session_end", buildEmotionalStateSessionCleanupHandler());
85
82
  api.on("agent_end", buildEmotionalStateStoreHandler(client, cfg));
86
83
  }
87
- // Register auto-context hook
88
84
  if (cfg.autoContext) {
89
- const autoContextHandler = buildAutoContextHandler(client, cfg);
90
- api.on("before_agent_start", autoContextHandler);
85
+ startHandlers.push(buildAutoContextHandler(client, cfg));
91
86
  }
92
- // Register startup-orientation hooks: recent-interactions + unfinished-loops
93
- // injected once per session on first turn. Lifecycle mirrors emotional-context.
94
87
  if (cfg.startupOrientation.enabled) {
95
- 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));
96
96
  api.on("after_compaction", buildStartupOrientationCompactionHandler());
97
97
  api.on("session_end", buildStartupOrientationSessionCleanupHandler());
98
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
+ }
99
116
  // Register auto-trace hook (send conversations to Hyperspell on session end)
100
117
  if (cfg.autoTrace.enabled) {
101
118
  api.on("agent_end", buildAutoTraceHandler(client, cfg));
102
119
  }
120
+ // Register hot-buffer hook: write each turn to POST /messages so it's
121
+ // instantly full-text searchable (vs. the slow /memories embedding path).
122
+ if (cfg.hotBuffer.enabled) {
123
+ api.on("agent_end", buildHotBufferHandler(client, cfg));
124
+ api.on("session_end", buildHotBufferSessionCleanupHandler());
125
+ }
103
126
  // Register memory sync hook
104
127
  if (cfg.syncMemories) {
105
128
  const fileSyncHandler = buildFileSyncHandler(client, cfg);
@@ -7,24 +7,57 @@
7
7
  * convention `buildScopeFilter` uses (`openclaw_scope`, `openclaw_user`).
8
8
  */
9
9
  /**
10
- * Memories produced by session-end hooks (auto-trace, emotional-state) are
11
- * tagged in metadata as `openclaw_source: "agent_end"` (see `sendTrace` in
12
- * client.ts). Those should NOT surface via generic retrieval: the
13
- * emotional-state hook injects them through its own dedicated path, and
14
- * replaying whole sanitized transcripts back into context creates a
15
- * self-amplifying pollution loop. Exclude them at the search filter.
10
+ * Memories produced by the auto-trace session-end hook are tagged in metadata
11
+ * as `openclaw_source: "agent_end"` (see `sendTrace` in client.ts). Those should
12
+ * NOT surface via generic retrieval — replaying whole sanitized transcripts back
13
+ * into context creates a self-amplifying pollution loop. Exclude them here.
16
14
  *
17
- * NOTE: an earlier version of this filter checked the top-level `source` field
18
- * for the value `"openclaw_agent_end"` wrong on BOTH counts (the tag lives in
19
- * metadata under `openclaw_source`, and its value is `"agent_end"`), so it
20
- * silently matched nothing and excluded no traces.
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.
30
+ *
31
+ * NOTE: an earlier version checked the top-level `source` field for
32
+ * "openclaw_agent_end" — wrong on BOTH counts (the tag lives in metadata under
33
+ * `openclaw_source`, value `"agent_end"`), so it silently matched nothing.
21
34
  */
22
35
  export const EXCLUDE_SESSION_END_FILTER = {
23
36
  openclaw_source: { $ne: "agent_end" },
24
37
  };
25
- /** Combine a caller-supplied filter with the session-end exclude via `$and`. */
26
- export function mergeWithExclude(base) {
38
+ /**
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).
44
+ *
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).
48
+ */
49
+ export function excludeFilterFor(cfg) {
50
+ return cfg.autoTrace.enabled ? EXCLUDE_SESSION_END_FILTER : undefined;
51
+ }
52
+ /**
53
+ * Combine a caller-supplied filter with the session-end exclude via `$and`.
54
+ * Returns `undefined` when neither a base filter nor an exclude applies.
55
+ */
56
+ export function mergeWithExclude(base, cfg) {
57
+ const exclude = excludeFilterFor(cfg);
58
+ if (!exclude)
59
+ return base;
27
60
  if (!base)
28
- return EXCLUDE_SESSION_END_FILTER;
29
- return { $and: [base, EXCLUDE_SESSION_END_FILTER] };
61
+ return exclude;
62
+ return { $and: [base, exclude] };
30
63
  }
@@ -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) {
@@ -11,7 +12,7 @@ export function createSearchToolFactory(client, cfg) {
11
12
  return (ctx) => ({
12
13
  name: "hyperspell_search",
13
14
  label: "Memory Search",
14
- description: "Search through the user's connected sources (Notion, Slack, Gmail, Google Drive, etc.) for relevant information.",
15
+ description: "Search the user's long-term memory and connected sources for anything not already in the current conversation. Covers: saved memories and notes; past conversations — including ones from earlier or parallel sessions you have no transcript for; and connected sources (Notion, Slack, Gmail, Google Drive, etc.). Reach for this whenever the user refers to something from before, asks what was said / decided / remembered, or when relevant context likely exists but isn't in front of you — search before concluding you don't know.",
15
16
  parameters: Type.Object({
16
17
  query: Type.String({ description: "Search query" }),
17
18
  limit: Type.Optional(Type.Number({ description: "Max results (default: 5)" })),
@@ -41,7 +42,7 @@ export function createSearchToolFactory(client, cfg) {
41
42
  }
42
43
  // Keep session-end trace memories out of agent-facing search, matching
43
44
  // the auto-context hook so both retrieval paths filter identically.
44
- filter = mergeWithExclude(filter);
45
+ filter = mergeWithExclude(filter, cfg);
45
46
  log.debug(`search tool: query="${params.query}" limit=${limit} after=${params.after ?? "none"} before=${params.before ?? "none"} userId=${userId} scope=${params.scope ?? "any"}`);
46
47
  try {
47
48
  const response = await client.searchRaw(params.query, {
@@ -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
  },
@@ -36,6 +36,11 @@
36
36
  "help": "Automatically send conversation traces to Hyperspell for memory extraction (procedural, mood) at the end of each session",
37
37
  "advanced": true
38
38
  },
39
+ "hotBuffer": {
40
+ "label": "Hot Buffer",
41
+ "help": "Write each turn to the realtime message buffer (POST /messages) so it is instantly full-text searchable, then auto-consolidated into vault Resources. Requires userId. { enabled, source, writeUser, writeAssistant }",
42
+ "advanced": true
43
+ },
39
44
  "emotionalContext": {
40
45
  "label": "Emotional Context",
41
46
  "help": "Maintain emotional continuity across sessions — remembers not just what happened, but how it felt",
@@ -116,6 +121,15 @@
116
121
  "metadata": { "type": "object" }
117
122
  }
118
123
  },
124
+ "hotBuffer": {
125
+ "type": "object",
126
+ "properties": {
127
+ "enabled": { "type": "boolean" },
128
+ "source": { "type": "string" },
129
+ "writeUser": { "type": "boolean" },
130
+ "writeAssistant": { "type": "boolean" }
131
+ }
132
+ },
119
133
  "emotionalContext": {
120
134
  "type": "boolean"
121
135
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperspell/openclaw-hyperspell",
3
- "version": "0.14.2",
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/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": [