@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 +24 -0
- package/dist/commands/setup.js +19 -6
- package/dist/config.js +26 -2
- package/dist/hooks/auto-trace.js +2 -0
- package/dist/hooks/memory-sync.js +104 -24
- package/dist/hooks/startup-orientation.js +108 -28
- package/dist/index.js +3 -0
- package/dist/sync/markdown.js +371 -16
- package/openclaw.plugin.json +40 -2
- package/package.json +2 -2
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,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
|
-
|
|
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}
|
|
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}
|
|
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
|
-
|
|
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:
|
|
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,
|
package/dist/hooks/auto-trace.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
log.
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
|
3
|
+
const MAX_ATTEMPTS = 2;
|
|
4
|
+
const RECENT_BUFFER_LIMIT = 100;
|
|
4
5
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
159
|
+
const cutoff = isoDaysAgo(so.recentDays);
|
|
91
160
|
const [recentSettled, loopsSettled] = await Promise.allSettled([
|
|
92
|
-
client
|
|
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
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
|
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
|
-
|
|
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
|
},
|
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,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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
+
}
|
package/openclaw.plugin.json
CHANGED
|
@@ -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
|
|
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
|
-
"
|
|
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.
|
|
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": [
|