@lorekit/cli 1.11.0 → 1.12.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/README.md CHANGED
@@ -51,9 +51,11 @@ without needing a marketplace:
51
51
  2. **MCP server** (`lorekit`) — the connection to your lessons, merged into the
52
52
  MCP config (preserving any other servers).
53
53
  3. **Hooks** — the *deterministic* layer: lessons injected on every
54
- `SessionStart`, a nudge on tool failure (`PostToolUseFailure`), and a
55
- retrospective nudge on `Stop`. These fire the shared `lorekit hook` engine
56
- and are merged into `settings.json` (existing hooks preserved).
54
+ `SessionStart`, and on a tool failure (`PostToolUseFailure`) any lessons that
55
+ look **relevant to that failure** ("you've hit this before") plus a nudge to
56
+ record the fix, and a retrospective nudge on `Stop`. These fire the shared
57
+ `lorekit hook` engine and are merged into `settings.json` (existing hooks
58
+ preserved).
57
59
 
58
60
  It first asks **where** to install:
59
61
 
@@ -212,10 +214,10 @@ rather than crashing. `--endpoint` / `--token` / `--store` behave as in `list`.
212
214
 
213
215
  ### `lorekit tree` (alias `resolve`)
214
216
 
215
- Show the scopes the hooks actually **inject** — branch → repo → global, in
216
- precedence order (most-specific first) — as a resolution hierarchy, and mark for
217
- any key present at more than one scope which scope's lesson **wins** and which are
218
- **shadowed**:
217
+ Show the scopes the hooks actually **inject** — project → branch → repo →
218
+ global, in precedence order (most-specific first) — as a resolution hierarchy,
219
+ and mark for any key present at more than one scope which scope's lesson **wins**
220
+ and which are **shadowed**:
219
221
 
220
222
  ```bash
221
223
  lorekit tree # the injected hierarchy with ✓ winning / ↳ shadowed marks
@@ -224,12 +226,12 @@ lorekit tree --json # per-entry { winning, shadowedBy } + a winners[]
224
226
  ```
225
227
 
226
228
  This mirrors the SessionStart hook's resolution **exactly**: it reads the scopes
227
- in `readOrder` (branch → repo → global) and keeps the first value seen per key, so
228
- a more-specific scope overrides a broader scope's same-key lesson. It answers
229
- "which lesson actually applies here, and what is being overridden?". Note that
230
- `project::` scope is **not** part of the injected set (the hooks never inject
231
- project lessons), so `tree` doesn't show it browse those with `lorekit list`.
232
- Each store is resolved independently, in the same Offline / Remote split.
229
+ in `readOrder` (project → branch → repo → global) and keeps the first value seen
230
+ per key, so a more-specific scope overrides a broader scope's same-key lesson. It
231
+ answers "which lesson actually applies here, and what is being overridden?". The
232
+ `project::` scope **is** part of the injected set (project is the most-specific
233
+ scope), so `tree`, the hooks, and every read command's `scopeList` now share one
234
+ ordering. Each store is resolved independently, in the same Offline / Remote split.
233
235
 
234
236
  ### `lorekit lint`
235
237
 
@@ -273,8 +275,9 @@ range. Cross-**store** divergence is `diff`'s job; `dedupe` looks within a store
273
275
  The **shared hook engine** behind the Claude Code / Cursor / Codex plugins.
274
276
  It is not run by hand — the plugins wire it into their hook config. It reads
275
277
  the host framework's JSON on stdin and prints that host's injection format on
276
- stdout (lessons at session start; a nudge on failure or at end of turn),
277
- always exiting 0 so it can never block the host agent.
278
+ stdout (lessons at session start; relevant lessons plus a write-nudge on a tool
279
+ failure; a retrospective nudge at end of turn), always exiting 0 so it can never
280
+ block the host agent.
278
281
 
