@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 +42 -0
- package/dist/commands/setup.js +2 -0
- package/dist/config.js +17 -0
- package/dist/hooks/auto-context.js +109 -18
- package/dist/hooks/emotional-state.js +118 -11
- package/dist/hooks/hot-buffer.js +0 -0
- package/dist/hooks/startup-orientation.js +68 -2
- package/dist/index.js +29 -13
- package/dist/lib/filters.js +23 -21
- package/dist/lib/ranking.js +63 -0
- package/dist/lib/search-error.js +125 -0
- package/dist/lib/session.js +44 -0
- package/dist/tools/search.js +8 -8
- package/openclaw.plugin.json +13 -0
- package/package.json +2 -2
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`);
|
package/dist/commands/setup.js
CHANGED
|
@@ -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
|
-
|
|
55
|
-
|
|
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
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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:
|
|
77
|
-
return;
|
|
165
|
+
log.debug("auto-context: nothing cleared the bar — injecting search directive only");
|
|
78
166
|
}
|
|
79
|
-
|
|
80
|
-
return { prependContext: wrapSingle(formatted) };
|
|
167
|
+
return { prependContext: wrapContext(formatted) };
|
|
81
168
|
}
|
|
82
169
|
catch (err) {
|
|
83
|
-
|
|
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
|
|
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
|
|
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
|
|
76
|
-
|
|
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
|
|
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) {
|
package/dist/hooks/hot-buffer.js
CHANGED
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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));
|
package/dist/lib/filters.js
CHANGED
|
@@ -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
|
-
*
|
|
16
|
-
* `openclaw_source
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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
|
+
}
|
package/dist/tools/search.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
80
|
+
// Distinguish a transient backend throttle (429 / Retry-After) from a
|
|
81
|
+
// real failure and surface it explicitly, so the agent doesn't read a
|
|
82
|
+
// backend hiccup as "no memories" and confabulate around it (issue #39).
|
|
83
|
+
const info = classifySearchError(err);
|
|
84
|
+
logSearchError(log, "search tool", info, err);
|
|
80
85
|
return {
|
|
81
|
-
content: [
|
|
82
|
-
{
|
|
83
|
-
type: "text",
|
|
84
|
-
text: `Search failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
85
|
-
},
|
|
86
|
-
],
|
|
86
|
+
content: [{ type: "text", text: searchErrorToolText(info) }],
|
|
87
87
|
};
|
|
88
88
|
}
|
|
89
89
|
},
|
package/openclaw.plugin.json
CHANGED
|
@@ -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.
|
|
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": [
|