@lorekit/cli 1.49.0 → 1.51.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.51.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
package/src/mcp.mjs CHANGED
@@ -158,6 +158,30 @@ export function normalizeRunEnvironment(raw) {
158
158
  return /^[A-Za-z0-9_.\-:]+$/.test(t) ? t : null;
159
159
  }
160
160
 
161
+ /**
162
+ * The retry delay a failed response advertised, in whole seconds, or null.
163
+ *
164
+ * Prefers the JSON body's `retryAfterSeconds` over the `Retry-After` header:
165
+ * both are set by the same `tooManyRequests()` helper, and the body value is
166
+ * the number the rate-limit RPC actually returned, while the header is its
167
+ * stringified copy that an intermediary may rewrite.
168
+ *
169
+ * TOTAL over any input. `headers` is read through optional calls because a
170
+ * test double (and a hand-rolled Response-alike) may not implement the Headers
171
+ * interface, and a missing retry hint must never be able to throw on an error
172
+ * path — the caller is already handling a failure.
173
+ */
174
+ export function retryAfterFrom(data, headers) {
175
+ const raw = data?.retryAfterSeconds ?? (typeof headers?.get === 'function' ? headers.get('retry-after') : null);
176
+ if (raw == null || raw === '') return null;
177
+ const n = Number(raw);
178
+ // A `Retry-After` may also be an HTTP-date; a non-numeric value is reported
179
+ // as "no hint" so the caller falls back to its own backoff rather than
180
+ // waiting on NaN.
181
+ if (!Number.isFinite(n) || n < 0) return null;
182
+ return Math.ceil(n);
183
+ }
184
+
161
185
  export async function restFetch(baseUrl, token, path, { method = 'GET', body, timeoutMs = 10000, traceparent } = {}) {
162
186
  const controller = new AbortController();
163
187
  const timer = setTimeout(() => controller.abort(), timeoutMs);
@@ -203,6 +227,12 @@ export async function restFetch(baseUrl, token, path, { method = 'GET', body, ti
203
227
  return {
204
228
  ok: false,
205
229
  httpStatus: res.status,
230
+ // How long the server asked the caller to wait, in seconds, or null when
231
+ // it did not say. Only a 429 carries one today (`tooManyRequests` sets
232
+ // BOTH a `retryAfterSeconds` body field and the `Retry-After` header),
233
+ // but this is read on every failure so a future 503 needs no change
234
+ // here. Additive: existing callers ignore the extra key.
235
+ retryAfter: retryAfterFrom(data, res.headers),
206
236
  error: data?.error ? { message: data.error, code: data.code } : { code: res.status, message: text.slice(0, 200) || res.statusText },
207
237
  };
208
238
  }
@@ -29,7 +29,7 @@ export const FIELDS = [
29
29
  // columns: a file written before this existed simply decodes it as absent.
30
30
  'expires_at',
31
31
  // Recurrence — how many times this lesson has been written, mirroring the
32
- // hosted `memories.seen_count` column (migration 00058) so an offline store
32
+ // hosted `memories.seen_count` column (migration 00059) so an offline store
33
33
  // carries the same salience signal a remote one does. Appended like the
34
34
  // columns above: a file written before this existed decodes it as absent,
35
35
  // which the read projection reports as 0 rather than inventing a count.
@@ -134,7 +134,7 @@ class LocalStore {
134
134
  archived_at: null,
135
135
  expires_at,
136
136
  // Recurrence, counted the way the hosted `memory_write` RPC counts it
137
- // (migration 00058): a write against a key this store already holds IS
137
+ // (migration 00059): a write against a key this store already holds IS
138
138
  // the next sighting. `seenCountOf` floors an absent/hand-edited value to
139
139
  // 0, so a file written before this column existed resumes at 1 on its
140
140
  // next write rather than throwing or restarting the tally at 2.
@@ -21,6 +21,7 @@
21
21
  import { restFetch, mcpToRestBase } from '../mcp.mjs';
22
22
  import { getActiveTraceparent } from '../telemetry.mjs';
23
23
  import { withReadFields } from './entry-fields.mjs';
24
+ import { normalizeCreatedAt } from './created-at.mjs';
24
25
 
25
26
  // Drop undefined/null args so JSON payloads stay tidy.
26
27
  function stripUndefined(obj) {
@@ -29,6 +30,55 @@ function stripUndefined(obj) {
29
30
  return out;
30
31
  }
31
32
 
33
+ /**
34
+ * A read that could not be answered — a transport failure or a non-2xx status,
35
+ * as opposed to "the lesson is not there".
36
+ *
37
+ * Exported so a caller can tell it from a programming error and degrade
38
+ * per-entry (report this one, keep going) instead of aborting a whole run.
39
+ * `result` carries the raw store envelope for the message the caller shows.
40
+ */
41
+ export class StoreReadError extends Error {
42
+ constructor(message, result) {
43
+ super(message);
44
+ this.name = 'StoreReadError';
45
+ this.result = result;
46
+ }
47
+ }
48
+
49
+ // An absolute `expires_at` expressed as the hosted write's relative `ttl_days`.
50
+ //
51
+ // Three outcomes, and the third exists because "no expiry" and "I cannot tell"
52
+ // must not collapse into one answer:
53
+ //
54
+ // `undefined` no expiry — the caller states that positively with
55
+ // `clear_ttl: true`, so a permanent lesson stops being expiring.
56
+ // `'expired'` already elapsed; the caller must refuse (see `putEntry`).
57
+ // `'unknown'` an unparseable value. The caller then sends NEITHER TTL field,
58
+ // leaving the RPC on its `'keep'` branch, because the safe
59
+ // reading of a corrupt frontmatter field is "do not touch the
60
+ // expiry" — the same fail-safe posture as `isExpired`. Treating
61
+ // it as no expiry would let one bad character wipe a live remote
62
+ // TTL.
63
+ //
64
+ // else the remaining WHOLE days, clamped to the schema's 1–365.
65
+ function remoteTtlDays(expiresAt, now = new Date()) {
66
+ const exact = remoteTtlDaysExact(expiresAt, now);
67
+ if (typeof exact !== 'number') return exact;
68
+ return Math.min(365, Math.max(1, exact));
69
+ }
70
+
71
+ // The same conversion WITHOUT the 1–365 clamp, so a caller can see that the
72
+ // clamp bound and report the loss. Same three non-numeric outcomes.
73
+ function remoteTtlDaysExact(expiresAt, now = new Date()) {
74
+ if (!expiresAt) return undefined;
75
+ const ms = Date.parse(expiresAt);
76
+ if (Number.isNaN(ms)) return 'unknown';
77
+ const remaining = ms - now.getTime();
78
+ if (remaining <= 0) return 'expired';
79
+ return Math.ceil(remaining / 86_400_000);
80
+ }
81
+
32
82
  export function createRemoteStore({ endpoint, token } = {}) {
33
83
  return new RemoteStore(endpoint, token);
34
84
  }
@@ -151,7 +201,24 @@ class RemoteStore {
151
201
  // scope+key is unique, so one row is all there can be — don't pull the default page of 50.
152
202
  p.set('limit', '1');
153
203
  const res = await this._rest(`/memories?${p}`);
154
- if (!res.ok) return { ok: false, error: res.error, networkError: res.networkError };
204
+ // `unusable` is passed through: `_rest` short-circuits an unconfigured
205
+ // store with that flag and NOTHING else, so a caller that drops it is left
206
+ // with a failure carrying no error and no networkError — a blank failure it
207
+ // can only report generically. Additive; every existing caller branches on
208
+ // `ok` and ignores the extra key.
209
+ if (!res.ok) {
210
+ return {
211
+ ok: false,
212
+ error: res.error ?? null,
213
+ // Carried for the same reason `write` carries them: a read can be
214
+ // rate-limited too, and a caller that retries needs to tell a 429 it
215
+ // should wait out from one it must not.
216
+ httpStatus: res.httpStatus ?? null,
217
+ retryAfter: res.retryAfter ?? null,
218
+ networkError: res.networkError ?? null,
219
+ unusable: res.unusable ?? false,
220
+ };
221
+ }
155
222
  const entries = res.data?.entries ?? [];
156
223
  // Same projection as list/search — a single read must not answer with a
157
224
  // different shape than the listing the caller found the key in.
@@ -181,7 +248,223 @@ class RemoteStore {
181
248
  if (origin_commit !== undefined) body.origin_commit = origin_commit;
182
249
  if (origin_pr !== undefined) body.origin_pr = origin_pr;
183
250
  const res = await this._rest('/memories', { method: 'POST', body });
184
- return { ok: res.ok, error: res.error, networkError: res.networkError };
251
+ // `httpStatus` and `retryAfter` are passed through so a caller can tell the
252
+ // two 429s apart and honour the server's own backoff. They are DIFFERENT
253
+ // failures wearing one status code: `code: 'rate_limited'` is transient and
254
+ // must be retried, `code: 'memory_cap'` is terminal (translateDbError maps
255
+ // the LK001 cap trigger to 429 as well) and must not be. Additive — the
256
+ // existing `{ ok, error, networkError }` keys are unchanged.
257
+ // Every field is coalesced, not just the retry hint: a caller comparing
258
+ // `httpStatus` must not get `null` from a refusal and `undefined` from the
259
+ // network-error or `unusable` branch, which is the exact split the shape
260
+ // exists to remove.
261
+ return {
262
+ ok: res.ok,
263
+ error: res.error ?? null,
264
+ httpStatus: res.httpStatus ?? null,
265
+ retryAfter: res.retryAfter ?? null,
266
+ networkError: res.networkError ?? null,
267
+ // `_rest` short-circuits an unconfigured store with `{ ok:false,
268
+ // unusable:true }` and no error at all, so without this the caller sees
269
+ // a failure with every field null and no reason. Passed through the way
270
+ // `listScopes` already does.
271
+ unusable: res.unusable ?? false,
272
+ };
273
+ }
274
+
275
+ // ── Migrate-destination parity with LocalStore ────────────────────────────
276
+ //
277
+ // `migrate` classifies each source entry ADD / UPDATE / NOOP with a read and
278
+ // then upserts it, against whatever store it was handed. LocalStore answers
279
+ // that with `getEntry` + `putEntry`; these are the remote halves, so the
280
+ // migrate loop stays ONE code path instead of branching per destination.
281
+ //
282
+ // The local pair is lossless (`putEntry` writes every field verbatim,
283
+ // archived rows included). The remote pair CANNOT be, because the hosted
284
+ // write is an RPC with a fixed parameter list, not a file write:
285
+ //
286
+ // preserved scope, key, source_agent, trigger, and `created` —
287
+ // sent as `created_at`, which memory_write honours on INSERT
288
+ // only, so a migrated lesson keeps its original creation date
289
+ // and its ranking recency with it. `value` survives too but is
290
+ // TRIMMED: `MemoryWriteSchema` applies `.transform(s =>
291
+ // s.trim())`, so surrounding whitespace does not make the trip.
292
+ // re-stamped `updated` — the server sets it to the write instant. There is
293
+ // no parameter for it, and inventing one would let a client
294
+ // backdate an edit it did not make.
295
+ // derived `seen_count` — the RPC owns the tally (migration 00059: a
296
+ // write against an existing key IS the next sighting). A lesson
297
+ // the hosted store has never seen lands at 1; one it already
298
+ // holds lands at ITS count plus one, not the local one. Either
299
+ // way the local history does not transfer.
300
+ // converted `expires_at` → `ttl_days`, the remaining whole days, clamped
301
+ // to the schema's 1–365 (a longer-lived TTL is clamped, not
302
+ // dropped — the alternative is silently making it permanent).
303
+ // A clamp IS lossy, so it is reported: the result carries
304
+ // `ttlClamped: true` and the caller can list the entry as
305
+ // shortened rather than leaving the user to discover it.
306
+ // A PERMANENT entry sends `clear_ttl: true` rather than simply
307
+ // omitting `ttl_days`: omission is the RPC's `'keep'` branch
308
+ // (migration 00031), which leaves an existing remote
309
+ // `expires_at` in place, so a permanent local lesson would
310
+ // land on an expiring remote row and still die.
311
+ // authoritative
312
+ // `tags`. The conflict clause is `tags = excluded.tags`, so the
313
+ // source entry's list REPLACES the hosted one — an untagged
314
+ // local entry sends `[]` and clears whatever labels the hosted
315
+ // row carried. That is what a verbatim upsert means here (the
316
+ // local file is the thing being migrated), but it is the one
317
+ // field where "verbatim" can remove hosted data, so it is
318
+ // called out rather than filed under preserved.
319
+ // sticky `origin_*`. The RPC's conflict clause coalesces provenance
320
+ // (`coalesce(excluded.origin_repo, memories.origin_repo)`, and
321
+ // likewise for branch/commit/pr) so a write that does not know
322
+ // a field cannot erase what an earlier one recorded. A migrated
323
+ // entry with NO provenance therefore leaves whatever the hosted
324
+ // row already had; it cannot clear it, by design, and there is
325
+ // no parameter that would. `source_agent` and `trigger` are NOT
326
+ // coalesced (`= excluded.*`), so an absent one still CLEARS the
327
+ // hosted value — not because a null is sent (`stripUndefined`
328
+ // drops nulls before the request) but because the REST handler
329
+ // substitutes `?? null` for the missing field and the RPC
330
+ // writes that. Omitted and null are the same instruction here,
331
+ // which is the opposite of what they mean for `origin_*`. `kind` and `host` are coalesced the same
332
+ // way, and this store never sends them at all — the server
333
+ // infers both from the `loop::` tag (`resolveKindHost`), which
334
+ // the tags carry, so a migrated lesson classifies itself.
335
+ //
336
+ // Two states have no remote representation at all and are REFUSED rather
337
+ // than silently rewritten, because writing them would resurrect a lesson the
338
+ // user retired: an archived entry (every conflict predicate on `memories` is
339
+ // partial on `archived_at is null`, so the hosted write does not revive the
340
+ // archived row — it INSERTS a second, live one beside it, leaving the store
341
+ // with both) and
342
+ // an already-expired one (any `ttl_days` re-dates it into the future). Both
343
+ // come back as `{ ok:false, unsupported }` so the caller can report them as
344
+ // skipped. `migrate` does NOT filter them today — adding that filter is the
345
+ // job of the PR that makes a remote destination reachable — so until then
346
+ // this refusal is the only thing standing between an archived lesson and
347
+ // resurrection, which is why it lives in the store and not in the caller.
348
+
349
+ // Raw lookup by scope+key, mirroring `LocalStore.getEntry` — the entry or
350
+ // null. The ROW is each store's own: this answers a REST `MemoryEntry`
351
+ // (`created_at`/`updated_at`) and the local one answers parsed frontmatter
352
+ // (`created`/`updated`), both through `withReadFields`. That is deliberate —
353
+ // the pair exists so a caller can ASK either store whether a key is there,
354
+ // not so it can compare two rows field-by-field without knowing which store
355
+ // produced them. A caller that compares has to speak the destination's
356
+ // spelling; that is what the remote comparison in the migrate loop does. LocalStore's is synchronous and this one cannot be, so callers must
357
+ // `await` it; awaiting the local store's plain return value is a no-op.
358
+ //
359
+ // One semantic difference the caller has to know about: LocalStore.getEntry
360
+ // sees archived rows and this cannot — `GET /memories` filters them out — so
361
+ // a remote destination classifies an archived counterpart as ADD, and the
362
+ // write then lands as a NEW live row beside the archived one (the conflict
363
+ // predicates are partial on `archived_at is null`). That is what the hosted
364
+ // `memory_write` does for any write against an archived key, not a
365
+ // migrate-specific quirk — and it is why `putEntry` refuses an archived
366
+ // source entry outright rather than relying on this classification.
367
+ //
368
+ // A FAILED read THROWS rather than answering null. Null is the answer to "no
369
+ // such lesson", and a caller that classifies ADD / UPDATE / NOOP acts on it:
370
+ // returning null for a transient 500 or a dropped connection would quietly
371
+ // reclassify an existing hosted lesson as new and overwrite it. The local
372
+ // store never throws here (a file read that fails is genuinely a miss), so
373
+ // this widens the contract only where the failure mode exists.
374
+ async getEntry({ scope, key } = {}) {
375
+ const res = await this.read({ scope, key });
376
+ if (!res.ok) {
377
+ // An unconfigured store carries no error at all, so name that case
378
+ // rather than reporting the generic failure text for it.
379
+ const reason = res.unusable
380
+ ? 'the remote store is not configured (missing endpoint or token)'
381
+ : (res.networkError || res.error?.message || 'read failed');
382
+ throw new StoreReadError(`remote read failed for ${scope}::${key}: ${reason}`, res);
383
+ }
384
+ return res.entry ?? null;
385
+ }
386
+
387
+ // Upsert one entry, as close to verbatim as the hosted write allows. See the
388
+ // fidelity table above for exactly which fields survive.
389
+ //
390
+ // EVERY branch answers with the SAME key set — `write`'s
391
+ // `{ ok, error, httpStatus, retryAfter, networkError, unusable }` plus
392
+ // Unlike `LocalStore.putEntry`, no `entry` comes back: the hosted write
393
+ // returns an id and derives the rest server-side, so echoing the request as
394
+ // if it were the stored row would be a fabrication. A caller that needs the
395
+ // stored row reads it back.
396
+ //
397
+ // `unsupported` (the refusal reason, else null), `ttlClamped` (the entry
398
+ // landed with a shortened life) and `createdAtDropped` (its `created` was
399
+ // unusable, so the server stamped the write instant instead). The last two
400
+ // report what HAPPENED, so both are false when the write did not succeed. A refusal fills the transport fields
401
+ // with null rather than omitting them, so a caller reading any one key never
402
+ // gets a value from one branch and `undefined` from another.
403
+ async putEntry(entry = {}, { now = new Date() } = {}) {
404
+ const refuse = (unsupported, message) => ({
405
+ ok: false,
406
+ unsupported,
407
+ // Carries a `code` like every real failure, so a caller can branch on
408
+ // one field instead of matching prose.
409
+ error: { message, code: 'unsupported' },
410
+ httpStatus: null,
411
+ retryAfter: null,
412
+ networkError: null,
413
+ // Every key any putEntry branch answers with, so a caller reading one
414
+ // never gets a value from one branch and `undefined` from another.
415
+ unusable: false,
416
+ ttlClamped: false,
417
+ createdAtDropped: false,
418
+ });
419
+ if (entry?.archived_at) {
420
+ return refuse('archived', 'archived entries cannot be written remotely — the hosted write would insert a second, live row beside the archived one');
421
+ }
422
+ const ttl = remoteTtlDays(entry?.expires_at, now);
423
+ if (ttl === 'expired') {
424
+ return refuse('expired', 'expired entries cannot be written remotely — any TTL would re-date them into the future');
425
+ }
426
+ // The same validation the server applies (`_shared/created-at.ts`, mirrored
427
+ // here), run BEFORE the request rather than discovered as a 400. A local
428
+ // file can hold a hand-edited or clock-skewed `created`, and losing the
429
+ // whole lesson over its creation date is the wrong trade: drop the
430
+ // override, let the server stamp now, and report the loss so the caller
431
+ // can say which entries were re-dated.
432
+ let createdAt;
433
+ let createdAtDropped = false;
434
+ try {
435
+ createdAt = normalizeCreatedAt(entry?.created ?? null, now) ?? undefined;
436
+ } catch {
437
+ createdAt = undefined;
438
+ createdAtDropped = Boolean(entry?.created);
439
+ }
440
+ const result = await this.write(stripUndefined({
441
+ scope: entry.scope,
442
+ key: entry.key,
443
+ value: entry.value == null ? '' : String(entry.value),
444
+ tags: Array.isArray(entry.tags) ? entry.tags : [],
445
+ source_agent: entry.source_agent,
446
+ trigger: entry.trigger,
447
+ created_at: createdAt,
448
+ // `'unknown'` sends neither field, leaving the RPC on its `'keep'`
449
+ // branch. A real TTL sends only `ttl_days`; no TTL says so explicitly
450
+ // with `clear_ttl` rather than by omission — see the fidelity note above.
451
+ ttl_days: typeof ttl === 'number' ? ttl : undefined,
452
+ clear_ttl: ttl === undefined ? true : undefined,
453
+ origin_repo: entry.origin_repo,
454
+ origin_branch: entry.origin_branch,
455
+ origin_commit: entry.origin_commit,
456
+ origin_pr: entry.origin_pr,
457
+ }));
458
+ // Always present, like every other key in this envelope — a caller must
459
+ // not have to know which branch produced the result to read it.
460
+ // Both flags describe what HAPPENED, so they are false on a write that did
461
+ // not happen — a failed request shortened nothing and re-dated nothing.
462
+ return {
463
+ ...result,
464
+ unsupported: null,
465
+ ttlClamped: Boolean(result.ok) && ttl === 365 && remoteTtlDaysExact(entry?.expires_at, now) > 365,
466
+ createdAtDropped: Boolean(result.ok) && createdAtDropped,
467
+ };
185
468
  }
186
469
 
187
470
  // Natural-key DELETE. Without `force` the server soft-archives (stamps