@hyperspell/openclaw-hyperspell 0.18.1 → 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.
- package/README.md +226 -10
- package/dist/client.js +14 -0
- package/dist/commands/preview.js +93 -0
- package/dist/commands/purge-channel.js +54 -0
- package/dist/commands/setup.js +64 -6
- package/dist/commands/slash.js +58 -0
- package/dist/config.js +43 -4
- package/dist/graph/ops.js +36 -3
- package/dist/hooks/auto-context.js +75 -4
- package/dist/hooks/auto-trace.js +20 -2
- package/dist/hooks/emotional-state.js +90 -22
- package/dist/hooks/hot-buffer.js +76 -6
- package/dist/hooks/memory-sync.js +23 -7
- package/dist/hooks/mood-weather.js +45 -0
- package/dist/hooks/startup-orientation.js +86 -55
- package/dist/index.js +37 -0
- package/dist/lib/eval-matchers.js +49 -0
- package/dist/lib/exclude-channels.js +17 -6
- package/dist/lib/filters.js +46 -16
- package/dist/lib/loops-audit.js +438 -0
- package/dist/lib/ranking.js +79 -15
- package/dist/logger.js +16 -0
- package/dist/sync/markdown.js +57 -2
- package/dist/tools/emotional-arc.js +60 -0
- package/dist/tools/remember.js +9 -1
- package/openclaw.plugin.json +8 -2
- package/package.json +2 -2
package/dist/lib/filters.js
CHANGED
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
* Filters match against memory METADATA keys by their bare name — the same
|
|
7
7
|
* convention `buildScopeFilter` uses (`openclaw_scope`, `openclaw_user`).
|
|
8
8
|
*/
|
|
9
|
+
/** Metadata tag on auto-trace session-end rows (see `sendTrace` in client.ts). */
|
|
10
|
+
export const AGENT_END_SOURCE = "agent_end";
|
|
11
|
+
/** Metadata tag on mood-weather roll records (see `recordMoodRoll` in hooks/mood-weather.ts). */
|
|
12
|
+
export const MOOD_WEATHER_SOURCE = "mood_weather";
|
|
9
13
|
/**
|
|
10
14
|
* Memories produced by the auto-trace session-end hook are tagged in metadata
|
|
11
15
|
* as `openclaw_source: "agent_end"` (see `sendTrace` in client.ts). Those should
|
|
@@ -23,31 +27,57 @@
|
|
|
23
27
|
* search (~1s observed live), and when auto-trace is off there are no `agent_end`
|
|
24
28
|
* rows to hide, so the filter would cost latency on every turn for zero benefit.
|
|
25
29
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
+
* Hot rows ARE positively tagged (`openclaw_source: "hot_buffer"` plus
|
|
31
|
+
* session/channel ids — see the hot-buffer hook): metadata-carrying
|
|
32
|
+
* `/messages` rows were verified retrievable AND filterable live 2026-07-02
|
|
33
|
+
* (docs/filter-dialect-test.mjs). The `$ne` exclude keeps them because
|
|
34
|
+
* "hot_buffer" != "agent_end". (An earlier note here claimed metadata made
|
|
35
|
+
* hot rows non-retrievable — that predated the backend fix.)
|
|
30
36
|
*
|
|
31
37
|
* NOTE: an earlier version checked the top-level `source` field for
|
|
32
38
|
* "openclaw_agent_end" — wrong on BOTH counts (the tag lives in metadata under
|
|
33
39
|
* `openclaw_source`, value `"agent_end"`), so it silently matched nothing.
|
|
34
40
|
*/
|
|
35
|
-
export const EXCLUDE_SESSION_END_FILTER = {
|
|
36
|
-
openclaw_source: { $ne: "agent_end" },
|
|
37
|
-
};
|
|
38
41
|
/**
|
|
39
42
|
* The exclude clause to apply for a given config — or `undefined` to skip the
|
|
40
|
-
* filter.
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
43
|
+
* filter. Each excluded tag is gated on the feature that writes it, for
|
|
44
|
+
* PERFORMANCE, not correctness (see the header comment): with the feature off
|
|
45
|
+
* there are no tagged rows to hide, so the ~1s predicate would be pure latency.
|
|
46
|
+
*
|
|
47
|
+
* - `agent_end` rows are written ONLY by the auto-trace hook → gate on
|
|
48
|
+
* `autoTrace.enabled` (verified live against an auto-trace-off agent with
|
|
49
|
+
* zero `agent_end` rows).
|
|
50
|
+
* - `mood_weather` rolls are recorded ONLY when the emotional-context handler
|
|
51
|
+
* is registered AND the dice are live → gate on both flags.
|
|
52
|
+
*
|
|
53
|
+
* Shape: a single excluded value keeps the proven plain-`$ne` form (byte-
|
|
54
|
+
* identical to the shipped filter, and post-#1921 it drops the tag while
|
|
55
|
+
* KEEPING untagged hot-buffer rows). Two values use `$nin`.
|
|
56
|
+
*
|
|
57
|
+
* ⚠️ The `$nin` two-value shape has NOT been re-verified live post-#1921 (the
|
|
58
|
+
* pre-#1921 truth table showed `$nin` diverging from `$ne`). Owner's post-merge
|
|
59
|
+
* step: run `node docs/filter-dialect-test.mjs` — the `$nin[agent_end,mood_weather]`
|
|
60
|
+
* row must show U=Y, A=N, M=N. Fallback plan if it fails: try the
|
|
61
|
+
* `$and[$ne,$ne]` probe row; if that also fails, gate back to single-value
|
|
62
|
+
* `$ne` for the two common one-feature-on configs, log a warning when both
|
|
63
|
+
* features are enabled, and file a backend dialect follow-up
|
|
64
|
+
* (docs/hyperspell-backend-followups.md style) for the both-on combo.
|
|
48
65
|
*/
|
|
49
66
|
export function excludeFilterFor(cfg) {
|
|
50
|
-
|
|
67
|
+
const excluded = [];
|
|
68
|
+
if (cfg.autoTrace.enabled)
|
|
69
|
+
excluded.push(AGENT_END_SOURCE);
|
|
70
|
+
// Mood rolls are recorded only when the emotional-context handler is
|
|
71
|
+
// registered AND the dice are live — same "no rows to hide → skip the
|
|
72
|
+
// ~1s predicate" gate as auto-trace.
|
|
73
|
+
if (cfg.emotionalContext && cfg.moodWeatherChance > 0) {
|
|
74
|
+
excluded.push(MOOD_WEATHER_SOURCE);
|
|
75
|
+
}
|
|
76
|
+
if (excluded.length === 0)
|
|
77
|
+
return undefined;
|
|
78
|
+
if (excluded.length === 1)
|
|
79
|
+
return { openclaw_source: { $ne: excluded[0] } };
|
|
80
|
+
return { openclaw_source: { $nin: excluded } };
|
|
51
81
|
}
|
|
52
82
|
/**
|
|
53
83
|
* Combine a caller-supplied filter with the session-end exclude via `$and`.
|
|
@@ -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
|
+
}
|
package/dist/lib/ranking.js
CHANGED
|
@@ -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
|
-
|
|
29
|
-
|
|
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);
|
|
@@ -63,25 +98,54 @@ export function rerank(results, w) {
|
|
|
63
98
|
.sort((a, b) => b._composite - a._composite);
|
|
64
99
|
}
|
|
65
100
|
/**
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
101
|
+
* One pass over ranked results, annotating each with its selection outcome.
|
|
102
|
+
* Same policy as selectRanked — which is now derived from this, so the two
|
|
103
|
+
* can never drift (proposal 02 §3a).
|
|
104
|
+
*
|
|
105
|
+
* Attribution order is deliberate: the threshold check comes first so a
|
|
106
|
+
* below-the-bar result is always attributed to "threshold" even when the
|
|
107
|
+
* max-results cap is already full; and a chatter item past the cap reads
|
|
108
|
+
* "max-results", not "chatter-quota" — it would have been cut regardless, so
|
|
109
|
+
* the quota was not the binding constraint (proposal 03 §3.2 semantics).
|
|
70
110
|
*/
|
|
71
|
-
export function
|
|
111
|
+
export function explainSelection(ranked, maxResults, threshold, chatterQuota) {
|
|
72
112
|
const out = [];
|
|
73
113
|
let chatter = 0;
|
|
114
|
+
let kept = 0;
|
|
74
115
|
for (const r of ranked) {
|
|
75
|
-
if (r._composite < threshold)
|
|
116
|
+
if (r._composite < threshold) {
|
|
117
|
+
out.push({ result: r, selected: false, cut: "threshold" });
|
|
76
118
|
continue;
|
|
77
|
-
if (r._kind === "chatter") {
|
|
78
|
-
if (chatter >= chatterQuota)
|
|
79
|
-
continue;
|
|
80
|
-
chatter++;
|
|
81
119
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
120
|
+
if (kept >= maxResults) {
|
|
121
|
+
out.push({ result: r, selected: false, cut: "max-results" });
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (r._kind === "chatter" && chatter >= chatterQuota) {
|
|
125
|
+
out.push({ result: r, selected: false, cut: "chatter-quota" });
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (r._kind === "chatter")
|
|
129
|
+
chatter++;
|
|
130
|
+
kept++;
|
|
131
|
+
out.push({ result: r, selected: true, cut: null });
|
|
85
132
|
}
|
|
86
133
|
return out;
|
|
87
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* Choose which ranked results to inject: keep those clearing `threshold` on
|
|
137
|
+
* their composite, cap CHATTER at `chatterQuota` regardless of score (so a
|
|
138
|
+
* high-similarity echo can inform but never flood — the penalty bounds rank,
|
|
139
|
+
* the quota bounds count), and stop at `maxResults`.
|
|
140
|
+
*
|
|
141
|
+
* Signature and return shape are intentionally unchanged: the retrieval eval
|
|
142
|
+
* harness (proposal 17) and existing call sites depend on
|
|
143
|
+
* `selectRanked(ranked, maxResults, threshold, chatterQuota): RankedResult[]`.
|
|
144
|
+
* It is a thin projection of explainSelection so selection policy lives in
|
|
145
|
+
* exactly one place.
|
|
146
|
+
*/
|
|
147
|
+
export function selectRanked(ranked, maxResults, threshold, chatterQuota) {
|
|
148
|
+
return explainSelection(ranked, maxResults, threshold, chatterQuota)
|
|
149
|
+
.filter((e) => e.selected)
|
|
150
|
+
.map((e) => e.result);
|
|
151
|
+
}
|
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);
|