@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.
package/README.md CHANGED
@@ -123,6 +123,13 @@ Matching uses the same semantics as the quarantine check itself: exact id or thr
123
123
 
124
124
  **Finding session ids for `--session`:** OpenClaw session keys embed the conversation id (`agent:<agentId>:<provider>:channel:<channelId>[...]` — the same format the plugin's quarantine matching parses). Look up the quarantined conversation's sessions in OpenClaw's session store and pass their session ids; each hot-buffer resource id equals its session id.
125
125
 
126
+ ### `openclaw openclaw-hyperspell network scan|complete|sync`
127
+
128
+ Memory Network primitives, used by the extraction cron's isolated session (and handy for
129
+ manual dry-runs — `scan` is read-only). `scan` lists unprocessed memories with content
130
+ summaries, `complete --ids <ids>` marks them processed, `sync` pushes `memory/` entity
131
+ files back to Hyperspell.
132
+
126
133
  ## Configuration Options
127
134
 
128
135
  | Option | Type | Default | Description |
@@ -135,10 +142,18 @@ Matching uses the same semantics as the quarantine check itself: exact id or thr
135
142
  | `syncMemories` | boolean | `false` | Sync markdown files in `workspace/memory/` to Hyperspell |
136
143
  | `sources` | string | - | Comma-separated sources to search (e.g., `vault,notion,slack`) |
137
144
  | `maxResults` | number | `10` | Maximum memories per context injection |
