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