@hyperspell/openclaw-hyperspell 0.18.1 → 0.20.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,3 +1,7 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { getWorkspaceDir } from "../config.js";
4
+ import { channelIdFromCtx } from "../lib/exclude-channels.js";
1
5
  import { resolveUser } from "../lib/sender.js";
2
6
  import { cleanupSpeakerSession, isMultiSpeaker, recordSender, senderIdFromCtx, } from "../lib/speaker-tracker.js";
3
7
  import { log } from "../logger.js";
@@ -12,10 +16,62 @@ const MAX_TOTAL_CHARS = 5_242_880;
12
16
  * this we'd re-post every prior message each turn. The server upserts (so it's
13
17
  * harmless correctness-wise), but re-posting is wasteful — this keeps each turn
14
18
  * to just its new messages. Cleared on session_end.
19
+ *
20
+ * This map alone is NOT restart-safe: it's module-scope, in-memory only. A
21
+ * gateway restart wipes it without firing session_end, so the next turn of a
22
+ * still-open session sees an empty set and re-posts the entire transcript to
23
+ * date in one shot (observed live: two 499/503-message flushes right after
24
+ * restarts, vs. the normal 2-10). `loadPersistedSent`/`persistSent` below
25
+ * mirror this map to disk per session so a restart degrades to "reload from
26
+ * disk" instead of "resend everything."
15
27
  */
16
28
  const sentBySession = new Map();
29
+ function stateDir(root) {
30
+ return path.join(root, "hot-buffer-sent");
31
+ }
32
+ function stateFile(root, sessionId) {
33
+ const safe = sessionId.replace(/[^a-zA-Z0-9_-]/g, "_");
34
+ return path.join(stateDir(root), `${safe}.json`);
35
+ }
36
+ function loadPersistedSent(root, sessionId) {
37
+ try {
38
+ const raw = fs.readFileSync(stateFile(root, sessionId), "utf-8");
39
+ const ids = JSON.parse(raw);
40
+ return new Set(ids);
41
+ }
42
+ catch {
43
+ return new Set();
44
+ }
45
+ }
46
+ function persistSent(root, sessionId, sent) {
47
+ try {
48
+ fs.mkdirSync(stateDir(root), { recursive: true });
49
+ fs.writeFileSync(stateFile(root, sessionId), JSON.stringify([...sent]));
50
+ }
51
+ catch (err) {
52
+ log.error("hot-buffer: failed to persist sent-id state to disk", err);
53
+ }
54
+ }
55
+ function deletePersistedSent(root, sessionId) {
56
+ try {
57
+ fs.unlinkSync(stateFile(root, sessionId));
58
+ }
59
+ catch {
60
+ // Nothing to clean up — fine.
61
+ }
62
+ }
17
63
  /** Sessions where we've already emitted the group-chat attribution warning. */
18
64
  const warnedGroupSessions = new Set();
65
+ /**
66
+ * Test-only: drop just the in-memory dedup cache for a session, leaving any
67
+ * persisted-to-disk state untouched. This is what a bare gateway restart
68
+ * looks like (module state wiped, disk state intact) — as opposed to
69
+ * `buildHotBufferSessionCleanupHandler`'s session_end, which clears both on
70
+ * purpose. Not used outside tests.
71
+ */
72
+ export function __simulateRestartForTest(sessionId) {
73
+ sentBySession.delete(sessionId);
74
+ }
19
75
  /**
20
76
  * Flatten a message's content into a single sanitized text string. Mirrors the
21
77
  * auto-trace sanitizer so the hot buffer never captures injected
@@ -57,7 +113,8 @@ function messageId(role, text) {
57
113
  * searchable. Runs on `agent_end` — fire-and-forget; a failure here must never
58
114
  * break the turn.
59
115
  */
