@lorekit/cli 1.32.1 → 1.33.1

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.1",
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": {
@@ -3,11 +3,12 @@
3
3
  // Storage is reached through the resolved store (local | remote), never a
4
4
  // backend directly, so the same read path serves every mode.
5
5
  import { deriveScope } from '../scope.mjs';
6
- // The precedence merge and the literal substring matcher come from the
7
- // dependency-free `lessons-pure.mjs` — the SAME primitives `tree` and `search`
8
- // use, so the hook can't drift from them, and the hot path never pulls in the
9
- // `lessons-view.mjs` render/`util` stack.
10
- import { resolvePrecedence, matchesQuery } from '../lessons-pure.mjs';
6
+ // The cross-scope precedence merge comes from the dependency-free
7
+ // `lessons-pure.mjs` — the SAME `resolvePrecedence` `tree` uses, so the hook
8
+ // can't drift from it, and the hot path never pulls in the `lessons-view.mjs`
9
+ // render/`util` stack. (Failure-relevance matching is the store's job now, so
10
+ // the hook no longer needs `matchesQuery` `search` still does.)
11
+ import { resolvePrecedence } from '../lessons-pure.mjs';
11
12
  // The deep-link builder is the SAME pure module the `link` command and the
12
13
  // `--link` flag use, so the hook's confirmation/nudge links are JSON-encoded
13
14
  // correctly (a raw `?scope=global` silently means "all scopes") and can't drift
@@ -135,23 +136,57 @@ export function failureQuery(toolName, toolResponse) {
135
136
  return terms;
136
137
  }
137
138
 
