@hyperspell/openclaw-hyperspell 0.19.0 → 0.21.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/README.md +260 -10
- package/dist/client.js +14 -0
- package/dist/commands/setup.js +16 -6
- package/dist/commands/slash.js +40 -0
- package/dist/config.js +89 -6
- package/dist/graph/ops.js +36 -3
- package/dist/hooks/auto-context.js +126 -15
- package/dist/hooks/emotional-state.js +19 -8
- package/dist/hooks/memory-sync.js +23 -7
- package/dist/hooks/mood-weather.js +45 -0
- package/dist/hooks/startup-orientation.js +10 -1
- package/dist/index.js +24 -1
- package/dist/lib/coverage-log.js +47 -0
- package/dist/lib/filters.js +39 -11
- package/dist/lib/loops-audit.js +438 -0
- package/dist/lib/mood-skew-audit.js +580 -0
- package/dist/lib/ranking.js +188 -11
- package/dist/logger.js +16 -0
- package/dist/sync/markdown.js +57 -2
- package/openclaw.plugin.json +35 -2
- package/package.json +2 -2
package/dist/lib/ranking.js
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
export const DEFAULT_ELBOW = {
|
|
2
|
+
enabled: false,
|
|
3
|
+
minResults: 3,
|
|
4
|
+
gapRatio: 2.5,
|
|
5
|
+
minGap: 0.05,
|
|
6
|
+
};
|
|
1
7
|
export const DEFAULT_RANKING = {
|
|
2
8
|
enabled: true,
|
|
3
9
|
curationBoost: 0.2,
|
|
@@ -6,8 +12,46 @@ export const DEFAULT_RANKING = {
|
|
|
6
12
|
storyTerms: [],
|
|
7
13
|
candidateMultiplier: 3,
|
|
8
14
|
chatterQuota: 2,
|
|
15
|
+
recencyHalfLifeDays: 90,
|
|
16
|
+
recencyMaxPenalty: 0.1,
|
|
17
|
+
recencyCuratedFactor: 0.5,
|
|
18
|
+
sourceWeights: {},
|
|
19
|
+
dedupThreshold: 0.8,
|
|
20
|
+
elbow: DEFAULT_ELBOW,
|
|
9
21
|
};
|
|
10
22
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
23
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
24
|
+
// Compiled story-term matchers, cached per storyTerms array instance — the
|
|
25
|
+
// config array is built once in parseConfig and stable for the process
|
|
26
|
+
// lifetime, so this avoids per-result regex compilation on the hot path.
|
|
27
|
+
const matcherCache = new WeakMap();
|
|
28
|
+
/**
|
|
29
|
+
* One regex per term, anchored at word boundaries so short terms can't
|
|
30
|
+
* false-positive inside longer words ("ada" no longer matches "adaptation";
|
|
31
|
+
* "mira" no longer matches "admiral"). Boundaries are word↔non-word
|
|
32
|
+
* transitions, so possessives and punctuation still match: "mira" hits
|
|
33
|
+
* "Mira's" and "mira-class". `\b` only anchors against word characters, so
|
|
34
|
+
* terms whose edges are punctuation get no anchor there and still match.
|
|
35
|
+
*/
|
|
36
|
+
function storyMatchers(storyTerms) {
|
|
37
|
+
let ms = matcherCache.get(storyTerms);
|
|
38
|
+
if (!ms) {
|
|
39
|
+
ms = storyTerms
|
|
40
|
+
.map((t) => t.trim().toLowerCase())
|
|
41
|
+
.filter((t) => t.length > 0)
|
|
42
|
+
.map((term) => {
|
|
43
|
+
const lead = /^\w/.test(term) ? "\\b" : "";
|
|
44
|
+
const tail = /\w$/.test(term) ? "\\b" : "";
|
|
45
|
+
return new RegExp(`${lead}${escapeRe(term)}${tail}`);
|
|
46
|
+
});
|
|
47
|
+
matcherCache.set(storyTerms, ms);
|
|
48
|
+
}
|
|
49
|
+
return ms;
|
|
50
|
+
}
|
|
51
|
+
/** Count ranked results by kind — the debug-tally shape auto-context logs. */
|
|
52
|
+
export function kindTally(results) {
|
|
53
|
+
return results.reduce((acc, r) => ((acc[r._kind] = (acc[r._kind] ?? 0) + 1), acc), {});
|
|
54
|
+
}
|
|
11
55
|
/** Highest available relevance for a result (doc score or its best highlight). */
|
|
12
56
|
export function baseScore(r) {
|
|
13
57
|
const topHighlight = r.highlights.reduce((m, h) => Math.max(m, h.score ?? 0), 0);
|
|
@@ -25,8 +69,11 @@ export function baseScore(r) {
|
|
|
25
69
|
export function classifyResult(r, storyTerms) {
|
|
26
70
|
const title = (r.title ?? "").trim();
|
|
27
71
|
if (storyTerms.length > 0) {
|
|
28
|
-
|
|
29
|
-
|
|
72
|
+
// \n-joined (not space-joined) so a multi-word phrase term can never
|
|
73
|
+
// spuriously match across the seam of two unrelated highlights (terms are
|
|
74
|
+
// escaped literals, so a phrase's inner space cannot match the \n).
|
|
75
|
+
const hay = `${title}\n${r.highlights.map((h) => h.text).join("\n")}`.toLowerCase();
|
|
76
|
+
if (storyMatchers(storyTerms).some((re) => re.test(hay)))
|
|
30
77
|
return "story";
|
|
31
78
|
}
|
|
32
79
|
const untitled = title === "" || /^unnamed conversation$/i.test(title);
|
|
@@ -36,24 +83,63 @@ export function classifyResult(r, storyTerms) {
|
|
|
36
83
|
return "curated";
|
|
37
84
|
return "other";
|
|
38
85
|
}
|
|
39
|
-
/**
|
|
40
|
-
|
|
86
|
+
/**
|
|
87
|
+
* Age-based additive penalty: exponential decay with a configurable half-life,
|
|
88
|
+
* capped at recencyMaxPenalty so recency stays a tiebreaker with teeth, never
|
|
89
|
+
* a dominant signal — an infinitely old result loses at most the cap, so it
|
|
90
|
+
* can reorder near-ties but never bury a result that out-relevances a rival
|
|
91
|
+
* by more than the cap. Additive (not a relevance multiplier) to stay on the
|
|
92
|
+
* same tuned scale as the boost/penalty algebra above.
|
|
93
|
+
*/
|
|
94
|
+
function recencyPenalty(createdAt, kind, w, now) {
|
|
95
|
+
if (w.recencyHalfLifeDays <= 0 || w.recencyMaxPenalty <= 0)
|
|
96
|
+
return 0;
|
|
97
|
+
if (!createdAt)
|
|
98
|
+
return 0; // unknown age — never punish missing data
|
|
99
|
+
const ts = Date.parse(createdAt);
|
|
100
|
+
if (Number.isNaN(ts))
|
|
101
|
+
return 0; // unparseable — same fail-open rule
|
|
102
|
+
// max(0, …) clamps future timestamps (clock skew) to zero age, never a boost.
|
|
103
|
+
const ageDays = Math.max(0, (now - ts) / 86_400_000);
|
|
104
|
+
const decay = 0.5 ** (ageDays / w.recencyHalfLifeDays);
|
|
105
|
+
// Kept memory (curated/story) is deliberately durable — it ages at the
|
|
106
|
+
// reduced curated factor so old truths keep their edge over old chatter.
|
|
107
|
+
const kept = kind === "curated" || kind === "story";
|
|
108
|
+
const factor = kept ? w.recencyCuratedFactor : 1;
|
|
109
|
+
return w.recencyMaxPenalty * (1 - decay) * factor;
|
|
110
|
+
}
|
|
111
|
+
/** Weight for a source; anything unlisted or malformed is neutral, never zero.
|
|
112
|
+
* The lookup-time guard is the safety floor: an unrecognized or unweighted
|
|
113
|
+
* source degrades to 1.0 — it never crashes and never zeroes a result out. */
|
|
114
|
+
export function sourceWeight(w, source) {
|
|
115
|
+
const v = w.sourceWeights[source];
|
|
116
|
+
return typeof v === "number" && Number.isFinite(v) && v > 0 ? v : 1;
|
|
117
|
+
}
|
|
118
|
+
/** Composite score + classification for one result. `now` is injectable so
|
|
119
|
+
* tests and the eval harness stay deterministic; runtime callers omit it. */
|
|
120
|
+
export function scoreResult(r, w, now = Date.now()) {
|
|
41
121
|
const kind = classifyResult(r, w.storyTerms);
|
|
42
122
|
const base = baseScore(r);
|
|
43
|
-
|
|
123
|
+
// The weight multiplies BASE only — kind boosts/penalties stay in the same
|
|
124
|
+
// additive currency regardless of source, so tuning sourceWeights can never
|
|
125
|
+
// silently retune chatterPenalty/curationBoost (proposal 11 §3.1). _base
|
|
126
|
+
// stays unweighted for debuggability; the weight shows only in _composite.
|
|
127
|
+
let composite = base * sourceWeight(w, r.source);
|
|
44
128
|
if (kind === "story")
|
|
45
129
|
composite += w.storyBoost + w.curationBoost; // the story is kept memory too
|
|
46
130
|
else if (kind === "curated")
|
|
47
131
|
composite += w.curationBoost;
|
|
48
132
|
else if (kind === "chatter")
|
|
49
133
|
composite -= w.chatterPenalty;
|
|
134
|
+
composite -= recencyPenalty(r.createdAt, kind, w, now);
|
|
50
135
|
return { kind, base, composite };
|
|
51
136
|
}
|
|
52
|
-
/** Re-rank results by composite score (descending). Pure; stable enough.
|
|
53
|
-
|
|
137
|
+
/** Re-rank results by composite score (descending). Pure; stable enough.
|
|
138
|
+
* One `now` per call so every candidate scores against a consistent clock. */
|
|
139
|
+
export function rerank(results, w, now = Date.now()) {
|
|
54
140
|
return results
|
|
55
141
|
.map((r) => {
|
|
56
|
-
const s = scoreResult(r, w);
|
|
142
|
+
const s = scoreResult(r, w, now);
|
|
57
143
|
return Object.assign({}, r, {
|
|
58
144
|
_kind: s.kind,
|
|
59
145
|
_base: s.base,
|
|
@@ -62,6 +148,59 @@ export function rerank(results, w) {
|
|
|
62
148
|
})
|
|
63
149
|
.sort((a, b) => b._composite - a._composite);
|
|
64
150
|
}
|
|
151
|
+
/** The text a result would lead its injection with: its highest-scored
|
|
152
|
+
* highlight, falling back to the title. Near-duplicate KEYS ≈ near-duplicate
|
|
153
|
+
* injected content, which is exactly what the diversity check must catch. */
|
|
154
|
+
export function dedupKey(r) {
|
|
155
|
+
let best = "";
|
|
156
|
+
let bestScore = -1;
|
|
157
|
+
for (const h of r.highlights) {
|
|
158
|
+
const s = h.score ?? 0;
|
|
159
|
+
if (s > bestScore) {
|
|
160
|
+
bestScore = s;
|
|
161
|
+
best = h.text;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return best !== "" ? best : (r.title ?? "");
|
|
165
|
+
}
|
|
166
|
+
function tokens(s) {
|
|
167
|
+
return new Set(s
|
|
168
|
+
.toLowerCase()
|
|
169
|
+
.replace(/[^\p{L}\p{N}\s]/gu, " ")
|
|
170
|
+
.split(/\s+/)
|
|
171
|
+
.filter((t) => t.length > 0));
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Token-set OVERLAP COEFFICIENT (|A∩B| / min|A|,|B|), not Jaccard: duplicated
|
|
175
|
+
* memories here typically differ mainly by length (a note vs the doc it
|
|
176
|
+
* quotes), and containment must read as duplication — 10 tokens fully inside
|
|
177
|
+
* 40 scores 1.0 here but only 0.25 under Jaccard. Dependency-free and linear;
|
|
178
|
+
* this runs synchronously in the ranking hot path.
|
|
179
|
+
*/
|
|
180
|
+
export function nearDuplicate(a, b, threshold) {
|
|
181
|
+
if (threshold <= 0)
|
|
182
|
+
return false;
|
|
183
|
+
const ta = tokens(a);
|
|
184
|
+
const tb = tokens(b);
|
|
185
|
+
const min = Math.min(ta.size, tb.size);
|
|
186
|
+
if (min === 0)
|
|
187
|
+
return false;
|
|
188
|
+
// Tiny keys (short titles, 2-3 common words) make overlap-coefficient
|
|
189
|
+
// trigger-happy; require exact token-set equality below 5 tokens.
|
|
190
|
+
if (min < 5) {
|
|
191
|
+
if (ta.size !== tb.size)
|
|
192
|
+
return false;
|
|
193
|
+
for (const t of ta)
|
|
194
|
+
if (!tb.has(t))
|
|
195
|
+
return false;
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
let inter = 0;
|
|
199
|
+
for (const t of ta)
|
|
200
|
+
if (tb.has(t))
|
|
201
|
+
inter++;
|
|
202
|
+
return inter / min >= threshold;
|
|
203
|
+
}
|
|
65
204
|
/**
|
|
66
205
|
* One pass over ranked results, annotating each with its selection outcome.
|
|
67
206
|
* Same policy as selectRanked — which is now derived from this, so the two
|
|
@@ -73,10 +212,22 @@ export function rerank(results, w) {
|
|
|
73
212
|
* "max-results", not "chatter-quota" — it would have been cut regardless, so
|
|
74
213
|
* the quota was not the binding constraint (proposal 03 §3.2 semantics).
|
|
75
214
|
*/
|
|
76
|
-
export function explainSelection(ranked, maxResults, threshold, chatterQuota) {
|
|
215
|
+
export function explainSelection(ranked, maxResults, threshold, chatterQuota, dedupThreshold = 0, elbow) {
|
|
77
216
|
const out = [];
|
|
217
|
+
// Dedup state is exactly "what was accepted": only SELECTED results record
|
|
218
|
+
// keys (and only they consume quota), so a skipped candidate — whatever cut
|
|
219
|
+
// it — can never shadow later different-kind content sharing its text, and
|
|
220
|
+
// a diversity-skipped chatter echo never burns a quota slot (proposal 09's
|
|
221
|
+
// double-count invariant).
|
|
222
|
+
const keys = [];
|
|
78
223
|
let chatter = 0;
|
|
79
224
|
let kept = 0;
|
|
225
|
+
// Elbow bookkeeping: gaps between consecutive ACCEPTED results only —
|
|
226
|
+
// threshold/quota/dup-skipped rows widen the observed gap on purpose (the
|
|
227
|
+
// drop that matters is between results that could actually be injected).
|
|
228
|
+
let gapSum = 0;
|
|
229
|
+
let lastAccepted = 0;
|
|
230
|
+
let elbowFired = false;
|
|
80
231
|
for (const r of ranked) {
|
|
81
232
|
if (r._composite < threshold) {
|
|
82
233
|
out.push({ result: r, selected: false, cut: "threshold" });
|
|
@@ -86,13 +237,39 @@ export function explainSelection(ranked, maxResults, threshold, chatterQuota) {
|
|
|
86
237
|
out.push({ result: r, selected: false, cut: "max-results" });
|
|
87
238
|
continue;
|
|
88
239
|
}
|
|
240
|
+
// Cliff = outlier vs the decline so far AND material in absolute terms;
|
|
241
|
+
// the two-part test keeps flat lists (tiny meanGap) and steady declines
|
|
242
|
+
// (every gap "big") from tripping it. Gated on the minResults floor, so
|
|
243
|
+
// a huge gap right after result #1 is never even examined — the elbow
|
|
244
|
+
// can only ever cut EARLIER than maxResults, never below the floor.
|
|
245
|
+
if (!elbowFired && elbow?.enabled && kept >= Math.max(2, elbow.minResults)) {
|
|
246
|
+
const gap = lastAccepted - r._composite;
|
|
247
|
+
if (gap >= elbow.minGap && gap >= elbow.gapRatio * (gapSum / (kept - 1)))
|
|
248
|
+
elbowFired = true;
|
|
249
|
+
}
|
|
250
|
+
if (elbowFired) {
|
|
251
|
+
out.push({ result: r, selected: false, cut: "elbow" });
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
// After max-results (a dup past a full cap was cut regardless — the cap
|
|
255
|
+
// was binding), before chatter-quota (a dup echo injects nothing, so the
|
|
256
|
+
// quota was not the binding constraint and must not be charged).
|
|
257
|
+
const key = dedupKey(r);
|
|
258
|
+
if (keys.some((k) => nearDuplicate(k, key, dedupThreshold))) {
|
|
259
|
+
out.push({ result: r, selected: false, cut: "near-duplicate" });
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
89
262
|
if (r._kind === "chatter" && chatter >= chatterQuota) {
|
|
90
263
|
out.push({ result: r, selected: false, cut: "chatter-quota" });
|
|
91
264
|
continue;
|
|
92
265
|
}
|
|
93
266
|
if (r._kind === "chatter")
|
|
94
267
|
chatter++;
|
|
268
|
+
if (kept > 0)
|
|
269
|
+
gapSum += lastAccepted - r._composite;
|
|
270
|
+
lastAccepted = r._composite;
|
|
95
271
|
kept++;
|
|
272
|
+
keys.push(key);
|
|
96
273
|
out.push({ result: r, selected: true, cut: null });
|
|
97
274
|
}
|
|
98
275
|
return out;
|
|
@@ -109,8 +286,8 @@ export function explainSelection(ranked, maxResults, threshold, chatterQuota) {
|
|
|
109
286
|
* It is a thin projection of explainSelection so selection policy lives in
|
|
110
287
|
* exactly one place.
|
|
111
288
|
*/
|
|
112
|
-
export function selectRanked(ranked, maxResults, threshold, chatterQuota) {
|
|
113
|
-
return explainSelection(ranked, maxResults, threshold, chatterQuota)
|
|
289
|
+
export function selectRanked(ranked, maxResults, threshold, chatterQuota, dedupThreshold = 0, elbow) {
|
|
290
|
+
return explainSelection(ranked, maxResults, threshold, chatterQuota, dedupThreshold, elbow)
|
|
114
291
|
.filter((e) => e.selected)
|
|
115
292
|
.map((e) => e.result);
|
|
116
293
|
}
|
package/dist/logger.js
CHANGED
|
@@ -19,6 +19,22 @@ export const log = {
|
|
|
19
19
|
_logger.debug(`hyperspell: ${message}`, ...args);
|
|
20
20
|
}
|
|
21
21
|
},
|
|
22
|
+
/**
|
|
23
|
+
* Operator-meaningful diagnostics (one line per event: injection summaries,
|
|
24
|
+
* cut attribution, ranked tallies). Emits via the host's INFO channel when the
|
|
25
|
+
* plugin's own `debug: true` is set, and stays silent otherwise.
|
|
26
|
+
*
|
|
27
|
+
* Why not `debug`: the host drops plugin debug-channel output from gateway.log
|
|
28
|
+
* even at `logging.level: "debug"` (issue #118 — 2,026 info lines vs zero debug
|
|
29
|
+
* lines, live). The plugin's `debug` flag is explicit operator intent, so these
|
|
30
|
+
* diagnostics must not depend on host debug-channel plumbing. Truly-verbose
|
|
31
|
+
* output (per-request dumps, per-candidate lines) stays on `debug`.
|
|
32
|
+
*/
|
|
33
|
+
diag: (message, ...args) => {
|
|
34
|
+
if (_debug) {
|
|
35
|
+
_logger.info(`hyperspell: ${message}`, ...args);
|
|
36
|
+
}
|
|
37
|
+
},
|
|
22
38
|
debugRequest: (method, params) => {
|
|
23
39
|
if (_debug) {
|
|
24
40
|
_logger.debug(`hyperspell: [${method}] request`, params);
|
package/dist/sync/markdown.js
CHANGED
|
@@ -40,6 +40,7 @@ function readMarkdownFile(filePath) {
|
|
|
40
40
|
title,
|
|
41
41
|
content: body.trim(),
|
|
42
42
|
hyperspellId: frontmatter.hyperspell_id || null,
|
|
43
|
+
graphEntity: frontmatter.graph_entity === "true",
|
|
43
44
|
};
|
|
44
45
|
}
|
|
45
46
|
catch (err) {
|
|
@@ -271,6 +272,39 @@ export function getMemoryFiles(workspaceDir, ignorePaths) {
|
|
|
271
272
|
walk(memoryDir);
|
|
272
273
|
return results;
|
|
273
274
|
}
|
|
275
|
+
/** Resolve a watchPath (workspace-relative or absolute) to an absolute path. */
|
|
276
|
+
export function resolveWatchPath(workspaceDir, p) {
|
|
277
|
+
return path.isAbsolute(p) ? p : path.join(workspaceDir, p);
|
|
278
|
+
}
|
|
279
|
+
/** Slug for unlabeled watchPaths: "notes/brainstem" -> "notes_brainstem". */
|
|
280
|
+
function watchPathSlug(workspaceDir, resolved) {
|
|
281
|
+
const rel = path.relative(workspaceDir, resolved);
|
|
282
|
+
const base = rel.startsWith("..") ? path.basename(resolved) : rel;
|
|
283
|
+
return base.split(path.sep).join("_").replace(/[^a-zA-Z0-9_]/g, "_");
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Provenance for a syncable file: the longest-prefix-matching watchPath's
|
|
287
|
+
* label (explicit `source` or derived slug), else "memory" for memory/ files.
|
|
288
|
+
* Longest prefix wins so a watchPath nested inside another (or inside memory/)
|
|
289
|
+
* keeps its more specific label.
|
|
290
|
+
*/
|
|
291
|
+
export function resolveSyncSource(filePath, workspaceDir, watchPaths) {
|
|
292
|
+
let best;
|
|
293
|
+
for (const wp of watchPaths) {
|
|
294
|
+
const resolved = resolveWatchPath(workspaceDir, wp.path);
|
|
295
|
+
if (filePath === resolved || filePath.startsWith(resolved + path.sep)) {
|
|
296
|
+
if (!best || resolved.length > best.len) {
|
|
297
|
+
best = { len: resolved.length, label: wp.source ?? watchPathSlug(workspaceDir, resolved) };
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (best)
|
|
302
|
+
return best.label;
|
|
303
|
+
const memoryDir = path.join(workspaceDir, "memory");
|
|
304
|
+
if (filePath.startsWith(memoryDir + path.sep))
|
|
305
|
+
return "memory";
|
|
306
|
+
return undefined;
|
|
307
|
+
}
|
|
274
308
|
/**
|
|
275
309
|
* Collect all syncable files based on configuration.
|
|
276
310
|
* Includes memory/*.md by default, plus any additional watchPaths.
|
|
@@ -280,7 +314,7 @@ export function getSyncableFiles(workspaceDir, watchPaths, ignorePaths) {
|
|
|
280
314
|
const files = getMemoryFiles(workspaceDir, ignorePaths);
|
|
281
315
|
if (watchPaths) {
|
|
282
316
|
for (const wp of watchPaths) {
|
|
283
|
-
const resolved =
|
|
317
|
+
const resolved = resolveWatchPath(workspaceDir, wp.path);
|
|
284
318
|
if (!fs.existsSync(resolved))
|
|
285
319
|
continue;
|
|
286
320
|
const stat = fs.statSync(resolved);
|
|
@@ -333,6 +367,13 @@ export async function syncMarkdownFile(client, filePath, options) {
|
|
|
333
367
|
metadata: {
|
|
334
368
|
openclaw_source: "memory_sync",
|
|
335
369
|
file_path: filePath,
|
|
370
|
+
// Content origin (memory/ vs a labeled watchPath). Additive key —
|
|
371
|
+
// openclaw_source stays the pipeline discriminator that retrieval
|
|
372
|
+
// filters and startup-orientation dedupe depend on.
|
|
373
|
+
...(options?.syncSource ? { openclaw_sync_source: options.syncSource } : {}),
|
|
374
|
+
// Honor the extraction prompt's contract: entity files synced back
|
|
375
|
+
// to Hyperspell must be skippable by the Memory Network scan.
|
|
376
|
+
...(file.graphEntity ? { graph_entity: "true" } : {}),
|
|
336
377
|
},
|
|
337
378
|
userId: options?.userId,
|
|
338
379
|
});
|
|
@@ -426,6 +467,14 @@ export async function syncMarkdownFileSectionized(client, filePath, workspaceDir
|
|
|
426
467
|
file_name: fileName,
|
|
427
468
|
section_title: section.title,
|
|
428
469
|
content_hash: section.contentHash,
|
|
470
|
+
// Content origin (memory/ vs a labeled watchPath). Additive key —
|
|
471
|
+
// openclaw_source stays the pipeline discriminator that retrieval
|
|
472
|
+
// filters and startup-orientation dedupe depend on.
|
|
473
|
+
...(options?.syncSource ? { openclaw_sync_source: options.syncSource } : {}),
|
|
474
|
+
// Honor the extraction prompt's contract: entity files synced
|
|
475
|
+
// back to Hyperspell must be skippable by the Memory Network
|
|
476
|
+
// scan, or the extractor is re-fed its own output every cycle.
|
|
477
|
+
...(file.graphEntity ? { graph_entity: "true" } : {}),
|
|
429
478
|
},
|
|
430
479
|
userId: options?.userId,
|
|
431
480
|
});
|
|
@@ -503,7 +552,12 @@ export async function syncAllMemoryFiles(client, workspaceDir, options) {
|
|
|
503
552
|
let failed = 0;
|
|
504
553
|
const errors = [];
|
|
505
554
|
for (const filePath of files) {
|
|
506
|
-
|
|
555
|
+
// Legacy bulk mode only walks memory/, so everything it uploads is
|
|
556
|
+
// memory-origin by construction.
|
|
557
|
+
const result = await syncMarkdownFile(client, filePath, {
|
|
558
|
+
userId: options?.userId,
|
|
559
|
+
syncSource: "memory",
|
|
560
|
+
});
|
|
507
561
|
if (result.success) {
|
|
508
562
|
synced++;
|
|
509
563
|
log.info(`Synced: ${path.basename(filePath)} -> ${result.resourceId}`);
|
|
@@ -564,6 +618,7 @@ export async function syncAllFilesSectionized(client, workspaceDir, options) {
|
|
|
564
618
|
for (const filePath of candidates) {
|
|
565
619
|
const result = await syncMarkdownFileSectionized(client, filePath, workspaceDir, {
|
|
566
620
|
userId: options?.userId,
|
|
621
|
+
syncSource: resolveSyncSource(filePath, workspaceDir, options?.watchPaths ?? []),
|
|
567
622
|
});
|
|
568
623
|
totalSynced += result.synced;
|
|
569
624
|
totalSkipped += result.skipped;
|
package/openclaw.plugin.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "openclaw-hyperspell",
|
|
3
3
|
"name": "Hyperspell",
|
|
4
4
|
"description": "Context and memory for your AI agents — search across Notion, Slack, Google Drive, and more",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.21.0",
|
|
6
6
|
"kind": "memory",
|
|
7
7
|
"contracts": {
|
|
8
8
|
"tools": [
|
|
@@ -75,6 +75,16 @@
|
|
|
75
75
|
"help": "Maximum memories injected into context per turn",
|
|
76
76
|
"advanced": true
|
|
77
77
|
},
|
|
78
|
+
"ranking": {
|
|
79
|
+
"label": "Composite Ranking",
|
|
80
|
+
"help": "Rank memories by more than raw relevance: boost curated memory and the active story, penalize auto-saved conversation fragments. Set storyTerms to 3-15 distinctive names/terms of the active thread (characters, codenames, invented words; matched case-insensitively at word boundaries) so it outranks chatter; update the list when the active story changes. Old results accrue a small bounded recency penalty (half-life recencyHalfLifeDays, cap recencyMaxPenalty; kept memory ages at recencyCuratedFactor); recencyHalfLifeDays 0 disables it. { enabled, storyTerms, curationBoost, storyBoost, chatterPenalty, candidateMultiplier, chatterQuota, recencyHalfLifeDays, recencyMaxPenalty, recencyCuratedFactor, sourceWeights, dedupThreshold, elbow }",
|
|
81
|
+
"advanced": true
|
|
82
|
+
},
|
|
83
|
+
"coverageLog": {
|
|
84
|
+
"label": "Coverage Log",
|
|
85
|
+
"help": "Append a local-only JSONL event when auto-context finds no relevant memories (zero results or all below threshold), so capture gaps can be told apart from ranking misses. Events include prompt text (truncated), so this is off by default. Never sent to Hyperspell.",
|
|
86
|
+
"advanced": true
|
|
87
|
+
},
|
|
78
88
|
"debug": {
|
|
79
89
|
"label": "Debug Logging",
|
|
80
90
|
"help": "Enable verbose debug logs for API calls and responses",
|
|
@@ -180,9 +190,32 @@
|
|
|
180
190
|
"storyBoost": { "type": "number", "minimum": 0, "maximum": 1 },
|
|
181
191
|
"storyTerms": { "type": "array", "items": { "type": "string" } },
|
|
182
192
|
"candidateMultiplier": { "type": "number", "minimum": 1, "maximum": 10 },
|
|
183
|
-
"chatterQuota": { "type": "number", "minimum": 0, "maximum": 20 }
|
|
193
|
+
"chatterQuota": { "type": "number", "minimum": 0, "maximum": 20 },
|
|
194
|
+
"recencyHalfLifeDays": { "type": "number", "minimum": 0, "maximum": 3650 },
|
|
195
|
+
"recencyMaxPenalty": { "type": "number", "minimum": 0, "maximum": 1 },
|
|
196
|
+
"recencyCuratedFactor": { "type": "number", "minimum": 0, "maximum": 1 },
|
|
197
|
+
"dedupThreshold": { "type": "number", "minimum": 0, "maximum": 1 },
|
|
198
|
+
"elbow": {
|
|
199
|
+
"type": "object",
|
|
200
|
+
"additionalProperties": false,
|
|
201
|
+
"description": "Stop injecting early at a natural score cliff instead of always filling maxResults. Conservative: only ever cuts earlier, never later; falls back to plain maxResults/threshold when no clear cliff exists. minGap is calibrated to the current composite scale (boosts of 0.15-0.2).",
|
|
202
|
+
"properties": {
|
|
203
|
+
"enabled": { "type": "boolean" },
|
|
204
|
+
"minResults": { "type": "number", "minimum": 2, "maximum": 20 },
|
|
205
|
+
"gapRatio": { "type": "number", "minimum": 1, "maximum": 10 },
|
|
206
|
+
"minGap": { "type": "number", "minimum": 0, "maximum": 1 }
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
"sourceWeights": {
|
|
210
|
+
"type": "object",
|
|
211
|
+
"additionalProperties": { "type": "number", "exclusiveMinimum": 0, "maximum": 5 },
|
|
212
|
+
"description": "Per-source multiplier on base relevance before ranking boosts/penalties (e.g. {\"notion\": 1.15, \"slack\": 0.85}). Sources not listed default to 1.0. To exclude a source entirely use the top-level sources filter, not a weight."
|
|
213
|
+
}
|
|
184
214
|
}
|
|
185
215
|
},
|
|
216
|
+
"coverageLog": {
|
|
217
|
+
"type": "boolean"
|
|
218
|
+
},
|
|
186
219
|
"debug": {
|
|
187
220
|
"type": "boolean"
|
|
188
221
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperspell/openclaw-hyperspell",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.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 lib/exclude-channels.test.ts lib/ranking.test.ts lib/eval-matchers.test.ts tools/search.test.ts tools/emotional-arc.test.ts commands/preview.test.ts commands/purge-channel.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 hooks/mood-weather.test.ts eval/eval.test.ts"
|
|
41
|
+
"test": "node --test --experimental-strip-types client.test.ts logger.test.ts lib/sender.test.ts lib/filters.test.ts lib/search-error.test.ts lib/coverage-log.test.ts lib/session.test.ts lib/exclude-channels.test.ts lib/ranking.test.ts lib/eval-matchers.test.ts lib/loops-audit.test.ts lib/mood-skew-audit.test.ts tools/search.test.ts tools/emotional-arc.test.ts commands/preview.test.ts commands/purge-channel.test.ts config.test.ts sync/markdown.test.ts graph/ops.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 hooks/mood-weather.test.ts eval/eval.test.ts"
|
|
42
42
|
},
|
|
43
43
|
"openclaw": {
|
|
44
44
|
"extensions": [
|