@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 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: {
@@ -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, ["enabled", "sectionize", "watchPaths", "debounceMs", "maxAgeDays"], "hyperspell.syncMemories");
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 context from the user's connected sources. Reference it only when relevant to the conversation.";
68
- 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.";
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: EXCLUDE_SESSION_END_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: EXCLUDE_SESSION_END_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
+ }
@@ -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
- export function getMemoryFiles(workspaceDir) {
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 files = getMemoryFiles(workspaceDir);
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.
@@ -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 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.",
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, {
@@ -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.1",
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": [