@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 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,19 @@ 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
+ | `coverageLog` | boolean | `false` | **Opt-in.** Append a local-only JSONL event whenever auto-context finds no relevant memories, so capture gaps can be told apart from ranking misses. Events include prompt text — see [Coverage log](#coverage-log). |
153
+ | `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
154
  | `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
155
 
156
+ 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).
157
+
142
158
  ## Slash Commands
143
159
 
144
160
  ### `/getcontext <query>`
@@ -188,18 +204,25 @@ When `syncMemories: true`, the plugin syncs markdown files from your agent's wor
188
204
  - Startup sync runs in the **background** — the agent boots immediately and sync proceeds without blocking it
189
205
  - 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
206
 
207
+ **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.
208
+
191
209
  **Tuning sync (object form):**
192
210
 
193
211
  ```jsonc
194
212
  "syncMemories": {
195
213
  "enabled": true,
196
214
  "sectionize": true, // split files on ## headings into separate memories
197
- "watchPaths": [], // extra files/dirs to sync beyond memory/
215
+ "watchPaths": [ // extra files/dirs to sync beyond memory/
216
+ "notes", // plain path — tagged with slug "notes"
217
+ { "path": "notes/brainstem", "source": "brainstem_daily" } // labeled — tagged openclaw_sync_source: "brainstem_daily"
218
+ ],
198
219
  "debounceMs": 2000, // wait for writes to settle before syncing
199
220
  "maxAgeDays": 30 // startup skips already-synced files older than this
200
221
  }
201
222
  ```
202
223
 
224
+ `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.
225
+
203
226
  `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
227
 
205
228
  **Example frontmatter after sync:**
@@ -232,6 +255,164 @@ When `autoContext: true` (default), the plugin automatically:
232
255
 
233
256
  This ensures the AI always has access to relevant information from your connected sources.
234
257
 
