@hyperspell/openclaw-hyperspell 0.18.1 → 0.19.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/README.md +46 -0
- package/dist/commands/preview.js +93 -0
- package/dist/commands/purge-channel.js +54 -0
- package/dist/commands/setup.js +49 -0
- package/dist/commands/slash.js +18 -0
- package/dist/hooks/auto-context.js +67 -3
- package/dist/hooks/auto-trace.js +20 -2
- package/dist/hooks/emotional-state.js +77 -20
- package/dist/hooks/hot-buffer.js +76 -6
- package/dist/hooks/startup-orientation.js +77 -55
- package/dist/index.js +21 -0
- package/dist/lib/eval-matchers.js +49 -0
- package/dist/lib/exclude-channels.js +17 -6
- package/dist/lib/filters.js +6 -4
- package/dist/lib/ranking.js +42 -13
- package/dist/tools/emotional-arc.js +60 -0
- package/dist/tools/remember.js +9 -1
- package/openclaw.plugin.json +3 -2
- package/package.json +2 -2
package/dist/hooks/hot-buffer.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { getWorkspaceDir } from "../config.js";
|
|
4
|
+
import { channelIdFromCtx } from "../lib/exclude-channels.js";
|
|
1
5
|
import { resolveUser } from "../lib/sender.js";
|
|
2
6
|
import { cleanupSpeakerSession, isMultiSpeaker, recordSender, senderIdFromCtx, } from "../lib/speaker-tracker.js";
|
|
3
7
|
import { log } from "../logger.js";
|
|
@@ -12,10 +16,62 @@ const MAX_TOTAL_CHARS = 5_242_880;
|
|
|
12
16
|
* this we'd re-post every prior message each turn. The server upserts (so it's
|
|
13
17
|
* harmless correctness-wise), but re-posting is wasteful — this keeps each turn
|
|
14
18
|
* to just its new messages. Cleared on session_end.
|
|
19
|
+
*
|
|
20
|
+
* This map alone is NOT restart-safe: it's module-scope, in-memory only. A
|
|
21
|
+
* gateway restart wipes it without firing session_end, so the next turn of a
|
|
22
|
+
* still-open session sees an empty set and re-posts the entire transcript to
|
|
23
|
+
* date in one shot (observed live: two 499/503-message flushes right after
|
|
24
|
+
* restarts, vs. the normal 2-10). `loadPersistedSent`/`persistSent` below
|
|
25
|
+
* mirror this map to disk per session so a restart degrades to "reload from
|
|
26
|
+
* disk" instead of "resend everything."
|
|
15
27
|
*/
|
|
16
28
|
const sentBySession = new Map();
|
|
29
|
+
function stateDir(root) {
|
|
30
|
+
return path.join(root, "hot-buffer-sent");
|
|
31
|
+
}
|
|
32
|
+
function stateFile(root, sessionId) {
|
|
33
|
+
const safe = sessionId.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
34
|
+
return path.join(stateDir(root), `${safe}.json`);
|
|
35
|
+
}
|
|
36
|
+
function loadPersistedSent(root, sessionId) {
|
|
37
|
+
try {
|
|
38
|
+
const raw = fs.readFileSync(stateFile(root, sessionId), "utf-8");
|
|
39
|
+
const ids = JSON.parse(raw);
|
|
40
|
+
return new Set(ids);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return new Set();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function persistSent(root, sessionId, sent) {
|
|
47
|
+
try {
|
|
48
|
+
fs.mkdirSync(stateDir(root), { recursive: true });
|
|
49
|
+
fs.writeFileSync(stateFile(root, sessionId), JSON.stringify([...sent]));
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
log.error("hot-buffer: failed to persist sent-id state to disk", err);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function deletePersistedSent(root, sessionId) {
|
|
56
|
+
try {
|
|
57
|
+
fs.unlinkSync(stateFile(root, sessionId));
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// Nothing to clean up — fine.
|
|
61
|
+
}
|
|
62
|
+
}
|
|
17
63
|
/** Sessions where we've already emitted the group-chat attribution warning. */
|
|
18
64
|
const warnedGroupSessions = new Set();
|
|
65
|
+
/**
|
|
66
|
+
* Test-only: drop just the in-memory dedup cache for a session, leaving any
|
|
67
|
+
* persisted-to-disk state untouched. This is what a bare gateway restart
|
|
68
|
+
* looks like (module state wiped, disk state intact) — as opposed to
|
|
69
|
+
* `buildHotBufferSessionCleanupHandler`'s session_end, which clears both on
|
|
70
|
+
* purpose. Not used outside tests.
|
|
71
|
+
*/
|
|
72
|
+
export function __simulateRestartForTest(sessionId) {
|
|
73
|
+
sentBySession.delete(sessionId);
|
|
74
|
+
}
|
|
19
75
|
/**
|
|
20
76
|
* Flatten a message's content into a single sanitized text string. Mirrors the
|
|
21
77
|
* auto-trace sanitizer so the hot buffer never captures injected
|
|
@@ -57,7 +113,8 @@ function messageId(role, text) {
|
|
|
57
113
|
* searchable. Runs on `agent_end` — fire-and-forget; a failure here must never
|
|
58
114
|
* break the turn.
|
|
59
115
|
*/
|
|
60
|
-
export function buildHotBufferHandler(client, cfg) {
|
|
116
|
+
export function buildHotBufferHandler(client, cfg, opts) {
|
|
117
|
+
const stateRoot = opts?.stateRoot ?? getWorkspaceDir();
|
|
61
118
|
return async (event, ctx) => {
|
|
62
119
|
if (event.success === false) {
|
|
63
120
|
log.debug("hot-buffer: skipping — agent ended with error");
|
|
@@ -86,7 +143,15 @@ export function buildHotBufferHandler(client, cfg) {
|
|
|
86
143
|
event.sessionId ??
|
|
87
144
|
crypto.randomUUID();
|
|
88
145
|
const resourceId = sessionId;
|
|
89
|
-
|
|
146
|
+
// Fall back to disk before assuming "nothing sent yet" — an empty
|
|
147
|
+
// in-memory entry is ambiguous between "brand new session" and "this
|
|
148
|
+
// process restarted mid-session," and treating the latter as the former
|
|
149
|
+
// is exactly the full-transcript-resend bug this guards against.
|
|
150
|
+
let sent = sentBySession.get(sessionId);
|
|
151
|
+
if (!sent) {
|
|
152
|
+
sent = loadPersistedSent(stateRoot, sessionId);
|
|
153
|
+
sentBySession.set(sessionId, sent);
|
|
154
|
+
}
|
|
90
155
|
// Record the current sender for evidence-based multi-speaker detection.
|
|
91
156
|
// isMultiSpeaker() will return true once a second distinct sender_id
|
|
92
157
|
// appears in this session, regardless of whether is_group_chat was set.
|
|
@@ -166,9 +231,11 @@ export function buildHotBufferHandler(client, cfg) {
|
|
|
166
231
|
// 2026-07-02 (docs/filter-dialect-test.mjs: metadata-carrying row is
|
|
167
232
|
// baseline-retrievable AND filterable). The retrieval exclude
|
|
168
233
|
// {openclaw_source:{$ne:"agent_end"}} keeps "hot_buffer" rows.
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
234
|
+
// channelIdFromCtx (ctx.channelId, else sessionKey parse) is the same
|
|
235
|
+
// resolver the quarantine check uses — tag-time identity must equal
|
|
236
|
+
// quarantine-time identity or purge-channel misses rows the exclude
|
|
237
|
+
// would have blocked.
|
|
238
|
+
const channelId = channelIdFromCtx(ctx);
|
|
172
239
|
const metadata = {
|
|
173
240
|
openclaw_source: "hot_buffer",
|
|
174
241
|
openclaw_session_id: sessionId,
|
|
@@ -186,6 +253,7 @@ export function buildHotBufferHandler(client, cfg) {
|
|
|
186
253
|
for (const id of pendingIds)
|
|
187
254
|
sent.add(id);
|
|
188
255
|
sentBySession.set(sessionId, sent);
|
|
256
|
+
persistSent(stateRoot, sessionId, sent);
|
|
189
257
|
log.info(`hot-buffer: wrote ${total} message(s) to ${sessionId} (user=${userId})`);
|
|
190
258
|
}
|
|
191
259
|
catch (err) {
|
|
@@ -195,13 +263,15 @@ export function buildHotBufferHandler(client, cfg) {
|
|
|
195
263
|
};
|
|
196
264
|
}
|
|
197
265
|
/** Drop per-session state on session end to avoid unbounded growth. */
|
|
198
|
-
export function buildHotBufferSessionCleanupHandler() {
|
|
266
|
+
export function buildHotBufferSessionCleanupHandler(opts) {
|
|
267
|
+
const stateRoot = opts?.stateRoot ?? getWorkspaceDir();
|
|
199
268
|
return (event) => {
|
|
200
269
|
const sessionId = event.sessionId;
|
|
201
270
|
if (sessionId) {
|
|
202
271
|
sentBySession.delete(sessionId);
|
|
203
272
|
warnedGroupSessions.delete(sessionId);
|
|
204
273
|
cleanupSpeakerSession(sessionId);
|
|
274
|
+
deletePersistedSent(stateRoot, sessionId);
|
|
205
275
|
}
|
|
206
276
|
};
|
|
207
277
|
}
|
|
@@ -78,7 +78,7 @@ function isoDaysAgo(days) {
|
|
|
78
78
|
* an unresolved caller has no personal space to orient to. In single-user mode,
|
|
79
79
|
* return undefined so the client falls back to its configured userId.
|
|
80
80
|
*/
|
|
81
|
-
function personalUserId(cfg, ctx) {
|
|
81
|
+
export function personalUserId(cfg, ctx) {
|
|
82
82
|
if (!cfg.multiUser)
|
|
83
83
|
return { skip: false, userId: undefined };
|
|
84
84
|
const resolved = resolveUser(ctx, cfg);
|
|
@@ -191,8 +191,78 @@ async function fetchRecentConversations(client, limit, userId) {
|
|
|
191
191
|
}
|
|
192
192
|
return out;
|
|
193
193
|
}
|
|
194
|
-
|
|
194
|
+
/**
|
|
195
|
+
* The fetch+format core of startup orientation, shared by the real
|
|
196
|
+
* before_agent_start handler and the read-only /previewcontext command.
|
|
197
|
+
* Pure with respect to session lifecycle: no inject-once cache, no retry
|
|
198
|
+
* counting — callers own that policy. Keeping one formatter here keeps the
|
|
199
|
+
* preview byte-identical to real injection.
|
|
200
|
+
*/
|
|
201
|
+
export async function gatherOrientation(client, cfg, userId) {
|
|
195
202
|
const so = cfg.startupOrientation;
|
|
203
|
+
// Source recent-interactions from wherever the session record actually
|
|
204
|
+
// lives. Prefer the hot buffer (modern path: clean session-grouped vault
|
|
205
|
+
// resources, present whenever the hot buffer is on — including auto-trace-
|
|
206
|
+
// off agents). Fall back to agent_end traces only when there's no hot
|
|
207
|
+
// buffer but auto-trace is on. Otherwise skip: there's nothing to fetch,
|
|
208
|
+
// and the trace-source list is expensive (observed ~12s + failing/turn),
|
|
209
|
+
// blocking before_agent_start and slowing the reply.
|
|
210
|
+
const recentSource = cfg.hotBuffer.enabled
|
|
211
|
+
? "hotBuffer"
|
|
212
|
+
: cfg.autoTrace.enabled
|
|
213
|
+
? "autoTrace"
|
|
214
|
+
: "none";
|
|
215
|
+
const recentFetch = recentSource === "hotBuffer"
|
|
216
|
+
? fetchRecentConversations(client, so.recentLimit, userId)
|
|
217
|
+
: recentSource === "autoTrace"
|
|
218
|
+
? fetchRecentTraces(client, isoDaysAgo(so.recentDays), so.recentLimit, userId)
|
|
219
|
+
: Promise.resolve([]);
|
|
220
|
+
const [recentSettled, loopsSettled] = await Promise.allSettled([
|
|
221
|
+
recentFetch,
|
|
222
|
+
client.search(so.loopsQuery, {
|
|
223
|
+
limit: so.loopsLimit,
|
|
224
|
+
userId,
|
|
225
|
+
}),
|
|
226
|
+
]);
|
|
227
|
+
const recentOk = recentSettled.status === "fulfilled";
|
|
228
|
+
const loopsOk = loopsSettled.status === "fulfilled";
|
|
229
|
+
const recent = recentOk ? recentSettled.value : [];
|
|
230
|
+
const loops = loopsOk ? loopsSettled.value : [];
|
|
231
|
+
if (!recentOk) {
|
|
232
|
+
log.error("startup-orientation: recent listMemories failed", recentSettled.reason);
|
|
233
|
+
}
|
|
234
|
+
if (!loopsOk) {
|
|
235
|
+
log.error("startup-orientation: loops search failed", loopsSettled.reason);
|
|
236
|
+
}
|
|
237
|
+
const recentBody = formatRecentInteractions(recent);
|
|
238
|
+
const loopsBody = formatUnfinishedLoops(loops);
|
|
239
|
+
return {
|
|
240
|
+
recentOk,
|
|
241
|
+
loopsOk,
|
|
242
|
+
recentCount: recent.length,
|
|
243
|
+
loopsCount: loops.length,
|
|
244
|
+
recentSource,
|
|
245
|
+
recentBlock: recentBody
|
|
246
|
+
? [
|
|
247
|
+
"<hyperspell-recent-interactions>",
|
|
248
|
+
`Your last ${so.recentDays} days of conversations with this user, most-recent-first. Use for situational continuity — don't quote verbatim.`,
|
|
249
|
+
"",
|
|
250
|
+
recentBody,
|
|
251
|
+
"</hyperspell-recent-interactions>",
|
|
252
|
+
].join("\n")
|
|
253
|
+
: null,
|
|
254
|
+
loopsBlock: loopsBody
|
|
255
|
+
? [
|
|
256
|
+
"<hyperspell-unfinished-loops>",
|
|
257
|
+
"Possible open threads — promises made, questions pending, work in progress. Low-confidence retrieval; treat as prompts to consider, not facts to act on.",
|
|
258
|
+
"",
|
|
259
|
+
loopsBody,
|
|
260
|
+
"</hyperspell-unfinished-loops>",
|
|
261
|
+
].join("\n")
|
|
262
|
+
: null,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
export function buildStartupOrientationHandler(client, cfg) {
|
|
196
266
|
return async (_event, ctx) => {
|
|
197
267
|
const sessionKey = ctx?.sessionKey;
|
|
198
268
|
if (sessionKey && injectedSessions.has(sessionKey))
|
|
@@ -224,36 +294,8 @@ export function buildStartupOrientationHandler(client, cfg) {
|
|
|
224
294
|
injectedSessions.add(sessionKey);
|
|
225
295
|
return;
|
|
226
296
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
// resources, present whenever the hot buffer is on — including auto-trace-
|
|
230
|
-
// off agents). Fall back to agent_end traces only when there's no hot
|
|
231
|
-
// buffer but auto-trace is on. Otherwise skip: there's nothing to fetch,
|
|
232
|
-
// and the trace-source list is expensive (observed ~12s + failing/turn),
|
|
233
|
-
// blocking before_agent_start and slowing the reply.
|
|
234
|
-
const recentFetch = cfg.hotBuffer.enabled
|
|
235
|
-
? fetchRecentConversations(client, so.recentLimit, userId)
|
|
236
|
-
: cfg.autoTrace.enabled
|
|
237
|
-
? fetchRecentTraces(client, isoDaysAgo(so.recentDays), so.recentLimit, userId)
|
|
238
|
-
: Promise.resolve([]);
|
|
239
|
-
const [recentSettled, loopsSettled] = await Promise.allSettled([
|
|
240
|
-
recentFetch,
|
|
241
|
-
client.search(so.loopsQuery, {
|
|
242
|
-
limit: so.loopsLimit,
|
|
243
|
-
userId,
|
|
244
|
-
}),
|
|
245
|
-
]);
|
|
246
|
-
const recentOk = recentSettled.status === "fulfilled";
|
|
247
|
-
const loopsOk = loopsSettled.status === "fulfilled";
|
|
248
|
-
const recent = recentOk ? recentSettled.value : [];
|
|
249
|
-
const loops = loopsOk ? loopsSettled.value : [];
|
|
250
|
-
if (!recentOk) {
|
|
251
|
-
log.error("startup-orientation: recent listMemories failed", recentSettled.reason);
|
|
252
|
-
}
|
|
253
|
-
if (!loopsOk) {
|
|
254
|
-
log.error("startup-orientation: loops search failed", loopsSettled.reason);
|
|
255
|
-
}
|
|
256
|
-
if (!recentOk && !loopsOk) {
|
|
297
|
+
const gathered = await gatherOrientation(client, cfg, userId);
|
|
298
|
+
if (!gathered.recentOk && !gathered.loopsOk) {
|
|
257
299
|
if (sessionKey)
|
|
258
300
|
failedAttempts.set(sessionKey, tries + 1);
|
|
259
301
|
log.debug(`startup-orientation: both calls failed (attempt ${tries + 1}/${MAX_ATTEMPTS}); will retry next turn`);
|
|
@@ -263,32 +305,12 @@ export function buildStartupOrientationHandler(client, cfg) {
|
|
|
263
305
|
injectedSessions.add(sessionKey);
|
|
264
306
|
failedAttempts.delete(sessionKey);
|
|
265
307
|
}
|
|
266
|
-
|
|
267
|
-
const loopsBody = formatUnfinishedLoops(loops);
|
|
268
|
-
if (!recentBody && !loopsBody) {
|
|
308
|
+
if (!gathered.recentBlock && !gathered.loopsBlock) {
|
|
269
309
|
log.debug("startup-orientation: nothing to inject");
|
|
270
310
|
return;
|
|
271
311
|
}
|
|
272
|
-
const blocks = [];
|
|
273
|
-
|
|
274
|
-
blocks.push([
|
|
275
|
-
"<hyperspell-recent-interactions>",
|
|
276
|
-
`Your last ${so.recentDays} days of conversations with this user, most-recent-first. Use for situational continuity — don't quote verbatim.`,
|
|
277
|
-
"",
|
|
278
|
-
recentBody,
|
|
279
|
-
"</hyperspell-recent-interactions>",
|
|
280
|
-
].join("\n"));
|
|
281
|
-
}
|
|
282
|
-
if (loopsBody) {
|
|
283
|
-
blocks.push([
|
|
284
|
-
"<hyperspell-unfinished-loops>",
|
|
285
|
-
"Possible open threads — promises made, questions pending, work in progress. Low-confidence retrieval; treat as prompts to consider, not facts to act on.",
|
|
286
|
-
"",
|
|
287
|
-
loopsBody,
|
|
288
|
-
"</hyperspell-unfinished-loops>",
|
|
289
|
-
].join("\n"));
|
|
290
|
-
}
|
|
291
|
-
log.debug(`startup-orientation: injecting recent=${recent.length} loops=${loops.length}`);
|
|
312
|
+
const blocks = [gathered.recentBlock, gathered.loopsBlock].filter((b) => b !== null);
|
|
313
|
+
log.debug(`startup-orientation: injecting recent=${gathered.recentCount} loops=${gathered.loopsCount}`);
|
|
292
314
|
return { prependContext: blocks.join("\n\n") };
|
|
293
315
|
};
|
|
294
316
|
}
|
package/dist/index.js
CHANGED
|
@@ -10,6 +10,7 @@ import { buildHotBufferHandler, buildHotBufferSessionCleanupHandler, } from "./h
|
|
|
10
10
|
import { buildStartupOrientationCompactionHandler, buildStartupOrientationHandler, buildStartupOrientationSessionCleanupHandler, } from "./hooks/startup-orientation.js";
|
|
11
11
|
import { isExcludedChannel } from "./lib/exclude-channels.js";
|
|
12
12
|
import { initLogger, log } from "./logger.js";
|
|
13
|
+
import { createEmotionalArcToolFactory } from "./tools/emotional-arc.js";
|
|
13
14
|
import { createRememberToolFactory } from "./tools/remember.js";
|
|
14
15
|
import { createSearchToolFactory } from "./tools/search.js";
|
|
15
16
|
import { registerNetworkTools } from "./graph/index.js";
|
|
@@ -63,6 +64,17 @@ export default {
|
|
|
63
64
|
};
|
|
64
65
|
},
|
|
65
66
|
});
|
|
67
|
+
api.registerCommand({
|
|
68
|
+
name: "previewcontext",
|
|
69
|
+
description: "Preview what Hyperspell would inject at the next session start",
|
|
70
|
+
acceptsArgs: false,
|
|
71
|
+
requireAuth: false,
|
|
72
|
+
handler: async () => {
|
|
73
|
+
return {
|
|
74
|
+
text: "Hyperspell not configured. Run 'openclaw openclaw-hyperspell setup' first.",
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
});
|
|
66
78
|
return;
|
|
67
79
|
}
|
|
68
80
|
const cfg = parseConfig(api.pluginConfig);
|
|
@@ -89,6 +101,15 @@ export default {
|
|
|
89
101
|
});
|
|
90
102
|
const startHandlers = [];
|
|
91
103
|
if (cfg.emotionalContext) {
|
|
104
|
+
// moodWeatherChance defaults to 0, so mood weather is inert unless the
|
|
105
|
+
// operator opts in — say so once at startup rather than staying silent.
|
|
106
|
+
if (cfg.moodWeatherChance === 0) {
|
|
107
|
+
log.info("emotionalContext is on but moodWeatherChance is 0 — mood weather will never roll. Set moodWeatherChance (e.g. 0.03–0.05) to enable it.");
|
|
108
|
+
}
|
|
109
|
+
// On-demand arc re-fetch (issue #76): the session-start injection can be
|
|
110
|
+
// compacted out of history mid-session; this tool lets the agent pull the
|
|
111
|
+
// exact same block back without waiting for the next prompt build.
|
|
112
|
+
api.registerTool(toolUnlessQuarantined(createEmotionalArcToolFactory(client, cfg)), { name: "hyperspell_emotional_arc" });
|
|
92
113
|
startHandlers.push(buildEmotionalStateFetchHandler(client, cfg));
|
|
93
114
|
api.on("after_compaction", buildEmotionalStateCompactionHandler());
|
|
94
115
|
api.on("session_end", buildEmotionalStateSessionCleanupHandler());
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse the JSONL fixture file: one JSON object per line; blank lines and
|
|
3
|
+
* `//` comment lines are ignored. Every fixture needs a query and at least
|
|
4
|
+
* one matcher — a matcher-less line would vacuously fail and look like a
|
|
5
|
+
* ranking problem, so reject it at parse time with the line number.
|
|
6
|
+
*/
|
|
7
|
+
export function parseFixtures(jsonl) {
|
|
8
|
+
const fixtures = [];
|
|
9
|
+
for (const [idx, line] of jsonl.split("\n").entries()) {
|
|
10
|
+
const trimmed = line.trim();
|
|
11
|
+
if (trimmed === "" || trimmed.startsWith("//"))
|
|
12
|
+
continue;
|
|
13
|
+
let parsed;
|
|
14
|
+
try {
|
|
15
|
+
parsed = JSON.parse(trimmed);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
throw new Error(`fixtures line ${idx + 1}: invalid JSON`);
|
|
19
|
+
}
|
|
20
|
+
const f = parsed;
|
|
21
|
+
if (typeof f.query !== "string" || f.query.trim() === "") {
|
|
22
|
+
throw new Error(`fixtures line ${idx + 1}: "query" is required`);
|
|
23
|
+
}
|
|
24
|
+
if (!f.expectedResourceId && !f.expectedTitleContains) {
|
|
25
|
+
throw new Error(`fixtures line ${idx + 1}: at least one of "expectedResourceId" / "expectedTitleContains" is required`);
|
|
26
|
+
}
|
|
27
|
+
fixtures.push(f);
|
|
28
|
+
}
|
|
29
|
+
return fixtures;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* OR-semantics matcher (proposal §3.1): a result satisfies a fixture if the
|
|
33
|
+
* resourceId matches exactly, or the title/any highlight contains the
|
|
34
|
+
* expected substring (case-insensitive). Returns WHICH matcher hit so the
|
|
35
|
+
* runner can warn when only the title matched — the id may have churned.
|
|
36
|
+
*/
|
|
37
|
+
export function matchFixture(r, f) {
|
|
38
|
+
if (f.expectedResourceId && r.resourceId === f.expectedResourceId)
|
|
39
|
+
return "id";
|
|
40
|
+
if (f.expectedTitleContains) {
|
|
41
|
+
const needle = f.expectedTitleContains.toLowerCase();
|
|
42
|
+
const hay = [r.title ?? "", ...r.highlights.map((h) => h.text)]
|
|
43
|
+
.join(" ")
|
|
44
|
+
.toLowerCase();
|
|
45
|
+
if (hay.includes(needle))
|
|
46
|
+
return "title";
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
* (`ctx.channelId` on agent hook contexts — e.g. a Discord channel id). Tool
|
|
12
12
|
* factory contexts don't carry `channelId`, so we recover the same id from the
|
|
13
13
|
* composite `sessionKey` (`agent:<agentId>:<provider>:<kind>:<id>[...]`).
|
|
14
|
+
*
|
|
15
|
+
* Quarantine is FORWARD-ONLY: it stops future injection/writes/tools but does
|
|
16
|
+
* not remove already-synced content. Retroactive cleanup of channel-tagged
|
|
17
|
+
* rows is the `purge-channel` CLI command (commands/purge-channel.ts).
|
|
14
18
|
*/
|
|
15
19
|
// Conversation-kind segments used in OpenClaw session keys. Mirrors core's
|
|
16
20
|
// TARGET_PREFIXES (src/plugins/hook-agent-context.ts) so the sessionKey
|
|
@@ -40,6 +44,17 @@ export function channelIdFromCtx(ctx) {
|
|
|
40
44
|
return direct;
|
|
41
45
|
return conversationIdFromSessionKey(ctx?.sessionKey);
|
|
42
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* True when a stored conversation id belongs to `channel` — exact match or a
|
|
49
|
+
* thread suffix (`<channel>:thread:<n>`), case-insensitive. Shared by the
|
|
50
|
+
* quarantine check and the purge-channel CLI so "what gets blocked" and "what
|
|
51
|
+
* gets purged" can never drift apart.
|
|
52
|
+
*/
|
|
53
|
+
export function conversationMatchesChannel(id, channel) {
|
|
54
|
+
const a = id.toLowerCase();
|
|
55
|
+
const b = channel.toLowerCase();
|
|
56
|
+
return a === b || a.startsWith(`${b}:`);
|
|
57
|
+
}
|
|
43
58
|
/**
|
|
44
59
|
* True when the context's conversation is quarantined. Purely subtractive on
|
|
45
60
|
* failure: an unresolvable id means "not excluded" — a session we can't place
|
|
@@ -48,12 +63,8 @@ export function channelIdFromCtx(ctx) {
|
|
|
48
63
|
export function isExcludedChannel(ctx, cfg) {
|
|
49
64
|
if (cfg.excludeChannels.length === 0)
|
|
50
65
|
return false;
|
|
51
|
-
const id = channelIdFromCtx(ctx)
|
|
66
|
+
const id = channelIdFromCtx(ctx);
|
|
52
67
|
if (!id)
|
|
53
68
|
return false;
|
|
54
|
-
return cfg.excludeChannels.some((entry) =>
|
|
55
|
-
const excluded = entry.toLowerCase();
|
|
56
|
-
// Prefix match so threads inside an excluded channel inherit the quarantine.
|
|
57
|
-
return id === excluded || id.startsWith(`${excluded}:`);
|
|
58
|
-
});
|
|
69
|
+
return cfg.excludeChannels.some((entry) => conversationMatchesChannel(id, entry));
|
|
59
70
|
}
|
package/dist/lib/filters.js
CHANGED
|
@@ -23,10 +23,12 @@
|
|
|
23
23
|
* search (~1s observed live), and when auto-trace is off there are no `agent_end`
|
|
24
24
|
* rows to hide, so the filter would cost latency on every turn for zero benefit.
|
|
25
25
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
26
|
+
* Hot rows ARE positively tagged (`openclaw_source: "hot_buffer"` plus
|
|
27
|
+
* session/channel ids — see the hot-buffer hook): metadata-carrying
|
|
28
|
+
* `/messages` rows were verified retrievable AND filterable live 2026-07-02
|
|
29
|
+
* (docs/filter-dialect-test.mjs). The `$ne` exclude keeps them because
|
|
30
|
+
* "hot_buffer" != "agent_end". (An earlier note here claimed metadata made
|
|
31
|
+
* hot rows non-retrievable — that predated the backend fix.)
|
|
30
32
|
*
|
|
31
33
|
* NOTE: an earlier version checked the top-level `source` field for
|
|
32
34
|
* "openclaw_agent_end" — wrong on BOTH counts (the tag lives in metadata under
|
package/dist/lib/ranking.js
CHANGED
|
@@ -63,25 +63,54 @@ export function rerank(results, w) {
|
|
|
63
63
|
.sort((a, b) => b._composite - a._composite);
|
|
64
64
|
}
|
|
65
65
|
/**
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
66
|
+
* One pass over ranked results, annotating each with its selection outcome.
|
|
67
|
+
* Same policy as selectRanked — which is now derived from this, so the two
|
|
68
|
+
* can never drift (proposal 02 §3a).
|
|
69
|
+
*
|
|
70
|
+
* Attribution order is deliberate: the threshold check comes first so a
|
|
71
|
+
* below-the-bar result is always attributed to "threshold" even when the
|
|
72
|
+
* max-results cap is already full; and a chatter item past the cap reads
|
|
73
|
+
* "max-results", not "chatter-quota" — it would have been cut regardless, so
|
|
74
|
+
* the quota was not the binding constraint (proposal 03 §3.2 semantics).
|
|
70
75
|
*/
|
|
71
|
-
export function
|
|
76
|
+
export function explainSelection(ranked, maxResults, threshold, chatterQuota) {
|
|
72
77
|
const out = [];
|
|
73
78
|
let chatter = 0;
|
|
79
|
+
let kept = 0;
|
|
74
80
|
for (const r of ranked) {
|
|
75
|
-
if (r._composite < threshold)
|
|
81
|
+
if (r._composite < threshold) {
|
|
82
|
+
out.push({ result: r, selected: false, cut: "threshold" });
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (kept >= maxResults) {
|
|
86
|
+
out.push({ result: r, selected: false, cut: "max-results" });
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (r._kind === "chatter" && chatter >= chatterQuota) {
|
|
90
|
+
out.push({ result: r, selected: false, cut: "chatter-quota" });
|
|
76
91
|
continue;
|
|
77
|
-
if (r._kind === "chatter") {
|
|
78
|
-
if (chatter >= chatterQuota)
|
|
79
|
-
continue;
|
|
80
|
-
chatter++;
|
|
81
92
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
93
|
+
if (r._kind === "chatter")
|
|
94
|
+
chatter++;
|
|
95
|
+
kept++;
|
|
96
|
+
out.push({ result: r, selected: true, cut: null });
|
|
85
97
|
}
|
|
86
98
|
return out;
|
|
87
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* Choose which ranked results to inject: keep those clearing `threshold` on
|
|
102
|
+
* their composite, cap CHATTER at `chatterQuota` regardless of score (so a
|
|
103
|
+
* high-similarity echo can inform but never flood — the penalty bounds rank,
|
|
104
|
+
* the quota bounds count), and stop at `maxResults`.
|
|
105
|
+
*
|
|
106
|
+
* Signature and return shape are intentionally unchanged: the retrieval eval
|
|
107
|
+
* harness (proposal 17) and existing call sites depend on
|
|
108
|
+
* `selectRanked(ranked, maxResults, threshold, chatterQuota): RankedResult[]`.
|
|
109
|
+
* It is a thin projection of explainSelection so selection policy lives in
|
|
110
|
+
* exactly one place.
|
|
111
|
+
*/
|
|
112
|
+
export function selectRanked(ranked, maxResults, threshold, chatterQuota) {
|
|
113
|
+
return explainSelection(ranked, maxResults, threshold, chatterQuota)
|
|
114
|
+
.filter((e) => e.selected)
|
|
115
|
+
.map((e) => e.result);
|
|
116
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Type } from "@sinclair/typebox";
|
|
2
|
+
import { buildEmotionalContext, EMOTIONAL_ARC_LIMIT, fetchRecentOrLatest, looksLikeRawTranscript, } from "../hooks/emotional-state.js";
|
|
3
|
+
import { log } from "../logger.js";
|
|
4
|
+
/** Hard cap so the agent can't ask the backend for an unbounded history. */
|
|
5
|
+
const MAX_ARC_LIMIT = 10;
|
|
6
|
+
export function createEmotionalArcToolFactory(client, cfg) {
|
|
7
|
+
// No sender context needed: the register is keyed to cfg.relationshipId, not
|
|
8
|
+
// the current speaker — but keep the factory signature so toolUnlessQuarantined
|
|
9
|
+
// can wrap it like search/remember.
|
|
10
|
+
return (_ctx) => ({
|
|
11
|
+
name: "hyperspell_emotional_arc",
|
|
12
|
+
label: "Emotional Arc",
|
|
13
|
+
description: "Fetch the recent emotional arc of your relationship with this user — the same emotional-context block that is injected at session start. Use it when that block is no longer in your history (e.g. after the conversation was compacted) or when you genuinely want to reflect on how the relationship has been feeling before responding. Returns the most recent emotional registers, newest first. Let the trajectory inform your tone — don't recite it back to the user.",
|
|
14
|
+
parameters: Type.Object({
|
|
15
|
+
limit: Type.Optional(Type.Number({
|
|
16
|
+
description: `How many recent registers to fetch (default ${EMOTIONAL_ARC_LIMIT}, max ${MAX_ARC_LIMIT})`,
|
|
17
|
+
})),
|
|
18
|
+
}),
|
|
19
|
+
async execute(_toolCallId, params) {
|
|
20
|
+
const limit = Math.min(Math.max(Math.floor(params.limit ?? EMOTIONAL_ARC_LIMIT), 1), MAX_ARC_LIMIT);
|
|
21
|
+
log.debug(`emotional-arc tool: limit=${limit} relationshipId=${cfg.relationshipId ?? "(default)"}`);
|
|
22
|
+
try {
|
|
23
|
+
const states = await fetchRecentOrLatest(client, cfg, limit);
|
|
24
|
+
// Same placeholder filter as the injection path: for ~10s after a
|
|
25
|
+
// store, the register can be the RAW input transcript (status=pending),
|
|
26
|
+
// not a distilled feeling. Returning that would pollute tone.
|
|
27
|
+
const usable = states.filter((s) => s.summary && !looksLikeRawTranscript(s.summary));
|
|
28
|
+
if (usable.length === 0) {
|
|
29
|
+
const text = states.length > 0
|
|
30
|
+
? "The latest emotional register is still being extracted — try again in a few seconds."
|
|
31
|
+
: "No emotional arc recorded yet for this relationship. It builds up as real conversations end.";
|
|
32
|
+
return { content: [{ type: "text", text }] };
|
|
33
|
+
}
|
|
34
|
+
// Deliberately identical to the session-start injection block
|
|
35
|
+
// (buildEmotionalContext), so a post-compaction re-fetch restores
|
|
36
|
+
// exactly what a fresh session would have received. Mood weather is
|
|
37
|
+
// intentionally NOT included — it is an injection-only session
|
|
38
|
+
// override, rolled at most once per session; a tool call must never
|
|
39
|
+
// re-roll or reveal it (do not import from mood-weather.ts here).
|
|
40
|
+
return {
|
|
41
|
+
content: [
|
|
42
|
+
{ type: "text", text: buildEmotionalContext(usable) },
|
|
43
|
+
],
|
|
44
|
+
details: { count: usable.length },
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
log.error("emotional-arc tool failed", err);
|
|
49
|
+
return {
|
|
50
|
+
content: [
|
|
51
|
+
{
|
|
52
|
+
type: "text",
|
|
53
|
+
text: `Failed to fetch emotional arc: ${err instanceof Error ? err.message : String(err)}`,
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
}
|
package/dist/tools/remember.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Type } from "@sinclair/typebox";
|
|
2
|
+
import { channelIdFromCtx } from "../lib/exclude-channels.js";
|
|
2
3
|
import { getDefaultWriteScope, resolveRole, resolveUser, routeWrite, } from "../lib/sender.js";
|
|
3
4
|
import { resolveCurrentSessionId } from "../lib/session.js";
|
|
4
5
|
import { isMultiSpeaker } from "../lib/speaker-tracker.js";
|
|
@@ -85,12 +86,19 @@ export function createRememberToolFactory(client, cfg) {
|
|
|
85
86
|
userId = resolved?.userId;
|
|
86
87
|
}
|
|
87
88
|
log.debug(`remember tool: "${params.text.slice(0, 50)}..." date=${params.date ?? "now"} userId=${userId} scope=${scope}`);
|
|
89
|
+
// Tag with the originating conversation so purge-channel can find this
|
|
90
|
+
// memory if the channel is quarantined later (the tool is already
|
|
91
|
+
// suppressed in channels that are quarantined NOW).
|
|
92
|
+
const channelId = channelIdFromCtx(ctx);
|
|
88
93
|
try {
|
|
89
94
|
await client.addMemory(params.text, {
|
|
90
95
|
title: params.title,
|
|
91
96
|
date: params.date,
|
|
92
97
|
collection,
|
|
93
|
-
metadata: {
|
|
98
|
+
metadata: {
|
|
99
|
+
source: "openclaw_tool",
|
|
100
|
+
...(channelId ? { openclaw_channel_id: channelId } : {}),
|
|
101
|
+
},
|
|
94
102
|
userId,
|
|
95
103
|
scope: scopingEnabled ? scope : undefined,
|
|
96
104
|
});
|
package/openclaw.plugin.json
CHANGED
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
"id": "openclaw-hyperspell",
|
|
3
3
|
"name": "Hyperspell",
|
|
4
4
|
"description": "Context and memory for your AI agents — search across Notion, Slack, Google Drive, and more",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.19.0",
|
|
6
6
|
"kind": "memory",
|
|
7
7
|
"contracts": {
|
|
8
8
|
"tools": [
|
|
9
9
|
"hyperspell_search",
|
|
10
10
|
"hyperspell_remember",
|
|
11
|
+
"hyperspell_emotional_arc",
|
|
11
12
|
"hyperspell_network_scan",
|
|
12
13
|
"hyperspell_network_write",
|
|
13
14
|
"hyperspell_network_complete"
|
|
@@ -48,7 +49,7 @@
|
|
|
48
49
|
},
|
|
49
50
|
"excludeChannels": {
|
|
50
51
|
"label": "Excluded Channels",
|
|
51
|
-
"help": "Conversation/channel ids fully quarantined from memory: no context injection, no memory writes, no memory tools in those sessions. Threads inside an excluded channel inherit the quarantine.",
|
|
52
|
+
"help": "Conversation/channel ids fully quarantined from memory: no context injection, no memory writes, no memory tools in those sessions. Threads inside an excluded channel inherit the quarantine. Forward-only; already-synced content is not removed (see purge-channel CLI command).",
|
|
52
53
|
"advanced": true
|
|
53
54
|
},
|
|
54
55
|
"startupOrientation": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperspell/openclaw-hyperspell",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.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 client.test.ts lib/sender.test.ts lib/filters.test.ts lib/search-error.test.ts lib/session.test.ts lib/exclude-channels.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 hooks/mood-weather.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/exclude-channels.test.ts lib/ranking.test.ts lib/eval-matchers.test.ts tools/search.test.ts tools/emotional-arc.test.ts commands/preview.test.ts commands/purge-channel.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 hooks/mood-weather.test.ts eval/eval.test.ts"
|
|
42
42
|
},
|
|
43
43
|
"openclaw": {
|
|
44
44
|
"extensions": [
|