@lorekit/cli 1.48.0 → 1.50.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
@@ -706,9 +706,9 @@ Both files share this schema — all fields optional:
706
706
  // next layer is tried, so a mistyped repo value falls through to
707
707
  // the user layer before defaulting to hybrid
708
708
 
709
- "hooks.sessionStart.maxChars": 1500,
710
- // character budget for that block (default 1500, ~375 tokens)
711
- // bounded to 200–20000; an out-of-range value is CLAMPED, not
709
+ "hooks.sessionStart.maxChars": 3000,
710
+ // character budget for that block (default 3000, ~750 tokens,
711
+ // ~25 index lines); bounded 200–20000, out-of-range CLAMPED not
712
712
  // rejected — a small number means "keep it short", and honouring
713
713
  // the floor is closer to that intent than restoring the default
714
714
  // repo wins over user, and a declared-but-unparseable repo value
@@ -717,11 +717,34 @@ 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,
720
+ "hooks.sessionStart.maxLessons": 100,
721
+ // how many memory LINES that block may hold, where
722
+ // maxChars bounds its characters — whichever binds
723
+ // first decides the block, so raising this alone does
724
+ // nothing unless maxChars comes up with it
725
+ // (default 100, bounded 3–200). Clamped, not
726
+ // rejected; repo wins over user with the same
727
+ // declared-value-owns-the-layer rule as maxChars.
728
+ // In practice this is a READ-DEPTH dial, not a size
729
+ // one: maxChars runs out around line 25, so what
730
+ // this really sets is the per-scope candidate fetch
731
+ // (100/scope by default) the ranker chooses from —
732
+ // 400 candidates across a four-scope hierarchy
733
+ // instead of the newest handful. It never exceeds
734
+ // 100/scope, the largest page GET /memories will
735
+ // return, so a ceiling above 100 fills its remaining
736
+ // lines from the other scopes instead. Below 25 the
737
+ // read does not shrink; you just see fewer lines.
738
+
739
+ "hooks.sessionStart.loopCap": 1,
721
740
  // how many memories one self-improvement loop (a
722
741
  // "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,
742
+ // (default 1, bounded 0–40; 0 excludes loop buckets
743
+ // entirely so only general memories are read). One per
744
+ // bucket keeps a loop's best lesson without letting its
745
+ // bookkeeping take a second slot from your codebase's
746
+ // memories, which are read by a different audience.
747
+ // Clamped,
725
748
  // not rejected; repo wins over user with the same
726
749
  // declared-value-owns-the-layer rule as maxChars
727
750
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lorekit/cli",
3
- "version": "1.48.0",
3
+ "version": "1.50.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
@@ -9,8 +9,9 @@
9
9
  // hooks.disabled — array of hook event names to suppress (e.g. ["Stop"])
10
10
  // hooks.stop — Stop-hook gating ("friction" default | "always" | "off")
11
11
  // hooks.sessionStart — injected-block shape ("hybrid" default | "index" | "map")
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)
12
+ // hooks.sessionStart.maxChars — character budget for that block (default 3000)
13
+ // hooks.sessionStart.loopCap — max lessons per self-improvement loop bucket (default 1; 0 excludes them)
14
+ // hooks.sessionStart.maxLessons — max LINES that block may hold (default 100, range 3–200)
14
15
  // hooks.sessionStart.branchHint — nudge the read toward the git branch topic ("on" default | "off")
15
16
  // hooks.userPrompt — the per-turn relevance pull ("on" default | "off")
16
17
  // hooks.adapter — explicit adapter override ("claude" | "cursor" | "codex")
@@ -103,16 +104,19 @@ export function normalizeSessionStartMode(v) {
103
104
  }
104
105
 
105
106
  // The default SessionStart character budget, and the bounds a configured one is
106
- // held to. ~1500 chars is roughly 375 tokens on the 4-chars-per-token heuristic
107
- // — enough for a dozen index lines plus the frame, and small enough that it
108
- // stays a footnote in a context window rather than a section of it.
107
+ // held to. ~3000 chars is roughly 750 tokens on the 4-chars-per-token heuristic
108
+ // — around 25 index lines plus the frame. That is the number that decides how
109
+ // much of the agent's window this costs, so it is the one tuned for value per
110
+ // token rather than for smallness: a dozen lines (the old 1500) routinely showed
111
+ // a store's newest churn and nothing that had been re-learned, which reads as
112
+ // noise and trains the reader to skim the block.
109
113
  //
110
114
  // The floor is what one header plus one lesson line needs; below it the block
111
115
  // would be a header and nothing else, which is worse than not firing. The
112
116
  // ceiling is a backstop against a typo'd `"maxChars": 1500000` turning every
113
117
  // session start into a wall of text — the hard lesson ceiling in
