@hyperspell/openclaw-hyperspell 0.13.1 → 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/client.js +24 -0
- package/dist/commands/setup.js +20 -5
- package/dist/config.js +26 -1
- package/dist/hooks/memory-sync.js +105 -24
- package/dist/index.js +14 -2
- package/dist/sync/markdown.js +421 -16
- package/openclaw.plugin.json +25 -2
- package/package.json +2 -2
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/client.js
CHANGED
|
@@ -214,6 +214,30 @@ export class HyperspellClient {
|
|
|
214
214
|
log.debugResponse("memories.get", { resourceId, hasData: "data" in raw });
|
|
215
215
|
return raw;
|
|
216
216
|
}
|
|
217
|
+
/**
|
|
218
|
+
* Delete a memory by resource id. Memory-sync uploads land in the "vault"
|
|
219
|
+
* source (user-added documents), so that is the default. Best-effort: a
|
|
220
|
+
* 404 (already gone) is treated as success so callers can prune their
|
|
221
|
+
* local manifest unconditionally.
|
|
222
|
+
*/
|
|
223
|
+
async deleteMemory(resourceId, options) {
|
|
224
|
+
const source = options?.source ?? "vault";
|
|
225
|
+
log.debugRequest("memories.delete", { resourceId, source });
|
|
226
|
+
try {
|
|
227
|
+
await this.client.memories.delete(resourceId, { source }, this.requestOptions(options?.userId));
|
|
228
|
+
log.debugResponse("memories.delete", { resourceId, deleted: true });
|
|
229
|
+
return { deleted: true };
|
|
230
|
+
}
|
|
231
|
+
catch (err) {
|
|
232
|
+
const status = err?.status;
|
|
233
|
+
if (status === 404) {
|
|
234
|
+
log.debug(`Memory ${resourceId} already absent (404) — treating as deleted`);
|
|
235
|
+
return { deleted: true };
|
|
236
|
+
}
|
|
237
|
+
log.error(`Failed to delete memory ${resourceId}`, err);
|
|
238
|
+
return { deleted: false };
|
|
239
|
+
}
|
|
240
|
+
}
|
|
217
241
|
async sendTrace(history, options) {
|
|
218
242
|
log.debugRequest("sessions.add", {
|
|
219
243
|
historyLength: history.length,
|
package/dist/commands/setup.js
CHANGED
|
@@ -4,7 +4,7 @@ import * as path from "node:path";
|
|
|
4
4
|
import { userInfo } from "node:os";
|
|
5
5
|
import * as p from "@clack/prompts";
|
|
6
6
|
import Hyperspell from "hyperspell";
|
|
7
|
-
import { syncAllMemoryFiles, getMemoryFiles } from "../sync/markdown.js";
|
|
7
|
+
import { syncAllFilesSectionized, syncAllMemoryFiles, getMemoryFiles, } from "../sync/markdown.js";
|
|
8
8
|
import { openInBrowser } from "../lib/browser.js";
|
|
9
9
|
import { HyperspellClient } from "../client.js";
|
|
10
10
|
import { getWorkspaceDir, parseConfig, resolveConfigPath } from "../config.js";
|
|
@@ -271,6 +271,13 @@ async function runSetup() {
|
|
|
271
271
|
autoTrace: { enabled: false, extract: ["procedure"] },
|
|
272
272
|
emotionalContext: false,
|
|
273
273
|
syncMemories: true,
|
|
274
|
+
syncMemoriesConfig: {
|
|
275
|
+
enabled: true,
|
|
276
|
+
sectionize: true,
|
|
277
|
+
watchPaths: [],
|
|
278
|
+
debounceMs: 2000,
|
|
279
|
+
maxAgeDays: 30,
|
|
280
|
+
},
|
|
274
281
|
sources: [],
|
|
275
282
|
maxResults: 10,
|
|
276
283
|
relevanceThreshold: 0.6,
|
|
@@ -284,15 +291,18 @@ async function runSetup() {
|
|
|
284
291
|
loopsQuery: "open tasks pending questions unfinished promised need to follow up",
|
|
285
292
|
},
|
|
286
293
|
});
|
|
287
|
-
|
|
294
|
+
// Use the same sectionized path the runtime uses, so a fresh setup
|
|
295
|
+
// doesn't whole-file-upload everything and then get re-synced as
|
|
296
|
+
// sections on first gateway start (= duplicate memories).
|
|
297
|
+
const result = await syncAllFilesSectionized(hyperspellClient, workspaceDir);
|
|
288
298
|
if (result.failed > 0) {
|
|
289
|
-
s3.stop(`Synced ${result.synced}
|
|
299
|
+
s3.stop(`Synced ${result.synced} section(s), ${result.skipped} unchanged, ${result.failed} failed`);
|
|
290
300
|
for (const error of result.errors) {
|
|
291
301
|
p.log.error(` ${error}`);
|
|
292
302
|
}
|
|
293
303
|
}
|
|
294
304
|
else {
|
|
295
|
-
s3.stop(`Synced ${result.synced}
|
|
305
|
+
s3.stop(`Synced ${result.synced} section(s) (${result.skipped} unchanged)`);
|
|
296
306
|
}
|
|
297
307
|
}
|
|
298
308
|
else {
|
|
@@ -552,7 +562,12 @@ export function registerCliCommands(program, pluginConfig) {
|
|
|
552
562
|
const cfg = parseConfig(pluginConfig);
|
|
553
563
|
const client = new HyperspellClient(cfg);
|
|
554
564
|
const workspaceDir = getWorkspaceDir();
|
|
555
|
-
|
|
565
|
+
// Mirror the runtime's sync mode so this manual command can't
|
|
566
|
+
// produce whole-file blobs that the gateway then re-syncs as
|
|
567
|
+
// sections (= duplicate memories).
|
|
568
|
+
const result = cfg.syncMemoriesConfig.sectionize
|
|
569
|
+
? await syncAllFilesSectionized(client, workspaceDir)
|
|
570
|
+
: await syncAllMemoryFiles(client, workspaceDir);
|
|
556
571
|
process.stdout.write(`Synced ${result.synced} files, ${result.failed} failed\n`);
|
|
557
572
|
if (result.errors.length > 0) {
|
|
558
573
|
for (const error of result.errors) {
|
package/dist/config.js
CHANGED
|
@@ -235,6 +235,22 @@ export function parseConfig(raw) {
|
|
|
235
235
|
const kgRaw = (cfg.knowledgeGraph ?? {});
|
|
236
236
|
const atRaw = (cfg.autoTrace ?? {});
|
|
237
237
|
const soRaw = (cfg.startupOrientation ?? {});
|
|
238
|
+
// syncMemories can be a boolean (legacy) or an object (new)
|
|
239
|
+
const smRaw = cfg.syncMemories;
|
|
240
|
+
const syncMemoriesEnabled = typeof smRaw === "boolean"
|
|
241
|
+
? smRaw
|
|
242
|
+
: typeof smRaw === "object" && smRaw !== null
|
|
243
|
+
? smRaw.enabled ?? true
|
|
244
|
+
: false;
|
|
245
|
+
const smObj = typeof smRaw === "object" && smRaw !== null
|
|
246
|
+
? smRaw
|
|
247
|
+
: {};
|
|
248
|
+
// The rest of parseConfig is strict about unknown keys; the syncMemories
|
|
249
|
+
// object form must be too, or a typo (sectionise/debounceMS) is silently
|
|
250
|
+
// ignored and the user gets default behavior they didn't ask for.
|
|
251
|
+
if (typeof smRaw === "object" && smRaw !== null && !Array.isArray(smRaw)) {
|
|
252
|
+
assertAllowedKeys(smObj, ["enabled", "sectionize", "watchPaths", "debounceMs", "maxAgeDays"], "hyperspell.syncMemories");
|
|
253
|
+
}
|
|
238
254
|
return {
|
|
239
255
|
apiKey,
|
|
240
256
|
userId: cfg.userId,
|
|
@@ -256,7 +272,16 @@ export function parseConfig(raw) {
|
|
|
256
272
|
loopsQuery: soRaw.loopsQuery ??
|
|
257
273
|
"open tasks pending questions unfinished promised need to follow up",
|
|
258
274
|
},
|
|
259
|
-
syncMemories:
|
|
275
|
+
syncMemories: syncMemoriesEnabled,
|
|
276
|
+
syncMemoriesConfig: {
|
|
277
|
+
enabled: syncMemoriesEnabled,
|
|
278
|
+
sectionize: smObj.sectionize ?? true,
|
|
279
|
+
watchPaths: Array.isArray(smObj.watchPaths)
|
|
280
|
+
? smObj.watchPaths
|
|
281
|
+
: [],
|
|
282
|
+
debounceMs: smObj.debounceMs ?? 2000,
|
|
283
|
+
maxAgeDays: smObj.maxAgeDays ?? 30,
|
|
284
|
+
},
|
|
260
285
|
sources: parseSources(cfg.sources),
|
|
261
286
|
maxResults: cfg.maxResults ?? 10,
|
|
262
287
|
relevanceThreshold: cfg.relevanceThreshold ?? 0.6,
|
|
@@ -1,54 +1,135 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
2
|
import { getWorkspaceDir } from "../config.js";
|
|
3
3
|
import { log } from "../logger.js";
|
|
4
|
-
import { syncMarkdownFile, syncAllMemoryFiles } from "../sync/markdown.js";
|
|
4
|
+
import { syncMarkdownFile, syncMarkdownFileSectionized, syncAllMemoryFiles, syncAllFilesSectionized, } from "../sync/markdown.js";
|
|
5
5
|
/**
|
|
6
|
-
* Build a handler for file change events that syncs markdown files to Hyperspell
|
|
6
|
+
* Build a handler for file change events that syncs markdown files to Hyperspell.
|
|
7
|
+
*
|
|
8
|
+
* When `sectionize` is enabled (default for new config), files are split by
|
|
9
|
+
* ## headings and each section is synced as a separate memory. Unchanged
|
|
10
|
+
* sections (tracked by content hash) are skipped.
|
|
11
|
+
*
|
|
12
|
+
* When `sectionize` is false (legacy mode), entire files are uploaded as
|
|
13
|
+
* single memories.
|
|
7
14
|
*/
|
|
8
15
|
export function buildFileSyncHandler(client, cfg) {
|
|
9
16
|
const workspaceDir = getWorkspaceDir();
|
|
10
17
|
const memoryDir = path.join(workspaceDir, "memory");
|
|
11
18
|
const syncUserId = cfg.multiUser?.sharedUserId;
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
+
const sectionize = cfg.syncMemoriesConfig.sectionize;
|
|
20
|
+
const watchPaths = cfg.syncMemoriesConfig.watchPaths;
|
|
21
|
+
const debounceMs = cfg.syncMemoriesConfig.debounceMs;
|
|
22
|
+
// Resolve additional watch paths to absolute paths for matching
|
|
23
|
+
const resolvedWatchPaths = (watchPaths ?? []).map((wp) => wp.startsWith("/") ? wp : path.join(workspaceDir, wp));
|
|
24
|
+
// Debounce map: filePath -> timeout handle
|
|
25
|
+
const debounceTimers = new Map();
|
|
26
|
+
/**
|
|
27
|
+
* Check whether a file path is syncable (in memory/ or in a watchPath).
|
|
28
|
+
*/
|
|
29
|
+
function isSyncable(filePath) {
|
|
30
|
+
if (!filePath.endsWith(".md"))
|
|
31
|
+
return false;
|
|
32
|
+
// Always sync memory/ files
|
|
33
|
+
if (filePath.startsWith(memoryDir + path.sep))
|
|
34
|
+
return true;
|
|
35
|
+
// Check additional watch paths
|
|
36
|
+
for (const wp of resolvedWatchPaths) {
|
|
37
|
+
if (filePath === wp || filePath.startsWith(wp + "/")) {
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
19
40
|
}
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
async function doSync(filePath) {
|
|
20
44
|
const fileName = path.basename(filePath);
|
|
21
45
|
log.info(`Memory file changed: ${fileName}`);
|
|
22
46
|
try {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
47
|
+
if (sectionize) {
|
|
48
|
+
const result = await syncMarkdownFileSectionized(client, filePath, workspaceDir, { userId: syncUserId });
|
|
49
|
+
if (result.synced > 0 || result.removed > 0) {
|
|
50
|
+
log.info(`Section-synced ${fileName}: ${result.synced} synced, ${result.skipped} unchanged, ${result.removed} removed`);
|
|
51
|
+
}
|
|
52
|
+
else if (result.skipped > 0) {
|
|
53
|
+
log.debug(`${fileName}: all ${result.skipped} sections unchanged`);
|
|
54
|
+
}
|
|
55
|
+
if (result.failed > 0) {
|
|
56
|
+
log.error(`${fileName}: ${result.failed} sections failed:`);
|
|
57
|
+
for (const err of result.errors) {
|
|
58
|
+
log.error(` - ${err}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
26
61
|
}
|
|
27
62
|
else {
|
|
28
|
-
|
|
63
|
+
// Legacy whole-file sync
|
|
64
|
+
const result = await syncMarkdownFile(client, filePath, { userId: syncUserId });
|
|
65
|
+
if (result.success) {
|
|
66
|
+
log.info(`Synced ${fileName} -> ${result.resourceId}`);
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
log.error(`Failed to sync ${fileName}: ${result.error}`);
|
|
70
|
+
}
|
|
29
71
|
}
|
|
30
72
|
}
|
|
31
73
|
catch (err) {
|
|
32
74
|
log.error(`Error syncing ${fileName}`, err);
|
|
33
75
|
}
|
|
76
|
+
}
|
|
77
|
+
return async (event) => {
|
|
78
|
+
const filePath = event.file_path;
|
|
79
|
+
if (!filePath)
|
|
80
|
+
return;
|
|
81
|
+
if (!isSyncable(filePath))
|
|
82
|
+
return;
|
|
83
|
+
// Debounce: if the file changes rapidly (agent writing in chunks),
|
|
84
|
+
// wait until writes settle before syncing.
|
|
85
|
+
if (debounceMs > 0) {
|
|
86
|
+
const existing = debounceTimers.get(filePath);
|
|
87
|
+
if (existing)
|
|
88
|
+
clearTimeout(existing);
|
|
89
|
+
debounceTimers.set(filePath, setTimeout(() => {
|
|
90
|
+
debounceTimers.delete(filePath);
|
|
91
|
+
doSync(filePath).catch((err) => log.error("Debounced sync failed", err));
|
|
92
|
+
}, debounceMs));
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
await doSync(filePath);
|
|
96
|
+
}
|
|
34
97
|
};
|
|
35
98
|
}
|
|
36
99
|
/**
|
|
37
|
-
* Sync all existing memory files on startup
|
|
100
|
+
* Sync all existing memory files on startup.
|
|
101
|
+
* Uses section-level sync when sectionize is enabled.
|
|
38
102
|
*/
|
|
39
103
|
export async function syncMemoriesOnStartup(client, workspaceDir, options) {
|
|
40
104
|
log.info("Syncing existing memory files...");
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
105
|
+
if (options?.sectionize) {
|
|
106
|
+
const result = await syncAllFilesSectionized(client, workspaceDir, {
|
|
107
|
+
userId: options.userId,
|
|
108
|
+
watchPaths: options.watchPaths,
|
|
109
|
+
maxAgeDays: options.maxAgeDays,
|
|
110
|
+
});
|
|
111
|
+
log.info(`Section sync complete: ${result.synced} synced, ${result.skipped} unchanged, ${result.agedOut} aged-out, ${result.removed} removed, ${result.failed} failed`);
|
|
112
|
+
if (result.failed > 0) {
|
|
113
|
+
for (const error of result.errors) {
|
|
114
|
+
log.error(` - ${error}`);
|
|
115
|
+
}
|
|
49
116
|
}
|
|
50
117
|
}
|
|
51
|
-
|
|
52
|
-
|
|
118
|
+
else {
|
|
119
|
+
const result = await syncAllMemoryFiles(client, workspaceDir, {
|
|
120
|
+
userId: options?.userId,
|
|
121
|
+
});
|
|
122
|
+
if (result.synced > 0) {
|
|
123
|
+
log.info(`Synced ${result.synced} memory files`);
|
|
124
|
+
}
|
|
125
|
+
if (result.failed > 0) {
|
|
126
|
+
log.error(`Failed to sync ${result.failed} files:`);
|
|
127
|
+
for (const error of result.errors) {
|
|
128
|
+
log.error(` - ${error}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (result.synced === 0 && result.failed === 0) {
|
|
132
|
+
log.info("No memory files found in memory/ directory");
|
|
133
|
+
}
|
|
53
134
|
}
|
|
54
135
|
}
|
package/dist/index.js
CHANGED
|
@@ -104,6 +104,7 @@ export default {
|
|
|
104
104
|
if (cfg.syncMemories) {
|
|
105
105
|
const fileSyncHandler = buildFileSyncHandler(client, cfg);
|
|
106
106
|
api.on("file_changed", fileSyncHandler);
|
|
107
|
+
api.logger.info(`hyperspell: memory sync enabled (sectionize=${cfg.syncMemoriesConfig.sectionize}, watchPaths=${cfg.syncMemoriesConfig.watchPaths.length})`);
|
|
107
108
|
}
|
|
108
109
|
// Register memory network tools
|
|
109
110
|
if (cfg.knowledgeGraph.enabled) {
|
|
@@ -116,11 +117,22 @@ export default {
|
|
|
116
117
|
id: "openclaw-hyperspell",
|
|
117
118
|
start: async () => {
|
|
118
119
|
api.logger.info("hyperspell: connected");
|
|
119
|
-
// 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.
|
|
120
127
|
if (cfg.syncMemories) {
|
|
121
128
|
const workspaceDir = getWorkspaceDir();
|
|
122
|
-
|
|
129
|
+
void syncMemoriesOnStartup(client, workspaceDir, {
|
|
123
130
|
userId: cfg.multiUser?.sharedUserId,
|
|
131
|
+
sectionize: cfg.syncMemoriesConfig.sectionize,
|
|
132
|
+
watchPaths: cfg.syncMemoriesConfig.watchPaths,
|
|
133
|
+
maxAgeDays: cfg.syncMemoriesConfig.maxAgeDays,
|
|
134
|
+
}).catch((err) => {
|
|
135
|
+
api.logger.error("hyperspell: background memory sync failed", err);
|
|
124
136
|
});
|
|
125
137
|
}
|
|
126
138
|
},
|
package/dist/sync/markdown.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
+
import * as crypto from "node:crypto";
|
|
3
4
|
import { log } from "../logger.js";
|
|
4
5
|
const FRONTMATTER_REGEX = /^---\n([\s\S]*?)\n---\n?/;
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
const MANIFEST_VERSION = 1;
|
|
7
|
+
const HASH_FILE_NAME = ".hyperspell-sync-hashes.json";
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Frontmatter helpers (unchanged from original)
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
8
11
|
function parseFrontmatter(content) {
|
|
9
12
|
const match = content.match(FRONTMATTER_REGEX);
|
|
10
13
|
if (!match) {
|
|
@@ -23,16 +26,10 @@ function parseFrontmatter(content) {
|
|
|
23
26
|
}
|
|
24
27
|
return { frontmatter, body };
|
|
25
28
|
}
|
|
26
|
-
/**
|
|
27
|
-
* Serialize frontmatter back to string
|
|
28
|
-
*/
|
|
29
29
|
function serializeFrontmatter(frontmatter) {
|
|
30
30
|
const lines = Object.entries(frontmatter).map(([key, value]) => `${key}: ${value}`);
|
|
31
31
|
return `---\n${lines.join("\n")}\n---\n`;
|
|
32
32
|
}
|
|
33
|
-
/**
|
|
34
|
-
* Read a markdown file and parse its content
|
|
35
|
-
*/
|
|
36
33
|
function readMarkdownFile(filePath) {
|
|
37
34
|
try {
|
|
38
35
|
const content = fs.readFileSync(filePath, "utf-8");
|
|
@@ -50,9 +47,6 @@ function readMarkdownFile(filePath) {
|
|
|
50
47
|
return null;
|
|
51
48
|
}
|
|
52
49
|
}
|
|
53
|
-
/**
|
|
54
|
-
* Update the hyperspell_id in the frontmatter of a markdown file
|
|
55
|
-
*/
|
|
56
50
|
function updateFrontmatterId(filePath, hyperspellId) {
|
|
57
51
|
try {
|
|
58
52
|
const content = fs.readFileSync(filePath, "utf-8");
|
|
@@ -66,9 +60,169 @@ function updateFrontmatterId(filePath, hyperspellId) {
|
|
|
66
60
|
log.error(`Failed to update frontmatter in ${filePath}`, err);
|
|
67
61
|
}
|
|
68
62
|
}
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Section-level parsing
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
function contentHash(text) {
|
|
67
|
+
return crypto.createHash("sha256").update(text.trim()).digest("hex").slice(0, 16);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Split markdown content into sections delimited by ## headings.
|
|
71
|
+
* Content before the first ## heading becomes a section titled after the
|
|
72
|
+
* file-level # heading (or the filename).
|
|
73
|
+
*
|
|
74
|
+
* Sections shorter than `minLength` characters are merged into the previous
|
|
75
|
+
* section to avoid noisy micro-memories.
|
|
76
|
+
*/
|
|
77
|
+
export function parseMarkdownSections(content, fallbackTitle, minLength = 80) {
|
|
78
|
+
const lines = content.split("\n");
|
|
79
|
+
const raw = [];
|
|
80
|
+
let fileTitle = fallbackTitle;
|
|
81
|
+
let currentSection = null;
|
|
82
|
+
for (let i = 0; i < lines.length; i++) {
|
|
83
|
+
const line = lines[i];
|
|
84
|
+
// Capture file-level title from # heading
|
|
85
|
+
if (/^# /.test(line) && currentSection === null) {
|
|
86
|
+
fileTitle = line.replace(/^# /, "").trim();
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
// New section on ## heading
|
|
90
|
+
if (/^## /.test(line)) {
|
|
91
|
+
if (currentSection) {
|
|
92
|
+
raw.push(currentSection);
|
|
93
|
+
}
|
|
94
|
+
currentSection = {
|
|
95
|
+
title: line.replace(/^## /, "").trim(),
|
|
96
|
+
lines: [],
|
|
97
|
+
startLine: i + 1,
|
|
98
|
+
};
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (currentSection) {
|
|
102
|
+
currentSection.lines.push(line);
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
// Content before any ## heading — becomes preamble section
|
|
106
|
+
if (!raw.length) {
|
|
107
|
+
currentSection = { title: fileTitle, lines: [line], startLine: i + 1 };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (currentSection) {
|
|
112
|
+
raw.push(currentSection);
|
|
113
|
+
}
|
|
114
|
+
// Merge short sections into the previous one
|
|
115
|
+
const merged = [];
|
|
116
|
+
for (const section of raw) {
|
|
117
|
+
const text = section.lines.join("\n").trim();
|
|
118
|
+
if (!text)
|
|
119
|
+
continue;
|
|
120
|
+
if (text.length < minLength && merged.length > 0) {
|
|
121
|
+
// Append to previous section
|
|
122
|
+
const prev = merged[merged.length - 1];
|
|
123
|
+
prev.content += `\n\n### ${section.title}\n${text}`;
|
|
124
|
+
prev.contentHash = contentHash(prev.content);
|
|
125
|
+
prev.endLine = section.startLine + section.lines.length;
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
merged.push({
|
|
129
|
+
title: section.title,
|
|
130
|
+
content: text,
|
|
131
|
+
contentHash: contentHash(text),
|
|
132
|
+
startLine: section.startLine,
|
|
133
|
+
endLine: section.startLine + section.lines.length,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return merged;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Section titles key the manifest per file, so two sections sharing the same
|
|
141
|
+
* ## heading in one file would collide — one resourceId tracked, the other
|
|
142
|
+
* silently orphaned and re-uploaded on every sync. Disambiguate repeats so
|
|
143
|
+
* each section maps to a stable, unique key.
|
|
144
|
+
*/
|
|
145
|
+
export function dedupeTitles(sections) {
|
|
146
|
+
const seen = new Map();
|
|
147
|
+
return sections.map((s) => {
|
|
148
|
+
const n = (seen.get(s.title) ?? 0) + 1;
|
|
149
|
+
seen.set(s.title, n);
|
|
150
|
+
return n === 1 ? s : { ...s, title: `${s.title} (${n})` };
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// Sync manifest (persisted to workspace, workspace-relative keys)
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
function manifestPath(workspaceDir) {
|
|
157
|
+
return path.join(workspaceDir, HASH_FILE_NAME);
|
|
158
|
+
}
|
|
159
|
+
/** Workspace-relative, posix-normalized key so the manifest is portable. */
|
|
160
|
+
export function fileKey(workspaceDir, filePath) {
|
|
161
|
+
return path.relative(workspaceDir, filePath).split(path.sep).join("/");
|
|
162
|
+
}
|
|
163
|
+
function emptyManifest() {
|
|
164
|
+
return { version: MANIFEST_VERSION, files: {} };
|
|
165
|
+
}
|
|
166
|
+
export function loadManifest(workspaceDir) {
|
|
167
|
+
const p = manifestPath(workspaceDir);
|
|
168
|
+
try {
|
|
169
|
+
if (fs.existsSync(p)) {
|
|
170
|
+
const parsed = JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
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) {
|
|
179
|
+
return { version: MANIFEST_VERSION, files: parsed.files };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
catch (err) {
|
|
184
|
+
log.error("Failed to read sync manifest", err);
|
|
185
|
+
}
|
|
186
|
+
return emptyManifest();
|
|
187
|
+
}
|
|
188
|
+
export function saveManifest(workspaceDir, manifest) {
|
|
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`;
|
|
195
|
+
try {
|
|
196
|
+
fs.writeFileSync(tmp, JSON.stringify(manifest, null, 2));
|
|
197
|
+
fs.renameSync(tmp, p);
|
|
198
|
+
}
|
|
199
|
+
catch (err) {
|
|
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
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
69
210
|
/**
|
|
70
|
-
*
|
|
211
|
+
* Serializes manifest read-modify-write across the whole process. Startup bulk
|
|
212
|
+
* sync and the live file_changed handler (plus independent per-file debounce
|
|
213
|
+
* timers) would otherwise interleave load → await addMemory → save and lose
|
|
214
|
+
* resourceId entries, causing the next sync to re-upload sections as brand-new
|
|
215
|
+
* memories. Every load→sync→save runs inside this critical section.
|
|
71
216
|
*/
|
|
217
|
+
let syncChain = Promise.resolve();
|
|
218
|
+
export function withSyncLock(fn) {
|
|
219
|
+
const run = syncChain.then(fn, fn);
|
|
220
|
+
syncChain = run.then(() => undefined, () => undefined);
|
|
221
|
+
return run;
|
|
222
|
+
}
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
// Public API — file-level sync (original, kept for backward compat)
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
72
226
|
export function getMemoryFiles(workspaceDir) {
|
|
73
227
|
const memoryDir = path.join(workspaceDir, "memory");
|
|
74
228
|
if (!fs.existsSync(memoryDir)) {
|
|
@@ -95,7 +249,47 @@ export function getMemoryFiles(workspaceDir) {
|
|
|
95
249
|
return results;
|
|
96
250
|
}
|
|
97
251
|
/**
|
|
98
|
-
*
|
|
252
|
+
* Collect all syncable files based on configuration.
|
|
253
|
+
* Includes memory/*.md by default, plus any additional watchPaths.
|
|
254
|
+
*/
|
|
255
|
+
export function getSyncableFiles(workspaceDir, watchPaths) {
|
|
256
|
+
const files = getMemoryFiles(workspaceDir);
|
|
257
|
+
if (watchPaths) {
|
|
258
|
+
for (const wp of watchPaths) {
|
|
259
|
+
const resolved = wp.startsWith("/") ? wp : path.join(workspaceDir, wp);
|
|
260
|
+
if (!fs.existsSync(resolved))
|
|
261
|
+
continue;
|
|
262
|
+
const stat = fs.statSync(resolved);
|
|
263
|
+
if (stat.isFile() && resolved.endsWith(".md")) {
|
|
264
|
+
if (!files.includes(resolved)) {
|
|
265
|
+
files.push(resolved);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
else if (stat.isDirectory()) {
|
|
269
|
+
// Walk directory for .md files
|
|
270
|
+
const walk = (dir) => {
|
|
271
|
+
try {
|
|
272
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
273
|
+
const fullPath = path.join(dir, entry.name);
|
|
274
|
+
if (entry.isDirectory()) {
|
|
275
|
+
walk(fullPath);
|
|
276
|
+
}
|
|
277
|
+
else if (entry.name.endsWith(".md") && !files.includes(fullPath)) {
|
|
278
|
+
files.push(fullPath);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
catch (_err) { /* skip unreadable dirs */ }
|
|
283
|
+
};
|
|
284
|
+
walk(resolved);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return files;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Original whole-file sync (kept for backward compatibility when
|
|
292
|
+
* sectionize is false).
|
|
99
293
|
*/
|
|
100
294
|
export async function syncMarkdownFile(client, filePath, options) {
|
|
101
295
|
const file = readMarkdownFile(filePath);
|
|
@@ -116,7 +310,6 @@ export async function syncMarkdownFile(client, filePath, options) {
|
|
|
116
310
|
},
|
|
117
311
|
userId: options?.userId,
|
|
118
312
|
});
|
|
119
|
-
// Update frontmatter with new resource ID if it changed or was newly created
|
|
120
313
|
if (result.resourceId !== file.hyperspellId) {
|
|
121
314
|
updateFrontmatterId(filePath, result.resourceId);
|
|
122
315
|
}
|
|
@@ -128,9 +321,156 @@ export async function syncMarkdownFile(client, filePath, options) {
|
|
|
128
321
|
return { success: false, error: errorMsg };
|
|
129
322
|
}
|
|
130
323
|
}
|
|
324
|
+
// ---------------------------------------------------------------------------
|
|
325
|
+
// Section-level sync (new)
|
|
326
|
+
// ---------------------------------------------------------------------------
|
|
131
327
|
/**
|
|
132
|
-
* Sync
|
|
328
|
+
* Sync a single markdown file to Hyperspell at section granularity.
|
|
329
|
+
* Each ## section becomes a separate memory. Content hashes are tracked
|
|
330
|
+
* so unchanged sections are skipped on subsequent syncs.
|
|
331
|
+
*
|
|
332
|
+
* The section title is prepended with the file-level context (e.g.
|
|
333
|
+
* "2026-05-09 — The day David's mum got sick") for better retrieval.
|
|
133
334
|
*/
|
|
335
|
+
export async function syncMarkdownFileSectionized(client, filePath, workspaceDir, options) {
|
|
336
|
+
return withSyncLock(async () => {
|
|
337
|
+
const empty = { synced: 0, skipped: 0, failed: 0, removed: 0, errors: [] };
|
|
338
|
+
try {
|
|
339
|
+
const file = readMarkdownFile(filePath);
|
|
340
|
+
if (!file || !file.content) {
|
|
341
|
+
return file ? empty : { ...empty, errors: ["Failed to read file"] };
|
|
342
|
+
}
|
|
343
|
+
const sections = dedupeTitles(parseMarkdownSections(file.content, file.title));
|
|
344
|
+
if (sections.length === 0) {
|
|
345
|
+
return empty;
|
|
346
|
+
}
|
|
347
|
+
const manifest = loadManifest(workspaceDir);
|
|
348
|
+
const key = fileKey(workspaceDir, filePath);
|
|
349
|
+
const prevSections = manifest.files[key]?.sections ?? {};
|
|
350
|
+
const fileName = path.basename(filePath, ".md");
|
|
351
|
+
let synced = 0;
|
|
352
|
+
let skipped = 0;
|
|
353
|
+
let failed = 0;
|
|
354
|
+
let removed = 0;
|
|
355
|
+
let dirty = false;
|
|
356
|
+
const errors = [];
|
|
357
|
+
const currentTitles = new Set(sections.map((s) => s.title));
|
|
358
|
+
// Sections in the manifest but absent from the current parse are either
|
|
359
|
+
// renames (same content hash resurfacing under a new title) or true
|
|
360
|
+
// deletions. Index leftovers by content hash so a renamed section can
|
|
361
|
+
// reclaim its existing Hyperspell resource instead of creating a
|
|
362
|
+
// duplicate + orphan. Hash collisions among removed sections are rare
|
|
363
|
+
// and resolved best-effort (last writer wins).
|
|
364
|
+
const orphanByHash = new Map();
|
|
365
|
+
for (const [title, rec] of Object.entries(prevSections)) {
|
|
366
|
+
if (!currentTitles.has(title)) {
|
|
367
|
+
orphanByHash.set(rec.hash, { title, resourceId: rec.resourceId });
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
const newSections = {};
|
|
371
|
+
for (const section of sections) {
|
|
372
|
+
const prev = prevSections[section.title];
|
|
373
|
+
// Unchanged: keep the record, skip the upload.
|
|
374
|
+
if (prev && prev.hash === section.contentHash) {
|
|
375
|
+
newSections[section.title] = prev;
|
|
376
|
+
skipped++;
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
// New title: if identical content was just removed under a different
|
|
380
|
+
// heading, this is a rename — reuse the resource and claim it so it is
|
|
381
|
+
// not also deleted as an orphan below.
|
|
382
|
+
let reuseResourceId = prev?.resourceId;
|
|
383
|
+
if (!prev) {
|
|
384
|
+
const renamedFrom = orphanByHash.get(section.contentHash);
|
|
385
|
+
if (renamedFrom) {
|
|
386
|
+
reuseResourceId = renamedFrom.resourceId;
|
|
387
|
+
orphanByHash.delete(section.contentHash);
|
|
388
|
+
log.info(`Section renamed: "${renamedFrom.title}" -> "${section.title}" (${fileName}) — updating in place`);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
const memoryTitle = file.title !== section.title ? `${file.title} — ${section.title}` : section.title;
|
|
392
|
+
try {
|
|
393
|
+
const result = await client.addMemory(section.content, {
|
|
394
|
+
title: memoryTitle,
|
|
395
|
+
resourceId: reuseResourceId,
|
|
396
|
+
collection: "openclaw",
|
|
397
|
+
metadata: {
|
|
398
|
+
openclaw_source: "memory_sync_section",
|
|
399
|
+
file_path: filePath,
|
|
400
|
+
file_name: fileName,
|
|
401
|
+
section_title: section.title,
|
|
402
|
+
content_hash: section.contentHash,
|
|
403
|
+
},
|
|
404
|
+
userId: options?.userId,
|
|
405
|
+
});
|
|
406
|
+
newSections[section.title] = {
|
|
407
|
+
hash: section.contentHash,
|
|
408
|
+
resourceId: result.resourceId,
|
|
409
|
+
};
|
|
410
|
+
synced++;
|
|
411
|
+
dirty = true;
|
|
412
|
+
log.info(`Synced section: ${memoryTitle} -> ${result.resourceId}`);
|
|
413
|
+
}
|
|
414
|
+
catch (err) {
|
|
415
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
416
|
+
errors.push(`${memoryTitle}: ${errorMsg}`);
|
|
417
|
+
failed++;
|
|
418
|
+
// Preserve the prior record so a transient failure does not drop the
|
|
419
|
+
// resourceId and force a duplicate upload next run.
|
|
420
|
+
if (prev)
|
|
421
|
+
newSections[section.title] = prev;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
// Whatever remains is a genuine deletion: gone from source, no rename
|
|
425
|
+
// reclaimed it. Delete the remote memory so retrieval does not keep
|
|
426
|
+
// serving content the user removed.
|
|
427
|
+
for (const { title, resourceId } of orphanByHash.values()) {
|
|
428
|
+
if (!resourceId) {
|
|
429
|
+
// Never got a resourceId — nothing to delete, just drop the record.
|
|
430
|
+
dirty = true;
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
const { deleted } = await client.deleteMemory(resourceId, {
|
|
434
|
+
userId: options?.userId,
|
|
435
|
+
});
|
|
436
|
+
if (deleted) {
|
|
437
|
+
removed++;
|
|
438
|
+
dirty = true;
|
|
439
|
+
log.info(`Removed deleted section "${title}" (${fileName}) -> ${resourceId}`);
|
|
440
|
+
}
|
|
441
|
+
else {
|
|
442
|
+
// Keep the record so the delete is retried next run rather than
|
|
443
|
+
// silently leaking the orphan into the retrieval layer. Count it
|
|
444
|
+
// as a failure so the stats are consistent and callers that gate
|
|
445
|
+
// logging on `failed > 0` actually surface it.
|
|
446
|
+
newSections[title] = prevSections[title];
|
|
447
|
+
failed++;
|
|
448
|
+
errors.push(`delete "${title}": failed`);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
if (Object.keys(newSections).length > 0) {
|
|
452
|
+
manifest.files[key] = { sections: newSections };
|
|
453
|
+
}
|
|
454
|
+
else {
|
|
455
|
+
delete manifest.files[key];
|
|
456
|
+
}
|
|
457
|
+
if (dirty) {
|
|
458
|
+
saveManifest(workspaceDir, manifest);
|
|
459
|
+
}
|
|
460
|
+
return { synced, skipped, failed, removed, errors };
|
|
461
|
+
}
|
|
462
|
+
catch (err) {
|
|
463
|
+
// The body is defensive everywhere above; this guards the lock chain
|
|
464
|
+
// against an unexpected throw so one bad file cannot wedge the queue.
|
|
465
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
466
|
+
log.error(`Sectionized sync crashed for ${filePath}`, err);
|
|
467
|
+
return { ...empty, errors: [msg] };
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
// ---------------------------------------------------------------------------
|
|
472
|
+
// Bulk sync (original whole-file approach)
|
|
473
|
+
// ---------------------------------------------------------------------------
|
|
134
474
|
export async function syncAllMemoryFiles(client, workspaceDir, options) {
|
|
135
475
|
const files = getMemoryFiles(workspaceDir);
|
|
136
476
|
let synced = 0;
|
|
@@ -149,3 +489,68 @@ export async function syncAllMemoryFiles(client, workspaceDir, options) {
|
|
|
149
489
|
}
|
|
150
490
|
return { synced, failed, errors };
|
|
151
491
|
}
|
|
492
|
+
// ---------------------------------------------------------------------------
|
|
493
|
+
// Bulk sync (section-level approach)
|
|
494
|
+
// ---------------------------------------------------------------------------
|
|
495
|
+
/**
|
|
496
|
+
* Sync all syncable files at section granularity.
|
|
497
|
+
* Unchanged sections (by content hash) are skipped.
|
|
498
|
+
*/
|
|
499
|
+
export async function syncAllFilesSectionized(client, workspaceDir, options) {
|
|
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
|
+
}
|
|
533
|
+
let totalSynced = 0;
|
|
534
|
+
let totalSkipped = 0;
|
|
535
|
+
let totalFailed = 0;
|
|
536
|
+
let totalRemoved = 0;
|
|
537
|
+
const allErrors = [];
|
|
538
|
+
for (const filePath of candidates) {
|
|
539
|
+
const result = await syncMarkdownFileSectionized(client, filePath, workspaceDir, {
|
|
540
|
+
userId: options?.userId,
|
|
541
|
+
});
|
|
542
|
+
totalSynced += result.synced;
|
|
543
|
+
totalSkipped += result.skipped;
|
|
544
|
+
totalFailed += result.failed;
|
|
545
|
+
totalRemoved += result.removed;
|
|
546
|
+
allErrors.push(...result.errors);
|
|
547
|
+
}
|
|
548
|
+
return {
|
|
549
|
+
synced: totalSynced,
|
|
550
|
+
skipped: totalSkipped,
|
|
551
|
+
failed: totalFailed,
|
|
552
|
+
removed: totalRemoved,
|
|
553
|
+
agedOut,
|
|
554
|
+
errors: allErrors,
|
|
555
|
+
};
|
|
556
|
+
}
|
package/openclaw.plugin.json
CHANGED
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
},
|
|
72
72
|
"syncMemories": {
|
|
73
73
|
"label": "Sync Memory Files",
|
|
74
|
-
"help": "Automatically sync markdown files
|
|
74
|
+
"help": "Automatically sync markdown files to Hyperspell. Set to true for defaults, or use an object for fine-grained control: { sectionize: true, watchPaths: ['MEMORY.md', 'notes/'], debounceMs: 2000 }",
|
|
75
75
|
"advanced": true
|
|
76
76
|
},
|
|
77
77
|
"knowledgeGraph": {
|
|
@@ -144,7 +144,30 @@
|
|
|
144
144
|
"type": "boolean"
|
|
145
145
|
},
|
|
146
146
|
"syncMemories": {
|
|
147
|
-
"
|
|
147
|
+
"oneOf": [
|
|
148
|
+
{ "type": "boolean" },
|
|
149
|
+
{
|
|
150
|
+
"type": "object",
|
|
151
|
+
"properties": {
|
|
152
|
+
"enabled": { "type": "boolean" },
|
|
153
|
+
"sectionize": {
|
|
154
|
+
"type": "boolean",
|
|
155
|
+
"description": "Split files by ## headings into separate memories for better retrieval granularity (default: true)"
|
|
156
|
+
},
|
|
157
|
+
"watchPaths": {
|
|
158
|
+
"type": "array",
|
|
159
|
+
"items": { "type": "string" },
|
|
160
|
+
"description": "Additional paths to watch beyond memory/ (relative to workspace or absolute)"
|
|
161
|
+
},
|
|
162
|
+
"debounceMs": {
|
|
163
|
+
"type": "number",
|
|
164
|
+
"minimum": 0,
|
|
165
|
+
"maximum": 30000,
|
|
166
|
+
"description": "Debounce file changes in ms to avoid syncing mid-write (default: 2000)"
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
]
|
|
148
171
|
},
|
|
149
172
|
"knowledgeGraph": {
|
|
150
173
|
"type": "object",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperspell/openclaw-hyperspell",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.1",
|
|
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 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 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": [
|