258
+ ### Composite ranking — surfacing your active work
259
+
260
+ Raw semantic relevance rewards *frequency*: a phrase repeated across a hundred
261
+ auto-saved conversation fragments looks "most similar" to everything and buries
262
+ quieter, truer memory — like the manuscript you're actually writing. When
263
+ `ranking.enabled` is on (default), auto-context re-scores candidates:
264
+
265
+ ```
266
+ composite = relevance
267
+ + curationBoost (a memory you deliberately kept: journals, notes, synced files)
268
+ + storyBoost (your active story/manuscript — matched via storyTerms)
269
+ − chatterPenalty (an auto-saved conversation fragment)
270
+ ```
271
+
272
+ Chatter is additionally capped at `chatterQuota` results per injection,
273
+ regardless of score.
274
+
275
+ Provenance is a signal too: an optional `sourceWeights` map multiplies a
276
+ result's **base relevance** (before the kind boosts/penalties are added) by a
277
+ per-source factor, so "a journaled Notion page is more intentional memory than
278
+ a titled Slack aside on the same topic" is expressible. Any source not listed
279
+ — including sources that don't exist yet — is neutral (`1.0`); the shipped
280
+ default is `{}`, a strict no-op. It weights sources already in the result set;
281
+ to *exclude* a source, use the `sources` filter instead (weights of 0 are
282
+ rejected at load with exactly that pointer). Suggested starting points, priors
283
+ to seed your own tuning rather than measured truth: authored systems slightly
284
+ up (`notion` 1.15, drive/box/dropbox 1.1), conversational exhaust slightly
285
+ down (`slack`/`microsoft_teams` 0.85, `trace` 0.8) — magnitudes small enough
286
+ to break ties, never to override a real relevance gap. Typos in weight keys
287
+ are not schema errors (new backend sources must not fail validation); with
288
+ `debug: true` unknown keys are flagged once at startup in `gateway.log`.
289
+
290
+ Selected results are also checked against each other for **near-duplicates**:
291
+ when the same memory exists in several forms (a re-synced doc section, a
292
+ `remember` note quoting it, a curated copy of a hot-buffer row), every copy
293
+ clears the threshold, none is chatter, and pre-dedup they filled multiple
294
+ injection slots with one piece of information. Each candidate's lead text is
295
+ compared against already-selected results by token overlap; above
296
+ `dedupThreshold` (0.8) the copy is skipped and its slot passes to the
297
+ next-ranked *different* memory. Skipped copies never consume the chatter
298
+ quota. Set `dedupThreshold: 0` to disable. Paraphrased duplicates (same fact,
299
+ different words) are beyond a string measure and out of scope.
300
+
301
+ Within each selected memory, the second excerpt earns its place: a result
302
+ renders up to two highlight bullets, and the runner-up rides along only when
303
+ its score is within 0.15 of the top one's — a .95/.40 pair renders one bullet
304
+ (the distant second is usually a weaker paraphrase), a .95/.85 pair renders
305
+ both. The top highlight is always kept, so a selected memory never disappears
306
+ from the injection because of this rule.
307
+
308
+ An optional **elbow cutoff** (`ranking.elbow`, **off by default**) stops
309
+ injecting at a natural score cliff instead of always filling `maxResults`: a
310
+ narrow query backed by 3 genuinely relevant memories no longer pads the
311
+ context with a plateau of marginal ones that happen to clear the threshold.
312
+ It is strictly conservative — it only ever cuts *earlier*, never later, never
313
+ below `minResults` (default 3), and when no clear cliff exists (gradual
314
+ decline, flat plateau) selection is identical to today. A cliff means the
315
+ drop from the last accepted result is both large against the decline seen so
316
+ far (`gapRatio` × mean gap, default 2.5×) *and* material in absolute terms
317
+ (`minGap`, default 0.05). Before enabling, run
318
+ `node --experimental-strip-types docs/elbow-scan.mjs` against your real data
319
+ to check firing rate and cut depth (with `debug: true` the live verdict shows
320
+ in `gateway.log` as `auto-context: elbow stopped at k (ceiling N)`).
321
+
322
+ Age matters too, gently: results accrue a small **recency penalty** that grows
323
+ with age (exponential decay, 90-day half-life by default) and is hard-capped at
324
+ `recencyMaxPenalty` (0.1) so it can break near-ties toward current information
325
+ but never bury a strong old match. Deliberately-kept memory (curated/story)
326
+ ages at half rate (`recencyCuratedFactor`) — an old truth you chose to keep
327
+ still beats a fresh shallow echo. Results without a timestamp are never
328
+ penalized. Set `recencyHalfLifeDays: 0` to disable.
329
+
330
+ **`storyBoost` does nothing until you set `storyTerms`.** The default is `[]`,
331
+ so no result ever classifies as "story". Set it to the distinctive proper nouns
332
+ of your active work — the title, character names, a project codename, invented
333
+ terminology:
334
+
335
+ ```jsonc
336
+ "config": {
337
+ "ranking": {
338
+ "storyTerms": ["lighthouse keeper", "mira", "the shoal chapters"]
339
+ }
340
+ }
341
+ ```
342
+
343
+ For example: if you're writing a novel called *The Lighthouse Keeper* with a
344
+ protagonist named Mira, the config above makes any memory whose **title or
345
+ highlight excerpt** contains those terms rank as "story" — it gets
346
+ `storyBoost + curationBoost` (+0.35 by default) and is exempt from the chatter
347
+ cap. If you sync the manuscript via `syncMemories` with `sectionize: true`,
348
+ every section is titled `The Lighthouse Keeper — <section>`, so the title term
349
+ alone catches the whole manuscript.
350
+
351
+ **How matching works:** terms are matched case-insensitively at **word
352
+ boundaries** against the result's title and highlight excerpts. `"mira"`
353
+ matches "Mira", "Mira's", and "mira-class", but **not** "ad**mira**l" or
354
+ "**mira**cle" — a short name can't false-positive inside longer words.
355
+ Multi-word phrases (`"lighthouse keeper"`) match the same way. There is no
356
+ prefix/stem matching: to catch inflected or partial forms, add each full form
357
+ as its own term.
358
+
359
+ Tips:
360
+
361
+ - **Use 3–15 distinctive terms.** Every term is checked per result per search;
362
+ more terms means more false-positive surface, not more recall. Never add a
363
+ word that appears in unrelated conversation ("book", "chapter", "draft").
364
+ - Terms are normalized on load — trimmed, lowercased, deduplicated — so casing
365
+ and stray whitespace in your config don't matter.
366
+ - **Update the list when the active story changes.** A stale term is worse than
367
+ a missing one: it keeps granting the boost to a dead thread's echoes.
368
+ - Ranking (including `storyTerms`) applies only to **auto-context** injection.
369
+ The `hyperspell_search` tool and `/getcontext` return raw relevance order —
370
+ don't test `storyTerms` there and conclude it's broken.
371
+ - To verify it's working, enable `debug: true` and watch `gateway.log` for the
372
+ per-search tally (`auto-context: ranked {...} candidates → selected {...}`) and the cut tally (threshold / max-results / elbow / near-duplicate / chatter-quota) —
373
+ it appears at default host log levels. The per-candidate lines
374
+ (`[story] 0.47→0.82 The Lighthouse Keeper — Chapter 3`), which show story
375
+ matches even when they lose to the threshold, are debug-level and also need
376
+ host debug logging.
377
+
378
+ Full knobs and defaults:
379
+
380
+ ```jsonc
381
+ "ranking": {
382
+ "enabled": true,
383
+ "curationBoost": 0.2, // lift for deliberately-kept memory
384
+ "chatterPenalty": 0.2, // penalty for auto-saved conversation fragments
385
+ "storyBoost": 0.15, // extra lift for storyTerms matches (stacks with curationBoost)
386
+ "storyTerms": [], // REQUIRED for storyBoost to do anything
387
+ "candidateMultiplier": 3, // fetch this × maxResults candidates before re-ranking
388
+ "chatterQuota": 2, // hard cap on chatter results per injection
389
+ "recencyHalfLifeDays": 90, // age at which half the recency penalty has accrued (0 = off)
390
+ "recencyMaxPenalty": 0.1, // ceiling on the recency penalty — a tiebreaker, never a burial
391
+ "recencyCuratedFactor": 0.5, // kept memory (curated/story) ages at this fraction of the rate
392
+ "sourceWeights": {}, // per-source multiplier on base relevance; unlisted = 1.0
393
+ "dedupThreshold": 0.8, // token-overlap ratio that skips a near-duplicate (0 = off)
394
+ "elbow": { // stop early at a natural score cliff (opt-in)
395
+ "enabled": false,
396
+ "minResults": 3, // never cut below this many accepted results
397
+ "gapRatio": 2.5, // cliff = drop >= this multiple of the mean gap so far...
398
+ "minGap": 0.05 // ...AND at least this big on the raw composite scale
399
+ }
400
+ }
401
+ ```
402
+
403
+ ### Coverage log
404
+
405
+ No ranking tweak can surface a memory that was never captured — and by default nothing distinguishes "ranking failed" from "it was never stored." With `coverageLog: true`, every auto-context turn that injects **no** memory sections appends one line to `<workspaceDir>/.hyperspell-coverage.jsonl`:
406
+
407
+ - `outcome: "empty"` — the search succeeded but returned zero candidates (a capture question: was this ever stored?)
408
+ - `outcome: "below_threshold"` — candidates existed but none cleared `relevanceThreshold`; `topScore` vs `threshold` says how near the miss was (a ranking question)
409
+
410
+ Failed searches never produce events — backend-unavailable is not "no memories." In multi-user mode there is one event per turn with per-lane detail, and a lane whose search failed is recorded as `status: "error"`, never as zero candidates.
411
+
412
+ **Local-only guarantee:** the log is written only to the workspace directory and is never sent to Hyperspell or anywhere remote. Because each event carries the triggering prompt (truncated to 500 chars), the feature is **off by default** — prompt text reaches disk only if you explicitly opt in. The file is capped at 5 MB with one `.old` rotation generation (~10 MB total), so content ages out instead of accumulating.
413
+
414
+ Review with `jq`, e.g.: `jq -r '[.ts, .outcome, .topScore, .prompt] | @tsv' ~/.openclaw/workspace/.hyperspell-coverage.jsonl` — after a week or two of labeling (capture gap / ranking near-miss / correct absence), the tallies say whether the next investment belongs in capture or ranking. Delete the file when done.
415
+
235
416
  ### `excludeChannels` is forward-only
