@hyperspell/openclaw-hyperspell 0.17.1 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -102,6 +102,27 @@ Check your current configuration and connection status.
102
102
 
103
103
  Open the Hyperspell connect page to link your accounts (Notion, Slack, Google Drive, etc.). After connecting, your sources are automatically updated in the config.
104
104
 
105
+ ### `openclaw openclaw-hyperspell purge-channel <channelId>`
106
+
107
+ Find — and with `--yes`, delete — memories that were synced from a specific conversation/channel. Use it for retroactive cleanup after adding a channel to `excludeChannels`, which is [forward-only](#excludechannels-is-forward-only).
108
+
109
+ ```
110
+ openclaw openclaw-hyperspell purge-channel 1521620672726438171 # dry run: list what would be deleted
111
+ openclaw openclaw-hyperspell purge-channel 1521620672726438171 --yes # actually delete
112
+ ```
113
+
114
+ | Flag | Description |
115
+ |------|-------------|
116
+ | `--source <sources>` | Comma-separated Hyperspell sources to scan. Default `vault` — the hot-buffer consolidation target and default `hotBuffer.source`. |
117
+ | `--user <userId>` | `X-As-User` to scan/delete under. Default: the configured `userId`. In `multiUser` deployments, run once per mapped userId. |
118
+ | `--session <ids>` | Comma-separated OpenClaw session ids — matches legacy **untagged** hot-buffer resources through the `resource_id === session id` identity. |
119
+ | `--resource <ids>` | Explicit resource ids to delete (escape hatch for traces or `/remember` memories you identified manually, e.g. via `hyperspell_search`). |
120
+ | `--yes` | Actually delete. Without it the command is a **dry run** that prints the matches and exits. |
121
+
122
+ Matching uses the same semantics as the quarantine check itself: exact id or thread suffix (`<channelId>:...`), case-insensitive. Discovery enumerates memories and filters client-side on the `openclaw_channel_id` metadata tag — deleted rows are reported per resource, and a resource that is already gone counts as deleted.
123
+
124
+ **Finding session ids for `--session`:** OpenClaw session keys embed the conversation id (`agent:<agentId>:<provider>:channel:<channelId>[...]` — the same format the plugin's quarantine matching parses). Look up the quarantined conversation's sessions in OpenClaw's session store and pass their session ids; each hot-buffer resource id equals its session id.
125
+
105
126
  ## Configuration Options
106
127
 
107
128
  | Option | Type | Default | Description |
@@ -109,9 +130,12 @@ Open the Hyperspell connect page to link your accounts (Notion, Slack, Google Dr
109
130
  | `apiKey` | string | `${HYPERSPELL_API_KEY}` | Hyperspell API key |
110
131
  | `userId` | string | - | User ID for multi-tenant memory (can be your email) |
111
132
  | `autoContext` | boolean | `true` | Auto-inject relevant memories before each AI turn |
133
+ | `emotionalContext` | boolean | `false` | Persist an emotional-state register at session end and inject the recent arc at session start |
134
+ | `moodWeatherChance` | number | `0` | Probability (0–1) that a fresh session rolls exogenous "mood weather". `0` disables. Suggested starting value: `0.03`–`0.05` — rare enough to read as weather, not a gimmick. Requires `emotionalContext`. |
112
135
  | `syncMemories` | boolean | `false` | Sync markdown files in `workspace/memory/` to Hyperspell |
113
136
  | `sources` | string | - | Comma-separated sources to search (e.g., `vault,notion,slack`) |
114
137
  | `maxResults` | number | `10` | Maximum memories per context injection |
138
+ | `excludeChannels` | string[] | `[]` | Conversation/channel ids fully quarantined from memory: no context injection, no memory writes, no memory tools. Threads inherit. **Forward-only** — see [below](#excludechannels-is-forward-only). |
115
139
  | `debug` | boolean | `false` | Enable verbose logging |
116
140
  | `dreaming.enabled` | boolean | `false` | Allow `memory-core` to sidecar-load so Dreaming can consolidate local session transcripts into `workspace/MEMORY.md`. See [Running alongside Dreaming](#running-alongside-dreaming). |
117
141
 
@@ -141,6 +165,16 @@ Manually sync all markdown files in `workspace/memory/` to Hyperspell.
141
165
  /sync
142
166
  ```
143
167
 
168
+ ### `/previewcontext`
169
+
170
+ Show exactly what Hyperspell would inject at the start of the next session — the emotional-context arc, the auto-context setting, and the startup-orientation blocks — without starting a session or touching any session state. Read-only and idempotent: run it twice and you get the same report.
171
+
172
+ Mood weather is shown as its configured chance only (e.g. "configured chance 8% per session") and is **never rolled by the preview** — each real session rolls independently at injection time, so the actual mood (if any) is only observable in the live session.
173
+
174
+ ```
175
+ /previewcontext
176
+ ```
177
+
144
178
  ## Memory Sync
145
179
 
146
180
  When `syncMemories: true`, the plugin syncs markdown files from your agent's workspace memory directory (e.g., `~/.openclaw/workspace/memory/`) to Hyperspell. This includes all `.md` files in subdirectories.
@@ -186,6 +220,7 @@ The plugin registers tools that the AI can use autonomously:
186
220
 
187
221
  - **hyperspell_search** - Search through connected sources
188
222
  - **hyperspell_remember** - Save information to memory
223
+ - **hyperspell_emotional_arc** - Re-fetch the recent emotional arc mid-conversation (requires `emotionalContext: true`); returns the same block injected at session start, e.g. after compaction removed it
189
224
 
190
225
  ## Auto-Context
191
226
 
@@ -197,6 +232,17 @@ When `autoContext: true` (default), the plugin automatically:
197
232
 
198
233
  This ensures the AI always has access to relevant information from your connected sources.
199
234
 
235
+ ### `excludeChannels` is forward-only
236
+
237
+ Adding a channel to `excludeChannels` stops all future injection/writes/tools for that conversation, but does **not** remove content that was synced before the channel was quarantined.
238
+
239
+ - **Hot-buffer content** written by plugin versions since 2026-07 is tagged with `openclaw_channel_id` and can be removed with [`openclaw openclaw-hyperspell purge-channel <id>`](#openclaw-openclaw-hyperspell-purge-channel-channelid). Older hot-buffer rows are untagged, but each row's resource id equals its OpenClaw session id, so they are reachable via the purge command's `--session` flag.
240
+ - **Auto-trace resources and `/remember` memories** written by current versions carry the same `openclaw_channel_id` tag going forward. Ones written by older versions are not channel-tagged and cannot be automatically purged — identify them manually (e.g. via `hyperspell_search`) and use the purge command's `--resource` escape hatch.
241
+ - **Emotional-state registers** are keyed by relationship, not channel, and live behind the `/emotional-state` API rather than the memories store this command scans — they are never matched by `purge-channel`, and deletion of them is per-relationship, all-or-nothing. (Newer plugin versions tag registers with a `channelId` metadata field for analysis, but that does not make them purgeable per channel.)
242
+ - **Server-side extractions** derived from traces (`extract: ["procedure", "memory", "mood"]`) are created backend-side; whether deleting the parent trace removes them is an open backend question (see `docs/hyperspell-backend-followups.md`).
243
+
244
+ The purge command prints these standing limitations on every run.
245
+
200
246
  ## Available Sources
201
247
 
202
248
  ### Documents & Storage
@@ -0,0 +1,93 @@
1
+ import { buildEmotionalContext, fetchRecentOrLatest, looksLikeRawTranscript, } from "../hooks/emotional-state.js";
2
+ import { gatherOrientation, personalUserId } from "../hooks/startup-orientation.js";
3
+ import { isExcludedChannel } from "../lib/exclude-channels.js";
4
+ import { log } from "../logger.js";
5
+ /**
6
+ * Assemble a read-only report of what before_agent_start WOULD inject at the
7
+ * start of the next session. Calls only the pure fetch/format functions the
8
+ * real hooks are composed from — never the hook handlers themselves — so it
9
+ * cannot touch the inject-once session caches, and it never calls rollMood
10
+ * (a debug command must not consume or reveal a mood roll; the roll happens
11
+ * once, in the real injection path, per session).
12
+ */
13
+ export async function buildPreviewReport(client, cfg, ctx) {
14
+ // Quarantined channels get no injected memory of any kind (index.ts guards
15
+ // before_agent_start with the same check) — report that instead of a bundle.
16
+ if (isExcludedChannel({ channelId: ctx.channel }, cfg)) {
17
+ return "This channel is quarantined (excludeChannels): no context would be injected here and no memory would be written.";
18
+ }
19
+ const sections = [];
20
+ // ---- 1. Emotional context (registration order in index.ts: first) ----
21
+ if (!cfg.emotionalContext) {
22
+ sections.push("Emotional context: OFF (emotionalContext=false) — no register/arc would be injected.");
23
+ }
24
+ else {
25
+ try {
26
+ const states = await fetchRecentOrLatest(client, cfg);
27
+ const usable = states.filter((s) => s.summary && !looksLikeRawTranscript(s.summary));
28
+ if (usable.length > 0) {
29
+ const pending = states.length - usable.length;
30
+ const pendingNote = pending > 0
31
+ ? `\n(${pending} more register(s) still extracting — excluded, same as the real hook.)`
32
+ : "";
33
+ sections.push(`Emotional context: would inject ${usable.length} register(s):\n\n${buildEmotionalContext(usable)}${pendingNote}`);
34
+ }
35
+ else if (states.length > 0) {
36
+ sections.push("Emotional context: register(s) exist but are still extracting (raw-transcript placeholder) — the real hook would skip this turn and retry next turn.");
37
+ }
38
+ else {
39
+ sections.push("Emotional context: ON, but no prior emotional state found — nothing to inject yet (first conversation, or register deleted).");
40
+ }
41
+ }
42
+ catch (err) {
43
+ log.error("/previewcontext: emotional fetch failed", err);
44
+ sections.push("Emotional context: fetch FAILED — the real hook would log this and inject nothing. Check logs.");
45
+ }
46
+ // Mood weather: report configuration only. Deliberately NOT rolled — see
47
+ // function doc comment. Preview must be idempotent and non-revealing.
48
+ sections.push(cfg.moodWeatherChance > 0
49
+ ? `Mood weather: configured chance ${Math.round(cfg.moodWeatherChance * 100)}% per session — NOT rolled by this preview. Each real session rolls independently at injection time; the actual mood (if any) is only observable in the live session.`
50
+ : "Mood weather: OFF (moodWeatherChance=0).");
51
+ }
52
+ // ---- 2. Auto-context (second in registration order) ----
53
+ sections.push(cfg.autoContext
54
+ ? "Auto-context: ON — will search memories using the text of your next message. Query-dependent, so not previewable here; use /getcontext <your message> to simulate."
55
+ : "Auto-context: OFF.");
56
+ // ---- 3. Startup orientation (third in registration order) ----
57
+ const so = cfg.startupOrientation;
58
+ if (!so.enabled) {
59
+ sections.push("Startup orientation: OFF (startupOrientation.enabled=false).");
60
+ }
61
+ else {
62
+ const { skip, userId } = personalUserId(cfg, ctx);
63
+ if (skip) {
64
+ sections.push("Startup orientation: ON, but you are an unknown sender in multi-user mode — the real hook would skip injection for you.");
65
+ }
66
+ else {
67
+ const g = await gatherOrientation(client, cfg, userId);
68
+ const parts = [];
69
+ if (g.recentSource === "none") {
70
+ parts.push("recent-interactions: no source (hotBuffer and autoTrace both off) — block would be empty.");
71
+ }
72
+ else if (!g.recentOk) {
73
+ parts.push(`recent-interactions: fetch FAILED (source=${g.recentSource}) — real hook would retry next turn.`);
74
+ }
75
+ else {
76
+ parts.push(g.recentBlock ?? `recent-interactions: source=${g.recentSource}, 0 results — block omitted.`);
77
+ }
78
+ if (!g.loopsOk) {
79
+ parts.push("unfinished-loops: search FAILED — real hook would retry next turn.");
80
+ }
81
+ else {
82
+ parts.push(g.loopsBlock ??
83
+ `unfinished-loops: 0 results for loopsQuery ("${so.loopsQuery}") — block omitted.`);
84
+ }
85
+ sections.push(`Startup orientation: ON.\n\n${parts.join("\n\n")}`);
86
+ }
87
+ }
88
+ return [
89
+ "What the next session would inject (read-only preview — no session state touched, no mood rolled):",
90
+ "",
91
+ sections.join("\n\n---\n\n"),
92
+ ].join("\n");
93
+ }
@@ -0,0 +1,54 @@
1
+ import { conversationMatchesChannel } from "../lib/exclude-channels.js";
2
+ /**
3
+ * Enumerate the given sources and return every memory attributable to
4
+ * `channelId` — via the `openclaw_channel_id` metadata tag (exact or
5
+ * thread-suffix, same semantics as the quarantine check), or via the
6
+ * hot-buffer `resourceId === sessionId` identity for legacy untagged rows.
7
+ */
8
+ export async function findChannelMemories(client, channelId, opts) {
9
+ const matches = [];
10
+ const sessionIds = new Set((opts.sessionIds ?? []).map((s) => s.toLowerCase()));
11
+ for (const source of opts.sources) {
12
+ for await (const m of client.listMemories({ source, userId: opts.userId })) {
13
+ const tagged = m.metadata.openclaw_channel_id;
14
+ if (typeof tagged === "string" && conversationMatchesChannel(tagged, channelId)) {
15
+ matches.push({ resourceId: m.resourceId, source: m.source, title: m.title, via: "channel_tag" });
16
+ }
17
+ else if (sessionIds.has(m.resourceId.toLowerCase())) {
18
+ // Hot-buffer resource_id === sessionId — rows written before channel
19
+ // tagging existed are reachable only through this identity.
20
+ matches.push({ resourceId: m.resourceId, source: m.source, title: m.title, via: "session_id" });
21
+ }
22
+ }
23
+ }
24
+ return matches;
25
+ }
26
+ /** Delete every match. `deleteMemory` is already 404-tolerant (absent = deleted). */
27
+ export async function deleteMatches(client, matches, opts) {
28
+ let deleted = 0;
29
+ let failed = 0;
30
+ for (const m of matches) {
31
+ const r = await client.deleteMemory(m.resourceId, { source: m.source, userId: opts.userId });
32
+ if (r.deleted)
33
+ deleted++;
34
+ else
35
+ failed++;
36
+ }
37
+ return { matched: matches, deleted, failed };
38
+ }
39
+ /** Render matches as an aligned `resourceId source via title` table. */
40
+ export function formatMatchTable(matches) {
41
+ const rows = [
42
+ ["RESOURCE ID", "SOURCE", "VIA", "TITLE"],
43
+ ...matches.map((m) => [m.resourceId, m.source, m.via, m.title ?? "-"]),
44
+ ];
45
+ const widths = [0, 1, 2].map((i) => Math.max(...rows.map((r) => r[i].length)));
46
+ return rows
47
+ .map((r) => `${r[0].padEnd(widths[0])} ${r[1].padEnd(widths[1])} ${r[2].padEnd(widths[2])} ${r[3]}`)
48
+ .join("\n");
49
+ }
50
+ /** Standing limitations — printed on every run so operators aren't surprised. */
51
+ export const PURGE_LIMITATIONS_FOOTER = "Note: not everything is channel-tagged. Auto-trace resources and /remember memories written by older plugin versions, " +
52
+ "hot-buffer rows written before 2026-07, and emotional-state registers are NOT matched by this command. " +
53
+ "Use --session for legacy hot-buffer rows and --resource for manually identified resources; " +
54
+ 'see the "excludeChannels is forward-only" section of the README.';
@@ -12,6 +12,7 @@ import { getWorkspaceDir, parseConfig, resolveConfigPath } from "../config.js";
12
12
  import { buildExtractionPrompt, CRON_JOB_NAME } from "../graph/cron.js";
13
13
  import { NetworkStateManager } from "../graph/state.js";
14
14
  import { scanMemories, formatScanResults, completeMemories } from "../graph/ops.js";
15
+ import { deleteMatches, findChannelMemories, formatMatchTable, PURGE_LIMITATIONS_FOOTER, } from "./purge-channel.js";
15
16
  async function fetchConnectionSources(client, userId) {
16
17
  try {
17
18
  const userClient = new Hyperspell({
@@ -277,6 +278,8 @@ async function runSetup() {
277
278
  writeAssistant: true,
278
279
  },
279
280
  emotionalContext: false,
281
+ moodWeatherChance: 0,
282
+ excludeChannels: [],
280
283
  syncMemories: true,
281
284
  syncMemoriesConfig: {
282
285
  enabled: true,
@@ -522,6 +525,54 @@ export function registerCliCommands(program, pluginConfig) {
522
525
  .action(async () => {
523
526
  await runConnect(pluginConfig);
524
527
  });
528
+ // Retroactive cleanup for quarantined channels. Dry run by default — no
529
+ // interactive prompt, so it stays usable from cron/exec like `network`.
530
+ hyperspellCmd
531
+ .command("purge-channel <channelId>")
532
+ .description("Find (and with --yes delete) memories synced from a channel — retroactive cleanup for excludeChannels, which is forward-only")
533
+ .option("--source <sources>", "Comma-separated Hyperspell sources to scan (default: vault, the hot-buffer consolidation target)", "vault")
534
+ .option("--user <userId>", "X-As-User to scan/delete under (default: configured userId). In multiUser deployments run once per mapped userId.")
535
+ .option("--session <ids>", "Comma-separated OpenClaw session ids — matches legacy untagged hot-buffer resources (resource_id === session id)")
536
+ .option("--resource <ids>", "Comma-separated explicit resource ids to include (escape hatch for traces / remember memories identified manually)")
537
+ .option("--yes", "Actually delete. Without this flag the command is a dry run.")
538
+ .action(async (channelId, opts) => {
539
+ const splitCsv = (v) => typeof v === "string" ? v.split(",").map((s) => s.trim()).filter(Boolean) : [];
540
+ try {
541
+ const cfg = parseConfig(pluginConfig);
542
+ const client = new HyperspellClient(cfg);
543
+ const sources = splitCsv(opts.source).map((s) => s.toLowerCase());
544
+ const userId = opts.user ?? cfg.userId;
545
+ const matches = await findChannelMemories(client, channelId, {
546
+ sources: sources.length > 0 ? sources : ["vault"],
547
+ userId,
548
+ sessionIds: splitCsv(opts.session),
549
+ });
550
+ // Explicit ids are operator-asserted; append any not already found.
551
+ const found = new Set(matches.map((m) => m.resourceId));
552
+ for (const id of splitCsv(opts.resource)) {
553
+ if (!found.has(id)) {
554
+ matches.push({ resourceId: id, source: sources[0] ?? "vault", title: null, via: "explicit" });
555
+ }
556
+ }
557
+ if (matches.length === 0) {
558
+ process.stdout.write(`No memories matched channel ${channelId}.\n\n${PURGE_LIMITATIONS_FOOTER}\n`);
559
+ return;
560
+ }
561
+ process.stdout.write(formatMatchTable(matches) + "\n\n");
562
+ if (!opts.yes) {
563
+ process.stdout.write(`Dry run: ${matches.length} memor${matches.length === 1 ? "y" : "ies"} would be deleted. Re-run with --yes to delete.\n\n${PURGE_LIMITATIONS_FOOTER}\n`);
564
+ return;
565
+ }
566
+ const result = await deleteMatches(client, matches, { userId });
567
+ process.stdout.write(`Deleted ${result.deleted} of ${result.matched.length} matched memories (${result.failed} failed).\n\n${PURGE_LIMITATIONS_FOOTER}\n`);
568
+ if (result.failed > 0)
569
+ process.exit(1);
570
+ }
571
+ catch (err) {
572
+ process.stderr.write(`Purge failed: ${err instanceof Error ? err.message : String(err)}\n`);
573
+ process.exit(1);
574
+ }
575
+ });
525
576
  // Memory Network CLI commands (used by isolated cron sessions via exec)
526
577
  const networkCmd = hyperspellCmd
527
578
  .command("network")
@@ -2,6 +2,7 @@ import { getWorkspaceDir } from "../config.js";
2
2
  import { buildScopeFilter, getCanReadScopes, getDefaultWriteScope, resolveRole, resolveUser, routeWrite, } from "../lib/sender.js";
3
3
  import { log } from "../logger.js";
4
4
  import { syncAllMemoryFiles } from "../sync/markdown.js";
5
+ import { buildPreviewReport } from "./preview.js";
5
6
  /**
6
7
  * Strip a `#scope-name` prefix from free text. Returns the scope and the
7
8
  * remainder. Used by /remember and /getcontext to let users narrow or route
@@ -156,4 +157,21 @@ export function registerCommands(api, client, cfg) {
156
157
  }
157
158
  },
158
159
  });
160
+ // /previewcontext - Show what would be injected into the next session
161
+ api.registerCommand({
162
+ name: "previewcontext",
163
+ description: "Preview what Hyperspell would inject at the next session start",
164
+ acceptsArgs: false,
165
+ requireAuth: true,
166
+ handler: async (ctx) => {
167
+ log.debug("/previewcontext command");
168
+ try {
169
+ return { text: await buildPreviewReport(client, cfg, ctx) };
170
+ }
171
+ catch (err) {
172
+ log.error("/previewcontext failed", err);
173
+ return { text: "Failed to build preview. Check logs for details." };
174
+ }
175
+ },
176
+ });
159
177
  }
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",
@@ -301,6 +303,14 @@ export function parseConfig(raw) {
301
303
  writeAssistant: hbRaw.writeAssistant ?? true,
302
304
  },
303
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
+ : [],
304
314
  relationshipId: cfg.relationshipId,
305
315
  startupOrientation: {
306
316
  enabled: soRaw.enabled ?? false,
@@ -1,8 +1,10 @@
1
+ import { appendFileSync } from "node:fs";
1
2
  import { buildScopeFilter, getCanReadScopes, resolveUser, } from "../lib/sender.js";
2
3
  import { excludeFilterFor, mergeWithExclude } from "../lib/filters.js";
3
- import { rerank, selectRanked } from "../lib/ranking.js";
4
+ import { explainSelection, rerank, } from "../lib/ranking.js";
4
5
  import { classifySearchError, logSearchError } from "../lib/search-error.js";
5
6
  import { resolveCurrentSessionId } from "../lib/session.js";
7
+ import { recordSender, senderIdFromCtx } from "../lib/speaker-tracker.js";
6
8
  import { log } from "../logger.js";
7
9
  function formatRelativeTime(isoTimestamp) {
8
10
  try {
@@ -78,6 +80,49 @@ function formatSelected(selected, threshold) {
78
80
  return null;
79
81
  return sections.join("\n\n");
80
82
  }
83
+ /**
84
+ * Opt-in score sampling for relevanceThreshold tuning (proposal 02 §3b).
85
+ * Writes one JSONL line per candidate when HYPERSPELL_SCORE_LOG names a file;
86
+ * review/analyze with docs/score-review.mjs and docs/score-analyze.mjs.
87
+ *
88
+ * The lines carry an 80-char prompt prefix and memory snippets — sensitive
89
+ * plaintext on disk — so this is OFF by default and never config-driven:
90
+ * it writes nothing unless the operator explicitly sets the env var (setting
91
+ * it IS the opt-in), and the log should be deleted after the tuning window.
92
+ * The env var is read at call time (not module load) so tests can set it.
93
+ * Must never throw into the retrieval path.
94
+ */
95
+ function logScoreSamples(prompt, sessionId, scope, explained, threshold) {
96
+ const path = process.env.HYPERSPELL_SCORE_LOG;
97
+ if (!path || explained.length === 0)
98
+ return;
99
+ const ts = new Date().toISOString();
100
+ const lines = explained.map((e) => {
101
+ const r = e.result;
102
+ const top = [...r.highlights].sort((a, b) => (b.score ?? 0) - (a.score ?? 0))[0];
103
+ return JSON.stringify({
104
+ ts,
105
+ sessionId,
106
+ scope,
107
+ prompt: prompt.slice(0, 80),
108
+ resourceId: r.resourceId,
109
+ title: (r.title ?? "").slice(0, 60),
110
+ kind: r._kind,
111
+ base: Number(r._base.toFixed(4)),
112
+ composite: Number(r._composite.toFixed(4)),
113
+ threshold,
114
+ selected: e.selected,
115
+ cut: e.cut,
116
+ snippet: (top?.text ?? "").replace(/\s+/g, " ").slice(0, 120),
117
+ });
118
+ });
119
+ try {
120
+ appendFileSync(path, `${lines.join("\n")}\n`);
121
+ }
122
+ catch {
123
+ // instrumentation must never break retrieval
124
+ }
125
+ }
81
126
  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.";
82
127
  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.";
83
128
  // A short, CONDITIONAL reminder appended only to a real memory block (not a
@@ -85,10 +130,22 @@ const DISCLAIMER = "Draw on it when relevant — including indirect connections
85
130
  // search-as-ritual; the agent's own instructions carry the standing rule). The
86
131
  // point here is just: what surfaced is a passive match, not all of memory.
87
132
  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.";
133
+ // Explicit authority precedence: live sender metadata outranks surfaced memory
134
+ // for identity questions. Without this, a high-scoring memory naming a different
135
+ // person than the live sender can silently override who the agent thinks it is
136
+ // talking to — the identity-bleed failure observed live (issue #58).
137
+ // Two carve-outs (issue #59 follow-up):
138
+ // (1) A memory about the CURRENT sender's own past is not a conflict — use it
139
+ // as recalled personal context. The guard only applies when the identity in
140
+ // the memory is clearly a different person from the live sender.
141
+ // (2) Display names and handles often differ across sessions (e.g. "dithilli"
142
+ // vs "David S"). Use judgment to identify whether a retrieved name refers
143
+ // to the current speaker before applying the guard.
144
+ 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.";
88
145
  /** Wrap a real memory block. No memory → no injection (caller returns nothing);
89
146
  * the standing search rule lives in the agent's own instructions, not here. */
90
147
  function wrapContext(memorySection) {
91
- return `<hyperspell-context>\n${INTRO}\n\n${memorySection}\n\n${DISCLAIMER}\n\n${SEARCH_REMINDER}\n</hyperspell-context>`;
148
+ return `<hyperspell-context>\n${INTRO}\n\n${AUTHORITY_GUARD}\n\n${memorySection}\n\n${DISCLAIMER}\n\n${SEARCH_REMINDER}\n</hyperspell-context>`;
92
149
  }
93
150
  /**
94
151
  * Drop results belonging to the CURRENT session. The hot buffer writes the live
@@ -119,6 +176,11 @@ export function buildAutoContextHandler(client, cfg) {
119
176
  // The live session's own just-written turns must not be surfaced back as
120
177
  // "recalled memory" (issue #42) — resolve its id once and exclude it below.
121
178
  const currentSessionId = resolveCurrentSessionId(event, ctx);
179
+ // Record the current sender so the speaker-tracker can detect multi-speaker
180
+ // sessions before any tool calls fire this turn (hot-buffer records on
181
+ // agent_end, which is too late for tools executed mid-turn).
182
+ if (currentSessionId)
183
+ recordSender(currentSessionId, senderIdFromCtx(ctx));
122
184
  // Multi-user path
123
185
  if (cfg.multiUser) {
124
186
  const resolved = resolveUser(ctx, cfg);
@@ -139,11 +201,27 @@ export function buildAutoContextHandler(client, cfg) {
139
201
  const ranked = rerank(results, ranking);
140
202
  // Threshold + chatter quota applied here, so a high-similarity echo can
141
203
  // inform but never flood (the quota bounds count; the penalty bounds rank).
142
- const selected = selectRanked(ranked, cfg.maxResults, cfg.relevanceThreshold, ranking.chatterQuota);
204
+ const explained = explainSelection(ranked, cfg.maxResults, cfg.relevanceThreshold, ranking.chatterQuota);
205
+ logScoreSamples(prompt, currentSessionId, "single", explained, cfg.relevanceThreshold);
206
+ const selected = explained.filter((e) => e.selected).map((e) => e.result);
207
+ // Cut-reason visibility (proposal 02, absorbs proposal 03): logged
208
+ // BEFORE the `formatted` check so quota drops stay visible even when
209
+ // nothing is injected (e.g. chatterQuota 0 with only chatter clearing
210
+ // the threshold). Stable "auto-context: cut" prefix for log greps.
211
+ // flatMap (not filter) so TS narrows to the cut member of the union.
212
+ const cuts = explained.flatMap((e) => (e.selected ? [] : [e]));
213
+ if (cuts.length > 0) {
214
+ const cutTally = cuts.reduce((acc, e) => ((acc[e.cut] = (acc[e.cut] ?? 0) + 1), acc), {});
215
+ const topQuotaDrop = cuts.find((e) => e.cut === "chatter-quota");
216
+ const quotaNote = topQuotaDrop
217
+ ? `, top quota-dropped composite ${topQuotaDrop.result._composite.toFixed(2)}`
218
+ : "";
219
+ log.debug(`auto-context: cut ${cuts.length} of ${ranked.length} candidates ${JSON.stringify(cutTally)}${quotaNote}`);
220
+ }
143
221
  formatted = formatSelected(selected, cfg.relevanceThreshold);
144
222
  if (formatted) {
145
223
  const tally = selected.reduce((acc, r) => ((acc[r._kind] = (acc[r._kind] ?? 0) + 1), acc), {});
146
- log.debug(`auto-context: injecting (ranked) ${JSON.stringify(tally)} from ${results.length} candidates (chatter cap ${ranking.chatterQuota})`);
224
+ log.debug(`auto-context: injecting (ranked) ${JSON.stringify(tally)} from ${results.length} candidates (chatter cap ${ranking.chatterQuota}, composite ${selected.at(-1)?._composite.toFixed(2)}–${selected[0]?._composite.toFixed(2)})`);
147
225
  }
148
226
  }
149
227
  else {
@@ -170,6 +248,10 @@ export function buildAutoContextHandler(client, cfg) {
170
248
  }
171
249
  };
172
250
  }
251
+ // Score logging / cut attribution (proposal 02) applies to the ranked
252
+ // single-user path only: this path never runs rerank/explainSelection, so
253
+ // there is no composite score to attribute. If ranking ever lands here, call
254
+ // logScoreSamples with scope "personal" / "shared" per search.
173
255
  async function multiUserSearch(client, cfg, prompt, resolved, currentSessionId) {
174
256
  const multiUser = cfg.multiUser;
175
257
  const isKnownSender = !!resolved?.resolved;
@@ -256,7 +338,7 @@ async function multiUserSearch(client, cfg, prompt, resolved, currentSessionId)
256
338
  if (isKnownSender && resolved) {
257
339
  const contextLine = resolved.context ? ` ${resolved.context}` : "";
258
340
  return {
259
- prependContext: `<hyperspell-context>\nYou are speaking with ${resolved.name}.${contextLine}\n</hyperspell-context>`,
341
+ prependContext: `<hyperspell-context>\nYou are speaking with ${resolved.name}.${contextLine}\n\n${AUTHORITY_GUARD}\n</hyperspell-context>`,
260
342
  };
261
343
  }
262
344
  return;
@@ -264,6 +346,6 @@ async function multiUserSearch(client, cfg, prompt, resolved, currentSessionId)
264
346
  const totalCount = personalResults.length + sharedResults.length;
265
347
  log.debug(`auto-context: injecting ${totalCount} memories (${personalResults.length} personal, ${sharedResults.length} shared)`);
266
348
  return {
267
- prependContext: `<hyperspell-context>\n${sections.join("\n\n")}\n\n${DISCLAIMER}\n</hyperspell-context>`,
349
+ prependContext: `<hyperspell-context>\n${sections.join("\n\n")}\n\n${AUTHORITY_GUARD}\n\n${DISCLAIMER}\n</hyperspell-context>`,
268
350
  };
269
351
  }
@@ -1,7 +1,11 @@
1
+ import { channelIdFromCtx } from "../lib/exclude-channels.js";
1
2
  import { resolveUser } from "../lib/sender.js";
3
+ import { isMultiSpeaker } from "../lib/speaker-tracker.js";
2
4
  import { log } from "../logger.js";
3
5
  const MIN_MESSAGES = 3;
4
6
  const MIN_CONVERSATION_LENGTH = 100;
7
+ /** Sessions where we've already emitted the group-chat attribution warning. */
8
+ const warnedGroupTraceSessions = new Set();
5
9
  /**
6
10
  * Strip transport/injection metadata from a text blob before it's stored as a
7
11
  * trace memory. Without this the auto-context and emotional-state hooks'
@@ -110,8 +114,22 @@ export function buildAutoTraceHandler(client, cfg) {
110
114
  log.debug(`auto-trace: skipping — conversation too short (${estimate} chars)`);
111
115
  return;
112
116
  }
113
- const sessionId = event.sessionId ?? crypto.randomUUID();
117
+ // The session id lives on the hook CONTEXT, not the agent_end event (same
118
+ // contract issue #42 hit in hot-buffer): reading only event.sessionId
119
+ // yields a fresh random UUID per turn, which breaks multi-speaker
120
+ // detection keying and makes the openclaw_session_id tag useless for
121
+ // cleanup. Prefer ctx; keep event/random as fallbacks.
122
+ const sessionId = ctx?.sessionId ??
123
+ event.sessionId ??
124
+ crypto.randomUUID();
114
125
  const history = messagesToJSONL(messages, sessionId);
126
+ // Warn once per session when multiple speakers have no multiUser config:
127
+ // all turns collapse into a single undifferentiated trace under cfg.userId.
128
+ // Uses both is_group_chat and sender_id drift detection (issue #59).
129
+ if (isMultiSpeaker(sessionId, ctx?.is_group_chat === true) && !cfg.multiUser && !warnedGroupTraceSessions.has(sessionId)) {
130
+ warnedGroupTraceSessions.add(sessionId);
131
+ 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)");
132
+ }
115
133
  // Title from first user message
116
134
  const firstUser = messages.find((m) => m.role === "user");
117
135
  const title = firstUser?.content
@@ -121,12 +139,22 @@ export function buildAutoTraceHandler(client, cfg) {
121
139
  // Falls back to sharedUserId for unknown senders (or undefined in single-user mode).
122
140
  const resolved = resolveUser(ctx, cfg);
123
141
  const userId = resolved?.userId;
142
+ // Tag traces with the conversation they came from. channelIdFromCtx is the
143
+ // same resolver the quarantine check uses, so tag-time identity matches
144
+ // quarantine-time identity — the purge-channel CLI relies on that parity.
145
+ // session_id is a first-class trace field but is NOT exposed by
146
+ // listMemories, so it's mirrored into metadata for enumeration.
147
+ const channelId = channelIdFromCtx(ctx);
124
148
  try {
125
149
  const result = await client.sendTrace(history, {
126
150
  sessionId,
127
151
  title,
128
152
  extract: cfg.autoTrace.extract,
129
- metadata: cfg.autoTrace.metadata,
153
+ metadata: {
154
+ ...cfg.autoTrace.metadata,
155
+ ...(channelId ? { openclaw_channel_id: channelId } : {}),
156
+ openclaw_session_id: sessionId,
157
+ },
130
158
  userId,
131
159
  // Auto-trace captures full conversation text — the most sensitive class
132
160
  // of memory. Default to private; users opt into family-visible recall