@hyperspell/openclaw-hyperspell 0.17.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 +51 -0
- package/dist/commands/slash.js +18 -0
- package/dist/config.js +10 -0
- package/dist/hooks/auto-context.js +88 -6
- package/dist/hooks/auto-trace.js +30 -2
- package/dist/hooks/emotional-state.js +101 -15
- package/dist/hooks/hot-buffer.js +122 -14
- package/dist/hooks/mood-weather.js +113 -0
- package/dist/hooks/startup-orientation.js +89 -54
- package/dist/index.js +42 -5
- package/dist/lib/eval-matchers.js +49 -0
- package/dist/lib/exclude-channels.js +70 -0
- package/dist/lib/filters.js +6 -4
- package/dist/lib/ranking.js +42 -13
- package/dist/lib/sender.js +18 -6
- package/dist/lib/speaker-tracker.js +56 -0
- package/dist/tools/emotional-arc.js +60 -0
- package/dist/tools/remember.js +27 -1
- package/dist/tools/search.js +15 -1
- package/openclaw.plugin.json +16 -1
- package/package.json +2 -2
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { resolveUser } from "../lib/sender.js";
|
|
2
|
+
import { resolveCurrentSessionId } from "../lib/session.js";
|
|
3
|
+
import { isMultiSpeaker } from "../lib/speaker-tracker.js";
|
|
2
4
|
import { log } from "../logger.js";
|
|
3
5
|
const MAX_ATTEMPTS = 2;
|
|
4
6
|
const RECENT_BUFFER_LIMIT = 100;
|
|
@@ -76,7 +78,7 @@ function isoDaysAgo(days) {
|
|
|
76
78
|
* an unresolved caller has no personal space to orient to. In single-user mode,
|
|
77
79
|
* return undefined so the client falls back to its configured userId.
|
|
78
80
|
*/
|
|
79
|
-
function personalUserId(cfg, ctx) {
|
|
81
|
+
export function personalUserId(cfg, ctx) {
|
|
80
82
|
if (!cfg.multiUser)
|
|
81
83
|
return { skip: false, userId: undefined };
|
|
82
84
|
const resolved = resolveUser(ctx, cfg);
|
|
@@ -189,8 +191,78 @@ async function fetchRecentConversations(client, limit, userId) {
|
|
|
189
191
|
}
|
|
190
192
|
return out;
|
|
191
193
|
}
|
|
192
|
-
|
|
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) {
|
|
193
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) {
|
|
194
266
|
return async (_event, ctx) => {
|
|
195
267
|
const sessionKey = ctx?.sessionKey;
|
|
196
268
|
if (sessionKey && injectedSessions.has(sessionKey))
|
|
@@ -211,36 +283,19 @@ export function buildStartupOrientationHandler(client, cfg) {
|
|
|
211
283
|
injectedSessions.add(sessionKey);
|
|
212
284
|
return;
|
|
213
285
|
}
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
? fetchRecentTraces(client, isoDaysAgo(so.recentDays), so.recentLimit, userId)
|
|
225
|
-
: Promise.resolve([]);
|
|
226
|
-
const [recentSettled, loopsSettled] = await Promise.allSettled([
|
|
227
|
-
recentFetch,
|
|
228
|
-
client.search(so.loopsQuery, {
|
|
229
|
-
limit: so.loopsLimit,
|
|
230
|
-
userId,
|
|
231
|
-
}),
|
|
232
|
-
]);
|
|
233
|
-
const recentOk = recentSettled.status === "fulfilled";
|
|
234
|
-
const loopsOk = loopsSettled.status === "fulfilled";
|
|
235
|
-
const recent = recentOk ? recentSettled.value : [];
|
|
236
|
-
const loops = loopsOk ? loopsSettled.value : [];
|
|
237
|
-
if (!recentOk) {
|
|
238
|
-
log.error("startup-orientation: recent listMemories failed", recentSettled.reason);
|
|
239
|
-
}
|
|
240
|
-
if (!loopsOk) {
|
|
241
|
-
log.error("startup-orientation: loops search failed", loopsSettled.reason);
|
|
286
|
+
// Skip when multiple speakers are present with no multiUser config.
|
|
287
|
+
// Orientation injects the primary user's personal activity context; in a
|
|
288
|
+
// group chat that leaks their private session history to other participants
|
|
289
|
+
// who may have triggered the session start (attribution v2, gap 2).
|
|
290
|
+
const sessionId = resolveCurrentSessionId(undefined, ctx);
|
|
291
|
+
if (!cfg.multiUser && isMultiSpeaker(sessionId, ctx?.is_group_chat === true)) {
|
|
292
|
+
log.debug("startup-orientation: skipping — multi-speaker session with no multiUser config (would expose primary user's personal context)");
|
|
293
|
+
if (sessionKey)
|
|
294
|
+
injectedSessions.add(sessionKey);
|
|
295
|
+
return;
|
|
242
296
|
}
|
|
243
|
-
|
|
297
|
+
const gathered = await gatherOrientation(client, cfg, userId);
|
|
298
|
+
if (!gathered.recentOk && !gathered.loopsOk) {
|
|
244
299
|
if (sessionKey)
|
|
245
300
|
failedAttempts.set(sessionKey, tries + 1);
|
|
246
301
|
log.debug(`startup-orientation: both calls failed (attempt ${tries + 1}/${MAX_ATTEMPTS}); will retry next turn`);
|
|
@@ -250,32 +305,12 @@ export function buildStartupOrientationHandler(client, cfg) {
|
|
|
250
305
|
injectedSessions.add(sessionKey);
|
|
251
306
|
failedAttempts.delete(sessionKey);
|
|
252
307
|
}
|
|
253
|
-
|
|
254
|
-
const loopsBody = formatUnfinishedLoops(loops);
|
|
255
|
-
if (!recentBody && !loopsBody) {
|
|
308
|
+
if (!gathered.recentBlock && !gathered.loopsBlock) {
|
|
256
309
|
log.debug("startup-orientation: nothing to inject");
|
|
257
310
|
return;
|
|
258
311
|
}
|
|
259
|
-
const blocks = [];
|
|
260
|
-
|
|
261
|
-
blocks.push([
|
|
262
|
-
"<hyperspell-recent-interactions>",
|
|
263
|
-
`Your last ${so.recentDays} days of conversations with this user, most-recent-first. Use for situational continuity — don't quote verbatim.`,
|
|
264
|
-
"",
|
|
265
|
-
recentBody,
|
|
266
|
-
"</hyperspell-recent-interactions>",
|
|
267
|
-
].join("\n"));
|
|
268
|
-
}
|
|
269
|
-
if (loopsBody) {
|
|
270
|
-
blocks.push([
|
|
271
|
-
"<hyperspell-unfinished-loops>",
|
|
272
|
-
"Possible open threads — promises made, questions pending, work in progress. Low-confidence retrieval; treat as prompts to consider, not facts to act on.",
|
|
273
|
-
"",
|
|
274
|
-
loopsBody,
|
|
275
|
-
"</hyperspell-unfinished-loops>",
|
|
276
|
-
].join("\n"));
|
|
277
|
-
}
|
|
278
|
-
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}`);
|
|
279
314
|
return { prependContext: blocks.join("\n\n") };
|
|
280
315
|
};
|
|
281
316
|
}
|
package/dist/index.js
CHANGED
|
@@ -8,7 +8,9 @@ import { buildEmotionalStateCompactionHandler, buildEmotionalStateFetchHandler,
|
|
|
8
8
|
import { buildFileSyncHandler, syncMemoriesOnStartup } from "./hooks/memory-sync.js";
|
|
9
9
|
import { buildHotBufferHandler, buildHotBufferSessionCleanupHandler, } from "./hooks/hot-buffer.js";
|
|
10
10
|
import { buildStartupOrientationCompactionHandler, buildStartupOrientationHandler, buildStartupOrientationSessionCleanupHandler, } from "./hooks/startup-orientation.js";
|
|
11
|
+
import { isExcludedChannel } from "./lib/exclude-channels.js";
|
|
11
12
|
import { initLogger, log } from "./logger.js";
|
|
13
|
+
import { createEmotionalArcToolFactory } from "./tools/emotional-arc.js";
|
|
12
14
|
import { createRememberToolFactory } from "./tools/remember.js";
|
|
13
15
|
import { createSearchToolFactory } from "./tools/search.js";
|
|
14
16
|
import { registerNetworkTools } from "./graph/index.js";
|
|
@@ -62,24 +64,56 @@ export default {
|
|
|
62
64
|
};
|
|
63
65
|
},
|
|
64
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
|
+
});
|
|
65
78
|
return;
|
|
66
79
|
}
|
|
67
80
|
const cfg = parseConfig(api.pluginConfig);
|
|
68
81
|
initLogger(api.logger, cfg.debug);
|
|
69
82
|
const client = new HyperspellClient(cfg);
|
|
83
|
+
// Channel quarantine (cfg.excludeChannels): excluded conversations get no
|
|
84
|
+
// memory surface in either direction. Guard the shared choke points here —
|
|
85
|
+
// before_agent_start (all injection), agent_end (all writes), and the tool
|
|
86
|
+
// factories — so individual hooks stay quarantine-unaware.
|
|
87
|
+
const quarantined = (ctx) => {
|
|
88
|
+
if (!isExcludedChannel(ctx, cfg))
|
|
89
|
+
return false;
|
|
90
|
+
log.debug("channel quarantined — skipping memory surface");
|
|
91
|
+
return true;
|
|
92
|
+
};
|
|
93
|
+
const unlessQuarantined = (handler) => (event, ctx) => quarantined(ctx) ? undefined : handler(event, ctx);
|
|
94
|
+
const toolUnlessQuarantined = (factory) => (ctx) => quarantined(ctx) ? null : factory(ctx);
|
|
70
95
|
// Register AI tools (factory pattern for sender context)
|
|
71
|
-
api.registerTool(createSearchToolFactory(client, cfg), {
|
|
96
|
+
api.registerTool(toolUnlessQuarantined(createSearchToolFactory(client, cfg)), {
|
|
72
97
|
name: "hyperspell_search",
|
|
73
98
|
});
|
|
74
|
-
api.registerTool(createRememberToolFactory(client, cfg), {
|
|
99
|
+
api.registerTool(toolUnlessQuarantined(createRememberToolFactory(client, cfg)), {
|
|
75
100
|
name: "hyperspell_remember",
|
|
76
101
|
});
|
|
77
102
|
const startHandlers = [];
|
|
78
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" });
|
|
79
113
|
startHandlers.push(buildEmotionalStateFetchHandler(client, cfg));
|
|
80
114
|
api.on("after_compaction", buildEmotionalStateCompactionHandler());
|
|
81
115
|
api.on("session_end", buildEmotionalStateSessionCleanupHandler());
|
|
82
|
-
api.on("agent_end", buildEmotionalStateStoreHandler(client, cfg));
|
|
116
|
+
api.on("agent_end", unlessQuarantined(buildEmotionalStateStoreHandler(client, cfg)));
|
|
83
117
|
}
|
|
84
118
|
if (cfg.autoContext) {
|
|
85
119
|
startHandlers.push(buildAutoContextHandler(client, cfg));
|
|
@@ -98,6 +132,9 @@ export default {
|
|
|
98
132
|
}
|
|
99
133
|
if (startHandlers.length > 0) {
|
|
100
134
|
api.on("before_agent_start", async (event, ctx) => {
|
|
135
|
+
// Quarantined channels get no injected memory of any kind.
|
|
136
|
+
if (quarantined(ctx))
|
|
137
|
+
return undefined;
|
|
101
138
|
const results = await Promise.all(startHandlers.map((h) => Promise.resolve()
|
|
102
139
|
.then(() => h(event, ctx))
|
|
103
140
|
.catch((err) => {
|
|
@@ -115,12 +152,12 @@ export default {
|
|
|
115
152
|
}
|
|
116
153
|
// Register auto-trace hook (send conversations to Hyperspell on session end)
|
|
117
154
|
if (cfg.autoTrace.enabled) {
|
|
118
|
-
api.on("agent_end", buildAutoTraceHandler(client, cfg));
|
|
155
|
+
api.on("agent_end", unlessQuarantined(buildAutoTraceHandler(client, cfg)));
|
|
119
156
|
}
|
|
120
157
|
// Register hot-buffer hook: write each turn to POST /messages so it's
|
|
121
158
|
// instantly full-text searchable (vs. the slow /memories embedding path).
|
|
122
159
|
if (cfg.hotBuffer.enabled) {
|
|
123
|
-
api.on("agent_end", buildHotBufferHandler(client, cfg));
|
|
160
|
+
api.on("agent_end", unlessQuarantined(buildHotBufferHandler(client, cfg)));
|
|
124
161
|
api.on("session_end", buildHotBufferSessionCleanupHandler());
|
|
125
162
|
}
|
|
126
163
|
// Register memory sync hook
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel-level memory quarantine (`excludeChannels` config).
|
|
3
|
+
*
|
|
4
|
+
* A channel on the list gets NO memory surface at all, in both directions:
|
|
5
|
+
* no context injection (auto-context, emotional state, startup orientation),
|
|
6
|
+
* no memory writes (hot buffer, auto-trace, emotional store), and no memory
|
|
7
|
+
* tools. Use it for conversations that must never mix with the owner's vault —
|
|
8
|
+
* e.g. a channel someone else drives (a shared game, a public bot surface).
|
|
9
|
+
*
|
|
10
|
+
* Matching is against the conversation id OpenClaw resolves for the session
|
|
11
|
+
* (`ctx.channelId` on agent hook contexts — e.g. a Discord channel id). Tool
|
|
12
|
+
* factory contexts don't carry `channelId`, so we recover the same id from the
|
|
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).
|
|
18
|
+
*/
|
|
19
|
+
// Conversation-kind segments used in OpenClaw session keys. Mirrors core's
|
|
20
|
+
// TARGET_PREFIXES (src/plugins/hook-agent-context.ts) so the sessionKey
|
|
21
|
+
// fallback resolves the same id core would put on ctx.channelId.
|
|
22
|
+
const TARGET_KINDS = new Set(["channel", "chat", "direct", "dm", "group", "thread", "user"]);
|
|
23
|
+
/**
|
|
24
|
+
* Extract the conversation id from a composite session key, e.g.
|
|
25
|
+
* `agent:main:discord:channel:123` → `123`. Thread/run suffixes stay attached
|
|
26
|
+
* (`...:channel:123:thread:456` → `123:thread:456`); the prefix match in
|
|
27
|
+
* `isExcludedChannel` handles them. Returns undefined when the key has no
|
|
28
|
+
* recognizable conversation segment (cron runs, bare UUIDs, ...).
|
|
29
|
+
*/
|
|
30
|
+
export function conversationIdFromSessionKey(sessionKey) {
|
|
31
|
+
if (typeof sessionKey !== "string" || sessionKey.length === 0)
|
|
32
|
+
return undefined;
|
|
33
|
+
const parts = sessionKey.split(":").filter((p) => p.length > 0);
|
|
34
|
+
const body = parts[0]?.toLowerCase() === "agent" && parts.length >= 3 ? parts.slice(2) : parts;
|
|
35
|
+
if (body.length >= 3 && TARGET_KINDS.has(body[1]?.toLowerCase() ?? "")) {
|
|
36
|
+
return body.slice(2).join(":");
|
|
37
|
+
}
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
/** Resolve the conversation id from any hook or tool-factory context. */
|
|
41
|
+
export function channelIdFromCtx(ctx) {
|
|
42
|
+
const direct = ctx?.channelId;
|
|
43
|
+
if (typeof direct === "string" && direct.length > 0)
|
|
44
|
+
return direct;
|
|
45
|
+
return conversationIdFromSessionKey(ctx?.sessionKey);
|
|
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
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* True when the context's conversation is quarantined. Purely subtractive on
|
|
60
|
+
* failure: an unresolvable id means "not excluded" — a session we can't place
|
|
61
|
+
* keeps normal memory behavior rather than silently losing it.
|
|
62
|
+
*/
|
|
63
|
+
export function isExcludedChannel(ctx, cfg) {
|
|
64
|
+
if (cfg.excludeChannels.length === 0)
|
|
65
|
+
return false;
|
|
66
|
+
const id = channelIdFromCtx(ctx);
|
|
67
|
+
if (!id)
|
|
68
|
+
return false;
|
|
69
|
+
return cfg.excludeChannels.some((entry) => conversationMatchesChannel(id, entry));
|
|
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
|
+
}
|
package/dist/lib/sender.js
CHANGED
|
@@ -4,9 +4,16 @@ import { getVoiceIdentifier } from "./voice-id.js";
|
|
|
4
4
|
function matchFromSenderMap(ctx, cfg) {
|
|
5
5
|
const multiUser = cfg.multiUser;
|
|
6
6
|
if (!multiUser) {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
if (!cfg.userId)
|
|
8
|
+
return undefined;
|
|
9
|
+
// In single-user mode memory ownership stays cfg.userId, but still capture
|
|
10
|
+
// the envelope sender name so downstream context can name who spoke even
|
|
11
|
+
// when all writes go to the same store. resolved: false — this is a static
|
|
12
|
+
// default, not a confirmed sender match (issue #59).
|
|
13
|
+
const envName = ctx?.sender ??
|
|
14
|
+
ctx?.username ??
|
|
15
|
+
undefined;
|
|
16
|
+
return { userId: cfg.userId, name: envName ?? cfg.userId, resolved: false };
|
|
10
17
|
}
|
|
11
18
|
// Try direct senderId lookup (slash command contexts)
|
|
12
19
|
const senderId = ctx?.senderId ??
|
|
@@ -17,13 +24,18 @@ function matchFromSenderMap(ctx, cfg) {
|
|
|
17
24
|
log.debug(`sender resolved via senderId: ${senderId} -> ${profile.userId}`);
|
|
18
25
|
return { ...profile, resolved: true };
|
|
19
26
|
}
|
|
20
|
-
// Try sessionKey
|
|
27
|
+
// Try sessionKey segment matching (longest-first to avoid partial matches).
|
|
28
|
+
// We split on common separators and require the handle to appear as a whole
|
|
29
|
+
// token — not as a substring of another word — to prevent "ali" matching
|
|
30
|
+
// "alinea:voice-session-42". The senderId exact-match path above is always
|
|
31
|
+
// preferred; this path is a human-readable-handle fallback.
|
|
21
32
|
const sessionKey = ctx?.sessionKey;
|
|
22
33
|
if (sessionKey) {
|
|
34
|
+
const tokens = new Set(sessionKey.split(/[:\-\/\s@#.]+/));
|
|
23
35
|
const sortedEntries = Object.entries(multiUser.senderMap).sort(([a], [b]) => b.length - a.length);
|
|
24
36
|
for (const [handle, profile] of sortedEntries) {
|
|
25
|
-
if (
|
|
26
|
-
log.debug(`sender resolved via sessionKey: ${handle} -> ${profile.userId}`);
|
|
37
|
+
if (tokens.has(handle)) {
|
|
38
|
+
log.debug(`sender resolved via sessionKey token: ${handle} -> ${profile.userId}`);
|
|
27
39
|
return { ...profile, resolved: true };
|
|
28
40
|
}
|
|
29
41
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session speaker tracking — evidence-based multi-speaker detection.
|
|
3
|
+
*
|
|
4
|
+
* PR #60 gated all group-chat guards on `is_group_chat: true` from the
|
|
5
|
+
* inbound envelope. That field is set by the platform connector and not all
|
|
6
|
+
* connectors set it consistently (Discord channels, Slack DM groups, voice
|
|
7
|
+
* rooms). This module tracks distinct sender_ids observed within each session
|
|
8
|
+
* so multi-speaker detection works from evidence rather than a connector
|
|
9
|
+
* boolean.
|
|
10
|
+
*
|
|
11
|
+
* Lifecycle:
|
|
12
|
+
* - record() on every turn that has a resolvable senderId (hot-buffer agent_end,
|
|
13
|
+
* auto-context before_agent_start)
|
|
14
|
+
* - isMultiSpeaker() checked by any hook or tool that needs to guard behaviour
|
|
15
|
+
* - cleanup() on session_end, called from the hot-buffer cleanup handler
|
|
16
|
+
*/
|
|
17
|
+
/** sessionId → set of distinct sender_ids seen in that session */
|
|
18
|
+
const sessionSenders = new Map();
|
|
19
|
+
/**
|
|
20
|
+
* Record a sender_id for a session. No-op if senderId is empty/undefined.
|
|
21
|
+
* Called on every turn so the tracker accumulates evidence across the session.
|
|
22
|
+
*/
|
|
23
|
+
export function recordSender(sessionId, senderId) {
|
|
24
|
+
if (!senderId)
|
|
25
|
+
return;
|
|
26
|
+
if (!sessionSenders.has(sessionId))
|
|
27
|
+
sessionSenders.set(sessionId, new Set());
|
|
28
|
+
sessionSenders.get(sessionId).add(senderId);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Resolve the senderId from a hook context, checking both camelCase
|
|
32
|
+
* (OpenClaw-normalised) and snake_case (raw connector form).
|
|
33
|
+
*/
|
|
34
|
+
export function senderIdFromCtx(ctx) {
|
|
35
|
+
return (ctx?.senderId ??
|
|
36
|
+
ctx?.sender_id ??
|
|
37
|
+
ctx?.requesterSenderId ??
|
|
38
|
+
undefined);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* True when two or more distinct sender_ids have appeared in this session, OR
|
|
42
|
+
* when the connector explicitly signalled `is_group_chat: true`. Either is
|
|
43
|
+
* sufficient — the envelope flag catches cases before the second sender speaks;
|
|
44
|
+
* the tracker catches cases where the flag was never set.
|
|
45
|
+
*/
|
|
46
|
+
export function isMultiSpeaker(sessionId, envelopeGroupChat) {
|
|
47
|
+
if (envelopeGroupChat)
|
|
48
|
+
return true;
|
|
49
|
+
if (!sessionId)
|
|
50
|
+
return false;
|
|
51
|
+
return (sessionSenders.get(sessionId)?.size ?? 0) > 1;
|
|
52
|
+
}
|
|
53
|
+
/** Drop the per-session entry on session end to prevent unbounded growth. */
|
|
54
|
+
export function cleanupSpeakerSession(sessionId) {
|
|
55
|
+
sessionSenders.delete(sessionId);
|
|
56
|
+
}
|
|
@@ -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
|
+
}
|