@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.
- package/dist/commands/setup.js +2 -0
- package/dist/config.js +18 -0
- package/dist/hooks/auto-context.js +64 -9
- package/dist/lib/ranking.js +87 -0
- package/dist/tools/search.js +1 -1
- package/openclaw.plugin.json +14 -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,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
|
-
|
|
57
|
-
|
|
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
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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
|
+
}
|
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, 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)" })),
|
package/openclaw.plugin.json
CHANGED
|
@@ -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.
|
|
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": [
|