@hyperspell/openclaw-hyperspell 0.14.2 → 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 +6 -0
- package/dist/config.js +14 -0
- package/dist/hooks/auto-context.js +6 -6
- package/dist/hooks/hot-buffer.js +0 -0
- package/dist/index.js +7 -0
- package/dist/lib/filters.js +45 -14
- package/dist/tools/search.js +2 -2
- 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: {
|
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"
|
|
@@ -269,6 +275,14 @@ export function parseConfig(raw) {
|
|
|
269
275
|
],
|
|
270
276
|
metadata: atRaw.metadata,
|
|
271
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
|
+
},
|
|
272
286
|
emotionalContext: cfg.emotionalContext ?? false,
|
|
273
287
|
relationshipId: cfg.relationshipId,
|
|
274
288
|
startupOrientation: {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { buildScopeFilter, getCanReadScopes, resolveUser, } from "../lib/sender.js";
|
|
2
|
-
import {
|
|
2
|
+
import { excludeFilterFor, mergeWithExclude } from "../lib/filters.js";
|
|
3
3
|
import { log } from "../logger.js";
|
|
4
4
|
function formatRelativeTime(isoTimestamp) {
|
|
5
5
|
try {
|
|
@@ -49,8 +49,8 @@ function formatHighlightBullets(results, maxResults, threshold) {
|
|
|
49
49
|
return null;
|
|
50
50
|
return sections.join("\n\n");
|
|
51
51
|
}
|
|
52
|
-
const INTRO = "The following is
|
|
53
|
-
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.";
|
|
54
54
|
function wrapSingle(body) {
|
|
55
55
|
return `<hyperspell-context>\n${INTRO}\n\n${body}\n\n${DISCLAIMER}\n</hyperspell-context>`;
|
|
56
56
|
}
|
|
@@ -69,7 +69,7 @@ export function buildAutoContextHandler(client, cfg) {
|
|
|
69
69
|
try {
|
|
70
70
|
const results = await client.search(prompt, {
|
|
71
71
|
limit: cfg.maxResults,
|
|
72
|
-
filter:
|
|
72
|
+
filter: excludeFilterFor(cfg),
|
|
73
73
|
});
|
|
74
74
|
const formatted = formatHighlightBullets(results, cfg.maxResults, cfg.relevanceThreshold);
|
|
75
75
|
if (!formatted) {
|
|
@@ -104,7 +104,7 @@ async function multiUserSearch(client, cfg, prompt, resolved) {
|
|
|
104
104
|
? client.search(prompt, {
|
|
105
105
|
limit: cfg.maxResults,
|
|
106
106
|
userId: resolved.userId,
|
|
107
|
-
filter:
|
|
107
|
+
filter: excludeFilterFor(cfg),
|
|
108
108
|
})
|
|
109
109
|
: null;
|
|
110
110
|
// Always search shared for unknown senders, even if includeSharedInSearch is false
|
|
@@ -115,7 +115,7 @@ async function multiUserSearch(client, cfg, prompt, resolved) {
|
|
|
115
115
|
? client.search(prompt, {
|
|
116
116
|
limit: sharedLimit,
|
|
117
117
|
userId: multiUser.sharedUserId,
|
|
118
|
-
filter: mergeWithExclude(scopeFilter),
|
|
118
|
+
filter: mergeWithExclude(scopeFilter, cfg),
|
|
119
119
|
})
|
|
120
120
|
: null;
|
|
121
121
|
const searches = [personalSearch, sharedSearch].filter(Boolean);
|
|
Binary file
|
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);
|
package/dist/lib/filters.js
CHANGED
|
@@ -7,24 +7,55 @@
|
|
|
7
7
|
* convention `buildScopeFilter` uses (`openclaw_scope`, `openclaw_user`).
|
|
8
8
|
*/
|
|
9
9
|
/**
|
|
10
|
-
* Memories produced by
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* replaying whole sanitized transcripts back into context creates a
|
|
15
|
-
* self-amplifying pollution loop. Exclude them at the search filter.
|
|
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.
|
|
16
14
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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.
|
|
21
30
|
*/
|
|
22
31
|
export const EXCLUDE_SESSION_END_FILTER = {
|
|
23
32
|
openclaw_source: { $ne: "agent_end" },
|
|
24
33
|
};
|
|
25
|
-
/**
|
|
26
|
-
|
|
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;
|
|
27
58
|
if (!base)
|
|
28
|
-
return
|
|
29
|
-
return { $and: [base,
|
|
59
|
+
return exclude;
|
|
60
|
+
return { $and: [base, exclude] };
|
|
30
61
|
}
|
package/dist/tools/search.js
CHANGED
|
@@ -11,7 +11,7 @@ export function createSearchToolFactory(client, cfg) {
|
|
|
11
11
|
return (ctx) => ({
|
|
12
12
|
name: "hyperspell_search",
|
|
13
13
|
label: "Memory Search",
|
|
14
|
-
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.",
|
|
15
15
|
parameters: Type.Object({
|
|
16
16
|
query: Type.String({ description: "Search query" }),
|
|
17
17
|
limit: Type.Optional(Type.Number({ description: "Max results (default: 5)" })),
|
|
@@ -41,7 +41,7 @@ export function createSearchToolFactory(client, cfg) {
|
|
|
41
41
|
}
|
|
42
42
|
// Keep session-end trace memories out of agent-facing search, matching
|
|
43
43
|
// the auto-context hook so both retrieval paths filter identically.
|
|
44
|
-
filter = mergeWithExclude(filter);
|
|
44
|
+
filter = mergeWithExclude(filter, cfg);
|
|
45
45
|
log.debug(`search tool: query="${params.query}" limit=${limit} after=${params.after ?? "none"} before=${params.before ?? "none"} userId=${userId} scope=${params.scope ?? "any"}`);
|
|
46
46
|
try {
|
|
47
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 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"
|
|
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": [
|