@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 +260 -10
- package/dist/client.js +14 -0
- package/dist/commands/setup.js +16 -6
- package/dist/commands/slash.js +40 -0
- package/dist/config.js +89 -6
- package/dist/graph/ops.js +36 -3
- package/dist/hooks/auto-context.js +126 -15
- package/dist/hooks/emotional-state.js +19 -8
- package/dist/hooks/memory-sync.js +23 -7
- package/dist/hooks/mood-weather.js +45 -0
- package/dist/hooks/startup-orientation.js +10 -1
- package/dist/index.js +24 -1
- package/dist/lib/coverage-log.js +47 -0
- package/dist/lib/filters.js +39 -11
- package/dist/lib/loops-audit.js +438 -0
- package/dist/lib/mood-skew-audit.js +580 -0
- package/dist/lib/ranking.js +188 -11
- package/dist/logger.js +16 -0
- package/dist/sync/markdown.js +57 -2
- package/openclaw.plugin.json +35 -2
- package/package.json +2 -2
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
|
-
| `
|
|
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": [
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
284
|
-
|
|
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
|
-
|
|
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`
|
|
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;
|
package/dist/commands/setup.js
CHANGED
|
@@ -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
|
|
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",
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/dist/commands/slash.js
CHANGED
|
@@ -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
|
-
?
|
|
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:
|
|
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,
|