@hyperspell/openclaw-hyperspell 0.14.0 → 0.14.2

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 CHANGED
@@ -151,6 +151,22 @@ When `syncMemories: true`, the plugin syncs markdown files from your agent's wor
151
151
  - The returned `resource_id` is stored in the file's YAML frontmatter as `hyperspell_id`
152
152
  - On subsequent syncs, files with an existing `hyperspell_id` are updated rather than duplicated
153
153
  - Files are synced automatically on startup and when they change
154
+ - Startup sync runs in the **background** — the agent boots immediately and sync proceeds without blocking it
155
+ - A per-section content hash is tracked in `<workspace>/.hyperspell-sync-hashes.json`, so unchanged sections are skipped on subsequent syncs (no re-ingestion)
156
+
157
+ **Tuning sync (object form):**
158
+
159
+ ```jsonc
160
+ "syncMemories": {
161
+ "enabled": true,
162
+ "sectionize": true, // split files on ## headings into separate memories
163
+ "watchPaths": [], // extra files/dirs to sync beyond memory/
164
+ "debounceMs": 2000, // wait for writes to settle before syncing
165
+ "maxAgeDays": 30 // startup skips already-synced files older than this
166
+ }
167
+ ```
168
+
169
+ `maxAgeDays` bounds steady-state load: on startup, files whose mtime is older than the cutoff **and** already recorded in the sync manifest are skipped without re-reading. Files not yet in the manifest are always synced once regardless of age, and editing an old file bumps its mtime back into the window. Set to `0` to disable the cutoff.
154
170
 
155
171
  **Example frontmatter after sync:**
156
172
 
@@ -276,6 +276,8 @@ async function runSetup() {
276
276
  sectionize: true,
277
277
  watchPaths: [],
278
278
  debounceMs: 2000,
279
+ maxAgeDays: 30,
280
+ ignorePaths: ["dreaming"],
279
281
  },
280
282
  sources: [],
281
283
  maxResults: 10,
