@hyperspell/openclaw-hyperspell 0.18.1 → 0.19.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 +46 -0
- package/dist/commands/preview.js +93 -0
- package/dist/commands/purge-channel.js +54 -0
- package/dist/commands/setup.js +49 -0
- package/dist/commands/slash.js +18 -0
- package/dist/hooks/auto-context.js +67 -3
- package/dist/hooks/auto-trace.js +20 -2
- package/dist/hooks/emotional-state.js +77 -20
- package/dist/hooks/hot-buffer.js +76 -6
- package/dist/hooks/startup-orientation.js +77 -55
- package/dist/index.js +21 -0
- package/dist/lib/eval-matchers.js +49 -0
- package/dist/lib/exclude-channels.js +17 -6
- package/dist/lib/filters.js +6 -4
- package/dist/lib/ranking.js +42 -13
- package/dist/tools/emotional-arc.js +60 -0
- package/dist/tools/remember.js +9 -1
- package/openclaw.plugin.json +3 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -102,6 +102,27 @@ 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
|
+
|
|
105
126
|
## Configuration Options
|
|
106
127
|
|
|
107
128
|
| Option | Type | Default | Description |
|
|
@@ -109,9 +130,12 @@ Open the Hyperspell connect page to link your accounts (Notion, Slack, Google Dr
|
|
|
109
130
|
| `apiKey` | string | `${HYPERSPELL_API_KEY}` | Hyperspell API key |
|
|
110
131
|
| `userId` | string | - | User ID for multi-tenant memory (can be your email) |
|
|
111
132
|
| `autoContext` | boolean | `true` | Auto-inject relevant memories before each AI turn |
|
|
133
|
+
| `emotionalContext` | boolean | `false` | Persist an emotional-state register at session end and inject the recent arc at session start |
|
|
134
|
+
| `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
135
|
| `syncMemories` | boolean | `false` | Sync markdown files in `workspace/memory/` to Hyperspell |
|
|
113
136
|
| `sources` | string | - | Comma-separated sources to search (e.g., `vault,notion,slack`) |
|
|
114
137
|
| `maxResults` | number | `10` | Maximum memories per context injection |
|
|
138
|
+
| `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). |
|
|
115
139
|
| `debug` | boolean | `false` | Enable verbose logging |
|
|
116
140
|
| `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
141
|
|
|
@@ -141,6 +165,16 @@ Manually sync all markdown files in `workspace/memory/` to Hyperspell.
|
|
|
141
165
|
/sync
|
|
142
166
|
```
|
|
143
167
|
|
|
168
|
+
### `/previewcontext`
|
|
169
|
+
|
|
170
|
+
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.
|
|
171
|
+
|
|
172
|
+
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.
|
|
173
|
+
|
|
174
|
+
```
|
|
175
|
+
/previewcontext
|
|
176
|
+
```
|
|
177
|
+
|
|
144
178
|
## Memory Sync
|
|
145
179
|
|
|
146
180
|
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.
|
|
@@ -186,6 +220,7 @@ The plugin registers tools that the AI can use autonomously:
|
|
|
186
220
|
|
|
187
221
|
- **hyperspell_search** - Search through connected sources
|
|
188
222
|
- **hyperspell_remember** - Save information to memory
|
|
223
|
+
- **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
224
|
|
|
190
225
|
## Auto-Context
|
|
191
226
|
|
|
@@ -197,6 +232,17 @@ When `autoContext: true` (default), the plugin automatically:
|
|
|
197
232
|
|
|
198
233
|
This ensures the AI always has access to relevant information from your connected sources.
|
|
199
234
|
|
|
235
|
+
### `excludeChannels` is forward-only
|
|
236
|
+
|
|
237
|
+
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.
|
|
238
|
+
|
|
239
|
+
- **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.
|
|
240
|
+
- **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.
|
|
241
|
+
- **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.)
|
|
242
|
+
- **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`).
|
|
243
|
+
|
|
244
|
+
The purge command prints these standing limitations on every run.
|
|
245
|
+
|
|
200
246
|
## Available Sources
|
|
201
247
|
|
|
202
248
|
### Documents & Storage
|
|
@@ -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({
|
|
@@ -524,6 +525,54 @@ export function registerCliCommands(program, pluginConfig) {
|
|
|
524
525
|
.action(async () => {
|
|
525
526
|
await runConnect(pluginConfig);
|
|
526
527
|
});
|
|
528
|
+
// Retroactive cleanup for quarantined channels. Dry run by default — no
|
|
529
|
+
// interactive prompt, so it stays usable from cron/exec like `network`.
|
|
530
|
+
hyperspellCmd
|
|
531
|
+
.command("purge-channel <channelId>")
|
|
532
|
+
.description("Find (and with --yes delete) memories synced from a channel — retroactive cleanup for excludeChannels, which is forward-only")
|
|
533
|
+
.option("--source <sources>", "Comma-separated Hyperspell sources to scan (default: vault, the hot-buffer consolidation target)", "vault")
|
|
534
|
+
.option("--user <userId>", "X-As-User to scan/delete under (default: configured userId). In multiUser deployments run once per mapped userId.")
|
|
535
|
+
.option("--session <ids>", "Comma-separated OpenClaw session ids — matches legacy untagged hot-buffer resources (resource_id === session id)")
|
|
536
|
+
.option("--resource <ids>", "Comma-separated explicit resource ids to include (escape hatch for traces / remember memories identified manually)")
|
|
537
|
+
.option("--yes", "Actually delete. Without this flag the command is a dry run.")
|
|
538
|
+
.action(async (channelId, opts) => {
|
|
539
|
+
const splitCsv = (v) => typeof v === "string" ? v.split(",").map((s) => s.trim()).filter(Boolean) : [];
|
|
540
|
+
try {
|
|
541
|
+
const cfg = parseConfig(pluginConfig);
|
|
542
|
+
const client = new HyperspellClient(cfg);
|
|
543
|
+
const sources = splitCsv(opts.source).map((s) => s.toLowerCase());
|
|
544
|
+
const userId = opts.user ?? cfg.userId;
|
|
545
|
+
const matches = await findChannelMemories(client, channelId, {
|
|
546
|
+
sources: sources.length > 0 ? sources : ["vault"],
|
|
547
|
+
userId,
|
|
548
|
+
sessionIds: splitCsv(opts.session),
|
|
549
|
+
});
|
|
550
|
+
// Explicit ids are operator-asserted; append any not already found.
|
|
551
|
+
const found = new Set(matches.map((m) => m.resourceId));
|
|
552
|
+
for (const id of splitCsv(opts.resource)) {
|
|
553
|
+
if (!found.has(id)) {
|
|
554
|
+
matches.push({ resourceId: id, source: sources[0] ?? "vault", title: null, via: "explicit" });
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
if (matches.length === 0) {
|
|
558
|
+
process.stdout.write(`No memories matched channel ${channelId}.\n\n${PURGE_LIMITATIONS_FOOTER}\n`);
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
process.stdout.write(formatMatchTable(matches) + "\n\n");
|
|
562
|
+
if (!opts.yes) {
|
|
563
|
+
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`);
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
const result = await deleteMatches(client, matches, { userId });
|
|
567
|
+
process.stdout.write(`Deleted ${result.deleted} of ${result.matched.length} matched memories (${result.failed} failed).\n\n${PURGE_LIMITATIONS_FOOTER}\n`);
|
|
568
|
+
if (result.failed > 0)
|
|
569
|
+
process.exit(1);
|
|
570
|
+
}
|
|
571
|
+
catch (err) {
|
|
572
|
+
process.stderr.write(`Purge failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
573
|
+
process.exit(1);
|
|
574
|
+
}
|
|
575
|
+
});
|
|
527
576
|
// Memory Network CLI commands (used by isolated cron sessions via exec)
|
|
528
577
|
const networkCmd = hyperspellCmd
|
|
529
578
|
.command("network")
|
package/dist/commands/slash.js
CHANGED
|
@@ -2,6 +2,7 @@ import { getWorkspaceDir } from "../config.js";
|
|
|
2
2
|
import { buildScopeFilter, getCanReadScopes, getDefaultWriteScope, resolveRole, resolveUser, routeWrite, } from "../lib/sender.js";
|
|
3
3
|
import { log } from "../logger.js";
|
|
4
4
|
import { syncAllMemoryFiles } from "../sync/markdown.js";
|
|
5
|
+
import { buildPreviewReport } from "./preview.js";
|
|
5
6
|
/**
|
|
6
7
|
* Strip a `#scope-name` prefix from free text. Returns the scope and the
|
|
7
8
|
* remainder. Used by /remember and /getcontext to let users narrow or route
|
|
@@ -156,4 +157,21 @@ export function registerCommands(api, client, cfg) {
|
|
|
156
157
|
}
|
|
157
158
|
},
|
|
158
159
|
});
|
|
160
|
+
// /previewcontext - Show what would be injected into the next session
|
|
161
|
+
api.registerCommand({
|
|
162
|
+
name: "previewcontext",
|
|
163
|
+
description: "Preview what Hyperspell would inject at the next session start",
|
|
164
|
+
acceptsArgs: false,
|
|
165
|
+
requireAuth: true,
|
|
166
|
+
handler: async (ctx) => {
|
|
167
|
+
log.debug("/previewcontext command");
|
|
168
|
+
try {
|
|
169
|
+
return { text: await buildPreviewReport(client, cfg, ctx) };
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
log.error("/previewcontext failed", err);
|
|
173
|
+
return { text: "Failed to build preview. Check logs for details." };
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
});
|
|
159
177
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { appendFileSync } from "node:fs";
|
|
1
2
|
import { buildScopeFilter, getCanReadScopes, resolveUser, } from "../lib/sender.js";
|
|
2
3
|
import { excludeFilterFor, mergeWithExclude } from "../lib/filters.js";
|
|
3
|
-
import { rerank,
|
|
4
|
+
import { explainSelection, rerank, } from "../lib/ranking.js";
|
|
4
5
|
import { classifySearchError, logSearchError } from "../lib/search-error.js";
|
|
5
6
|
import { resolveCurrentSessionId } from "../lib/session.js";
|
|
6
7
|
import { recordSender, senderIdFromCtx } from "../lib/speaker-tracker.js";
|
|
@@ -79,6 +80,49 @@ function formatSelected(selected, threshold) {
|
|
|
79
80
|
return null;
|
|
80
81
|
return sections.join("\n\n");
|
|
81
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* Opt-in score sampling for relevanceThreshold tuning (proposal 02 §3b).
|
|
85
|
+
* Writes one JSONL line per candidate when HYPERSPELL_SCORE_LOG names a file;
|
|
86
|
+
* review/analyze with docs/score-review.mjs and docs/score-analyze.mjs.
|
|
87
|
+
*
|
|
88
|
+
* The lines carry an 80-char prompt prefix and memory snippets — sensitive
|
|
89
|
+
* plaintext on disk — so this is OFF by default and never config-driven:
|
|
90
|
+
* it writes nothing unless the operator explicitly sets the env var (setting
|
|
91
|
+
* it IS the opt-in), and the log should be deleted after the tuning window.
|
|
92
|
+
* The env var is read at call time (not module load) so tests can set it.
|
|
93
|
+
* Must never throw into the retrieval path.
|
|
94
|
+
*/
|
|
95
|
+
function logScoreSamples(prompt, sessionId, scope, explained, threshold) {
|
|
96
|
+
const path = process.env.HYPERSPELL_SCORE_LOG;
|
|
97
|
+
if (!path || explained.length === 0)
|
|
98
|
+
return;
|
|
99
|
+
const ts = new Date().toISOString();
|
|
100
|
+
const lines = explained.map((e) => {
|
|
101
|
+
const r = e.result;
|
|
102
|
+
const top = [...r.highlights].sort((a, b) => (b.score ?? 0) - (a.score ?? 0))[0];
|
|
103
|
+
return JSON.stringify({
|
|
104
|
+
ts,
|
|
105
|
+
sessionId,
|
|
106
|
+
scope,
|
|
107
|
+
prompt: prompt.slice(0, 80),
|
|
108
|
+
resourceId: r.resourceId,
|
|
109
|
+
title: (r.title ?? "").slice(0, 60),
|
|
110
|
+
kind: r._kind,
|
|
111
|
+
base: Number(r._base.toFixed(4)),
|
|
112
|
+
composite: Number(r._composite.toFixed(4)),
|
|
113
|
+
threshold,
|
|
114
|
+
selected: e.selected,
|
|
115
|
+
cut: e.cut,
|
|
116
|
+
snippet: (top?.text ?? "").replace(/\s+/g, " ").slice(0, 120),
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
try {
|
|
120
|
+
appendFileSync(path, `${lines.join("\n")}\n`);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// instrumentation must never break retrieval
|
|
124
|
+
}
|
|
125
|
+
}
|
|
82
126
|
const INTRO = "The following is surfaced from the user's memory and connected sources, including past conversations. Reference it as recalled context, only when relevant to the conversation.";
|
|
83
127
|
const DISCLAIMER = "Draw on it when relevant — including indirect connections — but don't force it into every response or make assumptions beyond what's stated.";
|
|
84
128
|
// A short, CONDITIONAL reminder appended only to a real memory block (not a
|
|
@@ -157,11 +201,27 @@ export function buildAutoContextHandler(client, cfg) {
|
|
|
157
201
|
const ranked = rerank(results, ranking);
|
|
158
202
|
// Threshold + chatter quota applied here, so a high-similarity echo can
|
|
159
203
|
// inform but never flood (the quota bounds count; the penalty bounds rank).
|
|
160
|
-
const
|
|
204
|
+
const explained = explainSelection(ranked, cfg.maxResults, cfg.relevanceThreshold, ranking.chatterQuota);
|
|
205
|
+
logScoreSamples(prompt, currentSessionId, "single", explained, cfg.relevanceThreshold);
|
|
206
|
+
const selected = explained.filter((e) => e.selected).map((e) => e.result);
|
|
207
|
+
// Cut-reason visibility (proposal 02, absorbs proposal 03): logged
|
|
208
|
+
// BEFORE the `formatted` check so quota drops stay visible even when
|
|
209
|
+
// nothing is injected (e.g. chatterQuota 0 with only chatter clearing
|
|
210
|
+
// the threshold). Stable "auto-context: cut" prefix for log greps.
|
|
211
|
+
// flatMap (not filter) so TS narrows to the cut member of the union.
|
|
212
|
+
const cuts = explained.flatMap((e) => (e.selected ? [] : [e]));
|
|
213
|
+
if (cuts.length > 0) {
|
|
214
|
+
const cutTally = cuts.reduce((acc, e) => ((acc[e.cut] = (acc[e.cut] ?? 0) + 1), acc), {});
|
|
215
|
+
const topQuotaDrop = cuts.find((e) => e.cut === "chatter-quota");
|
|
216
|
+
const quotaNote = topQuotaDrop
|
|
217
|
+
? `, top quota-dropped composite ${topQuotaDrop.result._composite.toFixed(2)}`
|
|
218
|
+
: "";
|
|
219
|
+
log.debug(`auto-context: cut ${cuts.length} of ${ranked.length} candidates ${JSON.stringify(cutTally)}${quotaNote}`);
|
|
220
|
+
}
|
|
161
221
|
formatted = formatSelected(selected, cfg.relevanceThreshold);
|
|
162
222
|
if (formatted) {
|
|
163
223
|
const tally = selected.reduce((acc, r) => ((acc[r._kind] = (acc[r._kind] ?? 0) + 1), acc), {});
|
|
164
|
-
log.debug(`auto-context: injecting (ranked) ${JSON.stringify(tally)} from ${results.length} candidates (chatter cap ${ranking.chatterQuota})`);
|
|
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)})`);
|
|
165
225
|
}
|
|
166
226
|
}
|
|
167
227
|
else {
|
|
@@ -188,6 +248,10 @@ export function buildAutoContextHandler(client, cfg) {
|
|
|
188
248
|
}
|
|
189
249
|
};
|
|
190
250
|
}
|
|
251
|
+
// Score logging / cut attribution (proposal 02) applies to the ranked
|
|
252
|
+
// single-user path only: this path never runs rerank/explainSelection, so
|
|
253
|
+
// there is no composite score to attribute. If ranking ever lands here, call
|
|
254
|
+
// logScoreSamples with scope "personal" / "shared" per search.
|
|
191
255
|
async function multiUserSearch(client, cfg, prompt, resolved, currentSessionId) {
|
|
192
256
|
const multiUser = cfg.multiUser;
|
|
193
257
|
const isKnownSender = !!resolved?.resolved;
|
package/dist/hooks/auto-trace.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { channelIdFromCtx } from "../lib/exclude-channels.js";
|
|
1
2
|
import { resolveUser } from "../lib/sender.js";
|
|
2
3
|
import { isMultiSpeaker } from "../lib/speaker-tracker.js";
|
|
3
4
|
import { log } from "../logger.js";
|
|
@@ -113,7 +114,14 @@ export function buildAutoTraceHandler(client, cfg) {
|
|
|
113
114
|
log.debug(`auto-trace: skipping — conversation too short (${estimate} chars)`);
|
|
114
115
|
return;
|
|
115
116
|
}
|
|
116
|
-
|
|
117
|
+
// The session id lives on the hook CONTEXT, not the agent_end event (same
|
|
118
|
+
// contract issue #42 hit in hot-buffer): reading only event.sessionId
|
|
119
|
+
// yields a fresh random UUID per turn, which breaks multi-speaker
|
|
120
|
+
// detection keying and makes the openclaw_session_id tag useless for
|
|
121
|
+
// cleanup. Prefer ctx; keep event/random as fallbacks.
|
|
122
|
+
const sessionId = ctx?.sessionId ??
|
|
123
|
+
event.sessionId ??
|
|
124
|
+
crypto.randomUUID();
|
|
117
125
|
const history = messagesToJSONL(messages, sessionId);
|
|
118
126
|
// Warn once per session when multiple speakers have no multiUser config:
|
|
119
127
|
// all turns collapse into a single undifferentiated trace under cfg.userId.
|
|
@@ -131,12 +139,22 @@ export function buildAutoTraceHandler(client, cfg) {
|
|
|
131
139
|
// Falls back to sharedUserId for unknown senders (or undefined in single-user mode).
|
|
132
140
|
const resolved = resolveUser(ctx, cfg);
|
|
133
141
|
const userId = resolved?.userId;
|
|
142
|
+
// Tag traces with the conversation they came from. channelIdFromCtx is the
|
|
143
|
+
// same resolver the quarantine check uses, so tag-time identity matches
|
|
144
|
+
// quarantine-time identity — the purge-channel CLI relies on that parity.
|
|
145
|
+
// session_id is a first-class trace field but is NOT exposed by
|
|
146
|
+
// listMemories, so it's mirrored into metadata for enumeration.
|
|
147
|
+
const channelId = channelIdFromCtx(ctx);
|
|
134
148
|
try {
|
|
135
149
|
const result = await client.sendTrace(history, {
|
|
136
150
|
sessionId,
|
|
137
151
|
title,
|
|
138
152
|
extract: cfg.autoTrace.extract,
|
|
139
|
-
metadata:
|
|
153
|
+
metadata: {
|
|
154
|
+
...cfg.autoTrace.metadata,
|
|
155
|
+
...(channelId ? { openclaw_channel_id: channelId } : {}),
|
|
156
|
+
openclaw_session_id: sessionId,
|
|
157
|
+
},
|
|
140
158
|
userId,
|
|
141
159
|
// Auto-trace captures full conversation text — the most sensitive class
|
|
142
160
|
// of memory. Default to private; users opt into family-visible recall
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import { channelIdFromCtx } from "../lib/exclude-channels.js";
|
|
1
2
|
import { resolveCurrentSessionId } from "../lib/session.js";
|
|
2
3
|
import { isMultiSpeaker } from "../lib/speaker-tracker.js";
|
|
3
4
|
import { log } from "../logger.js";
|
|
4
5
|
import { sanitizeTraceText } from "./auto-trace.js";
|
|
5
6
|
import { buildMoodWeatherContext, rollMood } from "./mood-weather.js";
|
|
6
7
|
/** How many recent registers to surface as the "arc" at session start. */
|
|
7
|
-
const EMOTIONAL_ARC_LIMIT = 3;
|
|
8
|
+
export const EMOTIONAL_ARC_LIMIT = 3;
|
|
8
9
|
const MIN_MESSAGES = 3;
|
|
9
10
|
const MIN_CONVERSATION_LENGTH = 100;
|
|
10
11
|
/**
|
|
@@ -24,6 +25,25 @@ const NON_CONVERSATIONAL_TRIGGERS = new Set(["cron", "heartbeat", "memory"]);
|
|
|
24
25
|
const STORE_DEBOUNCE_MS = 3 * 60 * 1000;
|
|
25
26
|
/** relationshipId → last successful store time (ms). Module-scoped, per process. */
|
|
26
27
|
const lastStoreAt = new Map();
|
|
28
|
+
/**
|
|
29
|
+
* Cross-session cooldown for mood weather: once weather actually LANDS, no new
|
|
30
|
+
* roll for this long, no matter how many sessions start. "Rare per session"
|
|
31
|
+
* isn't "rare" when sessions cluster — five short same-day sessions would
|
|
32
|
+
* otherwise get five independent rolls and can whiplash silly → spiky → flat
|
|
33
|
+
* in one afternoon. Weather changes on the scale of days, not sessions.
|
|
34
|
+
* Misses do NOT start the cooldown — only landed weather does, so effective
|
|
35
|
+
* frequency for unclustered sessions still tracks moodWeatherChance.
|
|
36
|
+
*/
|
|
37
|
+
export const MOOD_WEATHER_COOLDOWN_MS = 6 * 60 * 60 * 1000;
|
|
38
|
+
/** relationshipId → when weather last actually landed (ms). Module-scoped, per process (mirrors lastStoreAt). */
|
|
39
|
+
const lastMoodRollAt = new Map();
|
|
40
|
+
/**
|
|
41
|
+
* sessionKey → the mood that landed for that session. Post-compaction
|
|
42
|
+
* re-injection must replay the SAME weather — not roll new dice (mood must
|
|
43
|
+
* stay stable for a whole session) and not silently drop it (the cooldown
|
|
44
|
+
* would otherwise suppress the re-roll mid-session).
|
|
45
|
+
*/
|
|
46
|
+
const sessionMoods = new Map();
|
|
27
47
|
/**
|
|
28
48
|
* Sessions where emotional context has already been injected this run.
|
|
29
49
|
* Emotional state doesn't change within a session (it's extracted at
|
|
@@ -120,9 +140,14 @@ function relativeWhen(iso) {
|
|
|
120
140
|
* backend doesn't expose `/emotional-state/recent` yet (returns null) or errors
|
|
121
141
|
* — so this works before AND after that endpoint deploys.
|
|
122
142
|
*/
|
|
123
|
-
async function fetchRecentOrLatest(client, cfg) {
|
|
143
|
+
export async function fetchRecentOrLatest(client, cfg, limit) {
|
|
144
|
+
// An explicit caller-supplied limit (the hyperspell_emotional_arc tool) must
|
|
145
|
+
// always win outright; only the *default* when no limit is passed may depend
|
|
146
|
+
// on config (see #68's depth-weighted default) — a model's explicit ask
|
|
147
|
+
// should never be silently overridden by an unrelated config knob.
|
|
148
|
+
const fetchLimit = limit ?? EMOTIONAL_ARC_LIMIT;
|
|
124
149
|
try {
|
|
125
|
-
const recent = await client.getRecentEmotionalStates(cfg.relationshipId,
|
|
150
|
+
const recent = await client.getRecentEmotionalStates(cfg.relationshipId, fetchLimit);
|
|
126
151
|
if (recent !== null)
|
|
127
152
|
return recent; // endpoint available (may be empty)
|
|
128
153
|
}
|
|
@@ -133,7 +158,7 @@ async function fetchRecentOrLatest(client, cfg) {
|
|
|
133
158
|
return single ? [single] : [];
|
|
134
159
|
}
|
|
135
160
|
/** Build the injected emotional-context block from one or more registers. */
|
|
136
|
-
function buildEmotionalContext(states) {
|
|
161
|
+
export function buildEmotionalContext(states) {
|
|
137
162
|
const intro = states.length > 1
|
|
138
163
|
? "How your relationship with this user has felt across your recent conversations, most recent first. Let the trajectory inform your tone — don't reference it explicitly."
|
|
139
164
|
: "The emotional register of your relationship with this user from your last interaction. Let it inform your tone — don't reference it explicitly.";
|
|
@@ -156,7 +181,8 @@ function buildEmotionalContext(states) {
|
|
|
156
181
|
*
|
|
157
182
|
* Runs on `before_agent_start` (which fires every turn).
|
|
158
183
|
*/
|
|
159
|
-
export function buildEmotionalStateFetchHandler(client, cfg) {
|
|
184
|
+
export function buildEmotionalStateFetchHandler(client, cfg, deps = {}) {
|
|
185
|
+
const now = deps.now ?? Date.now;
|
|
160
186
|
return async (_event, ctx) => {
|
|
161
187
|
const sessionKey = ctx?.sessionKey;
|
|
162
188
|
if (sessionKey && injectedSessions.has(sessionKey)) {
|
|
@@ -168,25 +194,45 @@ export function buildEmotionalStateFetchHandler(client, cfg) {
|
|
|
168
194
|
// after a store the register can be the RAW input transcript, not the
|
|
169
195
|
// distilled feeling. Injecting that is useless and pollutes tone.
|
|
170
196
|
const usable = states.filter((s) => s.summary && !looksLikeRawTranscript(s.summary));
|
|
197
|
+
if (usable.length === 0 && states.length > 0) {
|
|
198
|
+
// State(s) exist but are all still extracting — don't cache, so a
|
|
199
|
+
// later turn re-fetches once extraction completes. Runs BEFORE the
|
|
200
|
+
// mood roll so a discarded turn can't land weather or burn the
|
|
201
|
+
// cross-session cooldown.
|
|
202
|
+
log.debug("emotional-context: state(s) still extracting — skipping injection this turn");
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
171
205
|
// Mood weather: an exogenous, uncaused session mood that OVERRIDES the
|
|
172
|
-
// arc's tone for this session only.
|
|
173
|
-
// the same inject-once cache). Lives purely in the injection path — it is
|
|
206
|
+
// arc's tone for this session only. Lives purely in the injection path —
|
|
174
207
|
// never written back via the store handler, so one random morning can't
|
|
175
208
|
// calcify into the baseline. May clash with the room on purpose.
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
209
|
+
// Rolled once per session (inject-once cache) AND at most once per
|
|
210
|
+
// MOOD_WEATHER_COOLDOWN_MS across sessions (a landed roll suppresses new
|
|
211
|
+
// rolls; post-compaction re-injection replays the same mood instead).
|
|
212
|
+
const relId = cfg.relationshipId ?? "";
|
|
213
|
+
const priorMood = sessionKey ? sessionMoods.get(sessionKey) : undefined;
|
|
214
|
+
// Missing map entry means weather never landed this process — always
|
|
215
|
+
// eligible (don't subtract from an epoch the injectable clock may predate).
|
|
216
|
+
const lastLanded = lastMoodRollAt.get(relId);
|
|
217
|
+
const cooledDown = lastLanded === undefined || now() - lastLanded >= MOOD_WEATHER_COOLDOWN_MS;
|
|
218
|
+
const mood = priorMood ??
|
|
219
|
+
(cfg.moodWeatherChance > 0 && cooledDown
|
|
220
|
+
? rollMood(cfg.moodWeatherChance, deps.rng)
|
|
221
|
+
: null);
|
|
222
|
+
if (mood && !priorMood) {
|
|
223
|
+
lastMoodRollAt.set(relId, now());
|
|
224
|
+
if (sessionKey)
|
|
225
|
+
sessionMoods.set(sessionKey, mood);
|
|
179
226
|
log.info(`mood-weather: rolled "${mood.id}" this session`);
|
|
227
|
+
// Coordination with issue #71 (mood-weather observability): if #71's
|
|
228
|
+
// recordMoodRoll(client, mood, {...}) has landed, its call belongs
|
|
229
|
+
// HERE — inside this `!priorMood` guard — not on a bare `if (mood)`
|
|
230
|
+
// at the two return sites below. A priorMood replay (post-compaction
|
|
231
|
+
// re-injection of an already-rolled mood) is not a new roll; calling
|
|
232
|
+
// recordMoodRoll on replay would log the same event twice.
|
|
180
233
|
}
|
|
234
|
+
const moodBlock = mood ? buildMoodWeatherContext(mood) : "";
|
|
181
235
|
if (usable.length === 0) {
|
|
182
|
-
if (states.length > 0) {
|
|
183
|
-
// State(s) exist but are all still extracting — don't cache, so a
|
|
184
|
-
// later turn re-fetches once extraction completes. (We re-roll the
|
|
185
|
-
// mood then too, which is fine — still at most once per *injected*
|
|
186
|
-
// session, since extraction settles within seconds.)
|
|
187
|
-
log.debug("emotional-context: state(s) still extracting — skipping injection this turn");
|
|
188
|
-
return;
|
|
189
|
-
}
|
|
190
236
|
// No arc yet — but weather can still land on a blank slate.
|
|
191
237
|
log.debug("emotional-context: no prior emotional state found");
|
|
192
238
|
if (sessionKey)
|
|
@@ -228,8 +274,12 @@ export function buildEmotionalStateCompactionHandler() {
|
|
|
228
274
|
export function buildEmotionalStateSessionCleanupHandler() {
|
|
229
275
|
return async (_event, ctx) => {
|
|
230
276
|
const sessionKey = ctx?.sessionKey;
|
|
231
|
-
if (sessionKey)
|
|
277
|
+
if (sessionKey) {
|
|
232
278
|
injectedSessions.delete(sessionKey);
|
|
279
|
+
// Session over — its mood memo is dead weight. NOT cleared on
|
|
280
|
+
// compaction: surviving compaction is what makes the mood replay.
|
|
281
|
+
sessionMoods.delete(sessionKey);
|
|
282
|
+
}
|
|
233
283
|
};
|
|
234
284
|
}
|
|
235
285
|
/**
|
|
@@ -276,9 +326,16 @@ export function buildEmotionalStateStoreHandler(client, cfg) {
|
|
|
276
326
|
return;
|
|
277
327
|
}
|
|
278
328
|
try {
|
|
329
|
+
// Tag the register with the medium it was extracted from (voice vs Discord vs
|
|
330
|
+
// DM), mirroring hot-buffer's openclaw_channel_id tag. Capture-only: analysis/
|
|
331
|
+
// debugging metadata, deliberately NOT surfaced in the injected prose (#74).
|
|
332
|
+
const channelId = channelIdFromCtx(ctx);
|
|
279
333
|
const result = await client.storeEmotionalState(transcript, {
|
|
280
334
|
relationshipId: cfg.relationshipId,
|
|
281
|
-
metadata: {
|
|
335
|
+
metadata: {
|
|
336
|
+
source: "openclaw_agent_end",
|
|
337
|
+
...(channelId ? { channelId } : {}),
|
|
338
|
+
},
|
|
282
339
|
});
|
|
283
340
|
lastStoreAt.set(relId, Date.now());
|
|
284
341
|
log.info(`emotional-state: stored ${result.resourceId}`);
|