@hyperspell/openclaw-hyperspell 0.16.0 → 0.17.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.
@@ -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,20 @@ 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
+ };
76
+ }
61
77
  function parseSources(raw) {
62
78
  if (!raw) {
63
79
  return [];
@@ -309,6 +325,7 @@ export function parseConfig(raw) {
309
325
  sources: parseSources(cfg.sources),
310
326
  maxResults: cfg.maxResults ?? 10,
311
327
  relevanceThreshold: cfg.relevanceThreshold ?? 0.6,
328
+ ranking: parseRanking(cfg.ranking),
312
329
  debug: cfg.debug ?? false,
313
330
  knowledgeGraph: {
314
331
  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 } 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,52 @@ function formatHighlightBullets(results, maxResults, threshold) {
51
52
  return null;
52
53
  return sections.join("\n\n");
53
54
  }
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.
62
+ */
63
+ function formatRankedBullets(ranked, maxResults, threshold) {
64
+ const sections = [];
65
+ for (const r of ranked) {
66
+ if (r._composite < threshold)
67
+ continue;
68
+ const hiFloor = Math.min(threshold, r._base);
69
+ const chosen = [...r.highlights]
70
+ .sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
71
+ .filter((h) => (h.score ?? 0) >= hiFloor)
72
+ .slice(0, 2);
73
+ if (chosen.length === 0)
74
+ continue;
75
+ const title = r.title ?? `[${r.source}]`;
76
+ const bullets = chosen
77
+ .map((h) => `- ${h.text.replace(/\n/g, " ")} [${Math.round((h.score ?? 0) * 100)}%]`)
78
+ .join("\n");
79
+ sections.push(`### ${title} (resource_id: ${r.resourceId}, source: ${r.source})\n\n${bullets}`);
80
+ if (sections.length >= maxResults)
81
+ break;
82
+ }
83
+ if (sections.length === 0)
84
+ return null;
85
+ return sections.join("\n\n");
86
+ }
54
87
  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
88
  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>`;
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. */
96
+ 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>`;
58
101
  }
59
102
  /**
60
103
  * Drop results belonging to the CURRENT session. The hot buffer writes the live
@@ -93,17 +136,35 @@ export function buildAutoContextHandler(client, cfg) {
93
136
  // Single-user path — preserves main's highlights + threshold behavior
94
137
  log.debug(`auto-context: searching for "${prompt.slice(0, 50)}..."`);
95
138
  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);
139
+ const ranking = cfg.ranking;
140
+ // When composite ranking is on, fetch a WIDER candidate pool so quiet-but-
141
+ // true memory is present to be re-ranked, not cut off below the fetch limit.
142
+ const limit = ranking.enabled
143
+ ? cfg.maxResults * ranking.candidateMultiplier
144
+ : cfg.maxResults;
145
+ const results = dropCurrentSession(await client.search(prompt, { limit, filter: excludeFilterFor(cfg) }), currentSessionId);
146
+ let formatted;
147
+ if (ranking.enabled) {
148
+ const ranked = rerank(results, ranking);
149
+ formatted = formatRankedBullets(ranked, cfg.maxResults, cfg.relevanceThreshold);
150
+ 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`);
154
+ }
155
+ }
156
+ else {
157
+ formatted = formatHighlightBullets(results, cfg.maxResults, cfg.relevanceThreshold);
158
+ if (formatted)
159
+ log.debug(`auto-context: injecting ${results.length} memories`);
160
+ }
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.)
101
164
  if (!formatted) {
102
- log.debug("auto-context: no relevant memories found");
103
- return;
165
+ log.debug("auto-context: nothing cleared the bar — injecting search directive only");
104
166
  }
105
- log.debug(`auto-context: injecting ${results.length} memories`);
106
- return { prependContext: wrapSingle(formatted) };
167
+ return { prependContext: wrapContext(formatted) };
107
168
  }
108
169
  catch (err) {
109
170
  // A transient backend throttle (429 / Retry-After) must not be swallowed
@@ -0,0 +1,63 @@
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
+ };
9
+ 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
+ /** Highest available relevance for a result (doc score or its best highlight). */
11
+ export function baseScore(r) {
12
+ const topHighlight = r.highlights.reduce((m, h) => Math.max(m, h.score ?? 0), 0);
13
+ return Math.max(r.score ?? 0, topHighlight);
14
+ }
15
+ /**
16
+ * Classify a result by what KIND of memory it is — using only fields the search
17
+ * already returns (title shape + resource id), so no extra round-trip:
18
+ * - story: matches a configured story term (manuscript / its notes & threads)
19
+ * - chatter: untitled or "Unnamed Conversation" AND keyed by a bare session
20
+ * UUID — i.e. a hot-buffer conversation fragment (the dreamy echoes)
21
+ * - curated: has a real, human title and is not a raw session row — a journal,
22
+ * a writing note, a synced memory section (deliberately kept)
23
+ */
24
+ export function classifyResult(r, storyTerms) {
25
+ const title = (r.title ?? "").trim();
26
+ if (storyTerms.length > 0) {
27
+ const hay = `${title} ${r.highlights.map((h) => h.text).join(" ")}`.toLowerCase();
28
+ if (storyTerms.some((t) => t && hay.includes(t.toLowerCase())))
29
+ return "story";
30
+ }
31
+ const untitled = title === "" || /^unnamed conversation$/i.test(title);
32
+ if (untitled && UUID_RE.test(r.resourceId))
33
+ return "chatter";
34
+ if (title !== "" && !UUID_RE.test(r.resourceId))
35
+ return "curated";
36
+ return "other";
37
+ }
38
+ /** Composite score + classification for one result. */
39
+ export function scoreResult(r, w) {
40
+ const kind = classifyResult(r, w.storyTerms);
41
+ const base = baseScore(r);
42
+ let composite = base;
43
+ if (kind === "story")
44
+ composite += w.storyBoost + w.curationBoost; // the story is kept memory too
45
+ else if (kind === "curated")
46
+ composite += w.curationBoost;
47
+ else if (kind === "chatter")
48
+ composite -= w.chatterPenalty;
49
+ return { kind, base, composite };
50
+ }
51
+ /** Re-rank results by composite score (descending). Pure; stable enough. */
52
+ export function rerank(results, w) {
53
+ return results
54
+ .map((r) => {
55
+ const s = scoreResult(r, w);
56
+ return Object.assign({}, r, {
57
+ _kind: s.kind,
58
+ _base: s.base,
59
+ _composite: s.composite,
60
+ });
61
+ })
62
+ .sort((a, b) => b._composite - a._composite);
63
+ }
@@ -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 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.",
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,19 @@
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
+ }
169
+ },
157
170
  "debug": {
158
171
  "type": "boolean"
159
172
  },
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.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 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": [