@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,7 +1,10 @@
1
1
  import { getWorkspaceDir } from "../config.js";
2
+ import { MOOD_WEATHER_COLLECTION } from "../hooks/mood-weather.js";
3
+ import { MOOD_WEATHER_SOURCE } from "../lib/filters.js";
2
4
  import { buildScopeFilter, getCanReadScopes, getDefaultWriteScope, resolveRole, resolveUser, routeWrite, } from "../lib/sender.js";
3
5
  import { log } from "../logger.js";
4
6
  import { syncAllMemoryFiles } from "../sync/markdown.js";
7
+ import { buildPreviewReport } from "./preview.js";
5
8
  /**
6
9
  * Strip a `#scope-name` prefix from free text. Returns the scope and the
7
10
  * remainder. Used by /remember and /getcontext to let users narrow or route
@@ -156,4 +159,59 @@ export function registerCommands(api, client, cfg) {
156
159
  }
157
160
  },
158
161
  });
162
+ // /moodweather — private roll history (operator retrospection only; these rows
163
+ // are excluded from all agent recall, so this command is the ONLY reader).
164
+ api.registerCommand({
165
+ name: "moodweather",
166
+ description: "Show recent mood-weather rolls (never fed back into tone)",
167
+ acceptsArgs: false,
168
+ requireAuth: true,
169
+ handler: async () => {
170
+ log.debug("/moodweather command");
171
+ try {
172
+ const rows = [];
173
+ for await (const mem of client.listMemories({
174
+ collection: MOOD_WEATHER_COLLECTION,
175
+ pageSize: 50,
176
+ })) {
177
+ if (mem.metadata?.openclaw_source !== MOOD_WEATHER_SOURCE)
178
+ continue;
179
+ rows.push({
180
+ mood: String(mem.metadata.mood ?? "?"),
181
+ rolledAt: String(mem.metadata.rolled_at ?? ""),
182
+ });
183
+ if (rows.length >= 20)
184
+ break;
185
+ }
186
+ if (rows.length === 0) {
187
+ return { text: "No mood-weather rolls recorded." };
188
+ }
189
+ const lines = rows.map((r) => `• ${r.rolledAt.slice(0, 16).replace("T", " ")} — ${r.mood}`);
190
+ return {
191
+ text: `Recent mood-weather rolls (newest first):\n${lines.join("\n")}`,
192
+ };
193
+ }
194
+ catch (err) {
195
+ log.error("/moodweather failed", err);
196
+ return { text: "Failed to fetch mood-weather history. Check logs for details." };
197
+ }
198
+ },
199
+ });
200
+ // /previewcontext - Show what would be injected into the next session
201
+ api.registerCommand({
202
+ name: "previewcontext",
203
+ description: "Preview what Hyperspell would inject at the next session start",
204
+ acceptsArgs: false,
205
+ requireAuth: true,
206
+ handler: async (ctx) => {
207
+ log.debug("/previewcontext command");
208
+ try {
209
+ return { text: await buildPreviewReport(client, cfg, ctx) };
210
+ }
211
+ catch (err) {
212
+ log.error("/previewcontext failed", err);
213
+ return { text: "Failed to build preview. Check logs for details." };
214
+ }
215
+ },
216
+ });
159
217
  }
package/dist/config.js CHANGED
@@ -62,6 +62,39 @@ function resolveEnvVars(value) {
62
62
  return envValue;
63
63
  });
64
64
  }
65
+ /**
66
+ * Normalize `syncMemories.watchPaths` entries: the shipped/documented string
67
+ * form stays valid and is additive-upgraded to `{ path }`; the object form
68
+ * carries an optional provenance `source` label. Labels are sanitized with the
69
+ * same character rule as normalizeScope — metadata values must be alphanumeric
70
+ * + underscore or Hyperspell metadata filters silently miss.
71
+ */
72
+ function parseWatchPaths(raw) {
73
+ if (!Array.isArray(raw))
74
+ return [];
75
+ return raw.map((wp) => {
76
+ if (typeof wp === "string") {
77
+ if (!wp.trim()) {
78
+ throw new Error("hyperspell.syncMemories.watchPaths[] entry needs a non-empty path");
79
+ }
80
+ return { path: wp };
81
+ }
82
+ if (typeof wp !== "object" || wp === null || Array.isArray(wp)) {
83
+ throw new Error("hyperspell.syncMemories.watchPaths[] entries must be a string or { path, source? }");
84
+ }
85
+ const entry = wp;
86
+ assertAllowedKeys(entry, ["path", "source"], "hyperspell.syncMemories.watchPaths[]");
87
+ if (typeof entry.path !== "string" || !entry.path.trim()) {
88
+ throw new Error("hyperspell.syncMemories.watchPaths[] entry needs a non-empty path");
89
+ }
90
+ if (!entry.source)
91
+ return { path: entry.path };
92
+ return {
93
+ path: entry.path,
94
+ source: String(entry.source).replace(/[^a-zA-Z0-9_]/g, "_"),
95
+ };
96
+ });
97
+ }
65
98
  function parseRanking(raw) {
66
99
  const r = (raw ?? {});
67
100
  const num = (v, d) => (typeof v === "number" ? v : d);
@@ -70,8 +103,16 @@ function parseRanking(raw) {
70
103
  curationBoost: num(r.curationBoost, DEFAULT_RANKING.curationBoost),
71
104
  chatterPenalty: num(r.chatterPenalty, DEFAULT_RANKING.chatterPenalty),
72
105
  storyBoost: num(r.storyBoost, DEFAULT_RANKING.storyBoost),
106
+ // Trim + lowercase + dedupe, drop whitespace-only entries. Before this a
107
+ // stray " " entry passed the length filter and (under the old substring
108
+ // matcher) classified EVERY result as story, silently disabling the
109
+ // chatter penalty and quota (issue #82's footgun).
73
110
  storyTerms: Array.isArray(r.storyTerms)
74
- ? r.storyTerms.map((t) => String(t)).filter((t) => t.length > 0)
111
+ ? [
112
+ ...new Set(r.storyTerms
113
+ .map((t) => String(t).trim().toLowerCase())
114
+ .filter((t) => t.length > 0)),
115
+ ]
75
116
  : DEFAULT_RANKING.storyTerms,
76
117
  candidateMultiplier: Math.max(1, num(r.candidateMultiplier, DEFAULT_RANKING.candidateMultiplier)),
77
118
  chatterQuota: Math.max(0, num(r.chatterQuota, DEFAULT_RANKING.chatterQuota)),
@@ -324,9 +365,7 @@ export function parseConfig(raw) {
324
365
  syncMemoriesConfig: {
325
366
  enabled: syncMemoriesEnabled,
326
367
  sectionize: smObj.sectionize ?? true,
327
- watchPaths: Array.isArray(smObj.watchPaths)
328
- ? smObj.watchPaths
329
- : [],
368
+ watchPaths: parseWatchPaths(smObj.watchPaths),
330
369
  debounceMs: smObj.debounceMs ?? 2000,
331
370
  maxAgeDays: smObj.maxAgeDays ?? 30,
332
371
  ignorePaths: Array.isArray(smObj.ignorePaths)
package/dist/graph/ops.js CHANGED
@@ -1,8 +1,31 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
+ import { MOOD_WEATHER_SOURCE } from "../lib/filters.js";
3
4
  import { getAllUserIds } from "../lib/sender.js";
4
5
  import { log } from "../logger.js";
5
6
  const ENTITY_TYPES = ["people", "projects", "organizations", "topics"];
7
+ /**
8
+ * True for memories that came from a Memory Network entity file synced back
9
+ * to Hyperspell. These must never re-enter a scan as source memories — the
10
+ * extractor would be fed its own output every cycle (self-scan loop,
11
+ * proposal/06 §3.3). Two signals, both needed:
12
+ * - `graph_entity` metadata, propagated from entity-file frontmatter by the
13
+ * sync paths (`sync/markdown.ts`) — the contract the extraction prompt states;
14
+ * - the entity-directory `file_path`, which covers records synced before the
15
+ * frontmatter propagation existed (unchanged sections are content-hash
16
+ * skipped on re-sync, so their old metadata never gets refreshed).
17
+ */
18
+ export function isEntityFileMemory(metadata) {
19
+ if (!metadata)
20
+ return false;
21
+ if (metadata.graph_entity === "true" || metadata.graph_entity === true)
22
+ return true;
23
+ const filePath = metadata.file_path;
24
+ if (typeof filePath !== "string")
25
+ return false;
26
+ const normalized = filePath.split(path.sep).join("/");
27
+ return ENTITY_TYPES.some((type) => normalized.includes(`/memory/${type}/`));
28
+ }
6
29
  export function slugify(name) {
7
30
  return name
8
31
  .toLowerCase()
@@ -60,17 +83,27 @@ function summarizeMemoryData(data, source) {
60
83
  }
61
84
  return parts.join("\n");
62
85
  }
63
- export async function scanMemories(client, stateManager, batchSize, cfg) {
86
+ export async function scanMemories(client, stateManager, batchSize,
87
+ // Required (not optional) so no caller can silently bypass the multiUser
88
+ // fan-out again — the CLI `network scan` did exactly that (proposal/06 §3.3).
89
+ cfg) {
64
90
  const unprocessed = [];
65
- const userIds = cfg ? getAllUserIds(cfg) : [undefined];
91
+ // Single-user installs without a configured userId resolve to no ids;
92
+ // scan under the API key's default identity in that case.
93
+ const configuredIds = getAllUserIds(cfg);
94
+ const userIds = configuredIds.length > 0 ? configuredIds : [undefined];
66
95
  outer: for (const userId of userIds) {
67
96
  for await (const mem of client.listMemories({ userId })) {
68
97
  if (stateManager.isProcessed(mem.resourceId))
69
98
  continue;
70
- if (mem.metadata?.graph_entity === "true" || mem.metadata?.graph_entity === true)
99
+ if (isEntityFileMemory(mem.metadata))
71
100
  continue;
72
101
  if (mem.metadata?.status !== "completed")
73
102
  continue;
103
+ // Mood-weather roll records are observability-only (issue #71): the graph
104
+ // feeds context, so ingesting them would leak the rolls back into recall.
105
+ if (mem.metadata?.openclaw_source === MOOD_WEATHER_SOURCE)
106
+ continue;
74
107
  let summary = "";
75
108
  try {
76
109
  const full = await client.getMemory(mem.resourceId, mem.source, { userId });
@@ -1,6 +1,7 @@
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, kindTally, rerank, } from "../lib/ranking.js";
4
5
  import { classifySearchError, logSearchError } from "../lib/search-error.js";
5
6
  import { resolveCurrentSessionId } from "../lib/session.js";
6
7
  import { recordSender, senderIdFromCtx } from "../lib/speaker-tracker.js";
@@ -79,6 +80,49 @@ function formatSelected(selected, threshold) {
79
80
  return null;
80
81
  return sections.join("\n\n");
81
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
+ }
82
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.";
83
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.";
84
128
  // A short, CONDITIONAL reminder appended only to a real memory block (not a
@@ -157,11 +201,34 @@ export function buildAutoContextHandler(client, cfg) {
157
201
  const ranked = rerank(results, ranking);
158
202
  // Threshold + chatter quota applied here, so a high-similarity echo can
159
203
  // inform but never flood (the quota bounds count; the penalty bounds rank).
160
- 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
+ // Candidates → selected kind tally, logged UNCONDITIONALLY (unlike the
208
+ // "injecting" line below): a story/curated match that loses to the
209
+ // threshold is exactly the case an operator tuning storyTerms needs to
210
+ // see, and it never reaches the formatted branch (proposal 01 §3.4).
211
+ log.diag(`auto-context: ranked ${JSON.stringify(kindTally(ranked))} candidates → selected ${JSON.stringify(kindTally(selected))} (chatter cap ${ranking.chatterQuota})`);
212
+ for (const r of ranked.slice(0, 10)) {
213
+ log.debug(` [${r._kind}] ${r._base.toFixed(2)}→${r._composite.toFixed(2)} ${(r.title ?? r.resourceId).slice(0, 60)}`);
214
+ }
215
+ // Cut-reason visibility (proposal 02, absorbs proposal 03): logged
216
+ // BEFORE the `formatted` check so quota drops stay visible even when
217
+ // nothing is injected (e.g. chatterQuota 0 with only chatter clearing
218
+ // the threshold). Stable "auto-context: cut" prefix for log greps.
219
+ // flatMap (not filter) so TS narrows to the cut member of the union.
220
+ const cuts = explained.flatMap((e) => (e.selected ? [] : [e]));
221
+ if (cuts.length > 0) {
222
+ const cutTally = cuts.reduce((acc, e) => ((acc[e.cut] = (acc[e.cut] ?? 0) + 1), acc), {});
223
+ const topQuotaDrop = cuts.find((e) => e.cut === "chatter-quota");
224
+ const quotaNote = topQuotaDrop
225
+ ? `, top quota-dropped composite ${topQuotaDrop.result._composite.toFixed(2)}`
226
+ : "";
227
+ log.diag(`auto-context: cut ${cuts.length} of ${ranked.length} candidates ${JSON.stringify(cutTally)}${quotaNote}`);
228
+ }
161
229
  formatted = formatSelected(selected, cfg.relevanceThreshold);
162
230
  if (formatted) {
163
- const tally = selected.reduce((acc, r) => ((acc[r._kind] = (acc[r._kind] ?? 0) + 1), acc), {});
164
- log.debug(`auto-context: injecting (ranked) ${JSON.stringify(tally)} from ${results.length} candidates (chatter cap ${ranking.chatterQuota})`);
231
+ log.diag(`auto-context: injecting (ranked) ${JSON.stringify(kindTally(selected))} from ${results.length} candidates (chatter cap ${ranking.chatterQuota}, composite ${selected.at(-1)?._composite.toFixed(2)}–${selected[0]?._composite.toFixed(2)})`);
165
232
  }
166
233
  }
167
234
  else {
@@ -188,6 +255,10 @@ export function buildAutoContextHandler(client, cfg) {
188
255
  }
189
256
  };
190
257
  }
258
+ // Score logging / cut attribution (proposal 02) applies to the ranked
259
+ // single-user path only: this path never runs rerank/explainSelection, so
260
+ // there is no composite score to attribute. If ranking ever lands here, call
261
+ // logScoreSamples with scope "personal" / "shared" per search.
191
262
  async function multiUserSearch(client, cfg, prompt, resolved, currentSessionId) {
192
263
  const multiUser = cfg.multiUser;
193
264
  const isKnownSender = !!resolved?.resolved;
@@ -1,3 +1,4 @@
1
+ import { channelIdFromCtx } from "../lib/exclude-channels.js";
1
2
  import { resolveUser } from "../lib/sender.js";
2
3
  import { isMultiSpeaker } from "../lib/speaker-tracker.js";
3
4
  import { log } from "../logger.js";
@@ -113,7 +114,14 @@ export function buildAutoTraceHandler(client, cfg) {
113
114
  log.debug(`auto-trace: skipping — conversation too short (${estimate} chars)`);
114
115
  return;
115
116
  }
116
- 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();
117
125
  const history = messagesToJSONL(messages, sessionId);
118
126
  // Warn once per session when multiple speakers have no multiUser config:
119
127
  // all turns collapse into a single undifferentiated trace under cfg.userId.
@@ -131,12 +139,22 @@ export function buildAutoTraceHandler(client, cfg) {
131
139
  // Falls back to sharedUserId for unknown senders (or undefined in single-user mode).
132
140
  const resolved = resolveUser(ctx, cfg);
133
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);
134
148
  try {
135
149
  const result = await client.sendTrace(history, {
136
150
  sessionId,
137
151
  title,
138
152
  extract: cfg.autoTrace.extract,
139
- metadata: cfg.autoTrace.metadata,
153
+ metadata: {
154
+ ...cfg.autoTrace.metadata,
155
+ ...(channelId ? { openclaw_channel_id: channelId } : {}),
156
+ openclaw_session_id: sessionId,
157
+ },
140
158
  userId,
141
159
  // Auto-trace captures full conversation text — the most sensitive class
142
160
  // of memory. Default to private; users opt into family-visible recall
@@ -1,10 +1,11 @@
1
+ import { channelIdFromCtx } from "../lib/exclude-channels.js";
1
2
  import { resolveCurrentSessionId } from "../lib/session.js";
2
3
  import { isMultiSpeaker } from "../lib/speaker-tracker.js";
3
4
  import { log } from "../logger.js";
4
5
  import { sanitizeTraceText } from "./auto-trace.js";
5
- import { buildMoodWeatherContext, rollMood } from "./mood-weather.js";
6
+ import { buildMoodWeatherContext, recordMoodRoll, rollMood, } from "./mood-weather.js";
6
7
  /** How many recent registers to surface as the "arc" at session start. */
7
- const EMOTIONAL_ARC_LIMIT = 3;
8
+ export const EMOTIONAL_ARC_LIMIT = 3;
8
9
  const MIN_MESSAGES = 3;
9
10
  const MIN_CONVERSATION_LENGTH = 100;
10
11
  /**
@@ -14,6 +15,12 @@ const MIN_CONVERSATION_LENGTH = 100;
14
15
  * heartbeat overwrite the register from a deep conversation (the whipsaw).
15
16
  * `ctx.trigger` is one of cron|heartbeat|manual|memory|overflow|user; we store
16
17
  * only for user-driven turns (and `overflow`, a continuation of a user run).
18
+ *
19
+ * Lifetime (verified against openclaw core, issue #70): `trigger` is PER-RUN,
20
+ * not session-fixed — core rebuilds the hook ctx from each run's own params
21
+ * (embedded-agent-runner/run.ts), and inbound human replies always start a new
22
+ * run with trigger="user" even inside a cron-originated session. So a scheduled
23
+ * check-in that becomes a real conversation IS stored on the human turns.
17
24
  */
18
25
  const NON_CONVERSATIONAL_TRIGGERS = new Set(["cron", "heartbeat", "memory"]);
19
26
  /**
@@ -24,6 +31,25 @@ const NON_CONVERSATIONAL_TRIGGERS = new Set(["cron", "heartbeat", "memory"]);
24
31
  const STORE_DEBOUNCE_MS = 3 * 60 * 1000;
25
32
  /** relationshipId → last successful store time (ms). Module-scoped, per process. */
26
33
  const lastStoreAt = new Map();
34
+ /**
35
+ * Cross-session cooldown for mood weather: once weather actually LANDS, no new
36
+ * roll for this long, no matter how many sessions start. "Rare per session"
37
+ * isn't "rare" when sessions cluster — five short same-day sessions would
38
+ * otherwise get five independent rolls and can whiplash silly → spiky → flat
39
+ * in one afternoon. Weather changes on the scale of days, not sessions.
40
+ * Misses do NOT start the cooldown — only landed weather does, so effective
41
+ * frequency for unclustered sessions still tracks moodWeatherChance.
42
+ */
43
+ export const MOOD_WEATHER_COOLDOWN_MS = 6 * 60 * 60 * 1000;
44
+ /** relationshipId → when weather last actually landed (ms). Module-scoped, per process (mirrors lastStoreAt). */
45
+ const lastMoodRollAt = new Map();
46
+ /**
47
+ * sessionKey → the mood that landed for that session. Post-compaction
48
+ * re-injection must replay the SAME weather — not roll new dice (mood must
49
+ * stay stable for a whole session) and not silently drop it (the cooldown
50
+ * would otherwise suppress the re-roll mid-session).
51
+ */
52
+ const sessionMoods = new Map();
27
53
  /**
28
54
  * Sessions where emotional context has already been injected this run.
29
55
  * Emotional state doesn't change within a session (it's extracted at
@@ -120,9 +146,14 @@ function relativeWhen(iso) {
120
146
  * backend doesn't expose `/emotional-state/recent` yet (returns null) or errors
121
147
  * — so this works before AND after that endpoint deploys.
122
148
  */
123
- async function fetchRecentOrLatest(client, cfg) {
149
+ export async function fetchRecentOrLatest(client, cfg, limit) {
150
+ // An explicit caller-supplied limit (the hyperspell_emotional_arc tool) must
151
+ // always win outright; only the *default* when no limit is passed may depend
152
+ // on config (see #68's depth-weighted default) — a model's explicit ask
153
+ // should never be silently overridden by an unrelated config knob.
154
+ const fetchLimit = limit ?? EMOTIONAL_ARC_LIMIT;
124
155
  try {
125
- const recent = await client.getRecentEmotionalStates(cfg.relationshipId, EMOTIONAL_ARC_LIMIT);
156
+ const recent = await client.getRecentEmotionalStates(cfg.relationshipId, fetchLimit);
126
157
  if (recent !== null)
127
158
  return recent; // endpoint available (may be empty)
128
159
  }
@@ -133,7 +164,7 @@ async function fetchRecentOrLatest(client, cfg) {
133
164
  return single ? [single] : [];
134
165
  }
135
166
  /** Build the injected emotional-context block from one or more registers. */
136
- function buildEmotionalContext(states) {
167
+ export function buildEmotionalContext(states) {
137
168
  const intro = states.length > 1
138
169
  ? "How your relationship with this user has felt across your recent conversations, most recent first. Let the trajectory inform your tone — don't reference it explicitly."
139
170
  : "The emotional register of your relationship with this user from your last interaction. Let it inform your tone — don't reference it explicitly.";
@@ -156,7 +187,8 @@ function buildEmotionalContext(states) {
156
187
  *
157
188
  * Runs on `before_agent_start` (which fires every turn).
158
189
  */
159
- export function buildEmotionalStateFetchHandler(client, cfg) {
190
+ export function buildEmotionalStateFetchHandler(client, cfg, deps = {}) {
191
+ const now = deps.now ?? Date.now;
160
192
  return async (_event, ctx) => {
161
193
  const sessionKey = ctx?.sessionKey;
162
194
  if (sessionKey && injectedSessions.has(sessionKey)) {
@@ -168,32 +200,57 @@ export function buildEmotionalStateFetchHandler(client, cfg) {
168
200
  // after a store the register can be the RAW input transcript, not the
169
201
  // distilled feeling. Injecting that is useless and pollutes tone.
170
202
  const usable = states.filter((s) => s.summary && !looksLikeRawTranscript(s.summary));
203
+ if (usable.length === 0 && states.length > 0) {
204
+ // State(s) exist but are all still extracting — don't cache, so a
205
+ // later turn re-fetches once extraction completes. Runs BEFORE the
206
+ // mood roll so a discarded turn can't land weather or burn the
207
+ // cross-session cooldown.
208
+ log.debug("emotional-context: state(s) still extracting — skipping injection this turn");
209
+ return;
210
+ }
171
211
  // Mood weather: an exogenous, uncaused session mood that OVERRIDES the
172
- // arc's tone for this session only. Rolled once per session (gated by
173
- // the same inject-once cache). Lives purely in the injection path — it is
212
+ // arc's tone for this session only. Lives purely in the injection path —
174
213
  // never written back via the store handler, so one random morning can't
175
214
  // calcify into the baseline. May clash with the room on purpose.
176
- const mood = cfg.moodWeatherChance > 0 ? rollMood(cfg.moodWeatherChance) : null;
177
- const moodBlock = mood ? buildMoodWeatherContext(mood) : "";
178
- if (mood) {
215
+ // Rolled once per session (inject-once cache) AND at most once per
216
+ // MOOD_WEATHER_COOLDOWN_MS across sessions (a landed roll suppresses new
217
+ // rolls; post-compaction re-injection replays the same mood instead).
218
+ const relId = cfg.relationshipId ?? "";
219
+ const priorMood = sessionKey ? sessionMoods.get(sessionKey) : undefined;
220
+ // Missing map entry means weather never landed this process — always
221
+ // eligible (don't subtract from an epoch the injectable clock may predate).
222
+ const lastLanded = lastMoodRollAt.get(relId);
223
+ const cooledDown = lastLanded === undefined || now() - lastLanded >= MOOD_WEATHER_COOLDOWN_MS;
224
+ const mood = priorMood ??
225
+ (cfg.moodWeatherChance > 0 && cooledDown
226
+ ? rollMood(cfg.moodWeatherChance, deps.rng)
227
+ : null);
228
+ if (mood && !priorMood) {
229
+ lastMoodRollAt.set(relId, now());
230
+ if (sessionKey)
231
+ sessionMoods.set(sessionKey, mood);
179
232
  log.info(`mood-weather: rolled "${mood.id}" this session`);
233
+ // Observability record (issue #71) — fire-and-forget, recall-excluded.
234
+ // Stays inside the !priorMood guard: a post-compaction replay of an
235
+ // already-rolled mood is NOT a new roll and must not double-log. Rolls
236
+ // only happen past the still-extracting early return above, so every
237
+ // recorded roll is one that actually lands in this session's context.
238
+ recordMoodRoll(client, mood, {
239
+ sessionKey,
240
+ relationshipId: cfg.relationshipId,
241
+ });
180
242
  }
243
+ const moodBlock = mood ? buildMoodWeatherContext(mood) : "";
181
244
  if (usable.length === 0) {
182
- if (states.length > 0) {
183
- // State(s) exist but are all still extracting — don't cache, so a
184
- // later turn re-fetches once extraction completes. (We re-roll the
185
- // mood then too, which is fine — still at most once per *injected*
186
- // session, since extraction settles within seconds.)
187
- log.debug("emotional-context: state(s) still extracting — skipping injection this turn");
188
- return;
189
- }
190
245
  // No arc yet — but weather can still land on a blank slate.
191
246
  log.debug("emotional-context: no prior emotional state found");
192
247
  if (sessionKey)
193
248
  injectedSessions.add(sessionKey);
194
249
  return moodBlock ? { prependContext: moodBlock } : undefined;
195
250
  }
196
- log.debug(`emotional-context: injecting ${usable.length} recent register(s)`);
251
+ // log.diag, not debug: the exact line issue #118's live audit proved
252
+ // invisible — one line per injection, operator-meaningful.
253
+ log.diag(`emotional-context: injecting ${usable.length} recent register(s)`);
197
254
  // Mood block comes AFTER the arc so it reads as today's override on top
198
255
  // of the remembered trajectory — not blended into it.
199
256
  const context = moodBlock
@@ -228,8 +285,12 @@ export function buildEmotionalStateCompactionHandler() {
228
285
  export function buildEmotionalStateSessionCleanupHandler() {
229
286
  return async (_event, ctx) => {
230
287
  const sessionKey = ctx?.sessionKey;
231
- if (sessionKey)
288
+ if (sessionKey) {
232
289
  injectedSessions.delete(sessionKey);
290
+ // Session over — its mood memo is dead weight. NOT cleared on
291
+ // compaction: surviving compaction is what makes the mood replay.
292
+ sessionMoods.delete(sessionKey);
293
+ }
233
294
  };
234
295
  }
235
296
  /**
@@ -276,9 +337,16 @@ export function buildEmotionalStateStoreHandler(client, cfg) {
276
337
  return;
277
338
  }
278
339
  try {
340
+ // Tag the register with the medium it was extracted from (voice vs Discord vs
341
+ // DM), mirroring hot-buffer's openclaw_channel_id tag. Capture-only: analysis/
342
+ // debugging metadata, deliberately NOT surfaced in the injected prose (#74).
343
+ const channelId = channelIdFromCtx(ctx);
279
344
  const result = await client.storeEmotionalState(transcript, {
280
345
  relationshipId: cfg.relationshipId,
281
- metadata: { source: "openclaw_agent_end" },
346
+ metadata: {
347
+ source: "openclaw_agent_end",
348
+ ...(channelId ? { channelId } : {}),
349
+ },
282
350
  });
283
351
  lastStoreAt.set(relId, Date.now());
284
352
  log.info(`emotional-state: stored ${result.resourceId}`);