@hyperspell/openclaw-hyperspell 0.20.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.
@@ -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,6 +12,12 @@ 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;
11
23
  const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -71,24 +83,63 @@ export function classifyResult(r, storyTerms) {
71
83
  return "curated";
72
84
  return "other";
73
85
  }
74
- /** Composite score + classification for one result. */
75
- export function scoreResult(r, w) {
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()) {
76
121
  const kind = classifyResult(r, w.storyTerms);
77
122
  const base = baseScore(r);
78
- let composite = base;
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);
79
128
  if (kind === "story")
80
129
  composite += w.storyBoost + w.curationBoost; // the story is kept memory too
81
130
  else if (kind === "curated")
82
131
  composite += w.curationBoost;
83
132
  else if (kind === "chatter")
84
133
  composite -= w.chatterPenalty;
134
+ composite -= recencyPenalty(r.createdAt, kind, w, now);
85
135
  return { kind, base, composite };
86
136
  }
87
- /** Re-rank results by composite score (descending). Pure; stable enough. */
88
- export function rerank(results, w) {
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()) {
89
140
  return results
90
141
  .map((r) => {
91
- const s = scoreResult(r, w);
142
+ const s = scoreResult(r, w, now);
92
143
  return Object.assign({}, r, {
93
144
  _kind: s.kind,
94
145
  _base: s.base,
@@ -97,6 +148,59 @@ export function rerank(results, w) {
97
148
  })
98
149
  .sort((a, b) => b._composite - a._composite);
99
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
+ }
100
204
  /**
101
205
  * One pass over ranked results, annotating each with its selection outcome.
102
206
  * Same policy as selectRanked — which is now derived from this, so the two
@@ -108,10 +212,22 @@ export function rerank(results, w) {
108
212
  * "max-results", not "chatter-quota" — it would have been cut regardless, so
109
213
  * the quota was not the binding constraint (proposal 03 §3.2 semantics).
110
214
  */
111
- export function explainSelection(ranked, maxResults, threshold, chatterQuota) {
215
+ export function explainSelection(ranked, maxResults, threshold, chatterQuota, dedupThreshold = 0, elbow) {
112
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 = [];
113
223
  let chatter = 0;
114
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;
115
231
  for (const r of ranked) {
116
232
  if (r._composite < threshold) {
117
233
  out.push({ result: r, selected: false, cut: "threshold" });
@@ -121,13 +237,39 @@ export function explainSelection(ranked, maxResults, threshold, chatterQuota) {
121
237
  out.push({ result: r, selected: false, cut: "max-results" });
122
238
  continue;
123
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
+ }
124
262
  if (r._kind === "chatter" && chatter >= chatterQuota) {
125
263
  out.push({ result: r, selected: false, cut: "chatter-quota" });
126
264
  continue;
127
265
  }
128
266
  if (r._kind === "chatter")
129
267
  chatter++;
268
+ if (kept > 0)
269
+ gapSum += lastAccepted - r._composite;
270
+ lastAccepted = r._composite;
130
271
  kept++;
272
+ keys.push(key);
131
273
  out.push({ result: r, selected: true, cut: null });
132
274
  }
133
275
  return out;
@@ -144,8 +286,8 @@ export function explainSelection(ranked, maxResults, threshold, chatterQuota) {
144
286
  * It is a thin projection of explainSelection so selection policy lives in
145
287
  * exactly one place.
146
288
  */
147
- export function selectRanked(ranked, maxResults, threshold, chatterQuota) {
148
- 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)
149
291
  .filter((e) => e.selected)
150
292
  .map((e) => e.result);
151
293
  }
@@ -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.20.0",
5
+ "version": "0.21.0",
6
6
  "kind": "memory",
7
7
  "contracts": {
8
8
  "tools": [
@@ -77,7 +77,12 @@
77
77
  },
78
78
  "ranking": {
79
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. { enabled, storyTerms, curationBoost, storyBoost, chatterPenalty, candidateMultiplier, chatterQuota }",
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.",
81
86
  "advanced": true
82
87
  },
83
88
  "debug": {
@@ -185,9 +190,32 @@
185
190
  "storyBoost": { "type": "number", "minimum": 0, "maximum": 1 },
186
191
  "storyTerms": { "type": "array", "items": { "type": "string" } },
187
192
  "candidateMultiplier": { "type": "number", "minimum": 1, "maximum": 10 },
188
- "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
+ }
189
214
  }
190
215
  },
216
+ "coverageLog": {
217
+ "type": "boolean"
218
+ },
191
219
  "debug": {
192
220
  "type": "boolean"
193
221
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperspell/openclaw-hyperspell",
3
- "version": "0.20.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 logger.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 lib/loops-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"
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": [