@hyperspell/openclaw-hyperspell 0.19.0 → 0.20.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.
@@ -0,0 +1,438 @@
1
+ /**
2
+ * Pure logic for the unfinished-loops audit
3
+ * (docs/proposals/04-startup-orientation-loops-tuning.md) — extracted here so
4
+ * parsing/formatting/summary logic joins the hermetic unit suite while the
5
+ * live runner (scripts/audit-loops.ts) stays out of `npm test`, mirroring the
6
+ * lib/eval-matchers.ts + scripts/eval-retrieval.ts split.
7
+ *
8
+ * Nothing here touches the network or the production hook: the audit is
9
+ * read-only observation of what `gatherOrientation`'s loops search returns.
10
+ */
11
+ /**
12
+ * Candidate reworded default queries from proposal 04 §3.3 (Candidate A).
13
+ * These are audit probes only — the shipped default in config.ts is untouched
14
+ * until a variant graduates via the rubric's paired-day gate.
15
+ */
16
+ export const CANDIDATE_QUERIES = {
17
+ a1: "I'll get back to you, still need to, waiting on, didn't finish, let's pick this up later, promised to follow up",
18
+ a2: "unresolved follow-up: promised, waiting on an answer, blocked, next step, revisit, unfinished task, open question",
19
+ };
20
+ /**
21
+ * Caps from PR #107's `buildLoopsQuery` sketch
22
+ * (docs/plans/issue-73-static-loops-query.md) — reproduced here ONLY to
23
+ * simulate that candidate remedy side by side with the static query. This is
24
+ * deliberately not imported from production code: #107 is unimplemented by
25
+ * decision (audit-first), so the simulation owns its own copy of the shape.
26
+ */
27
+ const LOOPS_TITLE_MAX = 80;
28
+ const LOOPS_TOPICS_MAX = 300;
29
+ /**
30
+ * Simulate #107's dynamic query: static intent base + recent conversation
31
+ * titles. Empty titles collapse to exactly the base — same graceful
32
+ * degradation the candidate specifies.
33
+ */
34
+ export function deriveDynamicQuery(base, titles) {
35
+ const topics = [];
36
+ let used = 0;
37
+ for (const raw of titles) {
38
+ const title = raw.replace(/\s+/g, " ").trim().slice(0, LOOPS_TITLE_MAX);
39
+ if (title.length === 0)
40
+ continue;
41
+ if (used + title.length > LOOPS_TOPICS_MAX)
42
+ break;
43
+ topics.push(title);
44
+ used += title.length;
45
+ }
46
+ if (topics.length === 0)
47
+ return base;
48
+ return `${base} — recent topics: ${topics.join("; ")}`;
49
+ }
50
+ /**
51
+ * Recover titles from a formatted <hyperspell-recent-interactions> block
52
+ * (`- [3d ago] Title — snippet` / hot-buffer shape `- Title`). Parsing the
53
+ * rendered block instead of re-running a private fetch keeps the runner on
54
+ * gatherOrientation's real output; the trade-off (a title containing " — "
55
+ * gets truncated) is acceptable for a simulation input and documented in the
56
+ * rubric.
57
+ */
58
+ export function titlesFromRecentBlock(recentBlock) {
59
+ if (!recentBlock)
60
+ return [];
61
+ const titles = [];
62
+ for (const line of recentBlock.split("\n")) {
63
+ const m = /^- (?:\[[^\]]*\] )?(.*)$/.exec(line);
64
+ if (!m)
65
+ continue;
66
+ const title = m[1].split(" — ")[0].trim();
67
+ if (title.length > 0)
68
+ titles.push(title);
69
+ }
70
+ return titles;
71
+ }
72
+ export const SIMULATE_NAMES = [
73
+ "a1",
74
+ "a2",
75
+ "dynamic",
76
+ "after30",
77
+ "after60",
78
+ ];
79
+ /** Parse CLI args; throws with a usable message on anything malformed. */
80
+ export function parseAuditArgs(argv) {
81
+ const args = { json: false, simulate: [] };
82
+ const take = (flag, i) => {
83
+ const v = argv[i];
84
+ if (v === undefined)
85
+ throw new Error(`${flag} requires a value`);
86
+ return v;
87
+ };
88
+ for (let i = 0; i < argv.length; i++) {
89
+ const a = argv[i];
90
+ switch (a) {
91
+ case "-o": {
92
+ const fmt = take(a, ++i);
93
+ if (fmt !== "json")
94
+ throw new Error(`unsupported output format "${fmt}" (only: json)`);
95
+ args.json = true;
96
+ break;
97
+ }
98
+ case "--query":
99
+ args.query = take(a, ++i);
100
+ break;
101
+ case "--limit": {
102
+ const n = Number(take(a, ++i));
103
+ if (!Number.isInteger(n) || n < 1)
104
+ throw new Error("--limit must be a positive integer");
105
+ args.limit = n;
106
+ break;
107
+ }
108
+ case "--after":
109
+ args.after = take(a, ++i);
110
+ break;
111
+ case "--user":
112
+ args.user = take(a, ++i);
113
+ break;
114
+ case "--simulate": {
115
+ for (const name of take(a, ++i).split(",").map((s) => s.trim()).filter(Boolean)) {
116
+ if (!SIMULATE_NAMES.includes(name)) {
117
+ throw new Error(`unknown --simulate variant "${name}" (known: ${SIMULATE_NAMES.join(", ")})`);
118
+ }
119
+ args.simulate.push(name);
120
+ }
121
+ break;
122
+ }
123
+ case "--summary":
124
+ args.summary = { ledger: take(a, ++i), labels: take(a, ++i) };
125
+ break;
126
+ default:
127
+ throw new Error(`unknown argument "${a}"`);
128
+ }
129
+ }
130
+ return args;
131
+ }
132
+ /** `--after 30` = 30 days before `now`; anything else must parse as a date. */
133
+ export function resolveAfter(value, now = new Date()) {
134
+ if (/^\d+$/.test(value)) {
135
+ const d = new Date(now);
136
+ d.setUTCDate(d.getUTCDate() - Number(value));
137
+ return d.toISOString();
138
+ }
139
+ const d = new Date(value);
140
+ if (Number.isNaN(d.getTime()))
141
+ throw new Error(`--after "${value}" is neither a day count nor a date`);
142
+ return d.toISOString();
143
+ }
144
+ /**
145
+ * The variant list for one invocation. The first entry is always the audited
146
+ * primary: named "baseline" only when it is exactly the production values, so
147
+ * ledger lines can't silently mix overridden runs into the baseline series.
148
+ */
149
+ export function buildVariants(baseQuery, args, recentTitles, now = new Date()) {
150
+ const overridden = args.query !== undefined || args.after !== undefined || args.limit !== undefined;
151
+ const primary = {
152
+ name: overridden ? "custom" : "baseline",
153
+ query: baseQuery,
154
+ ...(args.after ? { after: resolveAfter(args.after, now) } : {}),
155
+ };
156
+ const variants = [primary];
157
+ for (const name of args.simulate) {
158
+ if (name === "a1" || name === "a2") {
159
+ variants.push({ name, query: CANDIDATE_QUERIES[name] });
160
+ }
161
+ else if (name === "dynamic") {
162
+ variants.push({ name, query: deriveDynamicQuery(baseQuery, recentTitles) });
163
+ }
164
+ else {
165
+ const days = name === "after30" ? "30" : "60";
166
+ variants.push({ name, query: baseQuery, after: resolveAfter(days, now) });
167
+ }
168
+ }
169
+ return variants;
170
+ }
171
+ const round4 = (n) => Number(n.toFixed(4));
172
+ export function toAuditRow(r, rank, includeContent) {
173
+ // Mirror formatUnfinishedLoops: it renders highlights[0] as returned, not
174
+ // the top-scored highlight, and silently drops no-highlight results.
175
+ const top = r.highlights[0];
176
+ return {
177
+ rank,
178
+ resourceId: r.resourceId,
179
+ title: r.title === null ? null : r.title.slice(0, 120),
180
+ source: r.source,
181
+ score: r.score === null ? null : round4(r.score),
182
+ createdAt: r.createdAt,
183
+ highlightCount: r.highlights.length,
184
+ rendered: top !== undefined,
185
+ ...(includeContent && top ? { snippet: top.text.replace(/\s+/g, " ").slice(0, 160) } : {}),
186
+ };
187
+ }
188
+ /** Count rendered bullets in a formatted loops/recent block. */
189
+ export function countBlockBullets(block) {
190
+ if (!block)
191
+ return 0;
192
+ return block.split("\n").filter((l) => l.startsWith("- ")).length;
193
+ }
194
+ export function buildRunRecord(input) {
195
+ const rows = input.results.map((r, i) => toAuditRow(r, i + 1, input.includeContent));
196
+ const renderedCount = rows.filter((r) => r.rendered).length;
197
+ const blockConsistent = input.gatherLoopsCount === null
198
+ ? null
199
+ : input.gatherLoopsCount === input.results.length &&
200
+ countBlockBullets(input.asInjectedBlock) === renderedCount;
201
+ return {
202
+ ts: input.ts,
203
+ variant: input.variant.name,
204
+ query: input.variant.query,
205
+ limit: input.limit,
206
+ after: input.variant.after ?? null,
207
+ userId: input.userId ?? null,
208
+ contentEnabled: input.includeContent,
209
+ resultCount: rows.length,
210
+ renderedCount,
211
+ wastedSlots: rows.length - renderedCount,
212
+ blockConsistent,
213
+ results: rows,
214
+ asInjected: input.includeContent ? input.asInjectedBlock : null,
215
+ };
216
+ }
217
+ /** Set-diff of surfaced resourceIds, rank order preserved within each list. */
218
+ export function compareVariantSets(baseline, variant) {
219
+ const baseIds = baseline.results.map((r) => r.resourceId);
220
+ const varIds = variant.results.map((r) => r.resourceId);
221
+ const baseSet = new Set(baseIds);
222
+ const varSet = new Set(varIds);
223
+ return {
224
+ overlap: baseIds.filter((id) => varSet.has(id)),
225
+ onlyBaseline: baseIds.filter((id) => !varSet.has(id)),
226
+ onlyVariant: varIds.filter((id) => !baseSet.has(id)),
227
+ };
228
+ }
229
+ /** JSONL with blank lines and `//` comment lines allowed (fixture-file style). */
230
+ export function parseJsonl(text) {
231
+ const out = [];
232
+ const lines = text.split("\n");
233
+ for (let i = 0; i < lines.length; i++) {
234
+ const line = lines[i].trim();
235
+ if (line.length === 0 || line.startsWith("//"))
236
+ continue;
237
+ try {
238
+ out.push(JSON.parse(line));
239
+ }
240
+ catch {
241
+ throw new Error(`invalid JSON on line ${i + 1}`);
242
+ }
243
+ }
244
+ return out;
245
+ }
246
+ export const LOOP_VERDICTS = [
247
+ "still-open",
248
+ "resolved",
249
+ "not-a-loop",
250
+ ];
251
+ const labelKey = (ts, variant, resourceId) => `${ts}|${variant}|${resourceId}`;
252
+ function emptyVerdictCounts() {
253
+ return { "still-open": 0, resolved: 0, "not-a-loop": 0 };
254
+ }
255
+ function dayHitRate(records, labels) {
256
+ let open = 0;
257
+ let labeled = 0;
258
+ for (const rec of records) {
259
+ for (const row of rec.results) {
260
+ if (!row.rendered)
261
+ continue;
262
+ const label = labels.get(labelKey(rec.ts, rec.variant, row.resourceId));
263
+ if (!label)
264
+ continue;
265
+ labeled++;
266
+ if (label.verdict === "still-open")
267
+ open++;
268
+ }
269
+ }
270
+ return labeled === 0 ? null : open / labeled;
271
+ }
272
+ /** Compute the rubric's metrics from ledger records plus hand labels. */
273
+ export function summarizeAudit(records, labels) {
274
+ const labelIndex = new Map();
275
+ for (const l of labels)
276
+ labelIndex.set(labelKey(l.ts, l.variant, l.resourceId), l);
277
+ const byVariant = new Map();
278
+ for (const rec of records) {
279
+ const list = byVariant.get(rec.variant) ?? [];
280
+ list.push(rec);
281
+ byVariant.set(rec.variant, list);
282
+ }
283
+ const perDay = (recs) => {
284
+ const days = new Map();
285
+ for (const rec of recs) {
286
+ const day = rec.ts.slice(0, 10);
287
+ const list = days.get(day) ?? [];
288
+ list.push(rec);
289
+ days.set(day, list);
290
+ }
291
+ return days;
292
+ };
293
+ const baselineDays = perDay(byVariant.get("baseline") ?? []);
294
+ const summaries = [];
295
+ for (const [variant, recs] of byVariant) {
296
+ const verdicts = emptyVerdictCounts();
297
+ const ageDaysByVerdict = {
298
+ "still-open": [],
299
+ resolved: [],
300
+ "not-a-loop": [],
301
+ };
302
+ let bullets = 0;
303
+ let wastedSlots = 0;
304
+ let labeled = 0;
305
+ let snippetInsufficient = 0;
306
+ const seenInRun = new Map();
307
+ for (const rec of recs) {
308
+ bullets += rec.renderedCount;
309
+ wastedSlots += rec.wastedSlots;
310
+ for (const id of new Set(rec.results.map((r) => r.resourceId))) {
311
+ seenInRun.set(id, (seenInRun.get(id) ?? 0) + 1);
312
+ }
313
+ for (const row of rec.results) {
314
+ if (!row.rendered)
315
+ continue;
316
+ const label = labelIndex.get(labelKey(rec.ts, rec.variant, row.resourceId));
317
+ if (!label)
318
+ continue;
319
+ labeled++;
320
+ verdicts[label.verdict]++;
321
+ if (label.snippetInsufficient)
322
+ snippetInsufficient++;
323
+ if (row.createdAt) {
324
+ const age = (new Date(rec.ts).getTime() - new Date(row.createdAt).getTime()) / 86_400_000;
325
+ if (Number.isFinite(age))
326
+ ageDaysByVerdict[label.verdict].push(Math.max(0, Math.round(age)));
327
+ }
328
+ }
329
+ }
330
+ let repeatRate = null;
331
+ if (recs.length >= 2 && seenInRun.size > 0) {
332
+ const repeating = [...seenInRun.values()].filter((n) => n > recs.length / 2).length;
333
+ repeatRate = round4(repeating / seenInRun.size);
334
+ }
335
+ let pairedDays = null;
336
+ let pairedWins = null;
337
+ if (variant !== "baseline") {
338
+ pairedDays = 0;
339
+ pairedWins = 0;
340
+ for (const [day, dayRecs] of perDay(recs)) {
341
+ const baseRecs = baselineDays.get(day);
342
+ if (!baseRecs)
343
+ continue;
344
+ const baseRate = dayHitRate(baseRecs, labelIndex);
345
+ const varRate = dayHitRate(dayRecs, labelIndex);
346
+ if (baseRate === null || varRate === null)
347
+ continue;
348
+ pairedDays++;
349
+ if (varRate > baseRate)
350
+ pairedWins++;
351
+ }
352
+ }
353
+ summaries.push({
354
+ variant,
355
+ runs: recs.length,
356
+ bullets,
357
+ wastedSlots,
358
+ labeled,
359
+ verdicts,
360
+ snippetInsufficient,
361
+ hitRate: labeled === 0 ? null : round4(verdicts["still-open"] / labeled),
362
+ repeatRate,
363
+ ageDaysByVerdict,
364
+ pairedDays,
365
+ pairedWins,
366
+ });
367
+ }
368
+ // Baseline first, then alphabetical — stable regardless of ledger order.
369
+ summaries.sort((a, b) => a.variant === "baseline" ? -1 : b.variant === "baseline" ? 1 : a.variant.localeCompare(b.variant));
370
+ return summaries;
371
+ }
372
+ function median(values) {
373
+ if (values.length === 0)
374
+ return null;
375
+ const sorted = [...values].sort((a, b) => a - b);
376
+ const mid = Math.floor(sorted.length / 2);
377
+ return sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
378
+ }
379
+ export function formatRunHuman(rec) {
380
+ const lines = [
381
+ `=== loops audit — ${rec.variant} ===`,
382
+ `query: ${JSON.stringify(rec.query)}`,
383
+ `limit: ${rec.limit} after: ${rec.after ?? "(none)"} userId: ${rec.userId ?? "(config default)"}`,
384
+ ];
385
+ if (rec.results.length === 0)
386
+ lines.push(" (no results)");
387
+ for (const r of rec.results) {
388
+ const score = r.score === null ? " —" : r.score.toFixed(4);
389
+ const when = r.createdAt ? r.createdAt.slice(0, 10) : " ";
390
+ const state = r.rendered ? "rendered" : "DROPPED: no highlights (wasted slot)";
391
+ lines.push(` ${r.rank}. ${score} ${when} ${r.title ?? `[${r.source}]`} (${r.resourceId}) [${state}]`);
392
+ if (r.snippet)
393
+ lines.push(` ${r.snippet}`);
394
+ }
395
+ lines.push(`rendered: ${rec.renderedCount}/${rec.resultCount} wasted slots: ${rec.wastedSlots}`);
396
+ if (rec.blockConsistent !== null) {
397
+ lines.push(rec.blockConsistent
398
+ ? "as-injected block agrees with the diagnostic search"
399
+ : "WARNING: as-injected block and diagnostic search DIVERGED — corpus moved between calls; rerun");
400
+ }
401
+ if (rec.asInjected)
402
+ lines.push("", rec.asInjected);
403
+ else if (rec.blockConsistent !== null && !rec.contentEnabled) {
404
+ lines.push("(set HYPERSPELL_AUDIT_CONTENT=1 to include snippets + the as-injected block)");
405
+ }
406
+ return lines.join("\n");
407
+ }
408
+ export function formatComparisonHuman(variant, cmp) {
409
+ return [
410
+ `--- ${variant} vs baseline ---`,
411
+ ` overlap: ${cmp.overlap.length === 0 ? "(none)" : cmp.overlap.join(", ")}`,
412
+ ` only baseline: ${cmp.onlyBaseline.length === 0 ? "(none)" : cmp.onlyBaseline.join(", ")}`,
413
+ ` only ${variant}: ${cmp.onlyVariant.length === 0 ? "(none)" : cmp.onlyVariant.join(", ")}`,
414
+ ].join("\n");
415
+ }
416
+ export function formatSummaryHuman(summaries) {
417
+ const lines = ["loops audit — summary (rubric: docs/loops-audit-rubric.md)", ""];
418
+ for (const s of summaries) {
419
+ lines.push(`${s.variant}: ${s.runs} run(s), ${s.bullets} bullet(s), ${s.wastedSlots} wasted slot(s)`);
420
+ if (s.labeled === 0) {
421
+ lines.push(" no labeled bullets yet");
422
+ }
423
+ else {
424
+ lines.push(` hit rate: ${((s.hitRate ?? 0) * 100).toFixed(0)}% (${s.verdicts["still-open"]} still-open / ${s.labeled} labeled)`, ` verdicts: still-open=${s.verdicts["still-open"]} resolved=${s.verdicts.resolved} not-a-loop=${s.verdicts["not-a-loop"]} snippet-insufficient=${s.snippetInsufficient}`);
425
+ for (const v of LOOP_VERDICTS) {
426
+ const m = median(s.ageDaysByVerdict[v]);
427
+ if (m !== null)
428
+ lines.push(` median age (${v}): ${m}d over ${s.ageDaysByVerdict[v].length} item(s)`);
429
+ }
430
+ }
431
+ if (s.repeatRate !== null)
432
+ lines.push(` repeat rate: ${(s.repeatRate * 100).toFixed(0)}% of surfaced ids appear in >half the runs`);
433
+ if (s.pairedDays !== null)
434
+ lines.push(` paired days vs baseline: ${s.pairedWins}/${s.pairedDays} win(s)`);
435
+ lines.push("");
436
+ }
437
+ return lines.join("\n").trimEnd();
438
+ }
@@ -8,6 +8,38 @@ export const DEFAULT_RANKING = {
8
8
  chatterQuota: 2,
9
9
  };
