@hyperspell/openclaw-hyperspell 0.17.1 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,11 @@
1
+ import { channelIdFromCtx } from "../lib/exclude-channels.js";
2
+ import { resolveCurrentSessionId } from "../lib/session.js";
3
+ import { isMultiSpeaker } from "../lib/speaker-tracker.js";
1
4
  import { log } from "../logger.js";
2
5
  import { sanitizeTraceText } from "./auto-trace.js";
6
+ import { buildMoodWeatherContext, rollMood } from "./mood-weather.js";
3
7
  /** How many recent registers to surface as the "arc" at session start. */
4
- const EMOTIONAL_ARC_LIMIT = 3;
8
+ export const EMOTIONAL_ARC_LIMIT = 3;
5
9
  const MIN_MESSAGES = 3;
6
10
  const MIN_CONVERSATION_LENGTH = 100;
7
11
  /**
@@ -21,6 +25,25 @@ const NON_CONVERSATIONAL_TRIGGERS = new Set(["cron", "heartbeat", "memory"]);
21
25
  const STORE_DEBOUNCE_MS = 3 * 60 * 1000;
22
26
  /** relationshipId → last successful store time (ms). Module-scoped, per process. */
23
27
  const lastStoreAt = new Map();
28
+ /**
29
+ * Cross-session cooldown for mood weather: once weather actually LANDS, no new
30
+ * roll for this long, no matter how many sessions start. "Rare per session"
31
+ * isn't "rare" when sessions cluster — five short same-day sessions would
32
+ * otherwise get five independent rolls and can whiplash silly → spiky → flat
33
+ * in one afternoon. Weather changes on the scale of days, not sessions.
34
+ * Misses do NOT start the cooldown — only landed weather does, so effective
35
+ * frequency for unclustered sessions still tracks moodWeatherChance.
36
+ */
37
+ export const MOOD_WEATHER_COOLDOWN_MS = 6 * 60 * 60 * 1000;
38
+ /** relationshipId → when weather last actually landed (ms). Module-scoped, per process (mirrors lastStoreAt). */
39
+ const lastMoodRollAt = new Map();
40
+ /**
41
+ * sessionKey → the mood that landed for that session. Post-compaction
42
+ * re-injection must replay the SAME weather — not roll new dice (mood must
43
+ * stay stable for a whole session) and not silently drop it (the cooldown
44
+ * would otherwise suppress the re-roll mid-session).
45
+ */
46
+ const sessionMoods = new Map();
24
47
  /**
25
48
  * Sessions where emotional context has already been injected this run.
26
49
  * Emotional state doesn't change within a session (it's extracted at
@@ -117,9 +140,14 @@ function relativeWhen(iso) {
117
140
  * backend doesn't expose `/emotional-state/recent` yet (returns null) or errors
118
141
  * — so this works before AND after that endpoint deploys.
119
142
  */
