@lorekit/cli 1.39.2 → 1.41.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 +2 -1
- package/bin/lorekit.mjs +17 -8
- package/package.json +1 -1
- package/src/config.mjs +6 -1
- package/src/dedupe.mjs +82 -22
- package/src/install.mjs +18 -1
- package/src/lessons-pure.mjs +44 -6
- package/src/lessons-view.mjs +51 -0
package/README.md
CHANGED
|
@@ -103,7 +103,8 @@ from `.gitignore`, negate it with `!.mcp.json`, or `git add -f .mcp.json` once);
|
|
|
103
103
|
`install --mcp-json` **warns when the file it wrote is still git-ignored**, so a
|
|
104
104
|
fresh web clone silently missing the config is not a mystery. Once `.mcp.json` is
|
|
105
105
|
tracked, only run `install --mcp-json` in that repo — a plain `install --project`
|
|
106
|
-
would embed a live token in the now-committed file
|
|
106
|
+
would embed a live token in the now-committed file (and the command warns before
|
|
107
|
+
it does). See the
|
|
107
108
|
[Claude Code on the web guide](https://lorekit.io/docs/claude-code-web).
|
|
108
109
|
|
|
109
110
|
In a TTY it prompts for the scope (and for `--endpoint` / `--token` if missing).
|
package/bin/lorekit.mjs
CHANGED
|
@@ -542,18 +542,27 @@ HEURISTIC — Jaccard overlap of lowercased word tokens, not a semantic/embeddin
|
|
|
542
542
|
measure — so it surfaces candidates for a human to review, and can both miss
|
|
543
543
|
paraphrases and group coincidental overlaps. Tune the cutoff with --threshold.
|
|
544
544
|
|
|
545
|
+
Pass --cluster-by-key <regex> to cluster by KEY shape instead of value overlap:
|
|
546
|
+
entries whose keys share the same first capture group (or full match) form one
|
|
547
|
+
family. This catches coordinate-key debt — e.g. many pr{N}-{commentId} rows for
|
|
548
|
+
one review comment — that the value heuristic misses when the values differ.
|
|
549
|
+
Key-shape mode has no similarity cutoff, so --threshold and --cluster-by-key are
|
|
550
|
+
mutually exclusive: passing both is a usage error rather than a silent ignore.
|
|
551
|
+
|
|
545
552
|
${c.bold('Options')}
|
|
546
|
-
-d, --dir <path>
|
|
547
|
-
--scope <scope>
|
|
548
|
-
--threshold <0..1>
|
|
549
|
-
--
|
|
550
|
-
|
|
551
|
-
-
|
|
552
|
-
|
|
553
|
+
-d, --dir <path> Target project root (default: current directory)
|
|
554
|
+
--scope <scope> Restrict to a single scope (default: all applicable)
|
|
555
|
+
--threshold <0..1> Similarity cutoff to cluster a pair (default: 0.8)
|
|
556
|
+
--cluster-by-key <re> Cluster by shared key capture instead of value overlap
|
|
557
|
+
--json Machine-readable output (clusters + signal)
|
|
558
|
+
-e, --endpoint <url> Remote endpoint override (else .mcp.json / LOREKIT_MCP_URL)
|
|
559
|
+
-t, --token <token> Remote token override (else .mcp.json / LOREKIT_TOKEN)
|
|
560
|
+
--store <path> Local project-tier store directory (default: .lorekit)
|
|
553
561
|
|
|
554
562
|
${c.bold('Examples')}
|
|
555
563
|
npx @lorekit/cli dedupe
|
|
556
564
|
npx @lorekit/cli dedupe --threshold 0.6 --json
|
|
565
|
+
npx @lorekit/cli dedupe --cluster-by-key "(pr\\d+-\\d+)" --json
|
|
557
566
|
`,
|
|
558
567
|
link: `${c.bold('lorekit link')} — print a shareable dashboard deep-link URL ${c.dim('(alias: url)')}
|
|
559
568
|
|
|
@@ -652,7 +661,7 @@ const KNOWN_FLAGS = [
|
|
|
652
661
|
'link', 'base', 'q', 'owner', 'range', 'view', 'archived',
|
|
653
662
|
'origin-repo', 'origin-branch', 'origin-commit', 'origin-pr', 'no-origin',
|
|
654
663
|
// Scale-aware survey flags
|
|
655
|
-
'all', 'max', 'since', 'until', 'key-prefix',
|
|
664
|
+
'all', 'max', 'since', 'until', 'key-prefix', 'cluster-by-key',
|
|
656
665
|
];
|
|
657
666
|
|
|
658
667
|
// Commands that write to disk / talk to the network on a human's behalf. These
|
package/package.json
CHANGED
package/src/config.mjs
CHANGED
|
@@ -591,7 +591,12 @@ export function tokenKind(token) {
|
|
|
591
591
|
// avoid a circular import with mcp.mjs.
|
|
592
592
|
//
|
|
593
593
|
// A source that STORES a token wins outright (project beats global — closest
|
|
594
|
-
// scope),
|
|
594
|
+
// scope), and it brings its OWN endpoint: a token authenticates one endpoint,
|
|
595
|
+
// so the two must travel together. A TOKENLESS source therefore never shadows a
|
|
596
|
+
// later source that has a token — not its token AND not its endpoint; its
|
|
597
|
+
// endpoint is only remembered as a fallback, used solely when NO source stores a
|
|
598
|
+
// token. (So "closest-scope-first" governs which token wins; the endpoint simply
|
|
599
|
+
// follows that token.)
|
|
595
600
|
// That shadowing is exactly what `install --global --mcp-json` created: it
|
|
596
601
|
// writes a committable, token-free project .mcp.json (auth via ${LOREKIT_TOKEN})
|
|
597
602
|
// AND the real token into ~/.claude.json. Returning early on the project entry
|
package/src/dedupe.mjs
CHANGED
|
@@ -15,13 +15,18 @@ import { resolveProjectRoot, readLorekitJson } from './config.mjs';
|
|
|
15
15
|
import { deriveScope } from './scope.mjs';
|
|
16
16
|
import { resolveDenies } from './control.mjs';
|
|
17
17
|
import { resolveStores, remoteUnavailableReason } from './stores.mjs';
|
|
18
|
-
import { scopeList, gather, gatherStream, clusterDuplicates, clusterDuplicatesBlocked, DEFAULT_MAX } from './lessons-view.mjs';
|
|
19
|
-
import { log, heading, status, c } from './util.mjs';
|
|
18
|
+
import { scopeList, gather, gatherStream, clusterDuplicates, clusterDuplicatesBlocked, clusterByKeyPattern, compileKeyPattern, DEFAULT_MAX } from './lessons-view.mjs';
|
|
19
|
+
import { log, heading, status, err, c } from './util.mjs';
|
|
20
20
|
|
|
21
21
|
const DEFAULT_THRESHOLD = 0.8;
|
|
22
|
-
// Maximum entries to accumulate before the
|
|
23
|
-
//
|
|
24
|
-
//
|
|
22
|
+
// Maximum entries to accumulate before the survey becomes memory-prohibitive.
|
|
23
|
+
// It bounds the accumulated entry list itself, so it applies in BOTH modes —
|
|
24
|
+
// key-shape clustering is O(n) and needs no blocking index, but it still holds
|
|
25
|
+
// the whole population in memory, and an unbounded remote drain is the risk the
|
|
26
|
+
// cap exists for. In value mode it additionally bounds the token-blocking index,
|
|
27
|
+
// which is the super-linear part. Beyond the cap the results are genuinely
|
|
28
|
+
// partial in either mode, and the user must narrow via --key-prefix / --since /
|
|
29
|
+
// --max.
|
|
25
30
|
const DEDUPE_POP_CAP = 2000;
|
|
26
31
|
|
|
27
32
|
// Smallest threshold the blocked clusterer accepts. `clusterDuplicatesBlocked`
|
|
@@ -77,6 +82,34 @@ export async function dedupe(args) {
|
|
|
77
82
|
args.threshold !== undefined
|
|
78
83
|
? parseThreshold(args.threshold)
|
|
79
84
|
: (repoThreshold(root) ?? DEFAULT_THRESHOLD);
|
|
85
|
+
|
|
86
|
+
// `--cluster-by-key <regex>` switches from value-overlap clustering to KEY-shape
|
|
87
|
+
// clustering: entries whose keys share the same first capture group (or full
|
|
88
|
+
// match) of the regex are grouped as a duplicate family — catches coordinate-key
|
|
89
|
+
// debt (e.g. `bucket::pr{N}-{commentId}::slug`) that the Jaccard heuristic misses
|
|
90
|
+
// when the values differ. A bare flag (no value) or an unparseable regex is a
|
|
91
|
+
// usage error: report it and exit non-zero rather than silently surveying by value.
|
|
92
|
+
const clusterByKeyRaw = args['cluster-by-key'];
|
|
93
|
+
const byKeyMode = clusterByKeyRaw !== undefined;
|
|
94
|
+
const keyPattern = byKeyMode ? compileKeyPattern(clusterByKeyRaw) : null;
|
|
95
|
+
if (byKeyMode && !keyPattern) {
|
|
96
|
+
err(
|
|
97
|
+
clusterByKeyRaw === true
|
|
98
|
+
? '--cluster-by-key needs a regex value, e.g. --cluster-by-key "(pr\\d+-\\d+)"'
|
|
99
|
+
: `--cluster-by-key: invalid regex ${JSON.stringify(String(clusterByKeyRaw))}`,
|
|
100
|
+
);
|
|
101
|
+
return { exitCode: 1 };
|
|
102
|
+
}
|
|
103
|
+
// Key-shape mode has no similarity cutoff, so an explicit `--threshold` would
|
|
104
|
+
// be silently ignored — the same silent fallback the bad-regex branch above
|
|
105
|
+
// refuses. Refuse it too. Only the EXPLICIT flag errors: a repo-level
|
|
106
|
+
// `dedupe.threshold` in .lorekit.json is a value-mode default, not a request,
|
|
107
|
+
// so it must not break a key-mode run.
|
|
108
|
+
if (byKeyMode && args.threshold !== undefined) {
|
|
109
|
+
err('--threshold is not used with --cluster-by-key (key-shape clustering has no similarity cutoff); drop one of them');
|
|
110
|
+
return { exitCode: 1 };
|
|
111
|
+
}
|
|
112
|
+
|
|
80
113
|
const scopeInfo = deriveScope(root);
|
|
81
114
|
// Default to every applicable scope; `--scope <s>` narrows to one.
|
|
82
115
|
const scopes = args.scope && typeof args.scope === 'string' ? [args.scope] : scopeList(scopeInfo);
|
|
@@ -156,7 +189,9 @@ export async function dedupe(args) {
|
|
|
156
189
|
}
|
|
157
190
|
return {
|
|
158
191
|
available: true,
|
|
159
|
-
clusters:
|
|
192
|
+
clusters: byKeyMode
|
|
193
|
+
? clusterByKeyPattern(entries, keyPattern)
|
|
194
|
+
: clusterDuplicatesBlocked(entries, threshold),
|
|
160
195
|
errored: flat.errored,
|
|
161
196
|
popCapped,
|
|
162
197
|
};
|
|
@@ -164,7 +199,9 @@ export async function dedupe(args) {
|
|
|
164
199
|
const { entries, errored, popCapped } = await streamAccumulate(store);
|
|
165
200
|
return {
|
|
166
201
|
available: true,
|
|
167
|
-
clusters:
|
|
202
|
+
clusters: byKeyMode
|
|
203
|
+
? clusterByKeyPattern(entries, keyPattern)
|
|
204
|
+
: clusterDuplicatesBlocked(entries, threshold),
|
|
168
205
|
errored,
|
|
169
206
|
popCapped,
|
|
170
207
|
};
|
|
@@ -188,12 +225,16 @@ export async function dedupe(args) {
|
|
|
188
225
|
const remoteClusters = remoteSection.available ? remoteSection.clusters.length : 0;
|
|
189
226
|
|
|
190
227
|
if (args.json) {
|
|
191
|
-
log(JSON.stringify(buildJson({ root, scopes, threshold, offlineSection, remoteSection }), null, 2));
|
|
228
|
+
log(JSON.stringify(buildJson({ root, scopes, threshold, byKeyMode, keyPattern, offlineSection, remoteSection }), null, 2));
|
|
192
229
|
} else {
|
|
193
230
|
heading('LoreKit dedupe');
|
|
194
231
|
log(` project: ${c.dim(root)}`);
|
|
195
232
|
log(` scopes: ${scopes.join(' → ')}`);
|
|
196
|
-
log(
|
|
233
|
+
log(
|
|
234
|
+
byKeyMode
|
|
235
|
+
? ` ${c.dim(`key-shape: clustering by shared key capture of /${keyPattern.source}/`)}`
|
|
236
|
+
: ` ${c.dim(`heuristic: Jaccard word-token overlap >= ${threshold} (not semantic)`)}`,
|
|
237
|
+
);
|
|
197
238
|
|
|
198
239
|
if (offlineSection.available && offlineSection.popCapped) {
|
|
199
240
|
log(` ${c.yellow('!')} population cap (${DEDUPE_POP_CAP}) reached for Offline — results are partial. Narrow with --key-prefix, --since, or --max.`);
|
|
@@ -211,7 +252,13 @@ export async function dedupe(args) {
|
|
|
211
252
|
log('');
|
|
212
253
|
const total = offlineClusters + remoteClusters;
|
|
213
254
|
if (total === 0) {
|
|
214
|
-
|
|
255
|
+
// "at this threshold" is only true in value mode — key-shape mode has no
|
|
256
|
+
// cutoff, so name the pattern that found nothing instead.
|
|
257
|
+
log(
|
|
258
|
+
byKeyMode
|
|
259
|
+
? ` ${c.green('✓')} no key-shape clusters for /${keyPattern.source}/`
|
|
260
|
+
: ` ${c.green('✓')} no likely-duplicate clusters at this threshold`,
|
|
261
|
+
);
|
|
215
262
|
} else {
|
|
216
263
|
const plural = total === 1 ? '' : 's';
|
|
217
264
|
log(` ${c.yellow('!')} ${total} duplicate cluster${plural} found`);
|
|
@@ -220,11 +267,14 @@ export async function dedupe(args) {
|
|
|
220
267
|
}
|
|
221
268
|
|
|
222
269
|
// Bounded, non-PII telemetry extras — counts + a boolean, never a scope
|
|
223
|
-
// string, key, path, or token.
|
|
270
|
+
// string, key, path, or token. `threshold` is emitted only in value mode,
|
|
271
|
+
// where it is the cutoff actually applied; the key-mode pattern is user text
|
|
272
|
+
// and is never emitted, only the bounded mode name.
|
|
224
273
|
return {
|
|
225
274
|
exitCode: 0,
|
|
226
275
|
'lorekit.cli.dedupe.scope_count': scopes.length,
|
|
227
|
-
'lorekit.cli.dedupe.
|
|
276
|
+
'lorekit.cli.dedupe.mode': byKeyMode ? 'key' : 'value',
|
|
277
|
+
...(byKeyMode ? {} : { 'lorekit.cli.dedupe.threshold': threshold }),
|
|
228
278
|
'lorekit.cli.dedupe.offline_clusters': offlineClusters,
|
|
229
279
|
'lorekit.cli.dedupe.remote_clusters': remoteClusters,
|
|
230
280
|
'lorekit.cli.dedupe.remote_available': remoteAvailable,
|
|
@@ -255,25 +305,35 @@ function renderDedupeSection(header, section) {
|
|
|
255
305
|
let n = 0;
|
|
256
306
|
for (const cluster of section.clusters) {
|
|
257
307
|
n += 1;
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
308
|
+
// Key-shape clusters carry a `keyGroup`; value-overlap clusters carry a
|
|
309
|
+
// similarity range. Render whichever signal the cluster has.
|
|
310
|
+
let signal;
|
|
311
|
+
if (cluster.keyGroup !== undefined) {
|
|
312
|
+
signal = `${cluster.size} memories, key-group "${cluster.keyGroup}"`;
|
|
313
|
+
} else {
|
|
314
|
+
const range =
|
|
315
|
+
cluster.minSimilarity === cluster.maxSimilarity
|
|
316
|
+
? cluster.minSimilarity.toFixed(2)
|
|
317
|
+
: `${cluster.minSimilarity.toFixed(2)}–${cluster.maxSimilarity.toFixed(2)}`;
|
|
318
|
+
signal = `${cluster.size} memories, similarity ${range}`;
|
|
319
|
+
}
|
|
320
|
+
log(` ${c.yellow('•')} cluster ${n} ${c.dim(`(${signal})`)}`);
|
|
263
321
|
for (const m of cluster.members) {
|
|
264
322
|
log(` ${c.cyan('-')} ${m.scope}::${m.key}`);
|
|
265
323
|
}
|
|
266
324
|
}
|
|
267
325
|
}
|
|
268
326
|
|
|
269
|
-
// The `--json` payload: `{ root, scopes, threshold, offline,
|
|
270
|
-
//
|
|
271
|
-
// maxSimilarity
|
|
272
|
-
|
|
327
|
+
// The `--json` payload: `{ root, scopes, mode, threshold|keyPattern, offline,
|
|
328
|
+
// remote }`. In value mode (`mode: "value"`) each cluster carries `minSimilarity`
|
|
329
|
+
// / `maxSimilarity`; in key-shape mode (`mode: "key"`) each carries `keyGroup`.
|
|
330
|
+
// Each store is a `{ available, clusters, errored }` record (or an unavailable note).
|
|
331
|
+
function buildJson({ root, scopes, threshold, byKeyMode, keyPattern, offlineSection, remoteSection }) {
|
|
273
332
|
return {
|
|
274
333
|
root,
|
|
275
334
|
scopes,
|
|
276
|
-
|
|
335
|
+
mode: byKeyMode ? 'key' : 'value',
|
|
336
|
+
...(byKeyMode ? { keyPattern: keyPattern.source } : { threshold }),
|
|
277
337
|
offline: sectionJson(offlineSection),
|
|
278
338
|
remote: sectionJson(remoteSection),
|
|
279
339
|
};
|
package/src/install.mjs
CHANGED
|
@@ -23,6 +23,8 @@ import {
|
|
|
23
23
|
homeDir,
|
|
24
24
|
mcpConfigPath,
|
|
25
25
|
readJsonIfExists,
|
|
26
|
+
readLorekitServer,
|
|
27
|
+
isWebMcpServerEntry,
|
|
26
28
|
} from './config.mjs';
|
|
27
29
|
import { buildRemoteUrl, splitEndpoint } from './mcp.mjs';
|
|
28
30
|
import { deriveScope } from './scope.mjs';
|
|
@@ -364,6 +366,21 @@ export async function install(args) {
|
|
|
364
366
|
let file = null;
|
|
365
367
|
let existed = false;
|
|
366
368
|
if (!scopeWriteOwnedByWeb) {
|
|
369
|
+
// A plain project install embeds the token in .mcp.json. If that file
|
|
370
|
+
// currently holds the committable web form (written by an earlier
|
|
371
|
+
// `--mcp-json`), this write replaces it with an embedded secret — in the
|
|
372
|
+
// very file the docs tell you to commit. Warn before clobbering it;
|
|
373
|
+
// `isWebMcpServerEntry` recognises the shape we are about to overwrite.
|
|
374
|
+
if (scope === 'project' && token) {
|
|
375
|
+
const prior = readLorekitServer(root);
|
|
376
|
+
if (prior && isWebMcpServerEntry(prior.server)) {
|
|
377
|
+
status(
|
|
378
|
+
'warn',
|
|
379
|
+
'.mcp.json',
|
|
380
|
+
`replacing the committable web entry with an embedded token — do not commit this file, or re-run with --mcp-json to keep the \${${WEB_TOKEN_ENV_VAR}} form`,
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
367
384
|
({ file, existed } = upsertMcpServer(root, remoteUrl, scope));
|
|
368
385
|
}
|
|
369
386
|
|
|
@@ -527,7 +544,7 @@ export async function install(args) {
|
|
|
527
544
|
'warn',
|
|
528
545
|
'token',
|
|
529
546
|
`not stored — the committable .mcp.json resolves \${${WEB_TOKEN_ENV_VAR}} at runtime; set it as an environment secret${
|
|
530
|
-
|
|
547
|
+
plan.action === 'flag' ? ' (the token you supplied is not persisted)' : ''
|
|
531
548
|
}`,
|
|
532
549
|
);
|
|
533
550
|
} else if (kind === 'none') {
|
package/src/lessons-pure.mjs
CHANGED
|
@@ -212,7 +212,7 @@ export function resolveScopeKeyArgs(positionals = [], options = {}) {
|
|
|
212
212
|
// takes every slot, evicting the durable lessons that have been re-learned a
|
|
213
213
|
// dozen times. Recency is a signal, not the ranking.
|
|
214
214
|
//
|
|
215
|
-
// The score is a weighted sum of
|
|
215
|
+
// The score is a weighted sum of four factors, each normalised to [0,1]:
|
|
216
216
|
//
|
|
217
217
|
// recency — exponential decay on age. Half-life, not a cliff: a lesson does
|
|
218
218
|
// not stop mattering on a particular day.
|
|
@@ -225,6 +225,12 @@ export function resolveScopeKeyArgs(positionals = [], options = {}) {
|
|
|
225
225
|
// when no terms are supplied, which is the SessionStart case: it
|
|
226
226
|
// then contributes the same constant to every candidate and the
|
|
227
227
|
// ordering is recency + salience alone.
|
|
228
|
+
// outcome — applied/resolution history in [0,1]. The factor only ever
|
|
229
|
+
// LIFTS: a lesson tagged on an outcome bus scores 1.0 and one
|
|
230
|
+
// carried to a PR 0.75, while a lesson with no history gets the
|
|
231
|
+
// COLD_START_OUTCOME_PRIOR (0.5) — the neutral floor, never 0.
|
|
232
|
+
// So a proven lesson ranks up; an unproven one is not penalised
|
|
233
|
+
// for lacking history and rides on recency and relevance.
|
|
228
234
|
//
|
|
229
235
|
// PURE AND TOTAL, with one scoped exception. `now` is a PARAMETER: the
|
|
230
236
|
// arithmetic never reads the clock, every factor is a function of the value
|
|
@@ -243,7 +249,7 @@ export function resolveScopeKeyArgs(positionals = [], options = {}) {
|
|
|
243
249
|
// nothing — a year-old lesson that has recurred 30 times still deserves a slot.
|
|
244
250
|
export const RECENCY_HALF_LIFE_DAYS = 14;
|
|
245
251
|
|
|
246
|
-
// Equal
|
|
252
|
+
// Equal quarters. Deliberately not tuned: with no corpus to tune against, an
|
|
247
253
|
// invented weighting is a guess wearing a decimal point. They are a parameter
|
|
248
254
|
// so a caller can experiment, and so a future PR can change them with evidence.
|
|
249
255
|
//
|
|
@@ -253,7 +259,21 @@ export const RECENCY_HALF_LIFE_DAYS = 14;
|
|
|
253
259
|
// recursion (a real `RangeError`, raised inside a hook the header promises will
|
|
254
260
|
// never throw). Freezing makes the corruption a `TypeError` at the assignment,
|
|
255
261
|
// in the caller's own frame, instead of a stack overflow three layers down.
|
|
256
|
-
export const DEFAULT_RANK_WEIGHTS = Object.freeze({ recency: 1, salience: 1, relevance: 1 });
|
|
262
|
+
export const DEFAULT_RANK_WEIGHTS = Object.freeze({ recency: 1, salience: 1, relevance: 1, outcome: 1 });
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* The cold-start prior for the outcome factor. A new lesson with no applied /
|
|
266
|
+
* resolution history gets this value rather than 0. The rationale: scoring
|
|
267
|
+
* absent outcome at 0 would sink every new lesson below stale ones purely for
|
|
268
|
+
* lacking outcome history (outcome-lag). 0.5 is the neutral midpoint of [0,1]
|
|
269
|
+
* — a cold lesson contributes an average outcome term, so it ranks on
|
|
270
|
+
* recency and relevance instead of being penalised for being new.
|
|
271
|
+
*
|
|
272
|
+
* This is the ONE deliberate asymmetry vs `normalizeRelevance` (which returns
|
|
273
|
+
* 0 for absent / unreadable input). Mirrored byte-identically in
|
|
274
|
+
* `packages/mcp-core/src/lesson-rank.ts` and its edge twin.
|
|
275
|
+
*/
|
|
276
|
+
export const COLD_START_OUTCOME_PRIOR = 0.5;
|
|
257
277
|
|
|
258
278
|
// Two scores closer than this are the same score. Sized well below any
|
|
259
279
|
// difference the factors can produce meaningfully (a one-second age gap moves a
|
|
@@ -379,6 +399,21 @@ function distinctTerms(terms) {
|
|
|
379
399
|
);
|
|
380
400
|
}
|
|
381
401
|
|
|
402
|
+
/**
|
|
403
|
+
* Normalize an outcome value into [0,1]. Absent or unreadable input returns
|
|
404
|
+
* `COLD_START_OUTCOME_PRIOR` — the deliberate asymmetry vs `normalizeRelevance`
|
|
405
|
+
* (which returns 0 for absent input). A present value is clamped to [0,1].
|
|
406
|
+
*
|
|
407
|
+
* The cold-start prior ensures a new lesson with no outcome history is not
|
|
408
|
+
* penalised during outcome-lag — it contributes an average outcome term and
|
|
409
|
+
* ranks on recency + relevance instead.
|
|
410
|
+
*/
|
|
411
|
+
export function normalizeOutcome(value) {
|
|
412
|
+
const n = typeof value === 'string' ? Number(value) : value;
|
|
413
|
+
if (typeof n !== 'number' || !Number.isFinite(n)) return COLD_START_OUTCOME_PRIOR;
|
|
414
|
+
return Math.min(1, Math.max(0, n));
|
|
415
|
+
}
|
|
416
|
+
|
|
382
417
|
/**
|
|
383
418
|
* Score one lesson in [0,1].
|
|
384
419
|
*
|
|
@@ -415,21 +450,24 @@ function scoreWithTerms(entry, termSet, { now, weights, maxSeenCount, halfLifeDa
|
|
|
415
450
|
recency: numberOr(weights?.recency, DEFAULT_RANK_WEIGHTS.recency),
|
|
416
451
|
salience: numberOr(weights?.salience, DEFAULT_RANK_WEIGHTS.salience),
|
|
417
452
|
relevance: numberOr(weights?.relevance, DEFAULT_RANK_WEIGHTS.relevance),
|
|
453
|
+
outcome: numberOr(weights?.outcome, DEFAULT_RANK_WEIGHTS.outcome),
|
|
418
454
|
};
|
|
419
|
-
let total = w.recency + w.salience + w.relevance;
|
|
455
|
+
let total = w.recency + w.salience + w.relevance + w.outcome;
|
|
420
456
|
if (!(total > 0)) {
|
|
421
457
|
w = {
|
|
422
458
|
recency: numberOr(DEFAULT_RANK_WEIGHTS.recency, 0),
|
|
423
459
|
salience: numberOr(DEFAULT_RANK_WEIGHTS.salience, 0),
|
|
424
460
|
relevance: numberOr(DEFAULT_RANK_WEIGHTS.relevance, 0),
|
|
461
|
+
outcome: numberOr(DEFAULT_RANK_WEIGHTS.outcome, 0),
|
|
425
462
|
};
|
|
426
|
-
total = w.recency + w.salience + w.relevance;
|
|
463
|
+
total = w.recency + w.salience + w.relevance + w.outcome;
|
|
427
464
|
}
|
|
428
465
|
if (!(total > 0)) return 0;
|
|
429
466
|
const recency = recencyFactor(entry?.updatedAt ?? entry?.updated_at ?? entry?.updated, now, halfLifeDays);
|
|
430
467
|
const salience = salienceFactor(seenCountFrom(entry), maxSeenCount);
|
|
431
468
|
const relevance = relevanceFromTerms(entry, termSet);
|
|
432
|
-
|
|
469
|
+
const outcome = normalizeOutcome(entry?.outcome);
|
|
470
|
+
return (w.recency * recency + w.salience * salience + w.relevance * relevance + w.outcome * outcome) / total;
|
|
433
471
|
}
|
|
434
472
|
|
|
435
473
|
// A non-negative finite number, or the fallback. Guards a caller passing a
|
package/src/lessons-view.mjs
CHANGED
|
@@ -430,6 +430,57 @@ export function clusterDuplicates(entries = [], threshold = 0.8) {
|
|
|
430
430
|
return clusters;
|
|
431
431
|
}
|
|
432
432
|
|
|
433
|
+
// Compile a `--cluster-by-key` pattern into a stateless RegExp, or null on bad
|
|
434
|
+
// input (empty / non-string / unparseable) — never throws, mirroring
|
|
435
|
+
// `parseThreshold`'s "never crash on bad input" contract. The source is always a
|
|
436
|
+
// STRING, so `new RegExp(raw)` carries no flags and is stateless by construction
|
|
437
|
+
// — flag stripping is only needed on the RegExp branch of `clusterByKeyPattern`,
|
|
438
|
+
// which can be handed a caller-built `/…/g`. Pure.
|
|
439
|
+
export function compileKeyPattern(raw) {
|
|
440
|
+
if (typeof raw !== 'string' || raw.length === 0) return null;
|
|
441
|
+
try {
|
|
442
|
+
return new RegExp(raw);
|
|
443
|
+
} catch {
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Cluster entries by a shared KEY capture rather than value overlap. Two entries
|
|
449
|
+
// cluster iff their keys yield the SAME first capture group of `pattern` (or the
|
|
450
|
+
// same full match when the pattern has no capture group). This catches
|
|
451
|
+
// coordinate-key duplicate FAMILIES — many `bucket::pr{N}-{commentId}::slug` rows
|
|
452
|
+
// recorded for one review comment, whose slugs (and thus values) differ enough
|
|
453
|
+
// that `clusterDuplicates`'s Jaccard sweep never links them. Returns only 2+
|
|
454
|
+
// clusters, each `{ members: [{ scope, key }], size, keyGroup }`, largest first
|
|
455
|
+
// (ties broken by keyGroup for stable output). A key that doesn't match the
|
|
456
|
+
// pattern is left unclustered. Pure — O(n), no threshold.
|
|
457
|
+
export function clusterByKeyPattern(entries = [], pattern) {
|
|
458
|
+
const re =
|
|
459
|
+
pattern instanceof RegExp
|
|
460
|
+
? new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, ''))
|
|
461
|
+
: compileKeyPattern(pattern);
|
|
462
|
+
if (!re) return [];
|
|
463
|
+
const byGroup = new Map();
|
|
464
|
+
for (const e of entries) {
|
|
465
|
+
const key = e.key == null ? '' : String(e.key);
|
|
466
|
+
re.lastIndex = 0;
|
|
467
|
+
const m = re.exec(key);
|
|
468
|
+
if (!m) continue;
|
|
469
|
+
const group = m[1] ?? m[0];
|
|
470
|
+
if (!byGroup.has(group)) byGroup.set(group, []);
|
|
471
|
+
byGroup.get(group).push({ scope: e.scope ?? null, key });
|
|
472
|
+
}
|
|
473
|
+
const clusters = [];
|
|
474
|
+
for (const [keyGroup, members] of byGroup) {
|
|
475
|
+
if (members.length < 2) continue;
|
|
476
|
+
clusters.push({ members, size: members.length, keyGroup });
|
|
477
|
+
}
|
|
478
|
+
clusters.sort(
|
|
479
|
+
(x, y) => y.size - x.size || String(x.keyGroup).localeCompare(String(y.keyGroup)),
|
|
480
|
+
);
|
|
481
|
+
return clusters;
|
|
482
|
+
}
|
|
483
|
+
|
|
433
484
|
// Collect a store's non-archived entries for each scope, via the common
|
|
434
485
|
// `store.list({scope})` contract. Returns ordered per-scope groups plus a total
|
|
435
486
|
// — a per-scope read failure is captured on the group, never thrown, so one bad
|