10
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
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
12
+ // Compiled story-term matchers, cached per storyTerms array instance — the
13
+ // config array is built once in parseConfig and stable for the process
14
+ // lifetime, so this avoids per-result regex compilation on the hot path.
15
+ const matcherCache = new WeakMap();
16
+ /**
17
+ * One regex per term, anchored at word boundaries so short terms can't
18
+ * false-positive inside longer words ("ada" no longer matches "adaptation";
19
+ * "mira" no longer matches "admiral"). Boundaries are word↔non-word
20
+ * transitions, so possessives and punctuation still match: "mira" hits
21
+ * "Mira's" and "mira-class". `\b` only anchors against word characters, so
22
+ * terms whose edges are punctuation get no anchor there and still match.
23
+ */
24
+ function storyMatchers(storyTerms) {
25
+ let ms = matcherCache.get(storyTerms);
26
+ if (!ms) {
27
+ ms = storyTerms
28
+ .map((t) => t.trim().toLowerCase())
29
+ .filter((t) => t.length > 0)
30
+ .map((term) => {
31
+ const lead = /^\w/.test(term) ? "\\b" : "";
32
+ const tail = /\w$/.test(term) ? "\\b" : "";
33
+ return new RegExp(`${lead}${escapeRe(term)}${tail}`);
34
+ });
35
+ matcherCache.set(storyTerms, ms);
36
+ }
37
+ return ms;
38
+ }
39
+ /** Count ranked results by kind — the debug-tally shape auto-context logs. */
40
+ export function kindTally(results) {
41
+ return results.reduce((acc, r) => ((acc[r._kind] = (acc[r._kind] ?? 0) + 1), acc), {});
42
+ }
11
43
  /** Highest available relevance for a result (doc score or its best highlight). */
