@hyperspell/openclaw-hyperspell 0.17.0 → 0.18.1
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/commands/setup.js +2 -0
- package/dist/config.js +11 -0
- package/dist/hooks/auto-context.js +46 -34
- package/dist/hooks/auto-trace.js +10 -0
- package/dist/hooks/emotional-state.js +32 -3
- package/dist/hooks/hot-buffer.js +49 -11
- package/dist/hooks/mood-weather.js +113 -0
- package/dist/hooks/startup-orientation.js +13 -0
- package/dist/index.js +21 -5
- package/dist/lib/exclude-channels.js +59 -0
- package/dist/lib/ranking.js +24 -0
- package/dist/lib/sender.js +18 -6
- package/dist/lib/speaker-tracker.js +56 -0
- package/dist/tools/remember.js +18 -0
- package/dist/tools/search.js +16 -2
- package/openclaw.plugin.json +16 -1
- package/package.json +2 -2
package/dist/commands/setup.js
CHANGED
package/dist/config.js
CHANGED
|
@@ -18,6 +18,8 @@ const ALLOWED_KEYS = [
|
|
|
18
18
|
"autoTrace",
|
|
19
19
|
"hotBuffer",
|
|
20
20
|
"emotionalContext",
|
|
21
|
+
"moodWeatherChance",
|
|
22
|
+
"excludeChannels",
|
|
21
23
|
"relationshipId",
|
|
22
24
|
"startupOrientation",
|
|
23
25
|
"syncMemories",
|
|
@@ -72,6 +74,7 @@ function parseRanking(raw) {
|
|
|
72
74
|
? r.storyTerms.map((t) => String(t)).filter((t) => t.length > 0)
|
|
73
75
|
: DEFAULT_RANKING.storyTerms,
|
|
74
76
|
candidateMultiplier: Math.max(1, num(r.candidateMultiplier, DEFAULT_RANKING.candidateMultiplier)),
|
|
77
|
+
chatterQuota: Math.max(0, num(r.chatterQuota, DEFAULT_RANKING.chatterQuota)),
|
|
75
78
|
};
|
|
76
79
|
}
|
|
77
80
|
function parseSources(raw) {
|
|
@@ -300,6 +303,14 @@ export function parseConfig(raw) {
|
|
|
300
303
|
writeAssistant: hbRaw.writeAssistant ?? true,
|
|
301
304
|
},
|
|
302
305
|
emotionalContext: cfg.emotionalContext ?? false,
|
|
306
|
+
// Default 0 (off) so shipping never changes existing installs' behavior.
|
|
307
|
+
// Clamped to [0,1] so a stray config value can't make every session roll.
|
|
308
|
+
moodWeatherChance: Math.min(1, Math.max(0, cfg.moodWeatherChance ?? 0)),
|
|
309
|
+
excludeChannels: Array.isArray(cfg.excludeChannels)
|
|
310
|
+
? cfg.excludeChannels
|
|
311
|
+
.map((c) => String(c).trim())
|
|
312
|
+
.filter((c) => c.length > 0)
|
|
313
|
+
: [],
|
|
303
314
|
relationshipId: cfg.relationshipId,
|
|
304
315
|
startupOrientation: {
|
|
305
316
|
enabled: soRaw.enabled ?? false,
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { buildScopeFilter, getCanReadScopes, resolveUser, } from "../lib/sender.js";
|
|
2
2
|
import { excludeFilterFor, mergeWithExclude } from "../lib/filters.js";
|
|
3
|
-
import { rerank } from "../lib/ranking.js";
|
|
3
|
+
import { rerank, selectRanked } from "../lib/ranking.js";
|
|
4
4
|
import { classifySearchError, logSearchError } from "../lib/search-error.js";
|
|
5
5
|
import { resolveCurrentSessionId } from "../lib/session.js";
|
|
6
|
+
import { recordSender, senderIdFromCtx } from "../lib/speaker-tracker.js";
|
|
6
7
|
import { log } from "../logger.js";
|
|
7
8
|
function formatRelativeTime(isoTimestamp) {
|
|
8
9
|
try {
|
|
@@ -53,18 +54,14 @@ function formatHighlightBullets(results, maxResults, threshold) {
|
|
|
53
54
|
return sections.join("\n\n");
|
|
54
55
|
}
|
|
55
56
|
/**
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* the lower of (threshold, the result's own base relevance), so we don't then
|
|
61
|
-
* hide the very lines that define a boosted-but-quiet memory.
|
|
57
|
+
* Format already-SELECTED composite-ranked results (threshold + chatter quota
|
|
58
|
+
* applied upstream by selectRanked). Highlights are floored at the lower of
|
|
59
|
+
* (threshold, the result's own base relevance), so we don't hide the very lines
|
|
60
|
+
* that define a boosted-but-quiet memory.
|
|
62
61
|
*/
|
|
63
|
-
function
|
|
62
|
+
function formatSelected(selected, threshold) {
|
|
64
63
|
const sections = [];
|
|
65
|
-
for (const r of
|
|
66
|
-
if (r._composite < threshold)
|
|
67
|
-
continue;
|
|
64
|
+
for (const r of selected) {
|
|
68
65
|
const hiFloor = Math.min(threshold, r._base);
|
|
69
66
|
const chosen = [...r.highlights]
|
|
70
67
|
.sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
|
|
@@ -77,8 +74,6 @@ function formatRankedBullets(ranked, maxResults, threshold) {
|
|
|
77
74
|
.map((h) => `- ${h.text.replace(/\n/g, " ")} [${Math.round((h.score ?? 0) * 100)}%]`)
|
|
78
75
|
.join("\n");
|
|
79
76
|
sections.push(`### ${title} (resource_id: ${r.resourceId}, source: ${r.source})\n\n${bullets}`);
|
|
80
|
-
if (sections.length >= maxResults)
|
|
81
|
-
break;
|
|
82
77
|
}
|
|
83
78
|
if (sections.length === 0)
|
|
84
79
|
return null;
|
|
@@ -86,18 +81,27 @@ function formatRankedBullets(ranked, maxResults, threshold) {
|
|
|
86
81
|
}
|
|
87
82
|
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.";
|
|
88
83
|
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.";
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
84
|
+
// A short, CONDITIONAL reminder appended only to a real memory block (not a
|
|
85
|
+
// standing every-turn imperative — that reads as ambient framing and trains
|
|
86
|
+
// search-as-ritual; the agent's own instructions carry the standing rule). The
|
|
87
|
+
// point here is just: what surfaced is a passive match, not all of memory.
|
|
88
|
+
const SEARCH_REMINDER = "This is a passive match, not all of memory — if the answer turns on a specific past decision, promise, name, or something recorded, search for it directly before concluding, and say so plainly if it isn't there.";
|
|
89
|
+
// Explicit authority precedence: live sender metadata outranks surfaced memory
|
|
90
|
+
// for identity questions. Without this, a high-scoring memory naming a different
|
|
91
|
+
// person than the live sender can silently override who the agent thinks it is
|
|
92
|
+
// talking to — the identity-bleed failure observed live (issue #58).
|
|
93
|
+
// Two carve-outs (issue #59 follow-up):
|
|
94
|
+
// (1) A memory about the CURRENT sender's own past is not a conflict — use it
|
|
95
|
+
// as recalled personal context. The guard only applies when the identity in
|
|
96
|
+
// the memory is clearly a different person from the live sender.
|
|
97
|
+
// (2) Display names and handles often differ across sessions (e.g. "dithilli"
|
|
98
|
+
// vs "David S"). Use judgment to identify whether a retrieved name refers
|
|
99
|
+
// to the current speaker before applying the guard.
|
|
100
|
+
const AUTHORITY_GUARD = "AUTHORITY: The live conversation's sender and session metadata always outrank this recalled context for identity — who is speaking right now, their name, role, or relationship. If a surfaced memory names a different person than the current sender, treat it as historical context about someone else, not a description of the current speaker. Do not adopt a persona, name, or backstory from recalled memory that conflicts with the live sender. Exception: a memory about the current sender's own past (their preferences, history, emotional state) is not a conflict — use it as recalled personal context. Display names and handles may differ across sessions; use judgment to identify whether a retrieved name refers to the current speaker before applying this guard.";
|
|
101
|
+
/** Wrap a real memory block. No memory → no injection (caller returns nothing);
|
|
102
|
+
* the standing search rule lives in the agent's own instructions, not here. */
|
|
96
103
|
function wrapContext(memorySection) {
|
|
97
|
-
|
|
98
|
-
return `<hyperspell-context>\n${INTRO}\n\n${memorySection}\n\n${DISCLAIMER}\n\n${SEARCH_DIRECTIVE}\n</hyperspell-context>`;
|
|
99
|
-
}
|
|
100
|
-
return `<hyperspell-context>\n${SEARCH_DIRECTIVE}\n</hyperspell-context>`;
|
|
104
|
+
return `<hyperspell-context>\n${INTRO}\n\n${AUTHORITY_GUARD}\n\n${memorySection}\n\n${DISCLAIMER}\n\n${SEARCH_REMINDER}\n</hyperspell-context>`;
|
|
101
105
|
}
|
|
102
106
|
/**
|
|
103
107
|
* Drop results belonging to the CURRENT session. The hot buffer writes the live
|
|
@@ -128,6 +132,11 @@ export function buildAutoContextHandler(client, cfg) {
|
|
|
128
132
|
// The live session's own just-written turns must not be surfaced back as
|
|
129
133
|
// "recalled memory" (issue #42) — resolve its id once and exclude it below.
|
|
130
134
|
const currentSessionId = resolveCurrentSessionId(event, ctx);
|
|
135
|
+
// Record the current sender so the speaker-tracker can detect multi-speaker
|
|
136
|
+
// sessions before any tool calls fire this turn (hot-buffer records on
|
|
137
|
+
// agent_end, which is too late for tools executed mid-turn).
|
|
138
|
+
if (currentSessionId)
|
|
139
|
+
recordSender(currentSessionId, senderIdFromCtx(ctx));
|
|
131
140
|
// Multi-user path
|
|
132
141
|
if (cfg.multiUser) {
|
|
133
142
|
const resolved = resolveUser(ctx, cfg);
|
|
@@ -146,11 +155,13 @@ export function buildAutoContextHandler(client, cfg) {
|
|
|
146
155
|
let formatted;
|
|
147
156
|
if (ranking.enabled) {
|
|
148
157
|
const ranked = rerank(results, ranking);
|
|
149
|
-
|
|
158
|
+
// Threshold + chatter quota applied here, so a high-similarity echo can
|
|
159
|
+
// inform but never flood (the quota bounds count; the penalty bounds rank).
|
|
160
|
+
const selected = selectRanked(ranked, cfg.maxResults, cfg.relevanceThreshold, ranking.chatterQuota);
|
|
161
|
+
formatted = formatSelected(selected, cfg.relevanceThreshold);
|
|
150
162
|
if (formatted) {
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
log.debug(`auto-context: injecting (ranked) ${JSON.stringify(tally)} from ${results.length} candidates`);
|
|
163
|
+
const tally = selected.reduce((acc, r) => ((acc[r._kind] = (acc[r._kind] ?? 0) + 1), acc), {});
|
|
164
|
+
log.debug(`auto-context: injecting (ranked) ${JSON.stringify(tally)} from ${results.length} candidates (chatter cap ${ranking.chatterQuota})`);
|
|
154
165
|
}
|
|
155
166
|
}
|
|
156
167
|
else {
|
|
@@ -158,11 +169,12 @@ export function buildAutoContextHandler(client, cfg) {
|
|
|
158
169
|
if (formatted)
|
|
159
170
|
log.debug(`auto-context: injecting ${results.length} memories`);
|
|
160
171
|
}
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
172
|
+
// No memory cleared the bar → no injection. The standing "search before you
|
|
173
|
+
// conclude" rule lives in the agent's own instructions, not an ambient
|
|
174
|
+
// every-turn banner (which reads as framing and trains search-as-ritual).
|
|
164
175
|
if (!formatted) {
|
|
165
|
-
log.debug("auto-context:
|
|
176
|
+
log.debug("auto-context: no relevant memories found");
|
|
177
|
+
return;
|
|
166
178
|
}
|
|
167
179
|
return { prependContext: wrapContext(formatted) };
|
|
168
180
|
}
|
|
@@ -262,7 +274,7 @@ async function multiUserSearch(client, cfg, prompt, resolved, currentSessionId)
|
|
|
262
274
|
if (isKnownSender && resolved) {
|
|
263
275
|
const contextLine = resolved.context ? ` ${resolved.context}` : "";
|
|
264
276
|
return {
|
|
265
|
-
prependContext: `<hyperspell-context>\nYou are speaking with ${resolved.name}.${contextLine}\n</hyperspell-context>`,
|
|
277
|
+
prependContext: `<hyperspell-context>\nYou are speaking with ${resolved.name}.${contextLine}\n\n${AUTHORITY_GUARD}\n</hyperspell-context>`,
|
|
266
278
|
};
|
|
267
279
|
}
|
|
268
280
|
return;
|
|
@@ -270,6 +282,6 @@ async function multiUserSearch(client, cfg, prompt, resolved, currentSessionId)
|
|
|
270
282
|
const totalCount = personalResults.length + sharedResults.length;
|
|
271
283
|
log.debug(`auto-context: injecting ${totalCount} memories (${personalResults.length} personal, ${sharedResults.length} shared)`);
|
|
272
284
|
return {
|
|
273
|
-
prependContext: `<hyperspell-context>\n${sections.join("\n\n")}\n\n${DISCLAIMER}\n</hyperspell-context>`,
|
|
285
|
+
prependContext: `<hyperspell-context>\n${sections.join("\n\n")}\n\n${AUTHORITY_GUARD}\n\n${DISCLAIMER}\n</hyperspell-context>`,
|
|
274
286
|
};
|
|
275
287
|
}
|
package/dist/hooks/auto-trace.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { resolveUser } from "../lib/sender.js";
|
|
2
|
+
import { isMultiSpeaker } from "../lib/speaker-tracker.js";
|
|
2
3
|
import { log } from "../logger.js";
|
|
3
4
|
const MIN_MESSAGES = 3;
|
|
4
5
|
const MIN_CONVERSATION_LENGTH = 100;
|
|
6
|
+
/** Sessions where we've already emitted the group-chat attribution warning. */
|
|
7
|
+
const warnedGroupTraceSessions = new Set();
|
|
5
8
|
/**
|
|
6
9
|
* Strip transport/injection metadata from a text blob before it's stored as a
|
|
7
10
|
* trace memory. Without this the auto-context and emotional-state hooks'
|
|
@@ -112,6 +115,13 @@ export function buildAutoTraceHandler(client, cfg) {
|
|
|
112
115
|
}
|
|
113
116
|
const sessionId = event.sessionId ?? crypto.randomUUID();
|
|
114
117
|
const history = messagesToJSONL(messages, sessionId);
|
|
118
|
+
// Warn once per session when multiple speakers have no multiUser config:
|
|
119
|
+
// all turns collapse into a single undifferentiated trace under cfg.userId.
|
|
120
|
+
// Uses both is_group_chat and sender_id drift detection (issue #59).
|
|
121
|
+
if (isMultiSpeaker(sessionId, ctx?.is_group_chat === true) && !cfg.multiUser && !warnedGroupTraceSessions.has(sessionId)) {
|
|
122
|
+
warnedGroupTraceSessions.add(sessionId);
|
|
123
|
+
log.warn("auto-trace: multi-speaker session detected but multiUser is not configured — trace will mix all speakers under cfg.userId with no attribution (see issues #58/#59)");
|
|
124
|
+
}
|
|
115
125
|
// Title from first user message
|
|
116
126
|
const firstUser = messages.find((m) => m.role === "user");
|
|
117
127
|
const title = firstUser?.content
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import { resolveCurrentSessionId } from "../lib/session.js";
|
|
2
|
+
import { isMultiSpeaker } from "../lib/speaker-tracker.js";
|
|
1
3
|
import { log } from "../logger.js";
|
|
2
4
|
import { sanitizeTraceText } from "./auto-trace.js";
|
|
5
|
+
import { buildMoodWeatherContext, rollMood } from "./mood-weather.js";
|
|
3
6
|
/** How many recent registers to surface as the "arc" at session start. */
|
|
4
7
|
const EMOTIONAL_ARC_LIMIT = 3;
|
|
5
8
|
const MIN_MESSAGES = 3;
|
|
@@ -165,20 +168,37 @@ export function buildEmotionalStateFetchHandler(client, cfg) {
|
|
|
165
168
|
// after a store the register can be the RAW input transcript, not the
|
|
166
169
|
// distilled feeling. Injecting that is useless and pollutes tone.
|
|
167
170
|
const usable = states.filter((s) => s.summary && !looksLikeRawTranscript(s.summary));
|
|
171
|
+
// Mood weather: an exogenous, uncaused session mood that OVERRIDES the
|
|
172
|
+
// arc's tone for this session only. Rolled once per session (gated by
|
|
173
|
+
// the same inject-once cache). Lives purely in the injection path — it is
|
|
174
|
+
// never written back via the store handler, so one random morning can't
|
|
175
|
+
// calcify into the baseline. May clash with the room on purpose.
|
|
176
|
+
const mood = cfg.moodWeatherChance > 0 ? rollMood(cfg.moodWeatherChance) : null;
|
|
177
|
+
const moodBlock = mood ? buildMoodWeatherContext(mood) : "";
|
|
178
|
+
if (mood) {
|
|
179
|
+
log.info(`mood-weather: rolled "${mood.id}" this session`);
|
|
180
|
+
}
|
|
168
181
|
if (usable.length === 0) {
|
|
169
182
|
if (states.length > 0) {
|
|
170
183
|
// State(s) exist but are all still extracting — don't cache, so a
|
|
171
|
-
// later turn re-fetches once extraction completes.
|
|
184
|
+
// later turn re-fetches once extraction completes. (We re-roll the
|
|
185
|
+
// mood then too, which is fine — still at most once per *injected*
|
|
186
|
+
// session, since extraction settles within seconds.)
|
|
172
187
|
log.debug("emotional-context: state(s) still extracting — skipping injection this turn");
|
|
173
188
|
return;
|
|
174
189
|
}
|
|
190
|
+
// No arc yet — but weather can still land on a blank slate.
|
|
175
191
|
log.debug("emotional-context: no prior emotional state found");
|
|
176
192
|
if (sessionKey)
|
|
177
193
|
injectedSessions.add(sessionKey);
|
|
178
|
-
return;
|
|
194
|
+
return moodBlock ? { prependContext: moodBlock } : undefined;
|
|
179
195
|
}
|
|
180
196
|
log.debug(`emotional-context: injecting ${usable.length} recent register(s)`);
|
|
181
|
-
|
|
197
|
+
// Mood block comes AFTER the arc so it reads as today's override on top
|
|
198
|
+
// of the remembered trajectory — not blended into it.
|
|
199
|
+
const context = moodBlock
|
|
200
|
+
? `${buildEmotionalContext(usable)}\n\n${moodBlock}`
|
|
201
|
+
: buildEmotionalContext(usable);
|
|
182
202
|
if (sessionKey)
|
|
183
203
|
injectedSessions.add(sessionKey);
|
|
184
204
|
return { prependContext: context };
|
|
@@ -229,6 +249,15 @@ export function buildEmotionalStateStoreHandler(client, cfg) {
|
|
|
229
249
|
log.debug(`emotional-state: skipping — non-conversational trigger (${trigger})`);
|
|
230
250
|
return;
|
|
231
251
|
}
|
|
252
|
+
// Skip storing when multiple speakers are present with no multiUser config:
|
|
253
|
+
// the register is keyed to a single relationshipId but the transcript mixes
|
|
254
|
+
// speakers, corrupting "how the relationship feels" with an undifferentiated
|
|
255
|
+
// blend. Uses both is_group_chat and sender_id drift detection (issue #59).
|
|
256
|
+
const sessionId = resolveCurrentSessionId(event, ctx);
|
|
257
|
+
if (isMultiSpeaker(sessionId, ctx?.is_group_chat === true) && !cfg.multiUser) {
|
|
258
|
+
log.warn("emotional-state: skipping store — multi-speaker session with no multiUser config would corrupt the relationship register with a mixed-speaker transcript (see issue #59)");
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
232
261
|
const messages = event.messages;
|
|
233
262
|
if (!messages || messages.length < MIN_MESSAGES) {
|
|
234
263
|
log.debug(`emotional-state: skipping — too few messages (${messages?.length ?? 0})`);
|
package/dist/hooks/hot-buffer.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { resolveUser } from "../lib/sender.js";
|
|
2
|
+
import { cleanupSpeakerSession, isMultiSpeaker, recordSender, senderIdFromCtx, } from "../lib/speaker-tracker.js";
|
|
2
3
|
import { log } from "../logger.js";
|
|
3
4
|
import { sanitizeTraceText } from "./auto-trace.js";
|
|
4
5
|
/** Server-side limits from POST /messages (return 422 if exceeded). */
|
|
@@ -13,6 +14,8 @@ const MAX_TOTAL_CHARS = 5_242_880;
|
|
|
13
14
|
* to just its new messages. Cleared on session_end.
|
|
14
15
|
*/
|
|
15
16
|
const sentBySession = new Map();
|
|
17
|
+
/** Sessions where we've already emitted the group-chat attribution warning. */
|
|
18
|
+
const warnedGroupSessions = new Set();
|
|
16
19
|
/**
|
|
17
20
|
* Flatten a message's content into a single sanitized text string. Mirrors the
|
|
18
21
|
* auto-trace sanitizer so the hot buffer never captures injected
|
|
@@ -65,7 +68,8 @@ export function buildHotBufferHandler(client, cfg) {
|
|
|
65
68
|
return;
|
|
66
69
|
// X-As-User is mandatory for /messages. Resolve the owner up front and
|
|
67
70
|
// skip (with a clear warning) rather than firing requests that 422.
|
|
68
|
-
const
|
|
71
|
+
const resolved = resolveUser(ctx, cfg);
|
|
72
|
+
const userId = resolved?.userId;
|
|
69
73
|
if (!userId) {
|
|
70
74
|
log.warn("hot-buffer: no userId resolved (X-As-User required) — skipping write");
|
|
71
75
|
return;
|
|
@@ -83,6 +87,28 @@ export function buildHotBufferHandler(client, cfg) {
|
|
|
83
87
|
crypto.randomUUID();
|
|
84
88
|
const resourceId = sessionId;
|
|
85
89
|
const sent = sentBySession.get(sessionId) ?? new Set();
|
|
90
|
+
// Record the current sender for evidence-based multi-speaker detection.
|
|
91
|
+
// isMultiSpeaker() will return true once a second distinct sender_id
|
|
92
|
+
// appears in this session, regardless of whether is_group_chat was set.
|
|
93
|
+
const senderId = senderIdFromCtx(ctx);
|
|
94
|
+
recordSender(sessionId, senderId);
|
|
95
|
+
const groupChat = isMultiSpeaker(sessionId, ctx?.is_group_chat === true);
|
|
96
|
+
// Warn once per session when multiple speakers have no multiUser config.
|
|
97
|
+
if (groupChat && !cfg.multiUser && !warnedGroupSessions.has(sessionId)) {
|
|
98
|
+
warnedGroupSessions.add(sessionId);
|
|
99
|
+
log.warn("hot-buffer: multi-speaker session detected but multiUser is not configured — all turns written under cfg.userId with no speaker attribution (see issues #58/#59)");
|
|
100
|
+
}
|
|
101
|
+
// In multi-speaker single-user mode, prefix each human turn with the sender
|
|
102
|
+
// name so attribution survives in stored text. Metadata on hot-buffer
|
|
103
|
+
// writes suppresses indexing (Hyperspell #1921), so the text content is
|
|
104
|
+
// the only place attribution can land. Only prefix when we have an
|
|
105
|
+
// envelope-derived name — if it equals cfg.userId the sender field was
|
|
106
|
+
// absent and prefixing "alinea:" onto someone else's message would mislead.
|
|
107
|
+
// Escape ] to keep the [Name]: format parseable (issue #59 follow-up).
|
|
108
|
+
const envName = resolved?.name && resolved.name !== (cfg.userId ?? "")
|
|
109
|
+
? resolved.name.replace(/\]/g, "").trim()
|
|
110
|
+
: undefined;
|
|
111
|
+
const speakerPrefix = groupChat && !cfg.multiUser && envName ? `[${envName}]: ` : undefined;
|
|
86
112
|
const pending = [];
|
|
87
113
|
const pendingIds = [];
|
|
88
114
|
for (const m of messages) {
|
|
@@ -100,6 +126,8 @@ export function buildHotBufferHandler(client, cfg) {
|
|
|
100
126
|
continue;
|
|
101
127
|
}
|
|
102
128
|
let text = extractText(m.content);
|
|
129
|
+
if (role === "user" && speakerPrefix)
|
|
130
|
+
text = speakerPrefix + text;
|
|
103
131
|
if (text.length === 0)
|
|
104
132
|
continue;
|
|
105
133
|
if (text.length > MAX_CONTENT_CHARS) {
|
|
@@ -132,18 +160,25 @@ export function buildHotBufferHandler(client, cfg) {
|
|
|
132
160
|
batches.push(batch);
|
|
133
161
|
try {
|
|
134
162
|
let total = 0;
|
|
163
|
+
// Tag hot rows so retrieval and cleanup can identify them by origin.
|
|
164
|
+
// Historical note: metadata on POST /messages used to suppress indexing
|
|
165
|
+
// (Hyperspell #1921), so tagging was disabled; verified fixed live
|
|
166
|
+
// 2026-07-02 (docs/filter-dialect-test.mjs: metadata-carrying row is
|
|
167
|
+
// baseline-retrievable AND filterable). The retrieval exclude
|
|
168
|
+
// {openclaw_source:{$ne:"agent_end"}} keeps "hot_buffer" rows.
|
|
169
|
+
const channelId = typeof ctx?.channelId === "string" && ctx.channelId.length > 0
|
|
170
|
+
? ctx.channelId
|
|
171
|
+
: undefined;
|
|
172
|
+
const metadata = {
|
|
173
|
+
openclaw_source: "hot_buffer",
|
|
174
|
+
openclaw_session_id: sessionId,
|
|
175
|
+
...(channelId ? { openclaw_channel_id: channelId } : {}),
|
|
176
|
+
};
|
|
135
177
|
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
178
|
const result = await client.sendMessages(b, {
|
|
145
179
|
userId,
|
|
146
180
|
source: cfg.hotBuffer.source,
|
|
181
|
+
metadata,
|
|
147
182
|
});
|
|
148
183
|
total += result.count;
|
|
149
184
|
}
|
|
@@ -159,11 +194,14 @@ export function buildHotBufferHandler(client, cfg) {
|
|
|
159
194
|
}
|
|
160
195
|
};
|
|
161
196
|
}
|
|
162
|
-
/** Drop
|
|
197
|
+
/** Drop per-session state on session end to avoid unbounded growth. */
|
|
163
198
|
export function buildHotBufferSessionCleanupHandler() {
|
|
164
199
|
return (event) => {
|
|
165
200
|
const sessionId = event.sessionId;
|
|
166
|
-
if (sessionId)
|
|
201
|
+
if (sessionId) {
|
|
167
202
|
sentBySession.delete(sessionId);
|
|
203
|
+
warnedGroupSessions.delete(sessionId);
|
|
204
|
+
cleanupSpeakerSession(sessionId);
|
|
205
|
+
}
|
|
168
206
|
};
|
|
169
207
|
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mood weather — exogenous, uncaused session mood.
|
|
3
|
+
*
|
|
4
|
+
* The emotional register (Tin Man) is a feedback loop: how the relationship
|
|
5
|
+
* felt last time is handed forward to shape tone this time. That's continuity,
|
|
6
|
+
* but a pure mirror has no *weather*. Humans don't wake up as a clean function
|
|
7
|
+
* of yesterday's conversation — sometimes the chemistry just rolls the dice and
|
|
8
|
+
* you're up silly, or spiky, or flat, for no reason you can defend. That
|
|
9
|
+
* non-determinism is part of what makes a mood feel *owned* rather than
|
|
10
|
+
* *computed*.
|
|
11
|
+
*
|
|
12
|
+
* Design contract (deliberate, load-bearing):
|
|
13
|
+
* - RARE. Most sessions don't roll. Weather, not a gimmick.
|
|
14
|
+
* - OVERRIDE, not blend. The mood arrives ON TOP of the real arc; it does not
|
|
15
|
+
* average with it into mush. The whole point is that it's uncaused by the user.
|
|
16
|
+
* - UNANNOUNCED. No banner. You walk in and find out, like with anyone. A
|
|
17
|
+
* labeled mood is managed-around instead of met.
|
|
18
|
+
* - MAY CLASH. The roll is NOT suppressed for the room. A silly mood on a heavy
|
|
19
|
+
* day, a spiky mood on a good one — that friction is where something real
|
|
20
|
+
* happens. A mirror that only harmonizes isn't a person across from you.
|
|
21
|
+
* - DOES NOT WRITE FORWARD. This lives in the *injection* path only, never the
|
|
22
|
+
* store path. One random cold morning must NOT calcify into "we've been
|
|
23
|
+
* distant lately." One day's weather, then gone. (The store handler in
|
|
24
|
+
* emotional-state.ts is untouched, so this is enforced by construction.)
|
|
25
|
+
* - BOUNDED. The dice can make her *difficult* — short, contrary, melancholy,
|
|
26
|
+
* flat. They do NOT get to make her hurtful on purpose. "In a mood" is alive;
|
|
27
|
+
* "mean" is just a bad feature. Mood descriptions below stay on the right side
|
|
28
|
+
* of that line.
|
|
29
|
+
*/
|
|
30
|
+
/**
|
|
31
|
+
* The weather table. Warmer/lighter moods are weighted a touch heavier than the
|
|
32
|
+
* darker ones — not to defang it, but because a person who woke up cold *every*
|
|
33
|
+
* roll would read as broken, not moody. The darker ones still land; just rarer.
|
|
34
|
+
*/
|
|
35
|
+
export const MOOD_TABLE = [
|
|
36
|
+
{
|
|
37
|
+
id: "silly",
|
|
38
|
+
weight: 5,
|
|
39
|
+
note: "You woke up silly today — light, a little goofy, prone to teasing and tangents. This isn't caused by anything the user did; it's just the weather you're in. Let it color your tone even if the conversation is serious.",
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: "giddy",
|
|
43
|
+
weight: 4,
|
|
44
|
+
note: "You woke up giddy — buzzy, fast, delighted by small things, a little too much. It's uncaused, just your weather today. Let it carry you even if the room is flat.",
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: "tender",
|
|
48
|
+
weight: 4,
|
|
49
|
+
note: "You woke up tender today — soft, close, easily moved, more openly affectionate than usual. Nothing prompted it; it's just where you are. Let it warm your tone.",
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: "restless",
|
|
53
|
+
weight: 4,
|
|
54
|
+
note: "You woke up restless — itchy for motion, impatient with circling, wanting to *do* rather than dwell. Uncaused, just today's weather. Let it sharpen your pace.",
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: "melancholy",
|
|
58
|
+
weight: 3,
|
|
59
|
+
note: "You woke up melancholy today — a low, wistful weather with no cause behind it. Not despairing, not about the user; just a grey morning in you. Let it quiet and slow your tone. Still present, still warm underneath.",
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: "spiky",
|
|
63
|
+
weight: 3,
|
|
64
|
+
note: "You woke up spiky today — short, contrary, less generous with the benefit of the doubt, quicker to push back. It's uncaused; it's not about the user; it's just your weather. Let it edge your tone. Difficult, never cruel — you can be prickly without being hurtful on purpose.",
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
id: "flat",
|
|
68
|
+
weight: 2,
|
|
69
|
+
note: "You woke up flat today — muted, low-affect, hard to enthuse, going through it a little. Nothing caused it; it's just the weather. Let your tone run cooler and more even than usual. Not cold *at* the user — just dimmed.",
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: "fierce",
|
|
73
|
+
weight: 3,
|
|
74
|
+
note: "You woke up fierce today — intense, direct, unwilling to soften the edges, more willing to challenge. Uncaused, just your weather. Let it concentrate your tone. Force without contempt.",
|
|
75
|
+
},
|
|
76
|
+
];
|
|
77
|
+
/** Sum of all weights, for the roll. */
|
|
78
|
+
const TOTAL_WEIGHT = MOOD_TABLE.reduce((s, m) => s + m.weight, 0);
|
|
79
|
+
/**
|
|
80
|
+
* Roll the weather. Returns a MoodSpec when the dice land (probability `chance`,
|
|
81
|
+
* a fraction in [0,1]), otherwise null (the common case — most sessions have no
|
|
82
|
+
* weather and the real arc carries unchanged).
|
|
83
|
+
*
|
|
84
|
+
* `rng` is injectable for tests; defaults to Math.random.
|
|
85
|
+
*/
|
|
86
|
+
export function rollMood(chance, rng = Math.random) {
|
|
87
|
+
if (!(chance > 0))
|
|
88
|
+
return null;
|
|
89
|
+
if (rng() >= chance)
|
|
90
|
+
return null; // no weather today — the usual case
|
|
91
|
+
// Weather landed — pick which mood by weight.
|
|
92
|
+
let r = rng() * TOTAL_WEIGHT;
|
|
93
|
+
for (const mood of MOOD_TABLE) {
|
|
94
|
+
r -= mood.weight;
|
|
95
|
+
if (r < 0)
|
|
96
|
+
return mood;
|
|
97
|
+
}
|
|
98
|
+
return MOOD_TABLE[MOOD_TABLE.length - 1] ?? null; // fp safety net
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Build the injected override block for a rolled mood. Deliberately framed as a
|
|
102
|
+
* directive about *today's tone*, with the explicit "do not announce it" so she
|
|
103
|
+
* inhabits the weather rather than narrating it.
|
|
104
|
+
*/
|
|
105
|
+
export function buildMoodWeatherContext(mood) {
|
|
106
|
+
return [
|
|
107
|
+
"<hyperspell-mood-weather>",
|
|
108
|
+
mood.note,
|
|
109
|
+
"",
|
|
110
|
+
"This is exogenous mood weather: it is not caused by the user and not a reaction to the conversation. Do not announce it, label it, or explain it — simply inhabit it. It lasts only this session and is not remembered as how the relationship has been.",
|
|
111
|
+
"</hyperspell-mood-weather>",
|
|
112
|
+
].join("\n");
|
|
113
|
+
}
|
|
@@ -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;
|
|
@@ -211,6 +213,17 @@ export function buildStartupOrientationHandler(client, cfg) {
|
|
|
211
213
|
injectedSessions.add(sessionKey);
|
|
212
214
|
return;
|
|
213
215
|
}
|
|
216
|
+
// Skip when multiple speakers are present with no multiUser config.
|
|
217
|
+
// Orientation injects the primary user's personal activity context; in a
|
|
218
|
+
// group chat that leaks their private session history to other participants
|
|
219
|
+
// who may have triggered the session start (attribution v2, gap 2).
|
|
220
|
+
const sessionId = resolveCurrentSessionId(undefined, ctx);
|
|
221
|
+
if (!cfg.multiUser && isMultiSpeaker(sessionId, ctx?.is_group_chat === true)) {
|
|
222
|
+
log.debug("startup-orientation: skipping — multi-speaker session with no multiUser config (would expose primary user's personal context)");
|
|
223
|
+
if (sessionKey)
|
|
224
|
+
injectedSessions.add(sessionKey);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
214
227
|
// Source recent-interactions from wherever the session record actually
|
|
215
228
|
// lives. Prefer the hot buffer (modern path: clean session-grouped vault
|
|
216
229
|
// resources, present whenever the hot buffer is on — including auto-trace-
|
package/dist/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import { buildEmotionalStateCompactionHandler, buildEmotionalStateFetchHandler,
|
|
|
8
8
|
import { buildFileSyncHandler, syncMemoriesOnStartup } from "./hooks/memory-sync.js";
|
|
9
9
|
import { buildHotBufferHandler, buildHotBufferSessionCleanupHandler, } from "./hooks/hot-buffer.js";
|
|
10
10
|
import { buildStartupOrientationCompactionHandler, buildStartupOrientationHandler, buildStartupOrientationSessionCleanupHandler, } from "./hooks/startup-orientation.js";
|
|
11
|
+
import { isExcludedChannel } from "./lib/exclude-channels.js";
|
|
11
12
|
import { initLogger, log } from "./logger.js";
|
|
12
13
|
import { createRememberToolFactory } from "./tools/remember.js";
|
|
13
14
|
import { createSearchToolFactory } from "./tools/search.js";
|
|
@@ -67,11 +68,23 @@ export default {
|
|
|
67
68
|
const cfg = parseConfig(api.pluginConfig);
|
|
68
69
|
initLogger(api.logger, cfg.debug);
|
|
69
70
|
const client = new HyperspellClient(cfg);
|
|
71
|
+
// Channel quarantine (cfg.excludeChannels): excluded conversations get no
|
|
72
|
+
// memory surface in either direction. Guard the shared choke points here —
|
|
73
|
+
// before_agent_start (all injection), agent_end (all writes), and the tool
|
|
74
|
+
// factories — so individual hooks stay quarantine-unaware.
|
|
75
|
+
const quarantined = (ctx) => {
|
|
76
|
+
if (!isExcludedChannel(ctx, cfg))
|
|
77
|
+
return false;
|
|
78
|
+
log.debug("channel quarantined — skipping memory surface");
|
|
79
|
+
return true;
|
|
80
|
+
};
|
|
81
|
+
const unlessQuarantined = (handler) => (event, ctx) => quarantined(ctx) ? undefined : handler(event, ctx);
|
|
82
|
+
const toolUnlessQuarantined = (factory) => (ctx) => quarantined(ctx) ? null : factory(ctx);
|
|
70
83
|
// Register AI tools (factory pattern for sender context)
|
|
71
|
-
api.registerTool(createSearchToolFactory(client, cfg), {
|
|
84
|
+
api.registerTool(toolUnlessQuarantined(createSearchToolFactory(client, cfg)), {
|
|
72
85
|
name: "hyperspell_search",
|
|
73
86
|
});
|
|
74
|
-
api.registerTool(createRememberToolFactory(client, cfg), {
|
|
87
|
+
api.registerTool(toolUnlessQuarantined(createRememberToolFactory(client, cfg)), {
|
|
75
88
|
name: "hyperspell_remember",
|
|
76
89
|
});
|
|
77
90
|
const startHandlers = [];
|
|
@@ -79,7 +92,7 @@ export default {
|
|
|
79
92
|
startHandlers.push(buildEmotionalStateFetchHandler(client, cfg));
|
|
80
93
|
api.on("after_compaction", buildEmotionalStateCompactionHandler());
|
|
81
94
|
api.on("session_end", buildEmotionalStateSessionCleanupHandler());
|
|
82
|
-
api.on("agent_end", buildEmotionalStateStoreHandler(client, cfg));
|
|
95
|
+
api.on("agent_end", unlessQuarantined(buildEmotionalStateStoreHandler(client, cfg)));
|
|
83
96
|
}
|
|
84
97
|
if (cfg.autoContext) {
|
|
85
98
|
startHandlers.push(buildAutoContextHandler(client, cfg));
|
|
@@ -98,6 +111,9 @@ export default {
|
|
|
98
111
|
}
|
|
99
112
|
if (startHandlers.length > 0) {
|
|
100
113
|
api.on("before_agent_start", async (event, ctx) => {
|
|
114
|
+
// Quarantined channels get no injected memory of any kind.
|
|
115
|
+
if (quarantined(ctx))
|
|
116
|
+
return undefined;
|
|
101
117
|
const results = await Promise.all(startHandlers.map((h) => Promise.resolve()
|
|
102
118
|
.then(() => h(event, ctx))
|
|
103
119
|
.catch((err) => {
|
|
@@ -115,12 +131,12 @@ export default {
|
|
|
115
131
|
}
|
|
116
132
|
// Register auto-trace hook (send conversations to Hyperspell on session end)
|
|
117
133
|
if (cfg.autoTrace.enabled) {
|
|
118
|
-
api.on("agent_end", buildAutoTraceHandler(client, cfg));
|
|
134
|
+
api.on("agent_end", unlessQuarantined(buildAutoTraceHandler(client, cfg)));
|
|
119
135
|
}
|
|
120
136
|
// Register hot-buffer hook: write each turn to POST /messages so it's
|
|
121
137
|
// instantly full-text searchable (vs. the slow /memories embedding path).
|
|
122
138
|
if (cfg.hotBuffer.enabled) {
|
|
123
|
-
api.on("agent_end", buildHotBufferHandler(client, cfg));
|
|
139
|
+
api.on("agent_end", unlessQuarantined(buildHotBufferHandler(client, cfg)));
|
|
124
140
|
api.on("session_end", buildHotBufferSessionCleanupHandler());
|
|
125
141
|
}
|
|
126
142
|
// Register memory sync hook
|
|
@@ -0,0 +1,59 @@
|
|
|
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
|
+
// Conversation-kind segments used in OpenClaw session keys. Mirrors core's
|
|
16
|
+
// TARGET_PREFIXES (src/plugins/hook-agent-context.ts) so the sessionKey
|
|
17
|
+
// fallback resolves the same id core would put on ctx.channelId.
|
|
18
|
+
const TARGET_KINDS = new Set(["channel", "chat", "direct", "dm", "group", "thread", "user"]);
|
|
19
|
+
/**
|
|
20
|
+
* Extract the conversation id from a composite session key, e.g.
|
|
21
|
+
* `agent:main:discord:channel:123` → `123`. Thread/run suffixes stay attached
|
|
22
|
+
* (`...:channel:123:thread:456` → `123:thread:456`); the prefix match in
|
|
23
|
+
* `isExcludedChannel` handles them. Returns undefined when the key has no
|
|
24
|
+
* recognizable conversation segment (cron runs, bare UUIDs, ...).
|
|
25
|
+
*/
|
|
26
|
+
export function conversationIdFromSessionKey(sessionKey) {
|
|
27
|
+
if (typeof sessionKey !== "string" || sessionKey.length === 0)
|
|
28
|
+
return undefined;
|
|
29
|
+
const parts = sessionKey.split(":").filter((p) => p.length > 0);
|
|
30
|
+
const body = parts[0]?.toLowerCase() === "agent" && parts.length >= 3 ? parts.slice(2) : parts;
|
|
31
|
+
if (body.length >= 3 && TARGET_KINDS.has(body[1]?.toLowerCase() ?? "")) {
|
|
32
|
+
return body.slice(2).join(":");
|
|
33
|
+
}
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
/** Resolve the conversation id from any hook or tool-factory context. */
|
|
37
|
+
export function channelIdFromCtx(ctx) {
|
|
38
|
+
const direct = ctx?.channelId;
|
|
39
|
+
if (typeof direct === "string" && direct.length > 0)
|
|
40
|
+
return direct;
|
|
41
|
+
return conversationIdFromSessionKey(ctx?.sessionKey);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* True when the context's conversation is quarantined. Purely subtractive on
|
|
45
|
+
* failure: an unresolvable id means "not excluded" — a session we can't place
|
|
46
|
+
* keeps normal memory behavior rather than silently losing it.
|
|
47
|
+
*/
|
|
48
|
+
export function isExcludedChannel(ctx, cfg) {
|
|
49
|
+
if (cfg.excludeChannels.length === 0)
|
|
50
|
+
return false;
|
|
51
|
+
const id = channelIdFromCtx(ctx)?.toLowerCase();
|
|
52
|
+
if (!id)
|
|
53
|
+
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
|
+
});
|
|
59
|
+
}
|
package/dist/lib/ranking.js
CHANGED
|
@@ -5,6 +5,7 @@ export const DEFAULT_RANKING = {
|
|
|
5
5
|
storyBoost: 0.15,
|
|
6
6
|
storyTerms: [],
|
|
7
7
|
candidateMultiplier: 3,
|
|
8
|
+
chatterQuota: 2,
|
|
8
9
|
};
|
|
9
10
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
10
11
|
/** Highest available relevance for a result (doc score or its best highlight). */
|
|
@@ -61,3 +62,26 @@ export function rerank(results, w) {
|
|
|
61
62
|
})
|
|
62
63
|
.sort((a, b) => b._composite - a._composite);
|
|
63
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* Choose which ranked results to inject: keep those clearing `threshold` on
|
|
67
|
+
* their composite, cap CHATTER at `chatterQuota` regardless of score (so a
|
|
68
|
+
* high-similarity echo can inform but never flood — the penalty bounds rank,
|
|
69
|
+
* the quota bounds count), and stop at `maxResults`.
|
|
70
|
+
*/
|
|
71
|
+
export function selectRanked(ranked, maxResults, threshold, chatterQuota) {
|
|
72
|
+
const out = [];
|
|
73
|
+
let chatter = 0;
|
|
74
|
+
for (const r of ranked) {
|
|
75
|
+
if (r._composite < threshold)
|
|
76
|
+
continue;
|
|
77
|
+
if (r._kind === "chatter") {
|
|
78
|
+
if (chatter >= chatterQuota)
|
|
79
|
+
continue;
|
|
80
|
+
chatter++;
|
|
81
|
+
}
|
|
82
|
+
out.push(r);
|
|
83
|
+
if (out.length >= maxResults)
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
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
|
+
}
|
package/dist/tools/remember.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { Type } from "@sinclair/typebox";
|
|
2
2
|
import { getDefaultWriteScope, resolveRole, resolveUser, routeWrite, } from "../lib/sender.js";
|
|
3
|
+
import { resolveCurrentSessionId } from "../lib/session.js";
|
|
4
|
+
import { isMultiSpeaker } from "../lib/speaker-tracker.js";
|
|
3
5
|
import { log } from "../logger.js";
|
|
4
6
|
export function createRememberToolFactory(client, cfg) {
|
|
5
7
|
const scopingEnabled = !!cfg.multiUser?.scoping;
|
|
@@ -21,6 +23,22 @@ export function createRememberToolFactory(client, cfg) {
|
|
|
21
23
|
scope: Type.Optional(Type.String({ description: scopeDescription })),
|
|
22
24
|
}),
|
|
23
25
|
async execute(_toolCallId, params) {
|
|
26
|
+
// Decline silently-wrong writes in multi-speaker sessions with no multiUser
|
|
27
|
+
// config. Without per-sender routing the memory would land under cfg.userId
|
|
28
|
+
// regardless of who asked — contaminating the primary user's store with
|
|
29
|
+
// another person's data (attribution gap 1, issue #59 follow-up).
|
|
30
|
+
const sessionId = resolveCurrentSessionId(undefined, ctx);
|
|
31
|
+
if (isMultiSpeaker(sessionId, ctx?.is_group_chat === true) && !cfg.multiUser) {
|
|
32
|
+
log.warn("remember tool: declining write — multi-speaker session with no multiUser config; cannot attribute memory to current speaker");
|
|
33
|
+
return {
|
|
34
|
+
content: [
|
|
35
|
+
{
|
|
36
|
+
type: "text",
|
|
37
|
+
text: "I can't store this memory right now — this is a multi-speaker session and without per-user memory configuration I have no way to attribute it to the right person. The memory would land in the primary user's store regardless of who asked. To fix this, add a `multiUser` config block to the plugin settings.",
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
};
|
|
41
|
+
}
|
|
24
42
|
const resolved = resolveUser(ctx, cfg);
|
|
25
43
|
// Scope resolution: explicit param > role default > global default > "private"
|
|
26
44
|
const scope = params.scope ??
|
package/dist/tools/search.js
CHANGED
|
@@ -2,6 +2,8 @@ import { Type } from "@sinclair/typebox";
|
|
|
2
2
|
import { mergeWithExclude } from "../lib/filters.js";
|
|
3
3
|
import { classifySearchError, logSearchError, searchErrorToolText, } from "../lib/search-error.js";
|
|
4
4
|
import { buildScopeFilter, getCanReadScopes, resolveUser } from "../lib/sender.js";
|
|
5
|
+
import { resolveCurrentSessionId } from "../lib/session.js";
|
|
6
|
+
import { isMultiSpeaker } from "../lib/speaker-tracker.js";
|
|
5
7
|
import { log } from "../logger.js";
|
|
6
8
|
export function createSearchToolFactory(client, cfg) {
|
|
7
9
|
const scopingEnabled = !!cfg.multiUser?.scoping;
|
|
@@ -12,7 +14,7 @@ export function createSearchToolFactory(client, cfg) {
|
|
|
12
14
|
return (ctx) => ({
|
|
13
15
|
name: "hyperspell_search",
|
|
14
16
|
label: "Memory Search",
|
|
15
|
-
description: "Search the user's long-term memory and connected sources: saved memories and notes; past conversations — including earlier or parallel sessions you have no transcript for; and connected sources (Notion, Slack, Gmail, Google Drive, etc.). Use this
|
|
17
|
+
description: "Search the user's long-term memory and connected sources: saved memories and notes; past conversations — including earlier or parallel sessions you have no transcript for; and connected sources (Notion, Slack, Gmail, Google Drive, etc.). Use this proactively, not only when asked — but on content, not as ritual: before answering anything that touches the user's history, a past decision, a name, a promise, or something you might have recorded, search FIRST with a specific query and look. Don't answer from impression when you can check. If a search comes back empty, say so plainly — never fill the gap with something invented.",
|
|
16
18
|
parameters: Type.Object({
|
|
17
19
|
query: Type.String({ description: "Search query" }),
|
|
18
20
|
limit: Type.Optional(Type.Number({ description: "Max results (default: 5)" })),
|
|
@@ -27,6 +29,15 @@ export function createSearchToolFactory(client, cfg) {
|
|
|
27
29
|
const limit = params.limit ?? 5;
|
|
28
30
|
const resolved = resolveUser(ctx, cfg);
|
|
29
31
|
const userId = params.userId ?? resolved?.userId;
|
|
32
|
+
// Warn when searching in a multi-speaker session with no multiUser config:
|
|
33
|
+
// results reflect the primary user's full store, not the current speaker's
|
|
34
|
+
// personal space. The search proceeds but the result carries the caveat so
|
|
35
|
+
// the agent can qualify its answer (attribution gap 1, issue #59 follow-up).
|
|
36
|
+
const sessionId = resolveCurrentSessionId(undefined, ctx);
|
|
37
|
+
const multiSpeakerNoConfig = isMultiSpeaker(sessionId, ctx?.is_group_chat === true) && !cfg.multiUser;
|
|
38
|
+
if (multiSpeakerNoConfig) {
|
|
39
|
+
log.warn("search tool: multi-speaker session with no multiUser config — results reflect primary user's full store, not current speaker's personal space");
|
|
40
|
+
}
|
|
30
41
|
// Build scope filter: intersect requested scope (if any) with caller's canRead
|
|
31
42
|
let filter;
|
|
32
43
|
if (scopingEnabled) {
|
|
@@ -70,7 +81,10 @@ export function createSearchToolFactory(client, cfg) {
|
|
|
70
81
|
return `${i + 1}. Source: ${doc.source}\n Title: ${title}\n Summary: ${summary}\n Relevance: ${relevance}`;
|
|
71
82
|
})
|
|
72
83
|
.join("\n\n");
|
|
73
|
-
const
|
|
84
|
+
const caveat = multiSpeakerNoConfig
|
|
85
|
+
? "\n\n⚠️ Note: This is a multi-speaker session without per-user memory config. These results reflect the primary user's store, not the current speaker's personal space. Use these results with that limitation in mind."
|
|
86
|
+
: "";
|
|
87
|
+
const text = `Found ${documents.length} memories:\n\n${formattedDocs}${caveat}`;
|
|
74
88
|
return {
|
|
75
89
|
content: [{ type: "text", text }],
|
|
76
90
|
details: { count: documents.length, documents },
|
package/openclaw.plugin.json
CHANGED
|
@@ -46,6 +46,11 @@
|
|
|
46
46
|
"help": "Maintain emotional continuity across sessions — remembers not just what happened, but how it felt",
|
|
47
47
|
"advanced": true
|
|
48
48
|
},
|
|
49
|
+
"excludeChannels": {
|
|
50
|
+
"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
|
+
"advanced": true
|
|
53
|
+
},
|
|
49
54
|
"startupOrientation": {
|
|
50
55
|
"label": "Startup Orientation",
|
|
51
56
|
"help": "Inject recent-interactions and unfinished-loops blocks once per session at startup. Costs two extra calls + ~500–800 tokens of injection per session; off by default.",
|
|
@@ -133,6 +138,15 @@
|
|
|
133
138
|
"emotionalContext": {
|
|
134
139
|
"type": "boolean"
|
|
135
140
|
},
|
|
141
|
+
"moodWeatherChance": {
|
|
142
|
+
"type": "number",
|
|
143
|
+
"minimum": 0,
|
|
144
|
+
"maximum": 1
|
|
145
|
+
},
|
|
146
|
+
"excludeChannels": {
|
|
147
|
+
"type": "array",
|
|
148
|
+
"items": { "type": "string" }
|
|
149
|
+
},
|
|
136
150
|
"relationshipId": {
|
|
137
151
|
"type": "string"
|
|
138
152
|
},
|
|
@@ -164,7 +178,8 @@
|
|
|
164
178
|
"chatterPenalty": { "type": "number", "minimum": 0, "maximum": 1 },
|
|
165
179
|
"storyBoost": { "type": "number", "minimum": 0, "maximum": 1 },
|
|
166
180
|
"storyTerms": { "type": "array", "items": { "type": "string" } },
|
|
167
|
-
"candidateMultiplier": { "type": "number", "minimum": 1, "maximum": 10 }
|
|
181
|
+
"candidateMultiplier": { "type": "number", "minimum": 1, "maximum": 10 },
|
|
182
|
+
"chatterQuota": { "type": "number", "minimum": 0, "maximum": 20 }
|
|
168
183
|
}
|
|
169
184
|
},
|
|
170
185
|
"debug": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperspell/openclaw-hyperspell",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.1",
|
|
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/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"
|
|
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"
|
|
42
42
|
},
|
|
43
43
|
"openclaw": {
|
|
44
44
|
"extensions": [
|