@hyperspell/openclaw-hyperspell 0.14.1 → 0.15.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 +51 -0
- package/dist/commands/setup.js +7 -0
- package/dist/config.js +25 -1
- package/dist/hooks/auto-context.js +6 -21
- package/dist/hooks/hot-buffer.js +0 -0
- package/dist/hooks/memory-sync.js +17 -0
- package/dist/index.js +8 -0
- package/dist/lib/filters.js +61 -0
- package/dist/sync/markdown.js +30 -4
- package/dist/tools/search.js +5 -1
- package/openclaw.plugin.json +14 -0
- package/package.json +2 -2
package/dist/client.js
CHANGED
|
@@ -270,6 +270,57 @@ export class HyperspellClient {
|
|
|
270
270
|
});
|
|
271
271
|
return { resourceId: result.resource_id, status: result.status };
|
|
272
272
|
}
|
|
273
|
+
/**
|
|
274
|
+
* POST /messages — the real-time hot buffer. Rows are full-text searchable
|
|
275
|
+
* the instant they're inserted (a Postgres GENERATED tsvector, no embedding
|
|
276
|
+
* wait) and auto-consolidated server-side into vault Resources within ~60s.
|
|
277
|
+
* The existing search path already unions this buffer with vector results,
|
|
278
|
+
* so once we write here our turns become searchable immediately.
|
|
279
|
+
*
|
|
280
|
+
* Auth: api key + `X-As-User` (REQUIRED — the endpoint 422s without it).
|
|
281
|
+
* Upsert key is (app_id, user_id, resource_id, message_id), so re-posting an
|
|
282
|
+
* identical message_id updates `content` only — safe to retry, no duplicates.
|
|
283
|
+
*
|
|
284
|
+
* Server-side limits (return 422): per-message content 1..512,000 chars;
|
|
285
|
+
* batch 1..1,000 messages. Callers should pre-enforce these.
|
|
286
|
+
*/
|
|
287
|
+
async sendMessages(messages, options) {
|
|
288
|
+
const userId = options?.userId ?? this.config.userId;
|
|
289
|
+
if (!userId) {
|
|
290
|
+
// X-As-User is mandatory for /messages. Fail loud rather than firing a
|
|
291
|
+
// request we know will 422.
|
|
292
|
+
throw new Error("sendMessages requires a userId (X-As-User) — none configured");
|
|
293
|
+
}
|
|
294
|
+
if (messages.length === 0)
|
|
295
|
+
return { count: 0 };
|
|
296
|
+
const source = (options?.source ?? "vault").toLowerCase();
|
|
297
|
+
const body = {
|
|
298
|
+
source,
|
|
299
|
+
messages: messages.map((m) => ({
|
|
300
|
+
resource_id: m.resourceId,
|
|
301
|
+
message_id: m.messageId,
|
|
302
|
+
content: m.content,
|
|
303
|
+
})),
|
|
304
|
+
};
|
|
305
|
+
log.debugRequest("messages.create", {
|
|
306
|
+
source,
|
|
307
|
+
count: messages.length,
|
|
308
|
+
userId,
|
|
309
|
+
});
|
|
310
|
+
const res = await fetch(`${API_BASE_URL}/messages`, {
|
|
311
|
+
method: "POST",
|
|
312
|
+
headers: { ...this.rawHeaders(), "X-As-User": userId },
|
|
313
|
+
body: JSON.stringify(body),
|
|
314
|
+
});
|
|
315
|
+
if (!res.ok) {
|
|
316
|
+
const text = await res.text().catch(() => "");
|
|
317
|
+
throw new Error(`POST /messages failed (${res.status}): ${text}`);
|
|
318
|
+
}
|
|
319
|
+
const data = await res.json();
|
|
320
|
+
const count = data?.count ?? messages.length;
|
|
321
|
+
log.debugResponse("messages.create", { count });
|
|
322
|
+
return { count };
|
|
323
|
+
}
|
|
273
324
|
async listConnections(options) {
|
|
274
325
|
log.debugRequest("connections.list", { userId: options?.userId });
|
|
275
326
|
const response = await this.client.connections.list(this.requestOptions(options?.userId));
|
package/dist/commands/setup.js
CHANGED
|
@@ -269,6 +269,12 @@ async function runSetup() {
|
|
|
269
269
|
userId,
|
|
270
270
|
autoContext: true,
|
|
271
271
|
autoTrace: { enabled: false, extract: ["procedure"] },
|
|
272
|
+
hotBuffer: {
|
|
273
|
+
enabled: false,
|
|
274
|
+
source: "vault",
|
|
275
|
+
writeUser: true,
|
|
276
|
+
writeAssistant: true,
|
|
277
|
+
},
|
|
272
278
|
emotionalContext: false,
|
|
273
279
|
syncMemories: true,
|
|
274
280
|
syncMemoriesConfig: {
|
|
@@ -277,6 +283,7 @@ async function runSetup() {
|
|
|
277
283
|
watchPaths: [],
|
|
278
284
|
debounceMs: 2000,
|
|
279
285
|
maxAgeDays: 30,
|
|
286
|
+
ignorePaths: ["dreaming"],
|
|
280
287
|
},
|
|
281
288
|
sources: [],
|
|
282
289
|
maxResults: 10,
|
package/dist/config.js
CHANGED
|
@@ -15,6 +15,7 @@ const ALLOWED_KEYS = [
|
|
|
15
15
|
"userId",
|
|
16
16
|
"autoContext",
|
|
17
17
|
"autoTrace",
|
|
18
|
+
"hotBuffer",
|
|
18
19
|
"emotionalContext",
|
|
19
20
|
"relationshipId",
|
|
20
21
|
"startupOrientation",
|
|
@@ -235,6 +236,11 @@ export function parseConfig(raw) {
|
|
|
235
236
|
const kgRaw = (cfg.knowledgeGraph ?? {});
|
|
236
237
|
const atRaw = (cfg.autoTrace ?? {});
|
|
237
238
|
const soRaw = (cfg.startupOrientation ?? {});
|
|
239
|
+
const hbRaw = (cfg.hotBuffer ?? {});
|
|
240
|
+
if (cfg.hotBuffer && typeof cfg.hotBuffer === "object" && !Array.isArray(cfg.hotBuffer)) {
|
|
241
|
+
assertAllowedKeys(hbRaw, ["enabled", "source", "writeUser", "writeAssistant"], "hyperspell.hotBuffer");
|
|
242
|
+
}
|
|
243
|
+
const hbSource = parseSources(hbRaw.source)[0] ?? "vault";
|
|
238
244
|
// syncMemories can be a boolean (legacy) or an object (new)
|
|
239
245
|
const smRaw = cfg.syncMemories;
|
|
240
246
|
const syncMemoriesEnabled = typeof smRaw === "boolean"
|
|
@@ -249,7 +255,14 @@ export function parseConfig(raw) {
|
|
|
249
255
|
// object form must be too, or a typo (sectionise/debounceMS) is silently
|
|
250
256
|
// ignored and the user gets default behavior they didn't ask for.
|
|
251
257
|
if (typeof smRaw === "object" && smRaw !== null && !Array.isArray(smRaw)) {
|
|
252
|
-
assertAllowedKeys(smObj, [
|
|
258
|
+
assertAllowedKeys(smObj, [
|
|
259
|
+
"enabled",
|
|
260
|
+
"sectionize",
|
|
261
|
+
"watchPaths",
|
|
262
|
+
"debounceMs",
|
|
263
|
+
"maxAgeDays",
|
|
264
|
+
"ignorePaths",
|
|
265
|
+
], "hyperspell.syncMemories");
|
|
253
266
|
}
|
|
254
267
|
return {
|
|
255
268
|
apiKey,
|
|
@@ -262,6 +275,14 @@ export function parseConfig(raw) {
|
|
|
262
275
|
],
|
|
263
276
|
metadata: atRaw.metadata,
|
|
264
277
|
},
|
|
278
|
+
hotBuffer: {
|
|
279
|
+
// Default OFF so shipping the plugin never changes existing installs'
|
|
280
|
+
// behavior; opt in per-install via plugin config.
|
|
281
|
+
enabled: hbRaw.enabled ?? false,
|
|
282
|
+
source: hbSource,
|
|
283
|
+
writeUser: hbRaw.writeUser ?? true,
|
|
284
|
+
writeAssistant: hbRaw.writeAssistant ?? true,
|
|
285
|
+
},
|
|
265
286
|
emotionalContext: cfg.emotionalContext ?? false,
|
|
266
287
|
relationshipId: cfg.relationshipId,
|
|
267
288
|
startupOrientation: {
|
|
@@ -281,6 +302,9 @@ export function parseConfig(raw) {
|
|
|
281
302
|
: [],
|
|
282
303
|
debounceMs: smObj.debounceMs ?? 2000,
|
|
283
304
|
maxAgeDays: smObj.maxAgeDays ?? 30,
|
|
305
|
+
ignorePaths: Array.isArray(smObj.ignorePaths)
|
|
306
|
+
? smObj.ignorePaths
|
|
307
|
+
: ["dreaming"],
|
|
284
308
|
},
|
|
285
309
|
sources: parseSources(cfg.sources),
|
|
286
310
|
maxResults: cfg.maxResults ?? 10,
|
|
@@ -1,21 +1,6 @@
|
|
|
1
1
|
import { buildScopeFilter, getCanReadScopes, resolveUser, } from "../lib/sender.js";
|
|
2
|
+
import { excludeFilterFor, 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);
|
|
@@ -64,8 +49,8 @@ function formatHighlightBullets(results, maxResults, threshold) {
|
|
|
64
49
|
return null;
|
|
65
50
|
return sections.join("\n\n");
|
|
66
51
|
}
|
|
67
|
-
const INTRO = "The following is
|
|
68
|
-
const DISCLAIMER = "
|
|
52
|
+
const INTRO = "The following is surfaced from the user's memory and connected sources, including past conversations. Reference it as recalled context, only when relevant to the conversation.";
|
|
53
|
+
const DISCLAIMER = "Draw on it when relevant — including indirect connections — but don't force it into every response or make assumptions beyond what's stated.";
|
|
69
54
|
function wrapSingle(body) {
|
|
70
55
|
return `<hyperspell-context>\n${INTRO}\n\n${body}\n\n${DISCLAIMER}\n</hyperspell-context>`;
|
|
71
56
|
}
|
|
@@ -84,7 +69,7 @@ export function buildAutoContextHandler(client, cfg) {
|
|
|
84
69
|
try {
|
|
85
70
|
const results = await client.search(prompt, {
|
|
86
71
|
limit: cfg.maxResults,
|
|
87
|
-
filter:
|
|
72
|
+
filter: excludeFilterFor(cfg),
|
|
88
73
|
});
|
|
89
74
|
const formatted = formatHighlightBullets(results, cfg.maxResults, cfg.relevanceThreshold);
|
|
90
75
|
if (!formatted) {
|
|
@@ -119,7 +104,7 @@ async function multiUserSearch(client, cfg, prompt, resolved) {
|
|
|
119
104
|
? client.search(prompt, {
|
|
120
105
|
limit: cfg.maxResults,
|
|
121
106
|
userId: resolved.userId,
|
|
122
|
-
filter:
|
|
107
|
+
filter: excludeFilterFor(cfg),
|
|
123
108
|
})
|
|
124
109
|
: null;
|
|
125
110
|
// Always search shared for unknown senders, even if includeSharedInSearch is false
|
|
@@ -130,7 +115,7 @@ async function multiUserSearch(client, cfg, prompt, resolved) {
|
|
|
130
115
|
? client.search(prompt, {
|
|
131
116
|
limit: sharedLimit,
|
|
132
117
|
userId: multiUser.sharedUserId,
|
|
133
|
-
filter: mergeWithExclude(scopeFilter),
|
|
118
|
+
filter: mergeWithExclude(scopeFilter, cfg),
|
|
134
119
|
})
|
|
135
120
|
: null;
|
|
136
121
|
const searches = [personalSearch, sharedSearch].filter(Boolean);
|
|
Binary file
|
|
@@ -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
|
@@ -6,6 +6,7 @@ import { buildAutoContextHandler } from "./hooks/auto-context.js";
|
|
|
6
6
|
import { buildAutoTraceHandler } from "./hooks/auto-trace.js";
|
|
7
7
|
import { buildEmotionalStateCompactionHandler, buildEmotionalStateFetchHandler, buildEmotionalStateSessionCleanupHandler, buildEmotionalStateStoreHandler, } from "./hooks/emotional-state.js";
|
|
8
8
|
import { buildFileSyncHandler, syncMemoriesOnStartup } from "./hooks/memory-sync.js";
|
|
9
|
+
import { buildHotBufferHandler, buildHotBufferSessionCleanupHandler, } from "./hooks/hot-buffer.js";
|
|
9
10
|
import { buildStartupOrientationCompactionHandler, buildStartupOrientationHandler, buildStartupOrientationSessionCleanupHandler, } from "./hooks/startup-orientation.js";
|
|
10
11
|
import { initLogger } from "./logger.js";
|
|
11
12
|
import { createRememberToolFactory } from "./tools/remember.js";
|
|
@@ -100,6 +101,12 @@ export default {
|
|
|
100
101
|
if (cfg.autoTrace.enabled) {
|
|
101
102
|
api.on("agent_end", buildAutoTraceHandler(client, cfg));
|
|
102
103
|
}
|
|
104
|
+
// Register hot-buffer hook: write each turn to POST /messages so it's
|
|
105
|
+
// instantly full-text searchable (vs. the slow /memories embedding path).
|
|
106
|
+
if (cfg.hotBuffer.enabled) {
|
|
107
|
+
api.on("agent_end", buildHotBufferHandler(client, cfg));
|
|
108
|
+
api.on("session_end", buildHotBufferSessionCleanupHandler());
|
|
109
|
+
}
|
|
103
110
|
// Register memory sync hook
|
|
104
111
|
if (cfg.syncMemories) {
|
|
105
112
|
const fileSyncHandler = buildFileSyncHandler(client, cfg);
|
|
@@ -131,6 +138,7 @@ export default {
|
|
|
131
138
|
sectionize: cfg.syncMemoriesConfig.sectionize,
|
|
132
139
|
watchPaths: cfg.syncMemoriesConfig.watchPaths,
|
|
133
140
|
maxAgeDays: cfg.syncMemoriesConfig.maxAgeDays,
|
|
141
|
+
ignorePaths: cfg.syncMemoriesConfig.ignorePaths,
|
|
134
142
|
}).catch((err) => {
|
|
135
143
|
api.logger.error("hyperspell: background memory sync failed", err);
|
|
136
144
|
});
|
|
@@ -0,0 +1,61 @@
|
|
|
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 the auto-trace session-end hook are tagged in metadata
|
|
11
|
+
* as `openclaw_source: "agent_end"` (see `sendTrace` in client.ts). Those should
|
|
12
|
+
* NOT surface via generic retrieval — replaying whole sanitized transcripts back
|
|
13
|
+
* into context creates a self-amplifying pollution loop. Exclude them here.
|
|
14
|
+
*
|
|
15
|
+
* THE #40 TENSION: hot-buffer rows written via `POST /messages` carry NO
|
|
16
|
+
* `openclaw_source`, and the backend evaluates absent-field metadata predicates
|
|
17
|
+
* in SQL three-valued logic — `metadata->>'openclaw_source'` is NULL for a
|
|
18
|
+
* missing key, and `NULL != 'agent_end'` is NULL (not TRUE) — so this filter
|
|
19
|
+
* also drops every untagged hot-buffer row. We could not work around that at the
|
|
20
|
+
* filter layer: `docs/filter-dialect-test.mjs` against the live backend showed
|
|
21
|
+
* that NO `openclaw_source` predicate returns untagged rows ($exists/$or/$nin/
|
|
22
|
+
* $not all fail), AND that `POST /messages` silently ignores a `metadata` field,
|
|
23
|
+
* so the rows can't be positively tagged either. See `excludeFilterFor` for the
|
|
24
|
+
* fix we ship (gate on auto-trace), and issue #40 for the backend follow-up
|
|
25
|
+
* (make `/messages` accept metadata, or make the filter NULL-tolerant).
|
|
26
|
+
*
|
|
27
|
+
* NOTE: an earlier version checked the top-level `source` field for
|
|
28
|
+
* "openclaw_agent_end" — wrong on BOTH counts (the tag lives in metadata under
|
|
29
|
+
* `openclaw_source`, value `"agent_end"`), so it silently matched nothing.
|
|
30
|
+
*/
|
|
31
|
+
export const EXCLUDE_SESSION_END_FILTER = {
|
|
32
|
+
openclaw_source: { $ne: "agent_end" },
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* The exclude clause to apply for a given config — or `undefined` to skip
|
|
36
|
+
* filtering entirely (issue #40, Option 4 — the only viable plugin-side fix).
|
|
37
|
+
* `openclaw_source: "agent_end"` rows are written ONLY by the auto-trace hook;
|
|
38
|
+
* when auto-trace is disabled there are none to hide, so we skip the filter
|
|
39
|
+
* entirely — which is also the ONLY way to keep untagged hot-buffer rows
|
|
40
|
+
* visible, since (per the dialect test) no filter and no write-tag can do it.
|
|
41
|
+
*
|
|
42
|
+
* LIMITATION: when auto-trace IS enabled, this still applies `$ne agent_end`,
|
|
43
|
+
* which drops untagged hot-buffer rows along with the traces. There is no
|
|
44
|
+
* plugin-side fix for that combination today; it needs the backend change
|
|
45
|
+
* tracked in #40. (The common single-feature install has auto-trace off.)
|
|
46
|
+
*/
|
|
47
|
+
export function excludeFilterFor(cfg) {
|
|
48
|
+
return cfg.autoTrace.enabled ? EXCLUDE_SESSION_END_FILTER : undefined;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Combine a caller-supplied filter with the session-end exclude via `$and`.
|
|
52
|
+
* Returns `undefined` when neither a base filter nor an exclude applies.
|
|
53
|
+
*/
|
|
54
|
+
export function mergeWithExclude(base, cfg) {
|
|
55
|
+
const exclude = excludeFilterFor(cfg);
|
|
56
|
+
if (!exclude)
|
|
57
|
+
return base;
|
|
58
|
+
if (!base)
|
|
59
|
+
return exclude;
|
|
60
|
+
return { $and: [base, exclude] };
|
|
61
|
+
}
|
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) {
|
|
@@ -10,7 +11,7 @@ export function createSearchToolFactory(client, cfg) {
|
|
|
10
11
|
return (ctx) => ({
|
|
11
12
|
name: "hyperspell_search",
|
|
12
13
|
label: "Memory Search",
|
|
13
|
-
description: "Search
|
|
14
|
+
description: "Search the user's long-term memory and connected sources for anything not already in the current conversation. Covers: saved memories and notes; past conversations — including ones from earlier or parallel sessions you have no transcript for; and connected sources (Notion, Slack, Gmail, Google Drive, etc.). Reach for this whenever the user refers to something from before, asks what was said / decided / remembered, or when relevant context likely exists but isn't in front of you — search before concluding you don't know.",
|
|
14
15
|
parameters: Type.Object({
|
|
15
16
|
query: Type.String({ description: "Search query" }),
|
|
16
17
|
limit: Type.Optional(Type.Number({ description: "Max results (default: 5)" })),
|
|
@@ -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, cfg);
|
|
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/openclaw.plugin.json
CHANGED
|
@@ -36,6 +36,11 @@
|
|
|
36
36
|
"help": "Automatically send conversation traces to Hyperspell for memory extraction (procedural, mood) at the end of each session",
|
|
37
37
|
"advanced": true
|
|
38
38
|
},
|
|
39
|
+
"hotBuffer": {
|
|
40
|
+
"label": "Hot Buffer",
|
|
41
|
+
"help": "Write each turn to the realtime message buffer (POST /messages) so it is instantly full-text searchable, then auto-consolidated into vault Resources. Requires userId. { enabled, source, writeUser, writeAssistant }",
|
|
42
|
+
"advanced": true
|
|
43
|
+
},
|
|
39
44
|
"emotionalContext": {
|
|
40
45
|
"label": "Emotional Context",
|
|
41
46
|
"help": "Maintain emotional continuity across sessions — remembers not just what happened, but how it felt",
|
|
@@ -116,6 +121,15 @@
|
|
|
116
121
|
"metadata": { "type": "object" }
|
|
117
122
|
}
|
|
118
123
|
},
|
|
124
|
+
"hotBuffer": {
|
|
125
|
+
"type": "object",
|
|
126
|
+
"properties": {
|
|
127
|
+
"enabled": { "type": "boolean" },
|
|
128
|
+
"source": { "type": "string" },
|
|
129
|
+
"writeUser": { "type": "boolean" },
|
|
130
|
+
"writeAssistant": { "type": "boolean" }
|
|
131
|
+
}
|
|
132
|
+
},
|
|
119
133
|
"emotionalContext": {
|
|
120
134
|
"type": "boolean"
|
|
121
135
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperspell/openclaw-hyperspell",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.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 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/hot-buffer.test.ts hooks/emotional-state.test.ts hooks/startup-orientation.test.ts"
|
|
42
42
|
},
|
|
43
43
|
"openclaw": {
|
|
44
44
|
"extensions": [
|