279
282
  ```bash
280
283
  lorekit hook --adapter <claude|cursor|codex> --event <SessionStart|Stop|…>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lorekit/cli",
3
- "version": "1.11.0",
3
+ "version": "1.12.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": {
@@ -3,23 +3,52 @@
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
11
 
7
12
  const MAX_LESSONS = 15;
13
+ // Cap on lessons injected on a failure — a small, focused "you've seen this
14
+ // before" set, never the whole applicable corpus.
15
+ const MAX_RELEVANT = 3;
16
+ // Cap on the terms distilled from a failure — bounds the relevance scan so a
17
+ // huge error blob can never turn into an unbounded match loop.
18
+ const MAX_TERMS = 12;
19
+ // Terms shorter than this are dropped: too generic to make a match meaningful.
20
+ const MIN_TERM_LEN = 4;
21
+ // Cap on how much failure text is scanned for terms. A tool can dump a
22
+ // multi-megabyte stderr/stdout; the salient error words are always near the
23
+ // front, so we bound the input BEFORE lowercasing/splitting it — otherwise a
24
+ // giant blob would materialise a giant token array (a CPU/memory spike) even
25
+ // though the term COUNT is capped. Generous enough to never clip a real error.
26
+ const MAX_SCAN_CHARS = 4096;
8
27
 
9
- // Read lessons narrow-to-broad through the store and merge; more specific
10
- // scope wins on key. Any per-scope failure is skipped (memory is best-effort).
28
+ // Read lessons narrow-to-broad through the store and resolve cross-scope
29
+ // precedence via the shared pure `resolvePrecedence` (the SAME first-seen /
30
+ // more-specific-wins merge `tree` renders) — so the hook and `tree` provably
31
+ // can't drift. Any per-scope failure is skipped (memory is best-effort).
11
32
  export async function fetchLessons(store, cwd) {
12
33
  const scope = deriveScope(cwd);
13
- const byKey = new Map();
34
+ const groups = [];
14
35
  for (const s of scope.readOrder) {
15
36
  const res = await store.list({ scope: s, limit: 25 });
16
- if (!res || !res.ok) continue;
17
- const entries = Array.isArray(res.entries) ? res.entries : [];
18
- for (const e of entries) {
19
- if (e && e.key && !byKey.has(e.key)) byKey.set(e.key, { ...e, scope: s });
20
- }
37
+ if (!res || !res.ok) continue; // best-effort: a failed scope contributes nothing
38
+ const entries = (Array.isArray(res.entries) ? res.entries : [])
39
+ .filter((e) => e && e.key)
40
+ .map((e) => ({ ...e, scope: s }));
41
+ groups.push({ scope: s, error: null, entries });
21
42
  }
22
- return { scope, lessons: [...byKey.values()].slice(0, MAX_LESSONS) };
43
+ // First value per key wins (most-specific scope, since `readOrder` is
44
+ // narrow→broad) — exactly what the old inline `byKey` merge did, now via the
45
+ // one shared resolver. The winners, in group order, are the injected set.
46
+ const { groups: resolved } = resolvePrecedence({ groups });
47
+ const lessons = [];
48
+ for (const g of resolved) {
49
+ for (const e of g.entries) if (e.winning) lessons.push(e);
50
+ }
51
+ return { scope, lessons: lessons.slice(0, MAX_LESSONS) };
23
52
  }
24
53
 
25
54
  // Render lessons as a compact markdown block, or null when there are none.
@@ -37,6 +66,60 @@ export function formatLessons(lessons, scope) {
37
66
  return `${header}\n${body}`;
38
67
  }
39
68
 
69
+ // Distil a small set of significant, lowercased search TERMS from a tool
70
+ // failure — the tool name plus the salient words of its error text — used to
71
+ // look up lessons that might already cover this failure. Pure and total: any
72
+ // shape of `toolResponse` (object, string, null, malformed) is handled without
73
+ // throwing. Terms are de-duplicated, stopword- and length-filtered so a match
74
+ // stays meaningful, and the count is capped (`MAX_TERMS`) so a huge error blob
75
+ // can't blow up the downstream scan.
76
+ export function failureQuery(toolName, toolResponse) {
77
+ const text = `${toolName ? String(toolName) : ''} ${errorText(toolResponse)}`.slice(0, MAX_SCAN_CHARS);
78
+ const seen = new Set();
79
+ const terms = [];
80
+ for (const raw of text.toLowerCase().split(/[^a-z0-9]+/)) {
81
+ if (raw.length < MIN_TERM_LEN || STOPWORDS.has(raw) || seen.has(raw)) continue;
82
+ seen.add(raw);
83
+ terms.push(raw);
84
+ if (terms.length >= MAX_TERMS) break;
85
+ }
86
+ return terms;
87
+ }
88
+
89
+ // Lessons whose key OR value literally contains ANY of the failure `terms`
90
+ // (case-insensitive, via the shared `matchesQuery` — never a regex), capped at
91
+ // `cap`. Pure and best-effort: no terms or no lessons → empty (the caller then
92
+ // falls back to the write-nudge alone). Preserves `lessons` order, so the
93
+ // most-specific scope's relevant lesson surfaces first.
94
+ export function relevantLessons(lessons, terms, cap = MAX_RELEVANT) {
95
+ if (!Array.isArray(lessons) || !lessons.length || !Array.isArray(terms) || !terms.length) {
96
+ return [];
97
+ }
98
+ const out = [];
99
+ for (const l of lessons) {
100
+ if (terms.some((t) => matchesQuery(l, t))) out.push(l);
101
+ if (out.length >= cap) break;
102
+ }
103
+ return out;
104
+ }
105
+
106
+ // Render the relevant-lessons block injected alongside the failure nudge, or
107
+ // null when nothing matched. Framed as prior art, not a directive — same
108
+ // "considerations, not rules" stance as `formatLessons`.
109
+ export function formatRelevantLessons(lessons) {
110
+ if (!lessons || lessons.length === 0) return null;
111
+ const header =
112
+ `LoreKit — you've hit something like this before. ${lessons.length} related ` +
113
+ 'lesson(s) (considerations, not rules; trust the current code if they conflict):';
114
+ const body = lessons
115
+ .map((l) => {
116
+ const first = String(l.value || '').split('\n')[0].slice(0, 300);
117
+ return `- (${l.scope}) ${l.key}: ${first}`;
118
+ })
119
+ .join('\n');
120
+ return `${header}\n${body}`;
121
+ }
122
+
40
123
  // The retrospective nudge emitted at end-of-turn (one-shot per session).
