@hyperspell/openclaw-hyperspell 0.18.1 → 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
@@ -102,6 +102,34 @@ Check your current configuration and connection status.
102
102
 
103
103
  Open the Hyperspell connect page to link your accounts (Notion, Slack, Google Drive, etc.). After connecting, your sources are automatically updated in the config.
104
104
 
105
+ ### `openclaw openclaw-hyperspell purge-channel <channelId>`
106
+
107
+ Find — and with `--yes`, delete — memories that were synced from a specific conversation/channel. Use it for retroactive cleanup after adding a channel to `excludeChannels`, which is [forward-only](#excludechannels-is-forward-only).
108
+
109
+ ```
110
+ openclaw openclaw-hyperspell purge-channel 1521620672726438171 # dry run: list what would be deleted
111
+ openclaw openclaw-hyperspell purge-channel 1521620672726438171 --yes # actually delete
112
+ ```
113
+
114
+ | Flag | Description |
115
+ |------|-------------|
116
+ | `--source <sources>` | Comma-separated Hyperspell sources to scan. Default `vault` — the hot-buffer consolidation target and default `hotBuffer.source`. |
117
+ | `--user <userId>` | `X-As-User` to scan/delete under. Default: the configured `userId`. In `multiUser` deployments, run once per mapped userId. |
118
+ | `--session <ids>` | Comma-separated OpenClaw session ids — matches legacy **untagged** hot-buffer resources through the `resource_id === session id` identity. |
119
+ | `--resource <ids>` | Explicit resource ids to delete (escape hatch for traces or `/remember` memories you identified manually, e.g. via `hyperspell_search`). |
120
+ | `--yes` | Actually delete. Without it the command is a **dry run** that prints the matches and exits. |
121
+
122
+ Matching uses the same semantics as the quarantine check itself: exact id or thread suffix (`<channelId>:...`), case-insensitive. Discovery enumerates memories and filters client-side on the `openclaw_channel_id` metadata tag — deleted rows are reported per resource, and a resource that is already gone counts as deleted.
123
+
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
+
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
+
105
133
  ## Configuration Options
106
134
 
107
135
  | Option | Type | Default | Description |
@@ -109,12 +137,23 @@ Open the Hyperspell connect page to link your accounts (Notion, Slack, Google Dr
109
137
  | `apiKey` | string | `${HYPERSPELL_API_KEY}` | Hyperspell API key |
110
138
  | `userId` | string | - | User ID for multi-tenant memory (can be your email) |
111
139
  | `autoContext` | boolean | `true` | Auto-inject relevant memories before each AI turn |
140
+ | `emotionalContext` | boolean | `false` | Persist an emotional-state register at session end and inject the recent arc at session start |
141
+ | `moodWeatherChance` | number | `0` | Probability (0–1) that a fresh session rolls exogenous "mood weather". `0` disables. Suggested starting value: `0.03`–`0.05` — rare enough to read as weather, not a gimmick. Requires `emotionalContext`. |
112
142
  | `syncMemories` | boolean | `false` | Sync markdown files in `workspace/memory/` to Hyperspell |
113
143
  | `sources` | string | - | Comma-separated sources to search (e.g., `vault,notion,slack`) |
114
144
  | `maxResults` | number | `10` | Maximum memories per context injection |
115
- | `debug` | boolean | `false` | Enable verbose logging |
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) |
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). |
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. |
116
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). |
117
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
+
118
157
  ## Slash Commands
119
158
 
120
159
  ### `/getcontext <query>`
@@ -141,6 +180,16 @@ Manually sync all markdown files in `workspace/memory/` to Hyperspell.
141
180
  /sync