60
- export function buildHotBufferHandler(client, cfg) {
116
+ export function buildHotBufferHandler(client, cfg, opts) {
117
+ const stateRoot = opts?.stateRoot ?? getWorkspaceDir();
61
118
  return async (event, ctx) => {
62
119
  if (event.success === false) {
63
120
  log.debug("hot-buffer: skipping — agent ended with error");
@@ -86,7 +143,15 @@ export function buildHotBufferHandler(client, cfg) {
86
143
  event.sessionId ??
87
144
  crypto.randomUUID();
88
145
  const resourceId = sessionId;
89
- const sent = sentBySession.get(sessionId) ?? new Set();
146
+ // Fall back to disk before assuming "nothing sent yet" an empty
147
+ // in-memory entry is ambiguous between "brand new session" and "this
148
+ // process restarted mid-session," and treating the latter as the former
149
+ // is exactly the full-transcript-resend bug this guards against.
150
+ let sent = sentBySession.get(sessionId);
151
+ if (!sent) {
152
+ sent = loadPersistedSent(stateRoot, sessionId);
153
+ sentBySession.set(sessionId, sent);
154
+ }
90
155
  // Record the current sender for evidence-based multi-speaker detection.
91
156
  // isMultiSpeaker() will return true once a second distinct sender_id
92
157
  // appears in this session, regardless of whether is_group_chat was set.
@@ -166,9 +231,11 @@ export function buildHotBufferHandler(client, cfg) {
166
231
  // 2026-07-02 (docs/filter-dialect-test.mjs: metadata-carrying row is
167
232
  // baseline-retrievable AND filterable). The retrieval exclude
168
233
  // {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;
234
+ // channelIdFromCtx (ctx.channelId, else sessionKey parse) is the same
235
+ // resolver the quarantine check uses — tag-time identity must equal
236
+ // quarantine-time identity or purge-channel misses rows the exclude
237
+ // would have blocked.
238
+ const channelId = channelIdFromCtx(ctx);
172
239
  const metadata = {
173
240
  openclaw_source: "hot_buffer",
174
241
  openclaw_session_id: sessionId,
@@ -186,6 +253,7 @@ export function buildHotBufferHandler(client, cfg) {
186
253
  for (const id of pendingIds)
187
254
  sent.add(id);
188
255
  sentBySession.set(sessionId, sent);
256
+ persistSent(stateRoot, sessionId, sent);
189
257
  log.info(`hot-buffer: wrote ${total} message(s) to ${sessionId} (user=${userId})`);
190
258
  }
191
259
  catch (err) {
@@ -195,13 +263,15 @@ export function buildHotBufferHandler(client, cfg) {
195
263
  };
196
264
  }
197
265
  /** Drop per-session state on session end to avoid unbounded growth. */
198
- export function buildHotBufferSessionCleanupHandler() {
266
+ export function buildHotBufferSessionCleanupHandler(opts) {
267
+ const stateRoot = opts?.stateRoot ?? getWorkspaceDir();
199
268
  return (event) => {
200
269
  const sessionId = event.sessionId;
201
270
  if (sessionId) {
202
271
  sentBySession.delete(sessionId);
203
272
  warnedGroupSessions.delete(sessionId);
204
273
  cleanupSpeakerSession(sessionId);
274
+ deletePersistedSent(stateRoot, sessionId);
205
275
  }
206
276
  };
207
277
  }
@@ -1,7 +1,7 @@
1
1
  import * as path from "node:path";
2
2
  import { getWorkspaceDir } from "../config.js";
3
3
  import { log } from "../logger.js";
4
- import { syncMarkdownFile, syncMarkdownFileSectionized, syncAllMemoryFiles, syncAllFilesSectionized, } from "../sync/markdown.js";
4
+ import { resolveSyncSource, resolveWatchPath, syncMarkdownFile, syncMarkdownFileSectionized, syncAllMemoryFiles, syncAllFilesSectionized, } from "../sync/markdown.js";
5
5
  /**
6
6
  * Build a handler for file change events that syncs markdown files to Hyperspell.
7
7
  *
@@ -21,17 +21,27 @@ export function buildFileSyncHandler(client, cfg) {
21
21
  const debounceMs = cfg.syncMemoriesConfig.debounceMs;
22
22
  const ignoreDirs = new Set(cfg.syncMemoriesConfig.ignorePaths);
23
23
  // Resolve additional watch paths to absolute paths for matching
24
- const resolvedWatchPaths = (watchPaths ?? []).map((wp) => wp.startsWith("/") ? wp : path.join(workspaceDir, wp));
24
+ const resolvedWatchPaths = (watchPaths ?? []).map((wp) => resolveWatchPath(workspaceDir, wp.path));
25
25
  /**
26
26
  * Mirror the bulk walk's exclusions on the live path: a file under a
27
27
  * dot-directory (e.g. .dreams) or an ignored directory (e.g. dreaming) must
28
28
  * not live-sync either, or an edit would re-ingest exactly what the walk
29
- * skips.
29
+ * skips. Applies against every watch root (memory/ AND each watchPath), so
30
+ * e.g. notes/brainstem/.drafts/x.md is excluded like the walk excludes it.
31
+ * The longest containing root wins: segments inside an explicitly configured
32
+ * watchPath are judged, the watchPath's own path is not.
30
33
  */
31
34
  function isIgnoredPath(filePath) {
32
- const rel = path.relative(memoryDir, filePath);
33
- if (rel.startsWith("..") || path.isAbsolute(rel))
35
+ let root;
36
+ for (const candidate of [memoryDir, ...resolvedWatchPaths]) {
37
+ if (filePath === candidate || filePath.startsWith(candidate + path.sep)) {
38
+ if (!root || candidate.length > root.length)
39
+ root = candidate;
40
+ }
41
+ }
42
+ if (!root)
34
43
  return false;
44
+ const rel = path.relative(root, filePath);
35
45
  const segments = rel.split(path.sep).slice(0, -1); // directory segments only
36
46
  return segments.some((seg) => seg.startsWith(".") || ignoreDirs.has(seg));
37
47
  }
@@ -59,9 +69,12 @@ export function buildFileSyncHandler(client, cfg) {
59
69
  async function doSync(filePath) {
60
70
  const fileName = path.basename(filePath);
61
71
  log.info(`Memory file changed: ${fileName}`);
72
+ // Same provenance the startup bulk sync stamps, so a file gets identical
73
+ // openclaw_sync_source metadata whichever path ingests it first.
74
+ const syncSource = resolveSyncSource(filePath, workspaceDir, watchPaths ?? []);
62
75
  try {
63
76
  if (sectionize) {
64
- const result = await syncMarkdownFileSectionized(client, filePath, workspaceDir, { userId: syncUserId });
77
+ const result = await syncMarkdownFileSectionized(client, filePath, workspaceDir, { userId: syncUserId, syncSource });
65
78
  if (result.synced > 0 || result.removed > 0) {
66
79
  log.info(`Section-synced ${fileName}: ${result.synced} synced, ${result.skipped} unchanged, ${result.removed} removed`);
67
80
  }
@@ -77,7 +90,10 @@ export function buildFileSyncHandler(client, cfg) {
77
90
  }
78
91
  else {
79
92
  // Legacy whole-file sync
80
- const result = await syncMarkdownFile(client, filePath, { userId: syncUserId });
93
+ const result = await syncMarkdownFile(client, filePath, {
94
+ userId: syncUserId,
95
+ syncSource,
96
+ });
81
97
  if (result.success) {
82
98
  log.info(`Synced ${fileName} -> ${result.resourceId}`);
83
99
  }
@@ -22,11 +22,16 @@
22
22
  * store path. One random cold morning must NOT calcify into "we've been
23
23
  * distant lately." One day's weather, then gone. (The store handler in
24
24
  * emotional-state.ts is untouched, so this is enforced by construction.)
25
+ * A private, recall-excluded observability record IS written per roll
26
+ * (issue #71) — see recordMoodRoll below; it is invisible to every
27
+ * injection/recall path, so the guarantee holds.
25
28
  * - BOUNDED. The dice can make her *difficult* — short, contrary, melancholy,
26
29
  * flat. They do NOT get to make her hurtful on purpose. "In a mood" is alive;
27
30
  * "mean" is just a bad feature. Mood descriptions below stay on the right side
28
31
  * of that line.
29
32
  */
33
+ import { MOOD_WEATHER_SOURCE } from "../lib/filters.js";
34
+ import { log } from "../logger.js";
30
35
  /**
31
36
  * The weather table. Warmer/lighter moods are weighted a touch heavier than the
32
37
  * darker ones — not to defang it, but because a person who woke up cold *every*
@@ -111,3 +116,43 @@ export function buildMoodWeatherContext(mood) {
111
116
  "</hyperspell-mood-weather>",
112
117
  ].join("\n");
113
118
  }
119
+ /** Collection the roll records live in, so /moodweather can list them without a search. */
120
+ export const MOOD_WEATHER_COLLECTION = "mood-weather";
121
+ /**
122
+ * Fire-and-forget observability record for a mood roll (issue #71).
123
+ *
124
+ * This does NOT weaken the "does not write forward" contract above: the record
125
+ * goes to the generic vault store tagged openclaw_source="mood_weather", which
126
+ * excludeFilterFor() drops from every recall path (auto-context, the
127
+ * hyperspell_search tool, startup-orientation loops, knowledge graph). The
128
+ * emotional-state arc fetch reads a different endpoint entirely (GET
129
+ * /emotional-state[/recent], written only by storeEmotionalState), so it can
130
+ * never surface there. Queryable only via the dedicated /moodweather command.
131
+ *
132
+ * Written via client.addMemory (memories.add) — NEVER POST /messages: a
133
+ * /messages write carrying metadata renders the row non-retrievable (see the
134
+ * warning in lib/filters.ts), while memories.add metadata is proven to persist
135
+ * and be filterable (canary A in docs/filter-dialect-test.mjs).
136
+ *
137
+ * Deliberately not awaited: this sits on the first-turn injection hot path, and
138
+ * a logging write must never delay or break the session.
139
+ */
140
+ export function recordMoodRoll(client, mood, opts) {
141
+ const rolledAt = new Date().toISOString();
142
+ void client
143
+ .addMemory(`Mood weather roll: woke up "${mood.id}" (${rolledAt}). Exogenous session mood — uncaused, session-only, never part of the relational register.`, {
144
+ title: `Mood weather — ${mood.id} (${rolledAt.slice(0, 10)})`,
145
+ collection: MOOD_WEATHER_COLLECTION,
146
+ metadata: {
147
+ openclaw_source: MOOD_WEATHER_SOURCE,
148
+ mood: mood.id,
149
+ rolled_at: rolledAt,
150
+ ...(opts.sessionKey ? { session: opts.sessionKey } : {}),
151
+ ...(opts.relationshipId ? { relationship_id: opts.relationshipId } : {}),
152
+ },
153
+ })
154
+ .catch((err) => {
155
+ // Fire-and-forget — observability must never break the session.
156
+ log.warn("mood-weather: roll record write failed (non-fatal)", err);
157
+ });
158
+ }
@@ -1,3 +1,4 @@
1
+ import { excludeFilterFor } from "../lib/filters.js";
1
2
  import { resolveUser } from "../lib/sender.js";
2
3
  import { resolveCurrentSessionId } from "../lib/session.js";
3
4
  import { isMultiSpeaker } from "../lib/speaker-tracker.js";
@@ -78,7 +79,7 @@ function isoDaysAgo(days) {
78
79
  * an unresolved caller has no personal space to orient to. In single-user mode,
79
80
  * return undefined so the client falls back to its configured userId.
80
81
  */
81
- function personalUserId(cfg, ctx) {
82
+ export function personalUserId(cfg, ctx) {
82
83
  if (!cfg.multiUser)
83
84
  return { skip: false, userId: undefined };
84
85
  const resolved = resolveUser(ctx, cfg);
@@ -191,8 +192,83 @@ async function fetchRecentConversations(client, limit, userId) {
191
192
  }
192
193
  return out;
193
194
  }
194
- export function buildStartupOrientationHandler(client, cfg) {
195
+ /**
196
+ * The fetch+format core of startup orientation, shared by the real
197
+ * before_agent_start handler and the read-only /previewcontext command.
198
+ * Pure with respect to session lifecycle: no inject-once cache, no retry
199
+ * counting — callers own that policy. Keeping one formatter here keeps the
200
+ * preview byte-identical to real injection.
201
+ */
202
+ export async function gatherOrientation(client, cfg, userId) {
195
203
  const so = cfg.startupOrientation;
204
+ // Source recent-interactions from wherever the session record actually
205
+ // lives. Prefer the hot buffer (modern path: clean session-grouped vault
206
+ // resources, present whenever the hot buffer is on — including auto-trace-
207
+ // off agents). Fall back to agent_end traces only when there's no hot
208
+ // buffer but auto-trace is on. Otherwise skip: there's nothing to fetch,
209
+ // and the trace-source list is expensive (observed ~12s + failing/turn),
210
+ // blocking before_agent_start and slowing the reply.
211
+ const recentSource = cfg.hotBuffer.enabled
212
+ ? "hotBuffer"
213
+ : cfg.autoTrace.enabled
214
+ ? "autoTrace"
215
+ : "none";
216
+ const recentFetch = recentSource === "hotBuffer"
217
+ ? fetchRecentConversations(client, so.recentLimit, userId)
218
+ : recentSource === "autoTrace"
219
+ ? fetchRecentTraces(client, isoDaysAgo(so.recentDays), so.recentLimit, userId)
220
+ : Promise.resolve([]);
221
+ const [recentSettled, loopsSettled] = await Promise.allSettled([
222
+ recentFetch,
223
+ // Loops feed agent context, so tagged observability rows (agent_end
224
+ // traces, mood_weather rolls) must be dropped like on every other recall
225
+ // path — this also closes a pre-existing gap where agent_end traces
226
+ // could surface in the loops block.
227
+ client.search(so.loopsQuery, {
228
+ limit: so.loopsLimit,
229
+ userId,
230
+ filter: excludeFilterFor(cfg),
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);
242
+ }
243
+ const recentBody = formatRecentInteractions(recent);
244
+ const loopsBody = formatUnfinishedLoops(loops);
245
+ return {
246
+ recentOk,
247
+ loopsOk,
248
+ recentCount: recent.length,
249
+ loopsCount: loops.length,
250
+ recentSource,
251
+ recentBlock: recentBody
252
+ ? [
253
+ "<hyperspell-recent-interactions>",
254
+ `Your last ${so.recentDays} days of conversations with this user, most-recent-first. Use for situational continuity — don't quote verbatim.`,
255
+ "",
256
+ recentBody,
257
+ "</hyperspell-recent-interactions>",
258
+ ].join("\n")
259
+ : null,
260
+ loopsBlock: loopsBody
261
+ ? [
262
+ "<hyperspell-unfinished-loops>",
263
+ "Possible open threads — promises made, questions pending, work in progress. Low-confidence retrieval; treat as prompts to consider, not facts to act on.",
264
+ "",
265
+ loopsBody,
266
+ "</hyperspell-unfinished-loops>",
267
+ ].join("\n")
268
+ : null,
269
+ };
270
+ }
271
+ export function buildStartupOrientationHandler(client, cfg) {
196
272
  return async (_event, ctx) => {
197
273
  const sessionKey = ctx?.sessionKey;
198
274
  if (sessionKey && injectedSessions.has(sessionKey))
@@ -224,36 +300,8 @@ export function buildStartupOrientationHandler(client, cfg) {
224
300
  injectedSessions.add(sessionKey);
225
301
  return;
226
302
  }
227
- // Source recent-interactions from wherever the session record actually
228
- // lives. Prefer the hot buffer (modern path: clean session-grouped vault
229
- // resources, present whenever the hot buffer is on — including auto-trace-
230
- // off agents). Fall back to agent_end traces only when there's no hot
231
- // buffer but auto-trace is on. Otherwise skip: there's nothing to fetch,
232
- // and the trace-source list is expensive (observed ~12s + failing/turn),
233
- // blocking before_agent_start and slowing the reply.
234
- const recentFetch = cfg.hotBuffer.enabled
235
- ? fetchRecentConversations(client, so.recentLimit, userId)
236
- : cfg.autoTrace.enabled
237
- ? fetchRecentTraces(client, isoDaysAgo(so.recentDays), so.recentLimit, userId)
238
- : Promise.resolve([]);
239
- const [recentSettled, loopsSettled] = await Promise.allSettled([
240
- recentFetch,
241
- client.search(so.loopsQuery, {
242
- limit: so.loopsLimit,
243
- userId,
244
- }),
245
- ]);
246
- const recentOk = recentSettled.status === "fulfilled";
247
- const loopsOk = loopsSettled.status === "fulfilled";
248
- const recent = recentOk ? recentSettled.value : [];
249
- const loops = loopsOk ? loopsSettled.value : [];
250
- if (!recentOk) {
251
- log.error("startup-orientation: recent listMemories failed", recentSettled.reason);
252
- }
253
- if (!loopsOk) {
254
- log.error("startup-orientation: loops search failed", loopsSettled.reason);
255
- }
256
- if (!recentOk && !loopsOk) {
303
+ const gathered = await gatherOrientation(client, cfg, userId);
304
+ if (!gathered.recentOk && !gathered.loopsOk) {
257
305
  if (sessionKey)
258
306
  failedAttempts.set(sessionKey, tries + 1);
259
307
  log.debug(`startup-orientation: both calls failed (attempt ${tries + 1}/${MAX_ATTEMPTS}); will retry next turn`);
@@ -263,32 +311,15 @@ export function buildStartupOrientationHandler(client, cfg) {
263
311
  injectedSessions.add(sessionKey);
264
312
  failedAttempts.delete(sessionKey);
265
313
  }
266
- const recentBody = formatRecentInteractions(recent);
267
- const loopsBody = formatUnfinishedLoops(loops);
268
- if (!recentBody && !loopsBody) {
314
+ if (!gathered.recentBlock && !gathered.loopsBlock) {
269
315
  log.debug("startup-orientation: nothing to inject");
270
316
  return;
271
317
  }
272
- const blocks = [];
273
- if (recentBody) {
274
- blocks.push([
275
- "<hyperspell-recent-interactions>",
276
- `Your last ${so.recentDays} days of conversations with this user, most-recent-first. Use for situational continuity — don't quote verbatim.`,
277
- "",
278
- recentBody,
279
- "</hyperspell-recent-interactions>",
280
- ].join("\n"));
281
- }
282
- if (loopsBody) {
283
- blocks.push([
284
- "<hyperspell-unfinished-loops>",
285
- "Possible open threads — promises made, questions pending, work in progress. Low-confidence retrieval; treat as prompts to consider, not facts to act on.",
286
- "",
287
- loopsBody,
288
- "</hyperspell-unfinished-loops>",
289
- ].join("\n"));
290
- }
291
- log.debug(`startup-orientation: injecting recent=${recent.length} loops=${loops.length}`);
318
+ const blocks = [gathered.recentBlock, gathered.loopsBlock].filter((b) => b !== null);
319
+ // log.diag, not debug: this one-line injection summary is the operator's
320
+ // only live signal that orientation fired (issue #118 — host drops plugin
321
+ // debug output from gateway.log).
322
+ log.diag(`startup-orientation: injecting recent=${gathered.recentCount} loops=${gathered.loopsCount}`);
292
323
  return { prependContext: blocks.join("\n\n") };
293
324
  };
294
325
  }
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@ import { buildHotBufferHandler, buildHotBufferSessionCleanupHandler, } from "./h
10
10
  import { buildStartupOrientationCompactionHandler, buildStartupOrientationHandler, buildStartupOrientationSessionCleanupHandler, } from "./hooks/startup-orientation.js";
11
11
  import { isExcludedChannel } from "./lib/exclude-channels.js";
12
12
  import { initLogger, log } from "./logger.js";
13
+ import { createEmotionalArcToolFactory } from "./tools/emotional-arc.js";
13
14
  import { createRememberToolFactory } from "./tools/remember.js";
14
15
  import { createSearchToolFactory } from "./tools/search.js";
15
16
  import { registerNetworkTools } from "./graph/index.js";
@@ -63,6 +64,17 @@ export default {
63
64
  };
64
65
  },
65
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
+ });
66
78
  return;
67
79
  }
68
80
  const cfg = parseConfig(api.pluginConfig);
@@ -89,6 +101,15 @@ export default {
89
101
  });
90
102
  const startHandlers = [];
91
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" });
92
113
  startHandlers.push(buildEmotionalStateFetchHandler(client, cfg));
93
114
  api.on("after_compaction", buildEmotionalStateCompactionHandler());
94
115
  api.on("session_end", buildEmotionalStateSessionCleanupHandler());
@@ -149,6 +170,22 @@ export default {
149
170
  if (cfg.knowledgeGraph.enabled) {
150
171
  registerNetworkTools(api, client, cfg);
151
172
  }
173
+ else {
174
+ // Discoverability (issue #81): the Memory Network ships fully built but
175
+ // default-off. If memories are accumulating (hot buffer / auto-trace /
176
+ // emotional state) and the operator never made a knowledgeGraph decision,
177
+ // say so once at startup — otherwise the feature is undetectable without
178
+ // reading source. Keyed off the RAW config: an explicit `knowledgeGraph`
179
+ // key (even { enabled: false }) is a decision and suppresses this.
180
+ const memoryAccumulating = cfg.hotBuffer.enabled || cfg.autoTrace.enabled || cfg.emotionalContext;
181
+ if (rawConfig?.knowledgeGraph === undefined && memoryAccumulating) {
182
+ log.info("memories are accumulating but the Memory Network (knowledgeGraph) is not configured — " +
183
+ "no entity extraction into memory/people|projects|organizations|topics will run. " +
184
+ "Enable it via 'openclaw openclaw-hyperspell setup' (Memory Network step) or set " +
185
+ "knowledgeGraph.enabled: true, or silence this note with knowledgeGraph: { enabled: false }. " +
186
+ "See README § Memory Network.");
187
+ }
188
+ }
152
189
  // Register slash commands
153
190
  registerCommands(api, client, cfg);
154
191
  // Register service for lifecycle management
@@ -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
+ }
@@ -11,6 +11,10 @@
11
11
  * (`ctx.channelId` on agent hook contexts — e.g. a Discord channel id). Tool
12
12
  * factory contexts don't carry `channelId`, so we recover the same id from the
13
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).
14
18
  */
15
19
  // Conversation-kind segments used in OpenClaw session keys. Mirrors core's
16
20
  // TARGET_PREFIXES (src/plugins/hook-agent-context.ts) so the sessionKey
@@ -40,6 +44,17 @@ export function channelIdFromCtx(ctx) {
40
44
  return direct;
41
45
  return conversationIdFromSessionKey(ctx?.sessionKey);
42
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
+ }
43
58
  /**
44
59
  * True when the context's conversation is quarantined. Purely subtractive on
45
60
  * failure: an unresolvable id means "not excluded" — a session we can't place
@@ -48,12 +63,8 @@ export function channelIdFromCtx(ctx) {
48
63
  export function isExcludedChannel(ctx, cfg) {
49
64
  if (cfg.excludeChannels.length === 0)
50
65
  return false;
51
- const id = channelIdFromCtx(ctx)?.toLowerCase();
66
+ const id = channelIdFromCtx(ctx);
52
67
  if (!id)
53
68
  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
- });
69
+ return cfg.excludeChannels.some((entry) => conversationMatchesChannel(id, entry));
59
70
  }