138
- // Lessons whose key OR value literally contains ANY of the failure `terms`
139
- // (case-insensitive, via the shared `matchesQuery` never a regex), capped at
140
- // `cap`. Pure and best-effort: no terms or no lessons empty (the caller then
141
- // falls back to the write-nudge alone). Preserves `lessons` order, so the
142
- // most-specific scope's relevant lesson surfaces first.
143
- export function relevantLessons(lessons, terms, cap = MAX_RELEVANT) {
144
- if (!Array.isArray(lessons) || !lessons.length || !Array.isArray(terms) || !terms.length) {
145
- return [];
146
- }
139
+ // De-duplicate store-search hits by `scope::key` and cap them, PRESERVING the
140
+ // store's order which is NOT relevance ordering: the remote store filters by
141
+ // FTS but orders by `updated_at desc` (recency), and the local one yields scope
142
+ // precedence (most-specific first) only WITHIN a tier `LocalStore.search`
143
+ // walks the scope hierarchy in `readOrder`, but `TwoTierStore.search` merges
144
+ // project-tier hits ahead of home-tier ones, so a `global` lesson in the project
145
+ // tier outranks a `repo::` one in home. Pure and total — any non-array input
146
+ // degrades to [] rather than throwing (this runs inside the best-effort failure
147
+ // hook).
148
+ export function dedupeRelevant(entries, cap = MAX_RELEVANT) {
149
+ if (!Array.isArray(entries)) return [];
150
+ const limit = Math.max(0, cap);
151
+ const seen = new Set();
147
152
  const out = [];
148
- for (const l of lessons) {
149
- if (terms.some((t) => matchesQuery(l, t))) out.push(l);
150
- if (out.length >= cap) break;
153
+ for (const e of entries) {
154
+ if (out.length >= limit) break; // checked BEFORE the push, so cap 0 yields []
155
+ if (!e || !e.key) continue;
156
+ const id = `${e.scope ?? ''}::${e.key}`;
157
+ if (seen.has(id)) continue;
158
+ seen.add(id);
159
+ out.push(e);
151
160
  }
152
161
  return out;
153
162
  }
154
163
 
164
+ // Retrieve lessons relevant to a tool failure by QUERYING the store across the
165
+ // scope hierarchy — as opposed to post-filtering the SessionStart-injected set,
166
+ // which could only ever resurface a lesson that was going to be shown anyway (a
167
+ // lesson in a sibling scope, or one past the per-scope read cap, was
168
+ // unreachable). A SINGLE `store.search` carries ALL the distilled failure terms
169
+ // (OR semantics), so the offline store is walked once rather than once per term.
170
+ // MATCHING is DELEGATED to the store — substring over the full scope for local,
171
+ // server-side FTS (with stemming, so `connect` matches `connection`) for remote.
172
+ // ORDERING is not: the remote returns `updated_at desc`, so the top-`cap` slice
173
+ // is the most RECENT matches, not the most relevant ones. Hits are de-duped and
174
+ // capped by the pure `dedupeRelevant`, keeping the store's own ordering (see its
175
+ // docblock). Best-effort: an unusable/throwing store returns [] so the
176
+ // caller falls back to the write-nudge alone.
177
+ export async function relevantLessonsFromStore(store, scope, terms, { cap = MAX_RELEVANT } = {}) {
178
+ if (!store || typeof store.search !== 'function') return [];
179
+ if (!scope || !Array.isArray(scope.readOrder) || scope.readOrder.length === 0) return [];
180
+ if (!Array.isArray(terms) || terms.length === 0) return [];
181
+ try {
182
+ const res = await store.search({ q: terms, scopes: scope.readOrder });
183
+ if (!res || !res.ok || !Array.isArray(res.entries)) return [];
184
+ return dedupeRelevant(res.entries, cap);
185
+ } catch {
186
+ return []; // best-effort: a failed search falls back to the nudge alone
187
+ }
188
+ }
189
+
155
190
  // Render the relevant-lessons block injected alongside the failure nudge, or
156
191
  // null when nothing matched. Same compact-index shape as `formatLessons`, with a
157
192
  // 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
@@ -205,14 +205,22 @@ class LocalStore {
205
205
  }
206
206
 
207
207
  // search({ q, scopes, tags }) → { ok, entries } — keyword over key/tags/body.
208
+ // `q` is a single needle (string) OR a list of needles (string[]); a list
209
+ // matches an entry when ANY needle is a substring (OR semantics). Either way
210
+ // this walks each scope EXACTLY ONCE — the failure hook passes all its terms
211
+ // in one call rather than one call per term, so N terms no longer re-read the
212
+ // store N times. An empty query (or empty list) returns everything, unchanged.
208
213
  async search({ q, scopes, tags } = {}) {
209
- const needle = String(q || '').toLowerCase();
214
+ const needles = (Array.isArray(q) ? q : [q])
215
+ .map((n) => String(n || '').toLowerCase())
216
+ .filter(Boolean);
217
+ const matchAll = needles.length === 0;
210
218
  const out = [];
211
219
  for (const scope of scopes || []) {
212
220
  const { entries } = await this.list({ scope, tags });
213
221
  for (const e of entries) {
214
222
  const hay = `${e.key}\n${(e.tags || []).join(' ')}\n${e.value || ''}`.toLowerCase();
215
- if (!needle || hay.includes(needle)) out.push(e);
223
+ if (matchAll || needles.some((n) => hay.includes(n))) out.push(e);
216
224
  }
217
225
  }
218
226
  return { ok: true, entries: out };
@@ -71,8 +71,13 @@ class RemoteStore {
71
71
  }
72
72
 
73
73
  async search({ q, scopes, tags } = {}) {
74
+ // A list of terms collapses into ONE `websearch` query joined by `OR`, so a
75
+ // multi-term failure lookup is a single round-trip (the server FTS ORs them
76
+ // and stems each). `failureQuery` distils terms to `[a-z0-9]+` tokens, so no
77
+ // FTS metacharacter reaches the query string. A plain string passes through.
78
+ const query = Array.isArray(q) ? q.filter(Boolean).join(' OR ') : q;
74
79
  const body = {};
75
- if (q) body.q = q;
80
+ if (query) body.q = query;
76
81
  if (scopes?.length) body.scopes = scopes;
77
82
  if (tags?.length) body.tags = tags;
78
83
  const res = await this._rest('/memories/search', { method: 'POST', body });