@hyperspell/openclaw-hyperspell 0.14.0 → 0.14.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/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,7 @@ async function runSetup() {
276
276
  sectionize: true,
277
277
  watchPaths: [],
278
278
  debounceMs: 2000,
279
+ maxAgeDays: 30,
279
280
  },
280
281
  sources: [],
281
282
  maxResults: 10,
package/dist/config.js CHANGED
@@ -249,7 +249,7 @@ 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, ["enabled", "sectionize", "watchPaths", "debounceMs", "maxAgeDays"], "hyperspell.syncMemories");
253
253
  }
254
254
  return {
255
255
  apiKey,
@@ -280,6 +280,7 @@ export function parseConfig(raw) {
280
280
  ? smObj.watchPaths
281
281
  : [],
282
282
  debounceMs: smObj.debounceMs ?? 2000,
283
+ maxAgeDays: smObj.maxAgeDays ?? 30,
283
284
  },
284
285
  sources: parseSources(cfg.sources),
285
286
  maxResults: cfg.maxResults ?? 10,
@@ -106,8 +106,9 @@ export async function syncMemoriesOnStartup(client, workspaceDir, options) {
106
106
  const result = await syncAllFilesSectionized(client, workspaceDir, {
107
107
  userId: options.userId,
108
108
  watchPaths: options.watchPaths,
109
+ maxAgeDays: options.maxAgeDays,
109
110
  });
110
- log.info(`Section sync complete: ${result.synced} synced, ${result.skipped} unchanged, ${result.removed} removed, ${result.failed} failed`);
111
+ log.info(`Section sync complete: ${result.synced} synced, ${result.skipped} unchanged, ${result.agedOut} aged-out, ${result.removed} removed, ${result.failed} failed`);
111
112
  if (result.failed > 0) {
112
113
  for (const error of result.errors) {
113
114
  log.error(` - ${error}`);
package/dist/index.js CHANGED
@@ -117,13 +117,22 @@ 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
+ }).catch((err) => {
135
+ api.logger.error("hyperspell: background memory sync failed", err);
127
136
  });
128
137
  }
129
138
  },
@@ -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
  /**
@@ -481,12 +498,44 @@ export async function syncAllMemoryFiles(client, workspaceDir, options) {
481
498
  */
482
499
  export async function syncAllFilesSectionized(client, workspaceDir, options) {
483
500
  const files = getSyncableFiles(workspaceDir, options?.watchPaths);
501
+ // Age pre-filter: a file untouched for longer than maxAgeDays that is
502
+ // already recorded in the manifest has been ingested at least once and is
503
+ // not changing — re-parsing/hashing/diffing it every startup is pure churn.
504
+ // Files NOT yet in the manifest are always processed regardless of age, so
505
+ // a genuinely cold start (or a never-synced old file) still ingests once.
506
+ // The live file_changed handler is unaffected: an edit bumps mtime, so
507
+ // edited-but-old files re-enter the window naturally.
508
+ const maxAgeDays = options?.maxAgeDays ?? 0;
509
+ let candidates = files;
510
+ let agedOut = 0;
511
+ if (maxAgeDays > 0) {
512
+ const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
513
+ const manifest = loadManifest(workspaceDir);
514
+ candidates = files.filter((filePath) => {
515
+ const key = fileKey(workspaceDir, filePath);
516
+ const known = manifest.files[key] !== undefined;
517
+ if (!known)
518
+ return true;
519
+ try {
520
+ if (fs.statSync(filePath).mtimeMs >= cutoff)
521
+ return true;
522
+ }
523
+ catch {
524
+ return true; // stat failed — be safe, process it
525
+ }
526
+ agedOut++;
527
+ return false;
528
+ });
529
+ if (agedOut > 0) {
530
+ log.info(`Skipping ${agedOut} unchanged file(s) older than ${maxAgeDays}d (already synced)`);
531
+ }
532
+ }
484
533
  let totalSynced = 0;
485
534
  let totalSkipped = 0;
486
535
  let totalFailed = 0;
487
536
  let totalRemoved = 0;
488
537
  const allErrors = [];
489
- for (const filePath of files) {
538
+ for (const filePath of candidates) {
490
539
  const result = await syncMarkdownFileSectionized(client, filePath, workspaceDir, {
491
540
  userId: options?.userId,
492
541
  });
@@ -501,6 +550,7 @@ export async function syncAllFilesSectionized(client, workspaceDir, options) {
501
550
  skipped: totalSkipped,
502
551
  failed: totalFailed,
503
552
  removed: totalRemoved,
553
+ agedOut,
504
554
  errors: allErrors,
505
555
  };
506
556
  }
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.1",
4
4
  "type": "module",
5
5
  "description": "OpenClaw Hyperspell memory plugin",
6
6
  "license": "MIT",