41
124
  export function retrospectiveNudge(scope) {
42
125
  const writeScope = scope.repoScope || 'global';
@@ -58,3 +141,64 @@ export function failureNudge(toolName, scope) {
58
141
  `lorekit-memory (memory.write to ${writeScope}), so the next run avoids it.`
59
142
  );
60
143
  }
144
+
145
+ // Pull the human-readable error text out of a tool_response of any shape. Total:
146
+ // null/primitive/string/object all yield a (possibly empty) string, never a
147
+ // throw — the relevance lookup is best-effort and must not break the host.
148
+ function errorText(response) {
149
+ if (response == null) return '';
150
+ if (typeof response === 'string') return response;
151
+ if (typeof response !== 'object') return String(response);
152
+ const parts = [];
153
+ for (const f of TEXT_FIELDS) {
154
+ const v = response[f];
155
+ if (typeof v === 'string') parts.push(v);
156
+ else if (v && typeof v === 'object') parts.push(safeStringify(v));
157
+ }
158
+ return parts.length ? parts.join(' ') : safeStringify(response);
159
+ }
160
+
161
+ function safeStringify(v) {
162
+ try {
163
+ return JSON.stringify(v) || '';
164
+ } catch {
165
+ return '';
166
+ }
167
+ }
168
+
169
+ // The tool_response fields that commonly carry error text, across frameworks.
170
+ const TEXT_FIELDS = [
171
+ 'stderr',
172
+ 'error',
173
+ 'message',
174
+ 'stdout',
175
+ 'output',
176
+ 'content',
177
+ 'reason',
178
+ 'detail',
179
+ 'details',
180
+ ];
181
+
182
+ // Generic words dropped from a failure query: too common to make a lesson match
183
+ // meaningful (they'd match almost anything). Kept small and hand-picked.
184
+ const STOPWORDS = new Set([
185
+ 'error',
186
+ 'errors',
187
+ 'failed',
188
+ 'failure',
189
+ 'tool',
190
+ 'call',
191
+ 'code',
192
+ 'exit',
193
+ 'with',
194
+ 'this',
195
+ 'that',
196
+ 'from',
197
+ 'null',
198
+ 'true',
199
+ 'false',
200
+ 'undefined',
201
+ 'command',
202
+ 'response',
203
+ 'status',
204
+ ]);
package/src/hook.mjs CHANGED
@@ -7,7 +7,15 @@ import { resolveProjectRoot } from './config.mjs';
7
7
  import { deriveScope } from './scope.mjs';
8
8
  import { loadControl } from './control.mjs';
9
9
  import { createStore } from './store/index.mjs';
10
- import { fetchLessons, formatLessons, retrospectiveNudge, failureNudge } from './core/lessons.mjs';
10
+ import {
11
+ fetchLessons,
12
+ formatLessons,
13
+ retrospectiveNudge,
14
+ failureNudge,
15
+ failureQuery,
16
+ relevantLessons,
17
+ formatRelevantLessons,
18
+ } from './core/lessons.mjs';
11
19
  import { isFailure } from './core/failure.mjs';
