@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 +16 -0
- package/dist/commands/setup.js +1 -0
- package/dist/config.js +2 -1
- package/dist/hooks/memory-sync.js +2 -1
- package/dist/index.js +11 -2
- package/dist/sync/markdown.js +56 -6
- package/package.json +1 -1
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
|
|
package/dist/commands/setup.js
CHANGED
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
|
-
|
|
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
|
},
|
package/dist/sync/markdown.js
CHANGED
|
@@ -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
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
|
|
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(
|
|
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
|
|
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
|
}
|