@lorekit/cli 1.42.0 → 1.44.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 +1 -1
- package/src/core/lessons.mjs +64 -3
- package/src/lessons-pure.mjs +101 -0
package/package.json
CHANGED
package/src/core/lessons.mjs
CHANGED
|
@@ -12,7 +12,9 @@ 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 {
|
|
15
|
+
import {
|
|
16
|
+
resolvePrecedence, rankLessons, diversifyRankedLessons, capPerBucket, loopBucketOf,
|
|
17
|
+
} from '../lessons-pure.mjs';
|
|
16
18
|
// The store's own scope inventory, normalised — the SAME helper `memory.scopes`
|
|
17
19
|
// uses, so the map and the MCP tool cannot disagree about what a scope holds or
|
|
18
20
|
// about what a failed enumeration looks like.
|
|
@@ -62,6 +64,20 @@ import { FRICTION_FAILURE, FRICTION_STUCK_LOOP } from './friction.mjs';
|
|
|
62
64
|
// shape the common one.
|
|
63
65
|
const HARD_LESSON_CEILING = 40;
|
|
64
66
|
|
|
67
|
+
// How many lessons any ONE self-improvement loop (`loop::<bucket>` tag) may
|
|
68
|
+
// contribute to a session-start injection. A prolific loop — the pr-reviewer's
|
|
69
|
+
// `loop::review-outcomes` / `loop::reviewer-comment-relevance`, or
|
|
70
|
+
// `loop::implement-suggestion-lessons` — writes constantly and recently, so it
|
|
71
|
+
// wins recency AND (being built to recur) salience, and a whole scope's read
|
|
72
|
+
// can collapse to one bot's private bookkeeping (observed: 13 of 15 slots).
|
|
73
|
+
// Ranking and MMR cannot fix that — the flood is real, varied, and high-scoring
|
|
74
|
+
// — but it is not what a GENERAL coding session needs; those lessons are read
|
|
75
|
+
// back by their own host through a tag filter. Two per bucket keeps the signal
|
|
76
|
+
// (a loop's top couple of lessons still surface) without the flood; general,
|
|
77
|
+
// non-loop lessons are never capped. Bounded, not shaped: on a store with no
|
|
78
|
+
// loop lessons it never binds.
|
|
79
|
+
const SESSION_START_LOOP_CAP = 2;
|
|
80
|
+
|
|
65
81
|
// How many lessons ride along with the scope map in `map` mode. Small on
|
|
66
82
|
// purpose: the point of that shape is the inventory, and a "map" that is mostly
|
|
67
83
|
// lessons is just `index` with extra steps.
|
|
@@ -167,7 +183,35 @@ export async function fetchLessons(store, cwd, { now = Date.now() } = {}) {
|
|
|
167
183
|
// first-appearance default — they agree today, but the hierarchy is
|
|
168
184
|
// `readOrder`'s to state, not an artefact of how this function happens to
|
|
169
185
|
// build its array.
|
|
170
|
-
|
|
186
|
+
// ONE options object feeds both the ranking and the diversification below, so
|
|
187
|
+
// the two can never drift: `diversifyRankedLessons` recomputes each entry's
|
|
188
|
+
// score to seed the MMR objective, and if its `terms`/`now`/`weights` differed
|
|
189
|
+
// from what `rankLessons` sorted on, those scores would not line up with the
|
|
190
|
+
// order — the near-identical `now` clock especially. Sharing the object makes
|
|
191
|
+
// that agreement structural rather than a thing two call sites have to keep in
|
|
192
|
+
// step by hand. `k` is diversification-only; `scopeOrder` is ranking-only and
|
|
193
|
+
// simply ignored by the diversifier's destructuring.
|
|
194
|
+
const rankOpts = { terms: [], now, scopeOrder: scope.readOrder };
|
|
195
|
+
const ranked = rankLessons(winners, rankOpts);
|
|
196
|
+
|
|
197
|
+
// AUDIENCE CAP before diversification: no single self-improvement loop may
|
|
198
|
+
// take more than `SESSION_START_LOOP_CAP` of the injected slots, so a general
|
|
199
|
+
// session is not flooded with one bot's private `loop::<bucket>` bookkeeping.
|
|
200
|
+
// General (non-loop) lessons pass through uncapped — they are what the cap
|
|
201
|
+
// frees room for. Applied to the ranked list so the survivors are each
|
|
202
|
+
// bucket's HIGHEST-ranked few, then diversified below. The scope map and
|
|
203
|
+
// `applicable` still read from the full `ranked` set — the cap governs what is
|
|
204
|
+
// shown, not the honest count of what exists per scope.
|
|
205
|
+
//
|
|
206
|
+
// WHERE THE FREED SLOTS FILL FROM, stated so the cap is not oversold. Each
|
|
207
|
+
// scope is read only to its newest `SCOPE_READ_LIMIT`, so on a scope whose
|
|
208
|
+
// recent writes are ALL one loop's, the general lessons that fill the freed
|
|
209
|
+
// slots come from the OTHER scopes in `readOrder` (a repo's loop churn makes
|
|
210
|
+
// room for `global` principles) — not from that same scope's older generals,
|
|
211
|
+
// which the bounded read never fetched. Reaching those is the recency-window
|
|
212
|
+
// limit (the `order=rank` CANDIDATE_LIMIT problem, one scope down), not this
|
|
213
|
+
// cap's to solve; the cap still does its job of unflooding across scopes.
|
|
214
|
+
const capped = capPerBucket(ranked, { cap: SESSION_START_LOOP_CAP, bucketOf: loopBucketOf });
|
|
171
215
|
|
|
172
216
|
// ── the scope map: EXACT counts when the store can enumerate ───────────────
|
|
173
217
|
//
|
|
@@ -219,12 +263,29 @@ export async function fetchLessons(store, cwd, { now = Date.now() } = {}) {
|
|
|
219
263
|
? scopeInventoryFromStore(inventory.scopes, scope.readOrder, derivedCounts)
|
|
220
264
|
: derivedCounts;
|
|
221
265
|
|
|
266
|
+
// DIVERSIFY before the ceiling, so the budget is not spent on near-identical
|
|
267
|
+
// lessons. Ranking answers "which lessons score highest"; on an active repo
|
|
268
|
+
// the highest cluster is often one task's iteration log — a dozen
|
|
269
|
+
// `review-outcomes::pr395-it{3,4,5}` rows that score alike AND read alike, so
|
|
270
|
+
// a plain top-N hands the reader the same lesson several times and evicts the
|
|
271
|
+
// variety underneath. `diversifyRankedLessons` applies the SAME MMR
|
|
272
|
+
// (`selectDiverse`, λ=0.7 lexical Jaccard) the hosted `order=rank` path
|
|
273
|
+
// already uses, which was defined and exported here but never wired into the
|
|
274
|
+
// session-start read. It seeds with the top-ranked lesson (score is still
|
|
275
|
+
// 0.7 of the objective) and only spends the remaining 0.3 pushing down a
|
|
276
|
+
// lesson that repeats one already shown — so the best lesson stays first and
|
|
277
|
+
// the set stops being a wall of duplicates. `terms: []` matches the
|
|
278
|
+
// `rankLessons` call above (relevance contributes nothing at session start),
|
|
279
|
+
// which the scores MUST agree with. The scope map and `applicable` still read
|
|
280
|
+
// from `ranked` — the map is a pointer to what EXISTS per scope, a question
|
|
281
|
+
// diversification does not change.
|
|
282
|
+
//
|
|
222
283
|
// `applicable` is the honest denominator for the header — how many the reader
|
|
223
284
|
// has, as opposed to how many fitted. It is counted BEFORE the ceiling, so
|
|
224
285
|
// "8 of 50" stays true no matter how the render is bounded.
|
|
225
286
|
return {
|
|
226
287
|
scope,
|
|
227
|
-
lessons:
|
|
288
|
+
lessons: diversifyRankedLessons(capped, { ...rankOpts, k: HARD_LESSON_CEILING }),
|
|
228
289
|
scopeCounts,
|
|
229
290
|
applicable: ranked.length,
|
|
230
291
|
};
|
package/src/lessons-pure.mjs
CHANGED
|
@@ -706,3 +706,104 @@ 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
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* The loop BUCKET a lesson belongs to, or null for a general (non-loop) lesson.
|
|
751
|
+
*
|
|
752
|
+
* A self-improvement loop writes into a `loop::<bucket>` tag namespace
|
|
753
|
+
* (`loop::review-outcomes`, `loop::implement-suggestion-lessons`, …) — the
|
|
754
|
+
* bucket convention `lorekit-setup` installs. Those lessons are a host's PRIVATE
|
|
755
|
+
* working memory, read back by that host through a tag filter; a general session
|
|
756
|
+
* reading a whole scope should not let one prolific loop's bookkeeping take
|
|
757
|
+
* every slot. Returns the first `loop::`-prefixed tag — the group key a cap
|
|
758
|
+
* counts against, matching `inferKindHost`'s first-recognised-wins order — or
|
|
759
|
+
* null when the lesson carries no loop tag (general knowledge, never capped).
|
|
760
|
+
* Keys on the `loop::` PREFIX convention ONLY; it re-encodes no specific bucket
|
|
761
|
+
* name, so a new loop bucket groups correctly without a code change here.
|
|
762
|
+
*/
|
|
763
|
+
export function loopBucketOf(entry) {
|
|
764
|
+
const tags = Array.isArray(entry?.tags) ? entry.tags : [];
|
|
765
|
+
for (const t of tags) {
|
|
766
|
+
if (typeof t !== 'string') continue;
|
|
767
|
+
const tag = t.trim();
|
|
768
|
+
if (tag.startsWith('loop::') && tag.length > 'loop::'.length) return tag;
|
|
769
|
+
}
|
|
770
|
+
return null;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* Cap how many lessons any one bucket may contribute, preserving input order.
|
|
775
|
+
*
|
|
776
|
+
* Walks the (already-ranked) list once: a lesson whose `bucketOf` is null is
|
|
777
|
+
* ALWAYS kept — those are the general lessons the cap exists to protect — and a
|
|
778
|
+
* bucketed lesson is kept only while its bucket is still under `cap`. So one
|
|
779
|
+
* loop's dozen recent rows no longer evict every general lesson; at most `cap`
|
|
780
|
+
* of them survive and the freed slots go to the next-ranked variety. Pure and
|
|
781
|
+
* total: a non-array input is []; `cap: 0` drops every bucketed lesson (read
|
|
782
|
+
* ONLY general knowledge) while still keeping the null-bucket ones — a negative
|
|
783
|
+
* cap is not finite-and-non-negative, so `numberOr` reads it as "no cap", not as
|
|
784
|
+
* a stricter zero; a missing
|
|
785
|
+
* `bucketOf` treats everything as general (a no-op cap). `cap` is coerced with
|
|
786
|
+
* the module's `numberOr` convention (as `diversifyRankedLessons` does for `k`),
|
|
787
|
+
* so a `NaN`/absent cap falls back to "no cap" rather than silently dropping
|
|
788
|
+
* every bucketed lesson, while a stringy `'2'` still caps.
|
|
789
|
+
*/
|
|
790
|
+
export function capPerBucket(entries, { cap = Infinity, bucketOf } = {}) {
|
|
791
|
+
if (!Array.isArray(entries)) return [];
|
|
792
|
+
const of = typeof bucketOf === 'function' ? bucketOf : () => null;
|
|
793
|
+
const limit = numberOr(cap, Infinity);
|
|
794
|
+
const counts = new Map();
|
|
795
|
+
const out = [];
|
|
796
|
+
for (const e of entries) {
|
|
797
|
+
const bucket = of(e);
|
|
798
|
+
if (bucket == null) {
|
|
799
|
+
out.push(e);
|
|
800
|
+
continue;
|
|
801
|
+
}
|
|
802
|
+
const n = counts.get(bucket) ?? 0;
|
|
803
|
+
if (n < limit) {
|
|
804
|
+
counts.set(bucket, n + 1);
|
|
805
|
+
out.push(e);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
return out;
|
|
809
|
+
}
|