@hyperspell/openclaw-hyperspell 0.15.0 → 0.17.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`);
@@ -6,6 +6,7 @@ import * as p from "@clack/prompts";
6
6
  import Hyperspell from "hyperspell";
7
7
  import { syncAllFilesSectionized, syncAllMemoryFiles, getMemoryFiles, } from "../sync/markdown.js";
8
8
  import { openInBrowser } from "../lib/browser.js";
9
+ import { DEFAULT_RANKING } from "../lib/ranking.js";
9
10
  import { HyperspellClient } from "../client.js";
10
11
  import { getWorkspaceDir, parseConfig, resolveConfigPath } from "../config.js";
11
12
  import { buildExtractionPrompt, CRON_JOB_NAME } from "../graph/cron.js";
@@ -288,6 +289,7 @@ async function runSetup() {
288
289
  sources: [],
289
290
  maxResults: 10,
290
291
  relevanceThreshold: 0.6,
292
+ ranking: DEFAULT_RANKING,
291
293
  debug: false,
292
294
  knowledgeGraph: { enabled: false, scanIntervalMinutes: 60, batchSize: 20 },
293
295
  startupOrientation: {
package/dist/config.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
+ import { DEFAULT_RANKING } from "./lib/ranking.js";
4
5
  /**
5
6
  * Convert user-facing scope names (which may contain hyphens) to SDK-safe
6
7
  * metadata values (alphanumeric + underscore only). Must be applied at every
@@ -23,6 +24,7 @@ const ALLOWED_KEYS = [
23
24
  "sources",
24
25
  "maxResults",
25
26
  "relevanceThreshold",
27
+ "ranking",
26
28
  "debug",
27
29
  "knowledgeGraph",
28
30
  "multiUser",
@@ -58,6 +60,20 @@ function resolveEnvVars(value) {
58
60
  return envValue;
59
61
  });
60
62
  }
63
+ function parseRanking(raw) {
64
+ const r = (raw ?? {});
65
+ const num = (v, d) => (typeof v === "number" ? v : d);
66
+ return {
67
+ enabled: r.enabled ?? DEFAULT_RANKING.enabled,
68
+ curationBoost: num(r.curationBoost, DEFAULT_RANKING.curationBoost),
69
+ chatterPenalty: num(r.chatterPenalty, DEFAULT_RANKING.chatterPenalty),
70
+ storyBoost: num(r.storyBoost, DEFAULT_RANKING.storyBoost),
71
+ storyTerms: Array.isArray(r.storyTerms)
72
+ ? r.storyTerms.map((t) => String(t)).filter((t) => t.length > 0)
73
+ : DEFAULT_RANKING.storyTerms,
74
+ candidateMultiplier: Math.max(1, num(r.candidateMultiplier, DEFAULT_RANKING.candidateMultiplier)),
75
+ };
76
+ }
61
77
  function parseSources(raw) {
62
78
  if (!raw) {
63
79
  return [];
@@ -309,6 +325,7 @@ export function parseConfig(raw) {
309
325
  sources: parseSources(cfg.sources),
310
326
  maxResults: cfg.maxResults ?? 10,
311
327
  relevanceThreshold: cfg.relevanceThreshold ?? 0.6,
328
+ ranking: parseRanking(cfg.ranking),
312
329
  debug: cfg.debug ?? false,
313
330
  knowledgeGraph: {
314
331
  enabled: kgRaw.enabled ?? false,
@@ -1,5 +1,8 @@
1
1
  import { buildScopeFilter, getCanReadScopes, resolveUser, } from "../lib/sender.js";
2
2
  import { excludeFilterFor, mergeWithExclude } from "../lib/filters.js";
3
+ import { rerank } from "../lib/ranking.js";
4
+ import { classifySearchError, logSearchError } from "../lib/search-error.js";
5
+ import { resolveCurrentSessionId } from "../lib/session.js";
3
6
  import { log } from "../logger.js";
4
7
  function formatRelativeTime(isoTimestamp) {
5
8
  try {
@@ -49,43 +52,131 @@ function formatHighlightBullets(results, maxResults, threshold) {
49
52
  return null;
50
53
  return sections.join("\n\n");
51
54
  }
55
+ /**
56
+ * Like formatHighlightBullets, but for composite-RANKED results: a result is
57
+ * kept on its composite score (relevance + curation/story boost − chatter), not
58
+ * raw relevance — so a deliberately-kept memory that's quietly relevant clears
59
+ * the bar where a louder conversation echo doesn't. Highlights are floored at
60
+ * the lower of (threshold, the result's own base relevance), so we don't then
61
+ * hide the very lines that define a boosted-but-quiet memory.
62
+ */
63
+ function formatRankedBullets(ranked, maxResults, threshold) {
64
+ const sections = [];
65
+ for (const r of ranked) {
66
+ if (r._composite < threshold)
67
+ continue;
68
+ const hiFloor = Math.min(threshold, r._base);
69
+ const chosen = [...r.highlights]
70
+ .sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
71
+ .filter((h) => (h.score ?? 0) >= hiFloor)
72
+ .slice(0, 2);
73
+ if (chosen.length === 0)
74
+ continue;
75
+ const title = r.title ?? `[${r.source}]`;
76
+ const bullets = chosen
77
+ .map((h) => `- ${h.text.replace(/\n/g, " ")} [${Math.round((h.score ?? 0) * 100)}%]`)
78
+ .join("\n");
79
+ sections.push(`### ${title} (resource_id: ${r.resourceId}, source: ${r.source})\n\n${bullets}`);
80
+ if (sections.length >= maxResults)
81
+ break;
82
+ }
83
+ if (sections.length === 0)
84
+ return null;
85
+ return sections.join("\n\n");
86
+ }
52
87
  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.";
