@hyperspell/openclaw-hyperspell 0.13.0 → 0.14.0

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/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,12 @@ 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
+ },
274
280
  sources: [],
275
281
  maxResults: 10,
276
282
  relevanceThreshold: 0.6,
@@ -281,19 +287,21 @@ async function runSetup() {
281
287
  recentDays: 7,
282
288
  recentLimit: 5,
283
289
  loopsLimit: 3,
284
- recentQuery: "conversation session interaction",
285
290
  loopsQuery: "open tasks pending questions unfinished promised need to follow up",
286
291
  },
287
292
  });
288
- const result = await syncAllMemoryFiles(hyperspellClient, workspaceDir);
293
+ // Use the same sectionized path the runtime uses, so a fresh setup
294
+ // doesn't whole-file-upload everything and then get re-synced as
295
+ // sections on first gateway start (= duplicate memories).
296
+ const result = await syncAllFilesSectionized(hyperspellClient, workspaceDir);
289
297
  if (result.failed > 0) {
290
- s3.stop(`Synced ${result.synced} files, ${result.failed} failed`);
298
+ s3.stop(`Synced ${result.synced} section(s), ${result.skipped} unchanged, ${result.failed} failed`);
291
299
  for (const error of result.errors) {
292
300
  p.log.error(` ${error}`);
293
301
  }
294
302
  }
295
303
  else {
296
- s3.stop(`Synced ${result.synced} memory files`);
304
+ s3.stop(`Synced ${result.synced} section(s) (${result.skipped} unchanged)`);
297
305
  }
298
306
  }
