@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.
- package/dist/commands/setup.js +2 -0
- package/dist/config.js +17 -0
- package/dist/hooks/auto-context.js +72 -11
- package/dist/lib/ranking.js +63 -0
- package/dist/tools/search.js +1 -1
- package/openclaw.plugin.json +13 -0
- package/package.json +2 -2
package/dist/commands/setup.js
CHANGED
|
@@ -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
|
-
|
|
57
|
-
|
|
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
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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:
|
|
103
|
-
return;
|
|
165
|
+
log.debug("auto-context: nothing cleared the bar — injecting search directive only");
|
|
104
166
|
}
|
|
105
|
-
|
|
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
|
+
}
|
package/dist/tools/search.js
CHANGED
|
@@ -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
|
|
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)" })),
|
package/openclaw.plugin.json
CHANGED
|
@@ -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.
|
|
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": [
|