@lorekit/cli 1.42.0 → 1.43.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lorekit/cli",
3
- "version": "1.42.0",
3
+ "version": "1.43.0",
4
4
  "description": "Install the LoreKit shared-memory skill and run health checks for the LoreKit MCP server.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -12,7 +12,7 @@ import { deriveScope } from '../scope.mjs';
12
12
  // the injected set is chosen by ONE scorer, and a future `memory.relevant` verb
13
13
  // must be able to reuse it rather than grow a second ranking with its own idea
14
14
  // of what "most useful" means.
15
- import { resolvePrecedence, rankLessons } from '../lessons-pure.mjs';
15
+ import { resolvePrecedence, rankLessons, diversifyRankedLessons } from '../lessons-pure.mjs';
16
16
  // The store's own scope inventory, normalised — the SAME helper `memory.scopes`
17
17
  // uses, so the map and the MCP tool cannot disagree about what a scope holds or
18
18
  // about what a failed enumeration looks like.
@@ -167,7 +167,16 @@ export async function fetchLessons(store, cwd, { now = Date.now() } = {}) {
167
167
  // first-appearance default — they agree today, but the hierarchy is
168
168
  // `readOrder`'s to state, not an artefact of how this function happens to
169
169
  // build its array.
170
- const ranked = rankLessons(winners, { terms: [], now, scopeOrder: scope.readOrder });
170
+ // ONE options object feeds both the ranking and the diversification below, so
171
+ // the two can never drift: `diversifyRankedLessons` recomputes each entry's
172
+ // score to seed the MMR objective, and if its `terms`/`now`/`weights` differed
173
+ // from what `rankLessons` sorted on, those scores would not line up with the
174
+ // order — the near-identical `now` clock especially. Sharing the object makes
175
+ // that agreement structural rather than a thing two call sites have to keep in
176
+ // step by hand. `k` is diversification-only; `scopeOrder` is ranking-only and
177
+ // simply ignored by the diversifier's destructuring.
178
+ const rankOpts = { terms: [], now, scopeOrder: scope.readOrder };
179
+ const ranked = rankLessons(winners, rankOpts);
171
180
 
172
181
  // ── the scope map: EXACT counts when the store can enumerate ───────────────
173
182
  //
@@ -219,12 +228,29 @@ export async function fetchLessons(store, cwd, { now = Date.now() } = {}) {
219
228
  ? scopeInventoryFromStore(inventory.scopes, scope.readOrder, derivedCounts)
220
229
  : derivedCounts;
221
230
 
231
+ // DIVERSIFY before the ceiling, so the budget is not spent on near-identical
232
+ // lessons. Ranking answers "which lessons score highest"; on an active repo
233
+ // the highest cluster is often one task's iteration log — a dozen
234
+ // `review-outcomes::pr395-it{3,4,5}` rows that score alike AND read alike, so
235
+ // a plain top-N hands the reader the same lesson several times and evicts the
236
+ // variety underneath. `diversifyRankedLessons` applies the SAME MMR
237
+ // (`selectDiverse`, λ=0.7 lexical Jaccard) the hosted `order=rank` path
238
+ // already uses, which was defined and exported here but never wired into the
239
+ // session-start read. It seeds with the top-ranked lesson (score is still
240
+ // 0.7 of the objective) and only spends the remaining 0.3 pushing down a
241
+ // lesson that repeats one already shown — so the best lesson stays first and
242
+ // the set stops being a wall of duplicates. `terms: []` matches the
243
+ // `rankLessons` call above (relevance contributes nothing at session start),
244
+ // which the scores MUST agree with. The scope map and `applicable` still read
245
+ // from `ranked` — the map is a pointer to what EXISTS per scope, a question
246
+ // diversification does not change.
247
+ //
222
248
  // `applicable` is the honest denominator for the header — how many the reader
223
249
  // has, as opposed to how many fitted. It is counted BEFORE the ceiling, so
224
250
  // "8 of 50" stays true no matter how the render is bounded.
225
251
  return {
226
252
  scope,
227
- lessons: ranked.slice(0, HARD_LESSON_CEILING),
253
+ lessons: diversifyRankedLessons(ranked, { ...rankOpts, k: HARD_LESSON_CEILING }),
228
254
  scopeCounts,
229
255
  applicable: ranked.length,
230
256
  };
@@ -706,3 +706,42 @@ export function rankLessons(entries = [], {
706
706
  return scored.map((s) => s.entry);
707
707
  }
708
708
 
709
+ /**
710
+ * Rank-then-diversify in one call — the `.mjs` twin's convenience for what the
711
+ * TS twin gets for free by carrying `{ entry, score }` pairs into `selectDiverse`.
712
+ *
713
+ * `selectDiverse` needs a parallel `scores` array, and those scores MUST be the
714
+ * same set-relative values the list was sorted on: the salience factor is
715
+ * normalised against the max `seen_count` in the candidate SET, so a score
716
+ * recomputed over a different population would not line up with the order. This
717
+ * helper recomputes the scores over exactly the list it diversifies, beside the
718
+ * unexported `seenCountFrom`/`scoreWithTerms`, so callers can apply MMR without
719
+ * reconstructing that alignment (and without `seenCountFrom` leaking out).
720
+ *
721
+ * `entries` is expected to be `rankLessons` output (best-first) so the seed of
722
+ * the greedy MMR is the top-ranked lesson; the pass-through
723
+ * `terms`/`weights`/`halfLifeDays`/`now` MUST match the `rankLessons` call that
724
+ * produced it, or the recomputed scores diverge from the sort. `k` caps the
725
+ * returned count (default: all). Empty/degenerate input returns `[]`.
726
+ */
727
+ export function diversifyRankedLessons(entries = [], {
728
+ terms = [],
729
+ now = Date.now(),
730
+ weights = DEFAULT_RANK_WEIGHTS,
731
+ halfLifeDays = RECENCY_HALF_LIFE_DAYS,
732
+ k = Infinity,
733
+ lambda,
734
+ } = {}) {
735
+ const list = Array.isArray(entries) ? entries.filter((e) => e && typeof e === 'object') : [];
736
+ if (list.length === 0) return [];
737
+ const termSet = distinctTerms(terms);
738
+ let maxSeenCount = 0;
739
+ for (const e of list) maxSeenCount = Math.max(maxSeenCount, seenCountFrom(e));
740
+ const scores = list.map((e) => scoreWithTerms(e, termSet, { now, weights, maxSeenCount, halfLifeDays }));
741
+ // `numberOr` is the module's coercion convention: a non-finite `k` (the
742
+ // `Infinity` default, `null`, or a stringy `'40'`) resolves to a real cap —
743
+ // the default falls through to the whole list, `'40'` becomes 40 — rather than
744
+ // silently returning everything on a shape a caller plausibly passes.
745
+ const limit = numberOr(k, list.length);
746
+ return selectDiverse(list, limit, { scores, lambda });
747
+ }