142
181
  ```
143
182
 
183
+ ### `/previewcontext`
184
+
185
+ Show exactly what Hyperspell would inject at the start of the next session — the emotional-context arc, the auto-context setting, and the startup-orientation blocks — without starting a session or touching any session state. Read-only and idempotent: run it twice and you get the same report.
186
+
187
+ Mood weather is shown as its configured chance only (e.g. "configured chance 8% per session") and is **never rolled by the preview** — each real session rolls independently at injection time, so the actual mood (if any) is only observable in the live session.
188
+
189
+ ```
190
+ /previewcontext
191
+ ```
192
+
144
193
  ## Memory Sync
145
194
 
146
195
  When `syncMemories: true`, the plugin syncs markdown files from your agent's workspace memory directory (e.g., `~/.openclaw/workspace/memory/`) to Hyperspell. This includes all `.md` files in subdirectories.
@@ -154,18 +203,25 @@ When `syncMemories: true`, the plugin syncs markdown files from your agent's wor
154
203
  - Startup sync runs in the **background** — the agent boots immediately and sync proceeds without blocking it
155
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)
156
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
+
157
208
  **Tuning sync (object form):**
158
209
 
159
210
  ```jsonc
160
211
  "syncMemories": {
161
212
  "enabled": true,
162
213
  "sectionize": true, // split files on ## headings into separate memories
163
- "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
+ ],
164
218
  "debounceMs": 2000, // wait for writes to settle before syncing
165
219
  "maxAgeDays": 30 // startup skips already-synced files older than this
166
220
  }
167
221
  ```
168
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
+
169
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.
170
226
 
171
227
  **Example frontmatter after sync:**
@@ -186,6 +242,7 @@ The plugin registers tools that the AI can use autonomously:
186
242
 
187
243
  - **hyperspell_search** - Search through connected sources
188
244
  - **hyperspell_remember** - Save information to memory
245
+ - **hyperspell_emotional_arc** - Re-fetch the recent emotional arc mid-conversation (requires `emotionalContext: true`); returns the same block injected at session start, e.g. after compaction removed it
189
246
 
190
247
  ## Auto-Context
191
248
 
@@ -197,6 +254,96 @@ When `autoContext: true` (default), the plugin automatically:
197
254
 
198
255
  This ensures the AI always has access to relevant information from your connected sources.
199
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
+
336
+ ### `excludeChannels` is forward-only
337
+
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.
339
+
340
+ - **Hot-buffer content** written by plugin versions since 2026-07 is tagged with `openclaw_channel_id` and can be removed with [`openclaw openclaw-hyperspell purge-channel <id>`](#openclaw-openclaw-hyperspell-purge-channel-channelid). Older hot-buffer rows are untagged, but each row's resource id equals its OpenClaw session id, so they are reachable via the purge command's `--session` flag.
341
+ - **Auto-trace resources and `/remember` memories** written by current versions carry the same `openclaw_channel_id` tag going forward. Ones written by older versions are not channel-tagged and cannot be automatically purged — identify them manually (e.g. via `hyperspell_search`) and use the purge command's `--resource` escape hatch.
342
+ - **Emotional-state registers** are keyed by relationship, not channel, and live behind the `/emotional-state` API rather than the memories store this command scans — they are never matched by `purge-channel`, and deletion of them is per-relationship, all-or-nothing. (Newer plugin versions tag registers with a `channelId` metadata field for analysis, but that does not make them purgeable per channel.)
343
+ - **Server-side extractions** derived from traces (`extract: ["procedure", "memory", "mood"]`) are created backend-side; whether deleting the parent trace removes them is an open backend question (see `docs/hyperspell-backend-followups.md`).
344
+
345
+ The purge command prints these standing limitations on every run.
346
+
200
347
  ## Available Sources
201
348
 
202
349
  ### Documents & Storage
@@ -230,16 +377,84 @@ This ensures the AI always has access to relevant information from your connecte
230
377
  ### Other
231
378
  - `web_crawler` - Crawled web pages
232
379
 
233
- ## 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`)
392
+
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
+ ```
234
418
 
235
- The plugin can automatically build a local knowledge graph from your memories:
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
+ ```
236
448
 
237
- 1. **Scan** memories for entities (people, organizations, projects, topics)
238
- 2. **Extract** structured information and relationships
239
- 3. **Write** entity files to `memory/people/`, `memory/organizations/`, etc.
240
- 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.
241
451
 
242
- 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.
243
458
 
244
459
  ---
245
460
 
