@hyperspell/openclaw-hyperspell 0.16.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.
@@ -6,6 +6,7 @@ import * as p from "@clack/prompts";
6
6
  import Hyperspell from "hyperspell";
7
7
  import { syncAllFilesSectionized, syncAllMemoryFiles, getMemoryFiles, } from "../sync/markdown.js";
8
8
  import { openInBrowser } from "../lib/browser.js";
9
+ import { DEFAULT_RANKING } from "../lib/ranking.js";
9
10
  import { HyperspellClient } from "../client.js";
10
11
  import { getWorkspaceDir, parseConfig, resolveConfigPath } from "../config.js";
11
12
  import { buildExtractionPrompt, CRON_JOB_NAME } from "../graph/cron.js";
@@ -288,6 +289,7 @@ async function runSetup() {
288
289
  sources: [],
289
290
  maxResults: 10,
290
291
  relevanceThreshold: 0.6,
292
+ ranking: DEFAULT_RANKING,
291
293
  debug: false,
292
294
  knowledgeGraph: { enabled: false, scanIntervalMinutes: 60, batchSize: 20 },
293
295
  startupOrientation: {
package/dist/config.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
+ import { DEFAULT_RANKING } from "./lib/ranking.js";
4
5
  /**
5
6
  * Convert user-facing scope names (which may contain hyphens) to SDK-safe
6
7
  * metadata values (alphanumeric + underscore only). Must be applied at every
@@ -23,6 +24,7 @@ const ALLOWED_KEYS = [
23
24
  "sources",
24
25
  "maxResults",
25
26
  "relevanceThreshold",
27
+ "ranking",
26
28
  "debug",
27
29
  "knowledgeGraph",
28
30
  "multiUser",
@@ -58,6 +60,21 @@ function resolveEnvVars(value) {
58
60
  return envValue;
59
61
  });
60
62
  }
63
+ function parseRanking(raw) {
64
+ const r = (raw ?? {});
65
+ const num = (v, d) => (typeof v === "number" ? v : d);
66
+ return {
67
+ enabled: r.enabled ?? DEFAULT_RANKING.enabled,
68
+ curationBoost: num(r.curationBoost, DEFAULT_RANKING.curationBoost),
69
+ chatterPenalty: num(r.chatterPenalty, DEFAULT_RANKING.chatterPenalty),
70
+ storyBoost: num(r.storyBoost, DEFAULT_RANKING.storyBoost),
71
+ storyTerms: Array.isArray(r.storyTerms)
72
+ ? r.storyTerms.map((t) => String(t)).filter((t) => t.length > 0)
73
+ : DEFAULT_RANKING.storyTerms,
74
+ candidateMultiplier: Math.max(1, num(r.candidateMultiplier, DEFAULT_RANKING.candidateMultiplier)),
75
+ chatterQuota: Math.max(0, num(r.chatterQuota, DEFAULT_RANKING.chatterQuota)),
76
+ };
77
+ }
61
78
  function parseSources(raw) {
62
79
  if (!raw) {
63
80
  return [];
@@ -309,6 +326,7 @@ export function parseConfig(raw) {
309
326
  sources: parseSources(cfg.sources),
310
327
  maxResults: cfg.maxResults ?? 10,
311
328
  relevanceThreshold: cfg.relevanceThreshold ?? 0.6,
329
+ ranking: parseRanking(cfg.ranking),
312
330
  debug: cfg.debug ?? false,
313
331
  knowledgeGraph: {
314
332
  enabled: kgRaw.enabled ?? false,
@@ -1,5 +1,6 @@
1
1
  import { buildScopeFilter, getCanReadScopes, resolveUser, } from "../lib/sender.js";
2
2
  import { excludeFilterFor, mergeWithExclude } from "../lib/filters.js";
3
+ import { rerank, selectRanked } from "../lib/ranking.js";
3
4
  import { classifySearchError, logSearchError } from "../lib/search-error.js";
4
5
  import { resolveCurrentSessionId } from "../lib/session.js";
5
6
  import { log } from "../logger.js";
@@ -51,10 +52,43 @@ function formatHighlightBullets(results, maxResults, threshold) {
51
52
  return null;
52
53
  return sections.join("\n\n");
53
54
  }
55
+ /**
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.
60
+ */
61
+ function formatSelected(selected, threshold) {
62
+ const sections = [];
63
+ for (const r of selected) {
64
+ const hiFloor = Math.min(threshold, r._base);
65
+ const chosen = [...r.highlights]
66
+ .sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
67
+ .filter((h) => (h.score ?? 0) >= hiFloor)
68
+ .slice(0, 2);
69
+ if (chosen.length === 0)
70
+ continue;
71
+ const title = r.title ?? `[${r.source}]`;
72
+ const bullets = chosen
73
+ .map((h) => `- ${h.text.replace(/\n/g, " ")} [${Math.round((h.score ?? 0) * 100)}%]`)
74
+ .join("\n");
75
+ sections.push(`### ${title} (resource_id: ${r.resourceId}, source: ${r.source})\n\n${bullets}`);
76
+ }
77
+ if (sections.length === 0)
78
+ return null;
79
+ return sections.join("\n\n");
80
+ }
54
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.";
55
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.";
56
- function wrapSingle(body) {
57
- return `<hyperspell-context>\n${INTRO}\n\n${body}\n\n${DISCLAIMER}\n</hyperspell-context>`;
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. */
90
+ function wrapContext(memorySection) {
91
+ return `<hyperspell-context>\n${INTRO}\n\n${memorySection}\n\n${DISCLAIMER}\n\n${SEARCH_REMINDER}\n</hyperspell-context>`;
58
92
  }
59
93
  /**
60
94
  * Drop results belonging to the CURRENT session. The hot buffer writes the live
@@ -93,17 +127,38 @@ export function buildAutoContextHandler(client, cfg) {
93
127
  // Single-user path — preserves main's highlights + threshold behavior
94
128
  log.debug(`auto-context: searching for "${prompt.slice(0, 50)}..."`);
95
129
  try {
96
- const results = dropCurrentSession(await client.search(prompt, {
97
- limit: cfg.maxResults,
98
- filter: excludeFilterFor(cfg),
99
- }), currentSessionId);
100
- const formatted = formatHighlightBullets(results, cfg.maxResults, cfg.relevanceThreshold);
130
+ const ranking = cfg.ranking;
131
+ // When composite ranking is on, fetch a WIDER candidate pool so quiet-but-
132
+ // true memory is present to be re-ranked, not cut off below the fetch limit.
133
+ const limit = ranking.enabled
134
+ ? cfg.maxResults * ranking.candidateMultiplier
135
+ : cfg.maxResults;
136
+ const results = dropCurrentSession(await client.search(prompt, { limit, filter: excludeFilterFor(cfg) }), currentSessionId);
137
+ let formatted;
138
+ if (ranking.enabled) {
139
+ const ranked = rerank(results, ranking);
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);
144
+ if (formatted) {
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})`);
147
+ }
148
+ }
149
+ else {
150
+ formatted = formatHighlightBullets(results, cfg.maxResults, cfg.relevanceThreshold);
151
+ if (formatted)
152
+ log.debug(`auto-context: injecting ${results.length} memories`);
153
+ }
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).
101
157
  if (!formatted) {
102
158
  log.debug("auto-context: no relevant memories found");
103
159
  return;
104
160
  }
105
- log.debug(`auto-context: injecting ${results.length} memories`);
106
- return { prependContext: wrapSingle(formatted) };
161
+ return { prependContext: wrapContext(formatted) };
107
162
  }
108
163
  catch (err) {
109
164
  // A transient backend throttle (429 / Retry-After) must not be swallowed
@@ -0,0 +1,87 @@
1
+ export const DEFAULT_RANKING = {
2
+ enabled: true,
3
+ curationBoost: 0.2,
4
+ chatterPenalty: 0.2,
5
+ storyBoost: 0.15,
6
+ storyTerms: [],
7
+ candidateMultiplier: 3,
8
+ chatterQuota: 2,
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;
11
+ /** Highest available relevance for a result (doc score or its best highlight). */
12
+ export function baseScore(r) {
13
+ const topHighlight = r.highlights.reduce((m, h) => Math.max(m, h.score ?? 0), 0);
14
+ return Math.max(r.score ?? 0, topHighlight);
15
+ }
16
+ /**
17
+ * Classify a result by what KIND of memory it is — using only fields the search
18
+ * already returns (title shape + resource id), so no extra round-trip:
19
+ * - story: matches a configured story term (manuscript / its notes & threads)
20
+ * - chatter: untitled or "Unnamed Conversation" AND keyed by a bare session
21
+ * UUID — i.e. a hot-buffer conversation fragment (the dreamy echoes)
22
+ * - curated: has a real, human title and is not a raw session row — a journal,
23
+ * a writing note, a synced memory section (deliberately kept)
24
+ */
25
+ export function classifyResult(r, storyTerms) {
26
+ const title = (r.title ?? "").trim();
27
+ if (storyTerms.length > 0) {
28
+ const hay = `${title} ${r.highlights.map((h) => h.text).join(" ")}`.toLowerCase();
29
+ if (storyTerms.some((t) => t && hay.includes(t.toLowerCase())))
30
+ return "story";
31
+ }
32
+ const untitled = title === "" || /^unnamed conversation$/i.test(title);
33
+ if (untitled && UUID_RE.test(r.resourceId))
34
+ return "chatter";
35
+ if (title !== "" && !UUID_RE.test(r.resourceId))
36
+ return "curated";
37
+ return "other";
38
+ }
39
+ /** Composite score + classification for one result. */
40
+ export function scoreResult(r, w) {
41
+ const kind = classifyResult(r, w.storyTerms);
42
+ const base = baseScore(r);
43
+ let composite = base;
44
+ if (kind === "story")
45
+ composite += w.storyBoost + w.curationBoost; // the story is kept memory too
46
+ else if (kind === "curated")
47
+ composite += w.curationBoost;
48
+ else if (kind === "chatter")
49
+ composite -= w.chatterPenalty;
50
+ return { kind, base, composite };
51
+ }
52
+ /** Re-rank results by composite score (descending). Pure; stable enough. */
53
+ export function rerank(results, w) {
54
+ return results
55
+ .map((r) => {
56
+ const s = scoreResult(r, w);
57
+ return Object.assign({}, r, {
58
+ _kind: s.kind,
59
+ _base: s.base,
60
+ _composite: s.composite,
61
+ });
62
+ })
63
+ .sort((a, b) => b._composite - a._composite);
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 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
+ 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)" })),
@@ -154,6 +154,20 @@
154
154
  "minimum": 1,
155
155
  "maximum": 20
156
156
  },
157
+ "ranking": {
158
+ "type": "object",
159
+ "additionalProperties": false,
160
+ "description": "Composite retrieval ranking — rank memories by more than raw relevance so kept memory (notes, journals, the active story) surfaces above auto-saved conversation fragments.",
161
+ "properties": {
162
+ "enabled": { "type": "boolean" },
163
+ "curationBoost": { "type": "number", "minimum": 0, "maximum": 1 },
164
+ "chatterPenalty": { "type": "number", "minimum": 0, "maximum": 1 },
165
+ "storyBoost": { "type": "number", "minimum": 0, "maximum": 1 },
166
+ "storyTerms": { "type": "array", "items": { "type": "string" } },
167
+ "candidateMultiplier": { "type": "number", "minimum": 1, "maximum": 10 },
168
+ "chatterQuota": { "type": "number", "minimum": 0, "maximum": 20 }
169
+ }
170
+ },
157
171
  "debug": {
158
172
  "type": "boolean"
159
173
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperspell/openclaw-hyperspell",
3
- "version": "0.16.0",
3
+ "version": "0.17.1",
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 client.test.ts lib/sender.test.ts lib/filters.test.ts lib/search-error.test.ts lib/session.test.ts tools/search.test.ts config.test.ts sync/markdown.test.ts hooks/auto-trace.test.ts hooks/auto-context.test.ts hooks/hot-buffer.test.ts hooks/emotional-state.test.ts hooks/startup-orientation.test.ts"
41
+ "test": "node --test --experimental-strip-types client.test.ts lib/sender.test.ts lib/filters.test.ts lib/search-error.test.ts lib/session.test.ts lib/ranking.test.ts tools/search.test.ts config.test.ts sync/markdown.test.ts hooks/auto-trace.test.ts hooks/auto-context.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": [