@lorekit/cli 1.49.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.49.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