@lorekit/cli 1.43.0 → 1.45.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.45.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.
@@ -95,8 +111,12 @@ const MAX_SCAN_CHARS = 4096;
95
111
  // is the follow-up that replaces this.
96
112
  export const SCOPE_READ_LIMIT = 25;
97
113
 
98
- export async function fetchLessons(store, cwd, { now = Date.now() } = {}) {
99
- const scope = deriveScope(cwd);
114
+ // `scope` may be injected instead of derived from `cwd` a seam for callers
115
+ // that already hold a resolved scope and for tests that need a deterministic
116
+ // branch (deriveScope shells out to git, so the ambient branch — often a
117
+ // detached `HEAD` in CI — cannot exercise the branch-seeded read otherwise).
118
+ export async function fetchLessons(store, cwd, { now = Date.now(), scope: scopeOverride = null } = {}) {
119
+ const scope = scopeOverride || deriveScope(cwd);
100
120
  // Issued BEFORE the per-scope read loop and awaited after it. Nothing in the
101
121
  // inventory depends on the loop, so awaiting it afterwards would cost a
102
122
  // remote store one extra SERIAL round-trip on the session-start path; started
@@ -161,23 +181,44 @@ export async function fetchLessons(store, cwd, { now = Date.now() } = {}) {
161
181
  // narrow scope should instead be guaranteed floor space, that is a weighting
162
182
  // change in `rankLessons`, not something to re-derive here.
163
183
  //
164
- // `terms: []` is the SessionStart case: nothing has been asked yet, so the
165
- // relevance factor contributes nothing and the order is recency + salience.
166
- // `scopeOrder` is passed explicitly rather than left to the scorer's
167
- // first-appearance default they agree today, but the hierarchy is
168
- // `readOrder`'s to state, not an artefact of how this function happens to
169
- // build its array.
184
+ // The rank options (see `sessionRankOpts`): a relevance query distilled from
185
+ // the branch NAME, at the DEFAULT weight, so a session on `feat/embedding-…`
186
+ // nudges embedding lessons up. Relevance only ever LIFTS an on-topic lesson —
187
+ // a non-matching lesson scores relevance 0, so a branch that matches nothing
188
+ // does not reorder the read which is why it need not (and must not, per the
189
+ // Σweights normalisation) be damped by a smaller weight. A trunk branch or
190
+ // detached HEAD yields no terms, and the read is recency + salience exactly as
191
+ // before. `scopeOrder` is passed explicitly rather than left to the scorer's
192
+ // first-appearance default — the hierarchy is `readOrder`'s to state, not an
193
+ // artefact of how this function happens to build its array.
170
194
  // 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
195
+ // the two can never drift on terms, weights OR the `now` clock:
196
+ // `diversifyRankedLessons` recomputes each entry's score to seed the MMR
197
+ // objective, and scored with different options those would not line up with the
198
+ // sorted order. `k` is diversification-only; `scopeOrder` is ranking-only and
177
199
  // simply ignored by the diversifier's destructuring.
178
- const rankOpts = { terms: [], now, scopeOrder: scope.readOrder };
200
+ const rankOpts = sessionRankOpts(scope, now);
179
201
  const ranked = rankLessons(winners, rankOpts);
180
202
 
203
+ // AUDIENCE CAP before diversification: no single self-improvement loop may
204
+ // take more than `SESSION_START_LOOP_CAP` of the injected slots, so a general
205
+ // session is not flooded with one bot's private `loop::<bucket>` bookkeeping.
206
+ // General (non-loop) lessons pass through uncapped — they are what the cap
207
+ // frees room for. Applied to the ranked list so the survivors are each
208
+ // bucket's HIGHEST-ranked few, then diversified below. The scope map and
209
+ // `applicable` still read from the full `ranked` set — the cap governs what is
210
+ // shown, not the honest count of what exists per scope.
211
+ //
212
+ // WHERE THE FREED SLOTS FILL FROM, stated so the cap is not oversold. Each
213
+ // scope is read only to its newest `SCOPE_READ_LIMIT`, so on a scope whose
214
+ // recent writes are ALL one loop's, the general lessons that fill the freed
215
+ // slots come from the OTHER scopes in `readOrder` (a repo's loop churn makes
216
+ // room for `global` principles) — not from that same scope's older generals,
217
+ // which the bounded read never fetched. Reaching those is the recency-window
218
+ // limit (the `order=rank` CANDIDATE_LIMIT problem, one scope down), not this
219
+ // cap's to solve; the cap still does its job of unflooding across scopes.
220
+ const capped = capPerBucket(ranked, { cap: SESSION_START_LOOP_CAP, bucketOf: loopBucketOf });
221
+
181
222
  // ── the scope map: EXACT counts when the store can enumerate ───────────────
182
223
  //
183
224
  // The map's job is to tell a reader how much lore is sitting in each scope
@@ -239,18 +280,19 @@ export async function fetchLessons(store, cwd, { now = Date.now() } = {}) {
239
280
  // session-start read. It seeds with the top-ranked lesson (score is still
240
281
  // 0.7 of the objective) and only spends the remaining 0.3 pushing down a
241
282
  // 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.
283
+ // the set stops being a wall of duplicates. Spreading `rankOpts` here (rather
284
+ // than restating terms/weights) is what keeps the diversifier's recomputed
285
+ // scores in agreement with the `rankLessons` sort above same terms, same
286
+ // branch-relevance weight, same `now`. The scope map and `applicable` still
287
+ // read from `ranked` — the map is a pointer to what EXISTS per scope, a
288
+ // question diversification does not change.
247
289
  //
248
290
  // `applicable` is the honest denominator for the header — how many the reader
249
291
  // has, as opposed to how many fitted. It is counted BEFORE the ceiling, so
250
292
  // "8 of 50" stays true no matter how the render is bounded.
251
293
  return {
252
294
  scope,
253
- lessons: diversifyRankedLessons(ranked, { ...rankOpts, k: HARD_LESSON_CEILING }),
295
+ lessons: diversifyRankedLessons(capped, { ...rankOpts, k: HARD_LESSON_CEILING }),
254
296
  scopeCounts,
255
297
  applicable: ranked.length,
256
298
  };
@@ -506,6 +548,53 @@ export function distilTerms(text) {
506
548
  return terms;
507
549
  }
508
550
 
551
+ // Single-segment branch names that carry no topic: the trunk names a session is
552
+ // most often on. A `<segment>/…` branch always has a leading type/author segment
553
+ // (`feat`, `fix`, `claude`, `dependabot`, a username) that is never the topic —
554
+ // see `branchQueryTerms` — so those words don't need listing here; this set is
555
+ // only consulted for a branch with NO `/`.
556
+ const TRUNK_BRANCHES = new Set(['main', 'master', 'develop', 'trunk', 'head']);
557
+
558
+ // Distil a relevance query from the branch NAME only — owner/repo never enters,
559
+ // because `deriveScope` keeps the raw branch in `scope.branch`. The leading
560
+ // `/`-segment of a branch is a type or author by convention (`feat/…`,
561
+ // `dependabot/…`, `alice/…`) and never the topic, so it is dropped WHOLESALE when
562
+ // a `/` is present; the DESCRIPTION is then tokenised by the shared `distilTerms`
563
+ // (so `MIN_TERM_LEN`, dedupe and the FTS-safe shape apply). A word like `release`
564
+ // survives when it is in the description (`feat/release-notes`), because only the
565
+ // FIRST segment is removed. Empty for a bare trunk name, a detached `HEAD`, or no
566
+ // git — the read then behaves exactly as before. Pure and total.
567
+ export function branchQueryTerms(scope) {
568
+ const branch = scope && typeof scope.branch === 'string' ? scope.branch : '';
569
+ if (!branch || branch === 'HEAD') return [];
570
+ const slash = branch.indexOf('/');
571
+ if (slash === -1) {
572
+ // No prefix segment: a bare trunk name carries no topic; anything else is
573
+ // its own description.
574
+ return TRUNK_BRANCHES.has(branch.toLowerCase()) ? [] : distilTerms(branch);
575
+ }
576
+ return distilTerms(branch.slice(slash + 1));
577
+ }
578
+
579
+ // The rank options for a session-start read of `scope` at `now` — THE wiring
580
+ // seam, so the branch-seeding is unit-testable without a git checkout. The branch
581
+ // query rides at the DEFAULT relevance weight, exactly like the prompt/failure
582
+ // paths: it only ever LIFTS an on-topic lesson (a non-matching lesson scores
583
+ // relevance 0, so a branch that matches nothing is byte-for-byte the old read),
584
+ // and it is deliberately NOT damped by a smaller weight — reducing one factor's
585
+ // weight shrinks the normaliser (Σweights) and rescales every score, which then
586
+ // distorts the unscaled Jaccard term in `selectDiverse`'s MMR even for lessons
587
+ // the branch never matched. `fetchLessons` feeds the ONE object this returns to
588
+ // both `rankLessons` and (spread) the diversifier, so their scores agree on
589
+ // terms and the clock.
590
+ export function sessionRankOpts(scope, now) {
591
+ return {
592
+ terms: branchQueryTerms(scope),
593
+ now,
594
+ scopeOrder: scope && scope.readOrder ? scope.readOrder : null,
595
+ };
596
+ }
597
+
509
598
  // De-duplicate store-search hits by `scope::key` and cap them, PRESERVING the
510
599
  // store's order — which is NOT relevance ordering: the remote store filters by
511
600
  // FTS but orders by `updated_at desc` (recency), and the local one yields scope
@@ -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
+ }