@hyperspell/openclaw-hyperspell 0.17.1 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,6 @@
1
1
  import { resolveUser } from "../lib/sender.js";
2
+ import { resolveCurrentSessionId } from "../lib/session.js";
3
+ import { isMultiSpeaker } from "../lib/speaker-tracker.js";
2
4
  import { log } from "../logger.js";
3
5
  const MAX_ATTEMPTS = 2;
4
6
  const RECENT_BUFFER_LIMIT = 100;
@@ -76,7 +78,7 @@ function isoDaysAgo(days) {
76
78
  * an unresolved caller has no personal space to orient to. In single-user mode,
77
79
  * return undefined so the client falls back to its configured userId.
78
80
  */
79
- function personalUserId(cfg, ctx) {
81
+ export function personalUserId(cfg, ctx) {
80
82
  if (!cfg.multiUser)
81
83
  return { skip: false, userId: undefined };
82
84
  const resolved = resolveUser(ctx, cfg);
@@ -189,8 +191,78 @@ async function fetchRecentConversations(client, limit, userId) {
189
191
  }
190
192
  return out;
191
193
  }
192
- export function buildStartupOrientationHandler(client, cfg) {
194
+ /**
195
+ * The fetch+format core of startup orientation, shared by the real
196
+ * before_agent_start handler and the read-only /previewcontext command.
197
+ * Pure with respect to session lifecycle: no inject-once cache, no retry
198
+ * counting — callers own that policy. Keeping one formatter here keeps the
199
+ * preview byte-identical to real injection.
200
+ */
201
+ export async function gatherOrientation(client, cfg, userId) {
193
202
  const so = cfg.startupOrientation;
203
+ // Source recent-interactions from wherever the session record actually
204
+ // lives. Prefer the hot buffer (modern path: clean session-grouped vault
205
+ // resources, present whenever the hot buffer is on — including auto-trace-
206
+ // off agents). Fall back to agent_end traces only when there's no hot
207
+ // buffer but auto-trace is on. Otherwise skip: there's nothing to fetch,
208
+ // and the trace-source list is expensive (observed ~12s + failing/turn),
209
+ // blocking before_agent_start and slowing the reply.
210
+ const recentSource = cfg.hotBuffer.enabled
211
+ ? "hotBuffer"
212
+ : cfg.autoTrace.enabled
213
+ ? "autoTrace"
214
+ : "none";
215
+ const recentFetch = recentSource === "hotBuffer"
216
+ ? fetchRecentConversations(client, so.recentLimit, userId)
217
+ : recentSource === "autoTrace"
218
+ ? fetchRecentTraces(client, isoDaysAgo(so.recentDays), so.recentLimit, userId)
219
+ : Promise.resolve([]);
220
+ const [recentSettled, loopsSettled] = await Promise.allSettled([
221
+ recentFetch,
222
+ client.search(so.loopsQuery, {
223
+ limit: so.loopsLimit,
224
+ userId,
225
+ }),
226
+ ]);
227
+ const recentOk = recentSettled.status === "fulfilled";
228
+ const loopsOk = loopsSettled.status === "fulfilled";
229
+ const recent = recentOk ? recentSettled.value : [];
230
+ const loops = loopsOk ? loopsSettled.value : [];
231
+ if (!recentOk) {
232
+ log.error("startup-orientation: recent listMemories failed", recentSettled.reason);
233
+ }
234
+ if (!loopsOk) {
235
+ log.error("startup-orientation: loops search failed", loopsSettled.reason);
236
+ }
237
+ const recentBody = formatRecentInteractions(recent);
238
+ const loopsBody = formatUnfinishedLoops(loops);
239
+ return {
240
+ recentOk,
241
+ loopsOk,
242
+ recentCount: recent.length,
243
+ loopsCount: loops.length,
244
+ recentSource,
245
+ recentBlock: recentBody
246
+ ? [
247
+ "<hyperspell-recent-interactions>",
248
+ `Your last ${so.recentDays} days of conversations with this user, most-recent-first. Use for situational continuity — don't quote verbatim.`,
249
+ "",
250
+ recentBody,
251
+ "</hyperspell-recent-interactions>",
252
+ ].join("\n")
253
+ : null,
254
+ loopsBlock: loopsBody
255
+ ? [
256
+ "<hyperspell-unfinished-loops>",
257
+ "Possible open threads — promises made, questions pending, work in progress. Low-confidence retrieval; treat as prompts to consider, not facts to act on.",
258
+ "",
259
+ loopsBody,
260
+ "</hyperspell-unfinished-loops>",
261
+ ].join("\n")
262
+ : null,
263
+ };
264
+ }
265
+ export function buildStartupOrientationHandler(client, cfg) {
194
266
  return async (_event, ctx) => {
195
267
  const sessionKey = ctx?.sessionKey;
196
268
  if (sessionKey && injectedSessions.has(sessionKey))
@@ -211,36 +283,19 @@ export function buildStartupOrientationHandler(client, cfg) {
211
283
  injectedSessions.add(sessionKey);
212
284
  return;
213
285
  }
214
- // Source recent-interactions from wherever the session record actually
215
- // lives. Prefer the hot buffer (modern path: clean session-grouped vault
216
- // resources, present whenever the hot buffer is on including auto-trace-
217
- // off agents). Fall back to agent_end traces only when there's no hot
218
- // buffer but auto-trace is on. Otherwise skip: there's nothing to fetch,
219
- // and the trace-source list is expensive (observed ~12s + failing/turn),
220
- // blocking before_agent_start and slowing the reply.
221
- const recentFetch = cfg.hotBuffer.enabled
222
- ? fetchRecentConversations(client, so.recentLimit, userId)
223
- : cfg.autoTrace.enabled
224
- ? fetchRecentTraces(client, isoDaysAgo(so.recentDays), so.recentLimit, userId)
225
- : Promise.resolve([]);
226
- const [recentSettled, loopsSettled] = await Promise.allSettled([
227
- recentFetch,
228
- client.search(so.loopsQuery, {
229
- limit: so.loopsLimit,
230
- userId,
231
- }),
232
- ]);
233
- const recentOk = recentSettled.status === "fulfilled";
234
- const loopsOk = loopsSettled.status === "fulfilled";
235
- const recent = recentOk ? recentSettled.value : [];
236
- const loops = loopsOk ? loopsSettled.value : [];
237
- if (!recentOk) {
238
- log.error("startup-orientation: recent listMemories failed", recentSettled.reason);
239
- }
240
- if (!loopsOk) {
241
- log.error("startup-orientation: loops search failed", loopsSettled.reason);
286
+ // Skip when multiple speakers are present with no multiUser config.
287
+ // Orientation injects the primary user's personal activity context; in a
288
+ // group chat that leaks their private session history to other participants
289
+ // who may have triggered the session start (attribution v2, gap 2).
290
+ const sessionId = resolveCurrentSessionId(undefined, ctx);
291
+ if (!cfg.multiUser && isMultiSpeaker(sessionId, ctx?.is_group_chat === true)) {
292
+ log.debug("startup-orientation: skipping multi-speaker session with no multiUser config (would expose primary user's personal context)");
293
+ if (sessionKey)
294
+ injectedSessions.add(sessionKey);
295
+ return;
242
296
  }
243
- if (!recentOk && !loopsOk) {
297
+ const gathered = await gatherOrientation(client, cfg, userId);
298
+ if (!gathered.recentOk && !gathered.loopsOk) {
244
299
  if (sessionKey)
245
300
  failedAttempts.set(sessionKey, tries + 1);
246
301
  log.debug(`startup-orientation: both calls failed (attempt ${tries + 1}/${MAX_ATTEMPTS}); will retry next turn`);
@@ -250,32 +305,12 @@ export function buildStartupOrientationHandler(client, cfg) {
250
305
  injectedSessions.add(sessionKey);
251
306
  failedAttempts.delete(sessionKey);
252
307
  }
253
- const recentBody = formatRecentInteractions(recent);
254
- const loopsBody = formatUnfinishedLoops(loops);
255
- if (!recentBody && !loopsBody) {
308
+ if (!gathered.recentBlock && !gathered.loopsBlock) {
256
309
  log.debug("startup-orientation: nothing to inject");
257
310
  return;
258
311
  }
259
- const blocks = [];
260
- if (recentBody) {
261
- blocks.push([
262
- "<hyperspell-recent-interactions>",
263
- `Your last ${so.recentDays} days of conversations with this user, most-recent-first. Use for situational continuity — don't quote verbatim.`,
264
- "",
265
- recentBody,
266
- "</hyperspell-recent-interactions>",
267
- ].join("\n"));
268
- }
269
- if (loopsBody) {
270
- blocks.push([
271
- "<hyperspell-unfinished-loops>",
272
- "Possible open threads — promises made, questions pending, work in progress. Low-confidence retrieval; treat as prompts to consider, not facts to act on.",
273
- "",
274
- loopsBody,
275
- "</hyperspell-unfinished-loops>",
276
- ].join("\n"));
277
- }
278
- log.debug(`startup-orientation: injecting recent=${recent.length} loops=${loops.length}`);
312
+ const blocks = [gathered.recentBlock, gathered.loopsBlock].filter((b) => b !== null);
313
+ log.debug(`startup-orientation: injecting recent=${gathered.recentCount} loops=${gathered.loopsCount}`);
279
314
  return { prependContext: blocks.join("\n\n") };
280
315
  };
281
316
  }
package/dist/index.js CHANGED
@@ -8,7 +8,9 @@ import { buildEmotionalStateCompactionHandler, buildEmotionalStateFetchHandler,
8
8
  import { buildFileSyncHandler, syncMemoriesOnStartup } from "./hooks/memory-sync.js";
9
9
  import { buildHotBufferHandler, buildHotBufferSessionCleanupHandler, } from "./hooks/hot-buffer.js";
10
10
  import { buildStartupOrientationCompactionHandler, buildStartupOrientationHandler, buildStartupOrientationSessionCleanupHandler, } from "./hooks/startup-orientation.js";
11
+ import { isExcludedChannel } from "./lib/exclude-channels.js";
11
12
  import { initLogger, log } from "./logger.js";
13
+ import { createEmotionalArcToolFactory } from "./tools/emotional-arc.js";
12
14
  import { createRememberToolFactory } from "./tools/remember.js";
13
15
  import { createSearchToolFactory } from "./tools/search.js";
14
16
  import { registerNetworkTools } from "./graph/index.js";
@@ -62,24 +64,56 @@ export default {
62
64
  };
63
65
  },
64
66
  });
67
+ api.registerCommand({
68
+ name: "previewcontext",
69
+ description: "Preview what Hyperspell would inject at the next session start",
70
+ acceptsArgs: false,
71
+ requireAuth: false,
72
+ handler: async () => {
73
+ return {
74
+ text: "Hyperspell not configured. Run 'openclaw openclaw-hyperspell setup' first.",
75
+ };
76
+ },
77
+ });
65
78
  return;
66
79
  }
67
80
  const cfg = parseConfig(api.pluginConfig);
68
81
  initLogger(api.logger, cfg.debug);
69
82
  const client = new HyperspellClient(cfg);
83
+ // Channel quarantine (cfg.excludeChannels): excluded conversations get no
84
+ // memory surface in either direction. Guard the shared choke points here —
85
+ // before_agent_start (all injection), agent_end (all writes), and the tool
86
+ // factories — so individual hooks stay quarantine-unaware.
87
+ const quarantined = (ctx) => {
88
+ if (!isExcludedChannel(ctx, cfg))
89
+ return false;
90
+ log.debug("channel quarantined — skipping memory surface");
91
+ return true;
92
+ };
93
+ const unlessQuarantined = (handler) => (event, ctx) => quarantined(ctx) ? undefined : handler(event, ctx);
94
+ const toolUnlessQuarantined = (factory) => (ctx) => quarantined(ctx) ? null : factory(ctx);
70
95
  // Register AI tools (factory pattern for sender context)
71
- api.registerTool(createSearchToolFactory(client, cfg), {
96
+ api.registerTool(toolUnlessQuarantined(createSearchToolFactory(client, cfg)), {
72
97
  name: "hyperspell_search",
73
98
  });
74
- api.registerTool(createRememberToolFactory(client, cfg), {
99
+ api.registerTool(toolUnlessQuarantined(createRememberToolFactory(client, cfg)), {
75
100
  name: "hyperspell_remember",
76
101
  });
77
102
  const startHandlers = [];
78
103
  if (cfg.emotionalContext) {
104
+ // moodWeatherChance defaults to 0, so mood weather is inert unless the
105
+ // operator opts in — say so once at startup rather than staying silent.
106
+ if (cfg.moodWeatherChance === 0) {
107
+ log.info("emotionalContext is on but moodWeatherChance is 0 — mood weather will never roll. Set moodWeatherChance (e.g. 0.03–0.05) to enable it.");
108
+ }
109
+ // On-demand arc re-fetch (issue #76): the session-start injection can be
110
+ // compacted out of history mid-session; this tool lets the agent pull the
111
+ // exact same block back without waiting for the next prompt build.
112
+ api.registerTool(toolUnlessQuarantined(createEmotionalArcToolFactory(client, cfg)), { name: "hyperspell_emotional_arc" });
79
113
  startHandlers.push(buildEmotionalStateFetchHandler(client, cfg));
80
114
  api.on("after_compaction", buildEmotionalStateCompactionHandler());
81
115
  api.on("session_end", buildEmotionalStateSessionCleanupHandler());
82
- api.on("agent_end", buildEmotionalStateStoreHandler(client, cfg));
116
+ api.on("agent_end", unlessQuarantined(buildEmotionalStateStoreHandler(client, cfg)));
83
117
  }
84
118
  if (cfg.autoContext) {
85
119
  startHandlers.push(buildAutoContextHandler(client, cfg));
@@ -98,6 +132,9 @@ export default {
98
132
  }
99
133
  if (startHandlers.length > 0) {
100
134
  api.on("before_agent_start", async (event, ctx) => {
135
+ // Quarantined channels get no injected memory of any kind.
136
+ if (quarantined(ctx))
137
+ return undefined;
101
138
  const results = await Promise.all(startHandlers.map((h) => Promise.resolve()
102
139
  .then(() => h(event, ctx))
103
140
  .catch((err) => {
@@ -115,12 +152,12 @@ export default {
115
152
  }
116
153
  // Register auto-trace hook (send conversations to Hyperspell on session end)
117
154
  if (cfg.autoTrace.enabled) {
118
- api.on("agent_end", buildAutoTraceHandler(client, cfg));
155
+ api.on("agent_end", unlessQuarantined(buildAutoTraceHandler(client, cfg)));
119
156
  }
120
157
  // Register hot-buffer hook: write each turn to POST /messages so it's
121
158
  // instantly full-text searchable (vs. the slow /memories embedding path).
122
159
  if (cfg.hotBuffer.enabled) {
123
- api.on("agent_end", buildHotBufferHandler(client, cfg));
160
+ api.on("agent_end", unlessQuarantined(buildHotBufferHandler(client, cfg)));
124
161
  api.on("session_end", buildHotBufferSessionCleanupHandler());
125
162
  }
126
163
  // Register memory sync hook
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Parse the JSONL fixture file: one JSON object per line; blank lines and
3
+ * `//` comment lines are ignored. Every fixture needs a query and at least
4
+ * one matcher — a matcher-less line would vacuously fail and look like a
5
+ * ranking problem, so reject it at parse time with the line number.
6
+ */
7
+ export function parseFixtures(jsonl) {
8
+ const fixtures = [];
9
+ for (const [idx, line] of jsonl.split("\n").entries()) {
10
+ const trimmed = line.trim();
11
+ if (trimmed === "" || trimmed.startsWith("//"))
12
+ continue;
13
+ let parsed;
14
+ try {
15
+ parsed = JSON.parse(trimmed);
16
+ }
17
+ catch {
18
+ throw new Error(`fixtures line ${idx + 1}: invalid JSON`);
19
+ }
20
+ const f = parsed;
21
+ if (typeof f.query !== "string" || f.query.trim() === "") {
22
+ throw new Error(`fixtures line ${idx + 1}: "query" is required`);
23
+ }
24
+ if (!f.expectedResourceId && !f.expectedTitleContains) {
25
+ throw new Error(`fixtures line ${idx + 1}: at least one of "expectedResourceId" / "expectedTitleContains" is required`);
26
+ }
27
+ fixtures.push(f);
28
+ }
29
+ return fixtures;
30
+ }
31
+ /**
32
+ * OR-semantics matcher (proposal §3.1): a result satisfies a fixture if the
33
+ * resourceId matches exactly, or the title/any highlight contains the
34
+ * expected substring (case-insensitive). Returns WHICH matcher hit so the
35
+ * runner can warn when only the title matched — the id may have churned.
36
+ */
37
+ export function matchFixture(r, f) {
38
+ if (f.expectedResourceId && r.resourceId === f.expectedResourceId)
39
+ return "id";
40
+ if (f.expectedTitleContains) {
41
+ const needle = f.expectedTitleContains.toLowerCase();
42
+ const hay = [r.title ?? "", ...r.highlights.map((h) => h.text)]
43
+ .join(" ")
44
+ .toLowerCase();
45
+ if (hay.includes(needle))
46
+ return "title";
47
+ }
48
+ return null;
49
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Channel-level memory quarantine (`excludeChannels` config).
3
+ *
4
+ * A channel on the list gets NO memory surface at all, in both directions:
5
+ * no context injection (auto-context, emotional state, startup orientation),
6
+ * no memory writes (hot buffer, auto-trace, emotional store), and no memory
7
+ * tools. Use it for conversations that must never mix with the owner's vault —
8
+ * e.g. a channel someone else drives (a shared game, a public bot surface).
9
+ *
10
+ * Matching is against the conversation id OpenClaw resolves for the session
11
+ * (`ctx.channelId` on agent hook contexts — e.g. a Discord channel id). Tool
12
+ * factory contexts don't carry `channelId`, so we recover the same id from the
13
+ * composite `sessionKey` (`agent:<agentId>:<provider>:<kind>:<id>[...]`).
14
+ *
15
+ * Quarantine is FORWARD-ONLY: it stops future injection/writes/tools but does
16
+ * not remove already-synced content. Retroactive cleanup of channel-tagged
17
+ * rows is the `purge-channel` CLI command (commands/purge-channel.ts).
18
+ */
19
+ // Conversation-kind segments used in OpenClaw session keys. Mirrors core's
20
+ // TARGET_PREFIXES (src/plugins/hook-agent-context.ts) so the sessionKey
21
+ // fallback resolves the same id core would put on ctx.channelId.
22
+ const TARGET_KINDS = new Set(["channel", "chat", "direct", "dm", "group", "thread", "user"]);
23
+ /**
24
+ * Extract the conversation id from a composite session key, e.g.
25
+ * `agent:main:discord:channel:123` → `123`. Thread/run suffixes stay attached
26
+ * (`...:channel:123:thread:456` → `123:thread:456`); the prefix match in
27
+ * `isExcludedChannel` handles them. Returns undefined when the key has no
28
+ * recognizable conversation segment (cron runs, bare UUIDs, ...).
29
+ */
30
+ export function conversationIdFromSessionKey(sessionKey) {
31
+ if (typeof sessionKey !== "string" || sessionKey.length === 0)
32
+ return undefined;
33
+ const parts = sessionKey.split(":").filter((p) => p.length > 0);
34
+ const body = parts[0]?.toLowerCase() === "agent" && parts.length >= 3 ? parts.slice(2) : parts;
35
+ if (body.length >= 3 && TARGET_KINDS.has(body[1]?.toLowerCase() ?? "")) {
36
+ return body.slice(2).join(":");
37
+ }
38
+ return undefined;
39
+ }
40
+ /** Resolve the conversation id from any hook or tool-factory context. */
41
+ export function channelIdFromCtx(ctx) {
42
+ const direct = ctx?.channelId;
43
+ if (typeof direct === "string" && direct.length > 0)
44
+ return direct;
45
+ return conversationIdFromSessionKey(ctx?.sessionKey);
46
+ }
47
+ /**
48
+ * True when a stored conversation id belongs to `channel` — exact match or a
49
+ * thread suffix (`<channel>:thread:<n>`), case-insensitive. Shared by the
50
+ * quarantine check and the purge-channel CLI so "what gets blocked" and "what
51
+ * gets purged" can never drift apart.
52
+ */
53
+ export function conversationMatchesChannel(id, channel) {
54
+ const a = id.toLowerCase();
55
+ const b = channel.toLowerCase();
56
+ return a === b || a.startsWith(`${b}:`);
57
+ }
58
+ /**
59
+ * True when the context's conversation is quarantined. Purely subtractive on
60
+ * failure: an unresolvable id means "not excluded" — a session we can't place
61
+ * keeps normal memory behavior rather than silently losing it.
62
+ */
63
+ export function isExcludedChannel(ctx, cfg) {
64
+ if (cfg.excludeChannels.length === 0)
65
+ return false;
66
+ const id = channelIdFromCtx(ctx);
67
+ if (!id)
68
+ return false;
69
+ return cfg.excludeChannels.some((entry) => conversationMatchesChannel(id, entry));
70
+ }
@@ -23,10 +23,12 @@
23
23
  * search (~1s observed live), and when auto-trace is off there are no `agent_end`
24
24
  * rows to hide, so the filter would cost latency on every turn for zero benefit.
25
25
  *
26
- * Do NOT instead try to positively tag hot rows via `POST /messages` metadata to
27
- * identify them: a `/messages` write carrying `metadata` is accepted (200) but
28
- * the row becomes NON-retrievable (verified live, post-#1921) see the
29
- * hot-buffer hook, which writes content only.
26
+ * Hot rows ARE positively tagged (`openclaw_source: "hot_buffer"` plus
27
+ * session/channel ids see the hot-buffer hook): metadata-carrying
28
+ * `/messages` rows were verified retrievable AND filterable live 2026-07-02
29
+ * (docs/filter-dialect-test.mjs). The `$ne` exclude keeps them because
30
+ * "hot_buffer" != "agent_end". (An earlier note here claimed metadata made
31
+ * hot rows non-retrievable — that predated the backend fix.)
30
32
  *
31
33
  * NOTE: an earlier version checked the top-level `source` field for
32
34
  * "openclaw_agent_end" — wrong on BOTH counts (the tag lives in metadata under
@@ -63,25 +63,54 @@ export function rerank(results, w) {
63
63
  .sort((a, b) => b._composite - a._composite);
64
64
  }
65
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`.
66
+ * One pass over ranked results, annotating each with its selection outcome.
67
+ * Same policy as selectRanked which is now derived from this, so the two
68
+ * can never drift (proposal 02 §3a).
69
+ *
70
+ * Attribution order is deliberate: the threshold check comes first so a
71
+ * below-the-bar result is always attributed to "threshold" even when the
72
+ * max-results cap is already full; and a chatter item past the cap reads
73
+ * "max-results", not "chatter-quota" — it would have been cut regardless, so
74
+ * the quota was not the binding constraint (proposal 03 §3.2 semantics).
70
75
  */
71
- export function selectRanked(ranked, maxResults, threshold, chatterQuota) {
76
+ export function explainSelection(ranked, maxResults, threshold, chatterQuota) {
72
77
  const out = [];
73
78
  let chatter = 0;
79
+ let kept = 0;
74
80
  for (const r of ranked) {
75
- if (r._composite < threshold)
81
+ if (r._composite < threshold) {
82
+ out.push({ result: r, selected: false, cut: "threshold" });
83
+ continue;
84
+ }
85
+ if (kept >= maxResults) {
86
+ out.push({ result: r, selected: false, cut: "max-results" });
87
+ continue;
88
+ }
89
+ if (r._kind === "chatter" && chatter >= chatterQuota) {
90
+ out.push({ result: r, selected: false, cut: "chatter-quota" });
76
91
  continue;
77
- if (r._kind === "chatter") {
78
- if (chatter >= chatterQuota)
79
- continue;
80
- chatter++;
81
92
  }
82
- out.push(r);
83
- if (out.length >= maxResults)
84
- break;
93
+ if (r._kind === "chatter")
94
+ chatter++;
95
+ kept++;
96
+ out.push({ result: r, selected: true, cut: null });
85
97
  }
86
98
  return out;
87
99
  }
100
+ /**
101
+ * Choose which ranked results to inject: keep those clearing `threshold` on
102
+ * their composite, cap CHATTER at `chatterQuota` regardless of score (so a
103
+ * high-similarity echo can inform but never flood — the penalty bounds rank,
104
+ * the quota bounds count), and stop at `maxResults`.
105
+ *
106
+ * Signature and return shape are intentionally unchanged: the retrieval eval
107
+ * harness (proposal 17) and existing call sites depend on
108
+ * `selectRanked(ranked, maxResults, threshold, chatterQuota): RankedResult[]`.
109
+ * It is a thin projection of explainSelection so selection policy lives in
110
+ * exactly one place.
111
+ */
112
+ export function selectRanked(ranked, maxResults, threshold, chatterQuota) {
113
+ return explainSelection(ranked, maxResults, threshold, chatterQuota)
114
+ .filter((e) => e.selected)
115
+ .map((e) => e.result);
116
+ }
@@ -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
+ }
@@ -0,0 +1,60 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ import { buildEmotionalContext, EMOTIONAL_ARC_LIMIT, fetchRecentOrLatest, looksLikeRawTranscript, } from "../hooks/emotional-state.js";
3
+ import { log } from "../logger.js";
4
+ /** Hard cap so the agent can't ask the backend for an unbounded history. */
5
+ const MAX_ARC_LIMIT = 10;
6
+ export function createEmotionalArcToolFactory(client, cfg) {
7
+ // No sender context needed: the register is keyed to cfg.relationshipId, not
8
+ // the current speaker — but keep the factory signature so toolUnlessQuarantined
9
+ // can wrap it like search/remember.
10
+ return (_ctx) => ({
11
+ name: "hyperspell_emotional_arc",
12
+ label: "Emotional Arc",
13
+ description: "Fetch the recent emotional arc of your relationship with this user — the same emotional-context block that is injected at session start. Use it when that block is no longer in your history (e.g. after the conversation was compacted) or when you genuinely want to reflect on how the relationship has been feeling before responding. Returns the most recent emotional registers, newest first. Let the trajectory inform your tone — don't recite it back to the user.",
14
+ parameters: Type.Object({
15
+ limit: Type.Optional(Type.Number({
16
+ description: `How many recent registers to fetch (default ${EMOTIONAL_ARC_LIMIT}, max ${MAX_ARC_LIMIT})`,
17
+ })),
18
+ }),
19
+ async execute(_toolCallId, params) {
20
+ const limit = Math.min(Math.max(Math.floor(params.limit ?? EMOTIONAL_ARC_LIMIT), 1), MAX_ARC_LIMIT);
21
+ log.debug(`emotional-arc tool: limit=${limit} relationshipId=${cfg.relationshipId ?? "(default)"}`);
22
+ try {
23
+ const states = await fetchRecentOrLatest(client, cfg, limit);
24
+ // Same placeholder filter as the injection path: for ~10s after a
25
+ // store, the register can be the RAW input transcript (status=pending),
26
+ // not a distilled feeling. Returning that would pollute tone.
27
+ const usable = states.filter((s) => s.summary && !looksLikeRawTranscript(s.summary));
28
+ if (usable.length === 0) {
29
+ const text = states.length > 0
30
+ ? "The latest emotional register is still being extracted — try again in a few seconds."
31
+ : "No emotional arc recorded yet for this relationship. It builds up as real conversations end.";
32
+ return { content: [{ type: "text", text }] };
33
+ }
34
+ // Deliberately identical to the session-start injection block
35
+ // (buildEmotionalContext), so a post-compaction re-fetch restores
36
+ // exactly what a fresh session would have received. Mood weather is
37
+ // intentionally NOT included — it is an injection-only session
38
+ // override, rolled at most once per session; a tool call must never
39
+ // re-roll or reveal it (do not import from mood-weather.ts here).
40
+ return {
41
+ content: [
42
+ { type: "text", text: buildEmotionalContext(usable) },
43
+ ],
44
+ details: { count: usable.length },
45
+ };
46
+ }
47
+ catch (err) {
48
+ log.error("emotional-arc tool failed", err);
49
+ return {
50
+ content: [
51
+ {
52
+ type: "text",
53
+ text: `Failed to fetch emotional arc: ${err instanceof Error ? err.message : String(err)}`,
54
+ },
55
+ ],
56
+ };
57
+ }
58
+ },
59
+ });
60
+ }