145
+ | `relevanceThreshold` | number | `0.6` | Minimum (composite) score a memory needs to be injected by auto-context |
146
+ | `ranking` | object | see below | Composite re-ranking of auto-context results — see [Composite ranking](#composite-ranking--surfacing-your-active-work) |
147
+ | `ranking.storyTerms` | string[] | `[]` | **Off until you set it.** Words/phrases identifying your active creative work, so it outranks conversation chatter (matched at word boundaries, case-insensitive) |
138
148
  | `excludeChannels` | string[] | `[]` | Conversation/channel ids fully quarantined from memory: no context injection, no memory writes, no memory tools. Threads inherit. **Forward-only** — see [below](#excludechannels-is-forward-only). |
139
- | `debug` | boolean | `false` | Enable verbose logging |
149
+ | `knowledgeGraph.enabled` | boolean | `false` | Memory Network: extract entities (people, projects, organizations, topics) from memories into `memory/` markdown files. See [Memory Network](#memory-network). |
150
+ | `knowledgeGraph.scanIntervalMinutes` | number | `60` | Extraction cadence the setup wizard bakes into the cron job it creates. The cron job is the runtime source of truth — to change cadence after setup, edit the cron job (and keep this field in sync). |
151
+ | `knowledgeGraph.batchSize` | number | `20` | Memories per extraction scan batch |
152
+ | `debug` | boolean | `false` | Enable diagnostic logging. One-line diagnostics (auto-context ranked/cut/injection summaries, orientation and emotional-context injection counts) are emitted at **info** level, so they appear in `gateway.log` at default host log levels — no host `logging.level` change needed. Verbose output (per-request/response dumps, per-candidate score lines) stays at debug level. |
140
153
  | `dreaming.enabled` | boolean | `false` | Allow `memory-core` to sidecar-load so Dreaming can consolidate local session transcripts into `workspace/MEMORY.md`. See [Running alongside Dreaming](#running-alongside-dreaming). |
141
154
 
155
+ Stored emotional-state registers (`emotionalContext`) are fetchable by external processes (e.g. a nightly consolidator reconciling its own day-read) — the verified fetch contract lives in [docs/emotional-state-external-reconciliation.md](docs/emotional-state-external-reconciliation.md).
156
+
142
157
  ## Slash Commands
143
158
 
144
159
  ### `/getcontext <query>`
@@ -188,18 +203,25 @@ When `syncMemories: true`, the plugin syncs markdown files from your agent's wor
188
203
  - Startup sync runs in the **background** — the agent boots immediately and sync proceeds without blocking it
189
204
  - A per-section content hash is tracked in `<workspace>/.hyperspell-sync-hashes.json`, so unchanged sections are skipped on subsequent syncs (no re-ingestion)
190
205
 
206
+ **Provenance:** every synced memory carries an `openclaw_sync_source` metadata key recording where the content came from: `"memory"` for files under `memory/`, and the watchPath's `source` label (or a slug derived from its path, e.g. `notes/brainstem` → `notes_brainstem`) for watchPath files. This lets retrieval distinguish curated memory files from machine-generated content. The key is additive — `openclaw_source` (which pipeline wrote the row) is unchanged. Already-synced content is **not retroactively retagged**: uploads are gated by per-section content hash, so previously synced sections keep their old metadata until their content next changes.
207
+
191
208
  **Tuning sync (object form):**
192
209
 
193
210
  ```jsonc
194
211
  "syncMemories": {
195
212
  "enabled": true,
196
213
  "sectionize": true, // split files on ## headings into separate memories
197
- "watchPaths": [], // extra files/dirs to sync beyond memory/
214
+ "watchPaths": [ // extra files/dirs to sync beyond memory/
215
+ "notes", // plain path — tagged with slug "notes"
216
+ { "path": "notes/brainstem", "source": "brainstem_daily" } // labeled — tagged openclaw_sync_source: "brainstem_daily"
217
+ ],
198
218
  "debounceMs": 2000, // wait for writes to settle before syncing
199
219
  "maxAgeDays": 30 // startup skips already-synced files older than this
200
220
  }
201
221
  ```
202
222
 
223
+ `watchPaths` is how you make **externally generated notes** searchable: tools that write dated markdown reports under the workspace (nightly consolidators, journal generators — e.g. a Brainstem consolidator writing `notes/brainstem/YYYY-MM-DD.md`) are invisible to sync until their file or directory is listed here. Watched paths are picked up by the startup bulk sync. Use the labeled object form so machine-generated content is distinguishable from curated memories at retrieval time. Sectionized mode (the default) is **recommended for external directories**: legacy whole-file mode (`sectionize: false`) writes a `hyperspell_id` frontmatter line back into the source file — i.e. the plugin edits another tool's files — while sectionized mode tracks state in the sync manifest and leaves watched files untouched.
224
+
203
225
  `maxAgeDays` bounds steady-state load: on startup, files whose mtime is older than the cutoff **and** already recorded in the sync manifest are skipped without re-reading. Files not yet in the manifest are always synced once regardless of age, and editing an old file bumps its mtime back into the window. Set to `0` to disable the cutoff.
204
226
 
205
227
  **Example frontmatter after sync:**
@@ -232,6 +254,85 @@ When `autoContext: true` (default), the plugin automatically:
232
254
 
233
255
  This ensures the AI always has access to relevant information from your connected sources.
234
256
 
257
+ ### Composite ranking — surfacing your active work
258
+
259
+ Raw semantic relevance rewards *frequency*: a phrase repeated across a hundred
260
+ auto-saved conversation fragments looks "most similar" to everything and buries
261
+ quieter, truer memory — like the manuscript you're actually writing. When
262
+ `ranking.enabled` is on (default), auto-context re-scores candidates:
263
+
264
+ ```
265
+ composite = relevance
266
+ + curationBoost (a memory you deliberately kept: journals, notes, synced files)
267
+ + storyBoost (your active story/manuscript — matched via storyTerms)
268
+ − chatterPenalty (an auto-saved conversation fragment)
269
+ ```
270
+
271
+ Chatter is additionally capped at `chatterQuota` results per injection,
272
+ regardless of score.
273
+
274
+ **`storyBoost` does nothing until you set `storyTerms`.** The default is `[]`,
275
+ so no result ever classifies as "story". Set it to the distinctive proper nouns
276
+ of your active work — the title, character names, a project codename, invented
277
+ terminology:
278
+
279
+ ```jsonc
280
+ "config": {
281
+ "ranking": {
282
+ "storyTerms": ["lighthouse keeper", "mira", "the shoal chapters"]
283
+ }
284
+ }
285
+ ```
286
+
287
+ For example: if you're writing a novel called *The Lighthouse Keeper* with a
288
+ protagonist named Mira, the config above makes any memory whose **title or
289
+ highlight excerpt** contains those terms rank as "story" — it gets
290
+ `storyBoost + curationBoost` (+0.35 by default) and is exempt from the chatter
291
+ cap. If you sync the manuscript via `syncMemories` with `sectionize: true`,
292
+ every section is titled `The Lighthouse Keeper — <section>`, so the title term
293
+ alone catches the whole manuscript.
294
+
295
+ **How matching works:** terms are matched case-insensitively at **word
296
+ boundaries** against the result's title and highlight excerpts. `"mira"`
297
+ matches "Mira", "Mira's", and "mira-class", but **not** "ad**mira**l" or
298
+ "**mira**cle" — a short name can't false-positive inside longer words.
299
+ Multi-word phrases (`"lighthouse keeper"`) match the same way. There is no
300
+ prefix/stem matching: to catch inflected or partial forms, add each full form
301
+ as its own term.
302
+
303
+ Tips:
304
+
305
+ - **Use 3–15 distinctive terms.** Every term is checked per result per search;
306
+ more terms means more false-positive surface, not more recall. Never add a
307
+ word that appears in unrelated conversation ("book", "chapter", "draft").
308
+ - Terms are normalized on load — trimmed, lowercased, deduplicated — so casing
309
+ and stray whitespace in your config don't matter.
310
+ - **Update the list when the active story changes.** A stale term is worse than
311
+ a missing one: it keeps granting the boost to a dead thread's echoes.
312
+ - Ranking (including `storyTerms`) applies only to **auto-context** injection.
313
+ The `hyperspell_search` tool and `/getcontext` return raw relevance order —
314
+ don't test `storyTerms` there and conclude it's broken.
315
+ - To verify it's working, enable `debug: true` and watch `gateway.log` for the
316
+ per-search tally (`auto-context: ranked {...} candidates → selected {...}`) —
317
+ it appears at default host log levels. The per-candidate lines
318
+ (`[story] 0.47→0.82 The Lighthouse Keeper — Chapter 3`), which show story
319
+ matches even when they lose to the threshold, are debug-level and also need
320
+ host debug logging.
321
+
322
+ Full knobs and defaults:
323
+
324
+ ```jsonc
325
+ "ranking": {
326
+ "enabled": true,
327
+ "curationBoost": 0.2, // lift for deliberately-kept memory
328
+ "chatterPenalty": 0.2, // penalty for auto-saved conversation fragments
329
+ "storyBoost": 0.15, // extra lift for storyTerms matches (stacks with curationBoost)
330
+ "storyTerms": [], // REQUIRED for storyBoost to do anything
331
+ "candidateMultiplier": 3, // fetch this × maxResults candidates before re-ranking
332
+ "chatterQuota": 2 // hard cap on chatter results per injection
333
+ }
334
+ ```
335
+
235
336
  ### `excludeChannels` is forward-only
236
337
 
237
338
  Adding a channel to `excludeChannels` stops all future injection/writes/tools for that conversation, but does **not** remove content that was synced before the channel was quarantined.
@@ -276,16 +377,84 @@ The purge command prints these standing limitations on every run.
276
377
  ### Other
277
378
  - `web_crawler` - Crawled web pages
278
379
 
279
- ## Knowledge Graph
380
+ ## Memory Network
381
+
382
+ The Memory Network (config key: `knowledgeGraph`) automatically builds a local knowledge
383
+ graph from your memories. A periodic cron job runs in an isolated session and:
384
+
385
+ 1. **Scans** unprocessed memories (`hyperspell_network_scan` / `network scan`)
386
+ 2. **Extracts** people, projects, organizations, and topics with relationships
387
+ 3. **Writes** one markdown file per entity into `memory/people/`, `memory/projects/`,
388
+ `memory/organizations/`, `memory/topics/` (`hyperspell_network_write`)
389
+ 4. **Marks** the batch processed so it is never re-scanned (`hyperspell_network_complete`;
390
+ state lives in `memory/.network-state.json`)
391
+ 5. **Syncs** the entity files back to Hyperspell so they are searchable (`network sync`)
280
392
 
281
- The plugin can automatically build a local knowledge graph from your memories:
393
+ If you also maintain a hand-written people/projects reference file, this feature
394
+ automates most of that by hand-off — see
395
+ [Migrating from a hand-maintained archive](docs/memory-network-migration.md) for a safe
396
+ dry-run and comparison procedure before adopting it.
397
+
398
+ ### Enabling it
399
+
400
+ The easiest path is the setup wizard, which flips the config, writes the extraction
401
+ prompt to `<workspace>/HYPERSPELL-MEMORY-NETWORK.md`, and creates the extraction cron
402
+ (interval from `knowledgeGraph.scanIntervalMinutes`, default 60 minutes):
403
+
404
+ ```bash
405
+ openclaw openclaw-hyperspell setup # answer "yes" at the Memory Network step
406
+ ```
407
+
408
+ Or manually: set the plugin config and create the cron yourself:
409
+
410
+ ```json
411
+ "knowledgeGraph": { "enabled": true }
412
+ ```
413
+
414
+ ```bash
415
+ openclaw cron add --name "Hyperspell Memory Network" --every 60m --session isolated \
416
+ --message "Read the file at <workspace>/HYPERSPELL-MEMORY-NETWORK.md and follow the instructions inside it."
417
+ ```
418
+
419
+ The `hyperspell_network_*` tools only register when `knowledgeGraph.enabled` is true.
420
+
421
+ ### What an extracted entity file looks like
422
+
423
+ `memory/people/alice-chen.md`:
424
+
425
+ ```markdown
426
+ ---
427
+ title: Alice Chen
428
+ type: person
429
+ graph_entity: true
430
+ email: alice@hyperspell.com
431
+ source_memories: {"slack":["C073WR69EPM"],"google_mail":["19bbe68026553623"]}
432
+ relationships: ["works-at:organizations/hyperspell","leads:projects/memory-network"]
433
+ last_extracted: 2026-07-07T12:00:00Z
434
+ ---
435
+ # Alice Chen
436
+
437
+ Engineering Manager at Hyperspell. Leads the Memory Network project.
438
+
439
+ ## Contact
440
+
441
+ - Email: alice@hyperspell.com
442
+
443
+ ## Relationships
444
+
445
+ - works-at: [hyperspell](../organizations/hyperspell.md)
446
+ - leads: [memory network](../projects/memory-network.md)
447
+ ```
282
448
 
283
- 1. **Scan** memories for entities (people, organizations, projects, topics)
284
- 2. **Extract** structured information and relationships
285
- 3. **Write** entity files to `memory/people/`, `memory/organizations/`, etc.
286
- 4. **Link** entities via markdown relationship references
449
+ Re-extraction **merges**: existing `source_memories`, `relationships`, and
450
+ `hyperspell_id` are preserved and unioned, so files are safe to hand-edit between runs.
287
451
 
288
- Enable the graph tools by using `hyperspell_network_scan`, `hyperspell_network_write`, and `hyperspell_network_complete` in your agent workflows.
452
+ Re-extraction is idempotent by design (the cron agent re-derives the same entities and
453
+ marks rows complete). A previously known gap meant entity files synced back to Hyperspell
454
+ re-entered future scans as unprocessed source memories; the fix lands alongside
455
+ [`proposal/06-knowledge-graph-enablement`](docs/proposals/06-knowledge-graph-enablement.md)
456
+ (PR #99), which propagates the `graph_entity` frontmatter into sync metadata and makes
457
+ the scanner skip memories synced from the entity directories.
289
458
 
290
459
  ---
291
460
 
@@ -303,7 +472,8 @@ Enable the graph tools by using `hyperspell_network_scan`, `hyperspell_network_w
303
472
 
304
473
  ### Auto-context not injecting
305
474
  - Verify `autoContext: true` in your config
306
- - Enable `debug: true` to see what queries are being made
475
+ - Enable `debug: true` the ranked/cut/injection summary lines land in
476
+ `gateway.log` at default host log levels
307
477
  - Check that you have memories matching your conversation topics
308
478
 
309
479
  ### Enabling `memory-core` disabled Hyperspell
package/dist/client.js CHANGED
@@ -2,6 +2,14 @@ import Hyperspell from "hyperspell";
2
2
  import { normalizeScope } from "./config.js";
3
3
  import { log } from "./logger.js";
4
4
  const API_BASE_URL = "https://api.hyperspell.com";
5
+ /**
6
+ * The backend metadata echo must be a plain object to map onto
7
+ * `EmotionalStateLatest.metadata`; anything else (absent, null, array,
8
+ * scalar) is dropped so a malformed echo can't poison callers.
9
+ */
10
+ function isMetadataObject(value) {
11
+ return typeof value === "object" && value !== null && !Array.isArray(value);
12
+ }
5
13
  export class HyperspellClient {
6
14
  client;
7
15
  config;
@@ -396,6 +404,10 @@ export class HyperspellClient {
396
404
  extractedAt: data.extracted_at,
397
405
  sessionId: data.session_id ?? null,
398
406
  relationshipId: data.relationship_id ?? null,
407
+ // Metadata echo maps through only when present and object-shaped —
408
+ // absent today (backend #116) and on legacy rows, so this is invisible
409
+ // until the backend ships the echo.
410
+ ...(isMetadataObject(data.metadata) ? { metadata: data.metadata } : {}),
399
411
  };
400
412
  log.debugResponse("emotional-state.get", { found: true, resourceId: result.resourceId });
401
413
  return result;
@@ -433,6 +445,8 @@ export class HyperspellClient {
433
445
  extractedAt: d.extracted_at ?? "",
434
446
  sessionId: d.session_id ?? null,
435
447
  relationshipId: d.relationship_id ?? null,
448
+ // Same conditional echo as getEmotionalState — see the note there.
449
+ ...(isMetadataObject(d.metadata) ? { metadata: d.metadata } : {}),
436
450
  }));
437
451
  log.debugResponse("emotional-state.recent", { count: list.length });
438
452
  return list;
@@ -324,12 +324,18 @@ async function runSetup() {
324
324
  // Step 8: Memory Network setup
325
325
  p.note("The Memory Network automatically extracts entities (people, projects,\n" +
326
326
  "organizations, topics) from your memories into structured markdown\n" +
327
- "files. This runs as a periodic cron job in the main session.", "Memory Network");
327
+ "files. This runs as a periodic cron job in an isolated session.", "Memory Network");
328
328
  const enableNetwork = await p.confirm({
329
329
  message: "Enable the Memory Network?",
330
330
  initialValue: false,
331
331
  });
332
332
  if (!p.isCancel(enableNetwork) && enableNetwork) {
333
+ // The wizard is the only place the extraction cron is created, so the
334
+ // config knob and the cron are born in agreement: one interval feeds both
335
+ // `--every` and the persisted `scanIntervalMinutes`. Runtime never
336
+ // reschedules — to change cadence later, edit the cron job (and keep the
337
+ // config field in sync so it stays an honest record of the cron).
338
+ const scanIntervalMinutes = 60;
333
339
  // Update config to enable knowledgeGraph
334
340
  try {
335
341
  const configPath = resolveConfigPath();
@@ -338,7 +344,7 @@ async function runSetup() {
338
344
  const config = JSON.parse(configContent);
339
345
  const pluginEntry = config?.plugins?.entries?.["openclaw-hyperspell"]?.config;
340
346
  if (pluginEntry) {
341
- pluginEntry.knowledgeGraph = { enabled: true };
347
+ pluginEntry.knowledgeGraph = { enabled: true, scanIntervalMinutes };
342
348
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
343
349
  }
344
350
  }
@@ -360,7 +366,7 @@ async function runSetup() {
360
366
  const output = execFileSync("openclaw", [
361
367
  "cron", "add",
362
368
  "--name", CRON_JOB_NAME,
363
- "--every", "1h",
369
+ "--every", `${scanIntervalMinutes}m`,
364
370
  "--session", "isolated",
365
371
  "--message", `Read the file at ${promptPath} and follow the instructions inside it.`,
366
372
  ], { stdio: ["pipe", "pipe", "pipe"], timeout: 10_000 });
@@ -375,14 +381,14 @@ async function runSetup() {
375
381
  }
376
382
  catch { }
377
383
  }
378
- s4.stop("Cron job created — Memory Network will scan every hour");
384
+ s4.stop(`Cron job created — Memory Network will scan every ${scanIntervalMinutes} minutes`);
379
385
  }
380
386
  catch (cronErr) {
381
387
  s4.stop("Could not create cron job automatically");
382
388
  p.log.warn(`Cron creation failed. Create it manually:`);
383
389
  p.note(`openclaw cron add \\\n` +
384
390
  ` --name "${CRON_JOB_NAME}" \\\n` +
385
- ` --every 1h \\\n` +
391
+ ` --every ${scanIntervalMinutes}m \\\n` +
386
392
  ` --session isolated \\\n` +
387
393
  ` --message "Read the file at ${promptPath} and follow the instructions inside it."`, "Manual cron setup");
388
394
  }
@@ -588,7 +594,10 @@ export function registerCliCommands(program, pluginConfig) {
588
594
  const workspaceDir = getWorkspaceDir();
589
595
  const stateManager = new NetworkStateManager(workspaceDir);
590
596
  const batchSize = Number.parseInt(opts.batchSize, 10) || 20;
591
- const memories = await scanMemories(client, stateManager, batchSize);
597
+ // cfg drives the multiUser fan-out inside scanMemories; omitting it
598
+ // made the CLI scan the default user only, silently skipping every
599
+ // mapped user's memories on multiUser installs.
600
+ const memories = await scanMemories(client, stateManager, batchSize, cfg);
592
601
  const text = formatScanResults(memories, stateManager.getProcessedCount(), stateManager.getLastScanAt());
593
602
  process.stdout.write(text + "\n");
594
603
  }
@@ -1,4 +1,6 @@
1
1
  import { getWorkspaceDir } from "../config.js";
2
+ import { MOOD_WEATHER_COLLECTION } from "../hooks/mood-weather.js";
3
+ import { MOOD_WEATHER_SOURCE } from "../lib/filters.js";
2
4
  import { buildScopeFilter, getCanReadScopes, getDefaultWriteScope, resolveRole, resolveUser, routeWrite, } from "../lib/sender.js";
3
5
  import { log } from "../logger.js";
4
6
  import { syncAllMemoryFiles } from "../sync/markdown.js";
@@ -157,6 +159,44 @@ export function registerCommands(api, client, cfg) {
157
159
  }
158
160
  },
159
161
  });
162
+ // /moodweather — private roll history (operator retrospection only; these rows
163
+ // are excluded from all agent recall, so this command is the ONLY reader).
164
+ api.registerCommand({
165
+ name: "moodweather",
166
+ description: "Show recent mood-weather rolls (never fed back into tone)",
167
+ acceptsArgs: false,
168
+ requireAuth: true,
169
+ handler: async () => {
170
+ log.debug("/moodweather command");
171
+ try {
172
+ const rows = [];
173
+ for await (const mem of client.listMemories({
174
+ collection: MOOD_WEATHER_COLLECTION,
175
+ pageSize: 50,
176
+ })) {
177
+ if (mem.metadata?.openclaw_source !== MOOD_WEATHER_SOURCE)
178
+ continue;
179
+ rows.push({
180
+ mood: String(mem.metadata.mood ?? "?"),
181
+ rolledAt: String(mem.metadata.rolled_at ?? ""),
182
+ });
183
+ if (rows.length >= 20)
184
+ break;
185
+ }
186
+ if (rows.length === 0) {
187
+ return { text: "No mood-weather rolls recorded." };
188
+ }
189
+ const lines = rows.map((r) => `• ${r.rolledAt.slice(0, 16).replace("T", " ")} — ${r.mood}`);
190
+ return {
191
+ text: `Recent mood-weather rolls (newest first):\n${lines.join("\n")}`,
192
+ };
193
+ }
194
+ catch (err) {
195
+ log.error("/moodweather failed", err);
196
+ return { text: "Failed to fetch mood-weather history. Check logs for details." };
197
+ }
198
+ },
199
+ });
160
200
  // /previewcontext - Show what would be injected into the next session
161
201
  api.registerCommand({
162
202
  name: "previewcontext",
package/dist/config.js CHANGED
@@ -62,6 +62,39 @@ function resolveEnvVars(value) {
62
62
  return envValue;
63
63
  });
64
64
  }
65
+ /**
66
+ * Normalize `syncMemories.watchPaths` entries: the shipped/documented string
67
+ * form stays valid and is additive-upgraded to `{ path }`; the object form
68
+ * carries an optional provenance `source` label. Labels are sanitized with the
69
+ * same character rule as normalizeScope — metadata values must be alphanumeric
70
+ * + underscore or Hyperspell metadata filters silently miss.
71
+ */
72
+ function parseWatchPaths(raw) {
73
+ if (!Array.isArray(raw))
74
+ return [];
75
+ return raw.map((wp) => {
76
+ if (typeof wp === "string") {
77
+ if (!wp.trim()) {
78
+ throw new Error("hyperspell.syncMemories.watchPaths[] entry needs a non-empty path");
79
+ }
80
+ return { path: wp };
81
+ }
82
+ if (typeof wp !== "object" || wp === null || Array.isArray(wp)) {
83
+ throw new Error("hyperspell.syncMemories.watchPaths[] entries must be a string or { path, source? }");
84
+ }
85
+ const entry = wp;
86
+ assertAllowedKeys(entry, ["path", "source"], "hyperspell.syncMemories.watchPaths[]");
87
+ if (typeof entry.path !== "string" || !entry.path.trim()) {
88
+ throw new Error("hyperspell.syncMemories.watchPaths[] entry needs a non-empty path");
89
+ }
90
+ if (!entry.source)
91
+ return { path: entry.path };
92
+ return {
93
+ path: entry.path,
94
+ source: String(entry.source).replace(/[^a-zA-Z0-9_]/g, "_"),
95
+ };
96
+ });
97
+ }
65
98
  function parseRanking(raw) {
66
99
  const r = (raw ?? {});
67
100
  const num = (v, d) => (typeof v === "number" ? v : d);
@@ -70,8 +103,16 @@ function parseRanking(raw) {
70
103
  curationBoost: num(r.curationBoost, DEFAULT_RANKING.curationBoost),
71
104
  chatterPenalty: num(r.chatterPenalty, DEFAULT_RANKING.chatterPenalty),
72
105
  storyBoost: num(r.storyBoost, DEFAULT_RANKING.storyBoost),
106
+ // Trim + lowercase + dedupe, drop whitespace-only entries. Before this a
107
+ // stray " " entry passed the length filter and (under the old substring
108
+ // matcher) classified EVERY result as story, silently disabling the
109
+ // chatter penalty and quota (issue #82's footgun).
73
110
  storyTerms: Array.isArray(r.storyTerms)
74
- ? r.storyTerms.map((t) => String(t)).filter((t) => t.length > 0)
111
+ ? [
112
+ ...new Set(r.storyTerms
113
+ .map((t) => String(t).trim().toLowerCase())
114
+ .filter((t) => t.length > 0)),
115
+ ]
75
116
  : DEFAULT_RANKING.storyTerms,
76
117
  candidateMultiplier: Math.max(1, num(r.candidateMultiplier, DEFAULT_RANKING.candidateMultiplier)),
77
118
  chatterQuota: Math.max(0, num(r.chatterQuota, DEFAULT_RANKING.chatterQuota)),
@@ -324,9 +365,7 @@ export function parseConfig(raw) {
324
365
  syncMemoriesConfig: {
325
366
  enabled: syncMemoriesEnabled,
326
367
  sectionize: smObj.sectionize ?? true,
327
- watchPaths: Array.isArray(smObj.watchPaths)
328
- ? smObj.watchPaths
329
- : [],
368
+ watchPaths: parseWatchPaths(smObj.watchPaths),
330
369
  debounceMs: smObj.debounceMs ?? 2000,
331
370
  maxAgeDays: smObj.maxAgeDays ?? 30,
332
371
  ignorePaths: Array.isArray(smObj.ignorePaths)
package/dist/graph/ops.js CHANGED
@@ -1,8 +1,31 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
+ import { MOOD_WEATHER_SOURCE } from "../lib/filters.js";
3
4
  import { getAllUserIds } from "../lib/sender.js";
4
5
  import { log } from "../logger.js";
5
6
  const ENTITY_TYPES = ["people", "projects", "organizations", "topics"];
7
+ /**
8
+ * True for memories that came from a Memory Network entity file synced back
9
+ * to Hyperspell. These must never re-enter a scan as source memories — the
10
+ * extractor would be fed its own output every cycle (self-scan loop,
11
+ * proposal/06 §3.3). Two signals, both needed:
12
+ * - `graph_entity` metadata, propagated from entity-file frontmatter by the
13
+ * sync paths (`sync/markdown.ts`) — the contract the extraction prompt states;
14
+ * - the entity-directory `file_path`, which covers records synced before the
15
+ * frontmatter propagation existed (unchanged sections are content-hash
16
+ * skipped on re-sync, so their old metadata never gets refreshed).
17
+ */
18
+ export function isEntityFileMemory(metadata) {
19
+ if (!metadata)
20
+ return false;
21
+ if (metadata.graph_entity === "true" || metadata.graph_entity === true)
22
+ return true;
23
+ const filePath = metadata.file_path;
24
+ if (typeof filePath !== "string")
25
+ return false;
26
+ const normalized = filePath.split(path.sep).join("/");
27
+ return ENTITY_TYPES.some((type) => normalized.includes(`/memory/${type}/`));
28
+ }
6
29
  export function slugify(name) {
7
30
  return name
8
31
  .toLowerCase()
@@ -60,17 +83,27 @@ function summarizeMemoryData(data, source) {
60
83
  }
61
84
  return parts.join("\n");
62
85
  }
63
- export async function scanMemories(client, stateManager, batchSize, 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,7 @@
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
5
  import { classifySearchError, logSearchError } from "../lib/search-error.js";
6
6
  import { resolveCurrentSessionId } from "../lib/session.js";
7
7
  import { recordSender, senderIdFromCtx } from "../lib/speaker-tracker.js";
@@ -204,6 +204,14 @@ export function buildAutoContextHandler(client, cfg) {
204
204
  const explained = explainSelection(ranked, cfg.maxResults, cfg.relevanceThreshold, ranking.chatterQuota);
205
205
  logScoreSamples(prompt, currentSessionId, "single", explained, cfg.relevanceThreshold);
206
206
  const selected = explained.filter((e) => e.selected).map((e) => e.result);
207
+ // Candidates → selected kind tally, logged UNCONDITIONALLY (unlike the
208
+ // "injecting" line below): a story/curated match that loses to the
209
+ // threshold is exactly the case an operator tuning storyTerms needs to
210
+ // see, and it never reaches the formatted branch (proposal 01 §3.4).
211
+ log.diag(`auto-context: ranked ${JSON.stringify(kindTally(ranked))} candidates → selected ${JSON.stringify(kindTally(selected))} (chatter cap ${ranking.chatterQuota})`);
212
+ for (const r of ranked.slice(0, 10)) {
213
+ log.debug(` [${r._kind}] ${r._base.toFixed(2)}→${r._composite.toFixed(2)} ${(r.title ?? r.resourceId).slice(0, 60)}`);
214
+ }
207
215
  // Cut-reason visibility (proposal 02, absorbs proposal 03): logged
208
216
  // BEFORE the `formatted` check so quota drops stay visible even when
209
217
  // nothing is injected (e.g. chatterQuota 0 with only chatter clearing
@@ -216,12 +224,11 @@ export function buildAutoContextHandler(client, cfg) {
216
224
  const quotaNote = topQuotaDrop
217
225
  ? `, top quota-dropped composite ${topQuotaDrop.result._composite.toFixed(2)}`
218
226
  : "";
219
- log.debug(`auto-context: cut ${cuts.length} of ${ranked.length} candidates ${JSON.stringify(cutTally)}${quotaNote}`);
227
+ log.diag(`auto-context: cut ${cuts.length} of ${ranked.length} candidates ${JSON.stringify(cutTally)}${quotaNote}`);
220
228
  }
221
229
  formatted = formatSelected(selected, cfg.relevanceThreshold);
222
230
  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)})`);
231
+ log.diag(`auto-context: injecting (ranked) ${JSON.stringify(kindTally(selected))} from ${results.length} candidates (chatter cap ${ranking.chatterQuota}, composite ${selected.at(-1)?._composite.toFixed(2)}–${selected[0]?._composite.toFixed(2)})`);
225
232
  }
226
233
  }
227
234
  else {
@@ -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