236
417
 
237
418
  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 +457,84 @@ The purge command prints these standing limitations on every run.
276
457
  ### Other
277
458
  - `web_crawler` - Crawled web pages
278
459
 
279
- ## Knowledge Graph
460
+ ## Memory Network
461
+
462
+ The Memory Network (config key: `knowledgeGraph`) automatically builds a local knowledge
463
+ graph from your memories. A periodic cron job runs in an isolated session and:
464
+
465
+ 1. **Scans** unprocessed memories (`hyperspell_network_scan` / `network scan`)
466
+ 2. **Extracts** people, projects, organizations, and topics with relationships
467
+ 3. **Writes** one markdown file per entity into `memory/people/`, `memory/projects/`,
468
+ `memory/organizations/`, `memory/topics/` (`hyperspell_network_write`)
469
+ 4. **Marks** the batch processed so it is never re-scanned (`hyperspell_network_complete`;
470
+ state lives in `memory/.network-state.json`)
471
+ 5. **Syncs** the entity files back to Hyperspell so they are searchable (`network sync`)
472
+
473
+ If you also maintain a hand-written people/projects reference file, this feature
474
+ automates most of that by hand-off — see
475
+ [Migrating from a hand-maintained archive](docs/memory-network-migration.md) for a safe
476
+ dry-run and comparison procedure before adopting it.
477
+
478
+ ### Enabling it
479
+
480
+ The easiest path is the setup wizard, which flips the config, writes the extraction
481
+ prompt to `<workspace>/HYPERSPELL-MEMORY-NETWORK.md`, and creates the extraction cron
482
+ (interval from `knowledgeGraph.scanIntervalMinutes`, default 60 minutes):
483
+
484
+ ```bash
485
+ openclaw openclaw-hyperspell setup # answer "yes" at the Memory Network step
486
+ ```
487
+
488
+ Or manually: set the plugin config and create the cron yourself:
489
+
490
+ ```json
491
+ "knowledgeGraph": { "enabled": true }
492
+ ```
493
+
494
+ ```bash
495
+ openclaw cron add --name "Hyperspell Memory Network" --every 60m --session isolated \
496
+ --message "Read the file at <workspace>/HYPERSPELL-MEMORY-NETWORK.md and follow the instructions inside it."
497
+ ```
498
+
499
+ The `hyperspell_network_*` tools only register when `knowledgeGraph.enabled` is true.
500
+
501
+ ### What an extracted entity file looks like
502
+
503
+ `memory/people/alice-chen.md`:
504
+
505
+ ```markdown
506
+ ---
507
+ title: Alice Chen
508
+ type: person
509
+ graph_entity: true
510
+ email: alice@hyperspell.com
511
+ source_memories: {"slack":["C073WR69EPM"],"google_mail":["19bbe68026553623"]}
512
+ relationships: ["works-at:organizations/hyperspell","leads:projects/memory-network"]
513
+ last_extracted: 2026-07-07T12:00:00Z
514
+ ---
515
+ # Alice Chen
516
+
517
+ Engineering Manager at Hyperspell. Leads the Memory Network project.
518
+
519
+ ## Contact
280
520
 
281
- The plugin can automatically build a local knowledge graph from your memories:
521
+ - Email: alice@hyperspell.com
522
+
523
+ ## Relationships
524
+
525
+ - works-at: [hyperspell](../organizations/hyperspell.md)
526
+ - leads: [memory network](../projects/memory-network.md)
527
+ ```
282
528
 
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
529
+ Re-extraction **merges**: existing `source_memories`, `relationships`, and
530
+ `hyperspell_id` are preserved and unioned, so files are safe to hand-edit between runs.
287
531
 
