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