@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.
- package/README.md +226 -10
- package/dist/client.js +14 -0
- package/dist/commands/preview.js +93 -0
- package/dist/commands/purge-channel.js +54 -0
- package/dist/commands/setup.js +64 -6
- package/dist/commands/slash.js +58 -0
- package/dist/config.js +43 -4
- package/dist/graph/ops.js +36 -3
- package/dist/hooks/auto-context.js +75 -4
- package/dist/hooks/auto-trace.js +20 -2
- package/dist/hooks/emotional-state.js +90 -22
- package/dist/hooks/hot-buffer.js +76 -6
- package/dist/hooks/memory-sync.js +23 -7
- package/dist/hooks/mood-weather.js +45 -0
- package/dist/hooks/startup-orientation.js +86 -55
- package/dist/index.js +37 -0
- package/dist/lib/eval-matchers.js +49 -0
- package/dist/lib/exclude-channels.js +17 -6
- package/dist/lib/filters.js +46 -16
- package/dist/lib/loops-audit.js +438 -0
- package/dist/lib/ranking.js +79 -15
- package/dist/logger.js +16 -0
- package/dist/sync/markdown.js +57 -2
- package/dist/tools/emotional-arc.js +60 -0
- package/dist/tools/remember.js +9 -1
- package/openclaw.plugin.json +8 -2
- package/package.json +2 -2
package/dist/commands/slash.js
CHANGED
|
@@ -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
|
-
?
|
|
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:
|
|
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,
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
|
|
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;
|
package/dist/hooks/auto-trace.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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,
|
|
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.
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
|
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: {
|
|
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}`);
|