@lorekit/cli 1.44.0 → 1.46.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 CHANGED
@@ -717,6 +717,26 @@ Both files share this schema — all fields optional:
717
717
  // memories are RANKED before the budget is spent, so what
718
718
  // survives is the most-recurring and most-recent, not the newest
719
719
 
720
+ "hooks.sessionStart.loopCap": 2,
721
+ // how many memories one self-improvement loop (a
722
+ // "loop::<bucket>" tag) may contribute to that block
723
+ // (default 2, bounded 0–40; 0 excludes loop buckets
724
+ // entirely so only general memories are read). Clamped,
725
+ // not rejected; repo wins over user with the same
726
+ // declared-value-owns-the-layer rule as maxChars
727
+
728
+ "hooks.sessionStart.branchHint": "on",
729
+ // whether the block is nudged toward the current git
730
+ // branch's topic — on "feat/embedding-pipeline",
731
+ // embedding memories are lifted (the leading type/author
732
+ // segment is ignored). Default "on"; it only ever lifts
733
+ // an on-topic memory, never buries one. "off" restores
734
+ // the plain most-recurring / most-recent read. Repo wins
735
+ // over user, but — following hooks.userPrompt, not the
736
+ // maxChars layer-lock — a declared-but-unparseable repo
737
+ // value FALLS THROUGH to a valid user value rather than
738
+ // owning the layer
739
+
720
740
  "hooks.adapter": "claude",
721
741
  // explicit adapter when auto-detection is ambiguous
722
742
  // values: "claude" | "cursor" | "codex"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lorekit/cli",