12
44
  export function baseScore(r) {
13
45
  const topHighlight = r.highlights.reduce((m, h) => Math.max(m, h.score ?? 0), 0);
@@ -25,8 +57,11 @@ export function baseScore(r) {
25
57
  export function classifyResult(r, storyTerms) {
26
58
  const title = (r.title ?? "").trim();
27
59
  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())))
60
+ // \n-joined (not space-joined) so a multi-word phrase term can never
61
+ // spuriously match across the seam of two unrelated highlights (terms are
62
+ // escaped literals, so a phrase's inner space cannot match the \n).
63
+ const hay = `${title}\n${r.highlights.map((h) => h.text).join("\n")}`.toLowerCase();
64
+ if (storyMatchers(storyTerms).some((re) => re.test(hay)))
30
65
  return "story";
31
66
  }
32
67
  const untitled = title === "" || /^unnamed conversation$/i.test(title);
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);
@@ -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 = wp.startsWith("/") ? wp : path.join(workspaceDir, wp);
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
- const result = await syncMarkdownFile(client, filePath, { userId: options?.userId });
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;
@@ -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.19.0",
5
+ "version": "0.20.0",
6
6
  "kind": "memory",
7
7
  "contracts": {
8
8
  "tools": [
@@ -75,6 +75,11 @@
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. { enabled, storyTerms, curationBoost, storyBoost, chatterPenalty, candidateMultiplier, chatterQuota }",
81
+ "advanced": true
82
+ },
78
83
  "debug": {
79
84
  "label": "Debug Logging",
80
85
  "help": "Enable verbose debug logs for API calls and responses",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperspell/openclaw-hyperspell",
3
- "version": "0.19.0",
3
+ "version": "0.20.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/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"
42
42
  },
43
43
  "openclaw": {
44
44
  "extensions": [