@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/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, cfg) {
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
- const userIds = cfg ? getAllUserIds(cfg) : [undefined];
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?.graph_entity === "true" || mem.metadata?.graph_entity === true)
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 chosen = [...r.highlights]
76
+ const passing = [...r.highlights]
68
77
  .sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
69
- .filter((h) => (h.score ?? 0) >= hiFloor)
70
- .slice(0, 2);
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
- export function buildAutoContextHandler(client, cfg) {
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
- const results = dropCurrentSession(await client.search(prompt, { limit, filter: excludeFilterFor(cfg) }), currentSessionId);
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.debug(`auto-context: cut ${cuts.length} of ${ranked.length} candidates ${JSON.stringify(cutTally)}${quotaNote}`);
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
- const tally = selected.reduce((acc, r) => ((acc[r._kind] = (acc[r._kind] ?? 0) + 1), acc), {});
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
- // Coordination with issue #71 (mood-weather observability): if #71's
228
- // recordMoodRoll(client, mood, {...}) has landed, its call belongs
229
- // HERE inside this `!priorMood` guard not on a bare `if (mood)`
230
- // at the two return sites below. A priorMood replay (post-compaction
231
- // re-injection of an already-rolled mood) is not a new roll; calling
232
- // recordMoodRoll on replay would log the same event twice.
233
+ // 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(`emotional-context: injecting ${usable.length} recent register(s)`);
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) => wp.startsWith("/") ? wp : path.join(workspaceDir, 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
- const rel = path.relative(memoryDir, filePath);
33
- if (rel.startsWith("..") || path.isAbsolute(rel))
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, { userId: syncUserId });
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(`startup-orientation: injecting recent=${gathered.recentCount} loops=${gathered.loopsCount}`);
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
+ }