@hyperspell/openclaw-hyperspell 0.19.0 → 0.21.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 +260 -10
- package/dist/client.js +14 -0
- package/dist/commands/setup.js +16 -6
- package/dist/commands/slash.js +40 -0
- package/dist/config.js +89 -6
- package/dist/graph/ops.js +36 -3
- package/dist/hooks/auto-context.js +126 -15
- package/dist/hooks/emotional-state.js +19 -8
- package/dist/hooks/memory-sync.js +23 -7
- package/dist/hooks/mood-weather.js +45 -0
- package/dist/hooks/startup-orientation.js +10 -1
- package/dist/index.js +24 -1
- package/dist/lib/coverage-log.js +47 -0
- package/dist/lib/filters.js +39 -11
- package/dist/lib/loops-audit.js +438 -0
- package/dist/lib/mood-skew-audit.js +580 -0
- package/dist/lib/ranking.js +188 -11
- package/dist/logger.js +16 -0
- package/dist/sync/markdown.js +57 -2
- package/openclaw.plugin.json +35 -2
- package/package.json +2 -2
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,7 +1,8 @@
|
|
|
1
1
|
import { appendFileSync } from "node:fs";
|
|
2
2
|
import { buildScopeFilter, getCanReadScopes, resolveUser, } from "../lib/sender.js";
|
|
3
3
|
import { excludeFilterFor, mergeWithExclude } from "../lib/filters.js";
|
|
4
|
-
import { explainSelection, rerank, } from "../lib/ranking.js";
|
|
4
|
+
import { explainSelection, kindTally, rerank, } from "../lib/ranking.js";
|
|
5
|
+
import { recordCoverageEvent, } from "../lib/coverage-log.js";
|
|
5
6
|
import { classifySearchError, logSearchError } from "../lib/search-error.js";
|
|
6
7
|
import { resolveCurrentSessionId } from "../lib/session.js";
|
|
7
8
|
import { recordSender, senderIdFromCtx } from "../lib/speaker-tracker.js";
|
|
@@ -54,22 +55,38 @@ function formatHighlightBullets(results, maxResults, threshold) {
|
|
|
54
55
|
return null;
|
|
55
56
|
return sections.join("\n\n");
|
|
56
57
|
}
|
|
58
|
+
// A second highlight rides along only when it is within this much of the top
|
|
59
|
+
// one's score — a distant second is dilution inside a correct pick (proposal
|
|
60
|
+
// 12). Absolute difference (not a ratio) to match every other score rule in
|
|
61
|
+
// this pipeline; 0.15 is "one storyBoost's worth" on the composite scale.
|
|
62
|
+
// Deliberately a constant, not config: a formatting heuristic two levels
|
|
63
|
+
// below anything a user reasons about — promote to ranking.highlightGap only
|
|
64
|
+
// if live tuning proves deployments actually differ.
|
|
65
|
+
const HIGHLIGHT_GAP = 0.15;
|
|
57
66
|
/**
|
|
58
67
|
* Format already-SELECTED composite-ranked results (threshold + chatter quota
|
|
59
68
|
* applied upstream by selectRanked). Highlights are floored at the lower of
|
|
60
69
|
* (threshold, the result's own base relevance), so we don't hide the very lines
|
|
61
|
-
* that define a boosted-but-quiet memory.
|
|
70
|
+
* that define a boosted-but-quiet memory. Exported for direct testing.
|
|
62
71
|
*/
|
|
63
|
-
function formatSelected(selected, threshold) {
|
|
72
|
+
export function formatSelected(selected, threshold) {
|
|
64
73
|
const sections = [];
|
|
65
74
|
for (const r of selected) {
|
|
66
75
|
const hiFloor = Math.min(threshold, r._base);
|
|
67
|
-
const
|
|
76
|
+
const passing = [...r.highlights]
|
|
68
77
|
.sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
|
|
69
|
-
.filter((h) => (h.score ?? 0) >= hiFloor)
|
|
70
|
-
|
|
71
|
-
if (chosen.length === 0)
|
|
78
|
+
.filter((h) => (h.score ?? 0) >= hiFloor);
|
|
79
|
+
if (passing.length === 0)
|
|
72
80
|
continue;
|
|
81
|
+
// Floor first (is this highlight relevant at all?), gap second (is the
|
|
82
|
+
// runner-up close enough to the winner to be worth its tokens?). The TOP
|
|
83
|
+
// highlight is always kept — the lowered hiFloor exists to protect
|
|
84
|
+
// boosted-but-quiet memories, and the gap rule must never re-hide them; a
|
|
85
|
+
// selected result can never format to fewer sections than today.
|
|
86
|
+
const [top, second] = passing;
|
|
87
|
+
const chosen = second && (top.score ?? 0) - (second.score ?? 0) <= HIGHLIGHT_GAP
|
|
88
|
+
? [top, second]
|
|
89
|
+
: [top];
|
|
73
90
|
const title = r.title ?? `[${r.source}]`;
|
|
74
91
|
const bullets = chosen
|
|
75
92
|
.map((h) => `- ${h.text.replace(/\n/g, " ")} [${Math.round((h.score ?? 0) * 100)}%]`)
|
|
@@ -168,7 +185,12 @@ export function dropCurrentSession(results, currentSessionId) {
|
|
|
168
185
|
}
|
|
169
186
|
return kept;
|
|
170
187
|
}
|
|
171
|
-
|
|
188
|
+
/** Highest raw relevance among candidates — `topScore 0.54` vs `threshold 0.6`
|
|
189
|
+
* is a ranking near-miss; `0.12` means the vault has nothing close (capture). */
|
|
190
|
+
function topScoreOf(results) {
|
|
191
|
+
return results.length ? Math.max(...results.map((r) => r.score ?? 0)) : null;
|
|
192
|
+
}
|
|
193
|
+
export function buildAutoContextHandler(client, cfg, opts) {
|
|
172
194
|
return async (event, ctx) => {
|
|
173
195
|
const prompt = event.prompt;
|
|
174
196
|
if (!prompt || prompt.length < 5)
|
|
@@ -184,7 +206,7 @@ export function buildAutoContextHandler(client, cfg) {
|
|
|
184
206
|
// Multi-user path
|
|
185
207
|
if (cfg.multiUser) {
|
|
186
208
|
const resolved = resolveUser(ctx, cfg);
|
|
187
|
-
return multiUserSearch(client, cfg, prompt, resolved, currentSessionId);
|
|
209
|
+
return multiUserSearch(client, cfg, prompt, resolved, currentSessionId, opts?.stateRoot);
|
|
188
210
|
}
|
|
189
211
|
// Single-user path — preserves main's highlights + threshold behavior
|
|
190
212
|
log.debug(`auto-context: searching for "${prompt.slice(0, 50)}..."`);
|
|
@@ -195,20 +217,39 @@ export function buildAutoContextHandler(client, cfg) {
|
|
|
195
217
|
const limit = ranking.enabled
|
|
196
218
|
? cfg.maxResults * ranking.candidateMultiplier
|
|
197
219
|
: cfg.maxResults;
|
|
198
|
-
|
|
220
|
+
// Kept as two steps so the coverage event below can report the pre-drop
|
|
221
|
+
// fetch count (a session-echo-dominated pool must be visible as such).
|
|
222
|
+
const rawResults = await client.search(prompt, {
|
|
223
|
+
limit,
|
|
224
|
+
filter: excludeFilterFor(cfg),
|
|
225
|
+
});
|
|
226
|
+
const results = dropCurrentSession(rawResults, currentSessionId);
|
|
199
227
|
let formatted;
|
|
200
228
|
if (ranking.enabled) {
|
|
201
229
|
const ranked = rerank(results, ranking);
|
|
202
230
|
// Threshold + chatter quota applied here, so a high-similarity echo can
|
|
203
231
|
// inform but never flood (the quota bounds count; the penalty bounds rank).
|
|
204
|
-
const explained = explainSelection(ranked, cfg.maxResults, cfg.relevanceThreshold, ranking.chatterQuota);
|
|
232
|
+
const explained = explainSelection(ranked, cfg.maxResults, cfg.relevanceThreshold, ranking.chatterQuota, ranking.dedupThreshold, ranking.elbow);
|
|
205
233
|
logScoreSamples(prompt, currentSessionId, "single", explained, cfg.relevanceThreshold);
|
|
206
234
|
const selected = explained.filter((e) => e.selected).map((e) => e.result);
|
|
235
|
+
// Candidates → selected kind tally, logged UNCONDITIONALLY (unlike the
|
|
236
|
+
// "injecting" line below): a story/curated match that loses to the
|
|
237
|
+
// threshold is exactly the case an operator tuning storyTerms needs to
|
|
238
|
+
// see, and it never reaches the formatted branch (proposal 01 §3.4).
|
|
239
|
+
log.diag(`auto-context: ranked ${JSON.stringify(kindTally(ranked))} candidates → selected ${JSON.stringify(kindTally(selected))} (chatter cap ${ranking.chatterQuota})`);
|
|
240
|
+
for (const r of ranked.slice(0, 10)) {
|
|
241
|
+
log.debug(` [${r._kind}] ${r._base.toFixed(2)}→${r._composite.toFixed(2)} ${(r.title ?? r.resourceId).slice(0, 60)}`);
|
|
242
|
+
}
|
|
207
243
|
// Cut-reason visibility (proposal 02, absorbs proposal 03): logged
|
|
208
244
|
// BEFORE the `formatted` check so quota drops stay visible even when
|
|
209
245
|
// nothing is injected (e.g. chatterQuota 0 with only chatter clearing
|
|
210
246
|
// the threshold). Stable "auto-context: cut" prefix for log greps.
|
|
211
247
|
// flatMap (not filter) so TS narrows to the cut member of the union.
|
|
248
|
+
// Elbow verdict — the live-validation instrument for proposal 13's
|
|
249
|
+
// rollout: firing rate + cut depth come straight from this line.
|
|
250
|
+
if (ranking.elbow.enabled && explained.some((e) => e.cut === "elbow")) {
|
|
251
|
+
log.diag(`auto-context: elbow stopped at ${selected.length} (ceiling ${cfg.maxResults})`);
|
|
252
|
+
}
|
|
212
253
|
const cuts = explained.flatMap((e) => (e.selected ? [] : [e]));
|
|
213
254
|
if (cuts.length > 0) {
|
|
214
255
|
const cutTally = cuts.reduce((acc, e) => ((acc[e.cut] = (acc[e.cut] ?? 0) + 1), acc), {});
|
|
@@ -216,12 +257,11 @@ export function buildAutoContextHandler(client, cfg) {
|
|
|
216
257
|
const quotaNote = topQuotaDrop
|
|
217
258
|
? `, top quota-dropped composite ${topQuotaDrop.result._composite.toFixed(2)}`
|
|
218
259
|
: "";
|
|
219
|
-
log.
|
|
260
|
+
log.diag(`auto-context: cut ${cuts.length} of ${ranked.length} candidates ${JSON.stringify(cutTally)}${quotaNote}`);
|
|
220
261
|
}
|
|
221
262
|
formatted = formatSelected(selected, cfg.relevanceThreshold);
|
|
222
263
|
if (formatted) {
|
|
223
|
-
|
|
224
|
-
log.debug(`auto-context: injecting (ranked) ${JSON.stringify(tally)} from ${results.length} candidates (chatter cap ${ranking.chatterQuota}, composite ${selected.at(-1)?._composite.toFixed(2)}–${selected[0]?._composite.toFixed(2)})`);
|
|
264
|
+
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)})`);
|
|
225
265
|
}
|
|
226
266
|
}
|
|
227
267
|
else {
|
|
@@ -234,6 +274,23 @@ export function buildAutoContextHandler(client, cfg) {
|
|
|
234
274
|
// every-turn banner (which reads as framing and trains search-as-ritual).
|
|
235
275
|
if (!formatted) {
|
|
236
276
|
log.debug("auto-context: no relevant memories found");
|
|
277
|
+
// Coverage signal (proposal 15): search SUCCEEDED but injected nothing —
|
|
278
|
+
// durably distinguish "never captured" (empty) from "captured but ranked
|
|
279
|
+
// out" (below_threshold). Thrown searches never reach here (the catch
|
|
280
|
+
// below owns them), so availability blips can't pollute the log.
|
|
281
|
+
if (cfg.coverageLog) {
|
|
282
|
+
recordCoverageEvent({
|
|
283
|
+
outcome: results.length > 0 ? "below_threshold" : "empty",
|
|
284
|
+
prompt,
|
|
285
|
+
fetched: rawResults.length,
|
|
286
|
+
candidates: results.length,
|
|
287
|
+
droppedCurrentSession: rawResults.length - results.length,
|
|
288
|
+
topScore: topScoreOf(results),
|
|
289
|
+
threshold: cfg.relevanceThreshold,
|
|
290
|
+
ranking: ranking.enabled,
|
|
291
|
+
sessionId: currentSessionId,
|
|
292
|
+
}, opts?.stateRoot);
|
|
293
|
+
}
|
|
237
294
|
return;
|
|
238
295
|
}
|
|
239
296
|
return { prependContext: wrapContext(formatted) };
|
|
@@ -252,7 +309,7 @@ export function buildAutoContextHandler(client, cfg) {
|
|
|
252
309
|
// single-user path only: this path never runs rerank/explainSelection, so
|
|
253
310
|
// there is no composite score to attribute. If ranking ever lands here, call
|
|
254
311
|
// logScoreSamples with scope "personal" / "shared" per search.
|
|
255
|
-
async function multiUserSearch(client, cfg, prompt, resolved, currentSessionId) {
|
|
312
|
+
async function multiUserSearch(client, cfg, prompt, resolved, currentSessionId, stateRoot) {
|
|
256
313
|
const multiUser = cfg.multiUser;
|
|
257
314
|
const isKnownSender = !!resolved?.resolved;
|
|
258
315
|
const includeShared = multiUser.includeSharedInSearch;
|
|
@@ -290,9 +347,18 @@ async function multiUserSearch(client, cfg, prompt, resolved, currentSessionId)
|
|
|
290
347
|
let idx = 0;
|
|
291
348
|
let personalResults = [];
|
|
292
349
|
let sharedResults = [];
|
|
350
|
+
// Per-lane fulfillment + pre-drop counts feed the coverage event below: a
|
|
351
|
+
// lane that REJECTED must never be recorded as "zero candidates" (#39's
|
|
352
|
+
// unavailable-is-not-empty distinction, kept intact in the log schema).
|
|
353
|
+
let personalOk = false;
|
|
354
|
+
let sharedOk = false;
|
|
355
|
+
let rawPersonalCount = 0;
|
|
356
|
+
let rawSharedCount = 0;
|
|
293
357
|
if (personalSearch) {
|
|
294
358
|
const r = settled[idx++];
|
|
295
359
|
if (r.status === "fulfilled") {
|
|
360
|
+
personalOk = true;
|
|
361
|
+
rawPersonalCount = r.value.length;
|
|
296
362
|
personalResults = dropCurrentSession(r.value, currentSessionId);
|
|
297
363
|
}
|
|
298
364
|
else {
|
|
@@ -302,6 +368,8 @@ async function multiUserSearch(client, cfg, prompt, resolved, currentSessionId)
|
|
|
302
368
|
if (sharedSearch) {
|
|
303
369
|
const r = settled[idx++];
|
|
304
370
|
if (r.status === "fulfilled") {
|
|
371
|
+
sharedOk = true;
|
|
372
|
+
rawSharedCount = r.value.length;
|
|
305
373
|
sharedResults = dropCurrentSession(r.value, currentSessionId);
|
|
306
374
|
}
|
|
307
375
|
else {
|
|
@@ -335,6 +403,49 @@ async function multiUserSearch(client, cfg, prompt, resolved, currentSessionId)
|
|
|
335
403
|
const haveMemorySections = sections.some((s) => s.startsWith("<personal-context>") || s.startsWith("<shared-context>"));
|
|
336
404
|
if (!haveMemorySections) {
|
|
337
405
|
log.debug("auto-context: no relevant memories found");
|
|
406
|
+
// Coverage signal (proposal 15): one event per turn (not per lane), with
|
|
407
|
+
// per-lane detail so "personal lane was down" is never misread as
|
|
408
|
+
// "personal memory is empty". Written only when at least one lane
|
|
409
|
+
// fulfilled — every-lane-failed is an availability event (#39), already
|
|
410
|
+
// logged by logSearchError above, and "unknown" is not "zero".
|
|
411
|
+
if (cfg.coverageLog) {
|
|
412
|
+
const lanes = [];
|
|
413
|
+
if (personalSearch)
|
|
414
|
+
lanes.push(personalOk
|
|
415
|
+
? {
|
|
416
|
+
lane: "personal",
|
|
417
|
+
status: "ok",
|
|
418
|
+
candidates: personalResults.length,
|
|
419
|
+
topScore: topScoreOf(personalResults),
|
|
420
|
+
}
|
|
421
|
+
: { lane: "personal", status: "error" });
|
|
422
|
+
if (sharedSearch)
|
|
423
|
+
lanes.push(sharedOk
|
|
424
|
+
? {
|
|
425
|
+
lane: "shared",
|
|
426
|
+
status: "ok",
|
|
427
|
+
candidates: sharedResults.length,
|
|
428
|
+
topScore: topScoreOf(sharedResults),
|
|
429
|
+
}
|
|
430
|
+
: { lane: "shared", status: "error" });
|
|
431
|
+
if (lanes.some((l) => l.status === "ok")) {
|
|
432
|
+
const candidates = personalResults.length + sharedResults.length;
|
|
433
|
+
const fetched = rawPersonalCount + rawSharedCount;
|
|
434
|
+
recordCoverageEvent({
|
|
435
|
+
outcome: candidates > 0 ? "below_threshold" : "empty",
|
|
436
|
+
prompt,
|
|
437
|
+
fetched,
|
|
438
|
+
candidates,
|
|
439
|
+
droppedCurrentSession: fetched - candidates,
|
|
440
|
+
topScore: topScoreOf([...personalResults, ...sharedResults]),
|
|
441
|
+
threshold: cfg.relevanceThreshold,
|
|
442
|
+
ranking: cfg.ranking.enabled,
|
|
443
|
+
sessionId: currentSessionId,
|
|
444
|
+
userId: resolved?.userId,
|
|
445
|
+
lanes,
|
|
446
|
+
}, stateRoot);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
338
449
|
if (isKnownSender && resolved) {
|
|
339
450
|
const contextLine = resolved.context ? ` ${resolved.context}` : "";
|
|
340
451
|
return {
|
|
@@ -3,7 +3,7 @@ import { resolveCurrentSessionId } from "../lib/session.js";
|
|
|
3
3
|
import { isMultiSpeaker } from "../lib/speaker-tracker.js";
|
|
4
4
|
import { log } from "../logger.js";
|
|
5
5
|
import { sanitizeTraceText } from "./auto-trace.js";
|
|
6
|
-
import { buildMoodWeatherContext, rollMood } from "./mood-weather.js";
|
|
6
|
+
import { buildMoodWeatherContext, recordMoodRoll, rollMood, } from "./mood-weather.js";
|
|
7
7
|
/** How many recent registers to surface as the "arc" at session start. */
|
|
8
8
|
export const EMOTIONAL_ARC_LIMIT = 3;
|
|
9
9
|
const MIN_MESSAGES = 3;
|
|
@@ -15,6 +15,12 @@ const MIN_CONVERSATION_LENGTH = 100;
|
|
|
15
15
|
* heartbeat overwrite the register from a deep conversation (the whipsaw).
|
|
16
16
|
* `ctx.trigger` is one of cron|heartbeat|manual|memory|overflow|user; we store
|
|
17
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.
|
|
18
24
|
*/
|
|
19
25
|
const NON_CONVERSATIONAL_TRIGGERS = new Set(["cron", "heartbeat", "memory"]);
|
|
20
26
|
/**
|
|
@@ -224,12 +230,15 @@ export function buildEmotionalStateFetchHandler(client, cfg, deps = {}) {
|
|
|
224
230
|
if (sessionKey)
|
|
225
231
|
sessionMoods.set(sessionKey, mood);
|
|
226
232
|
log.info(`mood-weather: rolled "${mood.id}" this session`);
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
|
|
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
|
+
});
|
|
233
242
|
}
|
|
234
243
|
const moodBlock = mood ? buildMoodWeatherContext(mood) : "";
|
|
235
244
|
if (usable.length === 0) {
|
|
@@ -239,7 +248,9 @@ export function buildEmotionalStateFetchHandler(client, cfg, deps = {}) {
|
|
|
239
248
|
injectedSessions.add(sessionKey);
|
|
240
249
|
return moodBlock ? { prependContext: moodBlock } : undefined;
|
|
241
250
|
}
|
|
242
|
-
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)`);
|
|
243
254
|
// Mood block comes AFTER the arc so it reads as today's override on top
|
|
244
255
|
// of the remembered trajectory — not blended into it.
|
|
245
256
|
const context = moodBlock
|
|
@@ -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";
|
|
@@ -219,9 +220,14 @@ export async function gatherOrientation(client, cfg, userId) {
|
|
|
219
220
|
: Promise.resolve([]);
|
|
220
221
|
const [recentSettled, loopsSettled] = await Promise.allSettled([
|
|
221
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.
|
|
222
227
|
client.search(so.loopsQuery, {
|
|
223
228
|
limit: so.loopsLimit,
|
|
224
229
|
userId,
|
|
230
|
+
filter: excludeFilterFor(cfg),
|
|
225
231
|
}),
|
|
226
232
|
]);
|
|
227
233
|
const recentOk = recentSettled.status === "fulfilled";
|
|
@@ -310,7 +316,10 @@ export function buildStartupOrientationHandler(client, cfg) {
|
|
|
310
316
|
return;
|
|
311
317
|
}
|
|
312
318
|
const blocks = [gathered.recentBlock, gathered.loopsBlock].filter((b) => b !== null);
|
|
313
|
-
log.debug
|
|
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}`);
|
|
314
323
|
return { prependContext: blocks.join("\n\n") };
|
|
315
324
|
};
|
|
316
325
|
}
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { HyperspellClient } from "./client.js";
|
|
2
2
|
import { registerCommands } from "./commands/slash.js";
|
|
3
3
|
import { registerCliCommands } from "./commands/setup.js";
|
|
4
|
-
import { parseConfig, hyperspellConfigSchema, getWorkspaceDir } from "./config.js";
|
|
4
|
+
import { parseConfig, hyperspellConfigSchema, getWorkspaceDir, VALID_SOURCES } from "./config.js";
|
|
5
5
|
import { buildAutoContextHandler } from "./hooks/auto-context.js";
|
|
6
6
|
import { buildAutoTraceHandler } from "./hooks/auto-trace.js";
|
|
7
7
|
import { buildEmotionalStateCompactionHandler, buildEmotionalStateFetchHandler, buildEmotionalStateSessionCleanupHandler, buildEmotionalStateStoreHandler, } from "./hooks/emotional-state.js";
|
|
@@ -79,6 +79,13 @@ export default {
|
|
|
79
79
|
}
|
|
80
80
|
const cfg = parseConfig(api.pluginConfig);
|
|
81
81
|
initLogger(api.logger, cfg.debug);
|
|
82
|
+
// sourceWeights keys are deliberately not schema-validated (new backend
|
|
83
|
+
// sources must not fail manifest validation), so a typo is a silent
|
|
84
|
+
// neutral no-op — surface it once at startup instead.
|
|
85
|
+
const unknownWeightKeys = Object.keys(cfg.ranking.sourceWeights).filter((k) => !VALID_SOURCES.includes(k));
|
|
86
|
+
if (unknownWeightKeys.length > 0) {
|
|
87
|
+
log.diag(`ranking.sourceWeights keys not in the known source list (typo? they weight nothing): ${unknownWeightKeys.join(", ")}`);
|
|
88
|
+
}
|
|
82
89
|
const client = new HyperspellClient(cfg);
|
|
83
90
|
// Channel quarantine (cfg.excludeChannels): excluded conversations get no
|
|
84
91
|
// memory surface in either direction. Guard the shared choke points here —
|
|
@@ -170,6 +177,22 @@ export default {
|
|
|
170
177
|
if (cfg.knowledgeGraph.enabled) {
|
|
171
178
|
registerNetworkTools(api, client, cfg);
|
|
172
179
|
}
|
|
180
|
+
else {
|
|
181
|
+
// Discoverability (issue #81): the Memory Network ships fully built but
|
|
182
|
+
// default-off. If memories are accumulating (hot buffer / auto-trace /
|
|
183
|
+
// emotional state) and the operator never made a knowledgeGraph decision,
|
|
184
|
+
// say so once at startup — otherwise the feature is undetectable without
|
|
185
|
+
// reading source. Keyed off the RAW config: an explicit `knowledgeGraph`
|
|
186
|
+
// key (even { enabled: false }) is a decision and suppresses this.
|
|
187
|
+
const memoryAccumulating = cfg.hotBuffer.enabled || cfg.autoTrace.enabled || cfg.emotionalContext;
|
|
188
|
+
if (rawConfig?.knowledgeGraph === undefined && memoryAccumulating) {
|
|
189
|
+
log.info("memories are accumulating but the Memory Network (knowledgeGraph) is not configured — " +
|
|
190
|
+
"no entity extraction into memory/people|projects|organizations|topics will run. " +
|
|
191
|
+
"Enable it via 'openclaw openclaw-hyperspell setup' (Memory Network step) or set " +
|
|
192
|
+
"knowledgeGraph.enabled: true, or silence this note with knowledgeGraph: { enabled: false }. " +
|
|
193
|
+
"See README § Memory Network.");
|
|
194
|
+
}
|
|
195
|
+
}
|
|
173
196
|
// Register slash commands
|
|
174
197
|
registerCommands(api, client, cfg);
|
|
175
198
|
// Register service for lifecycle management
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { getWorkspaceDir } from "../config.js";
|
|
4
|
+
import { log } from "../logger.js";
|
|
5
|
+
export const COVERAGE_LOG_NAME = ".hyperspell-coverage.jsonl";
|
|
6
|
+
// Truncation bounds line size and what sits on disk; 500 chars is enough to
|
|
7
|
+
// judge "should this have found something" without full-prompt fidelity.
|
|
8
|
+
const MAX_PROMPT_CHARS = 500;
|
|
9
|
+
// One-generation rotation caps the total footprint at ~2× this (current + .old).
|
|
10
|
+
const MAX_LOG_BYTES = 5 * 1024 * 1024;
|
|
11
|
+
/**
|
|
12
|
+
* Append one coverage event to the LOCAL-ONLY coverage log
|
|
13
|
+
* (`<workspaceDir>/.hyperspell-coverage.jsonl`). Never sent to the backend —
|
|
14
|
+
* same trust domain as the hot-buffer state files already living there.
|
|
15
|
+
* Prompt text is sensitive plaintext, so writes happen only behind the
|
|
16
|
+
* explicit `coverageLog: true` opt-in (checked at call sites, default OFF).
|
|
17
|
+
* Best-effort by contract: any failure is swallowed (debug-logged) — a
|
|
18
|
+
* coverage write must never throw into, block, or delay the turn.
|
|
19
|
+
*/
|
|
20
|
+
export function recordCoverageEvent(event, stateRoot) {
|
|
21
|
+
try {
|
|
22
|
+
const dir = stateRoot ?? getWorkspaceDir();
|
|
23
|
+
const p = path.join(dir, COVERAGE_LOG_NAME);
|
|
24
|
+
rotateIfOversized(p);
|
|
25
|
+
const line = JSON.stringify({
|
|
26
|
+
v: 1,
|
|
27
|
+
ts: new Date().toISOString(),
|
|
28
|
+
...event,
|
|
29
|
+
prompt: event.prompt.slice(0, MAX_PROMPT_CHARS),
|
|
30
|
+
});
|
|
31
|
+
fs.appendFileSync(p, `${line}\n`);
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
log.debug(`coverage-log: append failed — ${String(err)}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** One-generation rotation: current file > cap → rename to .old (replacing the
|
|
38
|
+
* previous .old), start fresh. Total footprint bounded at ~2× MAX_LOG_BYTES. */
|
|
39
|
+
function rotateIfOversized(p) {
|
|
40
|
+
try {
|
|
41
|
+
if (fs.statSync(p).size > MAX_LOG_BYTES)
|
|
42
|
+
fs.renameSync(p, `${p}.old`);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
/* missing file or rename race — appendFileSync creates/handles it */
|
|
46
|
+
}
|
|
47
|
+
}
|