@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 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));
@@ -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 { EXCLUDE_SESSION_END_FILTER, mergeWithExclude } from "../lib/filters.js";
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 context from the user's connected sources. Reference it only when relevant to the conversation.";
53
- const DISCLAIMER = "Use this context naturally when relevant — including indirect connections — but don't force it into every response or make assumptions beyond what's stated.";
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: EXCLUDE_SESSION_END_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: EXCLUDE_SESSION_END_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);
@@ -7,24 +7,55 @@
7
7
  * convention `buildScopeFilter` uses (`openclaw_scope`, `openclaw_user`).
8
8
  */
9
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.
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
- * 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.
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
- /** Combine a caller-supplied filter with the session-end exclude via `$and`. */
26
- export function mergeWithExclude(base) {
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 EXCLUDE_SESSION_END_FILTER;
29
- return { $and: [base, EXCLUDE_SESSION_END_FILTER] };
59
+ return exclude;
60
+ return { $and: [base, exclude] };
30
61
  }
@@ -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 through the user's connected sources (Notion, Slack, Gmail, Google Drive, etc.) for relevant information.",
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, {
@@ -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.14.2",
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": [