@hyperspell/openclaw-hyperspell 0.19.0 → 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.
@@ -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
@@ -170,6 +170,22 @@ export default {
170
170
  if (cfg.knowledgeGraph.enabled) {
171
171
  registerNetworkTools(api, client, cfg);
172
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
+ }
173
189
  // Register slash commands
174
190
  registerCommands(api, client, cfg);
175
191
  // Register service for lifecycle management
@@ -6,6 +6,10 @@
6
6
  * Filters match against memory METADATA keys by their bare name — the same
7
7
  * convention `buildScopeFilter` uses (`openclaw_scope`, `openclaw_user`).
8
8
  */
9
+ /** Metadata tag on auto-trace session-end rows (see `sendTrace` in client.ts). */
10
+ export const AGENT_END_SOURCE = "agent_end";
11
+ /** Metadata tag on mood-weather roll records (see `recordMoodRoll` in hooks/mood-weather.ts). */
12
+ export const MOOD_WEATHER_SOURCE = "mood_weather";
9
13
  /**
10
14
  * Memories produced by the auto-trace session-end hook are tagged in metadata
11
15
  * as `openclaw_source: "agent_end"` (see `sendTrace` in client.ts). Those should
@@ -34,22 +38,46 @@
34
38
  * "openclaw_agent_end" — wrong on BOTH counts (the tag lives in metadata under
35
39
  * `openclaw_source`, value `"agent_end"`), so it silently matched nothing.
36
40
  */
37
- export const EXCLUDE_SESSION_END_FILTER = {
38
- openclaw_source: { $ne: "agent_end" },
39
- };
40
41
  /**
41
42
  * The exclude clause to apply for a given config — or `undefined` to skip the
42
- * filter. `agent_end` rows are written ONLY by the auto-trace hook, so when
43
- * auto-trace is OFF there are none to hide and we skip the filter to avoid its
44
- * ~1s/search latency cost (pure overhead otherwise verified live against an
45
- * auto-trace-off agent with zero `agent_end` rows).
43
+ * filter. Each excluded tag is gated on the feature that writes it, for
44
+ * PERFORMANCE, not correctness (see the header comment): with the feature off
45
+ * there are no tagged rows to hide, so the ~1s predicate would be pure latency.
46
46
  *
47
- * When auto-trace is ON we apply `{$ne:"agent_end"}`, which post-#1921 drops the
48
- * traces while KEEPING untagged hot-buffer rows — so that path is correct now
49
- * (the old #40 hot-row-drop is fixed by the backend, not by gating).
47
+ * - `agent_end` rows are written ONLY by the auto-trace hook gate on
48
+ * `autoTrace.enabled` (verified live against an auto-trace-off agent with
49
+ * zero `agent_end` rows).
50
+ * - `mood_weather` rolls are recorded ONLY when the emotional-context handler
51
+ * is registered AND the dice are live → gate on both flags.
52
+ *
53
+ * Shape: a single excluded value keeps the proven plain-`$ne` form (byte-
54
+ * identical to the shipped filter, and post-#1921 it drops the tag while
55
+ * KEEPING untagged hot-buffer rows). Two values use `$nin`.
56
+ *
57
+ * ⚠️ The `$nin` two-value shape has NOT been re-verified live post-#1921 (the
58
+ * pre-#1921 truth table showed `$nin` diverging from `$ne`). Owner's post-merge
59
+ * step: run `node docs/filter-dialect-test.mjs` — the `$nin[agent_end,mood_weather]`
60
+ * row must show U=Y, A=N, M=N. Fallback plan if it fails: try the
61
+ * `$and[$ne,$ne]` probe row; if that also fails, gate back to single-value
62
+ * `$ne` for the two common one-feature-on configs, log a warning when both
63
+ * features are enabled, and file a backend dialect follow-up
64
+ * (docs/hyperspell-backend-followups.md style) for the both-on combo.
50
65
  */
51
66
  export function excludeFilterFor(cfg) {
52
- return cfg.autoTrace.enabled ? EXCLUDE_SESSION_END_FILTER : undefined;
67
+ const excluded = [];
68
+ if (cfg.autoTrace.enabled)
69
+ excluded.push(AGENT_END_SOURCE);
70
+ // Mood rolls are recorded only when the emotional-context handler is
71
+ // registered AND the dice are live — same "no rows to hide → skip the
72
+ // ~1s predicate" gate as auto-trace.
73
+ if (cfg.emotionalContext && cfg.moodWeatherChance > 0) {
74
+ excluded.push(MOOD_WEATHER_SOURCE);
75
+ }
76
+ if (excluded.length === 0)
77
+ return undefined;
78
+ if (excluded.length === 1)
79
+ return { openclaw_source: { $ne: excluded[0] } };
80
+ return { openclaw_source: { $nin: excluded } };
53
81
  }
54
82
  /**
55
83
  * Combine a caller-supplied filter with the session-end exclude via `$and`.