@@ -257,7 +472,8 @@ Enable the graph tools by using `hyperspell_network_scan`, `hyperspell_network_w
257
472
 
258
473
  ### Auto-context not injecting
259
474
  - Verify `autoContext: true` in your config
260
- - 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
261
477
  - Check that you have memories matching your conversation topics
262
478
 
263
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;
@@ -0,0 +1,93 @@
1
+ import { buildEmotionalContext, fetchRecentOrLatest, looksLikeRawTranscript, } from "../hooks/emotional-state.js";
2
+ import { gatherOrientation, personalUserId } from "../hooks/startup-orientation.js";
3
+ import { isExcludedChannel } from "../lib/exclude-channels.js";
4
+ import { log } from "../logger.js";
5
+ /**
6
+ * Assemble a read-only report of what before_agent_start WOULD inject at the
7
+ * start of the next session. Calls only the pure fetch/format functions the
8
+ * real hooks are composed from — never the hook handlers themselves — so it
9
+ * cannot touch the inject-once session caches, and it never calls rollMood
10
+ * (a debug command must not consume or reveal a mood roll; the roll happens
11
+ * once, in the real injection path, per session).
12
+ */
13
+ export async function buildPreviewReport(client, cfg, ctx) {
14
+ // Quarantined channels get no injected memory of any kind (index.ts guards
15
+ // before_agent_start with the same check) — report that instead of a bundle.
16
+ if (isExcludedChannel({ channelId: ctx.channel }, cfg)) {
17
+ return "This channel is quarantined (excludeChannels): no context would be injected here and no memory would be written.";
18
+ }
19
+ const sections = [];
20
+ // ---- 1. Emotional context (registration order in index.ts: first) ----
21
+ if (!cfg.emotionalContext) {
22
+ sections.push("Emotional context: OFF (emotionalContext=false) — no register/arc would be injected.");
23
+ }
24
+ else {
25
+ try {
26
+ const states = await fetchRecentOrLatest(client, cfg);
27
+ const usable = states.filter((s) => s.summary && !looksLikeRawTranscript(s.summary));
28
+ if (usable.length > 0) {
29
+ const pending = states.length - usable.length;
30
+ const pendingNote = pending > 0
31
+ ? `\n(${pending} more register(s) still extracting — excluded, same as the real hook.)`
32
+ : "";
33
+ sections.push(`Emotional context: would inject ${usable.length} register(s):\n\n${buildEmotionalContext(usable)}${pendingNote}`);
34
+ }
35
+ else if (states.length > 0) {
36
+ sections.push("Emotional context: register(s) exist but are still extracting (raw-transcript placeholder) — the real hook would skip this turn and retry next turn.");
37
+ }
38
+ else {
39
+ sections.push("Emotional context: ON, but no prior emotional state found — nothing to inject yet (first conversation, or register deleted).");
40
+ }
41
+ }
42
+ catch (err) {
43
+ log.error("/previewcontext: emotional fetch failed", err);
44
+ sections.push("Emotional context: fetch FAILED — the real hook would log this and inject nothing. Check logs.");
45
+ }
46
+ // Mood weather: report configuration only. Deliberately NOT rolled — see
47
+ // function doc comment. Preview must be idempotent and non-revealing.
48
+ sections.push(cfg.moodWeatherChance > 0
49
+ ? `Mood weather: configured chance ${Math.round(cfg.moodWeatherChance * 100)}% per session — NOT rolled by this preview. Each real session rolls independently at injection time; the actual mood (if any) is only observable in the live session.`
50
+ : "Mood weather: OFF (moodWeatherChance=0).");
51
+ }
52
+ // ---- 2. Auto-context (second in registration order) ----
53
+ sections.push(cfg.autoContext
54
+ ? "Auto-context: ON — will search memories using the text of your next message. Query-dependent, so not previewable here; use /getcontext <your message> to simulate."
55
+ : "Auto-context: OFF.");
56
+ // ---- 3. Startup orientation (third in registration order) ----
57
+ const so = cfg.startupOrientation;
58
+ if (!so.enabled) {
59
+ sections.push("Startup orientation: OFF (startupOrientation.enabled=false).");
60
+ }
61
+ else {
62
+ const { skip, userId } = personalUserId(cfg, ctx);
63
+ if (skip) {
64
+ sections.push("Startup orientation: ON, but you are an unknown sender in multi-user mode — the real hook would skip injection for you.");
65
+ }
66
+ else {
67
+ const g = await gatherOrientation(client, cfg, userId);
68
+ const parts = [];
69
+ if (g.recentSource === "none") {
70
+ parts.push("recent-interactions: no source (hotBuffer and autoTrace both off) — block would be empty.");
71
+ }
72
+ else if (!g.recentOk) {
73
+ parts.push(`recent-interactions: fetch FAILED (source=${g.recentSource}) — real hook would retry next turn.`);
74
+ }
75
+ else {
76
+ parts.push(g.recentBlock ?? `recent-interactions: source=${g.recentSource}, 0 results — block omitted.`);
77
+ }
78
+ if (!g.loopsOk) {
79
+ parts.push("unfinished-loops: search FAILED — real hook would retry next turn.");
80
+ }
81
+ else {
82
+ parts.push(g.loopsBlock ??
83
+ `unfinished-loops: 0 results for loopsQuery ("${so.loopsQuery}") — block omitted.`);
84
+ }
85
+ sections.push(`Startup orientation: ON.\n\n${parts.join("\n\n")}`);
86
+ }
87
+ }
88
+ return [
89
+ "What the next session would inject (read-only preview — no session state touched, no mood rolled):",
90
+ "",
91
+ sections.join("\n\n---\n\n"),
92
+ ].join("\n");
93
+ }
@@ -0,0 +1,54 @@
1
+ import { conversationMatchesChannel } from "../lib/exclude-channels.js";
2
+ /**
3
+ * Enumerate the given sources and return every memory attributable to
4
+ * `channelId` — via the `openclaw_channel_id` metadata tag (exact or
5
+ * thread-suffix, same semantics as the quarantine check), or via the
6
+ * hot-buffer `resourceId === sessionId` identity for legacy untagged rows.
7
+ */
8
+ export async function findChannelMemories(client, channelId, opts) {
9
+ const matches = [];
10
+ const sessionIds = new Set((opts.sessionIds ?? []).map((s) => s.toLowerCase()));
11
+ for (const source of opts.sources) {
12
+ for await (const m of client.listMemories({ source, userId: opts.userId })) {
13
+ const tagged = m.metadata.openclaw_channel_id;
14
+ if (typeof tagged === "string" && conversationMatchesChannel(tagged, channelId)) {
15
+ matches.push({ resourceId: m.resourceId, source: m.source, title: m.title, via: "channel_tag" });
16
+ }
17
+ else if (sessionIds.has(m.resourceId.toLowerCase())) {
18
+ // Hot-buffer resource_id === sessionId — rows written before channel
19
+ // tagging existed are reachable only through this identity.
20
+ matches.push({ resourceId: m.resourceId, source: m.source, title: m.title, via: "session_id" });
21
+ }
22
+ }
23
+ }
24
+ return matches;
25
+ }
26
+ /** Delete every match. `deleteMemory` is already 404-tolerant (absent = deleted). */
27
+ export async function deleteMatches(client, matches, opts) {
28
+ let deleted = 0;
29
+ let failed = 0;
30
+ for (const m of matches) {
31
+ const r = await client.deleteMemory(m.resourceId, { source: m.source, userId: opts.userId });
32
+ if (r.deleted)
33
+ deleted++;
34
+ else
35
+ failed++;
36
+ }
37
+ return { matched: matches, deleted, failed };
38
+ }
39
+ /** Render matches as an aligned `resourceId source via title` table. */
40
+ export function formatMatchTable(matches) {
41
+ const rows = [
42
+ ["RESOURCE ID", "SOURCE", "VIA", "TITLE"],
43
+ ...matches.map((m) => [m.resourceId, m.source, m.via, m.title ?? "-"]),
44
+ ];
45
+ const widths = [0, 1, 2].map((i) => Math.max(...rows.map((r) => r[i].length)));
46
+ return rows
47
+ .map((r) => `${r[0].padEnd(widths[0])} ${r[1].padEnd(widths[1])} ${r[2].padEnd(widths[2])} ${r[3]}`)
48
+ .join("\n");
49
+ }
50
+ /** Standing limitations — printed on every run so operators aren't surprised. */
51
+ export const PURGE_LIMITATIONS_FOOTER = "Note: not everything is channel-tagged. Auto-trace resources and /remember memories written by older plugin versions, " +
52
+ "hot-buffer rows written before 2026-07, and emotional-state registers are NOT matched by this command. " +
53
+ "Use --session for legacy hot-buffer rows and --resource for manually identified resources; " +
54
+ 'see the "excludeChannels is forward-only" section of the README.';
@@ -12,6 +12,7 @@ import { getWorkspaceDir, parseConfig, resolveConfigPath } from "../config.js";
12
12
  import { buildExtractionPrompt, CRON_JOB_NAME } from "../graph/cron.js";
13
13
  import { NetworkStateManager } from "../graph/state.js";
14
14
  import { scanMemories, formatScanResults, completeMemories } from "../graph/ops.js";
15
+ import { deleteMatches, findChannelMemories, formatMatchTable, PURGE_LIMITATIONS_FOOTER, } from "./purge-channel.js";
15
16
  async function fetchConnectionSources(client, userId) {
16
17
  try {
17
18
  const userClient = new Hyperspell({
@@ -323,12 +324,18 @@ async function runSetup() {
323
324
  // Step 8: Memory Network setup
324
325
  p.note("The Memory Network automatically extracts entities (people, projects,\n" +
325
326
  "organizations, topics) from your memories into structured markdown\n" +
326
- "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");
327
328
  const enableNetwork = await p.confirm({
328
329
  message: "Enable the Memory Network?",
329
330
  initialValue: false,
330
331
  });
331
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;
332
339
  // Update config to enable knowledgeGraph
333
340
  try {
334
341
  const configPath = resolveConfigPath();
@@ -337,7 +344,7 @@ async function runSetup() {
337
344
  const config = JSON.parse(configContent);
338
345
  const pluginEntry = config?.plugins?.entries?.["openclaw-hyperspell"]?.config;
339
346
  if (pluginEntry) {
340
- pluginEntry.knowledgeGraph = { enabled: true };
347
+ pluginEntry.knowledgeGraph = { enabled: true, scanIntervalMinutes };
341
348
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
342
349
  }
343
350
  }
@@ -359,7 +366,7 @@ async function runSetup() {
359
366
  const output = execFileSync("openclaw", [
360
367
  "cron", "add",
361
368
  "--name", CRON_JOB_NAME,
362
- "--every", "1h",
369
+ "--every", `${scanIntervalMinutes}m`,
363
370
  "--session", "isolated",
364
371
  "--message", `Read the file at ${promptPath} and follow the instructions inside it.`,
365
372
  ], { stdio: ["pipe", "pipe", "pipe"], timeout: 10_000 });
@@ -374,14 +381,14 @@ async function runSetup() {
374
381
  }
375
382
  catch { }
376
383
  }
377
- s4.stop("Cron job created — Memory Network will scan every hour");
384
+ s4.stop(`Cron job created — Memory Network will scan every ${scanIntervalMinutes} minutes`);
378
385
  }
379
386
  catch (cronErr) {
380
387
  s4.stop("Could not create cron job automatically");
381
388
  p.log.warn(`Cron creation failed. Create it manually:`);
382
389
  p.note(`openclaw cron add \\\n` +
383
390
  ` --name "${CRON_JOB_NAME}" \\\n` +
384
- ` --every 1h \\\n` +
391
+ ` --every ${scanIntervalMinutes}m \\\n` +
385
392
  ` --session isolated \\\n` +
386
393
  ` --message "Read the file at ${promptPath} and follow the instructions inside it."`, "Manual cron setup");
387
394
  }
@@ -524,6 +531,54 @@ export function registerCliCommands(program, pluginConfig) {
524
531
  .action(async () => {
525
532
  await runConnect(pluginConfig);
526
533
  });
534
+ // Retroactive cleanup for quarantined channels. Dry run by default — no
535
+ // interactive prompt, so it stays usable from cron/exec like `network`.
536
+ hyperspellCmd
537
+ .command("purge-channel <channelId>")
538
+ .description("Find (and with --yes delete) memories synced from a channel — retroactive cleanup for excludeChannels, which is forward-only")
539
+ .option("--source <sources>", "Comma-separated Hyperspell sources to scan (default: vault, the hot-buffer consolidation target)", "vault")
540
+ .option("--user <userId>", "X-As-User to scan/delete under (default: configured userId). In multiUser deployments run once per mapped userId.")
541
+ .option("--session <ids>", "Comma-separated OpenClaw session ids — matches legacy untagged hot-buffer resources (resource_id === session id)")
542
+ .option("--resource <ids>", "Comma-separated explicit resource ids to include (escape hatch for traces / remember memories identified manually)")
543
+ .option("--yes", "Actually delete. Without this flag the command is a dry run.")
544
+ .action(async (channelId, opts) => {
545
+ const splitCsv = (v) => typeof v === "string" ? v.split(",").map((s) => s.trim()).filter(Boolean) : [];
546
+ try {
547
+ const cfg = parseConfig(pluginConfig);
548
+ const client = new HyperspellClient(cfg);
549
+ const sources = splitCsv(opts.source).map((s) => s.toLowerCase());
550
+ const userId = opts.user ?? cfg.userId;
551
+ const matches = await findChannelMemories(client, channelId, {
552
+ sources: sources.length > 0 ? sources : ["vault"],
553
+ userId,
554
+ sessionIds: splitCsv(opts.session),
555
+ });
556
+ // Explicit ids are operator-asserted; append any not already found.
557
+ const found = new Set(matches.map((m) => m.resourceId));
558
+ for (const id of splitCsv(opts.resource)) {
559
+ if (!found.has(id)) {
560
+ matches.push({ resourceId: id, source: sources[0] ?? "vault", title: null, via: "explicit" });
561
+ }
562
+ }
563
+ if (matches.length === 0) {
564
+ process.stdout.write(`No memories matched channel ${channelId}.\n\n${PURGE_LIMITATIONS_FOOTER}\n`);
565
+ return;
566
+ }
567
+ process.stdout.write(formatMatchTable(matches) + "\n\n");
568
+ if (!opts.yes) {
569
+ process.stdout.write(`Dry run: ${matches.length} memor${matches.length === 1 ? "y" : "ies"} would be deleted. Re-run with --yes to delete.\n\n${PURGE_LIMITATIONS_FOOTER}\n`);
570
+ return;
571
+ }
572
+ const result = await deleteMatches(client, matches, { userId });
573
+ process.stdout.write(`Deleted ${result.deleted} of ${result.matched.length} matched memories (${result.failed} failed).\n\n${PURGE_LIMITATIONS_FOOTER}\n`);
574
+ if (result.failed > 0)
575
+ process.exit(1);
576
+ }
577
+ catch (err) {
578
+ process.stderr.write(`Purge failed: ${err instanceof Error ? err.message : String(err)}\n`);
579
+ process.exit(1);
580
+ }
581
+ });
527
582
  // Memory Network CLI commands (used by isolated cron sessions via exec)
528
583
  const networkCmd = hyperspellCmd
529
584
  .command("network")
@@ -539,7 +594,10 @@ export function registerCliCommands(program, pluginConfig) {
539
594
  const workspaceDir = getWorkspaceDir();
540
595
  const stateManager = new NetworkStateManager(workspaceDir);
541
596
  const batchSize = Number.parseInt(opts.batchSize, 10) || 20;
542
- 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);
543
601
  const text = formatScanResults(memories, stateManager.getProcessedCount(), stateManager.getLastScanAt());
544
602
  process.stdout.write(text + "\n");
545
603
  }