@lorekit/cli 1.37.0 → 1.39.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
@@ -672,6 +672,30 @@ Both files share this schema — all fields optional:
672
672
  // transcript; on Cursor/Codex there is none, so "friction"
673
673
  // falls back to firing so no lesson is silently lost)
674
674
 
675
+ "hooks.sessionStart": "hybrid",
676
+ // shape of the block injected at session start:
677
+ // "hybrid" (default) — fill the character budget with the
678
+ // highest-ranked memories, then add one line naming what
679
+ // was left out and where it lives
680
+ // "index" — the same list, no trailing map
681
+ // (truncation is silent)
682
+ // "map" — lead with the scope map plus the
683
+ // three most salient memories
684
+ // repo wins over user; an unrecognised value is ignored and the
685
+ // next layer is tried, so a mistyped repo value falls through to
686
+ // the user layer before defaulting to hybrid
687
+
688
+ "hooks.sessionStart.maxChars": 1500,
689
+ // character budget for that block (default 1500, ~375 tokens)
690
+ // bounded to 200–20000; an out-of-range value is CLAMPED, not
691
+ // rejected — a small number means "keep it short", and honouring
692
+ // the floor is closer to that intent than restoring the default
693
+ // repo wins over user, and a declared-but-unparseable repo value
694
+ // still claims the decision (a typo'd project policy degrades to
695
+ // the default rather than silently becoming a per-machine one)
696
+ // memories are RANKED before the budget is spent, so what
697
+ // survives is the most-recurring and most-recent, not the newest
698
+
675
699
  "hooks.adapter": "claude",
676
700
  // explicit adapter when auto-detection is ambiguous
677
701
  // values: "claude" | "cursor" | "codex"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lorekit/cli",
3
- "version": "1.37.0",
3
+ "version": "1.39.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
@@ -8,6 +8,8 @@
8
8
  // ttl.default — days until a write with no explicit TTL expires
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
+ // hooks.sessionStart — injected-block shape ("hybrid" default | "index" | "map")
12
+ // hooks.sessionStart.maxChars — character budget for that block (default 1500)
11
13
  // hooks.adapter — explicit adapter override ("claude" | "cursor" | "codex")
12
14
  //
13
15
  // Two layers of config, two kinds of statement:
@@ -55,6 +57,56 @@ export function normalizeStopMode(v) {
55
57
  return null;
56
58
  }
57
59
 
60
+ // SessionStart shape: what the injected block LOOKS like once the budget is
61
+ // spent. All three spend the same character budget; they differ in what they do
62
+ // with the lessons that did not fit.
63
+ // hybrid (default) — top-ranked lessons, then a one-line scope map naming what
64
+ // was left out, so nothing is silently invisible.
65
+ // index — lessons only. The pre-budget behaviour, minus the magic
66
+ // count: a big store is simply truncated with no map.
67
+ // map — the scope map plus a handful of the most salient lessons.
68
+ // For a large store where the inventory matters more than
69
+ // any particular lesson.
70
+ // Friendly spellings for the same reason `normalizeStopMode` has them: these are
71
+ // hand-edited JSON files.
72
+ export const SESSION_START_MODES = ['hybrid', 'index', 'map'];
73
+ export function normalizeSessionStartMode(v) {
74
+ if (typeof v !== 'string') return null;
75
+ const s = v.trim().toLowerCase();
76
+ if (['hybrid', 'both', 'auto', 'default'].includes(s)) return 'hybrid';
77
+ if (['index', 'list', 'lessons', 'full'].includes(s)) return 'index';
78
+ if (['map', 'scopes', 'summary', 'toc'].includes(s)) return 'map';
79
+ return null;
80
+ }
81
+
82
+ // The default SessionStart character budget, and the bounds a configured one is
83
+ // held to. ~1500 chars is roughly 375 tokens on the 4-chars-per-token heuristic
84
+ // — enough for a dozen index lines plus the frame, and small enough that it
85
+ // stays a footnote in a context window rather than a section of it.
86
+ //
87
+ // The floor is what one header plus one lesson line needs; below it the block
88
+ // would be a header and nothing else, which is worse than not firing. The
89
+ // ceiling is a backstop against a typo'd `"maxChars": 1500000` turning every
90
+ // session start into a wall of text — the hard lesson ceiling in
91
+ // `core/lessons.mjs` bounds it a second time, from the other direction.
92
+ export const DEFAULT_SESSION_START_MAX_CHARS = 1500;
93
+ export const MIN_SESSION_START_MAX_CHARS = 200;
94
+ export const MAX_SESSION_START_MAX_CHARS = 20000;
95
+
96
+ // Clamp a configured budget into the supported range, or null when the value is
97
+ // not a usable number at all (absent, a bare string, NaN). Total: the caller
98
+ // substitutes the default for null. Out-of-range CLAMPS rather than rejecting —
99
+ // a user who wrote `"maxChars": 50` wants a small block, and honouring the floor
100
+ // is closer to that intent than silently restoring the 1500 default.
101
+ export function normalizeSessionStartMaxChars(v) {
102
+ const n = firstNumber(v);
103
+ if (n === null) return null;
104
+ const i = Math.round(n);
105
+ if (i < MIN_SESSION_START_MAX_CHARS) return MIN_SESSION_START_MAX_CHARS;
106
+ if (i > MAX_SESSION_START_MAX_CHARS) return MAX_SESSION_START_MAX_CHARS;
107
+ return i;
108
+ }
109
+
58
110
  // A config value that is meant to be a number, or null when it is absent or is
59
111
  // something else entirely. Numeric strings are accepted because JSON configs get
60
112
  // hand-edited; the RANGE check happens later, at the point of use.
