@lorekit/cli 1.10.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 +69 -7
- package/bin/lorekit.mjs +99 -5
- package/package.json +1 -1
- package/src/core/lessons.mjs +153 -9
- package/src/dedupe.mjs +184 -0
- package/src/hook.mjs +25 -2
- package/src/lessons-pure.mjs +65 -0
- package/src/lessons-view.mjs +0 -0
- package/src/lint.mjs +152 -0
- package/src/scope.mjs +5 -1
- package/src/telemetry.mjs +4 -4
- package/src/tree.mjs +157 -0
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`,
|
|
55
|
-
|
|
56
|
-
and
|
|
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
|
|
|
@@ -210,13 +212,72 @@ remote is unconfigured (or a store is denied), a meaningful diff is impossible,
|
|
|
210
212
|
so `diff` prints a clear note (`comparable: false` in `--json`) and exits 0
|
|
211
213
|
rather than crashing. `--endpoint` / `--token` / `--store` behave as in `list`.
|
|
212
214
|
|
|
215
|
+
### `lorekit tree` (alias `resolve`)
|
|
216
|
+
|
|
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**:
|
|
221
|
+
|
|
222
|
+
```bash
|
|
223
|
+
lorekit tree # the injected hierarchy with ✓ winning / ↳ shadowed marks
|
|
224
|
+
lorekit tree --scope global # narrow to a single scope
|
|
225
|
+
lorekit tree --json # per-entry { winning, shadowedBy } + a winners[] list
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
This mirrors the SessionStart hook's resolution **exactly**: it reads the scopes
|
|
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.
|
|
235
|
+
|
|
236
|
+
### `lorekit lint`
|
|
237
|
+
|
|
238
|
+
Flag low-quality lessons across the applicable scopes and both stores. Each
|
|
239
|
+
finding names the rule it violated:
|
|
240
|
+
|
|
241
|
+
```bash
|
|
242
|
+
lorekit lint # findings grouped by scope; exits non-zero if any
|
|
243
|
+
lorekit lint --scope global # narrow to a single scope
|
|
244
|
+
lorekit lint --json # { total, offline, remote } structured findings
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Rules: **empty-value** (blank/whitespace-only body), **short-value** (a non-empty
|
|
248
|
+
body below a small length threshold), **untrimmed-value** (real content with
|
|
249
|
+
surrounding whitespace), **empty-key** (blank key), and **malformed-scope** (e.g.
|
|
250
|
+
a single `:` where `::` is expected). `lint` **exits non-zero (1) when any issue
|
|
251
|
+
is found**, so it is usable as a CI gate (`lorekit lint || exit 1`); a clean run —
|
|
252
|
+
or one where only a store is unavailable — exits 0. The pure rule predicates live
|
|
253
|
+
in `lessons-view.mjs` and are unit-tested one rule at a time.
|
|
254
|
+
|
|
255
|
+
### `lorekit dedupe`
|
|
256
|
+
|
|
257
|
+
Find likely-duplicate lessons and group them into clusters — per store, across
|
|
258
|
+
the applicable scopes:
|
|
259
|
+
|
|
260
|
+
```bash
|
|
261
|
+
lorekit dedupe # clusters of near-duplicate lessons per store
|
|
262
|
+
lorekit dedupe --threshold 0.6 # loosen the similarity cutoff (default 0.8)
|
|
263
|
+
lorekit dedupe --json # { threshold, offline, remote } clusters + signal
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
The similarity signal is a zero-dependency **heuristic** — Jaccard overlap of
|
|
267
|
+
lowercased word tokens, **not** a semantic/embedding measure — so it surfaces
|
|
268
|
+
candidates for a human to review and can both miss paraphrases and group
|
|
269
|
+
coincidental overlaps. Any pair scoring at or above `--threshold` links (transitively)
|
|
270
|
+
into one cluster; only clusters of 2+ members are reported, each with a similarity
|
|
271
|
+
range. Cross-**store** divergence is `diff`'s job; `dedupe` looks within a store.
|
|
272
|
+
|
|
213
273
|
### `lorekit hook`
|
|
214
274
|
|
|
215
275
|
The **shared hook engine** behind the Claude Code / Cursor / Codex plugins.
|
|
216
276
|
It is not run by hand — the plugins wire it into their hook config. It reads
|
|
217
277
|
the host framework's JSON on stdin and prints that host's injection format on
|
|
218
|
-
stdout (lessons at session start; a nudge on
|
|
219
|
-
always exiting 0 so it can never
|
|
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.
|
|
220
281
|
|
|
221
282
|
```bash
|
|
222
283
|
lorekit hook --adapter <claude|cursor|codex> --event <SessionStart|Stop|…>
|
|
@@ -392,8 +453,9 @@ active deny constraints.
|
|
|
392
453
|
| `--no-hooks` | Skip wiring the lifecycle hooks; skill + MCP only (`install`) |
|
|
393
454
|
| `--force` | Overwrite existing skill files (`install`) |
|
|
394
455
|
| `--deep` | Write/read/delete round-trip (`doctor`) |
|
|
395
|
-
| `--json` | Machine-readable output (`list` / `search` / `show` / `stats` / `diff`) |
|
|
396
|
-
| `--scope <scope>` | Restrict to a single scope (`list` / `search` / `stats` / `diff`; default: all applicable) |
|
|
456
|
+
| `--json` | Machine-readable output (`list` / `search` / `show` / `stats` / `diff` / `tree` / `lint` / `dedupe`) |
|
|
457
|
+
| `--scope <scope>` | Restrict to a single scope (`list` / `search` / `stats` / `diff` / `tree` / `lint` / `dedupe`; default: all applicable) |
|
|
458
|
+
| `--threshold <0..1>` | Duplicate-similarity cutoff (`dedupe`; default `0.8`) |
|
|
397
459
|
| `--adapter <name>` | Host framework for `hook`: `claude` / `cursor` / `codex` |
|
|
398
460
|
| `--event <name>` | Host hook event for `hook` (else read from the stdin payload) |
|
|
399
461
|
| `-h, --help` | Help |
|
package/bin/lorekit.mjs
CHANGED
|
@@ -11,6 +11,9 @@ import { search } from '../src/search.mjs';
|
|
|
11
11
|
import { show } from '../src/show.mjs';
|
|
12
12
|
import { stats } from '../src/stats.mjs';
|
|
13
13
|
import { diff } from '../src/diff.mjs';
|
|
14
|
+
import { tree } from '../src/tree.mjs';
|
|
15
|
+
import { lint } from '../src/lint.mjs';
|
|
16
|
+
import { dedupe } from '../src/dedupe.mjs';
|
|
14
17
|
import { hook } from '../src/hook.mjs';
|
|
15
18
|
import { migrate } from '../src/migrate.mjs';
|
|
16
19
|
import { mcpServer } from '../src/mcp-server.mjs';
|
|
@@ -56,6 +59,15 @@ ${c.bold('Commands')}
|
|
|
56
59
|
diff Compare the offline and remote stores for the applicable scopes and
|
|
57
60
|
report divergence: local-only, remote-only, and conflicting keys
|
|
58
61
|
(grouped by scope). Needs both stores readable. --json, --scope <s>.
|
|
62
|
+
tree Show the injected scopes (branch → repo → global) as a precedence
|
|
63
|
+
(resolve) hierarchy and mark, per key, which scope's lesson WINS and which are
|
|
64
|
+
shadowed — the real hook-resolution order. --json, --scope <s>.
|
|
65
|
+
lint Flag low-quality lessons (empty/short/untrimmed value, empty key,
|
|
66
|
+
malformed scope) across the applicable scopes and both stores. Exits
|
|
67
|
+
non-zero when issues are found (CI gate). --json, --scope <s>.
|
|
68
|
+
dedupe Find likely-duplicate lessons via a zero-dep word-overlap HEURISTIC
|
|
69
|
+
(Jaccard >= threshold, not semantic), grouped into clusters per
|
|
70
|
+
store. --json, --scope <s>, --threshold <0..1>.
|
|
59
71
|
migrate Relocate a LoreKit-format local store into the current layout.
|
|
60
72
|
Dry-run by default; pass --yes to apply. Idempotent.
|
|
61
73
|
hook Hook engine for Claude Code / Cursor / Codex. Reads the host's
|
|
@@ -74,8 +86,9 @@ ${c.bold('Options')}
|
|
|
74
86
|
-t, --token <token> LoreKit token (lk_rw_* to allow writes, lk_ro_* read-only)
|
|
75
87
|
--mode <mode> Memory mode: off | local | remote (doctor override)
|
|
76
88
|
--store <path> Local project-tier store directory (default: .lorekit)
|
|
77
|
-
--json Machine-readable output (list / search / show / stats / diff)
|
|
78
|
-
--scope <scope> Restrict to a single scope (list / search / stats / diff)
|
|
89
|
+
--json Machine-readable output (list / search / show / stats / diff / tree / lint / dedupe)
|
|
90
|
+
--scope <scope> Restrict to a single scope (list / search / stats / diff / tree / lint / dedupe)
|
|
91
|
+
--threshold <0..1> Duplicate-similarity cutoff (dedupe; default 0.8)
|
|
79
92
|
--from <path> Source store to migrate from (migrate)
|
|
80
93
|
--to <tier> Migration destination tier: home | project (migrate;
|
|
81
94
|
default routes each entry by scope)
|
|
@@ -295,6 +308,80 @@ ${c.bold('Examples')}
|
|
|
295
308
|
npx @lorekit/cli diff
|
|
296
309
|
npx @lorekit/cli diff --json
|
|
297
310
|
npx @lorekit/cli diff --scope global
|
|
311
|
+
`,
|
|
312
|
+
tree: `${c.bold('lorekit tree')} — show the scope precedence hierarchy and which lesson wins ${c.dim('(alias: resolve)')}
|
|
313
|
+
|
|
314
|
+
${c.bold('Usage')}
|
|
315
|
+
npx @lorekit/cli tree [options]
|
|
316
|
+
|
|
317
|
+
Shows the scopes the hooks actually inject for the current directory — branch,
|
|
318
|
+
repo, global, in precedence order (most-specific first) — and marks, for any key
|
|
319
|
+
present at more than one scope, which scope's lesson WINS and which are shadowed.
|
|
320
|
+
This mirrors the SessionStart hook's resolution exactly (a more-specific scope
|
|
321
|
+
overrides a broader scope's same-key lesson). Project-scope lessons are NOT
|
|
322
|
+
injected by the hooks, so they are not shown here — browse them with \`lorekit list\`.
|
|
323
|
+
Resolved independently per store, in the same Offline / Remote split.
|
|
324
|
+
|
|
325
|
+
${c.bold('Options')}
|
|
326
|
+
-d, --dir <path> Target project root (default: current directory)
|
|
327
|
+
--scope <scope> Restrict to a single scope (default: the injected set)
|
|
328
|
+
--json Machine-readable output (per-entry winning/shadowedBy tags)
|
|
329
|
+
-e, --endpoint <url> Remote endpoint override (else .mcp.json / LOREKIT_MCP_URL)
|
|
330
|
+
-t, --token <token> Remote token override (else .mcp.json / LOREKIT_TOKEN)
|
|
331
|
+
--store <path> Local project-tier store directory (default: .lorekit)
|
|
332
|
+
|
|
333
|
+
${c.bold('Examples')}
|
|
334
|
+
npx @lorekit/cli tree
|
|
335
|
+
npx @lorekit/cli resolve --json
|
|
336
|
+
`,
|
|
337
|
+
lint: `${c.bold('lorekit lint')} — flag low-quality lessons across the applicable scopes
|
|
338
|
+
|
|
339
|
+
${c.bold('Usage')}
|
|
340
|
+
npx @lorekit/cli lint [options]
|
|
341
|
+
|
|
342
|
+
Checks every lesson for the current directory's scopes (project/branch/repo/
|
|
343
|
+
global), across both stores, against a small set of quality rules: empty or
|
|
344
|
+
whitespace-only value, suspiciously short value, untrimmed value, empty key, and
|
|
345
|
+
malformed scope (e.g. a single \`:\` where \`::\` is expected). Each finding names
|
|
346
|
+
the rule it violated. Exits NON-ZERO when any issue is found, so it works as a CI
|
|
347
|
+
gate; a clean run — or one where only a store is unavailable — exits 0.
|
|
348
|
+
|
|
349
|
+
${c.bold('Options')}
|
|
350
|
+
-d, --dir <path> Target project root (default: current directory)
|
|
351
|
+
--scope <scope> Restrict to a single scope (default: all applicable)
|
|
352
|
+
--json Machine-readable output (structured findings list)
|
|
353
|
+
-e, --endpoint <url> Remote endpoint override (else .mcp.json / LOREKIT_MCP_URL)
|
|
354
|
+
-t, --token <token> Remote token override (else .mcp.json / LOREKIT_TOKEN)
|
|
355
|
+
--store <path> Local project-tier store directory (default: .lorekit)
|
|
356
|
+
|
|
357
|
+
${c.bold('Examples')}
|
|
358
|
+
npx @lorekit/cli lint
|
|
359
|
+
npx @lorekit/cli lint --json
|
|
360
|
+
npx @lorekit/cli lint --scope global
|
|
361
|
+
`,
|
|
362
|
+
dedupe: `${c.bold('lorekit dedupe')} — find likely-duplicate lessons (heuristic)
|
|
363
|
+
|
|
364
|
+
${c.bold('Usage')}
|
|
365
|
+
npx @lorekit/cli dedupe [options]
|
|
366
|
+
|
|
367
|
+
Groups lessons whose values overlap heavily into duplicate clusters, per store,
|
|
368
|
+
across the current directory's scopes. The similarity signal is a zero-dependency
|
|
369
|
+
HEURISTIC — Jaccard overlap of lowercased word tokens, not a semantic/embedding
|
|
370
|
+
measure — so it surfaces candidates for a human to review, and can both miss
|
|
371
|
+
paraphrases and group coincidental overlaps. Tune the cutoff with --threshold.
|
|
372
|
+
|
|
373
|
+
${c.bold('Options')}
|
|
374
|
+
-d, --dir <path> Target project root (default: current directory)
|
|
375
|
+
--scope <scope> Restrict to a single scope (default: all applicable)
|
|
376
|
+
--threshold <0..1> Similarity cutoff to cluster a pair (default: 0.8)
|
|
377
|
+
--json Machine-readable output (clusters + similarity signal)
|
|
378
|
+
-e, --endpoint <url> Remote endpoint override (else .mcp.json / LOREKIT_MCP_URL)
|
|
379
|
+
-t, --token <token> Remote token override (else .mcp.json / LOREKIT_TOKEN)
|
|
380
|
+
--store <path> Local project-tier store directory (default: .lorekit)
|
|
381
|
+
|
|
382
|
+
${c.bold('Examples')}
|
|
383
|
+
npx @lorekit/cli dedupe
|
|
384
|
+
npx @lorekit/cli dedupe --threshold 0.6 --json
|
|
298
385
|
`,
|
|
299
386
|
migrate: `${c.bold('lorekit migrate')} — relocate a LoreKit-format local store into the current layout
|
|
300
387
|
|
|
@@ -348,19 +435,20 @@ ${c.bold('Options')}
|
|
|
348
435
|
const KNOWN_FLAGS = [
|
|
349
436
|
'dir', 'project', 'global', 'endpoint', 'token', 'mode', 'store',
|
|
350
437
|
'from', 'to', 'apply', 'yes', 'no-hooks', 'force', 'deep', 'adapter',
|
|
351
|
-
'event', 'json', 'scope', 'help', 'version',
|
|
438
|
+
'event', 'json', 'scope', 'threshold', 'help', 'version',
|
|
352
439
|
];
|
|
353
440
|
|
|
354
441
|
// Commands that write to disk / talk to the network on a human's behalf. These
|
|
355
442
|
// reject unknown flags; the machine-facing `hook` / `mcp` do not (they must
|
|
356
443
|
// never fail on a stray flag, and only ever receive flags we control).
|
|
357
444
|
const HUMAN_COMMANDS = new Set([
|
|
358
|
-
'install', 'uninstall', 'doctor', 'list', 'search', 'show', 'stats', 'diff',
|
|
445
|
+
'install', 'uninstall', 'doctor', 'list', 'search', 'show', 'stats', 'diff',
|
|
446
|
+
'tree', 'lint', 'dedupe', 'migrate',
|
|
359
447
|
]);
|
|
360
448
|
|
|
361
449
|
// Command aliases — canonicalized before help / dispatch so `lorekit ls --help`
|
|
362
450
|
// and telemetry both resolve to the real command name.
|
|
363
|
-
const COMMAND_ALIASES = { ls: 'list', grep: 'search' };
|
|
451
|
+
const COMMAND_ALIASES = { ls: 'list', grep: 'search', resolve: 'tree' };
|
|
364
452
|
|
|
365
453
|
async function main() {
|
|
366
454
|
// Load a `.env` from the current directory (if any) before anything reads the
|
|
@@ -439,6 +527,12 @@ async function main() {
|
|
|
439
527
|
return traceCommand('stats', args, VERSION, () => stats(args));
|
|
440
528
|
case 'diff':
|
|
441
529
|
return traceCommand('diff', args, VERSION, () => diff(args));
|
|
530
|
+
case 'tree':
|
|
531
|
+
return traceCommand('tree', args, VERSION, () => tree(args));
|
|
532
|
+
case 'lint':
|
|
533
|
+
return traceCommand('lint', args, VERSION, () => lint(args));
|
|
534
|
+
case 'dedupe':
|
|
535
|
+
return traceCommand('dedupe', args, VERSION, () => dedupe(args));
|
|
442
536
|
case 'migrate':
|
|
443
537
|
return traceCommand('migrate', args, VERSION, () => migrate(args));
|
|
444
538
|
default:
|
package/package.json
CHANGED
package/src/core/lessons.mjs
CHANGED
|
@@ -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
|
|
10
|
-
//
|
|
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
|
|
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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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/dedupe.mjs
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// `lorekit dedupe` — detect likely-duplicate lessons across the applicable
|
|
2
|
+
// scopes within each store. ZERO-DEP means a HEURISTIC, never embeddings: it
|
|
3
|
+
// clusters lessons whose values share a high Jaccard word-token overlap (default
|
|
4
|
+
// 0.8, tunable with `--threshold`). It flags candidates worth a human's eye — it
|
|
5
|
+
// is NOT a semantic judge and can both miss paraphrases and group coincidental
|
|
6
|
+
// overlaps. The pure core (`tokenize` / `similarity` / `clusterDuplicates`) lives
|
|
7
|
+
// in `lessons-view.mjs` and is thoroughly unit-tested.
|
|
8
|
+
//
|
|
9
|
+
// Clustering is per-store, across all applicable scopes (an offline cluster may
|
|
10
|
+
// span project + global; cross-STORE divergence is `diff`'s job, not this one).
|
|
11
|
+
// Same Offline / Remote split and graceful degradation as `list`. Read-only.
|
|
12
|
+
// Human-facing, so the bin wraps it in `traceCommand`.
|
|
13
|
+
import process from 'node:process';
|
|
14
|
+
import { resolveProjectRoot } from './config.mjs';
|
|
15
|
+
import { deriveScope } from './scope.mjs';
|
|
16
|
+
import { resolveDenies } from './control.mjs';
|
|
17
|
+
import { resolveStores, remoteUnavailableReason } from './stores.mjs';
|
|
18
|
+
import { scopeList, gather, clusterDuplicates } from './lessons-view.mjs';
|
|
19
|
+
import { log, heading, status, c } from './util.mjs';
|
|
20
|
+
|
|
21
|
+
const DEFAULT_THRESHOLD = 0.8;
|
|
22
|
+
|
|
23
|
+
// Parse `--threshold` into a number in [0, 1]; anything unparseable or out of
|
|
24
|
+
// range falls back to the default (never a crash on bad input). Pure-ish helper.
|
|
25
|
+
export function parseThreshold(raw) {
|
|
26
|
+
if (raw === undefined || raw === true) return DEFAULT_THRESHOLD;
|
|
27
|
+
const n = Number(raw);
|
|
28
|
+
if (!Number.isFinite(n)) return DEFAULT_THRESHOLD;
|
|
29
|
+
return Math.min(1, Math.max(0, n));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Flatten a `gather()` result into one entry list (each entry keeps its scope)
|
|
33
|
+
// for cross-scope clustering, plus the scopes whose read errored (they can't be
|
|
34
|
+
// clustered and are surfaced, not silently dropped).
|
|
35
|
+
function flatten(gathered) {
|
|
36
|
+
const entries = [];
|
|
37
|
+
const errored = [];
|
|
38
|
+
for (const g of gathered.groups || []) {
|
|
39
|
+
if (g.error) {
|
|
40
|
+
errored.push({ scope: g.scope, error: g.error });
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
for (const e of g.entries || []) entries.push(e);
|
|
44
|
+
}
|
|
45
|
+
return { entries, errored };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function dedupe(args) {
|
|
49
|
+
const root = resolveProjectRoot(args.dir);
|
|
50
|
+
const env = { ...process.env };
|
|
51
|
+
if (args.store) env.LOREKIT_STORE = args.store;
|
|
52
|
+
|
|
53
|
+
const threshold = parseThreshold(args.threshold);
|
|
54
|
+
const scopeInfo = deriveScope(root);
|
|
55
|
+
// Default to every applicable scope; `--scope <s>` narrows to one.
|
|
56
|
+
const scopes = args.scope && typeof args.scope === 'string' ? [args.scope] : scopeList(scopeInfo);
|
|
57
|
+
|
|
58
|
+
const { local, remote, connection } = resolveStores(root, {
|
|
59
|
+
env,
|
|
60
|
+
endpoint: args.endpoint,
|
|
61
|
+
token: args.token,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Deny-wins section suppression, identical to the other read commands.
|
|
65
|
+
const { localDenied, remoteDenied } = resolveDenies(root, { env });
|
|
66
|
+
|
|
67
|
+
const buildSection = (flat) => ({
|
|
68
|
+
available: true,
|
|
69
|
+
clusters: clusterDuplicates(flat.entries, threshold),
|
|
70
|
+
errored: flat.errored,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const offlineSection = localDenied
|
|
74
|
+
? { available: false, reason: `disabled by deny constraint (${localDenied.source})` }
|
|
75
|
+
: buildSection(flatten(await gather(local, scopes)));
|
|
76
|
+
|
|
77
|
+
const remoteAvailable = !remoteDenied && remote.usable();
|
|
78
|
+
const remoteSection = remoteAvailable
|
|
79
|
+
? buildSection(flatten(await gather(remote, scopes)))
|
|
80
|
+
: {
|
|
81
|
+
available: false,
|
|
82
|
+
reason: remoteDenied
|
|
83
|
+
? `disabled by deny constraint (${remoteDenied.source})`
|
|
84
|
+
: remoteUnavailableReason(connection),
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const offlineClusters = offlineSection.available ? offlineSection.clusters.length : 0;
|
|
88
|
+
const remoteClusters = remoteSection.available ? remoteSection.clusters.length : 0;
|
|
89
|
+
|
|
90
|
+
if (args.json) {
|
|
91
|
+
log(JSON.stringify(buildJson({ root, scopes, threshold, offlineSection, remoteSection }), null, 2));
|
|
92
|
+
} else {
|
|
93
|
+
heading('LoreKit dedupe');
|
|
94
|
+
log(` project: ${c.dim(root)}`);
|
|
95
|
+
log(` scopes: ${scopes.join(' → ')}`);
|
|
96
|
+
log(` ${c.dim(`heuristic: Jaccard word-token overlap >= ${threshold} (not semantic)`)}`);
|
|
97
|
+
|
|
98
|
+
renderDedupeSection({ title: 'Offline' }, offlineSection);
|
|
99
|
+
renderDedupeSection(
|
|
100
|
+
{ title: 'Remote', subtitle: remoteAvailable ? connection.endpoint : undefined },
|
|
101
|
+
remoteSection,
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
log('');
|
|
105
|
+
const total = offlineClusters + remoteClusters;
|
|
106
|
+
if (total === 0) {
|
|
107
|
+
log(` ${c.green('✓')} no likely-duplicate clusters at this threshold`);
|
|
108
|
+
} else {
|
|
109
|
+
const plural = total === 1 ? '' : 's';
|
|
110
|
+
log(` ${c.yellow('!')} ${total} duplicate cluster${plural} found`);
|
|
111
|
+
}
|
|
112
|
+
log('');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Bounded, non-PII telemetry extras — counts + a boolean, never a scope
|
|
116
|
+
// string, key, path, or token.
|
|
117
|
+
return {
|
|
118
|
+
exitCode: 0,
|
|
119
|
+
'lorekit.cli.dedupe.scope_count': scopes.length,
|
|
120
|
+
'lorekit.cli.dedupe.threshold': threshold,
|
|
121
|
+
'lorekit.cli.dedupe.offline_clusters': offlineClusters,
|
|
122
|
+
'lorekit.cli.dedupe.remote_clusters': remoteClusters,
|
|
123
|
+
'lorekit.cli.dedupe.remote_available': remoteAvailable,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Render one store's clusters: each cluster lists its member scope::key lines
|
|
128
|
+
// and a similarity signal. A read error (a scope that couldn't be gathered) is
|
|
129
|
+
// surfaced up front so a partial read is never mistaken for "no duplicates".
|
|
130
|
+
function renderDedupeSection(header, section) {
|
|
131
|
+
heading(header.title);
|
|
132
|
+
if (header.subtitle) log(` ${c.dim(header.subtitle)}`);
|
|
133
|
+
|
|
134
|
+
if (!section.available) {
|
|
135
|
+
status('warn', 'unavailable', section.reason);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
for (const e of section.errored || []) {
|
|
140
|
+
log(` ${c.bold(e.scope)} ${c.yellow('!')} ${c.dim(e.error)}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!section.clusters.length) {
|
|
144
|
+
if (!(section.errored || []).length) log(` ${c.dim('no likely-duplicate clusters')}`);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let n = 0;
|
|
149
|
+
for (const cluster of section.clusters) {
|
|
150
|
+
n += 1;
|
|
151
|
+
const range =
|
|
152
|
+
cluster.minSimilarity === cluster.maxSimilarity
|
|
153
|
+
? cluster.minSimilarity.toFixed(2)
|
|
154
|
+
: `${cluster.minSimilarity.toFixed(2)}–${cluster.maxSimilarity.toFixed(2)}`;
|
|
155
|
+
log(` ${c.yellow('•')} cluster ${n} ${c.dim(`(${cluster.size} lessons, similarity ${range})`)}`);
|
|
156
|
+
for (const m of cluster.members) {
|
|
157
|
+
log(` ${c.cyan('-')} ${m.scope}::${m.key}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// The `--json` payload: `{ root, scopes, threshold, offline, remote }` — each
|
|
163
|
+
// store a `{ available, clusters: [{ members, size, minSimilarity,
|
|
164
|
+
// maxSimilarity }], errored }` record (or an unavailable note).
|
|
165
|
+
function buildJson({ root, scopes, threshold, offlineSection, remoteSection }) {
|
|
166
|
+
return {
|
|
167
|
+
root,
|
|
168
|
+
scopes,
|
|
169
|
+
threshold,
|
|
170
|
+
offline: sectionJson(offlineSection),
|
|
171
|
+
remote: sectionJson(remoteSection),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function sectionJson(section) {
|
|
176
|
+
if (!section.available) {
|
|
177
|
+
return { available: false, reason: section.reason, clusters: [], errored: [] };
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
available: true,
|
|
181
|
+
clusters: section.clusters,
|
|
182
|
+
errored: section.errored || [],
|
|
183
|
+
};
|
|
184
|
+
}
|
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 {
|
|
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
|
-
|
|
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
|
+
}
|
package/src/lessons-view.mjs
CHANGED
|
Binary file
|
package/src/lint.mjs
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// `lorekit lint` — flag low-quality lessons across the applicable scopes and
|
|
2
|
+
// both stores. Each finding names the rule it violated (empty/whitespace value,
|
|
3
|
+
// suspiciously short value, untrimmed value, empty key, malformed scope). The
|
|
4
|
+
// rules are pure predicates in `lessons-view.mjs` (`LINT_RULES` / `lintEntry`),
|
|
5
|
+
// each independently unit-tested.
|
|
6
|
+
//
|
|
7
|
+
// Exit convention: `lint` exits NON-ZERO (1) when any finding exists, so it is
|
|
8
|
+
// usable as a CI gate (`lorekit lint || fail`); a clean run — or a run where the
|
|
9
|
+
// only issue is an unavailable store — exits 0. `--json` carries the structured
|
|
10
|
+
// findings either way. Same Offline / Remote split and graceful degradation as
|
|
11
|
+
// `list`; a `LOREKIT_DENY` ceiling or an unconfigured remote is a note, not an
|
|
12
|
+
// error. Read-only. Human-facing, so the bin wraps it in `traceCommand`.
|
|
13
|
+
import process from 'node:process';
|
|
14
|
+
import { resolveProjectRoot } from './config.mjs';
|
|
15
|
+
import { deriveScope } from './scope.mjs';
|
|
16
|
+
import { resolveDenies } from './control.mjs';
|
|
17
|
+
import { resolveStores, remoteUnavailableReason } from './stores.mjs';
|
|
18
|
+
import { scopeList, gather, lintGroups } from './lessons-view.mjs';
|
|
19
|
+
import { log, heading, status, c } from './util.mjs';
|
|
20
|
+
|
|
21
|
+
export async function lint(args) {
|
|
22
|
+
const root = resolveProjectRoot(args.dir);
|
|
23
|
+
const env = { ...process.env };
|
|
24
|
+
if (args.store) env.LOREKIT_STORE = args.store;
|
|
25
|
+
|
|
26
|
+
const scopeInfo = deriveScope(root);
|
|
27
|
+
// Default to every applicable scope; `--scope <s>` narrows to one.
|
|
28
|
+
const scopes = args.scope && typeof args.scope === 'string' ? [args.scope] : scopeList(scopeInfo);
|
|
29
|
+
|
|
30
|
+
const { local, remote, connection } = resolveStores(root, {
|
|
31
|
+
env,
|
|
32
|
+
endpoint: args.endpoint,
|
|
33
|
+
token: args.token,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Deny-wins section suppression, identical to the other read commands.
|
|
37
|
+
const { localDenied, remoteDenied } = resolveDenies(root, { env });
|
|
38
|
+
|
|
39
|
+
const offlineResult = localDenied ? { groups: [], total: 0 } : lintGroups(await gather(local, scopes));
|
|
40
|
+
const remoteAvailable = !remoteDenied && remote.usable();
|
|
41
|
+
const remoteResult = remoteAvailable ? lintGroups(await gather(remote, scopes)) : { groups: [], total: 0 };
|
|
42
|
+
|
|
43
|
+
const offlineSection = localDenied
|
|
44
|
+
? { available: false, reason: `disabled by deny constraint (${localDenied.source})` }
|
|
45
|
+
: { available: true, ...offlineResult };
|
|
46
|
+
const remoteSection = remoteAvailable
|
|
47
|
+
? { available: true, ...remoteResult }
|
|
48
|
+
: {
|
|
49
|
+
available: false,
|
|
50
|
+
reason: remoteDenied
|
|
51
|
+
? `disabled by deny constraint (${remoteDenied.source})`
|
|
52
|
+
: remoteUnavailableReason(connection),
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const totalFindings =
|
|
56
|
+
(offlineSection.available ? offlineSection.total : 0) +
|
|
57
|
+
(remoteSection.available ? remoteSection.total : 0);
|
|
58
|
+
|
|
59
|
+
if (args.json) {
|
|
60
|
+
log(JSON.stringify(buildJson({ root, scopes, offlineSection, remoteSection, totalFindings }), null, 2));
|
|
61
|
+
} else {
|
|
62
|
+
heading('LoreKit lint');
|
|
63
|
+
log(` project: ${c.dim(root)}`);
|
|
64
|
+
log(` scopes: ${scopes.join(' → ')}`);
|
|
65
|
+
|
|
66
|
+
renderLintSection({ title: 'Offline' }, offlineSection);
|
|
67
|
+
renderLintSection(
|
|
68
|
+
{ title: 'Remote', subtitle: remoteAvailable ? connection.endpoint : undefined },
|
|
69
|
+
remoteSection,
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
log('');
|
|
73
|
+
if (totalFindings === 0) {
|
|
74
|
+
log(` ${c.green('✓')} no lint issues in the applicable scopes`);
|
|
75
|
+
} else {
|
|
76
|
+
const plural = totalFindings === 1 ? '' : 's';
|
|
77
|
+
log(` ${c.yellow('!')} ${totalFindings} lint issue${plural} found`);
|
|
78
|
+
}
|
|
79
|
+
log('');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Exit non-zero when findings exist so `lint` is usable as a CI gate. An
|
|
83
|
+
// unavailable store is never itself a failure — only actual findings are.
|
|
84
|
+
// Bounded, non-PII telemetry extras — counts + a boolean.
|
|
85
|
+
return {
|
|
86
|
+
exitCode: totalFindings > 0 ? 1 : 0,
|
|
87
|
+
'lorekit.cli.lint.scope_count': scopes.length,
|
|
88
|
+
'lorekit.cli.lint.offline_findings': offlineSection.available ? offlineSection.total : 0,
|
|
89
|
+
'lorekit.cli.lint.remote_findings': remoteSection.available ? remoteSection.total : 0,
|
|
90
|
+
'lorekit.cli.lint.total_findings': totalFindings,
|
|
91
|
+
'lorekit.cli.lint.remote_available': remoteAvailable,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Render one store's findings, grouped by scope: `key rule — message` per
|
|
96
|
+
// finding. A scope with no findings and no error is omitted; a read error is
|
|
97
|
+
// surfaced in place (its entries couldn't be linted).
|
|
98
|
+
function renderLintSection(header, section) {
|
|
99
|
+
heading(header.title);
|
|
100
|
+
if (header.subtitle) log(` ${c.dim(header.subtitle)}`);
|
|
101
|
+
|
|
102
|
+
if (!section.available) {
|
|
103
|
+
status('warn', 'unavailable', section.reason);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const printable = (section.groups || []).filter((g) => g.findings.length || g.error);
|
|
108
|
+
if (!printable.length) {
|
|
109
|
+
log(` ${c.dim('no lint issues in the applicable scopes')}`);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
for (const g of printable) {
|
|
114
|
+
log(` ${c.bold(g.scope)}`);
|
|
115
|
+
if (g.error) {
|
|
116
|
+
log(` ${c.yellow('!')} ${c.dim(g.error)}`);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
for (const f of g.findings) {
|
|
120
|
+
log(` ${c.yellow('•')} ${f.key} ${c.dim(`[${f.rule}]`)} ${f.message}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// The `--json` payload: `{ root, scopes, total, offline, remote }` — each store
|
|
126
|
+
// a `{ available, total, scopes: [{ scope, error, findings: [{ key, rule,
|
|
127
|
+
// message }] }] }` record (or an unavailable note), so a script gets the same
|
|
128
|
+
// structured finding list regardless of which store it came from.
|
|
129
|
+
function buildJson({ root, scopes, offlineSection, remoteSection, totalFindings }) {
|
|
130
|
+
return {
|
|
131
|
+
root,
|
|
132
|
+
scopes,
|
|
133
|
+
total: totalFindings,
|
|
134
|
+
offline: sectionJson(offlineSection),
|
|
135
|
+
remote: sectionJson(remoteSection),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function sectionJson(section) {
|
|
140
|
+
if (!section.available) {
|
|
141
|
+
return { available: false, reason: section.reason, total: 0, scopes: [] };
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
available: true,
|
|
145
|
+
total: section.total,
|
|
146
|
+
scopes: (section.groups || []).map((g) => ({
|
|
147
|
+
scope: g.scope,
|
|
148
|
+
error: g.error || null,
|
|
149
|
+
findings: g.findings,
|
|
150
|
+
})),
|
|
151
|
+
};
|
|
152
|
+
}
|
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
|
-
|
|
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/telemetry.mjs
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
// mirrors the Edge Function's SDK-free approach (supabase/functions/_shared/
|
|
5
5
|
// otel.ts): OTLP/JSON over the global fetch (Node 18+), no @opentelemetry/*
|
|
6
6
|
// packages. One span + one counter data point per human-facing command
|
|
7
|
-
// (install / uninstall / doctor / list / search / show / stats / diff /
|
|
8
|
-
// migrate), fired to Dash0 so the maintainers can see which
|
|
9
|
-
// actually run.
|
|
7
|
+
// (install / uninstall / doctor / list / search / show / stats / diff / tree /
|
|
8
|
+
// lint / dedupe / migrate), fired to Dash0 so the maintainers can see which
|
|
9
|
+
// commands people actually run.
|
|
10
10
|
//
|
|
11
11
|
// Privacy — this runs on end-users' machines, so it is deliberately narrow:
|
|
12
12
|
// • Opt-out honored: LOREKIT_TELEMETRY=0|off|false|no|disable, or the
|
|
@@ -275,7 +275,7 @@ function normalizeExitCode(result) {
|
|
|
275
275
|
* counter point. Returns the command's exit code unchanged. Telemetry failures
|
|
276
276
|
* are swallowed — the command result is never affected.
|
|
277
277
|
*
|
|
278
|
-
* @param {string} command bounded: install | uninstall | doctor | list | search | show | stats | diff | migrate
|
|
278
|
+
* @param {string} command bounded: install | uninstall | doctor | list | search | show | stats | diff | tree | lint | dedupe | migrate
|
|
279
279
|
* @param {object} args parsed CLI args (read for allow-listed flags only)
|
|
280
280
|
* @param {string} version CLI version (from package.json)
|
|
281
281
|
* @param {() => Promise<number>} run the command handler
|
package/src/tree.mjs
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// `lorekit tree` (alias `resolve`) — show the applicable scopes as a precedence
|
|
2
|
+
// hierarchy and mark, for any key that lives at MORE THAN ONE scope, which
|
|
3
|
+
// scope's lesson actually WINS and which are shadowed. This answers "which
|
|
4
|
+
// lesson applies here, and what is being overridden?".
|
|
5
|
+
//
|
|
6
|
+
// Precedence is not an assumption — it mirrors the hook engine exactly. The
|
|
7
|
+
// SessionStart hook (`core/lessons.mjs → fetchLessons`) reads the scopes in
|
|
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
|
+
//
|
|
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
|
|
20
|
+
// independently (precedence is per-store — the hook reads one resolved store),
|
|
21
|
+
// in the same Offline / Remote split as `list`. Graceful, read-only, wrapped in
|
|
22
|
+
// `traceCommand` by the bin.
|
|
23
|
+
import process from 'node:process';
|
|
24
|
+
import { resolveProjectRoot } from './config.mjs';
|
|
25
|
+
import { deriveScope } from './scope.mjs';
|
|
26
|
+
import { resolveDenies } from './control.mjs';
|
|
27
|
+
import { resolveStores, remoteUnavailableReason } from './stores.mjs';
|
|
28
|
+
import { resolvePrecedence } from './lessons-pure.mjs';
|
|
29
|
+
import { gather, preview, shortDate } from './lessons-view.mjs';
|
|
30
|
+
import { log, heading, status, c } from './util.mjs';
|
|
31
|
+
|
|
32
|
+
export async function tree(args) {
|
|
33
|
+
const root = resolveProjectRoot(args.dir);
|
|
34
|
+
const env = { ...process.env };
|
|
35
|
+
if (args.store) env.LOREKIT_STORE = args.store;
|
|
36
|
+
|
|
37
|
+
const scopeInfo = deriveScope(root);
|
|
38
|
+
// Default to the injected resolution set (`readOrder`, most-specific first);
|
|
39
|
+
// `--scope <s>` narrows to one (honored even outside the set — the user asked).
|
|
40
|
+
const scopes = args.scope && typeof args.scope === 'string' ? [args.scope] : scopeInfo.readOrder;
|
|
41
|
+
|
|
42
|
+
const { local, remote, connection } = resolveStores(root, {
|
|
43
|
+
env,
|
|
44
|
+
endpoint: args.endpoint,
|
|
45
|
+
token: args.token,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Deny-wins section suppression, identical to the other read commands.
|
|
49
|
+
const { localDenied, remoteDenied } = resolveDenies(root, { env });
|
|
50
|
+
|
|
51
|
+
const offlineResolved = localDenied ? null : resolvePrecedence(await gather(local, scopes));
|
|
52
|
+
const remoteAvailable = !remoteDenied && remote.usable();
|
|
53
|
+
const remoteResolved = remoteAvailable ? resolvePrecedence(await gather(remote, scopes)) : null;
|
|
54
|
+
|
|
55
|
+
const offlineSection = localDenied
|
|
56
|
+
? { available: false, reason: `disabled by deny constraint (${localDenied.source})` }
|
|
57
|
+
: { available: true, ...offlineResolved };
|
|
58
|
+
const remoteSection = remoteAvailable
|
|
59
|
+
? { available: true, ...remoteResolved }
|
|
60
|
+
: {
|
|
61
|
+
available: false,
|
|
62
|
+
reason: remoteDenied
|
|
63
|
+
? `disabled by deny constraint (${remoteDenied.source})`
|
|
64
|
+
: remoteUnavailableReason(connection),
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
if (args.json) {
|
|
68
|
+
log(JSON.stringify(buildJson({ root, scopes, offlineSection, remoteSection }), null, 2));
|
|
69
|
+
} else {
|
|
70
|
+
heading('LoreKit resolution tree');
|
|
71
|
+
log(` project: ${c.dim(root)}`);
|
|
72
|
+
log(` scopes: ${scopes.join(' → ')}`);
|
|
73
|
+
log(` ${c.dim('precedence order (most-specific first); a more-specific scope wins a duplicate key')}`);
|
|
74
|
+
|
|
75
|
+
renderTreeSection({ title: 'Offline' }, offlineSection);
|
|
76
|
+
renderTreeSection(
|
|
77
|
+
{ title: 'Remote', subtitle: remoteAvailable ? connection.endpoint : undefined },
|
|
78
|
+
remoteSection,
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
log('');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Bounded, non-PII telemetry extras — counts + a boolean, never a scope
|
|
85
|
+
// string, key, path, or token.
|
|
86
|
+
return {
|
|
87
|
+
exitCode: 0,
|
|
88
|
+
'lorekit.cli.tree.scope_count': scopes.length,
|
|
89
|
+
'lorekit.cli.tree.offline_winning': offlineSection.available ? offlineSection.winningTotal : 0,
|
|
90
|
+
'lorekit.cli.tree.offline_shadowed': offlineSection.available ? offlineSection.shadowedTotal : 0,
|
|
91
|
+
'lorekit.cli.tree.remote_shadowed': remoteSection.available ? remoteSection.shadowedTotal : 0,
|
|
92
|
+
'lorekit.cli.tree.remote_available': remoteAvailable,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Render one store's resolution: each scope in precedence order with its entries
|
|
97
|
+
// tagged winning (✓) or shadowed (↳ shadowed by <scope>). A scope with no
|
|
98
|
+
// entries and no error is omitted; a read error is surfaced in place.
|
|
99
|
+
function renderTreeSection(header, section) {
|
|
100
|
+
heading(header.title);
|
|
101
|
+
if (header.subtitle) log(` ${c.dim(header.subtitle)}`);
|
|
102
|
+
|
|
103
|
+
if (!section.available) {
|
|
104
|
+
status('warn', 'unavailable', section.reason);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const printable = (section.groups || []).filter((g) => g.entries.length || g.error);
|
|
109
|
+
if (!printable.length) {
|
|
110
|
+
log(` ${c.dim('no lessons found in the applicable scopes')}`);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
for (const g of printable) {
|
|
115
|
+
log(` ${c.bold(g.scope)}`);
|
|
116
|
+
if (g.error) {
|
|
117
|
+
log(` ${c.yellow('!')} ${c.dim(g.error)}`);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
for (const e of g.entries) {
|
|
121
|
+
const when = e.updated ? ` ${c.dim(`(updated ${shortDate(e.updated)})`)}` : '';
|
|
122
|
+
const mark = e.winning ? c.green('✓') : c.yellow('↳');
|
|
123
|
+
const tag = e.winning ? '' : ` ${c.dim(`shadowed by ${e.shadowedBy}`)}`;
|
|
124
|
+
log(` ${mark} ${e.key}${tag}${when}`);
|
|
125
|
+
if (e.value) log(` ${c.dim(preview(e.value))}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
log(
|
|
130
|
+
` ${c.dim(`${section.winningTotal} winning, ${section.shadowedTotal} shadowed`)}`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// The `--json` payload: per-section resolved groups (each entry carrying its
|
|
135
|
+
// `winning` / `shadowedBy` tags), the flat `winners` list, and the counts — so a
|
|
136
|
+
// script gets the resolution verdict directly, in the same shape per store.
|
|
137
|
+
function buildJson({ root, scopes, offlineSection, remoteSection }) {
|
|
138
|
+
return {
|
|
139
|
+
root,
|
|
140
|
+
scopes,
|
|
141
|
+
offline: sectionJson(offlineSection),
|
|
142
|
+
remote: sectionJson(remoteSection),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function sectionJson(section) {
|
|
147
|
+
if (!section.available) {
|
|
148
|
+
return { available: false, reason: section.reason, winners: [], groups: [] };
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
available: true,
|
|
152
|
+
winningTotal: section.winningTotal,
|
|
153
|
+
shadowedTotal: section.shadowedTotal,
|
|
154
|
+
winners: section.winners,
|
|
155
|
+
groups: section.groups,
|
|
156
|
+
};
|
|
157
|
+
}
|