@hyperspell/openclaw-hyperspell 0.21.0 → 0.22.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/dist/commands/preview.js +2 -2
- package/dist/hooks/emotional-state.js +16 -5
- package/dist/hooks/memory-sync.js +59 -0
- package/dist/hooks/startup-orientation.js +2 -2
- package/dist/index.js +32 -5
- package/dist/lib/speaker-tracker.js +1 -1
- package/dist/sync/markdown.js +2 -2
- package/openclaw.plugin.json +1 -1
- package/package.json +58 -58
package/dist/commands/preview.js
CHANGED
|
@@ -3,7 +3,7 @@ import { gatherOrientation, personalUserId } from "../hooks/startup-orientation.
|
|
|
3
3
|
import { isExcludedChannel } from "../lib/exclude-channels.js";
|
|
4
4
|
import { log } from "../logger.js";
|
|
5
5
|
/**
|
|
6
|
-
* Assemble a read-only report of what
|
|
6
|
+
* Assemble a read-only report of what the injection hook WOULD inject at the
|
|
7
7
|
* start of the next session. Calls only the pure fetch/format functions the
|
|
8
8
|
* real hooks are composed from — never the hook handlers themselves — so it
|
|
9
9
|
* cannot touch the inject-once session caches, and it never calls rollMood
|
|
@@ -12,7 +12,7 @@ import { log } from "../logger.js";
|
|
|
12
12
|
*/
|
|
13
13
|
export async function buildPreviewReport(client, cfg, ctx) {
|
|
14
14
|
// Quarantined channels get no injected memory of any kind (index.ts guards
|
|
15
|
-
//
|
|
15
|
+
// the injection hook with the same check) — report that instead of a bundle.
|
|
16
16
|
if (isExcludedChannel({ channelId: ctx.channel }, cfg)) {
|
|
17
17
|
return "This channel is quarantined (excludeChannels): no context would be injected here and no memory would be written.";
|
|
18
18
|
}
|
|
@@ -58,7 +58,7 @@ const sessionMoods = new Map();
|
|
|
58
58
|
* hundred tokens of repeated wrapper per turn.
|
|
59
59
|
*
|
|
60
60
|
* Lifecycle:
|
|
61
|
-
* - first
|
|
61
|
+
* - first injection-hook fire in a session: fetch, inject, mark.
|
|
62
62
|
* - subsequent turns in same session: skip (return undefined).
|
|
63
63
|
* - after_compaction: clear the mark so the next turn re-injects (the
|
|
64
64
|
* initial injection may have been compacted out of history).
|
|
@@ -89,12 +89,19 @@ function contentToText(content) {
|
|
|
89
89
|
}
|
|
90
90
|
return "";
|
|
91
91
|
}
|
|
92
|
-
|
|
92
|
+
/**
|
|
93
|
+
* Only conversational turns carry relationship signal. `system` is scaffolding
|
|
94
|
+
* and `tool`/`toolResult` are machinery — a Discord send receipt says nothing
|
|
95
|
+
* about how a conversation felt, and letting one through means the extractor
|
|
96
|
+
* distills API JSON into an emotional register. Same rule as hot-buffer.ts.
|
|
97
|
+
*/
|
|
98
|
+
const CONVERSATIONAL_ROLES = new Set(["user", "assistant"]);
|
|
99
|
+
export function messagesToTranscript(messages) {
|
|
93
100
|
const lines = [];
|
|
94
101
|
for (const m of messages) {
|
|
95
102
|
if (!m.role || !m.content)
|
|
96
103
|
continue;
|
|
97
|
-
if (m.role
|
|
104
|
+
if (!CONVERSATIONAL_ROLES.has(m.role))
|
|
98
105
|
continue;
|
|
99
106
|
const raw = contentToText(m.content);
|
|
100
107
|
if (!raw)
|
|
@@ -112,9 +119,13 @@ function messagesToTranscript(messages) {
|
|
|
112
119
|
* rather than a distilled emotional register. Real summaries are second-person
|
|
113
120
|
* prose ("Your relationship with this user…"); the placeholder echoes the input
|
|
114
121
|
* conversation, which has role-prefixed lines.
|
|
122
|
+
*
|
|
123
|
+
* Covers the full role vocabulary, not just what messagesToTranscript writes
|
|
124
|
+
* today: rows stored before tool roles were excluded above are still fetched
|
|
125
|
+
* until they age out, and they lead with `toolResult:`.
|
|
115
126
|
*/
|
|
116
127
|
export function looksLikeRawTranscript(summary) {
|
|
117
|
-
return /(^|\n)\s*(user|assistant)\s*:/i.test(summary);
|
|
128
|
+
return /(^|\n)\s*(?:user|assistant|system|tool(?:result|use|call)?)\s*:/i.test(summary);
|
|
118
129
|
}
|
|
119
130
|
/** Compact relative time for the arc labels (e.g. "just now", "3h ago", "2d ago"). */
|
|
120
131
|
function relativeWhen(iso) {
|
|
@@ -185,7 +196,7 @@ export function buildEmotionalContext(states) {
|
|
|
185
196
|
* context. On later turns of the same session, return undefined — the
|
|
186
197
|
* injection from the first turn is already in the conversation history.
|
|
187
198
|
*
|
|
188
|
-
* Runs on
|
|
199
|
+
* Runs on the session injection hook (fires every turn).
|
|
189
200
|
*/
|
|
190
201
|
export function buildEmotionalStateFetchHandler(client, cfg, deps = {}) {
|
|
191
202
|
const now = deps.now ?? Date.now;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
1
2
|
import * as path from "node:path";
|
|
2
3
|
import { getWorkspaceDir } from "../config.js";
|
|
3
4
|
import { log } from "../logger.js";
|
|
@@ -128,6 +129,64 @@ export function buildFileSyncHandler(client, cfg) {
|
|
|
128
129
|
}
|
|
129
130
|
};
|
|
130
131
|
}
|
|
132
|
+
/**
|
|
133
|
+
* Plugin-owned file watcher feeding the file-sync handler.
|
|
134
|
+
*
|
|
135
|
+
* OpenClaw 2026.7.2 removed the host `file_changed` hook with no replacement,
|
|
136
|
+
* so live sync must come from the plugin itself. Used on EVERY host version
|
|
137
|
+
* (old hosts still expose `file_changed`, but registering both would
|
|
138
|
+
* double-sync) — one canonical path. Events are forwarded in the old hook's
|
|
139
|
+
* `{ file_path }` shape; the handler already owns syncability filtering and
|
|
140
|
+
* debounce, so the watcher stays dumb.
|
|
141
|
+
*/
|
|
142
|
+
export function buildMemorySyncWatcher(cfg, onFileChanged) {
|
|
143
|
+
const workspaceDir = getWorkspaceDir();
|
|
144
|
+
const roots = [
|
|
145
|
+
path.join(workspaceDir, "memory"),
|
|
146
|
+
...(cfg.syncMemoriesConfig.watchPaths ?? []).map((wp) => resolveWatchPath(workspaceDir, wp.path)),
|
|
147
|
+
];
|
|
148
|
+
const watchers = [];
|
|
149
|
+
const start = () => {
|
|
150
|
+
let watched = 0;
|
|
151
|
+
for (const root of roots) {
|
|
152
|
+
let isDirectory;
|
|
153
|
+
try {
|
|
154
|
+
isDirectory = fs.statSync(root).isDirectory();
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
log.debug(`memory-sync watcher: root missing, skipping: ${root}`);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
// Watching a file directly breaks on editor rename-replace cycles;
|
|
161
|
+
// watch the parent and filter to the file's own events instead.
|
|
162
|
+
const dir = isDirectory ? root : path.dirname(root);
|
|
163
|
+
try {
|
|
164
|
+
const watcher = fs.watch(dir, { recursive: isDirectory }, (_type, filename) => {
|
|
165
|
+
if (!filename)
|
|
166
|
+
return;
|
|
167
|
+
const filePath = path.join(dir, filename.toString());
|
|
168
|
+
if (!isDirectory && filePath !== root)
|
|
169
|
+
return;
|
|
170
|
+
onFileChanged({ file_path: filePath });
|
|
171
|
+
});
|
|
172
|
+
watchers.push(watcher);
|
|
173
|
+
watched += 1;
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
log.error(`memory-sync watcher failed for ${dir}`, err);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (watched > 0) {
|
|
180
|
+
log.info(`memory-sync: watching ${watched} root(s) with plugin-owned watchers`);
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
const stop = () => {
|
|
184
|
+
for (const watcher of watchers)
|
|
185
|
+
watcher.close();
|
|
186
|
+
watchers.length = 0;
|
|
187
|
+
};
|
|
188
|
+
return { start, stop };
|
|
189
|
+
}
|
|
131
190
|
/**
|
|
132
191
|
* Sync all existing memory files on startup.
|
|
133
192
|
* Uses section-level sync when sectionize is enabled.
|
|
@@ -194,7 +194,7 @@ async function fetchRecentConversations(client, limit, userId) {
|
|
|
194
194
|
}
|
|
195
195
|
/**
|
|
196
196
|
* The fetch+format core of startup orientation, shared by the real
|
|
197
|
-
*
|
|
197
|
+
* session-start injection handler and the read-only /previewcontext command.
|
|
198
198
|
* Pure with respect to session lifecycle: no inject-once cache, no retry
|
|
199
199
|
* counting — callers own that policy. Keeping one formatter here keeps the
|
|
200
200
|
* preview byte-identical to real injection.
|
|
@@ -207,7 +207,7 @@ export async function gatherOrientation(client, cfg, userId) {
|
|
|
207
207
|
// off agents). Fall back to agent_end traces only when there's no hot
|
|
208
208
|
// buffer but auto-trace is on. Otherwise skip: there's nothing to fetch,
|
|
209
209
|
// and the trace-source list is expensive (observed ~12s + failing/turn),
|
|
210
|
-
// blocking
|
|
210
|
+
// blocking the injection hook and slowing the reply.
|
|
211
211
|
const recentSource = cfg.hotBuffer.enabled
|
|
212
212
|
? "hotBuffer"
|
|
213
213
|
: cfg.autoTrace.enabled
|
package/dist/index.js
CHANGED
|
@@ -5,7 +5,7 @@ import { parseConfig, hyperspellConfigSchema, getWorkspaceDir, VALID_SOURCES } f
|
|
|
5
5
|
import { buildAutoContextHandler } from "./hooks/auto-context.js";
|
|
6
6
|
import { buildAutoTraceHandler } from "./hooks/auto-trace.js";
|
|
7
7
|
import { buildEmotionalStateCompactionHandler, buildEmotionalStateFetchHandler, buildEmotionalStateSessionCleanupHandler, buildEmotionalStateStoreHandler, } from "./hooks/emotional-state.js";
|
|
8
|
-
import { buildFileSyncHandler, syncMemoriesOnStartup } from "./hooks/memory-sync.js";
|
|
8
|
+
import { buildFileSyncHandler, buildMemorySyncWatcher, syncMemoriesOnStartup, } from "./hooks/memory-sync.js";
|
|
9
9
|
import { buildHotBufferHandler, buildHotBufferSessionCleanupHandler, } from "./hooks/hot-buffer.js";
|
|
10
10
|
import { buildStartupOrientationCompactionHandler, buildStartupOrientationHandler, buildStartupOrientationSessionCleanupHandler, } from "./hooks/startup-orientation.js";
|
|
11
11
|
import { isExcludedChannel } from "./lib/exclude-channels.js";
|
|
@@ -89,7 +89,7 @@ export default {
|
|
|
89
89
|
const client = new HyperspellClient(cfg);
|
|
90
90
|
// Channel quarantine (cfg.excludeChannels): excluded conversations get no
|
|
91
91
|
// memory surface in either direction. Guard the shared choke points here —
|
|
92
|
-
//
|
|
92
|
+
// the injection hook (all injection), agent_end (all writes), and the tool
|
|
93
93
|
// factories — so individual hooks stay quarantine-unaware.
|
|
94
94
|
const quarantined = (ctx) => {
|
|
95
95
|
if (!isExcludedChannel(ctx, cfg))
|
|
@@ -137,8 +137,27 @@ export default {
|
|
|
137
137
|
api.on("after_compaction", buildStartupOrientationCompactionHandler());
|
|
138
138
|
api.on("session_end", buildStartupOrientationSessionCleanupHandler());
|
|
139
139
|
}
|
|
140
|
+
// Injection hook selection: OpenClaw 2026.7.2 removed the plugin-facing
|
|
141
|
+
// `before_agent_start`; its replacement `agent_turn_prepare` shipped in the
|
|
142
|
+
// same core commit as `enqueueNextTurnInjection` (openclaw#72287, 2026-04),
|
|
143
|
+
// so probing that api member tells us exactly which hook this host knows.
|
|
144
|
+
// Register exactly ONE of the two: cores between 2026-04 and 2026-07
|
|
145
|
+
// accept both and would double-inject. Both hooks share the same
|
|
146
|
+
// { prependContext } result contract and provide event.prompt.
|
|
147
|
+
//
|
|
148
|
+
// Do NOT rename this a third time. We were originally on
|
|
149
|
+
// `before_prompt_build`, moved to `before_agent_start`, and that is the
|
|
150
|
+
// only reason 2026.7.2 took injection down: `before_prompt_build` is
|
|
151
|
+
// still live in core's PROMPT_INJECTION_HOOK_NAMES and would have
|
|
152
|
+
// survived untouched. Staying on `agent_turn_prepare` (proven in
|
|
153
|
+
// production) — but the lesson is that "newer-sounding hook name" is not
|
|
154
|
+
// evidence of anything. Check core's hook table before moving again.
|
|
155
|
+
const injectionHook = typeof api
|
|
156
|
+
.enqueueNextTurnInjection === "function"
|
|
157
|
+
? "agent_turn_prepare"
|
|
158
|
+
: "before_agent_start";
|
|
140
159
|
if (startHandlers.length > 0) {
|
|
141
|
-
api.on(
|
|
160
|
+
api.on(injectionHook, async (event, ctx) => {
|
|
142
161
|
// Quarantined channels get no injected memory of any kind.
|
|
143
162
|
if (quarantined(ctx))
|
|
144
163
|
return undefined;
|
|
@@ -167,10 +186,16 @@ export default {
|
|
|
167
186
|
api.on("agent_end", unlessQuarantined(buildHotBufferHandler(client, cfg)));
|
|
168
187
|
api.on("session_end", buildHotBufferSessionCleanupHandler());
|
|
169
188
|
}
|
|
170
|
-
//
|
|
189
|
+
// Memory sync live watcher. Plugin-owned on every host version:
|
|
190
|
+
// 2026.7.2 removed the host `file_changed` hook, and on older hosts
|
|
191
|
+
// registering both paths would double-sync each edit. Started/stopped
|
|
192
|
+
// by the lifecycle service below.
|
|
193
|
+
let memorySyncWatcher;
|
|
171
194
|
if (cfg.syncMemories) {
|
|
172
195
|
const fileSyncHandler = buildFileSyncHandler(client, cfg);
|
|
173
|
-
|
|
196
|
+
memorySyncWatcher = buildMemorySyncWatcher(cfg, (event) => {
|
|
197
|
+
void fileSyncHandler(event);
|
|
198
|
+
});
|
|
174
199
|
api.logger.info(`hyperspell: memory sync enabled (sectionize=${cfg.syncMemoriesConfig.sectionize}, watchPaths=${cfg.syncMemoriesConfig.watchPaths.length})`);
|
|
175
200
|
}
|
|
176
201
|
// Register memory network tools
|
|
@@ -219,8 +244,10 @@ export default {
|
|
|
219
244
|
api.logger.error("hyperspell: background memory sync failed", err);
|
|
220
245
|
});
|
|
221
246
|
}
|
|
247
|
+
memorySyncWatcher?.start();
|
|
222
248
|
},
|
|
223
249
|
stop: () => {
|
|
250
|
+
memorySyncWatcher?.stop();
|
|
224
251
|
api.logger.info("hyperspell: stopped");
|
|
225
252
|
},
|
|
226
253
|
});
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*
|
|
11
11
|
* Lifecycle:
|
|
12
12
|
* - record() on every turn that has a resolvable senderId (hot-buffer agent_end,
|
|
13
|
-
* auto-context
|
|
13
|
+
* auto-context injection hook)
|
|
14
14
|
* - isMultiSpeaker() checked by any hook or tool that needs to guard behaviour
|
|
15
15
|
* - cleanup() on session_end, called from the hot-buffer cleanup handler
|
|
16
16
|
*/
|
package/dist/sync/markdown.js
CHANGED
|
@@ -210,7 +210,7 @@ export function saveManifest(workspaceDir, manifest) {
|
|
|
210
210
|
}
|
|
211
211
|
/**
|
|
212
212
|
* Serializes manifest read-modify-write across the whole process. Startup bulk
|
|
213
|
-
* sync and the live
|
|
213
|
+
* sync and the live file-watch handler (plus independent per-file debounce
|
|
214
214
|
* timers) would otherwise interleave load → await addMemory → save and lose
|
|
215
215
|
* resourceId entries, causing the next sync to re-upload sections as brand-new
|
|
216
216
|
* memories. Every load→sync→save runs inside this critical section.
|
|
@@ -583,7 +583,7 @@ export async function syncAllFilesSectionized(client, workspaceDir, options) {
|
|
|
583
583
|
// not changing — re-parsing/hashing/diffing it every startup is pure churn.
|
|
584
584
|
// Files NOT yet in the manifest are always processed regardless of age, so
|
|
585
585
|
// a genuinely cold start (or a never-synced old file) still ingests once.
|
|
586
|
-
// The live
|
|
586
|
+
// The live file-watch handler is unaffected: an edit bumps mtime, so
|
|
587
587
|
// edited-but-old files re-enter the window naturally.
|
|
588
588
|
const maxAgeDays = options?.maxAgeDays ?? 0;
|
|
589
589
|
let candidates = files;
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -1,60 +1,60 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
2
|
+
"name": "@hyperspell/openclaw-hyperspell",
|
|
3
|
+
"version": "0.22.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "OpenClaw Hyperspell memory plugin",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist/",
|
|
10
|
+
"openclaw.plugin.json",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"author": "Hyperspell <hello@hyperspell.com> (https://hyperspell.com)",
|
|
14
|
+
"homepage": "https://hyperspell.com",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/hyperspell/hyperspell-openclaw.git"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"openclaw",
|
|
21
|
+
"hyperspell",
|
|
22
|
+
"memory",
|
|
23
|
+
"rag",
|
|
24
|
+
"ai",
|
|
25
|
+
"plugin"
|
|
26
|
+
],
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@clack/prompts": "^1.0.0",
|
|
29
|
+
"@sinclair/typebox": "^0.34.0",
|
|
30
|
+
"hyperspell": "^0.35.1"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"openclaw": ">=2026.1.29"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsc -p tsconfig.build.json",
|
|
37
|
+
"prepublishOnly": "npm run build",
|
|
38
|
+
"check-types": "tsc --noEmit",
|
|
39
|
+
"lint": "bunx @biomejs/biome ci .",
|
|
40
|
+
"lint:fix": "bunx @biomejs/biome check --write .",
|
|
41
|
+
"test": "node --test --experimental-strip-types client.test.ts logger.test.ts lib/sender.test.ts lib/filters.test.ts lib/search-error.test.ts lib/coverage-log.test.ts lib/session.test.ts lib/exclude-channels.test.ts lib/ranking.test.ts lib/eval-matchers.test.ts lib/loops-audit.test.ts lib/mood-skew-audit.test.ts tools/search.test.ts tools/emotional-arc.test.ts commands/preview.test.ts commands/purge-channel.test.ts config.test.ts sync/markdown.test.ts graph/ops.test.ts hooks/memory-sync.watcher.test.ts hooks/auto-trace.test.ts hooks/auto-context.test.ts hooks/hot-buffer.test.ts hooks/emotional-state.test.ts hooks/startup-orientation.test.ts hooks/mood-weather.test.ts eval/eval.test.ts"
|
|
42
|
+
},
|
|
43
|
+
"openclaw": {
|
|
44
|
+
"extensions": [
|
|
45
|
+
"./dist/index.js"
|
|
46
|
+
],
|
|
47
|
+
"hooks": [],
|
|
48
|
+
"compat": {
|
|
49
|
+
"pluginApi": ">=2026.1.29",
|
|
50
|
+
"minGatewayVersion": "2026.1.29"
|
|
51
|
+
},
|
|
52
|
+
"build": {
|
|
53
|
+
"openclawVersion": "2026.3.24-beta.2",
|
|
54
|
+
"pluginSdkVersion": "2026.3.24-beta.2"
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"typescript": "^5.9.3"
|
|
59
|
+
}
|
|
60
60
|
}
|