@lorekit/cli 1.33.0 → 1.33.2

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/bin/lorekit.mjs CHANGED
@@ -718,8 +718,38 @@ async function main() {
718
718
  }
719
719
 
720
720
  main()
721
- .then((code) => process.exit(code ?? 0))
721
+ .then((code) => flushThenExit(code ?? 0))
722
722
  .catch((e) => {
723
723
  err(`${c.red('Error:')} ${e && e.stack ? e.stack : e}`);
724
- process.exit(1);
724
+ flushThenExit(1);
725
725
  });
726
+
727
+ // Exit only after stdout/stderr have drained.
728
+ //
729
+ // `process.exit()` truncates any output still buffered for a PIPE (the shape a
730
+ // spawned child's stdout has), because pipe writes are asynchronous. `lorekit
731
+ // mcp` streams newline-delimited JSON-RPC frames to stdout, and a large frame —
732
+ // e.g. a `memory.list` result for a big scope — overflows the pipe buffer, so
733
+ // exiting the instant `main()` resolves drops the tail of that write and the
734
+ // client sees a silent "no response" (this reproduced deterministically once a
735
+ // scope's payload crossed ~½ MB). Flushing first makes the final frame whole.
736
+ // The unref'd safety timer guarantees the process still exits if a stream never
737
+ // drains, so this can never turn a finished command into a hang.
738
+ function flushThenExit(code) {
739
+ let pending = 2;
740
+ const done = () => {
741
+ pending -= 1;
742
+ if (pending === 0) process.exit(code);
743
+ };
744
+ const safety = setTimeout(() => process.exit(code), 2000);
745
+ safety.unref?.();
746
+ for (const stream of [process.stdout, process.stderr]) {
747
+ try {
748
+ // An empty write's callback fires only after every previously-queued write
749
+ // has flushed to the fd, so it is a reliable drain barrier.
750
+ stream.write('', done);
751
+ } catch {
752
+ done();
753
+ }
754
+ }
755
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lorekit/cli",
3
- "version": "1.33.0",
3
+ "version": "1.33.2",
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,100 +136,55 @@ 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
- //
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.
149
- export function relevantLessons(lessons, terms, cap = MAX_RELEVANT) {
150
- if (!Array.isArray(lessons) || !lessons.length || !Array.isArray(terms) || !terms.length) {
151
- return [];
152
- }
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();
153
152
  const out = [];
154
- for (const l of lessons) {
155
- if (terms.some((t) => matchesQuery(l, t))) out.push(l);
156
- 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);
157
160
  }
158
161
  return out;
159
162
  }
160
163
 
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
164
  // 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
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
215
176
  // caller falls back to the write-nudge alone.
216
177
  export async function relevantLessonsFromStore(store, scope, terms, { cap = MAX_RELEVANT } = {}) {
217
178
  if (!store || typeof store.search !== 'function') return [];
218
179
  if (!scope || !Array.isArray(scope.readOrder) || scope.readOrder.length === 0) return [];
219
180
  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);
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
+ }
232
188
  }
233
189
 
234
190
  // Render the relevant-lessons block injected alongside the failure nudge, or
@@ -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 });