@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 +29 -6
- package/package.json +1 -1
- package/src/control.mjs +91 -11
- package/src/core/lessons.mjs +144 -41
- package/src/hook.mjs +5 -0
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":
|
|
710
|
-
// character budget for that block (default
|
|
711
|
-
// bounded
|
|
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.
|
|
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
|
|
724
|
-
// entirely so only general memories are read).
|
|
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
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
|
|
13
|
-
// hooks.sessionStart.loopCap — max lessons per self-improvement loop bucket (default
|
|
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. ~
|
|
107
|
-
// —
|
|
108
|
-
//
|
|
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 =
|
|
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
|
|
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.
|
|
147
|
-
//
|
|
148
|
-
//
|
|
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 =
|
|
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,
|
package/src/core/lessons.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
|
|
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
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
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
|
-
{
|
|
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:
|
|
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 >=
|
|
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
|
-
// `
|
|
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 `
|
|
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 `
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|