@@ -204,6 +256,30 @@ export function resolveControl({
204
256
  normalizeStopMode(userConfig['hooks.stop']) ||
205
257
  'friction';
206
258
 
259
+ // `hooks.sessionStart` — repo layer wins over user layer, default `hybrid`.
260
+ // Chooses the SHAPE of the injected block (see SESSION_START_MODES). An
261
+ // unrecognised value falls through to the next layer and finally to the
262
+ // default, exactly like `hooks.stop`: a mistyped shape must degrade to the
263
+ // sensible one, never blank the injection.
264
+ const hooksSessionStart =
265
+ normalizeSessionStartMode(repoConfig['hooks.sessionStart']) ||
266
+ normalizeSessionStartMode(userConfig['hooks.sessionStart']) ||
267
+ 'hybrid';
268
+
269
+ // `hooks.sessionStart.maxChars` — the character budget that block may spend.
270
+ //
271
+ // The LAYER is chosen before the value is parsed, the `ttl.default` rule (see
272
+ // declaresScalar): a repo that declared a budget owns the decision even when
273
+ // the value it declared is garbage, so a typo degrades to the default rather
274
+ // than silently handing the decision to whatever the developer happens to have
275
+ // in their home directory. Two people on the same commit must get the same
276
+ // block.
277
+ const sessionStartMaxCharsRaw = declaresScalar(repoConfig['hooks.sessionStart.maxChars'])
278
+ ? repoConfig['hooks.sessionStart.maxChars']
279
+ : userConfig['hooks.sessionStart.maxChars'];
280
+ const hooksSessionStartMaxChars =
281
+ normalizeSessionStartMaxChars(sessionStartMaxCharsRaw) ?? DEFAULT_SESSION_START_MAX_CHARS;
282
+
207
283
  // `hooks.adapter` — repo layer wins over user layer (explicit project override).
208
284
  const hooksAdapter =
209
285
  (typeof repoConfig['hooks.adapter'] === 'string' && repoConfig['hooks.adapter'].trim()) ||
@@ -242,6 +318,8 @@ export function resolveControl({
242
318
  scopeDefaults,
243
319
  hooksDisabled,
244
320
  hooksStop,
321
+ hooksSessionStart,
322
+ hooksSessionStartMaxChars,
245
323
  hooksAdapter,
246
324
  hooksInstructions,
247
325
  };
@@ -8,7 +8,11 @@ import { deriveScope } from '../scope.mjs';
8
8
  // can't drift from it, and the hot path never pulls in the `lessons-view.mjs`
9
9
  // render/`util` stack. (Failure-relevance matching is the store's job now, so
10
10
  // the hook no longer needs `matchesQuery` — `search` still does.)
11
- import { resolvePrecedence } from '../lessons-pure.mjs';
11
+ // `rankLessons` comes from the same dependency-free module for the same reason:
12
+ // the injected set is chosen by ONE scorer, and a future `memory.relevant` verb
13
+ // must be able to reuse it rather than grow a second ranking with its own idea
14
+ // of what "most useful" means.
15
+ import { resolvePrecedence, rankLessons } from '../lessons-pure.mjs';
12
16
  // The deep-link builder is the SAME pure module the `link` command and the
13
17
  // `--link` flag use, so the hook's confirmation/nudge links are JSON-encoded
14
18
  // correctly (a raw `?scope=global` silently means "all scopes") and can't drift
@@ -20,9 +24,45 @@ import { loreScopeUrl, buildLessonUrl } from '../deeplink-pure.mjs';
20
24
  // over MCP, in the agent's context. Advising the number is the only lever the
21
25
  // hook has, which is exactly why it must not be a second, hand-kept copy.
22
26
  import { resolveDefaultTtlDays, matchesScopePrefix } from '../store/ttl.mjs';
27
+ // The budget default lives with the rest of the config vocabulary in
28
+ // `control.mjs`, so the resolver and the renderer cannot disagree about what an
29
+ // unconfigured workspace gets. `formatLessons` is called directly by tests and
30
+ // by the no-store path in `hook.mjs`, so it needs its own fallback rather than
31
+ // relying on every caller to pass one.
32
+ import { DEFAULT_SESSION_START_MAX_CHARS, SESSION_START_MODES } from '../control.mjs';
23
33
  import { FRICTION_FAILURE, FRICTION_STUCK_LOOP } from './friction.mjs';
24
34
 
25
- const MAX_LESSONS = 15;
35
+ // THE INJECTED SET IS BOUNDED BY A CHARACTER BUDGET, NOT BY A COUNT.
36
+ //
37
+ // It used to be `MAX_LESSONS = 15` — a number with no derivation. Fifteen of
38
+ // what? A fifteen-line index of terse keys and a fifteen-line index of long ones
39
+ // differ by an order of magnitude in what they cost the context window, and the
40
+ // number said nothing about either. Worse, it was a HARD floor as well as a
41
+ // ceiling: a workspace with six lessons and a workspace with six hundred both
42
+ // got fifteen, so the small store was padded to a number and the large one was
43
+ // truncated to it, silently, with no way for the reader to know which had
44
+ // happened.
45
+ //
46
+ // What actually matters is how much of the window the block occupies, so that is
47
+ // what is spent. `hooks.sessionStart.maxChars` (control.mjs) sets it; the shape
48
+ // the remainder takes is `hooks.sessionStart`. Characters, not tokens: a real
49
+ // tokenizer is a dependency this package does not have and will not take, and
50
+ // the ~4-chars-per-token heuristic is accurate enough for a budget whose job is
51
+ // to bound an order of magnitude.
52
+ //
53
+ // `HARD_LESSON_CEILING` is the second bound, from the other direction. A budget
54
+ // alone cannot stop a store of 500 one-word keys from rendering 400 lines inside
55
+ // it, and a 400-line index is unreadable however few characters it costs. It is
56
+ // deliberately well above any budget a sane `maxChars` can fill, so in normal
57
+ // operation it never binds — it exists so the worst case is bounded, not to
58
+ // shape the common one.
59
+ const HARD_LESSON_CEILING = 40;
60
+
61
+ // How many lessons ride along with the scope map in `map` mode. Small on
62
+ // purpose: the point of that shape is the inventory, and a "map" that is mostly
63
+ // lessons is just `index` with extra steps.
64
+ const MAP_TOP_K = 3;
65
+
26
66
  // Cap on lessons injected on a failure — a small, focused "you've seen this
27
67
  // before" set, never the whole applicable corpus.
28
68
  const MAX_RELEVANT = 3;
@@ -42,26 +82,112 @@ const MAX_SCAN_CHARS = 4096;
42
82
  // precedence via the shared pure `resolvePrecedence` (the SAME first-seen /
43
83
  // more-specific-wins merge `tree` renders) — so the hook and `tree` provably
44
84
  // can't drift. Any per-scope failure is skipped (memory is best-effort).
45
- export async function fetchLessons(store, cwd) {
85
+ // Per-scope read cap. Unchanged from the count-capped era: it bounds the FETCH,
86
+ // which is a different question from how much gets injected, and raising it
87
+ // would make every session start pay for rows the budget was never going to
88
+ // show. Its one visible consequence is that a scope holding more than this
89
+ // many lessons reports a lower-bound count in the scope map — rendered `25+`
90
+ // rather than a number that looks exact. `memory.scopes` answers it exactly and
91
+ // is the follow-up that replaces this.
92
+ export const SCOPE_READ_LIMIT = 25;
93
+
94
+ export async function fetchLessons(store, cwd, { now = Date.now() } = {}) {
46
95
  const scope = deriveScope(cwd);
47
96
  const groups = [];
97
+ // Per scope: did the read come back full? Then the count below is a floor,
98
+ // not a total, and the map must say so rather than quietly under-report.
99
+ const truncatedScopes = new Set();
48
100
  for (const s of scope.readOrder) {
49
- const res = await store.list({ scope: s, limit: 25 });
101
+ const res = await store.list({ scope: s, limit: SCOPE_READ_LIMIT });
50
102
  if (!res || !res.ok) continue; // best-effort: a failed scope contributes nothing
51
- const entries = (Array.isArray(res.entries) ? res.entries : [])
103
+ const raw = Array.isArray(res.entries) ? res.entries : [];
104
+ if (raw.length >= SCOPE_READ_LIMIT) truncatedScopes.add(s);
105
+ const entries = raw
52
106
  .filter((e) => e && e.key)
53
107
  .map((e) => ({ ...e, scope: s }));
54
108
  groups.push({ scope: s, error: null, entries });
55
109
  }
56
110
  // First value per key wins (most-specific scope, since `readOrder` is
57
111
  // narrow→broad) — exactly what the old inline `byKey` merge did, now via the
58
- // one shared resolver. The winners, in group order, are the injected set.
112
+ // one shared resolver.
59
113
  const { groups: resolved } = resolvePrecedence({ groups });
60
- const lessons = [];
114
+ const winners = [];
61
115
  for (const g of resolved) {
62
- for (const e of g.entries) if (e.winning) lessons.push(e);
116
+ for (const e of g.entries) if (e.winning) winners.push(e);
63
117
  }
64
- return { scope, lessons: lessons.slice(0, MAX_LESSONS) };
118
+
119
+ // THE TWO STEPS ANSWER DIFFERENT QUESTIONS, AND THE ORDER MATTERS.
120
+ //
121
+ // `resolvePrecedence` decides WHICH COPY of a key survives — a project
122
+ // lesson shadows the global lesson of the same name — and that is a
123
+ // correctness rule, not a preference. `rankLessons` then decides WHICH OF THE
124
+ // SURVIVORS a reader sees first. Ranking runs on the winners only, so a
125
+ // shadowed lesson can never be promoted back into the set by scoring well;
126
+ // precedence still owns the merge, exactly as `tree` renders it.
127
+ //
128
+ // Before this, the cap took whatever the group order happened to hand it,
129
+ // which is recency within a scope. On an active repo the newest cluster is
130
+ // one task's iteration log, so a dozen near-identical one-offs took the whole
131
+ // budget and evicted the lessons that had been re-learned all month. Recency
132
+ // is one factor now, not the ordering.
133
+ //
134
+ // WHAT PRECEDENCE DOES *NOT* DO, STATED SO IT IS NOT MISREAD AS A BUG.
135
+ // Precedence only settles same-key collisions. Across DIFFERENT keys the cap
136
+ // is now a single cross-scope ranking in which `scopeOrder` is a TIEBREAK
137
+ // (`rankLessons` sorts on score first and only compares `scopeRank` inside
138
+ // `SCORE_EPSILON`) — so a recurring `global::` lesson can and will take a
139
+ // slot from a fresher-but-one-off `project::` one, where the old narrow-first
140
+ // group order always filled the budget from the most-specific scope down.
141
+ // That is the intended trade: the budget goes to what has been re-learned,
142
+ // wherever it lives, and a scope-major sort would reinstate exactly the
143
+ // "whatever the group order hands it" behaviour this change removes. If a
144
+ // narrow scope should instead be guaranteed floor space, that is a weighting
145
+ // change in `rankLessons`, not something to re-derive here.
146
+ //
147
+ // `terms: []` is the SessionStart case: nothing has been asked yet, so the
148
+ // relevance factor contributes nothing and the order is recency + salience.
149
+ // `scopeOrder` is passed explicitly rather than left to the scorer's
150
+ // first-appearance default — they agree today, but the hierarchy is
151
+ // `readOrder`'s to state, not an artefact of how this function happens to
152
+ // build its array.
153
+ const ranked = rankLessons(winners, { terms: [], now, scopeOrder: scope.readOrder });
154
+
155
+ // The scope map is built from the SAME pass, over the winners — the set a
156
+ // reader could actually act on, so a shadowed duplicate is not counted twice.
157
+ // Order follows `readOrder` (most-specific first) rather than count, because
158
+ // the map is a map: it should read like the hierarchy it describes.
159
+ const scopeCounts = scopeInventory(ranked, scope.readOrder, truncatedScopes);
160
+
161
+ // `applicable` is the honest denominator for the header — how many the reader
162
+ // has, as opposed to how many fitted. It is counted BEFORE the ceiling, so
163
+ // "8 of 50" stays true no matter how the render is bounded.
164
+ return {
165
+ scope,
166
+ lessons: ranked.slice(0, HARD_LESSON_CEILING),
167
+ scopeCounts,
168
+ applicable: ranked.length,
169
+ };
170
+ }
171
+
172
+ // Per-scope counts over an already-ranked lesson list, in the given scope order.
173
+ // A scope with no surviving lesson is omitted — a map row reading `0` is noise,
174
+ // and the reader cannot act on an empty scope. Pure.
175
+ export function scopeInventory(lessons, scopeOrder = [], truncated = new Set()) {
176
+ const counts = new Map();
177
+ for (const l of Array.isArray(lessons) ? lessons : []) {
178
+ const s = l?.scope;
179
+ if (!s) continue;
180
+ counts.set(s, (counts.get(s) || 0) + 1);
181
+ }
182
+ // `scopeOrder` first (the hierarchy), then anything else that turned up, so a
183
+ // lesson carrying an unexpected scope is still counted rather than dropped.
184
+ const ordered = [...scopeOrder.filter((s) => counts.has(s))];
185
+ for (const s of counts.keys()) if (!ordered.includes(s)) ordered.push(s);
186
+ return ordered.map((s) => ({
187
+ scope: s,
188
+ count: counts.get(s),
189
+ atReadLimit: truncated.has ? truncated.has(s) : false,
190
+ }));
65
191
  }
66
192
 
67
193
  // Cap on a lesson's one-line hook in the injected index. Long enough to jog
@@ -97,23 +223,103 @@ function lessonHook(value, max = HOOK_LEN) {
97
223
  // `hooks.instructions.SessionStart` in the control config. Lets teams inject
98
224
  // project-specific guidance (e.g. "focus on migration safety") without touching
99
225
  // the hook internals. Visible even when there are no lessons.
100
- export function formatLessons(lessons, scope, { instruction = null } = {}) {
101
- const noun = lessons && lessons.length === 1 ? 'memory' : 'memories';
102
- if (!lessons || lessons.length === 0) {
226
+ export function formatLessons(lessons, scope, {
227
+ instruction = null,
228
+ mode = 'hybrid',
229
+ maxChars = DEFAULT_SESSION_START_MAX_CHARS,
230
+ scopeCounts = null,
231
+ applicable = null,
232
+ } = {}) {
233
+ const all = Array.isArray(lessons) ? lessons : [];
234
+ if (all.length === 0) {
103
235
  // No lessons — only emit if there is a custom instruction to show.
104
236
  if (!instruction) return null;
105
237
  return (
106
- `LoreKit: 0 ${noun} loaded · ${scope.repoScope || 'this workspace'} ` +
238
+ `LoreKit: 0 memories loaded · ${scope.repoScope || 'this workspace'} ` +
107
239
  `— considerations, not rules; read any in full with memory.read.\n\n` +
108
240
  `Project instruction: ${instruction}`
109
241
  );
110
242
  }
243
+
244
+ const budget = Number.isFinite(maxChars) && maxChars > 0
245
+ ? maxChars
246
+ : DEFAULT_SESSION_START_MAX_CHARS;
247
+ const shape = SESSION_START_MODES.includes(mode) ? mode : 'hybrid';
248
+ const total = typeof applicable === 'number' && applicable >= all.length ? applicable : all.length;
249
+
250
+ // The map line is composed BEFORE the lessons are chosen, because in `hybrid`
251
+ // its length has to be reserved out of the budget. Appending it afterwards
252
+ // would let the block overrun the very number the config asked for — a budget
253
+ // you can exceed by one more line is not a budget.
254
+ const map = renderScopeMap(scopeCounts);
255
+ const reserve = shape === 'index' || !map ? 0 : map.length + 1;
256
+
257
+ const ceiling = shape === 'map' ? Math.min(MAP_TOP_K, HARD_LESSON_CEILING) : HARD_LESSON_CEILING;
258
+ const { shown } = fitLines(all, budget - reserve, ceiling);
259
+
260
+ // `map` always shows the inventory; `hybrid` shows it only when something was
261
+ // actually left out — otherwise the reader is looking at the complete set and
262
+ // a "…and here is what you are missing" line would be a lie. `index` never
263
+ // shows it, which is the whole difference between `index` and `hybrid`.
264
+ const showMap = Boolean(map) && (shape === 'map' || (shape === 'hybrid' && shown.length < total));
265
+
266
+ // Say `8 of 50` whenever the two differ. The old header reported only what it
267
+ // had rendered, so a truncated block was indistinguishable from a complete one
268
+ // and the agent had no way to know that reaching for `memory.search` was worth
269
+ // it. The count is what makes the truncation self-describing.
270
+ const counted = shown.length === total
271
+ ? `${total} ${total === 1 ? 'memory' : 'memories'}`
272
+ : `${shown.length} of ${total} memories`;
111
273
  const header =
112
- `LoreKit: ${lessons.length} ${noun} loaded · ${scope.repoScope || 'this workspace'} ` +
274
+ `LoreKit: ${counted} loaded · ${scope.repoScope || 'this workspace'} ` +
113
275
  `— considerations, not rules; read any in full with memory.read.`;
114
- const body = lessons.map((l) => `- (${l.scope}) ${l.key} — ${lessonHook(l.value)}`).join('\n');
276
+
277
+ const parts = [header, ...shown.map((s) => s.line)];
278
+ if (showMap) parts.push(map);
115
279
  const instructionBlock = instruction ? `\n\nProject instruction: ${instruction}` : '';
116
- return `${header}\n${body}${instructionBlock}`;
280
+ return `${parts.join('\n')}${instructionBlock}`;
281
+ }
282
+
283
+ // One index line for a lesson. Pure.
284
+ function lessonLine(l) {
285
+ return `- (${l.scope}) ${l.key} — ${lessonHook(l.value)}`;
286
+ }
287
+
288
+ // Take lessons in order until the next line would not fit, or the ceiling is
289
+ // reached. Returns `{ shown, used }`.
290
+ //
291
+ // THE FIRST LINE IS ALWAYS TAKEN, even when it alone exceeds the budget. A
292
+ // header with nothing under it is strictly worse than one over-long line: the
293
+ // reader learns nothing and cannot tell whether the store is empty or the budget
294
+ // is misconfigured. One line over budget is a visible, self-explaining overrun;
295
+ // zero lines is a silent one. Pure.
296
+ function fitLines(lessons, budget, ceiling) {
297
+ const shown = [];
298
+ let used = 0;
299
+ for (const l of lessons) {
300
+ if (shown.length >= ceiling) break;
301
+ const line = lessonLine(l);
302
+ const cost = line.length + 1; // the newline that joins it
303
+ if (shown.length > 0 && used + cost > budget) break;
304
+ used += cost;
305
+ shown.push({ lesson: l, line });
306
+ }
307
+ return { shown, used };
308
+ }
309
+
310
+ // The scope map: one line naming every scope that holds lessons and how many,
311
+ // so a truncated block still tells the reader WHERE the rest live and which verb
312
+ // reaches them. `25+` marks a scope whose read hit `SCOPE_READ_LIMIT`, so a
313
+ // lower bound never reads as an exact total. Null when there is nothing to
314
+ // describe. Pure.
315
+ export function renderScopeMap(scopeCounts) {
316
+ const rows = (Array.isArray(scopeCounts) ? scopeCounts : [])
317
+ .filter((s) => s && s.scope && Number(s.count) > 0);
318
+ if (rows.length === 0) return null;
319
+ const body = rows
320
+ .map((s) => `${s.scope} ${s.count}${s.atReadLimit ? '+' : ''}`)
321
+ .join(' · ');
322
+ return `More lore: ${body} — memory.search or memory.read to drill in.`;
117
323
  }
118
324
 
119
325
  // Distil a small set of significant, lowercased search TERMS from a tool
@@ -30,7 +30,12 @@ export const DEFAULT_APP_BASE = 'https://lorekit.io';
30
30
  export const LORE_PARAM_DEFAULTS = {
31
31
  scope: null, // string | null — null means "all scopes"
32
32
  q: '', // string search query
33
- range: null, // { from, to } | null (DateRange, "YYYY-MM-DD")
33
+ // { from, to } | { preset } | null. The CLI emits only the { from, to } arm
34
+ // (day strings via --range/--from/--to), which the Explorer still reads as
35
+ // whole UTC days with an INCLUSIVE end day — unchanged. The web model also
36
+ // accepts ISO instants in that arm and a relative { preset: '7d' } arm
37
+ // (packages/web/src/lib/time-range.ts); neither has a CLI flag yet.
38
+ range: null,
34
39
  owner: 'all', // 'all' | 'personal' | { orgId }
35
40
  // Filter[] | null — the Explorer's multi-dimension filter bar (label / agent /
36
41
  // trigger / repo / branch / pr). `null`, NOT `[]`, is the default on purpose:
@@ -42,7 +47,14 @@ export const LORE_PARAM_DEFAULTS = {
42
47
  filters: null,
43
48
  tags: [], // string[] — legacy label filter (AND across labels); [] means "no filter". Still READ by the app, superseded by `filters`
44
49
  view: 'scope', // 'scope' | 'time'
45
- archived: false, // boolean
50
+ // 'active' | 'archived' | 'expiring' | null — the Explorer's Status control.
51
+ // `null`, NOT 'active', is the default for `filters`' reason: the app has to
52
+ // tell "absent" from an explicit choice, because an absent `status` falls back
53
+ // to the legacy `archived` flag while an explicit `status=active` overrides it.
54
+ status: null,
55
+ // boolean — SUPERSEDED by `status`, still READ by the app so existing links
56
+ // (and `lorekit link --archived`) keep resolving to the archived view.
57
+ archived: false,
46
58
  lesson: null, // { scope, key } | null — opens the detail sheet
47
59
  };
48
60
 
@@ -50,7 +62,7 @@ export const LORE_PARAM_DEFAULTS = {
50
62
  // Mirrors the `useUrlState` call order in `LoreExplorer.tsx` (+ the `lesson`
51
63
  // param last), so `filters` and `tags` sit between `owner` and `view`. `scope`
52
64
  // precedes `lesson` so a lesson link reads `?scope=…&lesson=…`.
53
- const PARAM_ORDER = ['scope', 'q', 'range', 'owner', 'filters', 'tags', 'view', 'archived', 'lesson'];
65
+ const PARAM_ORDER = ['scope', 'q', 'range', 'owner', 'filters', 'tags', 'view', 'status', 'archived', 'lesson'];
54
66
 
55
67
  // Strip trailing slashes from a base URL, falling back to the default when the
56
68
  // input is empty/absent. Pure.
package/src/hook.mjs CHANGED
@@ -106,8 +106,14 @@ async function run(args) {
106
106
  }
107
107
  return 0;
108
108
  }
109
- const { scope: readScope, lessons } = await fetchLessons(store, root);
110
- emit(formatLessons(lessons, readScope, { instruction: sessionInstruction }));
109
+ const { scope: readScope, lessons, scopeCounts, applicable } = await fetchLessons(store, root);
110
+ emit(formatLessons(lessons, readScope, {
111
+ instruction: sessionInstruction,
112
+ mode: control.hooksSessionStart,
113
+ maxChars: control.hooksSessionStartMaxChars,
114
+ scopeCounts,
115
+ applicable,
116
+ }));
111
117
  return 0;
112
118
  }
113
119
 
@@ -199,3 +199,344 @@ export function resolveScopeKeyArgs(positionals = [], options = {}) {
199
199
  const { scope, key } = resolveScopeArg(first, isScope);
200
200
  return { scope: scope || '', key, consumed: 1 };
201
201
  }
202
+
203
+ // ── ranking: which lessons are worth the context budget ──────────────────────
204
+ //
205
+ // `resolvePrecedence` above answers "which SCOPE's copy of a key wins". This
206
+ // answers the different question the injection path actually has: given the
207
+ // winners, which ones does a reader most need to see FIRST.
208
+ //
209
+ // It exists because ordering by recency alone is actively harmful on a busy
210
+ // repo. The newest cluster of writes is usually one task's iteration log — a
211
+ // dozen near-identical one-off lessons — and under a recency sort that cluster
212
+ // takes every slot, evicting the durable lessons that have been re-learned a
213
+ // dozen times. Recency is a signal, not the ranking.
214
+ //
215
+ // The score is a weighted sum of three factors, each normalised to [0,1]:
216
+ //
217
+ // recency — exponential decay on age. Half-life, not a cliff: a lesson does
218
+ // not stop mattering on a particular day.
219
+ // salience — log(1 + seenCount), normalised across the candidate set. A
220
+ // lesson written eight times has been re-learned; one written
221
+ // once may just be noise. Logarithmic because the interesting
222
+ // step is 1 → 3, not 40 → 42, and normalised across the set
223
+ // because "recurring" only means anything relative to its peers.
224
+ // relevance — how much of the caller's query this lesson matches. Exactly 0
225
+ // when no terms are supplied, which is the SessionStart case: it
226
+ // then contributes the same constant to every candidate and the
227
+ // ordering is recency + salience alone.
228
+ //
229
+ // PURE AND TOTAL, with one scoped exception. `now` is a PARAMETER: the
230
+ // arithmetic never reads the clock, every factor is a function of the value
231
+ // passed in, and a caller that supplies one gets a ranking that is exactly
232
+ // reproducible in a test and in a bug report. `scoreLesson` and `rankLessons`
233
+ // default it to `Date.now()` at the call boundary so the common caller need not
234
+ // thread a clock through — that default is the ONLY clock read, it happens once
235
+ // per call, and passing `now` explicitly removes it. Every missing or malformed
236
+ // field degrades to its zero rather than throwing — this runs on the
237
+ // SessionStart hot path behind a hook that must exit 0, and losing the whole
238
+ // injection to save one unparseable timestamp is a bad trade.
239
+
240
+ // Age at which a lesson's recency factor halves. Two weeks is roughly the span
241
+ // over which a repo's "what am I working on" context turns over: yesterday's
242
+ // lesson should clearly outrank last month's, without last month's dropping to
243
+ // nothing — a year-old lesson that has recurred 30 times still deserves a slot.
244
+ export const RECENCY_HALF_LIFE_DAYS = 14;
245
+
246
+ // Equal thirds. Deliberately not tuned: with no corpus to tune against, an
247
+ // invented weighting is a guess wearing a decimal point. They are a parameter
248
+ // so a caller can experiment, and so a future PR can change them with evidence.
249
+ //
250
+ // FROZEN. An exported mutable object is shared state, and this one is the
251
+ // fallback the totality guarantee rests on: a caller that zeroed its fields
252
+ // turned `scoreLesson`'s "fall back to the defaults" branch into unbounded
253
+ // recursion (a real `RangeError`, raised inside a hook the header promises will
254
+ // never throw). Freezing makes the corruption a `TypeError` at the assignment,
255
+ // in the caller's own frame, instead of a stack overflow three layers down.
256
+ export const DEFAULT_RANK_WEIGHTS = Object.freeze({ recency: 1, salience: 1, relevance: 1 });
257
+
258
+ // Two scores closer than this are the same score. Sized well below any
259
+ // difference the factors can produce meaningfully (a one-second age gap moves a
260
+ // score by ~1e-6 at the default half-life) and well above float noise.
261
+ export const SCORE_EPSILON = 1e-9;
262
+
263
+ const MS_PER_DAY = 86400000;
264
+
265
+ // Milliseconds since the epoch for a value that may be an ISO string, a Date, a
266
+ // number, or junk. `null` when it cannot be read as a time — never NaN, which
267
+ // would propagate silently through the arithmetic below and sort the entry to
268
+ // wherever NaN happens to land.
269
+ function timeOf(value) {
270
+ if (value == null || value === '') return null;
271
+ const t = value instanceof Date ? value.getTime() : new Date(value).getTime();
272
+ return Number.isFinite(t) ? t : null;
273
+ }
274
+
275
+ /**
276
+ * Recency factor in [0,1]: 1 for something written now, 0.5 at one half-life.
277
+ *
278
+ * An unknown timestamp scores 0, NOT 0.5. The alternative — treating unknown as
279
+ * average — would let a lesson with no `updatedAt` outrank a real one that is
280
+ * merely a month old, on the strength of having less information about it.
281
+ *
282
+ * A FUTURE timestamp is clamped to 1 rather than allowed to exceed it. Clock
283
+ * skew between a writer and a reader is ordinary; a lesson from thirty seconds
284
+ * in the future is simply new, and letting age go negative would hand it an
285
+ * unbounded score that beats every honestly-dated lesson in the set.
286
+ */
287
+ export function recencyFactor(updatedAt, now, halfLifeDays = RECENCY_HALF_LIFE_DAYS) {
288
+ const t = timeOf(updatedAt);
289
+ const nowMs = timeOf(now);
290
+ if (t === null || nowMs === null) return 0;
291
+ const halfLife = Number.isFinite(halfLifeDays) && halfLifeDays > 0
292
+ ? halfLifeDays
293
+ : RECENCY_HALF_LIFE_DAYS;
294
+ const ageDays = Math.max(0, (nowMs - t) / MS_PER_DAY);
295
+ return Math.exp((-Math.LN2 * ageDays) / halfLife);
296
+ }
297
+
298
+ /**
299
+ * Salience factor in [0,1] — recurrence, relative to the most-recurring lesson
300
+ * in the same candidate set.
301
+ *
302
+ * `maxSeenCount` is passed in rather than derived here because the factor is
303
+ * only meaningful within a set: eight sightings is remarkable next to a set of
304
+ * one-offs and unremarkable next to a set of hundreds. `rankLessons` computes
305
+ * it once for the whole set.
306
+ *
307
+ * A set whose maximum is 0 or 1 yields 0 for every member — no lesson in it has
308
+ * recurred, so salience has nothing to say and the other factors decide. Note
309
+ * this makes a set of all-one-offs rank purely on recency, which is correct:
310
+ * the ranking only claims to separate recurring from non-recurring.
311
+ *
312
+ * A `seenCount` ABOVE `maxSeenCount` is clamped to 1 rather than allowed to
313
+ * exceed it, for the same reason `recencyFactor` clamps a future timestamp.
314
+ * `rankLessons` derives the maximum from the set, so in-set the clamp never
315
+ * binds; it binds when a caller reaches this (or `scoreLesson`) directly with a
316
+ * maximum that is not the set's, and without it the `[0,1]` both docblocks
317
+ * promise would simply be false — `salienceFactor(5, 2)` is 1.63.
318
+ */
319
+ export function salienceFactor(seenCount, maxSeenCount) {
320
+ const n = Number.isFinite(seenCount) ? Math.max(0, seenCount) : 0;
321
+ const max = Number.isFinite(maxSeenCount) ? Math.max(0, maxSeenCount) : 0;
322
+ if (max <= 1) return 0;
323
+ return Math.min(1, Math.log1p(n) / Math.log1p(max));
324
+ }
325
+
326
+ /**
327
+ * Relevance factor in [0,1] — the fraction of the caller's terms this lesson
328
+ * matches.
329
+ *
330
+ * Matching is `matchesQuery`'s: a LITERAL, case-insensitive substring of the
331
+ * key or the value. That is the same primitive `search` and the failure hook
332
+ * use, so a lesson that a `lorekit search <term>` would surface is a lesson
333
+ * this ranks up — one matcher, one meaning of "matches".
334
+ *
335
+ * The fraction is over DISTINCT terms, so a term repeated in the caller's list
336
+ * cannot inflate a lesson's score, and it is a fraction rather than a count so
337
+ * a three-term query and a ten-term query produce comparable numbers.
338
+ *
339
+ * Empty terms is 0, never 1. This is the SessionStart case and it must not
340
+ * silently become "everything is maximally relevant" — which, being a constant,
341
+ * would not change the ORDER but would compress the score range and make every
342
+ * downstream threshold meaningless.
343
+ *
344
+ * `terms` is a list, a lone term, or any `Set` — every form is normalised the
345
+ * same way, so `new Set([' Timeout '])` and `[' Timeout ']` score identically.
346
+ * Normalisation is NOT a caller responsibility and there is no pre-normalised
347
+ * fast path on this function: an unnormalised `Set` reaching the matcher scored
348
+ * `''` as a match on everything and a padded term as a match on nothing.
349
+ * `rankLessons` gets the once-per-ranking saving from the internal
350
+ * `relevanceFromTerms` instead, where the set is one this module built.
351
+ */
352
+ export function relevanceFactor(entry, terms) {
353
+ return relevanceFromTerms(entry, distinctTerms(terms));
354
+ }
355
+
356
+ // The matcher, over a set this module has already normalised. Internal on
357
+ // purpose: it is the seam that lets `rankLessons` normalise the query ONCE for
358
+ // the whole ranking instead of once per candidate (O(entries × terms) for a
359
+ // result that cannot differ between entries) without turning "hand me a
360
+ // correctly-shaped Set" into part of the public contract.
361
+ function relevanceFromTerms(entry, distinct) {
362
+ if (distinct.size === 0) return 0;
363
+ let hits = 0;
364
+ for (const term of distinct) if (matchesQuery(entry, term)) hits += 1;
365
+ return hits / distinct.size;
366
+ }
367
+
368
+ // The caller's query as a set of distinct, lowercased, non-empty terms. Accepts
369
+ // a list, a `Set`, or a lone value, and normalises the CONTENTS in every case —
370
+ // an earlier version short-circuited on `instanceof Set`, which let a
371
+ // hand-built set skip trimming and empty-filtering and diverge from the list
372
+ // path.
373
+ function distinctTerms(terms) {
374
+ const list = Array.isArray(terms) || terms instanceof Set ? [...terms] : [terms];
375
+ return new Set(
376
+ list
377
+ .map((t) => String(t == null ? '' : t).toLowerCase().trim())
378
+ .filter(Boolean),
379
+ );
380
+ }
381
+
382
+ /**
383
+ * Score one lesson in [0,1].
384
+ *
385
+ * `maxSeenCount` belongs to the candidate SET, so this is normally reached
386
+ * through `rankLessons` rather than called directly; it is exported so a caller
387
+ * can explain a ranking ("why is this one third?") without re-deriving the
388
+ * arithmetic.
389
+ *
390
+ * The weighted sum is divided by the total weight, so the result stays in [0,1]
391
+ * whatever weights are supplied and two runs with different weightings remain
392
+ * comparable. A weight set that sums to zero falls back to the defaults rather
393
+ * than dividing by zero — ONCE, by substitution rather than by recursion. The
394
+ * recursive form was total only while `DEFAULT_RANK_WEIGHTS` still summed above
395
+ * zero, which made a totality guarantee depend on an exported object nobody had
396
+ * mutated yet. If even the defaults are degenerate every lesson scores 0, which
397
+ * is honest (no weighted signal is left) and hands the ordering to the
398
+ * tiebreakers instead of to a stack overflow.
399
+ */
400
+ export function scoreLesson(entry, {
401
+ terms = [],
402
+ now = Date.now(),
403
+ weights = DEFAULT_RANK_WEIGHTS,
404
+ maxSeenCount = 0,
405
+ halfLifeDays = RECENCY_HALF_LIFE_DAYS,
406
+ } = {}) {
407
+ return scoreWithTerms(entry, distinctTerms(terms), { now, weights, maxSeenCount, halfLifeDays });
408
+ }
409
+
410
+ // `scoreLesson` with the query already normalised. `rankLessons` calls this so
411
+ // the whole ranking normalises the query once; `scoreLesson` normalises and
412
+ // delegates, so no caller has to know a normalised set exists.
413
+ function scoreWithTerms(entry, termSet, { now, weights, maxSeenCount, halfLifeDays }) {
414
+ let w = {
415
+ recency: numberOr(weights?.recency, DEFAULT_RANK_WEIGHTS.recency),
416
+ salience: numberOr(weights?.salience, DEFAULT_RANK_WEIGHTS.salience),
417
+ relevance: numberOr(weights?.relevance, DEFAULT_RANK_WEIGHTS.relevance),
418
+ };
419
+ let total = w.recency + w.salience + w.relevance;
420
+ if (!(total > 0)) {
421
+ w = {
422
+ recency: numberOr(DEFAULT_RANK_WEIGHTS.recency, 0),
423
+ salience: numberOr(DEFAULT_RANK_WEIGHTS.salience, 0),
424
+ relevance: numberOr(DEFAULT_RANK_WEIGHTS.relevance, 0),
425
+ };
426
+ total = w.recency + w.salience + w.relevance;
427
+ }
428
+ if (!(total > 0)) return 0;
429
+ const recency = recencyFactor(entry?.updatedAt ?? entry?.updated_at ?? entry?.updated, now, halfLifeDays);
430
+ const salience = salienceFactor(seenCountFrom(entry), maxSeenCount);
431
+ const relevance = relevanceFromTerms(entry, termSet);
432
+ return (w.recency * recency + w.salience * salience + w.relevance * relevance) / total;
433
+ }
434
+
435
+ // A non-negative finite number, or the fallback. Guards a caller passing a
436
+ // string, null, or NaN as a weight.
437
+ function numberOr(value, fallback) {
438
+ const n = typeof value === 'string' ? Number(value) : value;
439
+ return typeof n === 'number' && Number.isFinite(n) && n >= 0 ? n : fallback;
440
+ }
441
+
442
+ // The recurrence count off an entry, in either the store-projected form
443
+ // (`seenCount`, what `store/entry-fields.mjs` produces) or the raw REST/
444
+ // frontmatter spelling. Anything unreadable is 0 — no evidence of recurrence,
445
+ // which is not the same claim as one sighting.
446
+ function seenCountFrom(entry) {
447
+ const raw = entry?.seenCount ?? entry?.seen_count;
448
+ const n = typeof raw === 'string' ? Number(raw) : raw;
449
+ return typeof n === 'number' && Number.isFinite(n) && n > 0 ? n : 0;
450
+ }
451
+
452
+ /**
453
+ * Rank lessons best-first, returning a NEW array — the input is never reordered
454
+ * in place, because callers hold it (`fetchLessons` builds it from the
455
+ * precedence resolution and `tree` renders the same objects).
456
+ *
457
+ * Ties are broken deterministically, and the order of the tiebreakers is the
458
+ * design:
459
+ *
460
+ * 1. score, descending.
461
+ * 2. SCOPE PRECEDENCE — the position of the entry's scope in `scopeOrder`,
462
+ * which defaults to the order scopes first appear in the input. That
463
+ * default is not a convenience: `fetchLessons` hands entries over in
464
+ * `readOrder`, most-specific first, so the default IS the precedence
465
+ * hierarchy, and a project lesson beats a global one it ties with, for
466
+ * free and without this module knowing what a scope is.
467
+ * 3. key, lexicographically — the last resort, so the answer cannot depend on
468
+ * hash iteration order or on which page a row arrived in.
469
+ *
470
+ * Sorting on floats makes exact ties rarer than they look, so tiebreaker 2 also
471
+ * quietly matters for NEAR ties: two lessons written in the same minute with
472
+ * the same count differ in the tenth decimal place, which is noise, not a
473
+ * preference. `SCORE_EPSILON` treats a difference that small as a tie so the
474
+ * meaningful tiebreaker decides instead of floating-point dust.
475
+ *
476
+ * That tie is applied by QUANTISING each score onto a `SCORE_EPSILON` grid, not
477
+ * by an `abs(a - b) <= SCORE_EPSILON` comparison. The comparison form reads
478
+ * more naturally and is wrong: approximate equality is not TRANSITIVE, so three
479
+ * scores a grid-step apart give a≈b, b≈c and c>a, the comparator stops being a
480
+ * strict weak ordering, and the result depends on the order the rows arrived in
481
+ * — exactly the determinism this docblock claims. Measured before the fix:
482
+ * three lessons 3ms apart produced three different orderings across the six
483
+ * permutations of one input. Rounding first makes "same score" an equivalence
484
+ * relation, so the scope/key tiebreak is what decides every near tie.
485
+ *
486
+ * The residual cost is a boundary: two scores closer than a grid step can still
487
+ * land in adjacent buckets and be ordered by score. That is unavoidable for any
488
+ * transitive notion of approximate equality, and it is a far smaller defect
489
+ * than an ordering that changes with input order.
490
+ */
491
+ export function rankLessons(entries = [], {
492
+ terms = [],
493
+ now = Date.now(),
494
+ weights = DEFAULT_RANK_WEIGHTS,
495
+ halfLifeDays = RECENCY_HALF_LIFE_DAYS,
496
+ scopeOrder = null,
497
+ } = {}) {
498
+ const list = Array.isArray(entries) ? entries.filter((e) => e && typeof e === 'object') : [];
499
+ if (list.length === 0) return [];
500
+
501
+ // Normalise the query ONCE for the whole ranking, not once per candidate —
502
+ // `scoreWithTerms` takes the set this module built straight through.
503
+ const termSet = distinctTerms(terms);
504
+
505
+ // One pass for the set-relative normaliser, so scoring stays O(n).
506
+ let maxSeenCount = 0;
507
+ for (const e of list) maxSeenCount = Math.max(maxSeenCount, seenCountFrom(e));
508
+
509
+ // Scope precedence: an explicit order wins, else first-appearance order.
510
+ const rankByScope = new Map();
511
+ for (const s of Array.isArray(scopeOrder) ? scopeOrder : []) {
512
+ if (!rankByScope.has(s)) rankByScope.set(s, rankByScope.size);
513
+ }
514
+ for (const e of list) {
515
+ const s = e.scope;
516
+ if (s !== undefined && !rankByScope.has(s)) rankByScope.set(s, rankByScope.size);
517
+ }
518
+
519
+ const scored = list.map((entry, index) => ({
520
+ entry,
521
+ index,
522
+ // The score rounded onto the SCORE_EPSILON grid. Comparing THIS rather than
523
+ // the raw score is what keeps "close enough to be a tie" transitive — see
524
+ // the docblock. A score is in [0,1] and the grid is 1e-9, so the bucket is
525
+ // always a safe integer.
526
+ bucket: Math.round(
527
+ scoreWithTerms(entry, termSet, { now, weights, maxSeenCount, halfLifeDays }) / SCORE_EPSILON,
528
+ ),
529
+ scopeRank: rankByScope.has(entry.scope) ? rankByScope.get(entry.scope) : Number.MAX_SAFE_INTEGER,
530
+ key: String(entry.key ?? ''),
531
+ }));
532
+
533
+ scored.sort((a, b) => {
534
+ if (a.bucket !== b.bucket) return b.bucket - a.bucket;
535
+ if (a.scopeRank !== b.scopeRank) return a.scopeRank - b.scopeRank;
536
+ if (a.key !== b.key) return a.key < b.key ? -1 : 1;
537
+ return a.index - b.index; // stable: equal in every respect keeps input order
538
+ });
539
+
540
+ return scored.map((s) => s.entry);
541
+ }
542
+
@@ -0,0 +1,79 @@
1
+ // The read-shape fields a ranking layer needs, derived from a store row.
2
+ //
3
+ // Both stores answer with rows in their OWN vocabulary — the remote store hands
4
+ // back a REST `MemoryEntry` (`seen_count`, `updated_at`), the local store hands
5
+ // back parsed frontmatter (`seen_count`, `updated`). A ranker cannot care which
6
+ // one it is holding, so the READ-FIELD projection lives here, once, and both
7
+ // stores apply it on the way out. Two copies of "which key holds the timestamp"
8
+ // is exactly the drift the repo's mirror guards exist to prevent — see
9
+ // `updatedAtOf` for the one other place in the CLI that maps the same pair, and
10
+ // why it is not the same projection.
11
+ //
12
+ // TOTAL FUNCTIONS. Every entry point below is defined for any input — a null
13
+ // row, a string where a number belongs, a hand-edited frontmatter scalar, a
14
+ // response from a backend deployed before the column existed. This code runs on
15
+ // the SessionStart hot path behind a hook that must always exit 0, so a throw
16
+ // here would cost the user their lesson injection to save a field nobody
17
+ // promised. Missing or unusable degrades to the documented default and the
18
+ // caller ranks on what it does have.
19
+ //
20
+ // Zero-dependency: no imports, not even node builtins.
21
+
22
+ /**
23
+ * How many times this lesson has been written.
24
+ *
25
+ * `0` — not `1` — is the absent case, and the distinction is load-bearing.
26
+ * A live remote row always carries at least `1` (the column is `NOT NULL
27
+ * DEFAULT 1`), so `0` can only mean "this store did not tell me", which a
28
+ * salience score should read as no evidence rather than as one sighting.
29
+ * Fractions are floored and negatives clamped: the count is a tally.
30
+ */
31
+ export function seenCountOf(row) {
32
+ const raw = row?.seen_count;
33
+ const n = typeof raw === 'string' ? Number(raw) : raw;
34
+ if (typeof n !== 'number' || !Number.isFinite(n)) return 0;
35
+ return Math.max(0, Math.floor(n));
36
+ }
37
+
38
+ /**
39
+ * When this lesson was last written, as an ISO 8601 string, or `null`.
40
+ *
41
+ * Accepts either store's spelling (`updated_at` remote, `updated` local) and
42
+ * normalises through `Date` so a caller can compare two stores' entries without
43
+ * knowing which produced them. An unparseable value is `null`, never `Invalid
44
+ * Date` and never the raw text: a recency decay fed `NaN` silently sinks the
45
+ * entry to the bottom, which is a worse failure than admitting the timestamp is
46
+ * unknown.
47
+ *
48
+ * `normalizeEntry` (`src/lessons-view.mjs`) maps the same two spellings at the
49
+ * OPPOSITE precedence — `updated ?? updated_at`, local first — and that is
50
+ * deliberate, not drift: it builds the human/`--json` VIEW row, where a local
51
+ * file's own `updated` is the authoritative one and the value is passed through
52
+ * verbatim for rendering. This function builds the RANKING row, where the
53
+ * remote column is authoritative and the value is normalised through `Date`.
54
+ * Two different projections over one pair of keys, so neither can call the
55
+ * other; keep them in step by hand when a spelling changes.
56
+ */
57
+ export function updatedAtOf(row) {
58
+ const raw = row?.updated_at ?? row?.updated;
59
+ if (raw == null || raw === '') return null;
60
+ const d = new Date(raw);
61
+ return Number.isNaN(d.getTime()) ? null : d.toISOString();
62
+ }
63
+
64
+ /**
65
+ * A store row plus the two derived read fields.
66
+ *
67
+ * ADDITIVE by construction: the row is spread through untouched, so every
68
+ * existing caller keeps reading the exact keys it always did and only a caller
69
+ * that asks for `seenCount` / `updatedAt` sees anything new. A non-object row
70
+ * yields the defaults rather than throwing, so a malformed page cannot take
71
+ * down a whole listing.
72
+ */
73
+ export function withReadFields(row) {
74
+ return {
75
+ ...(row && typeof row === 'object' ? row : {}),
76
+ seenCount: seenCountOf(row),
77
+ updatedAt: updatedAtOf(row),
78
+ };
79
+ }
@@ -28,6 +28,12 @@ export const FIELDS = [
28
28
  // ttl.mjs). Null / absent means it never expires. Appended like the origin
29
29
  // columns: a file written before this existed simply decodes it as absent.
30
30
  'expires_at',
31
+ // Recurrence — how many times this lesson has been written, mirroring the
32
+ // hosted `memories.seen_count` column (migration 00058) so an offline store
33
+ // carries the same salience signal a remote one does. Appended like the
34
+ // columns above: a file written before this existed decodes it as absent,
35
+ // which the read projection reports as 0 rather than inventing a count.
36
+ 'seen_count',
31
37
  ];
32
38
 
33
39
  // Serialize an entry ({ ...columns, value }) into file text.
@@ -10,6 +10,7 @@ import path from 'node:path';
10
10
  import { serializeEntry, parseEntry, slugify, scopeToDir } from './format.mjs';
11
11
  import { normalizeCreatedAt } from './created-at.mjs';
12
12
  import { isLive, resolveExpiresAt } from './ttl.mjs';
13
+ import { seenCountOf, withReadFields } from './entry-fields.mjs';
13
14
 
14
15
  export function createLocalStore(baseDir) {
15
16
  return new LocalStore(baseDir);
@@ -73,13 +74,18 @@ class LocalStore {
73
74
  }
74
75
  rows.sort((a, b) => String(b.updated || '').localeCompare(String(a.updated || '')));
75
76
  if (limit) rows = rows.slice(0, limit);
76
- return { ok: true, entries: rows };
77
+ // The same additive projection the remote store applies, so a caller that
78
+ // ranks entries never has to ask which store produced them.
79
+ return { ok: true, entries: rows.map(withReadFields) };
77
80
  }
78
81
 
79
82
  // read({ scope, key }) → { ok, entry } — null when absent, archived, or expired.
80
83
  async read({ scope, key } = {}) {
81
84
  const found = this._findByKey(scope, key);
82
- return { ok: true, entry: found && isLive(found.entry) ? found.entry : null };
85
+ return {
86
+ ok: true,
87
+ entry: found && isLive(found.entry) ? withReadFields(found.entry) : null,
88
+ };
83
89
  }
84
90
 
85
91
  // write(...) → { ok, entry } — upsert by scope+key. Preserves `created` and
@@ -127,6 +133,21 @@ class LocalStore {
127
133
  updated: existing ? now : override || now,
128
134
  archived_at: null,
129
135
  expires_at,
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
138
+ // the next sighting. `seenCountOf` floors an absent/hand-edited value to
139
+ // 0, so a file written before this column existed resumes at 1 on its
140
+ // next write rather than throwing or restarting the tally at 2.
141
+ //
142
+ // Reviving an ARCHIVED entry restarts at 1, matching the hosted RPC.
143
+ // The two stores get there differently — every conflict predicate on
144
+ // `memories` is partial on `archived_at is null`, so the server INSERTS a
145
+ // fresh row, while this store revives the file in place (see the docblock
146
+ // above) — but the count means the same thing on both: the lesson was
147
+ // retired and is being learned again, not seen once more.
148
+ seen_count: existing && !existing.entry.archived_at
149
+ ? seenCountOf(existing.entry) + 1
150
+ : 1,
130
151
  value: value == null ? '' : String(value),
131
152
  };
132
153
  const file = existing ? existing.file : this._freshPath(dir, key);
@@ -158,6 +179,10 @@ class LocalStore {
158
179
  updated: entry.updated ?? now,
159
180
  archived_at: entry.archived_at ?? null,
160
181
  expires_at: entry.expires_at ?? null,
182
+ // Verbatim, like every field here: migrate relocates a store, it does not
183
+ // re-sight its lessons, so a relocated entry must keep the count it had.
184
+ // An entry that never carried one lands as null and reads back as 0.
185
+ seen_count: entry.seen_count ?? null,
161
186
  value: entry.value == null ? '' : String(entry.value),
162
187
  };
163
188
  const file = existing ? existing.file : this._freshPath(dir, entry.key);
@@ -20,6 +20,7 @@
20
20
  // Zero-dependency.
21
21
  import { restFetch, mcpToRestBase } from '../mcp.mjs';
22
22
  import { getActiveTraceparent } from '../telemetry.mjs';
23
+ import { withReadFields } from './entry-fields.mjs';
23
24
 
24
25
  // Drop undefined/null args so JSON payloads stay tidy.
25
26
  function stripUndefined(obj) {
@@ -74,7 +75,10 @@ class RemoteStore {
74
75
  const data = res.data ?? {};
75
76
  return {
76
77
  ok: true,
77
- entries: data.entries ?? [],
78
+ // `withReadFields` is additive — every key the route returned survives —
79
+ // so this projection costs existing callers nothing and gives a ranker
80
+ // the same `seenCount`/`updatedAt` pair the local store answers with.
81
+ entries: (data.entries ?? []).map(withReadFields),
78
82
  hasMore: data.hasMore ?? false,
79
83
  nextCursor: data.nextCursor ?? null,
80
84
  };
@@ -97,12 +101,45 @@ class RemoteStore {
97
101
  const data = res.data ?? {};
98
102
  return {
99
103
  ok: true,
100
- entries: data.entries ?? [],
104
+ entries: (data.entries ?? []).map(withReadFields),
101
105
  hasMore: data.hasMore ?? false,
102
106
  nextCursor: data.nextCursor ?? null,
103
107
  };
104
108
  }
105
109
 
110
+ // Top-K lessons RANKED for a query — `GET /memories/relevant`.
111
+ //
112
+ // The difference from `search()` is the ordering, and it is the whole point:
113
+ // search returns what MATCHES (ordered `updated_at desc` by the handler),
114
+ // this returns what is worth READING, scored on recency + salience +
115
+ // relevance by the same ranking the SessionStart hook applies. It answers in
116
+ // a compact index — scope, key, a one-line hook, the score — never full
117
+ // bodies, so a caller pays for the shortlist and fetches only what it wants.
118
+ //
119
+ // `scopes` is ordered MOST-SPECIFIC FIRST and that order is meaningful: the
120
+ // server uses it to break ties, so passing `deriveScope().readOrder` verbatim
121
+ // gives a project lesson precedence over the global one it ties with.
122
+ //
123
+ // Returns the store's standard `{ ok, entries }` envelope so a caller can
124
+ // treat it like any other read, plus `candidates` — how many the FTS matched
125
+ // before ranking — so it can say "3 of 47" rather than implying it saw
126
+ // everything.
127
+ async relevant({ q, scopes, limit, minScore } = {}) {
128
+ const p = new URLSearchParams();
129
+ if (q) p.set('q', q);
130
+ if (scopes?.length) p.set('scopes', Array.isArray(scopes) ? scopes.join(',') : scopes);
131
+ if (limit) p.set('limit', String(limit));
132
+ if (minScore != null) p.set('min_score', String(minScore));
133
+ const res = await this._rest(`/memories/relevant?${p}`);
134
+ if (!res.ok) return { ok: false, error: res.error, networkError: res.networkError };
135
+ const data = res.data ?? {};
136
+ return {
137
+ ok: true,
138
+ entries: Array.isArray(data.entries) ? data.entries : [],
139
+ candidates: Number(data.candidates) || 0,
140
+ };
141
+ }
142
+
106
143
  async read({ scope, key } = {}) {
107
144
  const p = new URLSearchParams();
108
145
  if (scope) p.set('scope', scope);
@@ -112,7 +149,9 @@ class RemoteStore {
112
149
  const res = await this._rest(`/memories?${p}`);
113
150
  if (!res.ok) return { ok: false, error: res.error, networkError: res.networkError };
114
151
  const entries = res.data?.entries ?? [];
115
- return { ok: true, entry: entries[0] ?? null };
152
+ // Same projection as list/search a single read must not answer with a
153
+ // different shape than the listing the caller found the key in.
154
+ return { ok: true, entry: entries[0] ? withReadFields(entries[0]) : null };
116
155
  }
117
156
 
118
157
  async write(args = {}) {
package/src/tree.mjs CHANGED
@@ -9,9 +9,16 @@
9
9
  // first) and keeps the FIRST value seen per key, so a more-specific scope
10
10
  // shadows a broader scope's same-key lesson. `tree` resolves over that same
11
11
  // `readOrder` set via the pure `resolvePrecedence`, so it shows the same
12
- // resolution order the agent is injected with (the hook additionally caps the
13
- // injected set at MAX_LESSONS; `tree` is uncapped, so a large workspace may list
14
- // more winners than the hook injects).
12
+ // RESOLUTION which copy of a key wins and which are shadowed — that the agent
13
+ // is injected under.
14
+ //
15
+ // It does NOT show the injected ORDER. The hook ranks the precedence winners
16
+ // with the pure `rankLessons` (recency + salience + relevance, scope only a
17
+ // tiebreak) and then spends a character budget on them
18
+ // (`hooks.sessionStart.maxChars`), so what the agent reads first is the
19
+ // scorer's order, not `readOrder`'s. `tree` stays a precedence view: unbudgeted,
20
+ // unranked, grouped narrow→broad, so a large workspace may list more winners
21
+ // than the hook injects and in a different order.
15
22
  //
16
23
  // NOTE on scope coverage: `readOrder` is the injected set. As of the smart-hooks
17
24
  // PR it INCLUDES `project::` (project is the most-specific scope and now wins /