@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 +32 -2
- package/package.json +1 -1
- package/src/core/lessons.mjs +44 -88
- package/src/store/local.mjs +10 -2
- package/src/store/remote.mjs +6 -1
package/bin/lorekit.mjs
CHANGED
|
@@ -718,8 +718,38 @@ async function main() {
|
|
|
718
718
|
}
|
|
719
719
|
|
|
720
720
|
main()
|
|
721
|
-
.then((code) =>
|
|
721
|
+
.then((code) => flushThenExit(code ?? 0))
|
|
722
722
|
.catch((e) => {
|
|
723
723
|
err(`${c.red('Error:')} ${e && e.stack ? e.stack : e}`);
|
|
724
|
-
|
|
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
package/src/core/lessons.mjs
CHANGED
|
@@ -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
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
|
|
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
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
155
|
-
if (
|
|
156
|
-
if (
|
|
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
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
// (
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
package/src/store/local.mjs
CHANGED
|
@@ -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
|
|
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 (
|
|
223
|
+
if (matchAll || needles.some((n) => hay.includes(n))) out.push(e);
|
|
216
224
|
}
|
|
217
225
|
}
|
|
218
226
|
return { ok: true, entries: out };
|
package/src/store/remote.mjs
CHANGED
|
@@ -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 (
|
|
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 });
|