12
20
  import { firstTimeThisSession } from './core/state.mjs';
13
21
  import { recordFixture } from './core/record.mjs';
@@ -92,7 +100,22 @@ async function run(args) {
92
100
  const known = adapter.guaranteedFailure ? adapter.guaranteedFailure(event) : false;
93
101
  if (!known && !isFailure(parsed.toolName, parsed.toolResponse)) return 0;
94
102
  if (!firstTimeThisSession(parsed.sessionId, 'failure')) return 0;
95
- emit(failureNudge(parsed.toolName, scope));
103
+ // Best-effort: surface any existing lessons that look relevant to THIS
104
+ // failure ("you've hit this before"), then the write-nudge. A missing /
105
+ // unusable store, or no match, silently falls back to the nudge alone.
106
+ let relevant = null;
107
+ try {
108
+ const store = createStore(control);
109
+ if (store) {
110
+ const { lessons } = await fetchLessons(store, root);
111
+ const terms = failureQuery(parsed.toolName, parsed.toolResponse);
112
+ relevant = formatRelevantLessons(relevantLessons(lessons, terms));
113
+ }
114
+ } catch {
115
+ relevant = null; // never let a lesson lookup break the failure nudge
116
+ }
117
+ const nudge = failureNudge(parsed.toolName, scope);
118
+ emit(relevant ? `${relevant}\n\n${nudge}` : nudge);
96
119
  return 0;
97
120
  }
98
121
 