114
118
  // `core/lessons.mjs` bounds it a second time, from the other direction.
115
- export const DEFAULT_SESSION_START_MAX_CHARS = 1500;
119
+ export const DEFAULT_SESSION_START_MAX_CHARS = 3000;
116
120
  export const MIN_SESSION_START_MAX_CHARS = 200;
117
121
  export const MAX_SESSION_START_MAX_CHARS = 20000;
118
122
 
@@ -132,7 +136,7 @@ export const HOOK_INSTRUCTION_EVENTS = [
132
136
  // not a usable number at all (absent, a bare string, NaN). Total: the caller
133
137
  // substitutes the default for null. Out-of-range CLAMPS rather than rejecting —
134
138
  // a user who wrote `"maxChars": 50` wants a small block, and honouring the floor
135
- // is closer to that intent than silently restoring the 1500 default.
139
+ // is closer to that intent than silently restoring the default.
136
140
  export function normalizeSessionStartMaxChars(v) {
137
141
  const n = firstNumber(v);
138
142
  if (n === null) return null;
@@ -143,14 +147,31 @@ export function normalizeSessionStartMaxChars(v) {
143
147
  }
144
148
 
145
149
  // 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.
150
+ // configured one is held to.
151
+ //
152
+ // WHAT A BUCKET IS AND WHY IT NEEDS CAPPING. A prolific self-improvement loop —
153
+ // the pr-reviewer's `loop::review-outcomes` / `loop::reviewer-comment-relevance`,
154
+ // or `loop::implement-suggestion-lessons` — writes constantly and recently, so it
155
+ // wins recency AND (being built to recur) salience, and a whole scope's read can
156
+ // collapse to one bot's private bookkeeping (observed: 13 of 15 slots before any
157
+ // cap existed). Ranking and MMR cannot fix that — the flood is real, varied and
158
+ // genuinely high-scoring — but it is not what a GENERAL coding session needs.
159
+ // General, non-loop lessons are never capped; they are what the cap frees room
160
+ // for. Bounded, not shaped: on a store with no loop lessons it never binds.
161
+ //
162
+ // ONE lesson per bucket: a self-improvement loop's
163
+ // single best lesson still surfaces, but its private bookkeeping cannot take a
164
+ // second slot from the codebase lore a general session actually needs. It was 2,
165
+ // which on a store with several active loops still spent a visible share of the
166
+ // block on bot ledgers — the measured case was 4 of 7 rendered lines. Loops read
167
+ // their own lessons back through a tag filter, so a slot spent here is a slot
168
+ // spent on the wrong audience. `0` is a meaningful setting — exclude loop buckets
169
+ // entirely and read only general codebase lessons.
149
170
  // The ceiling is a generous backstop against a typo'd cap, not a shared constant:
150
171
  // `core/lessons.mjs` bounds the whole read at its own hard lesson ceiling
151
172
  // downstream, so any loopCap at or above that never binds regardless of the exact
152
173
  // number here — they are deliberately independent, not kept in lockstep.
153
- export const DEFAULT_SESSION_START_LOOP_CAP = 2;
174
+ export const DEFAULT_SESSION_START_LOOP_CAP = 1;
154
175
  export const MIN_SESSION_START_LOOP_CAP = 0;
155
176
  export const MAX_SESSION_START_LOOP_CAP = 40;
156
177
 
@@ -167,6 +188,54 @@ export function normalizeSessionStartLoopCap(v) {
167
188
  return i;
168
189
  }
169
190
 
191
+ // The default SessionStart LINE ceiling, and the bounds a configured one is held
192
+ // to.
193
+ //
194
+ // TWO BOUNDS, TWO QUESTIONS. `maxChars` bounds what the block COSTS; this bounds
195
+ // what it LOOKS LIKE. A budget alone cannot stop a store of 500 one-word keys
196
+ // from rendering 400 lines inside it, and a 400-line index is unreadable however
197
+ // few characters it costs. Whichever binds first wins.
198
+ //
199
+ // AND IN PRACTICE THAT IS ALWAYS `maxChars`, WHICH IS THE POINT OF THIS NUMBER.
200
+ // At the default budget a block runs out of characters around line 25, so a
201
+ // ceiling of 100 does not render 100 lines — it is not a size setting at all. It
202
+ // is the DEPTH OF THE READ: `scopeReadLimit` derives the per-scope fetch from it,
203
+ // so 100 means the ranker chooses its ~25 rendered lines from up to 100 rows per
204
+ // scope (400 across a four-scope hierarchy) instead of from 25 (100 across four).
205
+ //
206
+ // That is where relevance actually comes from. The old default of 40 read the
207
+ // newest 25 rows per scope, so on a store of any size the candidate pool was the
208
+ // most RECENT few percent — a lesson re-learned five times last quarter could not
209
+ // be ranked because it was never fetched. Widening the pool costs one bigger read
210
+ // per session start and changes what the same ~25 lines are chosen from, which is
211
+ // a far better trade than rendering more lines from a shallow pool.
212
+ //
213
+ // The floor is three: below that the block stops being an index and becomes a
214
+ // sample, and the `map` shape already shows three. The ceiling is 200 because
215
+ // that is roughly where `MAX_SESSION_START_MAX_CHARS` runs out at ~120 chars a
216
+ // line — past it the ceiling could never bind, so a higher number would be inert
217
+ // rather than generous. `core/lessons.mjs` clamps to the same 200 a second time,
218
+ // from the other direction, so a caller passing the option directly is bounded
219
+ // too, and `MAX_STORE_LIST_LIMIT` caps the derived READ at what the route accepts.
220
+ export const DEFAULT_SESSION_START_MAX_LESSONS = 100;
221
+ export const MIN_SESSION_START_MAX_LESSONS = 3;
222
+ export const MAX_SESSION_START_MAX_LESSONS = 200;
223
+
224
+ // Clamp a configured line ceiling into range, or null when it is not a usable
225
+ // number (absent, a bare string, NaN). Total: the caller substitutes the default
226
+ // for null. Out-of-range CLAMPS rather than rejecting, exactly like the maxChars
227
+ // budget and the loop cap — a user who wrote `"maxLessons": 1` wants a short
228
+ // block, and honouring the floor is closer to that intent than silently
229
+ // restoring the default.
230
+ export function normalizeSessionStartMaxLessons(v) {
231
+ const n = firstNumber(v);
232
+ if (n === null) return null;
233
+ const i = Math.round(n);
234
+ if (i < MIN_SESSION_START_MAX_LESSONS) return MIN_SESSION_START_MAX_LESSONS;
235
+ if (i > MAX_SESSION_START_MAX_LESSONS) return MAX_SESSION_START_MAX_LESSONS;
236
+ return i;
237
+ }
238
+
170
239
  // A config value that is meant to be a number, or null when it is absent or is
171
240
  // something else entirely. Numeric strings are accepted because JSON configs get
172
241
  // hand-edited; the RANGE check happens later, at the point of use.
@@ -367,6 +436,16 @@ export function resolveControl({
367
436
  const hooksSessionStartLoopCap =
368
437
  normalizedLoopCap === null ? DEFAULT_SESSION_START_LOOP_CAP : normalizedLoopCap;
369
438
 
439
+ // `hooks.sessionStart.maxLessons` — how many LINES that block may hold. Same
440
+ // layer-before-parse rule as maxChars/loopCap (declaresScalar), for the same
441
+ // reason: a repo that declared a ceiling owns it even when the value is
442
+ // garbage, so two people on the same commit read the same block.
443
+ const sessionStartMaxLessonsRaw = declaresScalar(repoConfig['hooks.sessionStart.maxLessons'])
444
+ ? repoConfig['hooks.sessionStart.maxLessons']
445
+ : userConfig['hooks.sessionStart.maxLessons'];
446
+ const hooksSessionStartMaxLessons =
447
+ normalizeSessionStartMaxLessons(sessionStartMaxLessonsRaw) ?? DEFAULT_SESSION_START_MAX_LESSONS;
448
+
370
449
  // `hooks.sessionStart.branchHint` — whether the read is nudged toward the git
371
450
  // branch topic. On/off (the `hooks.userPrompt` vocabulary), default `on`, repo
372
451
  // layer wins. Off restores the pre-branch-query read: recency + salience only.
@@ -416,6 +495,7 @@ export function resolveControl({
416
495
  hooksSessionStart,
417
496
  hooksSessionStartMaxChars,
418
497
  hooksSessionStartLoopCap,
498
+ hooksSessionStartMaxLessons,
419
499
  hooksSessionStartBranchHint,
420
500
  hooksAdapter,
421
501
  hooksInstructions,
@@ -35,7 +35,13 @@ import { resolveDefaultTtlDays, matchesScopePrefix } from '../store/ttl.mjs';
35
35
  // unconfigured workspace gets. `formatLessons` is called directly by tests and
36
36
  // by the no-store path in `hook.mjs`, so it needs its own fallback rather than
37
37
  // relying on every caller to pass one.
38
- import { DEFAULT_SESSION_START_MAX_CHARS, SESSION_START_MODES } from '../control.mjs';
38
+ import {
39
+ DEFAULT_SESSION_START_LOOP_CAP,
40
+ DEFAULT_SESSION_START_MAX_CHARS,
41
+ DEFAULT_SESSION_START_MAX_LESSONS,
42
+ MAX_SESSION_START_MAX_LESSONS,
43
+ SESSION_START_MODES,
44
+ } from '../control.mjs';
39
45
  import { FRICTION_FAILURE, FRICTION_STUCK_LOOP } from './friction.mjs';
40
46
 
41
47
  // THE INJECTED SET IS BOUNDED BY A CHARACTER BUDGET, NOT BY A COUNT.
@@ -56,29 +62,44 @@ import { FRICTION_FAILURE, FRICTION_STUCK_LOOP } from './friction.mjs';
56
62
  // the ~4-chars-per-token heuristic is accurate enough for a budget whose job is
57
63
  // to bound an order of magnitude.
58
64
  //
59
- // `HARD_LESSON_CEILING` is the second bound, from the other direction. A budget
60
- // alone cannot stop a store of 500 one-word keys from rendering 400 lines inside
61
- // it, and a 400-line index is unreadable however few characters it costs. It is
62
- // deliberately well above any budget a sane `maxChars` can fill, so in normal
63
- // operation it never binds it exists so the worst case is bounded, not to
64
- // shape the common one.
65
- const HARD_LESSON_CEILING = 40;
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. 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.
81
- const SESSION_START_LOOP_CAP = 2;
65
+ // The LINE ceiling is the second bound, from the other direction. A budget alone
66
+ // cannot stop a store of 500 one-word keys from rendering 400 lines inside it,
67
+ // and a 400-line index is unreadable however few characters it costs. At its
68
+ // default (`DEFAULT_SESSION_START_MAX_LESSONS`, 100) it sits well above any block
69
+ // a default `maxChars` can fill, so in normal operation it never binds as a
70
+ // RENDER bound — its working job is to set the depth of the fetch (see
71
+ // `scopeReadLimit`), and bounding the worst-case line count is the backstop.
72
+ //
73
+ // It is CONFIGURABLE (`hooks.sessionStart.maxLessons`), which is why the two
74
+ // numbers are separate: the config is a preference, and `HARD_LESSON_CEILING` is
75
+ // the absolute clamp no caller can exceed — the same 200 the config normaliser
76
+ // clamps to, applied a second time here because `fetchLessons`/`formatLessons`
77
+ // take `maxLessons` as a plain option and a direct caller never passes through
78
+ // that normaliser.
79
+ const HARD_LESSON_CEILING = MAX_SESSION_START_MAX_LESSONS;
80
+
81
+ // The line ceiling a caller actually gets: their `maxLessons` ROUNDED (not
82
+ // truncated `Math.round`, so 40.6 becomes 41) and clamped into
83
+ // [1, HARD_LESSON_CEILING], or the default when the value is unusable. `1` and
84
+ // not the config floor of 3 — this is the last-resort clamp on an already
85
+ // normalised number, and a caller that deliberately asks for one line should get
86
+ // one, not three. Pure.
87
+ //
88
+ // UNUSABLE INCLUDES ZERO AND NEGATIVES, not just NaN, and that is the whole
89
+ // reason this reads as it does rather than as a bare `Math.max(1, …)`.
90
+ // `Number(null)` is `0` and `Number('')` is `0`, so a caller passing an explicit
91
+ // `maxLessons: null` — which the option default does NOT catch, since only
92
+ // `undefined` triggers a destructuring default — would clamp UP to a ONE-LINE
93
+ // block: a near-total, silent loss of the injection dressed up as a valid
94
+ // ceiling. A zero or negative ceiling is not a request for a short block, it is
95
+ // a value that has no reading, so it falls back to the default exactly like
96
+ // `normalizeSessionStartMaxLessons` returning `null` does. An explicit `1` is a
97
+ // reading, and still gets one line.
98
+ function resolveLessonCeiling(maxLessons) {
99
+ const n = Number(maxLessons);
100
+ if (!Number.isFinite(n) || n <= 0) return DEFAULT_SESSION_START_MAX_LESSONS;
101
+ return Math.min(HARD_LESSON_CEILING, Math.max(1, Math.round(n)));
102
+ }
82
103
 
83
104
  // How many lessons ride along with the scope map in `map` mode. Small on
84
105
  // purpose: the point of that shape is the inventory, and a "map" that is mostly
@@ -104,15 +125,74 @@ const MAX_SCAN_CHARS = 4096;
104
125
  // precedence via the shared pure `resolvePrecedence` (the SAME first-seen /
105
126
  // more-specific-wins merge `tree` renders) — so the hook and `tree` provably
106
127
  // can't drift. Any per-scope failure is skipped (memory is best-effort).
107
- // Per-scope read cap. Unchanged from the count-capped era: it bounds the FETCH,
108
- // which is a different question from how much gets injected, and raising it
109
- // would make every session start pay for rows the budget was never going to
110
- // show. Its one visible consequence is that a scope holding more than this
111
- // many lessons reports a lower-bound count in the scope map — rendered `25+`
112
- // rather than a number that looks exact. `memory.scopes` answers it exactly and
113
- // is the follow-up that replaces this.
128
+ // The FLOOR on the per-scope read not the default, which is the higher
129
+ // `scopeReadLimit(DEFAULT_SESSION_START_MAX_LESSONS)`. It exists so a LOWERED
130
+ // ceiling cannot starve the ranker: asking for five lines is a statement about
131
+ // the render, not permission to rank them out of five candidates.
132
+ //
133
+ // The visible consequence of whatever limit applies is that a scope holding more
134
+ // than that many lessons reports a lower-bound count in the scope map — rendered
135
+ // with a trailing `+` (`100+` at the default) rather than a number that looks
136
+ // exact. `memory.scopes` answers it exactly and is the follow-up that replaces
137
+ // this.
114
138
  export const SCOPE_READ_LIMIT = 25;
115
139
 
140
+ // The largest `limit` the hosted route will ACCEPT on one `GET /memories` call.
141
+ //
142
+ // Mirrored from `ListMemoriesQuerySchema` in `@lorekit/schemas` — the schema
143
+ // that validates `GET /memories`, which is the door `RemoteStore.list` actually
144
+ // knocks on. NOT `MemoryListSchema`: that is the MCP *tool* schema, a different
145
+ // entrance to the same data that happens to agree on 100 today. Self-contained
146
+ // here for the
147
+ // same reason `limits.ts` is mirrored into the edge function: this package takes
148
+ // no dependencies, and the number is a CONTRACT with the other side rather than
149
+ // a preference of ours. Keep the two in step — if the route's cap moves, this
150
+ // moves with it.
151
+ //
152
+ // It is a HARD bound on the read, not a soft one, because the failure it
153
+ // prevents is silent and total. Zod REJECTS an over-cap `limit` with a 400, so
154
+ // `RemoteStore.list` returns `{ ok: false }`, and `fetchLessons` — best-effort by
155
+ // design — skips that scope. Every scope fails the same way, so a remote user
156
+ // who set `maxLessons` above 100 would get an EMPTY block with no error
157
+ // anywhere: the exact shape of bug that survives for months because the hook
158
+ // swallows everything by contract. The local store has no such cap, but the
159
+ // bound is applied uniformly rather than per-store — one code path, and a
160
+ // ceiling above 100 still fills from the several scopes in `readOrder`
161
+ // (4 × 100 = 400 candidates for a 200-line ceiling).
162
+ export const MAX_STORE_LIST_LIMIT = 100;
163
+
164
+ // The per-scope read cap for a given line ceiling: the ceiling, held between the
165
+ // `SCOPE_READ_LIMIT` floor and the `MAX_STORE_LIST_LIMIT` transport cap.
166
+ //
167
+ // THE FETCH IS WHAT THIS DIAL IS ACTUALLY FOR. `maxChars` runs out around line 25
168
+ // at the default budget, so the ceiling almost never decides how many lines are
169
+ // RENDERED — it decides how many rows are FETCHED, and therefore how good the
170
+ // ~25 that render are. At the default of 100 the ranker chooses from up to 100
171
+ // rows per scope (400 across a four-scope hierarchy) rather than the newest 25.
172
+ // On a store of any size that is the difference between ranking a slice of this
173
+ // week's churn and ranking a real corpus.
174
+ //
175
+ // It was previously gated to grow ONLY above the default, so an unconfigured
176
+ // workspace kept a 25-row read. That gate died with the default it keyed on: once
177
+ // the default IS the depth we want, `ceiling > DEFAULT` can never fire inside the
178
+ // config's own 3–200 range, and the fetch would have been pinned at 25 forever
179
+ // while the ceiling asked for 100 — a dial that silently could not be fed.
180
+ //
181
+ // LOWERING the ceiling still does NOT shrink the fetch below `SCOPE_READ_LIMIT`:
182
+ // fewer candidates would mean the ranking picks its handful from a worse pool,
183
+ // which is a quality regression dressed up as a saving. Monotone in `maxLessons`,
184
+ // so the cost never falls as the ask grows.
185
+ //
186
+ // AND IT NEVER EXCEEDS WHAT THE TRANSPORT ACCEPTS (`MAX_STORE_LIST_LIMIT`). A
187
+ // ceiling of 200 is a legal config value, but a 200-row `limit` is not a legal
188
+ // request — so the read stops at 100 and the remaining lines fill from the other
189
+ // scopes in `readOrder`. Without this the dial's own top half silently emptied
190
+ // the block on a remote store. Pure.
191
+ export function scopeReadLimit(maxLessons) {
192
+ const ceiling = resolveLessonCeiling(maxLessons);
193
+ return Math.min(Math.max(ceiling, SCOPE_READ_LIMIT), MAX_STORE_LIST_LIMIT);
194
+ }
195
+
116
196
  // `scope` may be injected instead of derived from `cwd` — a seam for callers
117
197
  // that already hold a resolved scope and for tests that need a deterministic
118
198
  // branch (deriveScope shells out to git, so the ambient branch — often a
@@ -120,9 +200,21 @@ export const SCOPE_READ_LIMIT = 25;
120
200
  export async function fetchLessons(
121
201
  store,
122
202
  cwd,
123
- { now = Date.now(), scope: scopeOverride = null, loopCap = SESSION_START_LOOP_CAP, branchHint = true } = {},
203
+ {
204
+ now = Date.now(),
205
+ scope: scopeOverride = null,
206
+ loopCap = DEFAULT_SESSION_START_LOOP_CAP,
207
+ branchHint = true,
208
+ maxLessons = DEFAULT_SESSION_START_MAX_LESSONS,
209
+ } = {},
124
210
  ) {
125
211
  const scope = scopeOverride || deriveScope(cwd);
212
+ // ONE ceiling, derived once, spent on both the fetch and the diversifier — the
213
+ // two must agree or a raised ceiling asks for lines the read never fetched.
214
+ // `formatLessons` derives the same number from the same config key, so the
215
+ // render bound matches too.
216
+ const ceiling = resolveLessonCeiling(maxLessons);
217
+ const readLimit = scopeReadLimit(ceiling);
126
218
  // Issued BEFORE the per-scope read loop and awaited after it. Nothing in the
127
219
  // inventory depends on the loop, so awaiting it afterwards would cost a
128
220
  // remote store one extra SERIAL round-trip on the session-start path; started
@@ -141,10 +233,10 @@ export async function fetchLessons(
141
233
  // not a total, and the map must say so rather than quietly under-report.
142
234
  const truncatedScopes = new Set();
143
235
  for (const s of scope.readOrder) {
144
- const res = await store.list({ scope: s, limit: SCOPE_READ_LIMIT });
236
+ const res = await store.list({ scope: s, limit: readLimit });
145
237
  if (!res || !res.ok) continue; // best-effort: a failed scope contributes nothing
146
238
  const raw = Array.isArray(res.entries) ? res.entries : [];
147
- if (raw.length >= SCOPE_READ_LIMIT) truncatedScopes.add(s);
239
+ if (raw.length >= readLimit) truncatedScopes.add(s);
148
240
  const entries = raw
149
241
  .filter((e) => e && e.key)
150
242
  .map((e) => ({ ...e, scope: s }));
@@ -208,7 +300,7 @@ export async function fetchLessons(
208
300
 
209
301
  // AUDIENCE CAP before diversification: no single self-improvement loop may
210
302
  // take more than `loopCap` (the `hooks.sessionStart.loopCap` option, default
211
- // `SESSION_START_LOOP_CAP`) of the injected slots, so a general
303
+ // `DEFAULT_SESSION_START_LOOP_CAP`) of the injected slots, so a general
212
304
  // session is not flooded with one bot's private `loop::<bucket>` bookkeeping.
213
305
  // General (non-loop) lessons pass through uncapped — they are what the cap
214
306
  // frees room for. Applied to the ranked list so the survivors are each
@@ -217,7 +309,7 @@ export async function fetchLessons(
217
309
  // shown, not the honest count of what exists per scope.
218
310
  //
219
311
  // WHERE THE FREED SLOTS FILL FROM, stated so the cap is not oversold. Each
220
- // scope is read only to its newest `SCOPE_READ_LIMIT`, so on a scope whose
312
+ // scope is read only to its newest `readLimit` rows, so on a scope whose
221
313
  // recent writes are ALL one loop's, the general lessons that fill the freed
222
314
  // slots come from the OTHER scopes in `readOrder` (a repo's loop churn makes
223
315
  // room for `global` principles) — not from that same scope's older generals,
@@ -231,7 +323,7 @@ export async function fetchLessons(
231
323
  // The map's job is to tell a reader how much lore is sitting in each scope
232
324
  // that this injection did not show them, so its numbers should be the store's
233
325
  // real totals. Deriving them from the bounded read above cannot do that: the
234
- // read stops at `SCOPE_READ_LIMIT`, so a scope holding 400 lessons reported
326
+ // read stops at `readLimit`, so a scope holding 400 lessons reported
235
327
  // `25+` — technically honest, useless as a quantity, and the `+` was doing a
236
328
  // lot of work.
237
329
  //
@@ -299,7 +391,7 @@ export async function fetchLessons(
299
391
  // "8 of 50" stays true no matter how the render is bounded.
300
392
  return {
301
393
  scope,
302
- lessons: diversifyRankedLessons(capped, { ...rankOpts, k: HARD_LESSON_CEILING }),
394
+ lessons: diversifyRankedLessons(capped, { ...rankOpts, k: ceiling }),
303
395
  scopeCounts,
304
396
  applicable: ranked.length,
305
397
  };
@@ -408,10 +500,17 @@ function lessonHook(value, max = HOOK_LEN) {
408
500
  // to know what the reader saw (the shown-set bookkeeping) has to be told rather
409
501
  // than re-deriving it — a second copy of the fit maths would drift the moment
410
502
  // either bound changes.
503
+ // `maxLessons` — the LINE ceiling (`hooks.sessionStart.maxLessons`), the second
504
+ // bound alongside `maxChars`: whichever binds first decides the block. It is
505
+ // passed rather than re-read from config for the same reason `maxChars` is, and
506
+ // it must be the SAME number `fetchLessons` was given — a render bound above the
507
+ // fetch bound asks for lines that were never fetched, and one below it silently
508
+ // discards lessons the read paid for.
411
509
  export function formatLessons(lessons, scope, {
412
510
  instruction = null,
413
511
  mode = 'hybrid',
414
512
  maxChars = DEFAULT_SESSION_START_MAX_CHARS,
513
+ maxLessons = DEFAULT_SESSION_START_MAX_LESSONS,
415
514
  scopeCounts = null,
416
515
  applicable = null,
417
516
  onShown = null,
@@ -447,7 +546,11 @@ export function formatLessons(lessons, scope, {
447
546
  const map = renderScopeMap(scopeCounts);
448
547
  const reserve = shape === 'index' || !map ? 0 : map.length + 1;
449
548
 
450
- const ceiling = shape === 'map' ? Math.min(MAP_TOP_K, HARD_LESSON_CEILING) : HARD_LESSON_CEILING;
549
+ // `map` shows a handful of lessons whatever the ceiling says — the point of
550
+ // that shape is the inventory. `Math.min` rather than a flat `MAP_TOP_K` so a
551
+ // ceiling BELOW three still binds: a reader who asked for one line gets one.
552
+ const lineCeiling = resolveLessonCeiling(maxLessons);
553
+ const ceiling = shape === 'map' ? Math.min(MAP_TOP_K, lineCeiling) : lineCeiling;
451
554
  const { shown } = fitLines(all, budget - reserve, ceiling);
452
555
  report(shown.map((s) => s.lesson));
453
556
 
@@ -503,8 +606,8 @@ function fitLines(lessons, budget, ceiling) {
503
606
 
504
607
  // The scope map: one line naming every scope that holds lessons and how many,
505
608
  // so a truncated block still tells the reader WHERE the rest live and which verb
506
- // reaches them. `25+` marks a scope whose read hit `SCOPE_READ_LIMIT`, so a
507
- // lower bound never reads as an exact total. Null when there is nothing to
609
+ // reaches them. A trailing `+` (`25+`) marks a scope whose read hit the per-scope
610
+ // limit, so a lower bound never reads as an exact total. Null when there is nothing to
508
611
  // describe. Pure.
509
612
  export function renderScopeMap(scopeCounts) {
510
613
  const rows = (Array.isArray(scopeCounts) ? scopeCounts : [])
package/src/hook.mjs CHANGED
@@ -118,11 +118,16 @@ async function run(args) {
118
118
  const { scope: readScope, lessons, scopeCounts, applicable } = await fetchLessons(store, root, {
119
119
  loopCap: control.hooksSessionStartLoopCap,
120
120
  branchHint: control.hooksSessionStartBranchHint !== 'off',
121
+ maxLessons: control.hooksSessionStartMaxLessons,
121
122
  });
122
123
  emit(formatLessons(lessons, readScope, {
123
124
  instruction: sessionInstruction,
124
125
  mode: control.hooksSessionStart,
125
126
  maxChars: control.hooksSessionStartMaxChars,
127
+ // The SAME ceiling the fetch was given: the render bound and the fetch
128
+ // bound are one config key, and letting them diverge would either ask for
129
+ // lines that were never fetched or discard ones the read paid for.
130
+ maxLessons: control.hooksSessionStartMaxLessons,
126
131
  scopeCounts,
127
132
  applicable,
128
133
  // Record what this injection RENDERED, so the per-prompt hook treats it as
@@ -419,7 +419,7 @@ const MEMORY_DISPATCH = {
419
419
  // networkError, unusable }`. A tool that passed either through verbatim would
420
420
  // hand the model two different contracts for one tool name depending on a
421
421
  // config value it cannot see. What is left here is what only the MCP surface
422
- // owns: the ascending sort and the exit-clean degradation below.
422
+ // owns: the count-desc-then-scope-asc sort and the exit-clean degradation below.
423
423
  //
424
424
  // DEGRADATION IS EXIT-CLEAN, mirroring the `scopes` command, which reports an
425
425
  // unreachable remote as a short note at exit 0 rather than failing the run. An
@@ -441,31 +441,31 @@ export async function listScopes(store) {
441
441
  return ok ? { ok: true, scopes: sortScopes(scopes) } : { ok: true, scopes: [], note: reason };
442
442
  }
443
443
 
444
- // Sorted by scope ascending, which is the contract `docs/mcp-tools.md`, the
445
- // tool catalog and `llms.txt` all state for `memory.scopes`. The HOSTED surface
446
- // gets that ordering from `lorekit_memory_scopes` (`order by m.scope asc`,
447
- // migration 00039/00049), but `LocalStore`/`TwoTierStore.listScopes()` both
448
- // return their `Map` insertion order — a walk order, not an ordering — so the
449
- // stdio server owns it here rather than the two surfaces answering differently.
450
- // Sorting BOTH shapes (not just the local one) makes the guarantee a property
451
- // of this function instead of an assumption about the store it was handed.
452
- // Codepoint comparison, deliberately not `localeCompare`: the ordering must not
453
- // depend on the HOST's locale.
444
+ // Sorted by count DESC then scope asc, which is the contract `docs/mcp-tools.md`,
445
+ // the tool catalog and `llms.txt` all state for `memory.scopes`. The HOSTED
446
+ // surface gets that ordering from `lorekit_memory_scopes` (`order by count(*)
447
+ // desc, m.scope asc`, migration 00065), but `LocalStore`/`TwoTierStore.
448
+ // listScopes()` both return their `Map` insertion order — a walk order, not an
449
+ // ordering — so the stdio server owns it here rather than the two surfaces
450
+ // answering differently. Sorting BOTH shapes (not just the local one) makes the
451
+ // guarantee a property of this function instead of an assumption about the store
452
+ // it was handed.
454
453
  //
455
- // That is ascending-by-scope, not byte-identical parity with the hosted path,
456
- // and the difference is worth being precise about. `order by m.scope asc` sorts
457
- // under the DATABASE's collation (`en_US.UTF-8` on a default Supabase project),
458
- // which does not order like codepoint around punctuation and a scope string
459
- // is mostly punctuation (`::`, `/`, `-`), so `repo::a-b` and `repo::ab` can come
460
- // out in the opposite relative order on the two surfaces. Case cannot differ
461
- // (every scope segment is lowercased, see docs/scope-format.md). Closing the
462
- // remaining gap means `collate "C"` on the RPC's `order by`, which changes the
463
- // order `GET /memories/scopes` has always returned a public contract change
464
- // that belongs in its own migration, not here. Until then: both surfaces are
465
- // sorted ascending, neither is unordered, and nothing should depend on the two
466
- // agreeing on the exact position of a punctuated neighbour.
454
+ // The primary key is `count` (a number), which orders identically on both
455
+ // surfaces. Only the scope-asc TIEBREAK between equal-count scopes carries the
456
+ // old caveat: it is a codepoint comparison here (deliberately not
457
+ // `localeCompare`, so the ordering never depends on the HOST's locale), while
458
+ // `order by m.scope asc` sorts under the DATABASE's collation (`en_US.UTF-8` on a
459
+ // default Supabase project), which does not order like codepoint around
460
+ // punctuation — and a scope string is mostly punctuation (`::`, `/`, `-`), so
461
+ // two equal-count scopes like `repo::a-b` and `repo::ab` can come out in the
462
+ // opposite relative order on the two surfaces. Case cannot differ (every scope
463
+ // segment is lowercased, see docs/scope-format.md). Nothing should depend on the
464
+ // two agreeing on the exact position of a punctuated equal-count neighbour.
467
465
  function sortScopes(rows) {
468
- return rows.sort((a, b) => (a.scope < b.scope ? -1 : a.scope > b.scope ? 1 : 0));
466
+ return rows.sort(
467
+ (a, b) => b.count - a.count || (a.scope < b.scope ? -1 : a.scope > b.scope ? 1 : 0),
468
+ );
469
469
  }
470
470
 
471
471
  // Provenance for a tool call: the caller's explicit values win, the working
@@ -257,7 +257,7 @@ class RemoteStore {
257
257
  // The `scopes` array is the SAME `[{ scope, count }]` inventory shape
258
258
  // `LocalStore.listScopes()` returns, so `scopes.mjs` feeds both through the
259
259
  // same pure `filterScopeInventory`/`summarizeScopeInventory` helpers. Ordering
260
- // is not relied upon (the server sorts by scope asc; the view re-sorts by
260
+ // is not relied upon (the server sorts by count desc; the view re-sorts by
261
261
  // scope type). Failures use this store's standard `{ ok:false, error,
262
262
  // networkError }` envelope so the caller can degrade gracefully.
263
263
  //