@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 +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/src/mcp.mjs +30 -0
- package/src/store/format.mjs +1 -1
- package/src/store/local.mjs +1 -1
- package/src/store/remote.mjs +285 -2
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
|
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
|
}
|
package/src/store/format.mjs
CHANGED
|
@@ -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
|
|
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.
|
package/src/store/local.mjs
CHANGED
|
@@ -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
|
|
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.
|
package/src/store/remote.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|