@@ -0,0 +1,65 @@
1
+ // Pure, DEPENDENCY-FREE lesson primitives shared by the hook engine
2
+ // (`core/lessons.mjs`, the hot path) and the read-command view layer
3
+ // (`lessons-view.mjs`, which re-exports these). Kept in its own module so the
4
+ // hook can import the exact precedence + match logic the read commands use —
5
+ // one source of truth, no drift — WITHOUT dragging in the rendering/`util`
6
+ // stack (`heading`/`log`/`c`, plus the lint/dedupe/diff cores) that the rest of
7
+ // `lessons-view.mjs` carries. Zero imports on purpose.
8
+
9
+ // ── cross-scope precedence resolution (the `tree` + hook merge core) ──────────
10
+
11
+ // Given per-scope groups in RESOLUTION ORDER — most-specific first, exactly the
12
+ // order `deriveScope().readOrder` produces — compute which scope's lesson WINS
13
+ // each key and which are shadowed. This is the hook engine's merge: it iterates
14
+ // the scopes narrow-to-broad and keeps the FIRST value seen per key (`if
15
+ // (!winnerScopeByKey.has(key)) set`), so a more-specific scope shadows a broader
16
+ // scope's same-key lesson. Returns the same groups with every entry tagged
17
+ // `{ winning, shadowedBy }` (`shadowedBy` = the scope that won the key, or null
18
+ // for a winner), plus the resolved `winners` list (`{ scope, key }`, one per
19
+ // key) and winning/shadowed counts. A per-scope read error is passed through
20
+ // untouched (empty entries), so one unreadable scope never derails resolution.
21
+ // Pure — consumed by both `tree` (display) and `fetchLessons` (injection).
22
+ export function resolvePrecedence({ groups = [] } = {}) {
23
+ const winnerScopeByKey = new Map(); // key → the scope that first claimed it
24
+ const outGroups = [];
25
+ let winningTotal = 0;
26
+ let shadowedTotal = 0;
27
+ for (const g of groups) {
28
+ if (g.error) {
29
+ outGroups.push({ scope: g.scope, error: g.error, entries: [] });
30
+ continue;
31
+ }
32
+ const entries = [];
33
+ for (const e of g.entries || []) {
34
+ const prior = winnerScopeByKey.get(e.key);
35
+ if (prior === undefined) {
36
+ winnerScopeByKey.set(e.key, g.scope);
37
+ entries.push({ ...e, winning: true, shadowedBy: null });
38
+ winningTotal += 1;
39
+ } else {
40
+ entries.push({ ...e, winning: false, shadowedBy: prior });
41
+ shadowedTotal += 1;
42
+ }
43
+ }
44
+ outGroups.push({ scope: g.scope, error: null, entries });
45
+ }
46
+ const winners = [...winnerScopeByKey.entries()].map(([key, scope]) => ({ scope, key }));
47
+ return { groups: outGroups, winners, winningTotal, shadowedTotal };
48
+ }
49
+
50
+ // ── literal, case-insensitive substring matching (the `search` + hook core) ───
51
+
52
+ // Case-insensitive, LITERAL substring match of `query` against a normalized
53
+ // entry's key OR value — the `search` command's matcher, and the hook's
54
+ // failure-relevance matcher. Deliberately a plain `String.includes` (never
55
+ // `new RegExp(query)`) so a query full of regex metacharacters like `a.*(b)`
56
+ // matches those characters verbatim, never as a pattern. An empty query matches
57
+ // everything (the command guards emptiness as a usage error before ever calling
58
+ // this). Pure — trivially unit-testable.
59
+ export function matchesQuery(entry, query) {
60
+ const needle = String(query == null ? '' : query).toLowerCase();
61
+ if (!needle) return true;
62
+ const key = String(entry?.key ?? '').toLowerCase();
63
+ const value = String(entry?.value ?? '').toLowerCase();
64
+ return key.includes(needle) || value.includes(needle);
65
+ }
Binary file
package/src/scope.mjs CHANGED
@@ -44,7 +44,11 @@ export function deriveScope(cwd = process.cwd()) {
44
44
  : null;
45
45
  const projectScope = `project::${projectName}`;
46
46
 
47
- const readOrder = [branchScope, repoScope, 'global'].filter(Boolean);
47
+ // Injection / resolution order, most-specific → broadest. Matches the read
48
+ // commands' `scopeList` exactly (`lessons-view.mjs`) — project is FIRST, so a
49
+ // project-scoped lesson wins and IS injected. De-duplicated (a project whose
50
+ // basename collides with a scope, or a repo with no branch, never repeats).
51
+ const readOrder = [...new Set([projectScope, branchScope, repoScope, 'global'].filter(Boolean))];
48
52
 
49
53
  return {
50
54
  ownerRepo,
package/src/tree.mjs CHANGED
@@ -5,17 +5,18 @@
5
5
  //
6
6
  // Precedence is not an assumption — it mirrors the hook engine exactly. The
7
7
  // SessionStart hook (`core/lessons.mjs → fetchLessons`) reads the scopes in
8
- // `deriveScope().readOrder` (branch → repo → global, most-specific first) and
9
- // keeps the FIRST value seen per key, so a more-specific scope shadows a broader
10
- // scope's same-key lesson. `tree` resolves over that same `readOrder` set via
11
- // the pure `resolvePrecedence`, so it shows the same resolution order the agent
12
- // is injected with (the hook additionally caps the injected set at MAX_LESSONS;
13
- // `tree` is uncapped, so a large workspace may list more winners than the hook
14
- // injects).
8
+ // `deriveScope().readOrder` (project → branch → repo → global, most-specific
9
+ // first) and keeps the FIRST value seen per key, so a more-specific scope
10
+ // shadows a broader scope's same-key lesson. `tree` resolves over that same
11
+ // `readOrder` set via the pure `resolvePrecedence`, so it shows the same
12
+ // resolution order the agent is injected with (the hook additionally caps the
13
+ // injected set at MAX_LESSONS; `tree` is uncapped, so a large workspace may list
14
+ // more winners than the hook injects).
15
15
  //
16
- // NOTE on scope coverage: `readOrder` is the injected set, and it deliberately
17
- // excludes `project::` the hooks never inject project-scope lessons, so `tree`
18
- // doesn't either (browse those with `lorekit list`). Both stores are resolved
16
+ // NOTE on scope coverage: `readOrder` is the injected set. As of the smart-hooks
17
+ // PR it INCLUDES `project::` (project is the most-specific scope and now wins /
18
+ // is injected), so `tree` shows it too the ordering is now unified across the
19
+ // hook, `tree`, and every read command's `scopeList`. Both stores are resolved
19
20
  // independently (precedence is per-store — the hook reads one resolved store),
20
21
  // in the same Offline / Remote split as `list`. Graceful, read-only, wrapped in
21
22
  // `traceCommand` by the bin.
@@ -24,7 +25,8 @@ import { resolveProjectRoot } from './config.mjs';
24
25
  import { deriveScope } from './scope.mjs';
25
26
  import { resolveDenies } from './control.mjs';
26
27
  import { resolveStores, remoteUnavailableReason } from './stores.mjs';
27
- import { gather, resolvePrecedence, preview, shortDate } from './lessons-view.mjs';
28
+ import { resolvePrecedence } from './lessons-pure.mjs';
29
+ import { gather, preview, shortDate } from './lessons-view.mjs';
28
30
  import { log, heading, status, c } from './util.mjs';
29
31
 
30
32
  export async function tree(args) {