@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.
@@ -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).
@@ -89,12 +89,19 @@ function contentToText(content) {
89
89
  }
90
90
  return "";
91
91
  }
92
- function messagesToTranscript(messages) {
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 === "system")
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 `before_agent_start` (which fires every turn).
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
- * 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,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("before_agent_start", async (event, ctx) => {
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
- // Register memory sync hook
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
- api.on("file_changed", fileSyncHandler);
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 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;
@@ -2,7 +2,7 @@
2
2
  "id": "openclaw-hyperspell",
3
3
  "name": "Hyperspell",
4
4
  "description": "Context and memory for your AI agents — search across Notion, Slack, Google Drive, and more",
5
- "version": "0.21.0",
5
+ "version": "0.22.0",
6
6
  "kind": "memory",
7
7
  "contracts": {
8
8
  "tools": [
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.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
  }