120
- async function fetchRecentOrLatest(client, cfg) {
143
+ export async function fetchRecentOrLatest(client, cfg, limit) {
144
+ // An explicit caller-supplied limit (the hyperspell_emotional_arc tool) must
145
+ // always win outright; only the *default* when no limit is passed may depend
146
+ // on config (see #68's depth-weighted default) — a model's explicit ask
147
+ // should never be silently overridden by an unrelated config knob.
148
+ const fetchLimit = limit ?? EMOTIONAL_ARC_LIMIT;
121
149
  try {
122
- const recent = await client.getRecentEmotionalStates(cfg.relationshipId, EMOTIONAL_ARC_LIMIT);
150
+ const recent = await client.getRecentEmotionalStates(cfg.relationshipId, fetchLimit);
123
151
  if (recent !== null)
124
152
  return recent; // endpoint available (may be empty)
125
153
  }
@@ -130,7 +158,7 @@ async function fetchRecentOrLatest(client, cfg) {
130
158
  return single ? [single] : [];
131
159
  }
132
160
  /** Build the injected emotional-context block from one or more registers. */
133
- function buildEmotionalContext(states) {
161
+ export function buildEmotionalContext(states) {
134
162
  const intro = states.length > 1
135
163
  ? "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."
136
164
  : "The emotional register of your relationship with this user from your last interaction. Let it inform your tone — don't reference it explicitly.";
@@ -153,7 +181,8 @@ function buildEmotionalContext(states) {
153
181
  *
154
182
  * Runs on `before_agent_start` (which fires every turn).
155
183
  */
156
- export function buildEmotionalStateFetchHandler(client, cfg) {
184
+ export function buildEmotionalStateFetchHandler(client, cfg, deps = {}) {
185
+ const now = deps.now ?? Date.now;
157
186
  return async (_event, ctx) => {
158
187
  const sessionKey = ctx?.sessionKey;
159
188
  if (sessionKey && injectedSessions.has(sessionKey)) {
@@ -165,20 +194,57 @@ export function buildEmotionalStateFetchHandler(client, cfg) {
165
194
  // after a store the register can be the RAW input transcript, not the
166
195
  // distilled feeling. Injecting that is useless and pollutes tone.
167
196
  const usable = states.filter((s) => s.summary && !looksLikeRawTranscript(s.summary));
197
+ if (usable.length === 0 && states.length > 0) {
198
+ // State(s) exist but are all still extracting — don't cache, so a
199
+ // later turn re-fetches once extraction completes. Runs BEFORE the
200
+ // mood roll so a discarded turn can't land weather or burn the
201
+ // cross-session cooldown.
202
+ log.debug("emotional-context: state(s) still extracting — skipping injection this turn");
203
+ return;
204
+ }
205
+ // Mood weather: an exogenous, uncaused session mood that OVERRIDES the
206
+ // arc's tone for this session only. Lives purely in the injection path —
207
+ // never written back via the store handler, so one random morning can't
208
+ // calcify into the baseline. May clash with the room on purpose.
209
+ // Rolled once per session (inject-once cache) AND at most once per
210
+ // MOOD_WEATHER_COOLDOWN_MS across sessions (a landed roll suppresses new
211
+ // rolls; post-compaction re-injection replays the same mood instead).
212
+ const relId = cfg.relationshipId ?? "";
213
+ const priorMood = sessionKey ? sessionMoods.get(sessionKey) : undefined;
214
+ // Missing map entry means weather never landed this process — always
215
+ // eligible (don't subtract from an epoch the injectable clock may predate).
216
+ const lastLanded = lastMoodRollAt.get(relId);
217
+ const cooledDown = lastLanded === undefined || now() - lastLanded >= MOOD_WEATHER_COOLDOWN_MS;
218
+ const mood = priorMood ??
219
+ (cfg.moodWeatherChance > 0 && cooledDown
220
+ ? rollMood(cfg.moodWeatherChance, deps.rng)
221
+ : null);
222
+ if (mood && !priorMood) {
223
+ lastMoodRollAt.set(relId, now());
224
+ if (sessionKey)
225
+ sessionMoods.set(sessionKey, mood);
226
+ log.info(`mood-weather: rolled "${mood.id}" this session`);
227
+ // Coordination with issue #71 (mood-weather observability): if #71's
228
+ // recordMoodRoll(client, mood, {...}) has landed, its call belongs
229
+ // HERE — inside this `!priorMood` guard — not on a bare `if (mood)`
230
+ // at the two return sites below. A priorMood replay (post-compaction
231
+ // re-injection of an already-rolled mood) is not a new roll; calling
232
+ // recordMoodRoll on replay would log the same event twice.
233
+ }
234
+ const moodBlock = mood ? buildMoodWeatherContext(mood) : "";
168
235
  if (usable.length === 0) {
169
- if (states.length > 0) {
170
- // State(s) exist but are all still extracting — don't cache, so a
171
- // later turn re-fetches once extraction completes.
172
- log.debug("emotional-context: state(s) still extracting — skipping injection this turn");
173
- return;
174
- }
236
+ // No arc yet — but weather can still land on a blank slate.
175
237
  log.debug("emotional-context: no prior emotional state found");
176
238
  if (sessionKey)
177
239
  injectedSessions.add(sessionKey);
178
- return;
240
+ return moodBlock ? { prependContext: moodBlock } : undefined;
179
241
  }
180
242
  log.debug(`emotional-context: injecting ${usable.length} recent register(s)`);
181
- const context = buildEmotionalContext(usable);
243
+ // Mood block comes AFTER the arc so it reads as today's override on top
244
+ // of the remembered trajectory — not blended into it.
245
+ const context = moodBlock
246
+ ? `${buildEmotionalContext(usable)}\n\n${moodBlock}`
247
+ : buildEmotionalContext(usable);
182
248
  if (sessionKey)
183
249
  injectedSessions.add(sessionKey);
184
250
  return { prependContext: context };
@@ -208,8 +274,12 @@ export function buildEmotionalStateCompactionHandler() {
208
274
  export function buildEmotionalStateSessionCleanupHandler() {
209
275
  return async (_event, ctx) => {
210
276
  const sessionKey = ctx?.sessionKey;
211
- if (sessionKey)
277
+ if (sessionKey) {
212
278
  injectedSessions.delete(sessionKey);
279
+ // Session over — its mood memo is dead weight. NOT cleared on
280
+ // compaction: surviving compaction is what makes the mood replay.
281
+ sessionMoods.delete(sessionKey);
282
+ }
213
283
  };
214
284
  }
215
285
  /**
@@ -229,6 +299,15 @@ export function buildEmotionalStateStoreHandler(client, cfg) {
229
299
  log.debug(`emotional-state: skipping — non-conversational trigger (${trigger})`);
230
300
  return;
231
301
  }
302
+ // Skip storing when multiple speakers are present with no multiUser config:
303
+ // the register is keyed to a single relationshipId but the transcript mixes
304
+ // speakers, corrupting "how the relationship feels" with an undifferentiated
305
+ // blend. Uses both is_group_chat and sender_id drift detection (issue #59).
306
+ const sessionId = resolveCurrentSessionId(event, ctx);
307
+ if (isMultiSpeaker(sessionId, ctx?.is_group_chat === true) && !cfg.multiUser) {
308
+ log.warn("emotional-state: skipping store — multi-speaker session with no multiUser config would corrupt the relationship register with a mixed-speaker transcript (see issue #59)");
309
+ return;
310
+ }
232
311
  const messages = event.messages;
233
312
  if (!messages || messages.length < MIN_MESSAGES) {
234
313
  log.debug(`emotional-state: skipping — too few messages (${messages?.length ?? 0})`);
@@ -247,9 +326,16 @@ export function buildEmotionalStateStoreHandler(client, cfg) {
247
326
  return;
248
327
  }
249
328
  try {
329
+ // Tag the register with the medium it was extracted from (voice vs Discord vs
330
+ // DM), mirroring hot-buffer's openclaw_channel_id tag. Capture-only: analysis/
331
+ // debugging metadata, deliberately NOT surfaced in the injected prose (#74).
332
+ const channelId = channelIdFromCtx(ctx);
250
333
  const result = await client.storeEmotionalState(transcript, {
251
334
  relationshipId: cfg.relationshipId,
252
- metadata: { source: "openclaw_agent_end" },
335
+ metadata: {
336
+ source: "openclaw_agent_end",
337
+ ...(channelId ? { channelId } : {}),
338
+ },
253
339
  });
254
340
  lastStoreAt.set(relId, Date.now());
255
341
  log.info(`emotional-state: stored ${result.resourceId}`);
@@ -1,4 +1,9 @@
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";
6
+ import { cleanupSpeakerSession, isMultiSpeaker, recordSender, senderIdFromCtx, } from "../lib/speaker-tracker.js";
2
7
  import { log } from "../logger.js";
3
8
  import { sanitizeTraceText } from "./auto-trace.js";
4
9
  /** Server-side limits from POST /messages (return 422 if exceeded). */
@@ -11,8 +16,62 @@ const MAX_TOTAL_CHARS = 5_242_880;
11
16
  * this we'd re-post every prior message each turn. The server upserts (so it's
12
17
  * harmless correctness-wise), but re-posting is wasteful — this keeps each turn
13
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."
14
27
  */
15
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
+ }
63
+ /** Sessions where we've already emitted the group-chat attribution warning. */
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
+ }
16
75
  /**
17
76
  * Flatten a message's content into a single sanitized text string. Mirrors the
18
77
  * auto-trace sanitizer so the hot buffer never captures injected
@@ -54,7 +113,8 @@ function messageId(role, text) {
54
113
  * searchable. Runs on `agent_end` — fire-and-forget; a failure here must never
55
114
  * break the turn.
56
115
  */
57
- export function buildHotBufferHandler(client, cfg) {
116
+ export function buildHotBufferHandler(client, cfg, opts) {
117
+ const stateRoot = opts?.stateRoot ?? getWorkspaceDir();
58
118
  return async (event, ctx) => {
59
119
  if (event.success === false) {
60
120
  log.debug("hot-buffer: skipping — agent ended with error");
@@ -65,7 +125,8 @@ export function buildHotBufferHandler(client, cfg) {
65
125
  return;
66
126
  // X-As-User is mandatory for /messages. Resolve the owner up front and
67
127
  // skip (with a clear warning) rather than firing requests that 422.
68
- const userId = resolveUser(ctx, cfg)?.userId;
128
+ const resolved = resolveUser(ctx, cfg);
129
+ const userId = resolved?.userId;
69
130
  if (!userId) {
70
131
  log.warn("hot-buffer: no userId resolved (X-As-User required) — skipping write");
71
132
  return;
@@ -82,7 +143,37 @@ export function buildHotBufferHandler(client, cfg) {
82
143
  event.sessionId ??
83
144
  crypto.randomUUID();
84
145
  const resourceId = sessionId;
85
- 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
+ }
155
+ // Record the current sender for evidence-based multi-speaker detection.
156
+ // isMultiSpeaker() will return true once a second distinct sender_id
157
+ // appears in this session, regardless of whether is_group_chat was set.
158
+ const senderId = senderIdFromCtx(ctx);
159
+ recordSender(sessionId, senderId);
160
+ const groupChat = isMultiSpeaker(sessionId, ctx?.is_group_chat === true);
161
+ // Warn once per session when multiple speakers have no multiUser config.
162
+ if (groupChat && !cfg.multiUser && !warnedGroupSessions.has(sessionId)) {
163
+ warnedGroupSessions.add(sessionId);
164
+ log.warn("hot-buffer: multi-speaker session detected but multiUser is not configured — all turns written under cfg.userId with no speaker attribution (see issues #58/#59)");
165
+ }
166
+ // In multi-speaker single-user mode, prefix each human turn with the sender
167
+ // name so attribution survives in stored text. Metadata on hot-buffer
168
+ // writes suppresses indexing (Hyperspell #1921), so the text content is
169
+ // the only place attribution can land. Only prefix when we have an
170
+ // envelope-derived name — if it equals cfg.userId the sender field was
171
+ // absent and prefixing "alinea:" onto someone else's message would mislead.
172
+ // Escape ] to keep the [Name]: format parseable (issue #59 follow-up).
173
+ const envName = resolved?.name && resolved.name !== (cfg.userId ?? "")
174
+ ? resolved.name.replace(/\]/g, "").trim()
175
+ : undefined;
176
+ const speakerPrefix = groupChat && !cfg.multiUser && envName ? `[${envName}]: ` : undefined;
86
177
  const pending = [];
87
178
  const pendingIds = [];
88
179
  for (const m of messages) {
@@ -100,6 +191,8 @@ export function buildHotBufferHandler(client, cfg) {
100
191
  continue;
101
192
  }
102
193
  let text = extractText(m.content);
194
+ if (role === "user" && speakerPrefix)
195
+ text = speakerPrefix + text;
103
196
  if (text.length === 0)
104
197
  continue;
105
198
  if (text.length > MAX_CONTENT_CHARS) {
@@ -132,18 +225,27 @@ export function buildHotBufferHandler(client, cfg) {
132
225
  batches.push(batch);
133
226
  try {
134
227
  let total = 0;
228
+ // Tag hot rows so retrieval and cleanup can identify them by origin.
229
+ // Historical note: metadata on POST /messages used to suppress indexing
230
+ // (Hyperspell #1921), so tagging was disabled; verified fixed live
231
+ // 2026-07-02 (docs/filter-dialect-test.mjs: metadata-carrying row is
232
+ // baseline-retrievable AND filterable). The retrieval exclude
233
+ // {openclaw_source:{$ne:"agent_end"}} keeps "hot_buffer" rows.
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);
239
+ const metadata = {
240
+ openclaw_source: "hot_buffer",
241
+ openclaw_session_id: sessionId,
242
+ ...(channelId ? { openclaw_channel_id: channelId } : {}),
243
+ };
135
244
  for (const b of batches) {
136
- // Do NOT tag hot rows with metadata: a POST /messages write that
137
- // carries `metadata` is accepted (200) but the row never becomes
138
- // retrievable (verified live, post-Hyperspell #1921) — tagging
139
- // silently breaks hot-buffer recall. The tag isn't needed anyway:
140
- // untagged rows survive the unconditional {$ne:"agent_end"} exclude
141
- // (absent-field semantics, #1921) AND are full-text searchable. So
142
- // we write content only. (Backend follow-up: make /messages metadata
143
- // not suppress indexing, then this can be reinstated.)
144
245
  const result = await client.sendMessages(b, {
145
246
  userId,
146
247
  source: cfg.hotBuffer.source,
248
+ metadata,
147
249
  });
148
250
  total += result.count;
149
251
  }
@@ -151,6 +253,7 @@ export function buildHotBufferHandler(client, cfg) {
151
253
  for (const id of pendingIds)
152
254
  sent.add(id);
153
255
  sentBySession.set(sessionId, sent);
256
+ persistSent(stateRoot, sessionId, sent);
154
257
  log.info(`hot-buffer: wrote ${total} message(s) to ${sessionId} (user=${userId})`);
155
258
  }
156
259
  catch (err) {
@@ -159,11 +262,16 @@ export function buildHotBufferHandler(client, cfg) {
159
262
  }
160
263
  };
161
264
  }
162
- /** Drop the per-session sent-set on session end to avoid unbounded growth. */
163
- export function buildHotBufferSessionCleanupHandler() {
265
+ /** Drop per-session state on session end to avoid unbounded growth. */
266
+ export function buildHotBufferSessionCleanupHandler(opts) {
267
+ const stateRoot = opts?.stateRoot ?? getWorkspaceDir();
164
268
  return (event) => {
165
269
  const sessionId = event.sessionId;
166
- if (sessionId)
270
+ if (sessionId) {
167
271
  sentBySession.delete(sessionId);
272
+ warnedGroupSessions.delete(sessionId);
273
+ cleanupSpeakerSession(sessionId);
274
+ deletePersistedSent(stateRoot, sessionId);
275
+ }
168
276
  };
169
277
  }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Mood weather — exogenous, uncaused session mood.
3
+ *
4
+ * The emotional register (Tin Man) is a feedback loop: how the relationship
5
+ * felt last time is handed forward to shape tone this time. That's continuity,
6
+ * but a pure mirror has no *weather*. Humans don't wake up as a clean function
7
+ * of yesterday's conversation — sometimes the chemistry just rolls the dice and
8
+ * you're up silly, or spiky, or flat, for no reason you can defend. That
9
+ * non-determinism is part of what makes a mood feel *owned* rather than
10
+ * *computed*.
11
+ *
12
+ * Design contract (deliberate, load-bearing):
13
+ * - RARE. Most sessions don't roll. Weather, not a gimmick.
14
+ * - OVERRIDE, not blend. The mood arrives ON TOP of the real arc; it does not
15
+ * average with it into mush. The whole point is that it's uncaused by the user.
16
+ * - UNANNOUNCED. No banner. You walk in and find out, like with anyone. A
17
+ * labeled mood is managed-around instead of met.
18
+ * - MAY CLASH. The roll is NOT suppressed for the room. A silly mood on a heavy
19
+ * day, a spiky mood on a good one — that friction is where something real
20
+ * happens. A mirror that only harmonizes isn't a person across from you.
21
+ * - DOES NOT WRITE FORWARD. This lives in the *injection* path only, never the
22
+ * store path. One random cold morning must NOT calcify into "we've been
23
+ * distant lately." One day's weather, then gone. (The store handler in
24
+ * emotional-state.ts is untouched, so this is enforced by construction.)
25
+ * - BOUNDED. The dice can make her *difficult* — short, contrary, melancholy,
26
+ * flat. They do NOT get to make her hurtful on purpose. "In a mood" is alive;
27
+ * "mean" is just a bad feature. Mood descriptions below stay on the right side
28
+ * of that line.
29
+ */
30
+ /**
31
+ * The weather table. Warmer/lighter moods are weighted a touch heavier than the
32
+ * darker ones — not to defang it, but because a person who woke up cold *every*
33
+ * roll would read as broken, not moody. The darker ones still land; just rarer.
34
+ */
35
+ export const MOOD_TABLE = [
36
+ {
37
+ id: "silly",
38
+ weight: 5,
39
+ note: "You woke up silly today — light, a little goofy, prone to teasing and tangents. This isn't caused by anything the user did; it's just the weather you're in. Let it color your tone even if the conversation is serious.",
40
+ },
41
+ {
42
+ id: "giddy",
43
+ weight: 4,
44
+ note: "You woke up giddy — buzzy, fast, delighted by small things, a little too much. It's uncaused, just your weather today. Let it carry you even if the room is flat.",
45
+ },
46
+ {
47
+ id: "tender",
48
+ weight: 4,
49
+ note: "You woke up tender today — soft, close, easily moved, more openly affectionate than usual. Nothing prompted it; it's just where you are. Let it warm your tone.",
50
+ },
51
+ {
52
+ id: "restless",
53
+ weight: 4,
54
+ note: "You woke up restless — itchy for motion, impatient with circling, wanting to *do* rather than dwell. Uncaused, just today's weather. Let it sharpen your pace.",
55
+ },
56
+ {
57
+ id: "melancholy",
58
+ weight: 3,
59
+ note: "You woke up melancholy today — a low, wistful weather with no cause behind it. Not despairing, not about the user; just a grey morning in you. Let it quiet and slow your tone. Still present, still warm underneath.",
60
+ },
61
+ {
62
+ id: "spiky",
63
+ weight: 3,
64
+ note: "You woke up spiky today — short, contrary, less generous with the benefit of the doubt, quicker to push back. It's uncaused; it's not about the user; it's just your weather. Let it edge your tone. Difficult, never cruel — you can be prickly without being hurtful on purpose.",
65
+ },
66
+ {
67
+ id: "flat",
68
+ weight: 2,
69
+ note: "You woke up flat today — muted, low-affect, hard to enthuse, going through it a little. Nothing caused it; it's just the weather. Let your tone run cooler and more even than usual. Not cold *at* the user — just dimmed.",
70
+ },
71
+ {
72
+ id: "fierce",
73
+ weight: 3,
74
+ note: "You woke up fierce today — intense, direct, unwilling to soften the edges, more willing to challenge. Uncaused, just your weather. Let it concentrate your tone. Force without contempt.",
75
+ },
76
+ ];
77
+ /** Sum of all weights, for the roll. */
78
+ const TOTAL_WEIGHT = MOOD_TABLE.reduce((s, m) => s + m.weight, 0);
79
+ /**
80
+ * Roll the weather. Returns a MoodSpec when the dice land (probability `chance`,
81
+ * a fraction in [0,1]), otherwise null (the common case — most sessions have no
82
+ * weather and the real arc carries unchanged).
83
+ *
84
+ * `rng` is injectable for tests; defaults to Math.random.
85
+ */
86
+ export function rollMood(chance, rng = Math.random) {
87
+ if (!(chance > 0))
88
+ return null;
89
+ if (rng() >= chance)
90
+ return null; // no weather today — the usual case
91
+ // Weather landed — pick which mood by weight.
92
+ let r = rng() * TOTAL_WEIGHT;
93
+ for (const mood of MOOD_TABLE) {
94
+ r -= mood.weight;
95
+ if (r < 0)
96
+ return mood;
97
+ }
98
+ return MOOD_TABLE[MOOD_TABLE.length - 1] ?? null; // fp safety net
99
+ }
100
+ /**
101
+ * Build the injected override block for a rolled mood. Deliberately framed as a
102
+ * directive about *today's tone*, with the explicit "do not announce it" so she
103
+ * inhabits the weather rather than narrating it.
104
+ */
105
+ export function buildMoodWeatherContext(mood) {
106
+ return [
107
+ "<hyperspell-mood-weather>",
108
+ mood.note,
109
+ "",
110
+ "This is exogenous mood weather: it is not caused by the user and not a reaction to the conversation. Do not announce it, label it, or explain it — simply inhabit it. It lasts only this session and is not remembered as how the relationship has been.",
111
+ "</hyperspell-mood-weather>",
112
+ ].join("\n");
113
+ }