@hyperspell/openclaw-hyperspell 0.14.2 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client.js +93 -0
- package/dist/commands/setup.js +6 -0
- package/dist/config.js +14 -0
- package/dist/hooks/auto-context.js +45 -15
- package/dist/hooks/emotional-state.js +118 -11
- package/dist/hooks/hot-buffer.js +169 -0
- package/dist/hooks/startup-orientation.js +68 -2
- package/dist/index.js +36 -13
- package/dist/lib/filters.js +47 -14
- package/dist/lib/search-error.js +125 -0
- package/dist/lib/session.js +44 -0
- package/dist/tools/search.js +9 -9
- package/openclaw.plugin.json +14 -0
- package/package.json +2 -2
package/dist/client.js
CHANGED
|
@@ -270,6 +270,62 @@ export class HyperspellClient {
|
|
|
270
270
|
});
|
|
271
271
|
return { resourceId: result.resource_id, status: result.status };
|
|
272
272
|
}
|
|
273
|
+
/**
|
|
274
|
+
* POST /messages — the real-time hot buffer. Rows are full-text searchable
|
|
275
|
+
* the instant they're inserted (a Postgres GENERATED tsvector, no embedding
|
|
276
|
+
* wait) and auto-consolidated server-side into vault Resources within ~60s.
|
|
277
|
+
* The existing search path already unions this buffer with vector results,
|
|
278
|
+
* so once we write here our turns become searchable immediately.
|
|
279
|
+
*
|
|
280
|
+
* Auth: api key + `X-As-User` (REQUIRED — the endpoint 422s without it).
|
|
281
|
+
* Upsert key is (app_id, user_id, resource_id, message_id), so re-posting an
|
|
282
|
+
* identical message_id updates `content` only — safe to retry, no duplicates.
|
|
283
|
+
*
|
|
284
|
+
* Server-side limits (return 422): per-message content 1..512,000 chars;
|
|
285
|
+
* batch 1..1,000 messages. Callers should pre-enforce these.
|
|
286
|
+
*/
|
|
287
|
+
async sendMessages(messages, options) {
|
|
288
|
+
const userId = options?.userId ?? this.config.userId;
|
|
289
|
+
if (!userId) {
|
|
290
|
+
// X-As-User is mandatory for /messages. Fail loud rather than firing a
|
|
291
|
+
// request we know will 422.
|
|
292
|
+
throw new Error("sendMessages requires a userId (X-As-User) — none configured");
|
|
293
|
+
}
|
|
294
|
+
if (messages.length === 0)
|
|
295
|
+
return { count: 0 };
|
|
296
|
+
const source = (options?.source ?? "vault").toLowerCase();
|
|
297
|
+
const metadata = options?.metadata;
|
|
298
|
+
const body = {
|
|
299
|
+
source,
|
|
300
|
+
messages: messages.map((m) => ({
|
|
301
|
+
resource_id: m.resourceId,
|
|
302
|
+
message_id: m.messageId,
|
|
303
|
+
content: m.content,
|
|
304
|
+
// Per-message metadata (Hyperspell #1921): tags hot-buffer rows so
|
|
305
|
+
// retrieval can identify/filter them like /memories/add rows. Persists
|
|
306
|
+
// on the live hot row and is unioned onto the consolidated Resource.
|
|
307
|
+
...(metadata ? { metadata } : {}),
|
|
308
|
+
})),
|
|
309
|
+
};
|
|
310
|
+
log.debugRequest("messages.create", {
|
|
311
|
+
source,
|
|
312
|
+
count: messages.length,
|
|
313
|
+
userId,
|
|
314
|
+
});
|
|
315
|
+
const res = await fetch(`${API_BASE_URL}/messages`, {
|
|
316
|
+
method: "POST",
|
|
317
|
+
headers: { ...this.rawHeaders(), "X-As-User": userId },
|
|
318
|
+
body: JSON.stringify(body),
|
|
319
|
+
});
|
|
320
|
+
if (!res.ok) {
|
|
321
|
+
const text = await res.text().catch(() => "");
|
|
322
|
+
throw new Error(`POST /messages failed (${res.status}): ${text}`);
|
|
323
|
+
}
|
|
324
|
+
const data = await res.json();
|
|
325
|
+
const count = data?.count ?? messages.length;
|
|
326
|
+
log.debugResponse("messages.create", { count });
|
|
327
|
+
return { count };
|
|
328
|
+
}
|
|
273
329
|
async listConnections(options) {
|
|
274
330
|
log.debugRequest("connections.list", { userId: options?.userId });
|
|
275
331
|
const response = await this.client.connections.list(this.requestOptions(options?.userId));
|
|
@@ -344,6 +400,43 @@ export class HyperspellClient {
|
|
|
344
400
|
log.debugResponse("emotional-state.get", { found: true, resourceId: result.resourceId });
|
|
345
401
|
return result;
|
|
346
402
|
}
|
|
403
|
+
/**
|
|
404
|
+
* The most recent emotional states (newest first), up to `limit` — for
|
|
405
|
+
* surfacing the recent ARC of how a relationship has felt, not just one
|
|
406
|
+
* snapshot. Returns `null` when the backend doesn't expose
|
|
407
|
+
* `/emotional-state/recent` yet (404) so callers can fall back to
|
|
408
|
+
* `getEmotionalState`. Other non-OK responses throw.
|
|
409
|
+
*/
|
|
410
|
+
async getRecentEmotionalStates(relationshipId, limit = 3) {
|
|
411
|
+
log.debugRequest("emotional-state.recent", { relationshipId, limit });
|
|
412
|
+
const url = new URL(`${API_BASE_URL}/emotional-state/recent`);
|
|
413
|
+
if (relationshipId)
|
|
414
|
+
url.searchParams.set("relationship_id", relationshipId);
|
|
415
|
+
url.searchParams.set("limit", String(limit));
|
|
416
|
+
const res = await fetch(url.toString(), {
|
|
417
|
+
method: "GET",
|
|
418
|
+
headers: this.rawHeaders(),
|
|
419
|
+
});
|
|
420
|
+
if (res.status === 404) {
|
|
421
|
+
// Endpoint not deployed yet — signal the caller to fall back.
|
|
422
|
+
log.debug("emotional-state.recent unavailable (404) — caller falls back to latest");
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
if (!res.ok) {
|
|
426
|
+
const text = await res.text().catch(() => "");
|
|
427
|
+
throw new Error(`GET /emotional-state/recent failed (${res.status}): ${text}`);
|
|
428
|
+
}
|
|
429
|
+
const data = (await res.json());
|
|
430
|
+
const list = (data ?? []).map((d) => ({
|
|
431
|
+
resourceId: d.resource_id,
|
|
432
|
+
summary: d.summary ?? "",
|
|
433
|
+
extractedAt: d.extracted_at ?? "",
|
|
434
|
+
sessionId: d.session_id ?? null,
|
|
435
|
+
relationshipId: d.relationship_id ?? null,
|
|
436
|
+
}));
|
|
437
|
+
log.debugResponse("emotional-state.recent", { count: list.length });
|
|
438
|
+
return list;
|
|
439
|
+
}
|
|
347
440
|
async deleteEmotionalState(relationshipId) {
|
|
348
441
|
log.debugRequest("emotional-state.delete", { relationshipId });
|
|
349
442
|
const url = new URL(`${API_BASE_URL}/emotional-state`);
|
package/dist/commands/setup.js
CHANGED
|
@@ -269,6 +269,12 @@ async function runSetup() {
|
|
|
269
269
|
userId,
|
|
270
270
|
autoContext: true,
|
|
271
271
|
autoTrace: { enabled: false, extract: ["procedure"] },
|
|
272
|
+
hotBuffer: {
|
|
273
|
+
enabled: false,
|
|
274
|
+
source: "vault",
|
|
275
|
+
writeUser: true,
|
|
276
|
+
writeAssistant: true,
|
|
277
|
+
},
|
|
272
278
|
emotionalContext: false,
|
|
273
279
|
syncMemories: true,
|
|
274
280
|
syncMemoriesConfig: {
|
package/dist/config.js
CHANGED
|
@@ -15,6 +15,7 @@ const ALLOWED_KEYS = [
|
|
|
15
15
|
"userId",
|
|
16
16
|
"autoContext",
|
|
17
17
|
"autoTrace",
|
|
18
|
+
"hotBuffer",
|
|
18
19
|
"emotionalContext",
|
|
19
20
|
"relationshipId",
|
|
20
21
|
"startupOrientation",
|
|
@@ -235,6 +236,11 @@ export function parseConfig(raw) {
|
|
|
235
236
|
const kgRaw = (cfg.knowledgeGraph ?? {});
|
|
236
237
|
const atRaw = (cfg.autoTrace ?? {});
|
|
237
238
|
const soRaw = (cfg.startupOrientation ?? {});
|
|
239
|
+
const hbRaw = (cfg.hotBuffer ?? {});
|
|
240
|
+
if (cfg.hotBuffer && typeof cfg.hotBuffer === "object" && !Array.isArray(cfg.hotBuffer)) {
|
|
241
|
+
assertAllowedKeys(hbRaw, ["enabled", "source", "writeUser", "writeAssistant"], "hyperspell.hotBuffer");
|
|
242
|
+
}
|
|
243
|
+
const hbSource = parseSources(hbRaw.source)[0] ?? "vault";
|
|
238
244
|
// syncMemories can be a boolean (legacy) or an object (new)
|
|
239
245
|
const smRaw = cfg.syncMemories;
|
|
240
246
|
const syncMemoriesEnabled = typeof smRaw === "boolean"
|
|
@@ -269,6 +275,14 @@ export function parseConfig(raw) {
|
|
|
269
275
|
],
|
|
270
276
|
metadata: atRaw.metadata,
|
|
271
277
|
},
|
|
278
|
+
hotBuffer: {
|
|
279
|
+
// Default OFF so shipping the plugin never changes existing installs'
|
|
280
|
+
// behavior; opt in per-install via plugin config.
|
|
281
|
+
enabled: hbRaw.enabled ?? false,
|
|
282
|
+
source: hbSource,
|
|
283
|
+
writeUser: hbRaw.writeUser ?? true,
|
|
284
|
+
writeAssistant: hbRaw.writeAssistant ?? true,
|
|
285
|
+
},
|
|
272
286
|
emotionalContext: cfg.emotionalContext ?? false,
|
|
273
287
|
relationshipId: cfg.relationshipId,
|
|
274
288
|
startupOrientation: {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { buildScopeFilter, getCanReadScopes, resolveUser, } from "../lib/sender.js";
|
|
2
|
-
import {
|
|
2
|
+
import { excludeFilterFor, mergeWithExclude } from "../lib/filters.js";
|
|
3
|
+
import { classifySearchError, logSearchError } from "../lib/search-error.js";
|
|
4
|
+
import { resolveCurrentSessionId } from "../lib/session.js";
|
|
3
5
|
import { log } from "../logger.js";
|
|
4
6
|
function formatRelativeTime(isoTimestamp) {
|
|
5
7
|
try {
|
|
@@ -49,28 +51,52 @@ function formatHighlightBullets(results, maxResults, threshold) {
|
|
|
49
51
|
return null;
|
|
50
52
|
return sections.join("\n\n");
|
|
51
53
|
}
|
|
52
|
-
const INTRO = "The following is
|
|
53
|
-
const DISCLAIMER = "
|
|
54
|
+
const INTRO = "The following is surfaced from the user's memory and connected sources, including past conversations. Reference it as recalled context, only when relevant to the conversation.";
|
|
55
|
+
const DISCLAIMER = "Draw on it when relevant — including indirect connections — but don't force it into every response or make assumptions beyond what's stated.";
|
|
54
56
|
function wrapSingle(body) {
|
|
55
57
|
return `<hyperspell-context>\n${INTRO}\n\n${body}\n\n${DISCLAIMER}\n</hyperspell-context>`;
|
|
56
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* Drop results belonging to the CURRENT session. The hot buffer writes the live
|
|
61
|
+
* conversation to the vault every turn (`resourceId = sessionId`), and those
|
|
62
|
+
* rows are the highest-scoring matches for whatever the agent is currently
|
|
63
|
+
* saying — so without this the agent gets its own just-written turns injected
|
|
64
|
+
* back as "recalled memory," crowding out older context (issue #42).
|
|
65
|
+
*
|
|
66
|
+
* Purely subtractive: with no resolvable session id we return results unchanged,
|
|
67
|
+
* and a wrong id can only fail to hide the echo — never hide genuine
|
|
68
|
+
* cross-session memories, which live under a different `resource_id`.
|
|
69
|
+
*/
|
|
70
|
+
export function dropCurrentSession(results, currentSessionId) {
|
|
71
|
+
if (!currentSessionId)
|
|
72
|
+
return results;
|
|
73
|
+
const kept = results.filter((r) => r.resourceId !== currentSessionId);
|
|
74
|
+
const dropped = results.length - kept.length;
|
|
75
|
+
if (dropped > 0) {
|
|
76
|
+
log.debug(`auto-context: excluded ${dropped} result(s) from current session ${currentSessionId}`);
|
|
77
|
+
}
|
|
78
|
+
return kept;
|
|
79
|
+
}
|
|
57
80
|
export function buildAutoContextHandler(client, cfg) {
|
|
58
81
|
return async (event, ctx) => {
|
|
59
82
|
const prompt = event.prompt;
|
|
60
83
|
if (!prompt || prompt.length < 5)
|
|
61
84
|
return;
|
|
85
|
+
// The live session's own just-written turns must not be surfaced back as
|
|
86
|
+
// "recalled memory" (issue #42) — resolve its id once and exclude it below.
|
|
87
|
+
const currentSessionId = resolveCurrentSessionId(event, ctx);
|
|
62
88
|
// Multi-user path
|
|
63
89
|
if (cfg.multiUser) {
|
|
64
90
|
const resolved = resolveUser(ctx, cfg);
|
|
65
|
-
return multiUserSearch(client, cfg, prompt, resolved);
|
|
91
|
+
return multiUserSearch(client, cfg, prompt, resolved, currentSessionId);
|
|
66
92
|
}
|
|
67
93
|
// Single-user path — preserves main's highlights + threshold behavior
|
|
68
94
|
log.debug(`auto-context: searching for "${prompt.slice(0, 50)}..."`);
|
|
69
95
|
try {
|
|
70
|
-
const results = await client.search(prompt, {
|
|
96
|
+
const results = dropCurrentSession(await client.search(prompt, {
|
|
71
97
|
limit: cfg.maxResults,
|
|
72
|
-
filter:
|
|
73
|
-
});
|
|
98
|
+
filter: excludeFilterFor(cfg),
|
|
99
|
+
}), currentSessionId);
|
|
74
100
|
const formatted = formatHighlightBullets(results, cfg.maxResults, cfg.relevanceThreshold);
|
|
75
101
|
if (!formatted) {
|
|
76
102
|
log.debug("auto-context: no relevant memories found");
|
|
@@ -80,12 +106,16 @@ export function buildAutoContextHandler(client, cfg) {
|
|
|
80
106
|
return { prependContext: wrapSingle(formatted) };
|
|
81
107
|
}
|
|
82
108
|
catch (err) {
|
|
83
|
-
|
|
109
|
+
// A transient backend throttle (429 / Retry-After) must not be swallowed
|
|
110
|
+
// as a generic failure — log it at warn, distinguished from real errors,
|
|
111
|
+
// so the degraded auto-context is observable (issue #39). The hook stays
|
|
112
|
+
// silent (no injection) either way; the agent didn't explicitly ask.
|
|
113
|
+
logSearchError(log, "auto-context", classifySearchError(err), err);
|
|
84
114
|
return;
|
|
85
115
|
}
|
|
86
116
|
};
|
|
87
117
|
}
|
|
88
|
-
async function multiUserSearch(client, cfg, prompt, resolved) {
|
|
118
|
+
async function multiUserSearch(client, cfg, prompt, resolved, currentSessionId) {
|
|
89
119
|
const multiUser = cfg.multiUser;
|
|
90
120
|
const isKnownSender = !!resolved?.resolved;
|
|
91
121
|
const includeShared = multiUser.includeSharedInSearch;
|
|
@@ -104,7 +134,7 @@ async function multiUserSearch(client, cfg, prompt, resolved) {
|
|
|
104
134
|
? client.search(prompt, {
|
|
105
135
|
limit: cfg.maxResults,
|
|
106
136
|
userId: resolved.userId,
|
|
107
|
-
filter:
|
|
137
|
+
filter: excludeFilterFor(cfg),
|
|
108
138
|
})
|
|
109
139
|
: null;
|
|
110
140
|
// Always search shared for unknown senders, even if includeSharedInSearch is false
|
|
@@ -115,7 +145,7 @@ async function multiUserSearch(client, cfg, prompt, resolved) {
|
|
|
115
145
|
? client.search(prompt, {
|
|
116
146
|
limit: sharedLimit,
|
|
117
147
|
userId: multiUser.sharedUserId,
|
|
118
|
-
filter: mergeWithExclude(scopeFilter),
|
|
148
|
+
filter: mergeWithExclude(scopeFilter, cfg),
|
|
119
149
|
})
|
|
120
150
|
: null;
|
|
121
151
|
const searches = [personalSearch, sharedSearch].filter(Boolean);
|
|
@@ -126,19 +156,19 @@ async function multiUserSearch(client, cfg, prompt, resolved) {
|
|
|
126
156
|
if (personalSearch) {
|
|
127
157
|
const r = settled[idx++];
|
|
128
158
|
if (r.status === "fulfilled") {
|
|
129
|
-
personalResults = r.value;
|
|
159
|
+
personalResults = dropCurrentSession(r.value, currentSessionId);
|
|
130
160
|
}
|
|
131
161
|
else {
|
|
132
|
-
log
|
|
162
|
+
logSearchError(log, "auto-context: personal search", classifySearchError(r.reason), r.reason);
|
|
133
163
|
}
|
|
134
164
|
}
|
|
135
165
|
if (sharedSearch) {
|
|
136
166
|
const r = settled[idx++];
|
|
137
167
|
if (r.status === "fulfilled") {
|
|
138
|
-
sharedResults = r.value;
|
|
168
|
+
sharedResults = dropCurrentSession(r.value, currentSessionId);
|
|
139
169
|
}
|
|
140
170
|
else {
|
|
141
|
-
log
|
|
171
|
+
logSearchError(log, "auto-context: shared search", classifySearchError(r.reason), r.reason);
|
|
142
172
|
}
|
|
143
173
|
}
|
|
144
174
|
const sections = [];
|
|
@@ -1,7 +1,26 @@
|
|
|
1
1
|
import { log } from "../logger.js";
|
|
2
2
|
import { sanitizeTraceText } from "./auto-trace.js";
|
|
3
|
+
/** How many recent registers to surface as the "arc" at session start. */
|
|
4
|
+
const EMOTIONAL_ARC_LIMIT = 3;
|
|
3
5
|
const MIN_MESSAGES = 3;
|
|
4
6
|
const MIN_CONVERSATION_LENGTH = 100;
|
|
7
|
+
/**
|
|
8
|
+
* Only REAL human conversations should shape her emotional register. Automated
|
|
9
|
+
* runs — cron check-ins, heartbeats, internal memory passes — are not "how the
|
|
10
|
+
* relationship feels"; counting them lets a throwaway "how's your afternoon?"
|
|
11
|
+
* heartbeat overwrite the register from a deep conversation (the whipsaw).
|
|
12
|
+
* `ctx.trigger` is one of cron|heartbeat|manual|memory|overflow|user; we store
|
|
13
|
+
* only for user-driven turns (and `overflow`, a continuation of a user run).
|
|
14
|
+
*/
|
|
15
|
+
const NON_CONVERSATIONAL_TRIGGERS = new Set(["cron", "heartbeat", "memory"]);
|
|
16
|
+
/**
|
|
17
|
+
* Debounce window: don't re-extract the register on every turn of an active
|
|
18
|
+
* conversation (each store is a backend LLM call, and the latest already carries
|
|
19
|
+
* the full transcript). One snapshot per ~few minutes of real talk is plenty.
|
|
20
|
+
*/
|
|
21
|
+
const STORE_DEBOUNCE_MS = 3 * 60 * 1000;
|
|
22
|
+
/** relationshipId → last successful store time (ms). Module-scoped, per process. */
|
|
23
|
+
const lastStoreAt = new Map();
|
|
5
24
|
/**
|
|
6
25
|
* Sessions where emotional context has already been injected this run.
|
|
7
26
|
* Emotional state doesn't change within a session (it's extracted at
|
|
@@ -58,6 +77,75 @@ function messagesToTranscript(messages) {
|
|
|
58
77
|
}
|
|
59
78
|
return lines.join("\n");
|
|
60
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* True if a fetched emotional-state `summary` is actually the raw transcript
|
|
82
|
+
* placeholder returned during the async extraction window (status=pending),
|
|
83
|
+
* rather than a distilled emotional register. Real summaries are second-person
|
|
84
|
+
* prose ("Your relationship with this user…"); the placeholder echoes the input
|
|
85
|
+
* conversation, which has role-prefixed lines.
|
|
86
|
+
*/
|
|
87
|
+
export function looksLikeRawTranscript(summary) {
|
|
88
|
+
return /(^|\n)\s*(user|assistant)\s*:/i.test(summary);
|
|
89
|
+
}
|
|
90
|
+
/** Compact relative time for the arc labels (e.g. "just now", "3h ago", "2d ago"). */
|
|
91
|
+
function relativeWhen(iso) {
|
|
92
|
+
if (!iso)
|
|
93
|
+
return "";
|
|
94
|
+
try {
|
|
95
|
+
const mins = (Date.now() - new Date(iso).getTime()) / 60000;
|
|
96
|
+
if (Number.isNaN(mins))
|
|
97
|
+
return "";
|
|
98
|
+
if (mins < 2)
|
|
99
|
+
return "just now";
|
|
100
|
+
if (mins < 60)
|
|
101
|
+
return `${Math.floor(mins)}m ago`;
|
|
102
|
+
const hrs = mins / 60;
|
|
103
|
+
if (hrs < 24)
|
|
104
|
+
return `${Math.floor(hrs)}h ago`;
|
|
105
|
+
const days = hrs / 24;
|
|
106
|
+
if (days < 7)
|
|
107
|
+
return `${Math.floor(days)}d ago`;
|
|
108
|
+
return new Date(iso).toLocaleDateString("en", { month: "short", day: "numeric" });
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return "";
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Prefer the recent ARC (last N registers) so a single shallow read can't
|
|
116
|
+
* misrepresent the relationship. Falls back to the single latest when the
|
|
117
|
+
* backend doesn't expose `/emotional-state/recent` yet (returns null) or errors
|
|
118
|
+
* — so this works before AND after that endpoint deploys.
|
|
119
|
+
*/
|
|
120
|
+
async function fetchRecentOrLatest(client, cfg) {
|
|
121
|
+
try {
|
|
122
|
+
const recent = await client.getRecentEmotionalStates(cfg.relationshipId, EMOTIONAL_ARC_LIMIT);
|
|
123
|
+
if (recent !== null)
|
|
124
|
+
return recent; // endpoint available (may be empty)
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
log.debug("emotional-context: /recent unavailable — falling back to latest", err);
|
|
128
|
+
}
|
|
129
|
+
const single = await client.getEmotionalState(cfg.relationshipId);
|
|
130
|
+
return single ? [single] : [];
|
|
131
|
+
}
|
|
132
|
+
/** Build the injected emotional-context block from one or more registers. */
|
|
133
|
+
function buildEmotionalContext(states) {
|
|
134
|
+
const intro = states.length > 1
|
|
135
|
+
? "How your relationship with this user has felt across your recent conversations, most recent first. Let the trajectory inform your tone — don't reference it explicitly."
|
|
136
|
+
: "The emotional register of your relationship with this user from your last interaction. Let it inform your tone — don't reference it explicitly.";
|
|
137
|
+
const lines = states.map((s) => {
|
|
138
|
+
const when = relativeWhen(s.extractedAt);
|
|
139
|
+
return when ? `- [${when}] ${s.summary}` : `- ${s.summary}`;
|
|
140
|
+
});
|
|
141
|
+
return [
|
|
142
|
+
"<hyperspell-emotional-context>",
|
|
143
|
+
intro,
|
|
144
|
+
"",
|
|
145
|
+
...lines,
|
|
146
|
+
"</hyperspell-emotional-context>",
|
|
147
|
+
].join("\n");
|
|
148
|
+
}
|
|
61
149
|
/**
|
|
62
150
|
* Fetch emotional state on the first agent turn of a session and inject into
|
|
63
151
|
* context. On later turns of the same session, return undefined — the
|
|
@@ -72,21 +160,25 @@ export function buildEmotionalStateFetchHandler(client, cfg) {
|
|
|
72
160
|
return;
|
|
73
161
|
}
|
|
74
162
|
try {
|
|
75
|
-
const
|
|
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) {
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { resolveUser } from "../lib/sender.js";
|
|
2
|
+
import { log } from "../logger.js";
|
|
3
|
+
import { sanitizeTraceText } from "./auto-trace.js";
|
|
4
|
+
/** Server-side limits from POST /messages (return 422 if exceeded). */
|
|
5
|
+
const MAX_CONTENT_CHARS = 512_000;
|
|
6
|
+
const MAX_BATCH = 1_000;
|
|
7
|
+
const MAX_TOTAL_CHARS = 5_242_880;
|
|
8
|
+
/**
|
|
9
|
+
* Per-session set of message_ids already written to the hot buffer this run.
|
|
10
|
+
* `agent_end` fires once per turn with the *full* session history, so without
|
|
11
|
+
* this we'd re-post every prior message each turn. The server upserts (so it's
|
|
12
|
+
* harmless correctness-wise), but re-posting is wasteful — this keeps each turn
|
|
13
|
+
* to just its new messages. Cleared on session_end.
|
|
14
|
+
*/
|
|
15
|
+
const sentBySession = new Map();
|
|
16
|
+
/**
|
|
17
|
+
* Flatten a message's content into a single sanitized text string. Mirrors the
|
|
18
|
+
* auto-trace sanitizer so the hot buffer never captures injected
|
|
19
|
+
* <hyperspell-context>/emotional/orientation wrappers (which would otherwise be
|
|
20
|
+
* consolidated as "memory" and pollute future retrieval).
|
|
21
|
+
*/
|
|
22
|
+
function extractText(content) {
|
|
23
|
+
let raw;
|
|
24
|
+
if (typeof content === "string") {
|
|
25
|
+
raw = content;
|
|
26
|
+
}
|
|
27
|
+
else if (Array.isArray(content)) {
|
|
28
|
+
raw = content
|
|
29
|
+
.filter((i) => i?.type === "text" && typeof i.text === "string")
|
|
30
|
+
.map((i) => i.text)
|
|
31
|
+
.join("\n");
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
raw = "";
|
|
35
|
+
}
|
|
36
|
+
return sanitizeTraceText(raw);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Deterministic, stable id for a message so retries upsert rather than
|
|
40
|
+
* duplicate. FNV-1a over role+text; prefixed with the role initial to keep
|
|
41
|
+
* user/assistant lines distinct even when content collides.
|
|
42
|
+
*/
|
|
43
|
+
function messageId(role, text) {
|
|
44
|
+
let h = 0x811c9dc5;
|
|
45
|
+
const s = `${role} ${text}`;
|
|
46
|
+
for (let i = 0; i < s.length; i++) {
|
|
47
|
+
h ^= s.charCodeAt(i);
|
|
48
|
+
h = Math.imul(h, 0x01000193);
|
|
49
|
+
}
|
|
50
|
+
return `${role[0] ?? "x"}-${(h >>> 0).toString(16).padStart(8, "0")}`;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Write each completed turn to the Hyperspell hot buffer so it's instantly
|
|
54
|
+
* searchable. Runs on `agent_end` — fire-and-forget; a failure here must never
|
|
55
|
+
* break the turn.
|
|
56
|
+
*/
|
|
57
|
+
export function buildHotBufferHandler(client, cfg) {
|
|
58
|
+
return async (event, ctx) => {
|
|
59
|
+
if (event.success === false) {
|
|
60
|
+
log.debug("hot-buffer: skipping — agent ended with error");
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const messages = event.messages;
|
|
64
|
+
if (!messages || messages.length === 0)
|
|
65
|
+
return;
|
|
66
|
+
// X-As-User is mandatory for /messages. Resolve the owner up front and
|
|
67
|
+
// skip (with a clear warning) rather than firing requests that 422.
|
|
68
|
+
const userId = resolveUser(ctx, cfg)?.userId;
|
|
69
|
+
if (!userId) {
|
|
70
|
+
log.warn("hot-buffer: no userId resolved (X-As-User required) — skipping write");
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
// The session id lives on the hook CONTEXT, not the agent_end event
|
|
74
|
+
// (PluginHookAgentEndEvent = { messages, success, error?, durationMs? };
|
|
75
|
+
// PluginHookAgentContext carries sessionId). Reading event.sessionId
|
|
76
|
+
// always yielded undefined → a fresh random resourceId every turn, which
|
|
77
|
+
// (a) defeated the sentBySession dedup so each turn re-posted the entire
|
|
78
|
+
// growing transcript, and (b) scattered a session’s turns across
|
|
79
|
+
// per-turn resources, so nothing could exclude "the current session"
|
|
80
|
+
// (issue #42). Prefer ctx.sessionId; keep event/random as fallbacks.
|
|
81
|
+
const sessionId = ctx?.sessionId ??
|
|
82
|
+
event.sessionId ??
|
|
83
|
+
crypto.randomUUID();
|
|
84
|
+
const resourceId = sessionId;
|
|
85
|
+
const sent = sentBySession.get(sessionId) ?? new Set();
|
|
86
|
+
const pending = [];
|
|
87
|
+
const pendingIds = [];
|
|
88
|
+
for (const m of messages) {
|
|
89
|
+
const role = m.role;
|
|
90
|
+
if (role === "user") {
|
|
91
|
+
if (!cfg.hotBuffer.writeUser)
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
else if (role === "assistant") {
|
|
95
|
+
if (!cfg.hotBuffer.writeAssistant)
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
// Skip system / tool / toolResult — only conversational turns.
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
let text = extractText(m.content);
|
|
103
|
+
if (text.length === 0)
|
|
104
|
+
continue;
|
|
105
|
+
if (text.length > MAX_CONTENT_CHARS) {
|
|
106
|
+
log.warn(`hot-buffer: truncating ${role} message ${text.length} -> ${MAX_CONTENT_CHARS} chars`);
|
|
107
|
+
text = text.slice(0, MAX_CONTENT_CHARS);
|
|
108
|
+
}
|
|
109
|
+
const id = messageId(role, text);
|
|
110
|
+
if (sent.has(id))
|
|
111
|
+
continue;
|
|
112
|
+
pending.push({ resourceId, messageId: id, content: text });
|
|
113
|
+
pendingIds.push(id);
|
|
114
|
+
}
|
|
115
|
+
if (pending.length === 0)
|
|
116
|
+
return;
|
|
117
|
+
// Chunk by count AND cumulative content size to stay under the 422 limits.
|
|
118
|
+
const batches = [];
|
|
119
|
+
let batch = [];
|
|
120
|
+
let batchChars = 0;
|
|
121
|
+
for (const item of pending) {
|
|
122
|
+
if (batch.length >= MAX_BATCH ||
|
|
123
|
+
(batch.length > 0 && batchChars + item.content.length > MAX_TOTAL_CHARS)) {
|
|
124
|
+
batches.push(batch);
|
|
125
|
+
batch = [];
|
|
126
|
+
batchChars = 0;
|
|
127
|
+
}
|
|
128
|
+
batch.push(item);
|
|
129
|
+
batchChars += item.content.length;
|
|
130
|
+
}
|
|
131
|
+
if (batch.length > 0)
|
|
132
|
+
batches.push(batch);
|
|
133
|
+
try {
|
|
134
|
+
let total = 0;
|
|
135
|
+
for (const b of batches) {
|
|
136
|
+
// Do NOT tag hot rows with metadata: a POST /messages write that
|
|
137
|
+
// carries `metadata` is accepted (200) but the row never becomes
|
|
138
|
+
// retrievable (verified live, post-Hyperspell #1921) — tagging
|
|
139
|
+
// silently breaks hot-buffer recall. The tag isn't needed anyway:
|
|
140
|
+
// untagged rows survive the unconditional {$ne:"agent_end"} exclude
|
|
141
|
+
// (absent-field semantics, #1921) AND are full-text searchable. So
|
|
142
|
+
// we write content only. (Backend follow-up: make /messages metadata
|
|
143
|
+
// not suppress indexing, then this can be reinstated.)
|
|
144
|
+
const result = await client.sendMessages(b, {
|
|
145
|
+
userId,
|
|
146
|
+
source: cfg.hotBuffer.source,
|
|
147
|
+
});
|
|
148
|
+
total += result.count;
|
|
149
|
+
}
|
|
150
|
+
// Only mark as sent after a successful write so a failure retries next turn.
|
|
151
|
+
for (const id of pendingIds)
|
|
152
|
+
sent.add(id);
|
|
153
|
+
sentBySession.set(sessionId, sent);
|
|
154
|
+
log.info(`hot-buffer: wrote ${total} message(s) to ${sessionId} (user=${userId})`);
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
// Fire-and-forget — never break the turn.
|
|
158
|
+
log.error("hot-buffer write failed", err);
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
/** Drop the per-session sent-set on session end to avoid unbounded growth. */
|
|
163
|
+
export function buildHotBufferSessionCleanupHandler() {
|
|
164
|
+
return (event) => {
|
|
165
|
+
const sessionId = event.sessionId;
|
|
166
|
+
if (sessionId)
|
|
167
|
+
sentBySession.delete(sessionId);
|
|
168
|
+
};
|
|
169
|
+
}
|
|
@@ -2,6 +2,8 @@ import { resolveUser } from "../lib/sender.js";
|
|
|
2
2
|
import { log } from "../logger.js";
|
|
3
3
|
const MAX_ATTEMPTS = 2;
|
|
4
4
|
const RECENT_BUFFER_LIMIT = 100;
|
|
5
|
+
/** Hot-buffer conversation resources are keyed by the session id (a UUID). */
|
|
6
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
5
7
|
/**
|
|
6
8
|
* Sessions where the orientation block was already injected (or where we
|
|
7
9
|
* deliberately decided not to inject — unknown sender, exhausted retries).
|
|
@@ -134,6 +136,59 @@ async function fetchRecentTraces(client, cutoff, limit, userId) {
|
|
|
134
136
|
});
|
|
135
137
|
return buffer.slice(0, limit);
|
|
136
138
|
}
|
|
139
|
+
/**
|
|
140
|
+
* Pull recent conversation sessions from the hot buffer's vault resources — the
|
|
141
|
+
* modern source of "recent interactions" (the agent_end-trace path only works
|
|
142
|
+
* when auto-trace is on, which it usually isn't). The hot buffer writes one
|
|
143
|
+
* session-grouped Resource per conversation: `resource_id` = the session id (a
|
|
144
|
+
* UUID), untagged (no `openclaw_source`), with a generated title. We rely on
|
|
145
|
+
* `listMemories` returning resources newest-first (verified live) and take the
|
|
146
|
+
* newest `limit` conversation resources — their metadata carries no date to
|
|
147
|
+
* filter on, so order is our recency signal.
|
|
148
|
+
*
|
|
149
|
+
* Excludes: tagged rows (memory_sync_section / command / agent_end), non-UUID
|
|
150
|
+
* resources (synced docs), automated cron sessions, and untitled rows.
|
|
151
|
+
*/
|
|
152
|
+
async function fetchRecentConversations(client, limit, userId) {
|
|
153
|
+
const out = [];
|
|
154
|
+
const seenTitles = new Set();
|
|
155
|
+
let scanned = 0;
|
|
156
|
+
for await (const memory of client.listMemories({
|
|
157
|
+
source: "vault",
|
|
158
|
+
userId,
|
|
159
|
+
pageSize: 50,
|
|
160
|
+
})) {
|
|
161
|
+
scanned++;
|
|
162
|
+
if (scanned > RECENT_BUFFER_LIMIT)
|
|
163
|
+
break;
|
|
164
|
+
if (!UUID_RE.test(memory.resourceId))
|
|
165
|
+
continue;
|
|
166
|
+
if (memory.metadata?.openclaw_source)
|
|
167
|
+
continue;
|
|
168
|
+
const title = memory.title ?? "";
|
|
169
|
+
if (title.length === 0 || /^\[cron:/i.test(title))
|
|
170
|
+
continue;
|
|
171
|
+
// The backend sometimes generates the same title for distinct sessions
|
|
172
|
+
// (e.g. repeated daily-summary chats); collapse those so the block isn't
|
|
173
|
+
// padded with duplicates.
|
|
174
|
+
const titleKey = title.trim().toLowerCase();
|
|
175
|
+
if (seenTitles.has(titleKey))
|
|
176
|
+
continue;
|
|
177
|
+
seenTitles.add(titleKey);
|
|
178
|
+
out.push({
|
|
179
|
+
resourceId: memory.resourceId,
|
|
180
|
+
title: memory.title,
|
|
181
|
+
source: memory.source,
|
|
182
|
+
score: null,
|
|
183
|
+
url: null,
|
|
184
|
+
createdAt: null,
|
|
185
|
+
highlights: [],
|
|
186
|
+
});
|
|
187
|
+
if (out.length >= limit)
|
|
188
|
+
break; // newest-first → first N are most recent
|
|
189
|
+
}
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
137
192
|
export function buildStartupOrientationHandler(client, cfg) {
|
|
138
193
|
const so = cfg.startupOrientation;
|
|
139
194
|
return async (_event, ctx) => {
|
|
@@ -156,9 +211,20 @@ export function buildStartupOrientationHandler(client, cfg) {
|
|
|
156
211
|
injectedSessions.add(sessionKey);
|
|
157
212
|
return;
|
|
158
213
|
}
|
|
159
|
-
|
|
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
|
@@ -6,8 +6,9 @@ import { buildAutoContextHandler } from "./hooks/auto-context.js";
|
|
|
6
6
|
import { buildAutoTraceHandler } from "./hooks/auto-trace.js";
|
|
7
7
|
import { buildEmotionalStateCompactionHandler, buildEmotionalStateFetchHandler, buildEmotionalStateSessionCleanupHandler, buildEmotionalStateStoreHandler, } from "./hooks/emotional-state.js";
|
|
8
8
|
import { buildFileSyncHandler, syncMemoriesOnStartup } from "./hooks/memory-sync.js";
|
|
9
|
+
import { buildHotBufferHandler, buildHotBufferSessionCleanupHandler, } from "./hooks/hot-buffer.js";
|
|
9
10
|
import { buildStartupOrientationCompactionHandler, buildStartupOrientationHandler, buildStartupOrientationSessionCleanupHandler, } from "./hooks/startup-orientation.js";
|
|
10
|
-
import { initLogger } from "./logger.js";
|
|
11
|
+
import { initLogger, log } from "./logger.js";
|
|
11
12
|
import { createRememberToolFactory } from "./tools/remember.js";
|
|
12
13
|
import { createSearchToolFactory } from "./tools/search.js";
|
|
13
14
|
import { registerNetworkTools } from "./graph/index.js";
|
|
@@ -73,33 +74,55 @@ export default {
|
|
|
73
74
|
api.registerTool(createRememberToolFactory(client, cfg), {
|
|
74
75
|
name: "hyperspell_remember",
|
|
75
76
|
});
|
|
76
|
-
|
|
77
|
-
// - fetch: inject once per session on first turn (cached thereafter)
|
|
78
|
-
// - compaction: clear cache so the next turn re-injects after trim
|
|
79
|
-
// - session cleanup: drop Set entry on session end
|
|
80
|
-
// - store: extract new emotional state from the finished session
|
|
77
|
+
const startHandlers = [];
|
|
81
78
|
if (cfg.emotionalContext) {
|
|
82
|
-
|
|
79
|
+
startHandlers.push(buildEmotionalStateFetchHandler(client, cfg));
|
|
83
80
|
api.on("after_compaction", buildEmotionalStateCompactionHandler());
|
|
84
81
|
api.on("session_end", buildEmotionalStateSessionCleanupHandler());
|
|
85
82
|
api.on("agent_end", buildEmotionalStateStoreHandler(client, cfg));
|
|
86
83
|
}
|
|
87
|
-
// Register auto-context hook
|
|
88
84
|
if (cfg.autoContext) {
|
|
89
|
-
|
|
90
|
-
api.on("before_agent_start", autoContextHandler);
|
|
85
|
+
startHandlers.push(buildAutoContextHandler(client, cfg));
|
|
91
86
|
}
|
|
92
|
-
// Register startup-orientation hooks: recent-interactions + unfinished-loops
|
|
93
|
-
// injected once per session on first turn. Lifecycle mirrors emotional-context.
|
|
94
87
|
if (cfg.startupOrientation.enabled) {
|
|
95
|
-
|
|
88
|
+
// recent-interactions reads conversation sessions from the hot buffer
|
|
89
|
+
// when it's on, else falls back to auto-trace's agent_end traces. With
|
|
90
|
+
// BOTH off there's no source to read — warn so the operator knows the
|
|
91
|
+
// block will be empty rather than wondering why there's no continuity.
|
|
92
|
+
if (!cfg.hotBuffer.enabled && !cfg.autoTrace.enabled) {
|
|
93
|
+
log.warn("startup-orientation is enabled but neither hotBuffer nor autoTrace is on — recent-interactions will be empty (no conversation source to read).");
|
|
94
|
+
}
|
|
95
|
+
startHandlers.push(buildStartupOrientationHandler(client, cfg));
|
|
96
96
|
api.on("after_compaction", buildStartupOrientationCompactionHandler());
|
|
97
97
|
api.on("session_end", buildStartupOrientationSessionCleanupHandler());
|
|
98
98
|
}
|
|
99
|
+
if (startHandlers.length > 0) {
|
|
100
|
+
api.on("before_agent_start", async (event, ctx) => {
|
|
101
|
+
const results = await Promise.all(startHandlers.map((h) => Promise.resolve()
|
|
102
|
+
.then(() => h(event, ctx))
|
|
103
|
+
.catch((err) => {
|
|
104
|
+
// One injector failing must not break the others or the turn.
|
|
105
|
+
log.error("session-start handler failed", err);
|
|
106
|
+
return undefined;
|
|
107
|
+
})));
|
|
108
|
+
const parts = results
|
|
109
|
+
.map((r) => r?.prependContext)
|
|
110
|
+
.filter((p) => typeof p === "string" && p.length > 0);
|
|
111
|
+
return parts.length > 0
|
|
112
|
+
? { prependContext: parts.join("\n\n") }
|
|
113
|
+
: undefined;
|
|
114
|
+
});
|
|
115
|
+
}
|
|
99
116
|
// Register auto-trace hook (send conversations to Hyperspell on session end)
|
|
100
117
|
if (cfg.autoTrace.enabled) {
|
|
101
118
|
api.on("agent_end", buildAutoTraceHandler(client, cfg));
|
|
102
119
|
}
|
|
120
|
+
// Register hot-buffer hook: write each turn to POST /messages so it's
|
|
121
|
+
// instantly full-text searchable (vs. the slow /memories embedding path).
|
|
122
|
+
if (cfg.hotBuffer.enabled) {
|
|
123
|
+
api.on("agent_end", buildHotBufferHandler(client, cfg));
|
|
124
|
+
api.on("session_end", buildHotBufferSessionCleanupHandler());
|
|
125
|
+
}
|
|
103
126
|
// Register memory sync hook
|
|
104
127
|
if (cfg.syncMemories) {
|
|
105
128
|
const fileSyncHandler = buildFileSyncHandler(client, cfg);
|
package/dist/lib/filters.js
CHANGED
|
@@ -7,24 +7,57 @@
|
|
|
7
7
|
* convention `buildScopeFilter` uses (`openclaw_scope`, `openclaw_user`).
|
|
8
8
|
*/
|
|
9
9
|
/**
|
|
10
|
-
* Memories produced by
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* replaying whole sanitized transcripts back into context creates a
|
|
15
|
-
* self-amplifying pollution loop. Exclude them at the search filter.
|
|
10
|
+
* Memories produced by the auto-trace session-end hook are tagged in metadata
|
|
11
|
+
* as `openclaw_source: "agent_end"` (see `sendTrace` in client.ts). Those should
|
|
12
|
+
* NOT surface via generic retrieval — replaying whole sanitized transcripts back
|
|
13
|
+
* into context creates a self-amplifying pollution loop. Exclude them here.
|
|
16
14
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
15
|
+
* Since backend Hyperspell #1921, `$ne` follows MongoDB absent-field semantics:
|
|
16
|
+
* it KEEPS rows whose `openclaw_source` is absent (untagged hot-buffer rows) and
|
|
17
|
+
* drops only `agent_end`. So applying this filter is now SAFE for hot rows — the
|
|
18
|
+
* old #40 tension (where `NULL != 'agent_end'` dropped untagged rows) is resolved
|
|
19
|
+
* at the backend.
|
|
20
|
+
*
|
|
21
|
+
* We nonetheless GATE the filter on auto-trace (see `excludeFilterFor`) for
|
|
22
|
+
* PERFORMANCE, not correctness: a `{$ne}` predicate measurably slows the vector
|
|
23
|
+
* search (~1s observed live), and when auto-trace is off there are no `agent_end`
|
|
24
|
+
* rows to hide, so the filter would cost latency on every turn for zero benefit.
|
|
25
|
+
*
|
|
26
|
+
* Do NOT instead try to positively tag hot rows via `POST /messages` metadata to
|
|
27
|
+
* identify them: a `/messages` write carrying `metadata` is accepted (200) but
|
|
28
|
+
* the row becomes NON-retrievable (verified live, post-#1921) — see the
|
|
29
|
+
* hot-buffer hook, which writes content only.
|
|
30
|
+
*
|
|
31
|
+
* NOTE: an earlier version checked the top-level `source` field for
|
|
32
|
+
* "openclaw_agent_end" — wrong on BOTH counts (the tag lives in metadata under
|
|
33
|
+
* `openclaw_source`, value `"agent_end"`), so it silently matched nothing.
|
|
21
34
|
*/
|
|
22
35
|
export const EXCLUDE_SESSION_END_FILTER = {
|
|
23
36
|
openclaw_source: { $ne: "agent_end" },
|
|
24
37
|
};
|
|
25
|
-
/**
|
|
26
|
-
|
|
38
|
+
/**
|
|
39
|
+
* The exclude clause to apply for a given config — or `undefined` to skip the
|
|
40
|
+
* filter. `agent_end` rows are written ONLY by the auto-trace hook, so when
|
|
41
|
+
* auto-trace is OFF there are none to hide and we skip the filter to avoid its
|
|
42
|
+
* ~1s/search latency cost (pure overhead otherwise — verified live against an
|
|
43
|
+
* auto-trace-off agent with zero `agent_end` rows).
|
|
44
|
+
*
|
|
45
|
+
* When auto-trace is ON we apply `{$ne:"agent_end"}`, which post-#1921 drops the
|
|
46
|
+
* traces while KEEPING untagged hot-buffer rows — so that path is correct now
|
|
47
|
+
* (the old #40 hot-row-drop is fixed by the backend, not by gating).
|
|
48
|
+
*/
|
|
49
|
+
export function excludeFilterFor(cfg) {
|
|
50
|
+
return cfg.autoTrace.enabled ? EXCLUDE_SESSION_END_FILTER : undefined;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Combine a caller-supplied filter with the session-end exclude via `$and`.
|
|
54
|
+
* Returns `undefined` when neither a base filter nor an exclude applies.
|
|
55
|
+
*/
|
|
56
|
+
export function mergeWithExclude(base, cfg) {
|
|
57
|
+
const exclude = excludeFilterFor(cfg);
|
|
58
|
+
if (!exclude)
|
|
59
|
+
return base;
|
|
27
60
|
if (!base)
|
|
28
|
-
return
|
|
29
|
-
return { $and: [base,
|
|
61
|
+
return exclude;
|
|
62
|
+
return { $and: [base, exclude] };
|
|
30
63
|
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared classification of read-path (search/retrieval) failures, used by every
|
|
3
|
+
* retrieval path (the `hyperspell_search` tool + the auto-context hook) so both
|
|
4
|
+
* surface a transient backend throttle the same way — mirroring how
|
|
5
|
+
* `lib/filters.ts` keeps filtering consistent across them.
|
|
6
|
+
*
|
|
7
|
+
* Why this exists (issue #39): under load the Hyperspell backend sheds requests
|
|
8
|
+
* with a `Retry-After` header advertising a multi-second cooldown (~55s observed)
|
|
9
|
+
* — surfaced as a 429 and, when shedding hard, attached to 5xx responses too.
|
|
10
|
+
* The `hyperspell` SDK already retries 429/5xx with bounded exponential backoff
|
|
11
|
+
* (maxRetries=2), but no in-turn backoff can ride out a ~55s cooldown, so the
|
|
12
|
+
* call still throws. The bug is what happens NEXT: the read path collapsed the
|
|
13
|
+
* error to "Search failed" / injected nothing, so a session reads it as "no
|
|
14
|
+
* memories" and confabulates around an empty result.
|
|
15
|
+
*
|
|
16
|
+
* The fix is to DISTINGUISH a throttle (429 / `Retry-After`) from a generic
|
|
17
|
+
* transient 5xx and a permanent 4xx, surface it EXPLICITLY to the agent, and log
|
|
18
|
+
* it at `warn` with the cooldown so the throttle is observable. We deliberately
|
|
19
|
+
* do NOT add our own retry — the SDK already does, and a longer in-turn sleep
|
|
20
|
+
* would just hang the turn.
|
|
21
|
+
*/
|
|
22
|
+
/** Pull a numeric HTTP status off an unknown thrown value (SDK `APIError.status`). */
|
|
23
|
+
function statusOf(err) {
|
|
24
|
+
const s = err?.status;
|
|
25
|
+
return typeof s === "number" ? s : undefined;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Read the `Retry-After` header (in seconds) off a thrown SDK `APIError`.
|
|
29
|
+
* `APIError.headers` is a `Headers` instance; we also tolerate a plain object so
|
|
30
|
+
* this stays robust to SDK shape changes and is trivial to unit-test. Per RFC,
|
|
31
|
+
* `Retry-After` is either delta-seconds or an HTTP-date — handle both.
|
|
32
|
+
*/
|
|
33
|
+
function retryAfterSecondsOf(err) {
|
|
34
|
+
const headers = err?.headers;
|
|
35
|
+
let raw;
|
|
36
|
+
if (headers && typeof headers.get === "function") {
|
|
37
|
+
raw = headers.get("retry-after");
|
|
38
|
+
}
|
|
39
|
+
else if (headers && typeof headers === "object") {
|
|
40
|
+
const obj = headers;
|
|
41
|
+
raw = obj["retry-after"] ?? obj["Retry-After"];
|
|
42
|
+
}
|
|
43
|
+
const trimmed = raw?.trim();
|
|
44
|
+
if (!trimmed)
|
|
45
|
+
return undefined;
|
|
46
|
+
const asSeconds = Number(trimmed);
|
|
47
|
+
if (Number.isFinite(asSeconds))
|
|
48
|
+
return Math.max(0, Math.round(asSeconds));
|
|
49
|
+
const asDate = Date.parse(trimmed);
|
|
50
|
+
if (!Number.isNaN(asDate)) {
|
|
51
|
+
return Math.max(0, Math.round((asDate - Date.now()) / 1000));
|
|
52
|
+
}
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Cap, in seconds, beyond which we will NOT echo a literal Retry-After value to
|
|
57
|
+
* the agent. A backend advertising minutes is plausible; one advertising
|
|
58
|
+
* "~99999999999s" (or a far-future HTTP-date) is not, and interpolating it
|
|
59
|
+
* verbatim next to "try again shortly" is exactly the misleading signal #39
|
|
60
|
+
* exists to avoid. Above this we fall back to generic wording. The accurate
|
|
61
|
+
* value is still logged.
|
|
62
|
+
*/
|
|
63
|
+
const MAX_SURFACED_RETRY_SECONDS = 600;
|
|
64
|
+
export function classifySearchError(err) {
|
|
65
|
+
const status = statusOf(err);
|
|
66
|
+
const retryAfterSeconds = retryAfterSecondsOf(err);
|
|
67
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
68
|
+
// A 429, or any status carrying Retry-After (the backend attaches it to the
|
|
69
|
+
// 5xx it sheds under load too), means an active cooldown window — flag it as
|
|
70
|
+
// a throttle regardless of the exact status code.
|
|
71
|
+
if (status === 429 || retryAfterSeconds !== undefined) {
|
|
72
|
+
return { kind: "throttled", status, retryAfterSeconds, detail };
|
|
73
|
+
}
|
|
74
|
+
if (status !== undefined && status >= 500) {
|
|
75
|
+
return { kind: "transient", status, detail };
|
|
76
|
+
}
|
|
77
|
+
if (status !== undefined && status >= 400) {
|
|
78
|
+
return { kind: "client", status, detail };
|
|
79
|
+
}
|
|
80
|
+
return { kind: "unknown", detail };
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Agent-facing text for a failed `hyperspell_search` tool call. For a throttle or
|
|
84
|
+
* transient error the wording is deliberate: it tells the agent this is a backend
|
|
85
|
+
* condition and NOT an empty memory, so it doesn't conclude "nothing was found"
|
|
86
|
+
* (issue #39's core symptom).
|
|
87
|
+
*/
|
|
88
|
+
export function searchErrorToolText(info) {
|
|
89
|
+
switch (info.kind) {
|
|
90
|
+
case "throttled": {
|
|
91
|
+
const s = info.retryAfterSeconds;
|
|
92
|
+
if (s !== undefined && s <= MAX_SURFACED_RETRY_SECONDS) {
|
|
93
|
+
return `Memory search is temporarily rate-limited by the backend (retry in ~${s}s). This is a transient throttle, NOT an empty memory — do not conclude nothing was found; try again shortly.`;
|
|
94
|
+
}
|
|
95
|
+
// Unknown or implausibly long cooldown — don't echo an absurd number.
|
|
96
|
+
return `Memory search is temporarily rate-limited by the backend. This is a transient throttle, NOT an empty memory — do not conclude nothing was found; retry later rather than assuming nothing exists.`;
|
|
97
|
+
}
|
|
98
|
+
case "transient":
|
|
99
|
+
return `Memory search hit a transient backend error (HTTP ${info.status}) and automatic retries were exhausted. This is NOT an empty memory — do not conclude nothing was found; try again shortly.`;
|
|
100
|
+
default:
|
|
101
|
+
return `Search failed: ${info.detail}`;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/** One-line summary for logs. */
|
|
105
|
+
function summarize(info) {
|
|
106
|
+
const parts = [`kind=${info.kind}`];
|
|
107
|
+
if (info.status !== undefined)
|
|
108
|
+
parts.push(`status=${info.status}`);
|
|
109
|
+
if (info.retryAfterSeconds !== undefined)
|
|
110
|
+
parts.push(`retry-after≈${info.retryAfterSeconds}s`);
|
|
111
|
+
return parts.join(" ");
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Log a read-path failure consistently across both retrieval paths. Throttles and
|
|
115
|
+
* transient 5xx are expected-under-load and log at `warn` (with the cooldown, so
|
|
116
|
+
* blips are observable); genuine errors log at `error` with the raw cause.
|
|
117
|
+
*/
|
|
118
|
+
export function logSearchError(log, where, info, err) {
|
|
119
|
+
if (info.kind === "throttled" || info.kind === "transient") {
|
|
120
|
+
log.warn(`${where}: backend unavailable — ${summarize(info)}`);
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
log.error(`${where} failed (${summarize(info)})`, err);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the CURRENT session's id — the value the hot buffer keys its rows by
|
|
3
|
+
* (`resourceId = event.sessionId`, see `hooks/hot-buffer.ts`). The auto-context
|
|
4
|
+
* hook uses this to exclude the live session's own just-written turns from its
|
|
5
|
+
* retrieval, so the agent isn't fed its own recent messages back as "recalled
|
|
6
|
+
* memory" (issue #42).
|
|
7
|
+
*
|
|
8
|
+
* The wrinkle: hooks see `ctx.sessionKey`, which is a COMPOSITE — e.g.
|
|
9
|
+
* `agent:main:cron:<cronId>:run:<sessionId>` — not the bare `sessionId` the hot
|
|
10
|
+
* buffer writes. So we prefer an explicit `sessionId` field when present and
|
|
11
|
+
* otherwise recover the bare id from the `:run:` suffix (or a trailing UUID).
|
|
12
|
+
*
|
|
13
|
+
* Returns `undefined` when no id can be determined — callers MUST treat that as
|
|
14
|
+
* "don't exclude" so retrieval degrades to its prior behavior rather than
|
|
15
|
+
* dropping anything by accident. The exclusion is purely subtractive: a wrong or
|
|
16
|
+
* missing id can only fail to hide the echo, never hide real cross-session
|
|
17
|
+
* memories (those live under a different resource_id).
|
|
18
|
+
*/
|
|
19
|
+
const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
|
|
20
|
+
const RUN_SEP = ":run:";
|
|
21
|
+
export function resolveCurrentSessionId(event, ctx) {
|
|
22
|
+
// Prefer an explicit sessionId — this is exactly what the hot buffer uses.
|
|
23
|
+
const direct = (typeof event?.sessionId === "string" && event.sessionId) ||
|
|
24
|
+
(typeof ctx?.sessionId === "string" && ctx.sessionId);
|
|
25
|
+
if (direct)
|
|
26
|
+
return direct;
|
|
27
|
+
const key = ctx?.sessionKey;
|
|
28
|
+
if (typeof key !== "string" || key.length === 0)
|
|
29
|
+
return undefined;
|
|
30
|
+
// Composite key — the bare session id is the `:run:` suffix.
|
|
31
|
+
const runIdx = key.lastIndexOf(RUN_SEP);
|
|
32
|
+
if (runIdx >= 0) {
|
|
33
|
+
const tail = key.slice(runIdx + RUN_SEP.length);
|
|
34
|
+
if (tail.length > 0)
|
|
35
|
+
return tail;
|
|
36
|
+
}
|
|
37
|
+
// No `:run:` segment — accept the key only if it IS (ends with) a bare UUID;
|
|
38
|
+
// anything else (a phone handle, etc.) can't match a resource_id, so bail out
|
|
39
|
+
// rather than return a value that would silently never match.
|
|
40
|
+
const m = key.match(UUID_RE);
|
|
41
|
+
if (m && key.endsWith(m[0]))
|
|
42
|
+
return m[0];
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
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
|
|
15
|
+
description: "Search the user's long-term memory and connected sources for anything not already in the current conversation. Covers: saved memories and notes; past conversations — including ones from earlier or parallel sessions you have no transcript for; and connected sources (Notion, Slack, Gmail, Google Drive, etc.). Reach for this whenever the user refers to something from before, asks what was said / decided / remembered, or when relevant context likely exists but isn't in front of you — search before concluding you don't know.",
|
|
15
16
|
parameters: Type.Object({
|
|
16
17
|
query: Type.String({ description: "Search query" }),
|
|
17
18
|
limit: Type.Optional(Type.Number({ description: "Max results (default: 5)" })),
|
|
@@ -41,7 +42,7 @@ export function createSearchToolFactory(client, cfg) {
|
|
|
41
42
|
}
|
|
42
43
|
// Keep session-end trace memories out of agent-facing search, matching
|
|
43
44
|
// the auto-context hook so both retrieval paths filter identically.
|
|
44
|
-
filter = mergeWithExclude(filter);
|
|
45
|
+
filter = mergeWithExclude(filter, cfg);
|
|
45
46
|
log.debug(`search tool: query="${params.query}" limit=${limit} after=${params.after ?? "none"} before=${params.before ?? "none"} userId=${userId} scope=${params.scope ?? "any"}`);
|
|
46
47
|
try {
|
|
47
48
|
const response = await client.searchRaw(params.query, {
|
|
@@ -76,14 +77,13 @@ export function createSearchToolFactory(client, cfg) {
|
|
|
76
77
|
};
|
|
77
78
|
}
|
|
78
79
|
catch (err) {
|
|
79
|
-
|
|
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
|
@@ -36,6 +36,11 @@
|
|
|
36
36
|
"help": "Automatically send conversation traces to Hyperspell for memory extraction (procedural, mood) at the end of each session",
|
|
37
37
|
"advanced": true
|
|
38
38
|
},
|
|
39
|
+
"hotBuffer": {
|
|
40
|
+
"label": "Hot Buffer",
|
|
41
|
+
"help": "Write each turn to the realtime message buffer (POST /messages) so it is instantly full-text searchable, then auto-consolidated into vault Resources. Requires userId. { enabled, source, writeUser, writeAssistant }",
|
|
42
|
+
"advanced": true
|
|
43
|
+
},
|
|
39
44
|
"emotionalContext": {
|
|
40
45
|
"label": "Emotional Context",
|
|
41
46
|
"help": "Maintain emotional continuity across sessions — remembers not just what happened, but how it felt",
|
|
@@ -116,6 +121,15 @@
|
|
|
116
121
|
"metadata": { "type": "object" }
|
|
117
122
|
}
|
|
118
123
|
},
|
|
124
|
+
"hotBuffer": {
|
|
125
|
+
"type": "object",
|
|
126
|
+
"properties": {
|
|
127
|
+
"enabled": { "type": "boolean" },
|
|
128
|
+
"source": { "type": "string" },
|
|
129
|
+
"writeUser": { "type": "boolean" },
|
|
130
|
+
"writeAssistant": { "type": "boolean" }
|
|
131
|
+
}
|
|
132
|
+
},
|
|
119
133
|
"emotionalContext": {
|
|
120
134
|
"type": "boolean"
|
|
121
135
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperspell/openclaw-hyperspell",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "OpenClaw Hyperspell memory plugin",
|
|
6
6
|
"license": "MIT",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"check-types": "tsc --noEmit",
|
|
39
39
|
"lint": "bunx @biomejs/biome ci .",
|
|
40
40
|
"lint:fix": "bunx @biomejs/biome check --write .",
|
|
41
|
-
"test": "node --test --experimental-strip-types lib/sender.test.ts lib/filters.test.ts config.test.ts sync/markdown.test.ts hooks/auto-trace.test.ts hooks/emotional-state.test.ts hooks/startup-orientation.test.ts"
|
|
41
|
+
"test": "node --test --experimental-strip-types client.test.ts lib/sender.test.ts lib/filters.test.ts lib/search-error.test.ts lib/session.test.ts tools/search.test.ts config.test.ts sync/markdown.test.ts hooks/auto-trace.test.ts hooks/auto-context.test.ts hooks/hot-buffer.test.ts hooks/emotional-state.test.ts hooks/startup-orientation.test.ts"
|
|
42
42
|
},
|
|
43
43
|
"openclaw": {
|
|
44
44
|
"extensions": [
|