@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 +226 -10
- package/dist/client.js +14 -0
- package/dist/commands/preview.js +93 -0
- package/dist/commands/purge-channel.js +54 -0
- package/dist/commands/setup.js +64 -6
- package/dist/commands/slash.js +58 -0
- package/dist/config.js +43 -4
- package/dist/graph/ops.js +36 -3
- package/dist/hooks/auto-context.js +75 -4
- package/dist/hooks/auto-trace.js +20 -2
- package/dist/hooks/emotional-state.js +90 -22
- package/dist/hooks/hot-buffer.js +76 -6
- package/dist/hooks/memory-sync.js +23 -7
- package/dist/hooks/mood-weather.js +45 -0
- package/dist/hooks/startup-orientation.js +86 -55
- package/dist/index.js +37 -0
- package/dist/lib/eval-matchers.js +49 -0
- package/dist/lib/exclude-channels.js +17 -6
- package/dist/lib/filters.js +46 -16
- package/dist/lib/loops-audit.js +438 -0
- package/dist/lib/ranking.js +79 -15
- package/dist/logger.js +16 -0
- package/dist/sync/markdown.js +57 -2
- package/dist/tools/emotional-arc.js +60 -0
- package/dist/tools/remember.js +9 -1
- package/openclaw.plugin.json +8 -2
- package/package.json +2 -2
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
|
-
| `
|
|
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": [
|
|
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
|
-
##
|
|
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
|
|
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
|
-
|
|
238
|
-
|
|
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
|
-
|
|
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`
|
|
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.';
|
package/dist/commands/setup.js
CHANGED
|
@@ -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
|
|
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",
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
}
|