@lorekit/cli 1.32.1 → 1.33.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lorekit/cli",
3
- "version": "1.32.1",
3
+ "version": "1.33.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": {
@@ -140,6 +140,12 @@ export function failureQuery(toolName, toolResponse) {
140
140
  // `cap`. Pure and best-effort: no terms or no lessons → empty (the caller then
141
141
  // falls back to the write-nudge alone). Preserves `lessons` order, so the
142
142
  // most-specific scope's relevant lesson surfaces first.
143
+ //
144
+ // This is the LITERAL, client-side matcher — it can only ever narrow a list it
145
+ // is handed. On the failure hot path it has been superseded by
146
+ // `relevantLessonsFromStore`, which QUERIES the store instead of post-filtering
147
+ // the already-injected set (see that function's note). Kept for its unit tests
148
+ // and as the pure building block behind the store-delegated ranking.
143
149
  export function relevantLessons(lessons, terms, cap = MAX_RELEVANT) {
144
150
  if (!Array.isArray(lessons) || !lessons.length || !Array.isArray(terms) || !terms.length) {
145
151
  return [];
@@ -152,6 +158,79 @@ export function relevantLessons(lessons, terms, cap = MAX_RELEVANT) {
152
158
  return out;
153
159
  }
154
160
 
161
+ // Rank the store-search hits for a failure lookup. `resultsPerTerm` is one entry
162
+ // list per failure term (a term that errored or matched nothing contributes an
163
+ // empty list). A lesson that matched MORE terms is more relevant, so it ranks
164
+ // higher; ties break toward the MORE-SPECIFIC scope (its position in
165
+ // `readOrder`, which is most-specific-first) and then toward first-seen order
166
+ // for a stable result. De-duplicates by `scope::key` across every term/scope,
167
+ // keeping the first-seen entry. Pure and total — any non-array input degrades to
168
+ // [] rather than throwing (this runs inside the best-effort failure hook).
169
+ // Capped at `cap`.
170
+ export function rankRelevant(resultsPerTerm, readOrder = [], cap = MAX_RELEVANT) {
171
+ if (!Array.isArray(resultsPerTerm)) return [];
172
+ const order = new Map(
173
+ (Array.isArray(readOrder) ? readOrder : []).map((s, i) => [s, i]),
174
+ );
175
+ const UNKNOWN = Number.MAX_SAFE_INTEGER; // a scope not in readOrder sorts last
176
+ const byId = new Map();
177
+ let seq = 0;
178
+ for (const entries of resultsPerTerm) {
179
+ if (!Array.isArray(entries)) continue;
180
+ const seenThisTerm = new Set(); // one term can't count a lesson twice
181
+ for (const e of entries) {
182
+ if (!e || !e.key) continue;
183
+ const id = `${e.scope ?? ''}::${e.key}`;
184
+ if (seenThisTerm.has(id)) continue;
185
+ seenThisTerm.add(id);
186
+ const cur = byId.get(id);
187
+ if (cur) {
188
+ cur.matches += 1;
189
+ } else {
190
+ byId.set(id, {
191
+ entry: e,
192
+ matches: 1,
193
+ rank: order.has(e.scope) ? order.get(e.scope) : UNKNOWN,
194
+ seq: seq++,
195
+ });
196
+ }
197
+ }
198
+ }
199
+ return [...byId.values()]
200
+ .sort((a, b) => b.matches - a.matches || a.rank - b.rank || a.seq - b.seq)
201
+ .slice(0, Math.max(0, cap))
202
+ .map((r) => r.entry);
203
+ }
204
+
205
+ // Retrieve lessons relevant to a tool failure by QUERYING the store across the
206
+ // scope hierarchy — as opposed to `relevantLessons`, which post-filtered the
207
+ // already-injected set and could therefore only ever resurface a lesson that was
208
+ // going to be shown anyway (a lesson in a sibling scope, or one past the
209
+ // per-scope read cap, was unreachable). Runs one search per distilled term
210
+ // (bounded by `failureQuery`'s MAX_TERMS), concurrently and best-effort: a failed
211
+ // or throwing search contributes nothing. Matching is DELEGATED to the store — a
212
+ // literal substring over the full scope for the local store, server-side FTS
213
+ // (with stemming, so `connect` matches `connection`) for the remote — then the
214
+ // union is ranked by the pure `rankRelevant`. Returns [] on any failure so the
215
+ // caller falls back to the write-nudge alone.
216
+ export async function relevantLessonsFromStore(store, scope, terms, { cap = MAX_RELEVANT } = {}) {
217
+ if (!store || typeof store.search !== 'function') return [];
218
+ if (!scope || !Array.isArray(scope.readOrder) || scope.readOrder.length === 0) return [];
219
+ if (!Array.isArray(terms) || terms.length === 0) return [];
220
+ const scopes = scope.readOrder;
221
+ const resultsPerTerm = await Promise.all(
222
+ terms.map(async (t) => {
223
+ try {
224
+ const res = await store.search({ q: t, scopes });
225
+ return res && res.ok && Array.isArray(res.entries) ? res.entries : [];
226
+ } catch {
227
+ return []; // best-effort: one failed term never sinks the lookup
228
+ }
229
+ }),
230
+ );
231
+ return rankRelevant(resultsPerTerm, scopes, cap);
232
+ }
233
+
155
234
  // Render the relevant-lessons block injected alongside the failure nudge, or
156
235
  // null when nothing matched. Same compact-index shape as `formatLessons`, with a
157
236
  // touch more hook per line (there are at most MAX_RELEVANT and they're directly
package/src/hook.mjs CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  retrospectiveNudge,
14
14
  failureNudge,
15
15
  failureQuery,
16
- relevantLessons,
16
+ relevantLessonsFromStore,
17
17
  formatRelevantLessons,
18
18
  writeConfirmation,
19
19
  } from './core/lessons.mjs';
@@ -148,9 +148,13 @@ async function run(args) {
148
148
  try {
149
149
  const store = createStore(control);
150
150
  if (store) {
151
- const { lessons } = await fetchLessons(store, root);
151
+ // QUERY the store across the scope hierarchy for lessons matching this
152
+ // failure — not a post-filter of the SessionStart-injected set, which
153
+ // could only ever resurface an already-shown lesson. Matching is the
154
+ // store's job (server FTS with stemming for remote, full-scope substring
155
+ // for local), so a paraphrased prior lesson can still surface.
152
156
  const terms = failureQuery(parsed.toolName, parsed.toolResponse);
153
- relevant = formatRelevantLessons(relevantLessons(lessons, terms));
157
+ relevant = formatRelevantLessons(await relevantLessonsFromStore(store, scope, terms));
154
158
  }
155
159
  } catch {
156
160
  relevant = null; // never let a lesson lookup break the failure nudge