@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 +46 -0
- package/dist/commands/preview.js +93 -0
- package/dist/commands/purge-channel.js +54 -0
- package/dist/commands/setup.js +51 -0
- package/dist/commands/slash.js +18 -0
- package/dist/config.js +10 -0
- package/dist/hooks/auto-context.js +88 -6
- package/dist/hooks/auto-trace.js +30 -2
- package/dist/hooks/emotional-state.js +101 -15
- package/dist/hooks/hot-buffer.js +122 -14
- package/dist/hooks/mood-weather.js +113 -0
- package/dist/hooks/startup-orientation.js +89 -54
- package/dist/index.js +42 -5
- package/dist/lib/eval-matchers.js +49 -0
- package/dist/lib/exclude-channels.js +70 -0
- package/dist/lib/filters.js +6 -4
- package/dist/lib/ranking.js +42 -13
- package/dist/lib/sender.js +18 -6
- package/dist/lib/speaker-tracker.js +56 -0
- package/dist/tools/emotional-arc.js +60 -0
- package/dist/tools/remember.js +27 -1
- package/dist/tools/search.js +15 -1
- package/openclaw.plugin.json +16 -1
- package/package.json +2 -2
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|
|
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}`);
|
package/dist/hooks/hot-buffer.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|