@hyperspell/openclaw-hyperspell 0.21.0 → 0.21.1
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 +2 -2
- package/dist/hooks/memory-sync.js +59 -0
- package/dist/hooks/startup-orientation.js +2 -2
- package/dist/index.js +24 -5
- package/dist/lib/speaker-tracker.js +1 -1
- package/dist/sync/markdown.js +2 -2
- 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).
|
|
@@ -185,7 +185,7 @@ export function buildEmotionalContext(states) {
|
|
|
185
185
|
* context. On later turns of the same session, return undefined — the
|
|
186
186
|
* injection from the first turn is already in the conversation history.
|
|
187
187
|
*
|
|
188
|
-
* Runs on
|
|
188
|
+
* Runs on the session injection hook (fires every turn).
|
|
189
189
|
*/
|
|
190
190
|
export function buildEmotionalStateFetchHandler(client, cfg, deps = {}) {
|
|
191
191
|
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,19 @@ 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
|
+
const injectionHook = typeof api
|
|
148
|
+
.enqueueNextTurnInjection === "function"
|
|
149
|
+
? "agent_turn_prepare"
|
|
150
|
+
: "before_agent_start";
|
|
140
151
|
if (startHandlers.length > 0) {
|
|
141
|
-
api.on(
|
|
152
|
+
api.on(injectionHook, async (event, ctx) => {
|
|
142
153
|
// Quarantined channels get no injected memory of any kind.
|
|
143
154
|
if (quarantined(ctx))
|
|
144
155
|
return undefined;
|
|
@@ -167,10 +178,16 @@ export default {
|
|
|
167
178
|
api.on("agent_end", unlessQuarantined(buildHotBufferHandler(client, cfg)));
|
|
168
179
|
api.on("session_end", buildHotBufferSessionCleanupHandler());
|
|
169
180
|
}
|
|
170
|
-
//
|
|
181
|
+
// Memory sync live watcher. Plugin-owned on every host version:
|
|
182
|
+
// 2026.7.2 removed the host `file_changed` hook, and on older hosts
|
|
183
|
+
// registering both paths would double-sync each edit. Started/stopped
|
|
184
|
+
// by the lifecycle service below.
|
|
185
|
+
let memorySyncWatcher;
|
|
171
186
|
if (cfg.syncMemories) {
|
|
172
187
|
const fileSyncHandler = buildFileSyncHandler(client, cfg);
|
|
173
|
-
|
|
188
|
+
memorySyncWatcher = buildMemorySyncWatcher(cfg, (event) => {
|
|
189
|
+
void fileSyncHandler(event);
|
|
190
|
+
});
|
|
174
191
|
api.logger.info(`hyperspell: memory sync enabled (sectionize=${cfg.syncMemoriesConfig.sectionize}, watchPaths=${cfg.syncMemoriesConfig.watchPaths.length})`);
|
|
175
192
|
}
|
|
176
193
|
// Register memory network tools
|
|
@@ -219,8 +236,10 @@ export default {
|
|
|
219
236
|
api.logger.error("hyperspell: background memory sync failed", err);
|
|
220
237
|
});
|
|
221
238
|
}
|
|
239
|
+
memorySyncWatcher?.start();
|
|
222
240
|
},
|
|
223
241
|
stop: () => {
|
|
242
|
+
memorySyncWatcher?.stop();
|
|
224
243
|
api.logger.info("hyperspell: stopped");
|
|
225
244
|
},
|
|
226
245
|
});
|
|
@@ -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/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.21.1",
|
|
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
|
}
|