@hyperspell/openclaw-hyperspell 0.14.1 → 0.14.2
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/commands/setup.js +1 -0
- package/dist/config.js +11 -1
- package/dist/hooks/auto-context.js +1 -16
- package/dist/hooks/memory-sync.js +17 -0
- package/dist/index.js +1 -0
- package/dist/lib/filters.js +30 -0
- package/dist/sync/markdown.js +30 -4
- package/dist/tools/search.js +4 -0
- package/package.json +2 -2
package/dist/commands/setup.js
CHANGED
package/dist/config.js
CHANGED
|
@@ -249,7 +249,14 @@ export function parseConfig(raw) {
|
|
|
249
249
|
// object form must be too, or a typo (sectionise/debounceMS) is silently
|
|
250
250
|
// ignored and the user gets default behavior they didn't ask for.
|
|
251
251
|
if (typeof smRaw === "object" && smRaw !== null && !Array.isArray(smRaw)) {
|
|
252
|
-
assertAllowedKeys(smObj, [
|
|
252
|
+
assertAllowedKeys(smObj, [
|
|
253
|
+
"enabled",
|
|
254
|
+
"sectionize",
|
|
255
|
+
"watchPaths",
|
|
256
|
+
"debounceMs",
|
|
257
|
+
"maxAgeDays",
|
|
258
|
+
"ignorePaths",
|
|
259
|
+
], "hyperspell.syncMemories");
|
|
253
260
|
}
|
|
254
261
|
return {
|
|
255
262
|
apiKey,
|
|
@@ -281,6 +288,9 @@ export function parseConfig(raw) {
|
|
|
281
288
|
: [],
|
|
282
289
|
debounceMs: smObj.debounceMs ?? 2000,
|
|
283
290
|
maxAgeDays: smObj.maxAgeDays ?? 30,
|
|
291
|
+
ignorePaths: Array.isArray(smObj.ignorePaths)
|
|
292
|
+
? smObj.ignorePaths
|
|
293
|
+
: ["dreaming"],
|
|
284
294
|
},
|
|
285
295
|
sources: parseSources(cfg.sources),
|
|
286
296
|
maxResults: cfg.maxResults ?? 10,
|
|
@@ -1,21 +1,6 @@
|
|
|
1
1
|
import { buildScopeFilter, getCanReadScopes, resolveUser, } from "../lib/sender.js";
|
|
2
|
+
import { EXCLUDE_SESSION_END_FILTER, mergeWithExclude } from "../lib/filters.js";
|
|
2
3
|
import { log } from "../logger.js";
|
|
3
|
-
/**
|
|
4
|
-
* Memories produced by session-end hooks (auto-trace, emotional-state) are
|
|
5
|
-
* tagged `source: "openclaw_agent_end"`. The emotional-state hook already has
|
|
6
|
-
* a dedicated fetch path (`getEmotionalState` + `<hyperspell-emotional-context>`
|
|
7
|
-
* injection), so those memories should NOT also surface via generic retrieval —
|
|
8
|
-
* double-injection dilutes results and replays the conversation verbatim.
|
|
9
|
-
* Exclude them here at the search filter.
|
|
10
|
-
*/
|
|
11
|
-
const EXCLUDE_SESSION_END_FILTER = {
|
|
12
|
-
source: { $ne: "openclaw_agent_end" },
|
|
13
|
-
};
|
|
14
|
-
function mergeWithExclude(base) {
|
|
15
|
-
if (!base)
|
|
16
|
-
return EXCLUDE_SESSION_END_FILTER;
|
|
17
|
-
return { $and: [base, EXCLUDE_SESSION_END_FILTER] };
|
|
18
|
-
}
|
|
19
4
|
function formatRelativeTime(isoTimestamp) {
|
|
20
5
|
try {
|
|
21
6
|
const dt = new Date(isoTimestamp);
|
|
@@ -19,8 +19,22 @@ export function buildFileSyncHandler(client, cfg) {
|
|
|
19
19
|
const sectionize = cfg.syncMemoriesConfig.sectionize;
|
|
20
20
|
const watchPaths = cfg.syncMemoriesConfig.watchPaths;
|
|
21
21
|
const debounceMs = cfg.syncMemoriesConfig.debounceMs;
|
|
22
|
+
const ignoreDirs = new Set(cfg.syncMemoriesConfig.ignorePaths);
|
|
22
23
|
// Resolve additional watch paths to absolute paths for matching
|
|
23
24
|
const resolvedWatchPaths = (watchPaths ?? []).map((wp) => wp.startsWith("/") ? wp : path.join(workspaceDir, wp));
|
|
25
|
+
/**
|
|
26
|
+
* Mirror the bulk walk's exclusions on the live path: a file under a
|
|
27
|
+
* dot-directory (e.g. .dreams) or an ignored directory (e.g. dreaming) must
|
|
28
|
+
* not live-sync either, or an edit would re-ingest exactly what the walk
|
|
29
|
+
* skips.
|
|
30
|
+
*/
|
|
31
|
+
function isIgnoredPath(filePath) {
|
|
32
|
+
const rel = path.relative(memoryDir, filePath);
|
|
33
|
+
if (rel.startsWith("..") || path.isAbsolute(rel))
|
|
34
|
+
return false;
|
|
35
|
+
const segments = rel.split(path.sep).slice(0, -1); // directory segments only
|
|
36
|
+
return segments.some((seg) => seg.startsWith(".") || ignoreDirs.has(seg));
|
|
37
|
+
}
|
|
24
38
|
// Debounce map: filePath -> timeout handle
|
|
25
39
|
const debounceTimers = new Map();
|
|
26
40
|
/**
|
|
@@ -29,6 +43,8 @@ export function buildFileSyncHandler(client, cfg) {
|
|
|
29
43
|
function isSyncable(filePath) {
|
|
30
44
|
if (!filePath.endsWith(".md"))
|
|
31
45
|
return false;
|
|
46
|
+
if (isIgnoredPath(filePath))
|
|
47
|
+
return false;
|
|
32
48
|
// Always sync memory/ files
|
|
33
49
|
if (filePath.startsWith(memoryDir + path.sep))
|
|
34
50
|
return true;
|
|
@@ -107,6 +123,7 @@ export async function syncMemoriesOnStartup(client, workspaceDir, options) {
|
|
|
107
123
|
userId: options.userId,
|
|
108
124
|
watchPaths: options.watchPaths,
|
|
109
125
|
maxAgeDays: options.maxAgeDays,
|
|
126
|
+
ignorePaths: options.ignorePaths,
|
|
110
127
|
});
|
|
111
128
|
log.info(`Section sync complete: ${result.synced} synced, ${result.skipped} unchanged, ${result.agedOut} aged-out, ${result.removed} removed, ${result.failed} failed`);
|
|
112
129
|
if (result.failed > 0) {
|
package/dist/index.js
CHANGED
|
@@ -131,6 +131,7 @@ export default {
|
|
|
131
131
|
sectionize: cfg.syncMemoriesConfig.sectionize,
|
|
132
132
|
watchPaths: cfg.syncMemoriesConfig.watchPaths,
|
|
133
133
|
maxAgeDays: cfg.syncMemoriesConfig.maxAgeDays,
|
|
134
|
+
ignorePaths: cfg.syncMemoriesConfig.ignorePaths,
|
|
134
135
|
}).catch((err) => {
|
|
135
136
|
api.logger.error("hyperspell: background memory sync failed", err);
|
|
136
137
|
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Hyperspell `options.filter` clauses, used by every retrieval path
|
|
3
|
+
* (auto-context hook + the hyperspell_search tool) so filtering stays
|
|
4
|
+
* consistent across them.
|
|
5
|
+
*
|
|
6
|
+
* Filters match against memory METADATA keys by their bare name — the same
|
|
7
|
+
* convention `buildScopeFilter` uses (`openclaw_scope`, `openclaw_user`).
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Memories produced by session-end hooks (auto-trace, emotional-state) are
|
|
11
|
+
* tagged in metadata as `openclaw_source: "agent_end"` (see `sendTrace` in
|
|
12
|
+
* client.ts). Those should NOT surface via generic retrieval: the
|
|
13
|
+
* emotional-state hook injects them through its own dedicated path, and
|
|
14
|
+
* replaying whole sanitized transcripts back into context creates a
|
|
15
|
+
* self-amplifying pollution loop. Exclude them at the search filter.
|
|
16
|
+
*
|
|
17
|
+
* NOTE: an earlier version of this filter checked the top-level `source` field
|
|
18
|
+
* for the value `"openclaw_agent_end"` — wrong on BOTH counts (the tag lives in
|
|
19
|
+
* metadata under `openclaw_source`, and its value is `"agent_end"`), so it
|
|
20
|
+
* silently matched nothing and excluded no traces.
|
|
21
|
+
*/
|
|
22
|
+
export const EXCLUDE_SESSION_END_FILTER = {
|
|
23
|
+
openclaw_source: { $ne: "agent_end" },
|
|
24
|
+
};
|
|
25
|
+
/** Combine a caller-supplied filter with the session-end exclude via `$and`. */
|
|
26
|
+
export function mergeWithExclude(base) {
|
|
27
|
+
if (!base)
|
|
28
|
+
return EXCLUDE_SESSION_END_FILTER;
|
|
29
|
+
return { $and: [base, EXCLUDE_SESSION_END_FILTER] };
|
|
30
|
+
}
|
package/dist/sync/markdown.js
CHANGED
|
@@ -223,15 +223,38 @@ export function withSyncLock(fn) {
|
|
|
223
223
|
// ---------------------------------------------------------------------------
|
|
224
224
|
// Public API — file-level sync (original, kept for backward compat)
|
|
225
225
|
// ---------------------------------------------------------------------------
|
|
226
|
-
|
|
226
|
+
/**
|
|
227
|
+
* Directory names skipped by default when walking memory/. The dreaming engine
|
|
228
|
+
* writes first-person dream journals under memory/dreaming/ that are not user
|
|
229
|
+
* memories; ingesting them pollutes retrieval (they score highly on emotional/
|
|
230
|
+
* associative queries and crowd out real memories). Configurable via
|
|
231
|
+
* `syncMemories.ignorePaths`.
|
|
232
|
+
*/
|
|
233
|
+
export const DEFAULT_IGNORE_DIRS = ["dreaming"];
|
|
234
|
+
/**
|
|
235
|
+
* Skip a directory entry during the walk when it is a dot-directory/file
|
|
236
|
+
* (e.g. `.dreams`, engine scaffolding) or a directory named in `ignore`.
|
|
237
|
+
* Dot-entries are always skipped regardless of the configured list.
|
|
238
|
+
*/
|
|
239
|
+
function isIgnoredEntry(entry, ignore) {
|
|
240
|
+
if (entry.name.startsWith("."))
|
|
241
|
+
return true;
|
|
242
|
+
if (entry.isDirectory() && ignore.has(entry.name))
|
|
243
|
+
return true;
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
export function getMemoryFiles(workspaceDir, ignorePaths) {
|
|
227
247
|
const memoryDir = path.join(workspaceDir, "memory");
|
|
228
248
|
if (!fs.existsSync(memoryDir)) {
|
|
229
249
|
return [];
|
|
230
250
|
}
|
|
251
|
+
const ignore = new Set(ignorePaths ?? DEFAULT_IGNORE_DIRS);
|
|
231
252
|
const results = [];
|
|
232
253
|
function walk(dir) {
|
|
233
254
|
try {
|
|
234
255
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
256
|
+
if (isIgnoredEntry(entry, ignore))
|
|
257
|
+
continue;
|
|
235
258
|
const fullPath = path.join(dir, entry.name);
|
|
236
259
|
if (entry.isDirectory()) {
|
|
237
260
|
walk(fullPath);
|
|
@@ -252,8 +275,9 @@ export function getMemoryFiles(workspaceDir) {
|
|
|
252
275
|
* Collect all syncable files based on configuration.
|
|
253
276
|
* Includes memory/*.md by default, plus any additional watchPaths.
|
|
254
277
|
*/
|
|
255
|
-
export function getSyncableFiles(workspaceDir, watchPaths) {
|
|
256
|
-
const
|
|
278
|
+
export function getSyncableFiles(workspaceDir, watchPaths, ignorePaths) {
|
|
279
|
+
const ignore = new Set(ignorePaths ?? DEFAULT_IGNORE_DIRS);
|
|
280
|
+
const files = getMemoryFiles(workspaceDir, ignorePaths);
|
|
257
281
|
if (watchPaths) {
|
|
258
282
|
for (const wp of watchPaths) {
|
|
259
283
|
const resolved = wp.startsWith("/") ? wp : path.join(workspaceDir, wp);
|
|
@@ -270,6 +294,8 @@ export function getSyncableFiles(workspaceDir, watchPaths) {
|
|
|
270
294
|
const walk = (dir) => {
|
|
271
295
|
try {
|
|
272
296
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
297
|
+
if (isIgnoredEntry(entry, ignore))
|
|
298
|
+
continue;
|
|
273
299
|
const fullPath = path.join(dir, entry.name);
|
|
274
300
|
if (entry.isDirectory()) {
|
|
275
301
|
walk(fullPath);
|
|
@@ -497,7 +523,7 @@ export async function syncAllMemoryFiles(client, workspaceDir, options) {
|
|
|
497
523
|
* Unchanged sections (by content hash) are skipped.
|
|
498
524
|
*/
|
|
499
525
|
export async function syncAllFilesSectionized(client, workspaceDir, options) {
|
|
500
|
-
const files = getSyncableFiles(workspaceDir, options?.watchPaths);
|
|
526
|
+
const files = getSyncableFiles(workspaceDir, options?.watchPaths, options?.ignorePaths);
|
|
501
527
|
// Age pre-filter: a file untouched for longer than maxAgeDays that is
|
|
502
528
|
// already recorded in the manifest has been ingested at least once and is
|
|
503
529
|
// not changing — re-parsing/hashing/diffing it every startup is pure churn.
|
package/dist/tools/search.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Type } from "@sinclair/typebox";
|
|
2
|
+
import { mergeWithExclude } from "../lib/filters.js";
|
|
2
3
|
import { buildScopeFilter, getCanReadScopes, resolveUser } from "../lib/sender.js";
|
|
3
4
|
import { log } from "../logger.js";
|
|
4
5
|
export function createSearchToolFactory(client, cfg) {
|
|
@@ -38,6 +39,9 @@ export function createSearchToolFactory(client, cfg) {
|
|
|
38
39
|
: canRead;
|
|
39
40
|
filter = buildScopeFilter(allowed, resolved?.userId ?? "");
|
|
40
41
|
}
|
|
42
|
+
// Keep session-end trace memories out of agent-facing search, matching
|
|
43
|
+
// the auto-context hook so both retrieval paths filter identically.
|
|
44
|
+
filter = mergeWithExclude(filter);
|
|
41
45
|
log.debug(`search tool: query="${params.query}" limit=${limit} after=${params.after ?? "none"} before=${params.before ?? "none"} userId=${userId} scope=${params.scope ?? "any"}`);
|
|
42
46
|
try {
|
|
43
47
|
const response = await client.searchRaw(params.query, {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperspell/openclaw-hyperspell",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.2",
|
|
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 sync/markdown.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 lib/filters.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": [
|