@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.
@@ -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 before_agent_start WOULD inject at the
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
- // before_agent_start with the same check) — report that instead of a bundle.
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 before_agent_start in a session: fetch, inject, mark.
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 `before_agent_start` (which fires every turn).
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
- * before_agent_start handler and the read-only /previewcontext command.
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 before_agent_start and slowing the reply.
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
- // before_agent_start (all injection), agent_end (all writes), and the tool
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("before_agent_start", async (event, ctx) => {
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
- // Register memory sync hook
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
- api.on("file_changed", fileSyncHandler);
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 before_agent_start)
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
  */
@@ -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 file_changed handler (plus independent per-file debounce
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 file_changed handler is unaffected: an edit bumps mtime, so
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
- "name": "@hyperspell/openclaw-hyperspell",
3
- "version": "0.21.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/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
- }
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
  }