@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 +1 -1
- package/src/core/lessons.mjs +52 -17
- package/src/hook.mjs +7 -3
- package/src/store/local.mjs +10 -2
- package/src/store/remote.mjs +6 -1
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,23 +136,57 @@ export function failureQuery(toolName, toolResponse) {
|
|
|
135
136
|
return terms;
|
|
136
137
|
}
|
|
137
138
|
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|
149
|
-
if (
|
|
150
|
-
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);
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
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 });
|