288
- Enable the graph tools by using `hyperspell_network_scan`, `hyperspell_network_write`, and `hyperspell_network_complete` in your agent workflows.
532
+ Re-extraction is idempotent by design (the cron agent re-derives the same entities and
533
+ marks rows complete). A previously known gap meant entity files synced back to Hyperspell
534
+ re-entered future scans as unprocessed source memories; the fix lands alongside
535
+ [`proposal/06-knowledge-graph-enablement`](docs/proposals/06-knowledge-graph-enablement.md)
536
+ (PR #99), which propagates the `graph_entity` frontmatter into sync metadata and makes
537
+ the scanner skip memories synced from the entity directories.
289
538
 
290
539
  ---
291
540
 
@@ -303,7 +552,8 @@ Enable the graph tools by using `hyperspell_network_scan`, `hyperspell_network_w
303
552
 
304
553
  ### Auto-context not injecting
305
554
  - Verify `autoContext: true` in your config
306
- - Enable `debug: true` to see what queries are being made
555
+ - Enable `debug: true` the ranked/cut/injection summary lines land in
556
+ `gateway.log` at default host log levels
307
557
  - Check that you have memories matching your conversation topics
308
558
 
309
559
  ### 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;
@@ -293,6 +293,7 @@ async function runSetup() {
293
293
  maxResults: 10,
294
294
  relevanceThreshold: 0.6,
295
295
  ranking: DEFAULT_RANKING,
296
+ coverageLog: false,
296
297
  debug: false,
297
298
  knowledgeGraph: { enabled: false, scanIntervalMinutes: 60, batchSize: 20 },
298
299
  startupOrientation: {
@@ -324,12 +325,18 @@ async function runSetup() {
324
325
  // Step 8: Memory Network setup
325
326
  p.note("The Memory Network automatically extracts entities (people, projects,\n" +
326
327
  "organizations, topics) from your memories into structured markdown\n" +
327
- "files. This runs as a periodic cron job in the main session.", "Memory Network");
328
+ "files. This runs as a periodic cron job in an isolated session.", "Memory Network");
328
329
  const enableNetwork = await p.confirm({
329
330
  message: "Enable the Memory Network?",
330
331
  initialValue: false,
331
332
  });
332
333
  if (!p.isCancel(enableNetwork) && enableNetwork) {
334
+ // The wizard is the only place the extraction cron is created, so the
335
+ // config knob and the cron are born in agreement: one interval feeds both
336
+ // `--every` and the persisted `scanIntervalMinutes`. Runtime never
337
+ // reschedules — to change cadence later, edit the cron job (and keep the
338
+ // config field in sync so it stays an honest record of the cron).
339
+ const scanIntervalMinutes = 60;
333
340
  // Update config to enable knowledgeGraph
334
341
  try {
335
342
  const configPath = resolveConfigPath();
@@ -338,7 +345,7 @@ async function runSetup() {
338
345
  const config = JSON.parse(configContent);
339
346
  const pluginEntry = config?.plugins?.entries?.["openclaw-hyperspell"]?.config;
340
347
  if (pluginEntry) {
341
- pluginEntry.knowledgeGraph = { enabled: true };
348
+ pluginEntry.knowledgeGraph = { enabled: true, scanIntervalMinutes };
342
349
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
343
350
  }
344
351
  }
@@ -360,7 +367,7 @@ async function runSetup() {
360
367
  const output = execFileSync("openclaw", [
361
368
  "cron", "add",
362
369
  "--name", CRON_JOB_NAME,
363
- "--every", "1h",
370
+ "--every", `${scanIntervalMinutes}m`,
364
371
  "--session", "isolated",
365
372
  "--message", `Read the file at ${promptPath} and follow the instructions inside it.`,
366
373
  ], { stdio: ["pipe", "pipe", "pipe"], timeout: 10_000 });
@@ -375,14 +382,14 @@ async function runSetup() {
375
382
  }
376
383
  catch { }
377
384
  }
378
- s4.stop("Cron job created — Memory Network will scan every hour");
385
+ s4.stop(`Cron job created — Memory Network will scan every ${scanIntervalMinutes} minutes`);
379
386
  }
380
387
  catch (cronErr) {
381
388
  s4.stop("Could not create cron job automatically");
382
389
  p.log.warn(`Cron creation failed. Create it manually:`);
383
390
  p.note(`openclaw cron add \\\n` +
384
391
  ` --name "${CRON_JOB_NAME}" \\\n` +
385
- ` --every 1h \\\n` +
392
+ ` --every ${scanIntervalMinutes}m \\\n` +
386
393
  ` --session isolated \\\n` +
387
394
  ` --message "Read the file at ${promptPath} and follow the instructions inside it."`, "Manual cron setup");
388
395
  }
@@ -588,7 +595,10 @@ export function registerCliCommands(program, pluginConfig) {
588
595
  const workspaceDir = getWorkspaceDir();
589
596
  const stateManager = new NetworkStateManager(workspaceDir);
590
597
  const batchSize = Number.parseInt(opts.batchSize, 10) || 20;
591
- const memories = await scanMemories(client, stateManager, batchSize);
598
+ // cfg drives the multiUser fan-out inside scanMemories; omitting it
599
+ // made the CLI scan the default user only, silently skipping every
600
+ // mapped user's memories on multiUser installs.
601
+ const memories = await scanMemories(client, stateManager, batchSize, cfg);
592
602
  const text = formatScanResults(memories, stateManager.getProcessedCount(), stateManager.getLastScanAt());
593
603
  process.stdout.write(text + "\n");
594
604
  }
@@ -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
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
- import { DEFAULT_RANKING } from "./lib/ranking.js";
4
+ import { DEFAULT_ELBOW, DEFAULT_RANKING, } from "./lib/ranking.js";
5
5
  /**
6
6
  * Convert user-facing scope names (which may contain hyphens) to SDK-safe
7
7
  * metadata values (alphanumeric + underscore only). Must be applied at every
@@ -27,12 +27,13 @@ const ALLOWED_KEYS = [
27
27
  "maxResults",
28
28
  "relevanceThreshold",
29
29
  "ranking",
30
+ "coverageLog",
30
31
  "debug",
31
32
  "knowledgeGraph",
32
33
  "multiUser",
33
34
  "dreaming",
34
35
  ];
35
- const VALID_SOURCES = [
36
+ export const VALID_SOURCES = [
36
37
  "reddit",
37
38
  "notion",
38
39
  "slack",
@@ -62,6 +63,60 @@ function resolveEnvVars(value) {
62
63
  return envValue;
63
64
  });
64
65
  }
66
+ /**
67
+ * Normalize `syncMemories.watchPaths` entries: the shipped/documented string
68
+ * form stays valid and is additive-upgraded to `{ path }`; the object form
69
+ * carries an optional provenance `source` label. Labels are sanitized with the
70
+ * same character rule as normalizeScope — metadata values must be alphanumeric
71
+ * + underscore or Hyperspell metadata filters silently miss.
72
+ */
73
+ function parseWatchPaths(raw) {
74
+ if (!Array.isArray(raw))
75
+ return [];
76
+ return raw.map((wp) => {
77
+ if (typeof wp === "string") {
78
+ if (!wp.trim()) {
79
+ throw new Error("hyperspell.syncMemories.watchPaths[] entry needs a non-empty path");
80
+ }
81
+ return { path: wp };
82
+ }
83
+ if (typeof wp !== "object" || wp === null || Array.isArray(wp)) {
84
+ throw new Error("hyperspell.syncMemories.watchPaths[] entries must be a string or { path, source? }");
85
+ }
86
+ const entry = wp;
87
+ assertAllowedKeys(entry, ["path", "source"], "hyperspell.syncMemories.watchPaths[]");
88
+ if (typeof entry.path !== "string" || !entry.path.trim()) {
89
+ throw new Error("hyperspell.syncMemories.watchPaths[] entry needs a non-empty path");
90
+ }
91
+ if (!entry.source)
92
+ return { path: entry.path };
93
+ return {
94
+ path: entry.path,
95
+ source: String(entry.source).replace(/[^a-zA-Z0-9_]/g, "_"),
96
+ };
97
+ });
98
+ }
99
+ /** Per-source multipliers: non-numeric values silently skip (num()'s
100
+ * convention); explicit non-positive values throw (a 0 weight is a filtering
101
+ * decision wearing a weighting costume — the sources filter is the right
102
+ * tool, and the error says so). Keys lowercase but are NOT validated against
103
+ * VALID_SOURCES: the backend adds sources independently of plugin releases,
104
+ * and an unmatched key is a harmless neutral no-op (startup diag logs it). */
105
+ function parseSourceWeights(raw) {
106
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
107
+ return {};
108
+ const out = {};
109
+ for (const [k, v] of Object.entries(raw)) {
110
+ if (typeof v !== "number")
111
+ continue;
112
+ if (!Number.isFinite(v) || v <= 0) {
113
+ throw new Error(`ranking.sourceWeights.${k} must be a positive number (got ${v}); ` +
114
+ `to exclude a source entirely, use the "sources" filter instead`);
115
+ }
116
+ out[k.trim().toLowerCase()] = v;
117
+ }
118
+ return out;
119
+ }
65
120
  function parseRanking(raw) {
66
121
  const r = (raw ?? {});
67
122
  const num = (v, d) => (typeof v === "number" ? v : d);
@@ -70,11 +125,37 @@ function parseRanking(raw) {
70
125
  curationBoost: num(r.curationBoost, DEFAULT_RANKING.curationBoost),
71
126
  chatterPenalty: num(r.chatterPenalty, DEFAULT_RANKING.chatterPenalty),
72
127
  storyBoost: num(r.storyBoost, DEFAULT_RANKING.storyBoost),
128
+ // Trim + lowercase + dedupe, drop whitespace-only entries. Before this a
129
+ // stray " " entry passed the length filter and (under the old substring
130
+ // matcher) classified EVERY result as story, silently disabling the
131
+ // chatter penalty and quota (issue #82's footgun).
73
132
  storyTerms: Array.isArray(r.storyTerms)
74
- ? r.storyTerms.map((t) => String(t)).filter((t) => t.length > 0)
133
+ ? [
134
+ ...new Set(r.storyTerms
135
+ .map((t) => String(t).trim().toLowerCase())
136
+ .filter((t) => t.length > 0)),
137
+ ]
75
138
  : DEFAULT_RANKING.storyTerms,
76
139
  candidateMultiplier: Math.max(1, num(r.candidateMultiplier, DEFAULT_RANKING.candidateMultiplier)),
77
140
  chatterQuota: Math.max(0, num(r.chatterQuota, DEFAULT_RANKING.chatterQuota)),
141
+ recencyHalfLifeDays: Math.max(0, num(r.recencyHalfLifeDays, DEFAULT_RANKING.recencyHalfLifeDays)),
142
+ recencyMaxPenalty: Math.max(0, num(r.recencyMaxPenalty, DEFAULT_RANKING.recencyMaxPenalty)),
143
+ recencyCuratedFactor: Math.min(1, Math.max(0, num(r.recencyCuratedFactor, DEFAULT_RANKING.recencyCuratedFactor))),
144
+ sourceWeights: parseSourceWeights(r.sourceWeights),
145
+ dedupThreshold: Math.min(1, Math.max(0, num(r.dedupThreshold, DEFAULT_RANKING.dedupThreshold))),
146
+ elbow: parseElbow(r.elbow),
147
+ };
148
+ }
149
+ function parseElbow(raw) {
150
+ const e = (raw ?? {});
151
+ const num = (v, d) => (typeof v === "number" ? v : d);
152
+ return {
153
+ enabled: e.enabled ?? DEFAULT_ELBOW.enabled,
154
+ // >= 2: at least one gap must exist before meanGap is defined (the
155
+ // first accepted result never records a gap).
156
+ minResults: Math.max(2, num(e.minResults, DEFAULT_ELBOW.minResults)),
157
+ gapRatio: Math.max(1, num(e.gapRatio, DEFAULT_ELBOW.gapRatio)),
158
+ minGap: Math.max(0, num(e.minGap, DEFAULT_ELBOW.minGap)),
78
159
  };
79
160
  }
80
161
  function parseSources(raw) {
@@ -324,9 +405,7 @@ export function parseConfig(raw) {
324
405
  syncMemoriesConfig: {
325
406
  enabled: syncMemoriesEnabled,
326
407
  sectionize: smObj.sectionize ?? true,
327
- watchPaths: Array.isArray(smObj.watchPaths)
328
- ? smObj.watchPaths
329
- : [],
408
+ watchPaths: parseWatchPaths(smObj.watchPaths),
330
409
  debounceMs: smObj.debounceMs ?? 2000,
331
410
  maxAgeDays: smObj.maxAgeDays ?? 30,
332
411
  ignorePaths: Array.isArray(smObj.ignorePaths)
@@ -337,6 +416,10 @@ export function parseConfig(raw) {
337
416
  maxResults: cfg.maxResults ?? 10,
338
417
  relevanceThreshold: cfg.relevanceThreshold ?? 0.6,
339
418
  ranking: parseRanking(cfg.ranking),
419
+ // Default OFF (deviates from proposal 15 §6 by maintainer decision):
420
+ // coverage events carry prompt text, so nothing is written to disk
421
+ // unless the operator explicitly opts in.
422
+ coverageLog: cfg.coverageLog ?? false,
340
423
  debug: cfg.debug ?? false,
341
424
  knowledgeGraph: {
342
425
  enabled: kgRaw.enabled ?? false,