@hyperspell/openclaw-hyperspell 0.17.0 → 0.17.1

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/config.js CHANGED
@@ -72,6 +72,7 @@ function parseRanking(raw) {
72
72
  ? r.storyTerms.map((t) => String(t)).filter((t) => t.length > 0)
73
73
  : DEFAULT_RANKING.storyTerms,
74
74
  candidateMultiplier: Math.max(1, num(r.candidateMultiplier, DEFAULT_RANKING.candidateMultiplier)),
75
+ chatterQuota: Math.max(0, num(r.chatterQuota, DEFAULT_RANKING.chatterQuota)),
75
76
  };
76
77
  }
77
78
  function parseSources(raw) {
@@ -1,6 +1,6 @@
1
1
  import { buildScopeFilter, getCanReadScopes, resolveUser, } from "../lib/sender.js";
2
2
  import { excludeFilterFor, mergeWithExclude } from "../lib/filters.js";
3
- import { rerank } from "../lib/ranking.js";
3
+ import { rerank, selectRanked } from "../lib/ranking.js";
4
4
  import { classifySearchError, logSearchError } from "../lib/search-error.js";
5
5
  import { resolveCurrentSessionId } from "../lib/session.js";
6
6
  import { log } from "../logger.js";
@@ -53,18 +53,14 @@ function formatHighlightBullets(results, maxResults, threshold) {
53
53
  return sections.join("\n\n");
54
54
  }
55
55
  /**
56
- * Like formatHighlightBullets, but for composite-RANKED results: a result is
57
- * kept on its composite score (relevance + curation/story boost chatter), not
58
- * raw relevance so a deliberately-kept memory that's quietly relevant clears
59
- * the bar where a louder conversation echo doesn't. Highlights are floored at
60
- * the lower of (threshold, the result's own base relevance), so we don't then
61
- * hide the very lines that define a boosted-but-quiet memory.
56
+ * Format already-SELECTED composite-ranked results (threshold + chatter quota
57
+ * applied upstream by selectRanked). Highlights are floored at the lower of
58
+ * (threshold, the result's own base relevance), so we don't hide the very lines
59
+ * that define a boosted-but-quiet memory.
62
60
  */
63
- function formatRankedBullets(ranked, maxResults, threshold) {
61
+ function formatSelected(selected, threshold) {
64
62
  const sections = [];
65
- for (const r of ranked) {
66
- if (r._composite < threshold)
67
- continue;
63
+ for (const r of selected) {
68
64
  const hiFloor = Math.min(threshold, r._base);
69
65
  const chosen = [...r.highlights]
70
66
  .sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
@@ -77,8 +73,6 @@ function formatRankedBullets(ranked, maxResults, threshold) {
77
73
  .map((h) => `- ${h.text.replace(/\n/g, " ")} [${Math.round((h.score ?? 0) * 100)}%]`)
78
74
  .join("\n");
79
75
  sections.push(`### ${title} (resource_id: ${r.resourceId}, source: ${r.source})\n\n${bullets}`);
80
- if (sections.length >= maxResults)
81
- break;
82
76
  }
83
77
  if (sections.length === 0)
84
78
  return null;
@@ -86,18 +80,15 @@ function formatRankedBullets(ranked, maxResults, threshold) {
86
80
  }
87
81
  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.";
88
82
  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.";
89
- // Injected EVERY turn (even when nothing cleared the bar): this passive match is
90
- // a starting point, not the whole of memory. The point is to make her LOOK —
91
- // actively search before concluding rather than answer from whatever happened
92
- // to surface, which is what lets an agent invent instead of recall.
93
- const SEARCH_DIRECTIVE = "This is a passive match and may miss what matters. Before answering anything that touches your shared history, a past decision, a name, a promise, or something you may have recorded, run hyperspell_search with a specific query and look even if something is already shown above. Don't answer from impression when you can check. If a search returns nothing, say so plainly; never fill the gap with something invented.";
94
- /** Wrap the per-turn context: the standing search directive always, plus the
95
- * surfaced memory when anything cleared the bar. */
83
+ // A short, CONDITIONAL reminder appended only to a real memory block (not a
84
+ // standing every-turn imperative that reads as ambient framing and trains
85
+ // search-as-ritual; the agent's own instructions carry the standing rule). The
86
+ // point here is just: what surfaced is a passive match, not all of memory.
87
+ const SEARCH_REMINDER = "This is a passive match, not all of memory if the answer turns on a specific past decision, promise, name, or something recorded, search for it directly before concluding, and say so plainly if it isn't there.";
88
+ /** Wrap a real memory block. No memory no injection (caller returns nothing);
89
+ * the standing search rule lives in the agent's own instructions, not here. */
96
90
  function wrapContext(memorySection) {
97
- if (memorySection) {
98
- return `<hyperspell-context>\n${INTRO}\n\n${memorySection}\n\n${DISCLAIMER}\n\n${SEARCH_DIRECTIVE}\n</hyperspell-context>`;
99
- }
100
- return `<hyperspell-context>\n${SEARCH_DIRECTIVE}\n</hyperspell-context>`;
91
+ return `<hyperspell-context>\n${INTRO}\n\n${memorySection}\n\n${DISCLAIMER}\n\n${SEARCH_REMINDER}\n</hyperspell-context>`;
101
92
  }
102
93
  /**
103
94
  * Drop results belonging to the CURRENT session. The hot buffer writes the live
@@ -146,11 +137,13 @@ export function buildAutoContextHandler(client, cfg) {
146
137
  let formatted;
147
138
  if (ranking.enabled) {
148
139
  const ranked = rerank(results, ranking);
149
- formatted = formatRankedBullets(ranked, cfg.maxResults, cfg.relevanceThreshold);
140
+ // Threshold + chatter quota applied here, so a high-similarity echo can
141
+ // inform but never flood (the quota bounds count; the penalty bounds rank).
142
+ const selected = selectRanked(ranked, cfg.maxResults, cfg.relevanceThreshold, ranking.chatterQuota);
143
+ formatted = formatSelected(selected, cfg.relevanceThreshold);
150
144
  if (formatted) {
151
- const kept = ranked.filter((r) => r._composite >= cfg.relevanceThreshold);
152
- const tally = kept.slice(0, cfg.maxResults).reduce((acc, r) => ((acc[r._kind] = (acc[r._kind] ?? 0) + 1), acc), {});
153
- log.debug(`auto-context: injecting (ranked) ${JSON.stringify(tally)} from ${results.length} candidates`);
145
+ const tally = selected.reduce((acc, r) => ((acc[r._kind] = (acc[r._kind] ?? 0) + 1), acc), {});
146
+ log.debug(`auto-context: injecting (ranked) ${JSON.stringify(tally)} from ${results.length} candidates (chatter cap ${ranking.chatterQuota})`);
154
147
  }
155
148
  }
156
149
  else {
@@ -158,11 +151,12 @@ export function buildAutoContextHandler(client, cfg) {
158
151
  if (formatted)
159
152
  log.debug(`auto-context: injecting ${results.length} memories`);
160
153
  }
161
- // Always inject the standing search directive so she's prompted to LOOK
162
- // every turn with the surfaced memory appended when anything cleared the
163
- // bar. (#issue: passive injection alone let her answer from impression.)
154
+ // No memory cleared the bar no injection. The standing "search before you
155
+ // conclude" rule lives in the agent's own instructions, not an ambient
156
+ // every-turn banner (which reads as framing and trains search-as-ritual).
164
157
  if (!formatted) {
165
- log.debug("auto-context: nothing cleared the bar — injecting search directive only");
158
+ log.debug("auto-context: no relevant memories found");
159
+ return;
166
160
  }
167
161
  return { prependContext: wrapContext(formatted) };
168
162
  }
@@ -5,6 +5,7 @@ export const DEFAULT_RANKING = {
5
5
  storyBoost: 0.15,
6
6
  storyTerms: [],
7
7
  candidateMultiplier: 3,
8
+ chatterQuota: 2,
8
9
  };
9
10
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
10
11
  /** Highest available relevance for a result (doc score or its best highlight). */
@@ -61,3 +62,26 @@ export function rerank(results, w) {
61
62
  })
62
63
  .sort((a, b) => b._composite - a._composite);
63
64
  }
65
+ /**
66
+ * Choose which ranked results to inject: keep those clearing `threshold` on
67
+ * their composite, cap CHATTER at `chatterQuota` regardless of score (so a
68
+ * high-similarity echo can inform but never flood — the penalty bounds rank,
69
+ * the quota bounds count), and stop at `maxResults`.
70
+ */
71
+ export function selectRanked(ranked, maxResults, threshold, chatterQuota) {
72
+ const out = [];
73
+ let chatter = 0;
74
+ for (const r of ranked) {
75
+ if (r._composite < threshold)
76
+ continue;
77
+ if (r._kind === "chatter") {
78
+ if (chatter >= chatterQuota)
79
+ continue;
80
+ chatter++;
81
+ }
82
+ out.push(r);
83
+ if (out.length >= maxResults)
84
+ break;
85
+ }
86
+ return out;
87
+ }
@@ -12,7 +12,7 @@ export function createSearchToolFactory(client, cfg) {
12
12
  return (ctx) => ({
13
13
  name: "hyperspell_search",
14
14
  label: "Memory Search",
15
- description: "Search the user's long-term memory and connected sources: saved memories and notes; past conversations — including earlier or parallel sessions you have no transcript for; and connected sources (Notion, Slack, Gmail, Google Drive, etc.). Use this PROACTIVELY and by default, as a routine step not only when asked. Before answering anything that touches the user's history, a past decision, a name, a promise, or something you might have recorded, search FIRST with a specific query and look. Don't answer from impression when you can check. If a search comes back empty, say so plainly — never fill the gap with something invented.",
15
+ description: "Search the user's long-term memory and connected sources: saved memories and notes; past conversations — including earlier or parallel sessions you have no transcript for; and connected sources (Notion, Slack, Gmail, Google Drive, etc.). Use this proactively, not only when asked but on content, not as ritual: before answering anything that touches the user's history, a past decision, a name, a promise, or something you might have recorded, search FIRST with a specific query and look. Don't answer from impression when you can check. If a search comes back empty, say so plainly — never fill the gap with something invented.",
16
16
  parameters: Type.Object({
17
17
  query: Type.String({ description: "Search query" }),
18
18
  limit: Type.Optional(Type.Number({ description: "Max results (default: 5)" })),
@@ -164,7 +164,8 @@
164
164
  "chatterPenalty": { "type": "number", "minimum": 0, "maximum": 1 },
165
165
  "storyBoost": { "type": "number", "minimum": 0, "maximum": 1 },
166
166
  "storyTerms": { "type": "array", "items": { "type": "string" } },
167
- "candidateMultiplier": { "type": "number", "minimum": 1, "maximum": 10 }
167
+ "candidateMultiplier": { "type": "number", "minimum": 1, "maximum": 10 },
168
+ "chatterQuota": { "type": "number", "minimum": 0, "maximum": 20 }
168
169
  }
169
170
  },
170
171
  "debug": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperspell/openclaw-hyperspell",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "type": "module",
5
5
  "description": "OpenClaw Hyperspell memory plugin",
6
6
  "license": "MIT",