299
307
  else {
@@ -553,7 +561,12 @@ export function registerCliCommands(program, pluginConfig) {
553
561
  const cfg = parseConfig(pluginConfig);
554
562
  const client = new HyperspellClient(cfg);
555
563
  const workspaceDir = getWorkspaceDir();
556
- const result = await syncAllMemoryFiles(client, workspaceDir);
564
+ // Mirror the runtime's sync mode so this manual command can't
565
+ // produce whole-file blobs that the gateway then re-syncs as
566
+ // sections (= duplicate memories).
567
+ const result = cfg.syncMemoriesConfig.sectionize
568
+ ? await syncAllFilesSectionized(client, workspaceDir)
569
+ : await syncAllMemoryFiles(client, workspaceDir);
557
570
  process.stdout.write(`Synced ${result.synced} files, ${result.failed} failed\n`);
558
571
  if (result.errors.length > 0) {
559
572
  for (const error of result.errors) {
package/dist/config.js CHANGED
@@ -25,6 +25,7 @@ const ALLOWED_KEYS = [
25
25
  "debug",
26
26
  "knowledgeGraph",
27
27
  "multiUser",
28
+ "dreaming",
28
29
  ];
29
30
  const VALID_SOURCES = [
30
31
  "reddit",
@@ -234,6 +235,22 @@ export function parseConfig(raw) {
234
235
  const kgRaw = (cfg.knowledgeGraph ?? {});
235
236
  const atRaw = (cfg.autoTrace ?? {});
236
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"], "hyperspell.syncMemories");
253
+ }
237
254
  return {
238
255
  apiKey,
239
256
  userId: cfg.userId,
@@ -252,11 +269,18 @@ export function parseConfig(raw) {
252
269
  recentDays: soRaw.recentDays ?? 7,
253
270
  recentLimit: soRaw.recentLimit ?? 5,
254
271
  loopsLimit: soRaw.loopsLimit ?? 3,
255
- recentQuery: soRaw.recentQuery ?? "conversation session interaction",
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
+ },
260
284
  sources: parseSources(cfg.sources),
261
285
  maxResults: cfg.maxResults ?? 10,
262
286
  relevanceThreshold: cfg.relevanceThreshold ?? 0.6,
@@ -13,6 +13,8 @@ export function sanitizeTraceText(input) {
13
13
  let out = input;
14
14
  out = out.replace(/<hyperspell-context>[\s\S]*?<\/hyperspell-context>\n?/g, "");
15
15
  out = out.replace(/<hyperspell-emotional-context>[\s\S]*?<\/hyperspell-emotional-context>\n?/g, "");
16
+ out = out.replace(/<hyperspell-recent-interactions>[\s\S]*?<\/hyperspell-recent-interactions>\n?/g, "");
17
+ out = out.replace(/<hyperspell-unfinished-loops>[\s\S]*?<\/hyperspell-unfinished-loops>\n?/g, "");
16
18
  out = out.replace(/Sender \(untrusted metadata\):\s*```json[\s\S]*?```\n?/g, "");
17
19
  out = out.replace(/\[Bootstrap pending\][\s\S]*?(?=\n{2,}|\nSystem:|\nSender|$)/g, "");
18
20
  out = out.replace(/\[Startup context loaded by runtime\][\s\S]*?(?:\n\n|$)/g, "");
@@ -1,54 +1,134 @@
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
+ });
110
+ log.info(`Section sync complete: ${result.synced} synced, ${result.skipped} unchanged, ${result.removed} removed, ${result.failed} failed`);
111
+ if (result.failed > 0) {
112
+ for (const error of result.errors) {
113
+ log.error(` - ${error}`);
114
+ }
49
115
  }
50
116
  }
51
- if (result.synced === 0 && result.failed === 0) {
52
- log.info("No memory files found in memory/ directory");
117
+ else {
118
+ const result = await syncAllMemoryFiles(client, workspaceDir, {
119
+ userId: options?.userId,
120
+ });
121
+ if (result.synced > 0) {
122
+ log.info(`Synced ${result.synced} memory files`);
123
+ }
124
+ if (result.failed > 0) {
125
+ log.error(`Failed to sync ${result.failed} files:`);
126
+ for (const error of result.errors) {
127
+ log.error(` - ${error}`);
128
+ }
129
+ }
130
+ if (result.synced === 0 && result.failed === 0) {
131
+ log.info("No memory files found in memory/ directory");
132
+ }
53
133
  }
54
134
  }
@@ -1,15 +1,23 @@
1
1
  import { resolveUser } from "../lib/sender.js";
2
2
  import { log } from "../logger.js";
3
- const RECENT_FILTER = { openclaw_source: "agent_end" };
3
+ const MAX_ATTEMPTS = 2;
4
+ const RECENT_BUFFER_LIMIT = 100;
4
5
  /**
5
- * Track which sessions already received the orientation injection. The block is
6
- * expensive (two search calls + a few hundred tokens of wrapper) and its
7
- * contents don't change within a session, so inject-once is the right shape.
8
- * Lifecycle mirrors the emotional-context hook: first turn injects, later turns
9
- * skip, `after_compaction` clears (injection may have been trimmed), and
10
- * `session_end` deletes to keep the Set bounded.
6
+ * Sessions where the orientation block was already injected (or where we
7
+ * deliberately decided not to inject unknown sender, exhausted retries).
8
+ * Re-checked at the top of every turn; on hit, we skip.
9
+ *
10
+ * Lifecycle mirrors the emotional-context hook: `after_compaction` clears
11
+ * (the original injection may have been trimmed out of history) and
12
+ * `session_end` cleans up to keep both structures bounded.
11
13
  */
12
14
  const injectedSessions = new Set();
15
+ /**
16
+ * Counts attempts for sessions where every call has failed so far. We only
17
+ * count failures here, so a session that succeeds on retry will never appear.
18
+ * Capped at MAX_ATTEMPTS — past that we give up and add to injectedSessions.
19
+ */
20
+ const failedAttempts = new Map();
13
21
  function formatRelativeTime(iso) {
14
22
  if (!iso)
15
23
  return "";
@@ -36,7 +44,7 @@ function formatRecentInteractions(results) {
36
44
  return null;
37
45
  const lines = results.map((r) => {
38
46
  const when = formatRelativeTime(r.createdAt);
39
- const title = r.title ?? `[${r.source}]`;
47
+ const title = r.title || `[${r.source}]`;
40
48
  const prefix = when ? `[${when}] ` : "";
41
49
  const top = r.highlights[0]?.text?.replace(/\n/g, " ").slice(0, 140);
42
50
  const tail = top ? ` — ${top}` : "";
@@ -50,7 +58,7 @@ function formatUnfinishedLoops(results) {
50
58
  const top = r.highlights[0];
51
59
  if (!top)
52
60
  continue;
53
- const title = r.title ?? `[${r.source}]`;
61
+ const title = r.title || `[${r.source}]`;
54
62
  bullets.push(`- ${title}: ${top.text.replace(/\n/g, " ")}`);
55
63
  }
56
64
  return bullets.length > 0 ? bullets.join("\n") : null;
@@ -58,7 +66,7 @@ function formatUnfinishedLoops(results) {
58
66
  function isoDaysAgo(days) {
59
67
  const d = new Date();
60
68
  d.setUTCDate(d.getUTCDate() - days);
61
- return d.toISOString();
69
+ return d;
62
70
  }
63
71
  /**
64
72
  * Resolve the userId to use for personal searches. In multi-user mode, skip
@@ -74,12 +82,73 @@ function personalUserId(cfg, ctx) {
74
82
  return { skip: true };
75
83
  return { skip: false, userId: resolved.userId };
76
84
  }
85
+ /**
86
+ * Pull recent agent_end traces via listMemories, sorted chronologically.
87
+ *
88
+ * Why list, not search: a date-window + relevance search ranks results by
89
+ * lexical similarity to a generic query, which is approximately random
90
+ * within a 7-day slice and can easily exclude yesterday in favor of a
91
+ * 5-day-old session. listMemories gives us true chronological recall.
92
+ *
93
+ * The SDK's list endpoint doesn't expose date or metadata filters in our
94
+ * wrapper, so we filter client-side. Buffer is capped at
95
+ * RECENT_BUFFER_LIMIT to bound wire cost; if a user has more than that
96
+ * many traces in the cutoff window we'll still get the newest ones,
97
+ * since the underlying API returns recency-ordered pages.
98
+ */
99
+ async function fetchRecentTraces(client, cutoff, limit, userId) {
100
+ const buffer = [];
101
+ let scanned = 0;
102
+ const cutoffMs = cutoff.getTime();
103
+ for await (const memory of client.listMemories({
104
+ source: "trace",
105
+ userId,
106
+ pageSize: 50,
107
+ })) {
108
+ scanned++;
109
+ if (scanned > RECENT_BUFFER_LIMIT)
110
+ break;
111
+ const meta = memory.metadata;
112
+ if (meta.openclaw_source !== "agent_end")
113
+ continue;
114
+ const createdRaw = meta.created_at;
115
+ if (typeof createdRaw !== "string")
116
+ continue;
117
+ const createdMs = new Date(createdRaw).getTime();
118
+ if (Number.isNaN(createdMs) || createdMs < cutoffMs)
119
+ continue;
120
+ buffer.push({
121
+ resourceId: memory.resourceId,
122
+ title: memory.title,
123
+ source: memory.source,
124
+ score: null,
125
+ url: null,
126
+ createdAt: createdRaw,
127
+ highlights: [],
128
+ });
129
+ }
130
+ buffer.sort((a, b) => {
131
+ const at = a.createdAt ? new Date(a.createdAt).getTime() : 0;
132
+ const bt = b.createdAt ? new Date(b.createdAt).getTime() : 0;
133
+ return bt - at;
134
+ });
135
+ return buffer.slice(0, limit);
136
+ }
77
137
  export function buildStartupOrientationHandler(client, cfg) {
78
138
  const so = cfg.startupOrientation;
79
139
  return async (_event, ctx) => {
80
140
  const sessionKey = ctx?.sessionKey;
81
141
  if (sessionKey && injectedSessions.has(sessionKey))
82
142
  return;
143
+ const tries = (sessionKey && failedAttempts.get(sessionKey)) || 0;
144
+ if (tries >= MAX_ATTEMPTS) {
145
+ log.debug(`startup-orientation: giving up after ${tries} failed attempt(s)`);
146
+ if (sessionKey) {
147
+ injectedSessions.add(sessionKey);
148
+ failedAttempts.delete(sessionKey);
149
+ }
150
+ return;
151
+ }
83
152
  const { skip, userId } = personalUserId(cfg, ctx);
84
153
  if (skip) {
85
154
  log.debug("startup-orientation: skipping — unknown sender in multi-user mode");
@@ -87,31 +156,36 @@ export function buildStartupOrientationHandler(client, cfg) {
87
156
  injectedSessions.add(sessionKey);
88
157
  return;
89
158
  }
90
- const after = isoDaysAgo(so.recentDays);
159
+ const cutoff = isoDaysAgo(so.recentDays);
91
160
  const [recentSettled, loopsSettled] = await Promise.allSettled([
92
- client.search(so.recentQuery, {
93
- limit: so.recentLimit,
94
- after,
95
- filter: RECENT_FILTER,
96
- userId,
97
- }),
161
+ fetchRecentTraces(client, cutoff, so.recentLimit, userId),
98
162
  client.search(so.loopsQuery, {
99
163
  limit: so.loopsLimit,
100
164
  userId,
101
165
  }),
102
166
  ]);
103
- const recent = recentSettled.status === "fulfilled" ? recentSettled.value : [];
104
- if (recentSettled.status === "rejected") {
105
- log.error("startup-orientation: recent search failed", recentSettled.reason);
167
+ const recentOk = recentSettled.status === "fulfilled";
168
+ const loopsOk = loopsSettled.status === "fulfilled";
169
+ const recent = recentOk ? recentSettled.value : [];
170
+ const loops = loopsOk ? loopsSettled.value : [];
171
+ if (!recentOk) {
172
+ log.error("startup-orientation: recent listMemories failed", recentSettled.reason);
106
173
  }
107
- const loops = loopsSettled.status === "fulfilled" ? loopsSettled.value : [];
108
- if (loopsSettled.status === "rejected") {
174
+ if (!loopsOk) {
109
175
  log.error("startup-orientation: loops search failed", loopsSettled.reason);
110
176
  }
177
+ if (!recentOk && !loopsOk) {
178
+ if (sessionKey)
179
+ failedAttempts.set(sessionKey, tries + 1);
180
+ log.debug(`startup-orientation: both calls failed (attempt ${tries + 1}/${MAX_ATTEMPTS}); will retry next turn`);
181
+ return;
182
+ }
183
+ if (sessionKey) {
184
+ injectedSessions.add(sessionKey);
185
+ failedAttempts.delete(sessionKey);
186
+ }
111
187
  const recentBody = formatRecentInteractions(recent);
112
188
  const loopsBody = formatUnfinishedLoops(loops);
113
- if (sessionKey)
114
- injectedSessions.add(sessionKey);
115
189
  if (!recentBody && !loopsBody) {
116
190
  log.debug("startup-orientation: nothing to inject");
117
191
  return;
@@ -120,7 +194,7 @@ export function buildStartupOrientationHandler(client, cfg) {
120
194
  if (recentBody) {
121
195
  blocks.push([
122
196
  "<hyperspell-recent-interactions>",
123
- `Your last ${so.recentDays} days of conversations with this user, most-relevant-first. Use for situational continuity — don't quote verbatim.`,
197
+ `Your last ${so.recentDays} days of conversations with this user, most-recent-first. Use for situational continuity — don't quote verbatim.`,
124
198
  "",
125
199
  recentBody,
126
200
  "</hyperspell-recent-interactions>",
@@ -142,7 +216,11 @@ export function buildStartupOrientationHandler(client, cfg) {
142
216
  export function buildStartupOrientationCompactionHandler() {
143
217
  return async (_event, ctx) => {
144
218
  const sessionKey = ctx?.sessionKey;
145
- if (sessionKey && injectedSessions.delete(sessionKey)) {
219
+ if (!sessionKey)
220
+ return;
221
+ const dropped = injectedSessions.delete(sessionKey);
222
+ failedAttempts.delete(sessionKey);
223
+ if (dropped) {
146
224
  log.debug(`startup-orientation: cache cleared after compaction (session=${sessionKey})`);
147
225
  }
148
226
  };
@@ -150,7 +228,9 @@ export function buildStartupOrientationCompactionHandler() {
150
228
  export function buildStartupOrientationSessionCleanupHandler() {
151
229
  return async (_event, ctx) => {
152
230
  const sessionKey = ctx?.sessionKey;
153
- if (sessionKey)
154
- injectedSessions.delete(sessionKey);
231
+ if (!sessionKey)
232
+ return;
233
+ injectedSessions.delete(sessionKey);
234
+ failedAttempts.delete(sessionKey);
155
235
  };
156
236
  }
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) {
@@ -121,6 +122,8 @@ export default {
121
122
  const workspaceDir = getWorkspaceDir();
122
123
  await syncMemoriesOnStartup(client, workspaceDir, {
123
124
  userId: cfg.multiUser?.sharedUserId,
125
+ sectionize: cfg.syncMemoriesConfig.sectionize,
126
+ watchPaths: cfg.syncMemoriesConfig.watchPaths,
124
127
  });
125
128
  }
126
129
  },
@@ -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,152 @@ 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
69
  /**
70
- * Get all markdown files from the memory directory, including subdirectories
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.
71
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
+ // 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) {
175
+ return { version: MANIFEST_VERSION, files: parsed.files };
176
+ }
177
+ }
178
+ }
179
+ catch (err) {
180
+ log.error("Failed to read sync manifest", err);
181
+ }
182
+ return emptyManifest();
183
+ }
184
+ export function saveManifest(workspaceDir, manifest) {
185
+ const p = manifestPath(workspaceDir);
186
+ try {
187
+ fs.writeFileSync(p, JSON.stringify(manifest, null, 2));
188
+ }
189
+ catch (err) {
190
+ log.error("Failed to write sync manifest", err);
191
+ }
192
+ }
193
+ /**
194
+ * Serializes manifest read-modify-write across the whole process. Startup bulk
195
+ * sync and the live file_changed handler (plus independent per-file debounce
196
+ * timers) would otherwise interleave load → await addMemory → save and lose
197
+ * resourceId entries, causing the next sync to re-upload sections as brand-new
198
+ * memories. Every load→sync→save runs inside this critical section.
199
+ */
200
+ let syncChain = Promise.resolve();
201
+ export function withSyncLock(fn) {
202
+ const run = syncChain.then(fn, fn);
203
+ syncChain = run.then(() => undefined, () => undefined);
204
+ return run;
205
+ }
206
+ // ---------------------------------------------------------------------------
207
+ // Public API — file-level sync (original, kept for backward compat)
208
+ // ---------------------------------------------------------------------------
72
209
  export function getMemoryFiles(workspaceDir) {
73
210
  const memoryDir = path.join(workspaceDir, "memory");
74
211
  if (!fs.existsSync(memoryDir)) {
@@ -95,7 +232,47 @@ export function getMemoryFiles(workspaceDir) {
95
232
  return results;
96
233
  }
97
234
  /**
98
- * Sync a single markdown file to Hyperspell
235
+ * Collect all syncable files based on configuration.
236
+ * Includes memory/*.md by default, plus any additional watchPaths.
237
+ */
238
+ export function getSyncableFiles(workspaceDir, watchPaths) {
239
+ const files = getMemoryFiles(workspaceDir);
240
+ if (watchPaths) {
241
+ for (const wp of watchPaths) {
242
+ const resolved = wp.startsWith("/") ? wp : path.join(workspaceDir, wp);
243
+ if (!fs.existsSync(resolved))
244
+ continue;
245
+ const stat = fs.statSync(resolved);
246
+ if (stat.isFile() && resolved.endsWith(".md")) {
247
+ if (!files.includes(resolved)) {
248
+ files.push(resolved);
249
+ }
250
+ }
251
+ else if (stat.isDirectory()) {
252
+ // Walk directory for .md files
253
+ const walk = (dir) => {
254
+ try {
255
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
256
+ const fullPath = path.join(dir, entry.name);
257
+ if (entry.isDirectory()) {
258
+ walk(fullPath);
259
+ }
260
+ else if (entry.name.endsWith(".md") && !files.includes(fullPath)) {
261
+ files.push(fullPath);
262
+ }
263
+ }
264
+ }
265
+ catch (_err) { /* skip unreadable dirs */ }
266
+ };
267
+ walk(resolved);
268
+ }
269
+ }
270
+ }
271
+ return files;
272
+ }
273
+ /**
274
+ * Original whole-file sync (kept for backward compatibility when
275
+ * sectionize is false).
99
276
  */
100
277
  export async function syncMarkdownFile(client, filePath, options) {
101
278
  const file = readMarkdownFile(filePath);
@@ -116,7 +293,6 @@ export async function syncMarkdownFile(client, filePath, options) {
116
293
  },
117
294
  userId: options?.userId,
118
295
  });
119
- // Update frontmatter with new resource ID if it changed or was newly created
120
296
  if (result.resourceId !== file.hyperspellId) {
121
297
  updateFrontmatterId(filePath, result.resourceId);
122
298
  }
@@ -128,9 +304,156 @@ export async function syncMarkdownFile(client, filePath, options) {
128
304
  return { success: false, error: errorMsg };
129
305
  }
130
306
  }
307
+ // ---------------------------------------------------------------------------
308
+ // Section-level sync (new)
309
+ // ---------------------------------------------------------------------------
131
310
  /**
132
- * Sync all markdown files in the memory directory
311
+ * Sync a single markdown file to Hyperspell at section granularity.
312
+ * Each ## section becomes a separate memory. Content hashes are tracked
313
+ * so unchanged sections are skipped on subsequent syncs.
314
+ *
315
+ * The section title is prepended with the file-level context (e.g.
316
+ * "2026-05-09 — The day David's mum got sick") for better retrieval.
133
317
  */
318
+ export async function syncMarkdownFileSectionized(client, filePath, workspaceDir, options) {
319
+ return withSyncLock(async () => {
320
+ const empty = { synced: 0, skipped: 0, failed: 0, removed: 0, errors: [] };
321
+ try {
322
+ const file = readMarkdownFile(filePath);
323
+ if (!file || !file.content) {
324
+ return file ? empty : { ...empty, errors: ["Failed to read file"] };
325
+ }
326
+ const sections = dedupeTitles(parseMarkdownSections(file.content, file.title));
327
+ if (sections.length === 0) {
328
+ return empty;
329
+ }
330
+ const manifest = loadManifest(workspaceDir);
331
+ const key = fileKey(workspaceDir, filePath);
332
+ const prevSections = manifest.files[key]?.sections ?? {};
333
+ const fileName = path.basename(filePath, ".md");
334
+ let synced = 0;
335
+ let skipped = 0;
336
+ let failed = 0;
337
+ let removed = 0;
338
+ let dirty = false;
339
+ const errors = [];
340
+ const currentTitles = new Set(sections.map((s) => s.title));
341
+ // Sections in the manifest but absent from the current parse are either
342
+ // renames (same content hash resurfacing under a new title) or true
343
+ // deletions. Index leftovers by content hash so a renamed section can
344
+ // reclaim its existing Hyperspell resource instead of creating a
345
+ // duplicate + orphan. Hash collisions among removed sections are rare
346
+ // and resolved best-effort (last writer wins).
347
+ const orphanByHash = new Map();
348
+ for (const [title, rec] of Object.entries(prevSections)) {
349
+ if (!currentTitles.has(title)) {
350
+ orphanByHash.set(rec.hash, { title, resourceId: rec.resourceId });
351
+ }
352
+ }
353
+ const newSections = {};
354
+ for (const section of sections) {
355
+ const prev = prevSections[section.title];
356
+ // Unchanged: keep the record, skip the upload.
357
+ if (prev && prev.hash === section.contentHash) {
358
+ newSections[section.title] = prev;
359
+ skipped++;
360
+ continue;
361
+ }
362
+ // New title: if identical content was just removed under a different
363
+ // heading, this is a rename — reuse the resource and claim it so it is
364
+ // not also deleted as an orphan below.
365
+ let reuseResourceId = prev?.resourceId;
366
+ if (!prev) {
367
+ const renamedFrom = orphanByHash.get(section.contentHash);
368
+ if (renamedFrom) {
369
+ reuseResourceId = renamedFrom.resourceId;
370
+ orphanByHash.delete(section.contentHash);
371
+ log.info(`Section renamed: "${renamedFrom.title}" -> "${section.title}" (${fileName}) — updating in place`);
372
+ }
373
+ }
374
+ const memoryTitle = file.title !== section.title ? `${file.title} — ${section.title}` : section.title;
375
+ try {
376
+ const result = await client.addMemory(section.content, {
377
+ title: memoryTitle,
378
+ resourceId: reuseResourceId,
379
+ collection: "openclaw",
380
+ metadata: {
381
+ openclaw_source: "memory_sync_section",
382
+ file_path: filePath,
383
+ file_name: fileName,
384
+ section_title: section.title,
385
+ content_hash: section.contentHash,
386
+ },
387
+ userId: options?.userId,
388
+ });
389
+ newSections[section.title] = {
390
+ hash: section.contentHash,
391
+ resourceId: result.resourceId,
392
+ };
393
+ synced++;
394
+ dirty = true;
395
+ log.info(`Synced section: ${memoryTitle} -> ${result.resourceId}`);
396
+ }
397
+ catch (err) {
398
+ const errorMsg = err instanceof Error ? err.message : String(err);
399
+ errors.push(`${memoryTitle}: ${errorMsg}`);
400
+ failed++;
401
+ // Preserve the prior record so a transient failure does not drop the
402
+ // resourceId and force a duplicate upload next run.
403
+ if (prev)
404
+ newSections[section.title] = prev;
405
+ }
406
+ }
407
+ // Whatever remains is a genuine deletion: gone from source, no rename
408
+ // reclaimed it. Delete the remote memory so retrieval does not keep
409
+ // serving content the user removed.
410
+ for (const { title, resourceId } of orphanByHash.values()) {
411
+ if (!resourceId) {
412
+ // Never got a resourceId — nothing to delete, just drop the record.
413
+ dirty = true;
414
+ continue;
415
+ }
416
+ const { deleted } = await client.deleteMemory(resourceId, {
417
+ userId: options?.userId,
418
+ });
419
+ if (deleted) {
420
+ removed++;
421
+ dirty = true;
422
+ log.info(`Removed deleted section "${title}" (${fileName}) -> ${resourceId}`);
423
+ }
424
+ else {
425
+ // Keep the record so the delete is retried next run rather than
426
+ // silently leaking the orphan into the retrieval layer. Count it
427
+ // as a failure so the stats are consistent and callers that gate
428
+ // logging on `failed > 0` actually surface it.
429
+ newSections[title] = prevSections[title];
430
+ failed++;
431
+ errors.push(`delete "${title}": failed`);
432
+ }
433
+ }
434
+ if (Object.keys(newSections).length > 0) {
435
+ manifest.files[key] = { sections: newSections };
436
+ }
437
+ else {
438
+ delete manifest.files[key];
439
+ }
440
+ if (dirty) {
441
+ saveManifest(workspaceDir, manifest);
442
+ }
443
+ return { synced, skipped, failed, removed, errors };
444
+ }
445
+ catch (err) {
446
+ // The body is defensive everywhere above; this guards the lock chain
447
+ // against an unexpected throw so one bad file cannot wedge the queue.
448
+ const msg = err instanceof Error ? err.message : String(err);
449
+ log.error(`Sectionized sync crashed for ${filePath}`, err);
450
+ return { ...empty, errors: [msg] };
451
+ }
452
+ });
453
+ }
454
+ // ---------------------------------------------------------------------------
455
+ // Bulk sync (original whole-file approach)
456
+ // ---------------------------------------------------------------------------
134
457
  export async function syncAllMemoryFiles(client, workspaceDir, options) {
135
458
  const files = getMemoryFiles(workspaceDir);
136
459
  let synced = 0;
@@ -149,3 +472,35 @@ export async function syncAllMemoryFiles(client, workspaceDir, options) {
149
472
  }
150
473
  return { synced, failed, errors };
151
474
  }
475
+ // ---------------------------------------------------------------------------
476
+ // Bulk sync (section-level approach)
477
+ // ---------------------------------------------------------------------------
478
+ /**
479
+ * Sync all syncable files at section granularity.
480
+ * Unchanged sections (by content hash) are skipped.
481
+ */
482
+ export async function syncAllFilesSectionized(client, workspaceDir, options) {
483
+ const files = getSyncableFiles(workspaceDir, options?.watchPaths);
484
+ let totalSynced = 0;
485
+ let totalSkipped = 0;
486
+ let totalFailed = 0;
487
+ let totalRemoved = 0;
488
+ const allErrors = [];
489
+ for (const filePath of files) {
490
+ const result = await syncMarkdownFileSectionized(client, filePath, workspaceDir, {
491
+ userId: options?.userId,
492
+ });
493
+ totalSynced += result.synced;
494
+ totalSkipped += result.skipped;
495
+ totalFailed += result.failed;
496
+ totalRemoved += result.removed;
497
+ allErrors.push(...result.errors);
498
+ }
499
+ return {
500
+ synced: totalSynced,
501
+ skipped: totalSkipped,
502
+ failed: totalFailed,
503
+ removed: totalRemoved,
504
+ errors: allErrors,
505
+ };
506
+ }
@@ -41,6 +41,11 @@
41
41
  "help": "Maintain emotional continuity across sessions — remembers not just what happened, but how it felt",
42
42
  "advanced": true
43
43
  },
44
+ "startupOrientation": {
45
+ "label": "Startup Orientation",
46
+ "help": "Inject recent-interactions and unfinished-loops blocks once per session at startup. Costs two extra calls + ~500–800 tokens of injection per session; off by default.",
47
+ "advanced": true
48
+ },
44
49
  "relationshipId": {
45
50
  "label": "Relationship ID",
46
51
  "placeholder": "partner-anna",
@@ -66,7 +71,7 @@
66
71
  },
67
72
  "syncMemories": {
68
73
  "label": "Sync Memory Files",
69
- "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 }",
70
75
  "advanced": true
71
76
  },
72
77
  "knowledgeGraph": {
@@ -117,6 +122,16 @@
117
122
  "relationshipId": {
118
123
  "type": "string"
119
124
  },
125
+ "startupOrientation": {
126
+ "type": "object",
127
+ "properties": {
128
+ "enabled": { "type": "boolean" },
129
+ "recentDays": { "type": "number", "minimum": 1, "maximum": 90 },
130
+ "recentLimit": { "type": "number", "minimum": 1, "maximum": 20 },
131
+ "loopsLimit": { "type": "number", "minimum": 1, "maximum": 20 },
132
+ "loopsQuery": { "type": "string" }
133
+ }
134
+ },
120
135
  "sources": {
121
136
  "type": "string"
122
137
  },
@@ -129,7 +144,30 @@
129
144
  "type": "boolean"
130
145
  },
131
146
  "syncMemories": {
132
- "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
+ ]
133
171
  },
134
172
  "knowledgeGraph": {
135
173
  "type": "object",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperspell/openclaw-hyperspell",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
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": [