53
88
  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
- function wrapSingle(body) {
55
- return `<hyperspell-context>\n${INTRO}\n\n${body}\n\n${DISCLAIMER}\n</hyperspell-context>`;
89
+ // Injected EVERY turn (even when nothing cleared the bar): this passive match is
90
+ // a starting point, not the whole of memory. The point is to make her LOOK —
91
+ // actively search before concluding — rather than answer from whatever happened
92
+ // to surface, which is what lets an agent invent instead of recall.
93
+ const SEARCH_DIRECTIVE = "This is a passive match and may miss what matters. Before answering anything that touches your shared history, a past decision, a name, a promise, or something you may have recorded, run hyperspell_search with a specific query and look — even if something is already shown above. Don't answer from impression when you can check. If a search returns nothing, say so plainly; never fill the gap with something invented.";
94
+ /** Wrap the per-turn context: the standing search directive always, plus the
95
+ * surfaced memory when anything cleared the bar. */
96
+ function wrapContext(memorySection) {
97
+ if (memorySection) {
98
+ return `<hyperspell-context>\n${INTRO}\n\n${memorySection}\n\n${DISCLAIMER}\n\n${SEARCH_DIRECTIVE}\n</hyperspell-context>`;
99
+ }
100
+ return `<hyperspell-context>\n${SEARCH_DIRECTIVE}\n</hyperspell-context>`;
101
+ }
102
+ /**
103
+ * Drop results belonging to the CURRENT session. The hot buffer writes the live
104
+ * conversation to the vault every turn (`resourceId = sessionId`), and those
105
+ * rows are the highest-scoring matches for whatever the agent is currently
106
+ * saying — so without this the agent gets its own just-written turns injected
107
+ * back as "recalled memory," crowding out older context (issue #42).
108
+ *
109
+ * Purely subtractive: with no resolvable session id we return results unchanged,
110
+ * and a wrong id can only fail to hide the echo — never hide genuine
111
+ * cross-session memories, which live under a different `resource_id`.
112
+ */
113
+ export function dropCurrentSession(results, currentSessionId) {
114
+ if (!currentSessionId)
115
+ return results;
116
+ const kept = results.filter((r) => r.resourceId !== currentSessionId);
117
+ const dropped = results.length - kept.length;
118
+ if (dropped > 0) {
119
+ log.debug(`auto-context: excluded ${dropped} result(s) from current session ${currentSessionId}`);
120
+ }
121
+ return kept;
56
122
  }
57
123
  export function buildAutoContextHandler(client, cfg) {
58
124
  return async (event, ctx) => {
59
125
  const prompt = event.prompt;
60
126
  if (!prompt || prompt.length < 5)
61
127
  return;
128
+ // The live session's own just-written turns must not be surfaced back as
129
+ // "recalled memory" (issue #42) — resolve its id once and exclude it below.
130
+ const currentSessionId = resolveCurrentSessionId(event, ctx);
62
131
  // Multi-user path
63
132
  if (cfg.multiUser) {
64
133
  const resolved = resolveUser(ctx, cfg);
65
- return multiUserSearch(client, cfg, prompt, resolved);
134
+ return multiUserSearch(client, cfg, prompt, resolved, currentSessionId);
66
135
  }
67
136
  // Single-user path — preserves main's highlights + threshold behavior
68
137
  log.debug(`auto-context: searching for "${prompt.slice(0, 50)}..."`);
69
138
  try {
70
- const results = await client.search(prompt, {
71
- limit: cfg.maxResults,
72
- filter: excludeFilterFor(cfg),
73
- });
74
- const formatted = formatHighlightBullets(results, cfg.maxResults, cfg.relevanceThreshold);
139
+ const ranking = cfg.ranking;
140
+ // When composite ranking is on, fetch a WIDER candidate pool so quiet-but-
141
+ // true memory is present to be re-ranked, not cut off below the fetch limit.
142
+ const limit = ranking.enabled
143
+ ? cfg.maxResults * ranking.candidateMultiplier
144
+ : cfg.maxResults;
145
+ const results = dropCurrentSession(await client.search(prompt, { limit, filter: excludeFilterFor(cfg) }), currentSessionId);
146
+ let formatted;
147
+ if (ranking.enabled) {
148
+ const ranked = rerank(results, ranking);
149
+ formatted = formatRankedBullets(ranked, cfg.maxResults, cfg.relevanceThreshold);
150
+ if (formatted) {
151
+ const kept = ranked.filter((r) => r._composite >= cfg.relevanceThreshold);
152
+ const tally = kept.slice(0, cfg.maxResults).reduce((acc, r) => ((acc[r._kind] = (acc[r._kind] ?? 0) + 1), acc), {});
153
+ log.debug(`auto-context: injecting (ranked) ${JSON.stringify(tally)} from ${results.length} candidates`);
154
+ }
155
+ }
156
+ else {
157
+ formatted = formatHighlightBullets(results, cfg.maxResults, cfg.relevanceThreshold);
158
+ if (formatted)
159
+ log.debug(`auto-context: injecting ${results.length} memories`);
160
+ }
161
+ // Always inject the standing search directive so she's prompted to LOOK
162
+ // every turn — with the surfaced memory appended when anything cleared the
163
+ // bar. (#issue: passive injection alone let her answer from impression.)
75
164
  if (!formatted) {
76
- log.debug("auto-context: no relevant memories found");
77
- return;
165
+ log.debug("auto-context: nothing cleared the bar — injecting search directive only");
78
166
  }
79
- log.debug(`auto-context: injecting ${results.length} memories`);
80
- return { prependContext: wrapSingle(formatted) };
167
+ return { prependContext: wrapContext(formatted) };
81
168
  }
82
169
  catch (err) {
83
- log.error("auto-context failed", err);
170
+ // A transient backend throttle (429 / Retry-After) must not be swallowed
171
+ // as a generic failure — log it at warn, distinguished from real errors,
172
+ // so the degraded auto-context is observable (issue #39). The hook stays
173
+ // silent (no injection) either way; the agent didn't explicitly ask.
174
+ logSearchError(log, "auto-context", classifySearchError(err), err);
84
175
  return;
85
176
  }
86
177
  };
87
178
  }
88
- async function multiUserSearch(client, cfg, prompt, resolved) {
179
+ async function multiUserSearch(client, cfg, prompt, resolved, currentSessionId) {
89
180
  const multiUser = cfg.multiUser;
90
181
  const isKnownSender = !!resolved?.resolved;
91
182
  const includeShared = multiUser.includeSharedInSearch;
@@ -126,19 +217,19 @@ async function multiUserSearch(client, cfg, prompt, resolved) {
126
217
  if (personalSearch) {
127
218
  const r = settled[idx++];
128
219
  if (r.status === "fulfilled") {
129
- personalResults = r.value;
220
+ personalResults = dropCurrentSession(r.value, currentSessionId);
130
221
  }
131
222
  else {
132
- log.error("auto-context: personal search failed", r.reason);
223
+ logSearchError(log, "auto-context: personal search", classifySearchError(r.reason), r.reason);
133
224
  }
134
225
  }
135
226
  if (sharedSearch) {
136
227
  const r = settled[idx++];
137
228
  if (r.status === "fulfilled") {
138
- sharedResults = r.value;
229
+ sharedResults = dropCurrentSession(r.value, currentSessionId);
139
230
  }
140
231
  else {
141
- log.error("auto-context: shared search failed", r.reason);
232
+ logSearchError(log, "auto-context: shared search", classifySearchError(r.reason), r.reason);
142
233
  }
143
234
  }
144
235
  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,63 @@
1
+ export const DEFAULT_RANKING = {
2
+ enabled: true,
3
+ curationBoost: 0.2,
4
+ chatterPenalty: 0.2,
5
+ storyBoost: 0.15,
6
+ storyTerms: [],
7
+ candidateMultiplier: 3,
8
+ };
9
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
10
+ /** Highest available relevance for a result (doc score or its best highlight). */
11
+ export function baseScore(r) {
12
+ const topHighlight = r.highlights.reduce((m, h) => Math.max(m, h.score ?? 0), 0);
13
+ return Math.max(r.score ?? 0, topHighlight);
14
+ }
15
+ /**
16
+ * Classify a result by what KIND of memory it is — using only fields the search
17
+ * already returns (title shape + resource id), so no extra round-trip:
18
+ * - story: matches a configured story term (manuscript / its notes & threads)
19
+ * - chatter: untitled or "Unnamed Conversation" AND keyed by a bare session
20
+ * UUID — i.e. a hot-buffer conversation fragment (the dreamy echoes)
21
+ * - curated: has a real, human title and is not a raw session row — a journal,
22
+ * a writing note, a synced memory section (deliberately kept)
23
+ */
24
+ export function classifyResult(r, storyTerms) {
25
+ const title = (r.title ?? "").trim();
26
+ if (storyTerms.length > 0) {
27
+ const hay = `${title} ${r.highlights.map((h) => h.text).join(" ")}`.toLowerCase();
28
+ if (storyTerms.some((t) => t && hay.includes(t.toLowerCase())))
29
+ return "story";
30
+ }
31
+ const untitled = title === "" || /^unnamed conversation$/i.test(title);
32
+ if (untitled && UUID_RE.test(r.resourceId))
33
+ return "chatter";
34
+ if (title !== "" && !UUID_RE.test(r.resourceId))
35
+ return "curated";
36
+ return "other";
37
+ }
38
+ /** Composite score + classification for one result. */
39
+ export function scoreResult(r, w) {
40
+ const kind = classifyResult(r, w.storyTerms);
41
+ const base = baseScore(r);
42
+ let composite = base;
43
+ if (kind === "story")
44
+ composite += w.storyBoost + w.curationBoost; // the story is kept memory too
45
+ else if (kind === "curated")
46
+ composite += w.curationBoost;
47
+ else if (kind === "chatter")
48
+ composite -= w.chatterPenalty;
49
+ return { kind, base, composite };
50
+ }
51
+ /** Re-rank results by composite score (descending). Pure; stable enough. */
52
+ export function rerank(results, w) {
53
+ return results
54
+ .map((r) => {
55
+ const s = scoreResult(r, w);
56
+ return Object.assign({}, r, {
57
+ _kind: s.kind,
58
+ _base: s.base,
59
+ _composite: s.composite,
60
+ });
61
+ })
62
+ .sort((a, b) => b._composite - a._composite);
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 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
+ description: "Search the user's long-term memory and connected sources: saved memories and notes; past conversations — including earlier or parallel sessions you have no transcript for; and connected sources (Notion, Slack, Gmail, Google Drive, etc.). Use this PROACTIVELY and by default, as a routine step — not only when asked. Before answering anything that touches the user's history, a past decision, a name, a promise, or something you might have recorded, search FIRST with a specific query and look. Don't answer from impression when you can check. If a search comes back empty, say so plainly — never fill the gap with something invented.",
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)" })),
@@ -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
  },
@@ -154,6 +154,19 @@
154
154
  "minimum": 1,
155
155
  "maximum": 20
156
156
  },
157
+ "ranking": {
158
+ "type": "object",
159
+ "additionalProperties": false,
160
+ "description": "Composite retrieval ranking — rank memories by more than raw relevance so kept memory (notes, journals, the active story) surfaces above auto-saved conversation fragments.",
161
+ "properties": {
162
+ "enabled": { "type": "boolean" },
163
+ "curationBoost": { "type": "number", "minimum": 0, "maximum": 1 },
164
+ "chatterPenalty": { "type": "number", "minimum": 0, "maximum": 1 },
165
+ "storyBoost": { "type": "number", "minimum": 0, "maximum": 1 },
166
+ "storyTerms": { "type": "array", "items": { "type": "string" } },
167
+ "candidateMultiplier": { "type": "number", "minimum": 1, "maximum": 10 }
168
+ }
169
+ },
157
170
  "debug": {
158
171
  "type": "boolean"
159
172
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperspell/openclaw-hyperspell",
3
- "version": "0.15.0",
3
+ "version": "0.17.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 lib/ranking.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": [