@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.
@@ -277,6 +277,8 @@ async function runSetup() {
277
277
  writeAssistant: true,
278
278
  },
279
279
  emotionalContext: false,
280
+ moodWeatherChance: 0,
281
+ excludeChannels: [],
280
282
  syncMemories: true,
281
283
  syncMemoriesConfig: {
282
284
  enabled: true,
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
- * Like formatHighlightBullets, but for composite-RANKED results: a result is
57
- * kept on its composite score (relevance + curation/story boost chatter), not
58
- * raw relevance so a deliberately-kept memory that's quietly relevant clears
59
- * the bar where a louder conversation echo doesn't. Highlights are floored at
60
- * the lower of (threshold, the result's own base relevance), so we don't then
61
- * hide the very lines that define a boosted-but-quiet memory.
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 formatRankedBullets(ranked, maxResults, threshold) {
62
+ function formatSelected(selected, threshold) {
64
63
  const sections = [];
65
- for (const r of ranked) {
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
- // Injected EVERY turn (even when nothing cleared the bar): this passive match is
90
- // a starting point, not the whole of memory. The point is to make her LOOK —
91
- // actively search before concluding rather than answer from whatever happened
92
- // to surface, which is what lets an agent invent instead of recall.
93
- const SEARCH_DIRECTIVE = "This is a passive match and may miss what matters. Before answering anything that touches your shared history, a past decision, a name, a promise, or something you may have recorded, run hyperspell_search with a specific query and look even if something is already shown above. Don't answer from impression when you can check. If a search returns nothing, say so plainly; never fill the gap with something invented.";
94
- /** Wrap the per-turn context: the standing search directive always, plus the
95
- * surfaced memory when anything cleared the bar. */
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
- if (memorySection) {
98
- return `<hyperspell-context>\n${INTRO}\n\n${memorySection}\n\n${DISCLAIMER}\n\n${SEARCH_DIRECTIVE}\n</hyperspell-context>`;
99
- }
100
- return `<hyperspell-context>\n${SEARCH_DIRECTIVE}\n</hyperspell-context>`;
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
- formatted = formatRankedBullets(ranked, cfg.maxResults, cfg.relevanceThreshold);
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 kept = ranked.filter((r) => r._composite >= cfg.relevanceThreshold);
152
- const tally = kept.slice(0, cfg.maxResults).reduce((acc, r) => ((acc[r._kind] = (acc[r._kind] ?? 0) + 1), acc), {});
153
- log.debug(`auto-context: injecting (ranked) ${JSON.stringify(tally)} from ${results.length} candidates`);
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
- // Always inject the standing search directive so she's prompted to LOOK
162
- // every turn with the surfaced memory appended when anything cleared the
163
- // bar. (#issue: passive injection alone let her answer from impression.)
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: nothing cleared the bar — injecting search directive only");
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
  }
@@ -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
- const context = buildEmotionalContext(usable);
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})`);
@@ -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 userId = resolveUser(ctx, cfg)?.userId;
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 the per-session sent-set on session end to avoid unbounded growth. */
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
+ }
@@ -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
+ }
@@ -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
- return cfg.userId
8
- ? { userId: cfg.userId, name: cfg.userId, resolved: true }
9
- : undefined;
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 substring matching (longest-first to avoid partial matches)
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 (sessionKey.includes(handle)) {
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
+ }
@@ -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 ??
@@ -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 PROACTIVELY and by default, as a routine step not only when asked. Before answering anything that touches the user's history, a past decision, a name, a promise, or something you might have recorded, search FIRST with a specific query and look. Don't answer from impression when you can check. If a search comes back empty, say so plainly — never fill the gap with something invented.",
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 text = `Found ${documents.length} memories:\n\n${formattedDocs}`;
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 },
@@ -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.17.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": [