@lorekit/cli 1.43.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lorekit/cli",
3
- "version": "1.43.0",
3
+ "version": "1.44.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,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 { resolvePrecedence, rankLessons, diversifyRankedLessons } from '../lessons-pure.mjs';
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.
@@ -178,6 +194,25 @@ export async function fetchLessons(store, cwd, { now = Date.now() } = {}) {
178
194
  const rankOpts = { terms: [], now, scopeOrder: scope.readOrder };
179
195
  const ranked = rankLessons(winners, rankOpts);
180
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 });
215
+
181
216
  // ── the scope map: EXACT counts when the store can enumerate ───────────────
182
217
  //
183
218
  // The map's job is to tell a reader how much lore is sitting in each scope
@@ -250,7 +285,7 @@ export async function fetchLessons(store, cwd, { now = Date.now() } = {}) {
250
285
  // "8 of 50" stays true no matter how the render is bounded.
251
286
  return {
252
287
  scope,
253
- lessons: diversifyRankedLessons(ranked, { ...rankOpts, k: HARD_LESSON_CEILING }),
288
+ lessons: diversifyRankedLessons(capped, { ...rankOpts, k: HARD_LESSON_CEILING }),
254
289
  scopeCounts,
255
290
  applicable: ranked.length,
256
291
  };
@@ -745,3 +745,65 @@ export function diversifyRankedLessons(entries = [], {
745
745
  const limit = numberOr(k, list.length);
746
746
  return selectDiverse(list, limit, { scores, lambda });
747
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
+ }