3
- "version": "1.44.0",
3
+ "version": "1.46.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": {
package/src/control.mjs CHANGED
@@ -10,6 +10,8 @@
10
10
  // hooks.stop — Stop-hook gating ("friction" default | "always" | "off")
11
11
  // hooks.sessionStart — injected-block shape ("hybrid" default | "index" | "map")
12
12
  // hooks.sessionStart.maxChars — character budget for that block (default 1500)
13
+ // hooks.sessionStart.loopCap — max lessons per self-improvement loop bucket (default 2; 0 excludes them)
14
+ // hooks.sessionStart.branchHint — nudge the read toward the git branch topic ("on" default | "off")
13
15
  // hooks.userPrompt — the per-turn relevance pull ("on" default | "off")
14
16
  // hooks.adapter — explicit adapter override ("claude" | "cursor" | "codex")
15
17
  //
@@ -140,6 +142,31 @@ export function normalizeSessionStartMaxChars(v) {
140
142
  return i;
141
143
  }
142
144
 
145
+ // The default per-loop-bucket cap for the SessionStart read, and the bounds a
146
+ // configured one is held to. 2 keeps each self-improvement loop's top couple of
147
+ // lessons without letting one bucket flood a general session; 0 is a meaningful
148
+ // setting — exclude loop buckets entirely and read only general codebase lessons.
149
+ // The ceiling is a generous backstop against a typo'd cap, not a shared constant:
150
+ // `core/lessons.mjs` bounds the whole read at its own hard lesson ceiling
151
+ // downstream, so any loopCap at or above that never binds regardless of the exact
152
+ // number here — they are deliberately independent, not kept in lockstep.
153
+ export const DEFAULT_SESSION_START_LOOP_CAP = 2;
154
+ export const MIN_SESSION_START_LOOP_CAP = 0;
155
+ export const MAX_SESSION_START_LOOP_CAP = 40;
156
+
157
+ // Clamp a configured loop cap into range, or null when it is not a usable number
158
+ // (absent, a bare string, NaN). Total: the caller substitutes the default for
159
+ // null. Out-of-range CLAMPS rather than rejecting, like the maxChars budget — and
160
+ // `0` is honoured, not floored away, because "exclude loop buckets" is a real ask.
161
+ export function normalizeSessionStartLoopCap(v) {
162
+ const n = firstNumber(v);
163
+ if (n === null) return null;
164
+ const i = Math.round(n);
165
+ if (i < MIN_SESSION_START_LOOP_CAP) return MIN_SESSION_START_LOOP_CAP;
166
+ if (i > MAX_SESSION_START_LOOP_CAP) return MAX_SESSION_START_LOOP_CAP;
167
+ return i;
168
+ }
169
+
143
170
  // A config value that is meant to be a number, or null when it is absent or is
144
171
  // something else entirely. Numeric strings are accepted because JSON configs get
145
172
  // hand-edited; the RANGE check happens later, at the point of use.
@@ -328,6 +355,26 @@ export function resolveControl({
328
355
  const hooksSessionStartMaxChars =
329
356
  normalizeSessionStartMaxChars(sessionStartMaxCharsRaw) ?? DEFAULT_SESSION_START_MAX_CHARS;
330
357
 
358
+ // `hooks.sessionStart.loopCap` — how many lessons one `loop::<bucket>` may
359
+ // contribute. Same layer-before-parse rule as maxChars (declaresScalar): a repo
360
+ // that declared a cap owns it even when the value is garbage, so two people on
361
+ // the same commit get the same read. `0` is a valid, deliberate value, so the
362
+ // default is only substituted when NOTHING usable was declared.
363
+ const sessionStartLoopCapRaw = declaresScalar(repoConfig['hooks.sessionStart.loopCap'])
364
+ ? repoConfig['hooks.sessionStart.loopCap']
365
+ : userConfig['hooks.sessionStart.loopCap'];
366
+ const normalizedLoopCap = normalizeSessionStartLoopCap(sessionStartLoopCapRaw);
367
+ const hooksSessionStartLoopCap =
368
+ normalizedLoopCap === null ? DEFAULT_SESSION_START_LOOP_CAP : normalizedLoopCap;
369
+
370
+ // `hooks.sessionStart.branchHint` — whether the read is nudged toward the git
371
+ // branch topic. On/off (the `hooks.userPrompt` vocabulary), default `on`, repo
372
+ // layer wins. Off restores the pre-branch-query read: recency + salience only.
373
+ const hooksSessionStartBranchHint =
374
+ normalizeUserPromptMode(repoConfig['hooks.sessionStart.branchHint']) ||
375
+ normalizeUserPromptMode(userConfig['hooks.sessionStart.branchHint']) ||
376
+ 'on';
377
+
331
378
  // `hooks.adapter` — repo layer wins over user layer (explicit project override).
332
379
  const hooksAdapter =
333
380
  (typeof repoConfig['hooks.adapter'] === 'string' && repoConfig['hooks.adapter'].trim()) ||
@@ -368,6 +415,8 @@ export function resolveControl({
368
415
  hooksUserPrompt,
369
416
  hooksSessionStart,
370
417
  hooksSessionStartMaxChars,
418
+ hooksSessionStartLoopCap,
419
+ hooksSessionStartBranchHint,
371
420
  hooksAdapter,
372
421
  hooksInstructions,
373
422
  };
@@ -75,7 +75,9 @@ const HARD_LESSON_CEILING = 40;
75
75
  // back by their own host through a tag filter. Two per bucket keeps the signal
76
76
  // (a loop's top couple of lessons still surface) without the flood; general,
77
77
  // non-loop lessons are never capped. Bounded, not shaped: on a store with no
78
- // loop lessons it never binds.
78
+ // loop lessons it never binds. This is the DEFAULT — a repo/user can override it
79
+ // with `hooks.sessionStart.loopCap` (0 excludes loop buckets entirely), which
80
+ // `fetchLessons` receives as its `loopCap` option.
79
81
  const SESSION_START_LOOP_CAP = 2;
80
82
 
81
83
  // How many lessons ride along with the scope map in `map` mode. Small on
@@ -111,8 +113,16 @@ const MAX_SCAN_CHARS = 4096;
111
113
  // is the follow-up that replaces this.
112
114
  export const SCOPE_READ_LIMIT = 25;
113
115
 
114
- export async function fetchLessons(store, cwd, { now = Date.now() } = {}) {
115
- const scope = deriveScope(cwd);
116
+ // `scope` may be injected instead of derived from `cwd` a seam for callers
117
+ // that already hold a resolved scope and for tests that need a deterministic
118
+ // branch (deriveScope shells out to git, so the ambient branch — often a
119
+ // detached `HEAD` in CI — cannot exercise the branch-seeded read otherwise).
120
+ export async function fetchLessons(
121
+ store,
122
+ cwd,
123
+ { now = Date.now(), scope: scopeOverride = null, loopCap = SESSION_START_LOOP_CAP, branchHint = true } = {},
124
+ ) {
125
+ const scope = scopeOverride || deriveScope(cwd);
116
126
  // Issued BEFORE the per-scope read loop and awaited after it. Nothing in the
117
127
  // inventory depends on the loop, so awaiting it afterwards would cost a
118
128
  // remote store one extra SERIAL round-trip on the session-start path; started
@@ -177,25 +187,28 @@ export async function fetchLessons(store, cwd, { now = Date.now() } = {}) {
177
187
  // narrow scope should instead be guaranteed floor space, that is a weighting
178
188
  // change in `rankLessons`, not something to re-derive here.
179
189
  //
180
- // `terms: []` is the SessionStart case: nothing has been asked yet, so the
181
- // relevance factor contributes nothing and the order is recency + salience.
182
- // `scopeOrder` is passed explicitly rather than left to the scorer's
183
- // first-appearance default they agree today, but the hierarchy is
184
- // `readOrder`'s to state, not an artefact of how this function happens to
185
- // build its array.
190
+ // The rank options (see `sessionRankOpts`): a relevance query distilled from
191
+ // the branch NAME, at the DEFAULT weight, so a session on `feat/embedding-…`
192
+ // nudges embedding lessons up. Relevance only ever LIFTS an on-topic lesson —
193
+ // a non-matching lesson scores relevance 0, so a branch that matches nothing
194
+ // does not reorder the read which is why it need not (and must not, per the
195
+ // Σweights normalisation) be damped by a smaller weight. A trunk branch or
196
+ // detached HEAD yields no terms, and the read is recency + salience exactly as
197
+ // before. `scopeOrder` is passed explicitly rather than left to the scorer's
198
+ // first-appearance default — the hierarchy is `readOrder`'s to state, not an
199
+ // artefact of how this function happens to build its array.
186
200
  // 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
201
+ // the two can never drift on terms, weights OR the `now` clock:
202
+ // `diversifyRankedLessons` recomputes each entry's score to seed the MMR
203
+ // objective, and scored with different options those would not line up with the
204
+ // sorted order. `k` is diversification-only; `scopeOrder` is ranking-only and
193
205
  // simply ignored by the diversifier's destructuring.
194
- const rankOpts = { terms: [], now, scopeOrder: scope.readOrder };
206
+ const rankOpts = sessionRankOpts(scope, now, { branchHint });
195
207
  const ranked = rankLessons(winners, rankOpts);
196
208
 
197
209
  // 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
210
+ // take more than `loopCap` (the `hooks.sessionStart.loopCap` option, default
211
+ // `SESSION_START_LOOP_CAP`) of the injected slots, so a general
199
212
  // session is not flooded with one bot's private `loop::<bucket>` bookkeeping.
200
213
  // General (non-loop) lessons pass through uncapped — they are what the cap
201
214
  // frees room for. Applied to the ranked list so the survivors are each
@@ -211,7 +224,7 @@ export async function fetchLessons(store, cwd, { now = Date.now() } = {}) {
211
224
  // which the bounded read never fetched. Reaching those is the recency-window
212
225
  // limit (the `order=rank` CANDIDATE_LIMIT problem, one scope down), not this
213
226
  // 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 });
227
+ const capped = capPerBucket(ranked, { cap: loopCap, bucketOf: loopBucketOf });
215
228
 
216
229
  // ── the scope map: EXACT counts when the store can enumerate ───────────────
217
230
  //
@@ -274,11 +287,12 @@ export async function fetchLessons(store, cwd, { now = Date.now() } = {}) {
274
287
  // session-start read. It seeds with the top-ranked lesson (score is still
275
288
  // 0.7 of the objective) and only spends the remaining 0.3 pushing down a
276
289
  // 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.
290
+ // the set stops being a wall of duplicates. Spreading `rankOpts` here (rather
291
+ // than restating terms/weights) is what keeps the diversifier's recomputed
292
+ // scores in agreement with the `rankLessons` sort above same terms, same
293
+ // branch-relevance weight, same `now`. The scope map and `applicable` still
294
+ // read from `ranked` — the map is a pointer to what EXISTS per scope, a
295
+ // question diversification does not change.
282
296
  //
283
297
  // `applicable` is the honest denominator for the header — how many the reader
284
298
  // has, as opposed to how many fitted. It is counted BEFORE the ceiling, so
@@ -541,6 +555,56 @@ export function distilTerms(text) {
541
555
  return terms;
542
556
  }
543
557
 
558
+ // Single-segment branch names that carry no topic: the trunk names a session is
559
+ // most often on. A `<segment>/…` branch always has a leading type/author segment
560
+ // (`feat`, `fix`, `claude`, `dependabot`, a username) that is never the topic —
561
+ // see `branchQueryTerms` — so those words don't need listing here; this set is
562
+ // only consulted for a branch with NO `/`.
563
+ const TRUNK_BRANCHES = new Set(['main', 'master', 'develop', 'trunk', 'head']);
564
+
565
+ // Distil a relevance query from the branch NAME only — owner/repo never enters,
566
+ // because `deriveScope` keeps the raw branch in `scope.branch`. The leading
567
+ // `/`-segment of a branch is a type or author by convention (`feat/…`,
568
+ // `dependabot/…`, `alice/…`) and never the topic, so it is dropped WHOLESALE when
569
+ // a `/` is present; the DESCRIPTION is then tokenised by the shared `distilTerms`
570
+ // (so `MIN_TERM_LEN`, dedupe and the FTS-safe shape apply). A word like `release`
571
+ // survives when it is in the description (`feat/release-notes`), because only the
572
+ // FIRST segment is removed. Empty for a bare trunk name, a detached `HEAD`, or no
573
+ // git — the read then behaves exactly as before. Pure and total.
574
+ export function branchQueryTerms(scope) {
575
+ const branch = scope && typeof scope.branch === 'string' ? scope.branch : '';
576
+ if (!branch || branch === 'HEAD') return [];
577
+ const slash = branch.indexOf('/');
578
+ if (slash === -1) {
579
+ // No prefix segment: a bare trunk name carries no topic; anything else is
580
+ // its own description.
581
+ return TRUNK_BRANCHES.has(branch.toLowerCase()) ? [] : distilTerms(branch);
582
+ }
583
+ return distilTerms(branch.slice(slash + 1));
584
+ }
585
+
586
+ // The rank options for a session-start read of `scope` at `now` — THE wiring
587
+ // seam, so the branch-seeding is unit-testable without a git checkout. The branch
588
+ // query rides at the DEFAULT relevance weight, exactly like the prompt/failure
589
+ // paths: it only ever LIFTS an on-topic lesson (a non-matching lesson scores
590
+ // relevance 0, so a branch that matches nothing is byte-for-byte the old read),
591
+ // and it is deliberately NOT damped by a smaller weight — reducing one factor's
592
+ // weight shrinks the normaliser (Σweights) and rescales every score, which then
593
+ // distorts the unscaled Jaccard term in `selectDiverse`'s MMR even for lessons
594
+ // the branch never matched. `fetchLessons` feeds the ONE object this returns to
595
+ // both `rankLessons` and (spread) the diversifier, so their scores agree on
596
+ // terms and the clock.
597
+ export function sessionRankOpts(scope, now, { branchHint = true } = {}) {
598
+ return {
599
+ // `branchHint: false` (config `hooks.sessionStart.branchHint: off`) restores
600
+ // the pre-branch-query read — no terms, so relevance contributes nothing and
601
+ // the order is recency + salience.
602
+ terms: branchHint ? branchQueryTerms(scope) : [],
603
+ now,
604
+ scopeOrder: scope && scope.readOrder ? scope.readOrder : null,
605
+ };
606
+ }
607
+
544
608
  // De-duplicate store-search hits by `scope::key` and cap them, PRESERVING the
545
609
  // store's order — which is NOT relevance ordering: the remote store filters by
546
610
  // FTS but orders by `updated_at desc` (recency), and the local one yields scope
package/src/hook.mjs CHANGED
@@ -115,7 +115,10 @@ async function run(args) {
115
115
  }
116
116
  return 0;
117
117
  }
118
- const { scope: readScope, lessons, scopeCounts, applicable } = await fetchLessons(store, root);
118
+ const { scope: readScope, lessons, scopeCounts, applicable } = await fetchLessons(store, root, {
119
+ loopCap: control.hooksSessionStartLoopCap,
120
+ branchHint: control.hooksSessionStartBranchHint !== 'off',
121
+ });
119
122
  emit(formatLessons(lessons, readScope, {
120
123
  instruction: sessionInstruction,
121
124
  mode: control.hooksSessionStart,