package/dist/config.js CHANGED
@@ -249,7 +249,14 @@ export function parseConfig(raw) {
249
249
  // object form must be too, or a typo (sectionise/debounceMS) is silently
250
250
  // ignored and the user gets default behavior they didn't ask for.
251
251
  if (typeof smRaw === "object" && smRaw !== null && !Array.isArray(smRaw)) {
252
- assertAllowedKeys(smObj, ["enabled", "sectionize", "watchPaths", "debounceMs"], "hyperspell.syncMemories");
252
+ assertAllowedKeys(smObj, [
253
+ "enabled",
254
+ "sectionize",
255
+ "watchPaths",
256
+ "debounceMs",
257
+ "maxAgeDays",
258
+ "ignorePaths",
259
+ ], "hyperspell.syncMemories");
253
260
  }
254
261
  return {
255
262
  apiKey,
@@ -280,6 +287,10 @@ export function parseConfig(raw) {
280
287
  ? smObj.watchPaths
281
288
  : [],
282
289
  debounceMs: smObj.debounceMs ?? 2000,
290
+ maxAgeDays: smObj.maxAgeDays ?? 30,
291
+ ignorePaths: Array.isArray(smObj.ignorePaths)
292
+ ? smObj.ignorePaths
293
+ : ["dreaming"],
283
294
  },
284
295
  sources: parseSources(cfg.sources),
285
296
  maxResults: cfg.maxResults ?? 10,
@@ -1,21 +1,6 @@
1
1
  import { buildScopeFilter, getCanReadScopes, resolveUser, } from "../lib/sender.js";
2
+ import { EXCLUDE_SESSION_END_FILTER, mergeWithExclude } from "../lib/filters.js";
2
3
  import { log } from "../logger.js";
3
- /**
4
- * Memories produced by session-end hooks (auto-trace, emotional-state) are
5
- * tagged `source: "openclaw_agent_end"`. The emotional-state hook already has
6
- * a dedicated fetch path (`getEmotionalState` + `<hyperspell-emotional-context>`
7
- * injection), so those memories should NOT also surface via generic retrieval —
8
- * double-injection dilutes results and replays the conversation verbatim.
9
- * Exclude them here at the search filter.
10
- */
11
- const EXCLUDE_SESSION_END_FILTER = {
12
- source: { $ne: "openclaw_agent_end" },
13
- };
14
- function mergeWithExclude(base) {
15
- if (!base)
16
- return EXCLUDE_SESSION_END_FILTER;
17
- return { $and: [base, EXCLUDE_SESSION_END_FILTER] };
18
- }
19
4
  function formatRelativeTime(isoTimestamp) {
20
5
  try {
21
6
  const dt = new Date(isoTimestamp);
@@ -19,8 +19,22 @@ export function buildFileSyncHandler(client, cfg) {
19
19
  const sectionize = cfg.syncMemoriesConfig.sectionize;
20
20
  const watchPaths = cfg.syncMemoriesConfig.watchPaths;
21
21
  const debounceMs = cfg.syncMemoriesConfig.debounceMs;
22
+ const ignoreDirs = new Set(cfg.syncMemoriesConfig.ignorePaths);
22
23
  // Resolve additional watch paths to absolute paths for matching
23
24
  const resolvedWatchPaths = (watchPaths ?? []).map((wp) => wp.startsWith("/") ? wp : path.join(workspaceDir, wp));
25
+ /**
26
+ * Mirror the bulk walk's exclusions on the live path: a file under a
27
+ * dot-directory (e.g. .dreams) or an ignored directory (e.g. dreaming) must
28
+ * not live-sync either, or an edit would re-ingest exactly what the walk
29
+ * skips.
30
+ */
31
+ function isIgnoredPath(filePath) {
32
+ const rel = path.relative(memoryDir, filePath);
33
+ if (rel.startsWith("..") || path.isAbsolute(rel))
34
+ return false;
35
+ const segments = rel.split(path.sep).slice(0, -1); // directory segments only
36
+ return segments.some((seg) => seg.startsWith(".") || ignoreDirs.has(seg));
37
+ }
24
38
  // Debounce map: filePath -> timeout handle
25
39
  const debounceTimers = new Map();
26
40
  /**
@@ -29,6 +43,8 @@ export function buildFileSyncHandler(client, cfg) {
29
43
  function isSyncable(filePath) {
30
44
  if (!filePath.endsWith(".md"))
31
45
  return false;
46
+ if (isIgnoredPath(filePath))
47
+ return false;
32
48
  // Always sync memory/ files
33
49
  if (filePath.startsWith(memoryDir + path.sep))
34
50
  return true;
@@ -106,8 +122,10 @@ export async function syncMemoriesOnStartup(client, workspaceDir, options) {
106
122
  const result = await syncAllFilesSectionized(client, workspaceDir, {
107
123
  userId: options.userId,
108
124
  watchPaths: options.watchPaths,
125
+ maxAgeDays: options.maxAgeDays,
126
+ ignorePaths: options.ignorePaths,
109
127
  });
110
- log.info(`Section sync complete: ${result.synced} synced, ${result.skipped} unchanged, ${result.removed} removed, ${result.failed} failed`);
128
+ log.info(`Section sync complete: ${result.synced} synced, ${result.skipped} unchanged, ${result.agedOut} aged-out, ${result.removed} removed, ${result.failed} failed`);
111
129
  if (result.failed > 0) {
112
130
  for (const error of result.errors) {
113
131
  log.error(` - ${error}`);
package/dist/index.js CHANGED
@@ -117,13 +117,23 @@ export default {
117
117
  id: "openclaw-hyperspell",
118
118
  start: async () => {
119
119
  api.logger.info("hyperspell: connected");
120
- // Sync memories on startup if enabled
120
+ // Sync memories on startup if enabled.
121
+ //
122
+ // Deliberately NOT awaited: the bulk sync is sequential and
123
+ // network-bound (one request per changed section), so awaiting
124
+ // it here stalls the agent's startup until the entire corpus
125
+ // has been processed. Run it in the background and let the
126
+ // agent come up immediately; failures are logged, not fatal.
121
127
  if (cfg.syncMemories) {
122
128
  const workspaceDir = getWorkspaceDir();
123
- await syncMemoriesOnStartup(client, workspaceDir, {
129
+ void syncMemoriesOnStartup(client, workspaceDir, {
124
130
  userId: cfg.multiUser?.sharedUserId,
125
131
  sectionize: cfg.syncMemoriesConfig.sectionize,
126
132
  watchPaths: cfg.syncMemoriesConfig.watchPaths,
133
+ maxAgeDays: cfg.syncMemoriesConfig.maxAgeDays,
134
+ ignorePaths: cfg.syncMemoriesConfig.ignorePaths,
135
+ }).catch((err) => {
136
+ api.logger.error("hyperspell: background memory sync failed", err);
127
137
  });
128
138
  }
129
139
  },
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Shared Hyperspell `options.filter` clauses, used by every retrieval path
3
+ * (auto-context hook + the hyperspell_search tool) so filtering stays
4
+ * consistent across them.
5
+ *
6
+ * Filters match against memory METADATA keys by their bare name — the same
7
+ * convention `buildScopeFilter` uses (`openclaw_scope`, `openclaw_user`).
8
+ */
9
+ /**
10
+ * Memories produced by session-end hooks (auto-trace, emotional-state) are
11
+ * tagged in metadata as `openclaw_source: "agent_end"` (see `sendTrace` in
12
+ * client.ts). Those should NOT surface via generic retrieval: the
13
+ * emotional-state hook injects them through its own dedicated path, and
14
+ * replaying whole sanitized transcripts back into context creates a
15
+ * self-amplifying pollution loop. Exclude them at the search filter.
16
+ *
17
+ * NOTE: an earlier version of this filter checked the top-level `source` field
18
+ * for the value `"openclaw_agent_end"` — wrong on BOTH counts (the tag lives in
19
+ * metadata under `openclaw_source`, and its value is `"agent_end"`), so it
20
+ * silently matched nothing and excluded no traces.
21
+ */
22
+ export const EXCLUDE_SESSION_END_FILTER = {
23
+ openclaw_source: { $ne: "agent_end" },
24
+ };
25
+ /** Combine a caller-supplied filter with the session-end exclude via `$and`. */
26
+ export function mergeWithExclude(base) {
27
+ if (!base)
28
+ return EXCLUDE_SESSION_END_FILTER;
29
+ return { $and: [base, EXCLUDE_SESSION_END_FILTER] };
30
+ }
@@ -168,10 +168,14 @@ export function loadManifest(workspaceDir) {
168
168
  try {
169
169
  if (fs.existsSync(p)) {
170
170
  const parsed = JSON.parse(fs.readFileSync(p, "utf-8"));
171
- // Self-healing: an unrecognized/legacy shape is discarded. This feature
172
- // has never shipped a released on-disk format, so nothing to migrate
173
- // a mismatch just triggers one re-sync, which is idempotent.
174
- if (parsed && parsed.version === MANIFEST_VERSION && parsed.files) {
171
+ // Forward-migrate rather than discard. Throwing the manifest away on a
172
+ // version bump means every section re-uploads on the next run the
173
+ // exact full-re-ingest load this guard exists to prevent. As long as the
174
+ // `files` map is structurally a record we keep it: the per-section hash
175
+ // check still gates every upload, so a stale-but-readable manifest is
176
+ // strictly better than an empty one. Only a missing/corrupt `files`
177
+ // (truly unusable) falls back to empty.
178
+ if (parsed && typeof parsed.files === "object" && parsed.files !== null) {
175
179
  return { version: MANIFEST_VERSION, files: parsed.files };
176
180
  }
177
181
  }
@@ -183,11 +187,24 @@ export function loadManifest(workspaceDir) {
183
187
  }
184
188
  export function saveManifest(workspaceDir, manifest) {
185
189
  const p = manifestPath(workspaceDir);
190
+ // Atomic write: a process killed mid-write (deploy/restart) would otherwise
191
+ // leave a truncated JSON file, which loadManifest can only treat as corrupt
192
+ // -> empty -> full re-ingest. Write to a temp sibling, then rename (atomic
193
+ // on the same filesystem) so readers only ever see a complete manifest.
194
+ const tmp = `${p}.${process.pid}.tmp`;
186
195
  try {
187
- fs.writeFileSync(p, JSON.stringify(manifest, null, 2));
196
+ fs.writeFileSync(tmp, JSON.stringify(manifest, null, 2));
197
+ fs.renameSync(tmp, p);
188
198
  }
189
199
  catch (err) {
190
200
  log.error("Failed to write sync manifest", err);
201
+ try {
202
+ if (fs.existsSync(tmp))
203
+ fs.unlinkSync(tmp);
204
+ }
205
+ catch {
206
+ /* best-effort cleanup */
207
+ }
191
208
  }
192
209
  }
193
210
  /**
@@ -206,15 +223,38 @@ export function withSyncLock(fn) {
206
223
  // ---------------------------------------------------------------------------
207
224
  // Public API — file-level sync (original, kept for backward compat)
208
225
  // ---------------------------------------------------------------------------
209
- export function getMemoryFiles(workspaceDir) {
226
+ /**
227
+ * Directory names skipped by default when walking memory/. The dreaming engine
228
+ * writes first-person dream journals under memory/dreaming/ that are not user
229
+ * memories; ingesting them pollutes retrieval (they score highly on emotional/
230
+ * associative queries and crowd out real memories). Configurable via
231
+ * `syncMemories.ignorePaths`.
232
+ */
233
+ export const DEFAULT_IGNORE_DIRS = ["dreaming"];
234
+ /**
235
+ * Skip a directory entry during the walk when it is a dot-directory/file
236
+ * (e.g. `.dreams`, engine scaffolding) or a directory named in `ignore`.
237
+ * Dot-entries are always skipped regardless of the configured list.
238
+ */
239
+ function isIgnoredEntry(entry, ignore) {
240
+ if (entry.name.startsWith("."))
241
+ return true;
242
+ if (entry.isDirectory() && ignore.has(entry.name))
243
+ return true;
244
+ return false;
245
+ }
246
+ export function getMemoryFiles(workspaceDir, ignorePaths) {
210
247
  const memoryDir = path.join(workspaceDir, "memory");
211
248
  if (!fs.existsSync(memoryDir)) {
212
249
  return [];
213
250
  }
251
+ const ignore = new Set(ignorePaths ?? DEFAULT_IGNORE_DIRS);
214
252
  const results = [];
215
253
  function walk(dir) {
216
254
  try {
217
255
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
256
+ if (isIgnoredEntry(entry, ignore))
257
+ continue;
218
258
  const fullPath = path.join(dir, entry.name);
219
259
  if (entry.isDirectory()) {
220
260
  walk(fullPath);
@@ -235,8 +275,9 @@ export function getMemoryFiles(workspaceDir) {
235
275
  * Collect all syncable files based on configuration.
236
276
  * Includes memory/*.md by default, plus any additional watchPaths.
237
277
  */
238
- export function getSyncableFiles(workspaceDir, watchPaths) {
239
- const files = getMemoryFiles(workspaceDir);
278
+ export function getSyncableFiles(workspaceDir, watchPaths, ignorePaths) {
279
+ const ignore = new Set(ignorePaths ?? DEFAULT_IGNORE_DIRS);
280
+ const files = getMemoryFiles(workspaceDir, ignorePaths);
240
281
  if (watchPaths) {
241
282
  for (const wp of watchPaths) {
242
283
  const resolved = wp.startsWith("/") ? wp : path.join(workspaceDir, wp);
@@ -253,6 +294,8 @@ export function getSyncableFiles(workspaceDir, watchPaths) {
253
294
  const walk = (dir) => {
254
295
  try {
255
296
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
297
+ if (isIgnoredEntry(entry, ignore))
298
+ continue;
256
299
  const fullPath = path.join(dir, entry.name);
257
300
  if (entry.isDirectory()) {
258
301
  walk(fullPath);
@@ -480,13 +523,45 @@ export async function syncAllMemoryFiles(client, workspaceDir, options) {
480
523
  * Unchanged sections (by content hash) are skipped.
481
524
  */
482
525
  export async function syncAllFilesSectionized(client, workspaceDir, options) {
483
- const files = getSyncableFiles(workspaceDir, options?.watchPaths);
526
+ const files = getSyncableFiles(workspaceDir, options?.watchPaths, options?.ignorePaths);
527
+ // Age pre-filter: a file untouched for longer than maxAgeDays that is
528
+ // already recorded in the manifest has been ingested at least once and is
529
+ // not changing — re-parsing/hashing/diffing it every startup is pure churn.
530
+ // Files NOT yet in the manifest are always processed regardless of age, so
531
+ // a genuinely cold start (or a never-synced old file) still ingests once.
532
+ // The live file_changed handler is unaffected: an edit bumps mtime, so
533
+ // edited-but-old files re-enter the window naturally.
534
+ const maxAgeDays = options?.maxAgeDays ?? 0;
535
+ let candidates = files;
536
+ let agedOut = 0;
537
+ if (maxAgeDays > 0) {
538
+ const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
539
+ const manifest = loadManifest(workspaceDir);
540
+ candidates = files.filter((filePath) => {
541
+ const key = fileKey(workspaceDir, filePath);
542
+ const known = manifest.files[key] !== undefined;
543
+ if (!known)
544
+ return true;
545
+ try {
546
+ if (fs.statSync(filePath).mtimeMs >= cutoff)
547
+ return true;
548
+ }
549
+ catch {
550
+ return true; // stat failed — be safe, process it
551
+ }
552
+ agedOut++;
553
+ return false;
554
+ });
555
+ if (agedOut > 0) {
556
+ log.info(`Skipping ${agedOut} unchanged file(s) older than ${maxAgeDays}d (already synced)`);
557
+ }
558
+ }
484
559
  let totalSynced = 0;
485
560
  let totalSkipped = 0;
486
561
  let totalFailed = 0;
487
562
  let totalRemoved = 0;
488
563
  const allErrors = [];
489
- for (const filePath of files) {
564
+ for (const filePath of candidates) {
490
565
  const result = await syncMarkdownFileSectionized(client, filePath, workspaceDir, {
491
566
  userId: options?.userId,
492
567
  });
@@ -501,6 +576,7 @@ export async function syncAllFilesSectionized(client, workspaceDir, options) {
501
576
  skipped: totalSkipped,
502
577
  failed: totalFailed,
503
578
  removed: totalRemoved,
579
+ agedOut,
504
580
  errors: allErrors,
505
581
  };
506
582
  }
@@ -1,4 +1,5 @@
1
1
  import { Type } from "@sinclair/typebox";
2
+ import { mergeWithExclude } from "../lib/filters.js";
2
3
  import { buildScopeFilter, getCanReadScopes, resolveUser } from "../lib/sender.js";
3
4
  import { log } from "../logger.js";
4
5
  export function createSearchToolFactory(client, cfg) {
@@ -38,6 +39,9 @@ export function createSearchToolFactory(client, cfg) {
38
39
  : canRead;
39
40
  filter = buildScopeFilter(allowed, resolved?.userId ?? "");
40
41
  }
42
+ // Keep session-end trace memories out of agent-facing search, matching
43
+ // the auto-context hook so both retrieval paths filter identically.
44
+ filter = mergeWithExclude(filter);
41
45
  log.debug(`search tool: query="${params.query}" limit=${limit} after=${params.after ?? "none"} before=${params.before ?? "none"} userId=${userId} scope=${params.scope ?? "any"}`);
42
46
  try {
43
47
  const response = await client.searchRaw(params.query, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperspell/openclaw-hyperspell",
3
- "version": "0.14.0",
3
+ "version": "0.14.2",
4
4
  "type": "module",
5
5
  "description": "OpenClaw Hyperspell memory plugin",
6
6
  "license": "MIT",
@@ -38,7 +38,7 @@
38
38
  "check-types": "tsc --noEmit",
39
39
  "lint": "bunx @biomejs/biome ci .",
40
40
  "lint:fix": "bunx @biomejs/biome check --write .",
41
- "test": "node --test --experimental-strip-types lib/sender.test.ts config.test.ts sync/markdown.test.ts hooks/auto-trace.test.ts hooks/emotional-state.test.ts hooks/startup-orientation.test.ts"
41
+ "test": "node --test --experimental-strip-types lib/sender.test.ts lib/filters.test.ts config.test.ts sync/markdown.test.ts hooks/auto-trace.test.ts hooks/emotional-state.test.ts hooks/startup-orientation.test.ts"
42
42
  },
43
43
  "openclaw": {
44
44
  "extensions": [