@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 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,
@@ -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
- const result = await syncAllMemoryFiles(hyperspellClient, workspaceDir);
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} files, ${result.failed} failed`);
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} memory files`);
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
- const result = await syncAllMemoryFiles(client, workspaceDir);
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: cfg.syncMemories ?? false,
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
- return async (event) => {
13
- const filePath = event.file_path;
14
- if (!filePath)
15
- return;
16
- // Only process markdown files in the workspace's memory directory
17
- if (!filePath.startsWith(memoryDir) || !filePath.endsWith(".md")) {
18
- return;
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
- const result = await syncMarkdownFile(client, filePath, { userId: syncUserId });
24
- if (result.success) {
25
- log.info(`Synced ${fileName} -> ${result.resourceId}`);
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
- log.error(`Failed to sync ${fileName}: ${result.error}`);
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
- const result = await syncAllMemoryFiles(client, workspaceDir, { userId: options?.userId });
42
- if (result.synced > 0) {
43
- log.info(`Synced ${result.synced} memory files`);
44
- }
45
- if (result.failed > 0) {
46
- log.error(`Failed to sync ${result.failed} files:`);
47
- for (const error of result.errors) {
48
- log.error(` - ${error}`);
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
- if (result.synced === 0 && result.failed === 0) {
52
- log.info("No memory files found in memory/ directory");
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
- await syncMemoriesOnStartup(client, workspaceDir, {
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
  },
@@ -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
- * Parse frontmatter from markdown content
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
- * Get all markdown files from the memory directory, including subdirectories
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
- * Sync a single markdown file to Hyperspell
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 all markdown files in the memory directory
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
+ }
@@ -71,7 +71,7 @@
71
71
  },
72
72
  "syncMemories": {
73
73
  "label": "Sync Memory Files",
74
- "help": "Automatically sync markdown files in workspace/memory/ to Hyperspell",
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
- "type": "boolean"
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.13.1",
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": [