@lorekit/cli 1.9.0 → 1.11.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
@@ -173,6 +173,101 @@ missing. It exits **non-zero** when the key is found in no readable store, so it
173
173
  fits scripts. `--json` emits the full normalized record(s) and which store each
174
174
  came from. Both a scope and a key are required (else a usage error).
175
175
 
176
+ ### `lorekit stats`
177
+
178
+ An at-a-glance overview of how many lessons apply to the current directory —
179
+ counted **per scope** and **per store** (Offline vs Remote), with per-store and
180
+ grand totals, in the same Offline / Remote split as `list`:
181
+
182
+ ```bash
183
+ lorekit stats # per-scope counts + totals for both stores
184
+ lorekit stats --scope global # narrow to a single scope
185
+ lorekit stats --json # { offline, remote } with per-scope { count } rows
186
+ ```
187
+
188
+ Every applicable scope prints a row (a scope with zero lessons still shows `0`,
189
+ which is the point of an overview). An unconfigured remote degrades to a short
190
+ note — never an error, always exit 0. `--endpoint` / `--token` / `--store`
191
+ behave as in `list`. Remote counts reflect what the hosted `memory.list` returns
192
+ per scope (the server's default page size); there is no cap-usage `N / limit`
193
+ figure because the MCP surface exposes no total-count or cap tool.
194
+
195
+ ### `lorekit diff`
196
+
197
+ Compare the **offline** and **remote** stores for the applicable scopes and
198
+ report where they diverge, grouped by scope:
199
+
200
+ ```bash
201
+ lorekit diff # local-only / remote-only / conflicting, per scope
202
+ lorekit diff --scope global # narrow to a single scope
203
+ lorekit diff --json # { comparable, totals, groups[] } for scripts
204
+ ```
205
+
206
+ Three groups: **local-only** (key present offline, absent remote), **remote-only**
207
+ (absent offline, present remote), and **conflicting** (same `scope::key` in both,
208
+ but the value or tags differ). A diff needs **both** stores readable — if the
209
+ remote is unconfigured (or a store is denied), a meaningful diff is impossible,
210
+ so `diff` prints a clear note (`comparable: false` in `--json`) and exits 0
211
+ rather than crashing. `--endpoint` / `--token` / `--store` behave as in `list`.
212
+
213
+ ### `lorekit tree` (alias `resolve`)
214
+
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**:
219
+
220
+ ```bash
221
+ lorekit tree # the injected hierarchy with ✓ winning / ↳ shadowed marks
222
+ lorekit tree --scope global # narrow to a single scope
223
+ lorekit tree --json # per-entry { winning, shadowedBy } + a winners[] list
224
+ ```
225
+
226
+ 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.
233
+
234
+ ### `lorekit lint`
235
+
236
+ Flag low-quality lessons across the applicable scopes and both stores. Each
237
+ finding names the rule it violated:
238
+
239
+ ```bash
240
+ lorekit lint # findings grouped by scope; exits non-zero if any
241
+ lorekit lint --scope global # narrow to a single scope
242
+ lorekit lint --json # { total, offline, remote } structured findings
243
+ ```
244
+
245
+ Rules: **empty-value** (blank/whitespace-only body), **short-value** (a non-empty
246
+ body below a small length threshold), **untrimmed-value** (real content with
247
+ surrounding whitespace), **empty-key** (blank key), and **malformed-scope** (e.g.
248
+ a single `:` where `::` is expected). `lint` **exits non-zero (1) when any issue
249
+ is found**, so it is usable as a CI gate (`lorekit lint || exit 1`); a clean run —
250
+ or one where only a store is unavailable — exits 0. The pure rule predicates live
251
+ in `lessons-view.mjs` and are unit-tested one rule at a time.
252
+
253
+ ### `lorekit dedupe`
254
+
255
+ Find likely-duplicate lessons and group them into clusters — per store, across
256
+ the applicable scopes:
257
+
258
+ ```bash
259
+ lorekit dedupe # clusters of near-duplicate lessons per store
260
+ lorekit dedupe --threshold 0.6 # loosen the similarity cutoff (default 0.8)
261
+ lorekit dedupe --json # { threshold, offline, remote } clusters + signal
262
+ ```
263
+
264
+ The similarity signal is a zero-dependency **heuristic** — Jaccard overlap of
265
+ lowercased word tokens, **not** a semantic/embedding measure — so it surfaces
266
+ candidates for a human to review and can both miss paraphrases and group
267
+ coincidental overlaps. Any pair scoring at or above `--threshold` links (transitively)
268
+ into one cluster; only clusters of 2+ members are reported, each with a similarity
269
+ range. Cross-**store** divergence is `diff`'s job; `dedupe` looks within a store.
270
+
176
271
  ### `lorekit hook`
177
272
 
178
273
  The **shared hook engine** behind the Claude Code / Cursor / Codex plugins.
@@ -355,8 +450,9 @@ active deny constraints.
355
450
  | `--no-hooks` | Skip wiring the lifecycle hooks; skill + MCP only (`install`) |
356
451
  | `--force` | Overwrite existing skill files (`install`) |
357
452
  | `--deep` | Write/read/delete round-trip (`doctor`) |
358
- | `--json` | Machine-readable output (`list` / `search` / `show`) |
359
- | `--scope <scope>` | Restrict to a single scope (`list` / `search`; default: all applicable) |
453
+ | `--json` | Machine-readable output (`list` / `search` / `show` / `stats` / `diff` / `tree` / `lint` / `dedupe`) |
454
+ | `--scope <scope>` | Restrict to a single scope (`list` / `search` / `stats` / `diff` / `tree` / `lint` / `dedupe`; default: all applicable) |
455
+ | `--threshold <0..1>` | Duplicate-similarity cutoff (`dedupe`; default `0.8`) |
360
456
  | `--adapter <name>` | Host framework for `hook`: `claude` / `cursor` / `codex` |
361
457
  | `--event <name>` | Host hook event for `hook` (else read from the stdin payload) |
362
458
  | `-h, --help` | Help |
@@ -442,7 +538,7 @@ forever. This reduces manual validation to a single capture pass per tool.
442
538
  ## Usage telemetry
443
539
 
444
540
  The human-facing commands (`install`, `uninstall`, `doctor`, `list`, `search`,
445
- `show`, `migrate`)
541
+ `show`, `stats`, `diff`, `migrate`)
446
542
  emit one OpenTelemetry span + one counter point per run so the maintainers can see which
447
543
  commands people use. It is zero-dependency (OTLP/JSON over `fetch`, no SDK) and
448
544
  deliberately narrow — it carries only the command name, a bounded set of boolean
package/bin/lorekit.mjs CHANGED
@@ -9,6 +9,11 @@ import { doctor } from '../src/doctor.mjs';
9
9
  import { list } from '../src/list.mjs';
10
10
  import { search } from '../src/search.mjs';
11
11
  import { show } from '../src/show.mjs';
12
+ import { stats } from '../src/stats.mjs';
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';
12
17
  import { hook } from '../src/hook.mjs';
13
18
  import { migrate } from '../src/migrate.mjs';
14
19
  import { mcpServer } from '../src/mcp-server.mjs';
@@ -48,6 +53,21 @@ ${c.bold('Commands')}
48
53
  show Inspect one lesson in full: its complete value, scope, key, updated
49
54
  date, tags, and which store(s) it lives in (noting any divergence
50
55
  when it is in both). --json. Usage: show <scope> <key>.
56
+ stats Count the applicable lessons per scope and per store (offline vs
57
+ remote), with per-store and grand totals, in the same Offline/
58
+ Remote split. --json, --scope <s>.
59
+ diff Compare the offline and remote stores for the applicable scopes and
60
+ report divergence: local-only, remote-only, and conflicting keys
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>.
51
71
  migrate Relocate a LoreKit-format local store into the current layout.
52
72
  Dry-run by default; pass --yes to apply. Idempotent.
53
73
  hook Hook engine for Claude Code / Cursor / Codex. Reads the host's
@@ -66,8 +86,9 @@ ${c.bold('Options')}
66
86
  -t, --token <token> LoreKit token (lk_rw_* to allow writes, lk_ro_* read-only)
67
87
  --mode <mode> Memory mode: off | local | remote (doctor override)
68
88
  --store <path> Local project-tier store directory (default: .lorekit)
69
- --json Machine-readable output (list / search / show)
70
- --scope <scope> Restrict to a single scope (list / search)
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)
71
92
  --from <path> Source store to migrate from (migrate)
72
93
  --to <tier> Migration destination tier: home | project (migrate;
73
94
  default routes each entry by scope)
@@ -240,6 +261,127 @@ ${c.bold('Options')}
240
261
  ${c.bold('Examples')}
241
262
  npx @lorekit/cli show global prefer-guard-clauses
242
263
  npx @lorekit/cli show project::widget build-flags --json
264
+ `,
265
+ stats: `${c.bold('lorekit stats')} — count the applicable lessons per scope and per store
266
+
267
+ ${c.bold('Usage')}
268
+ npx @lorekit/cli stats [options]
269
+
270
+ Shows how many lessons apply to the current directory's scopes (project/branch/
271
+ repo/global), broken down per scope and per store (Offline = the local .lorekit/
272
+ + ~/.lorekit/ two-tier store; Remote = the hosted MCP server), with per-store and
273
+ grand totals. An unconfigured remote degrades to a short note, never an error.
274
+
275
+ ${c.bold('Options')}
276
+ -d, --dir <path> Target project root (default: current directory)
277
+ --scope <scope> Restrict to a single scope (default: all applicable)
278
+ --json Machine-readable output
279
+ -e, --endpoint <url> Remote endpoint override (else .mcp.json / LOREKIT_MCP_URL)
280
+ -t, --token <token> Remote token override (else .mcp.json / LOREKIT_TOKEN)
281
+ --store <path> Local project-tier store directory (default: .lorekit)
282
+
283
+ ${c.bold('Examples')}
284
+ npx @lorekit/cli stats
285
+ npx @lorekit/cli stats --json
286
+ npx @lorekit/cli stats --scope global
287
+ `,
288
+ diff: `${c.bold('lorekit diff')} — compare the offline and remote stores
289
+
290
+ ${c.bold('Usage')}
291
+ npx @lorekit/cli diff [options]
292
+
293
+ Compares the local (offline) store against the hosted (remote) store for the
294
+ current directory's scopes and reports where they diverge, grouped by scope:
295
+ local-only keys, remote-only keys, and conflicting keys (same key, different
296
+ value or tags). A diff needs BOTH stores readable — if the remote is
297
+ unconfigured or a store is denied, \`diff\` prints a clear note and exits 0.
298
+
299
+ ${c.bold('Options')}
300
+ -d, --dir <path> Target project root (default: current directory)
301
+ --scope <scope> Restrict to a single scope (default: all applicable)
302
+ --json Machine-readable output
303
+ -e, --endpoint <url> Remote endpoint override (else .mcp.json / LOREKIT_MCP_URL)
304
+ -t, --token <token> Remote token override (else .mcp.json / LOREKIT_TOKEN)
305
+ --store <path> Local project-tier store directory (default: .lorekit)
306
+
307
+ ${c.bold('Examples')}
308
+ npx @lorekit/cli diff
309
+ npx @lorekit/cli diff --json
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
243
385
  `,
244
386
  migrate: `${c.bold('lorekit migrate')} — relocate a LoreKit-format local store into the current layout
245
387
 
@@ -293,19 +435,20 @@ ${c.bold('Options')}
293
435
  const KNOWN_FLAGS = [
294
436
  'dir', 'project', 'global', 'endpoint', 'token', 'mode', 'store',
295
437
  'from', 'to', 'apply', 'yes', 'no-hooks', 'force', 'deep', 'adapter',
296
- 'event', 'json', 'scope', 'help', 'version',
438
+ 'event', 'json', 'scope', 'threshold', 'help', 'version',
297
439
  ];
298
440
 
299
441
  // Commands that write to disk / talk to the network on a human's behalf. These
300
442
  // reject unknown flags; the machine-facing `hook` / `mcp` do not (they must
301
443
  // never fail on a stray flag, and only ever receive flags we control).
302
444
  const HUMAN_COMMANDS = new Set([
303
- 'install', 'uninstall', 'doctor', 'list', 'search', 'show', 'migrate',
445
+ 'install', 'uninstall', 'doctor', 'list', 'search', 'show', 'stats', 'diff',
446
+ 'tree', 'lint', 'dedupe', 'migrate',
304
447
  ]);
305
448
 
306
449
  // Command aliases — canonicalized before help / dispatch so `lorekit ls --help`
307
450
  // and telemetry both resolve to the real command name.
308
- const COMMAND_ALIASES = { ls: 'list', grep: 'search' };
451
+ const COMMAND_ALIASES = { ls: 'list', grep: 'search', resolve: 'tree' };
309
452
 
310
453
  async function main() {
311
454
  // Load a `.env` from the current directory (if any) before anything reads the
@@ -380,6 +523,16 @@ async function main() {
380
523
  return traceCommand('search', args, VERSION, () => search(args));
381
524
  case 'show':
382
525
  return traceCommand('show', args, VERSION, () => show(args));
526
+ case 'stats':
527
+ return traceCommand('stats', args, VERSION, () => stats(args));
528
+ case 'diff':
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));
383
536
  case 'migrate':
384
537
  return traceCommand('migrate', args, VERSION, () => migrate(args));
385
538
  default:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lorekit/cli",
3
- "version": "1.9.0",
3
+ "version": "1.11.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": {
package/src/control.mjs CHANGED
@@ -124,6 +124,19 @@ export function localStoreDirs(root = process.cwd(), env = process.env) {
124
124
  return { home, project: projectDirFrom({ env, userConfig, repoConfig, root }) };
125
125
  }
126
126
 
127
+ // Resolve the deny-wins ceiling for the read commands: which section (offline /
128
+ // remote) is forbidden outright by an active `deny` constraint. A deny is never
129
+ // overridable (see the module header), so this is the single seam `list`,
130
+ // `search`, `show`, `stats`, and `diff` share instead of each re-deriving the
131
+ // same `control.denies.find(...)` block. Returns the matched deny object
132
+ // ({ mode, source }) or null per side — the `source` explains the "why" in each
133
+ // command's graceful note. Thin wrapper over `loadControl`, co-located with it.
134
+ export function resolveDenies(root, { env = process.env } = {}) {
135
+ const control = loadControl(root, { env });
136
+ const find = (mode) => control.denies.find((d) => d.mode === mode) || null;
137
+ return { localDenied: find('local'), remoteDenied: find('remote') };
138
+ }
139
+
127
140
  // IO wrapper — load env + config files, derive the connection, then resolve.
128
141
  export function loadControl(root, { env = process.env } = {}) {
129
142
  const home = userConfigDir(env);
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/diff.mjs ADDED
@@ -0,0 +1,180 @@
1
+ // `lorekit diff` — compare the offline (local two-tier) store against the remote
2
+ // (hosted MCP) store for the current directory's scopes, and report where they
3
+ // diverge in three groups, grouped by scope:
4
+ // • local-only — key present offline, absent remote;
5
+ // • remote-only — absent offline, present remote;
6
+ // • conflicting — same scope::key in both, but the value/tags differ.
7
+ //
8
+ // A diff is only meaningful when BOTH stores are readable. If either is
9
+ // unavailable — the remote unconfigured, a `LOREKIT_DENY` ceiling, etc. — a diff
10
+ // is impossible, so `diff` prints a clear note explaining which store is missing
11
+ // and exits 0 (never a crash). Read-only. Human-facing, so the bin wraps it in
12
+ // `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, diffGroups, preview, shortDate } from './lessons-view.mjs';
19
+ import { log, heading, status, c } from './util.mjs';
20
+
21
+ export async function diff(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 localAvailable = !localDenied;
40
+ const remoteAvailable = !remoteDenied && remote.usable();
41
+
42
+ // Explain, per store, why it is not comparable (used for the not-comparable
43
+ // note below). A store that IS available reports `null`.
44
+ const offlineReason = localDenied
45
+ ? `disabled by deny constraint (${localDenied.source})`
46
+ : null;
47
+ const remoteReason = remoteDenied
48
+ ? `disabled by deny constraint (${remoteDenied.source})`
49
+ : remoteAvailable
50
+ ? null
51
+ : remoteUnavailableReason(connection);
52
+
53
+ const comparable = localAvailable && remoteAvailable;
54
+
55
+ // Only gather (and diff) when both stores are readable — a one-sided read
56
+ // cannot distinguish "only in the other store" from "the other store is
57
+ // simply unreachable".
58
+ const result = comparable ? diffGroups(await gather(local, scopes), await gather(remote, scopes)) : null;
59
+
60
+ if (args.json) {
61
+ log(JSON.stringify(buildJson({ root, scopes, comparable, offlineReason, remoteReason, result }), null, 2));
62
+ } else if (!comparable) {
63
+ renderNotComparable({ root, scopes, offlineReason, remoteReason });
64
+ } else {
65
+ renderDiff({ root, scopes, connection, remoteAvailable, result });
66
+ }
67
+
68
+ // Bounded, non-PII telemetry extras — counts + booleans, never a scope
69
+ // string, key, path, or token.
70
+ const totals = result ? result.totals : { localOnly: 0, remoteOnly: 0, conflicting: 0 };
71
+ return {
72
+ exitCode: 0,
73
+ 'lorekit.cli.diff.scope_count': scopes.length,
74
+ 'lorekit.cli.diff.comparable': comparable,
75
+ 'lorekit.cli.diff.local_only': totals.localOnly,
76
+ 'lorekit.cli.diff.remote_only': totals.remoteOnly,
77
+ 'lorekit.cli.diff.conflicting': totals.conflicting,
78
+ };
79
+ }
80
+
81
+ // The not-comparable note: which store(s) are missing, and why a diff needs both.
82
+ function renderNotComparable({ root, scopes, offlineReason, remoteReason }) {
83
+ heading('LoreKit diff');
84
+ log(` project: ${c.dim(root)}`);
85
+ log(` scopes: ${scopes.join(' → ')}`);
86
+ log('');
87
+ if (offlineReason) status('warn', 'offline unavailable', offlineReason);
88
+ if (remoteReason) status('warn', 'remote unavailable', remoteReason);
89
+ log(
90
+ ` ${c.dim('a diff compares the offline and remote stores side by side — both must be readable.')}`,
91
+ );
92
+ log('');
93
+ }
94
+
95
+ // The real diff: three grouped sections. Each section groups the divergent keys
96
+ // by scope; a scope with nothing in a section is omitted from it.
97
+ function renderDiff({ root, scopes, connection, remoteAvailable, result }) {
98
+ heading('LoreKit diff');
99
+ log(` project: ${c.dim(root)}`);
100
+ log(` scopes: ${scopes.join(' → ')}`);
101
+ if (remoteAvailable) log(` remote: ${c.dim(connection.endpoint)}`);
102
+
103
+ const { groups, totals } = result;
104
+
105
+ // Surface any per-scope read errors up front — a scope that failed to read on
106
+ // one side can't be diffed, and its keys are neither "only" nor "conflicting".
107
+ const errored = groups.filter((g) => g.error);
108
+ if (errored.length) {
109
+ heading('Unreadable scopes');
110
+ for (const g of errored) log(` ${c.bold(g.scope)} ${c.yellow('!')} ${c.dim(g.error)}`);
111
+ }
112
+
113
+ renderSet('Local-only', 'present offline, absent remote', groups, (g) => g.localOnly);
114
+ renderSet('Remote-only', 'absent offline, present remote', groups, (g) => g.remoteOnly);
115
+ renderConflicts(groups);
116
+
117
+ if (totals.localOnly + totals.remoteOnly + totals.conflicting === 0 && !errored.length) {
118
+ log('');
119
+ log(` ${c.dim('the offline and remote stores are in sync for the applicable scopes')}`);
120
+ }
121
+ log('');
122
+ }
123
+
124
+ // One "only in store X" section: group its entries by scope, key + preview per
125
+ // entry. Skipped entirely when no scope has any entry in it.
126
+ function renderSet(title, subtitle, groups, pick) {
127
+ const present = groups.filter((g) => pick(g).length);
128
+ if (!present.length) return;
129
+ heading(title);
130
+ log(` ${c.dim(subtitle)}`);
131
+ for (const g of present) {
132
+ log(` ${c.bold(g.scope)}`);
133
+ for (const e of pick(g)) {
134
+ const when = e.updated ? ` ${c.dim(`(updated ${shortDate(e.updated)})`)}` : '';
135
+ log(` ${c.cyan('•')} ${e.key}${when}`);
136
+ if (e.value) log(` ${c.dim(preview(e.value))}`);
137
+ }
138
+ }
139
+ }
140
+
141
+ // The conflicting section: same key in both stores, differing value/tags. Shows
142
+ // a short preview of each side so the divergence is visible at a glance.
143
+ function renderConflicts(groups) {
144
+ const present = groups.filter((g) => g.conflicting.length);
145
+ if (!present.length) return;
146
+ heading('Conflicting');
147
+ log(` ${c.dim('same key in both stores, but the value or tags differ')}`);
148
+ for (const g of present) {
149
+ log(` ${c.bold(g.scope)}`);
150
+ for (const conflict of g.conflicting) {
151
+ log(` ${c.yellow('•')} ${conflict.key}`);
152
+ log(` ${c.dim('offline')} ${preview(conflict.local.value)}`);
153
+ log(` ${c.dim('remote ')} ${preview(conflict.remote.value)}`);
154
+ }
155
+ }
156
+ }
157
+
158
+ // The `--json` payload. When not comparable:
159
+ // { root, scopes, comparable:false, offline:{available,reason}, remote:{...} }
160
+ // When comparable:
161
+ // { root, scopes, comparable:true, totals, groups:[{ scope, localOnly,
162
+ // remoteOnly, conflicting, error }] } — the exact shape `diffGroups` returns.
163
+ function buildJson({ root, scopes, comparable, offlineReason, remoteReason, result }) {
164
+ if (!comparable) {
165
+ return {
166
+ root,
167
+ scopes,
168
+ comparable: false,
169
+ offline: offlineReason ? { available: false, reason: offlineReason } : { available: true },
170
+ remote: remoteReason ? { available: false, reason: remoteReason } : { available: true },
171
+ };
172
+ }
173
+ return {
174
+ root,
175
+ scopes,
176
+ comparable: true,
177
+ totals: result.totals,
178
+ groups: result.groups,
179
+ };
180
+ }
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/list.mjs CHANGED
@@ -11,7 +11,7 @@ import path from 'node:path';
11
11
  import process from 'node:process';
12
12
  import { resolveProjectRoot } from './config.mjs';
13
13
  import { deriveScope } from './scope.mjs';
14
- import { loadControl } from './control.mjs';
14
+ import { resolveDenies } from './control.mjs';
15
15
  import { resolveStores, remoteUnavailableReason } from './stores.mjs';
16
16
  import { scopeList, gather, renderSection } from './lessons-view.mjs';
17
17
  import { log, heading, c } from './util.mjs';
@@ -49,10 +49,7 @@ export async function list(args) {
49
49
  // A `LOREKIT_DENY=remote|local` privacy/compliance ceiling (deny-wins, never
50
50
  // overridable) suppresses that section entirely — the same invariant the
51
51
  // agent-facing control model enforces, honored here in the human read view.
52
- const control = loadControl(root, { env });
53
- const deny = (mode) => control.denies.find((d) => d.mode === mode) || null;
54
- const localDenied = deny('local');
55
- const remoteDenied = deny('remote');
52
+ const { localDenied, remoteDenied } = resolveDenies(root, { env });
56
53
 
57
54
  const offline = localDenied ? { groups: [], total: 0 } : await gather(local, scopes);
58
55
  const remoteAvailable = !remoteDenied && remote.usable();
package/src/search.mjs CHANGED
@@ -16,7 +16,7 @@
16
16
  import process from 'node:process';
17
17
  import { resolveProjectRoot } from './config.mjs';
18
18
  import { deriveScope } from './scope.mjs';
19
- import { loadControl } from './control.mjs';
19
+ import { resolveDenies } from './control.mjs';
20
20
  import { resolveStores, remoteUnavailableReason } from './stores.mjs';
21
21
  import { scopeList, gather, filterGroups, renderSection } from './lessons-view.mjs';
22
22
  import { log, err, heading, c } from './util.mjs';
@@ -48,10 +48,7 @@ export async function search(args) {
48
48
  });
49
49
 
50
50
  // Deny-wins section suppression, identical to `list`.
51
- const control = loadControl(root, { env });
52
- const deny = (mode) => control.denies.find((d) => d.mode === mode) || null;
53
- const localDenied = deny('local');
54
- const remoteDenied = deny('remote');
51
+ const { localDenied, remoteDenied } = resolveDenies(root, { env });
55
52
 
56
53
  const offline = localDenied
57
54
  ? { groups: [], total: 0 }
package/src/show.mjs CHANGED
@@ -11,7 +11,7 @@
11
11
  // so the bin wraps it in `traceCommand`.
12
12
  import process from 'node:process';
13
13
  import { resolveProjectRoot } from './config.mjs';
14
- import { loadControl } from './control.mjs';
14
+ import { resolveDenies } from './control.mjs';
15
15
  import { resolveStores, remoteUnavailableReason } from './stores.mjs';
16
16
  import { normalizeEntry, shortDate, describeError, recordsDiverge } from './lessons-view.mjs';
17
17
  import { log, err, heading, status, c } from './util.mjs';
@@ -56,10 +56,7 @@ export async function show(args) {
56
56
  });
57
57
 
58
58
  // Deny-wins section suppression, identical to `list`/`search`.
59
- const control = loadControl(root, { env });
60
- const deny = (mode) => control.denies.find((d) => d.mode === mode) || null;
61
- const localDenied = deny('local');
62
- const remoteDenied = deny('remote');
59
+ const { localDenied, remoteDenied } = resolveDenies(root, { env });
63
60
 
64
61
  const offline = localDenied
65
62
  ? { available: false, reason: `disabled by deny constraint (${localDenied.source})` }
package/src/stats.mjs ADDED
@@ -0,0 +1,142 @@
1
+ // `lorekit stats` — an at-a-glance overview of the lessons that apply to the
2
+ // current directory: how many live in each scope, split per store (offline vs
3
+ // remote), plus per-store and grand totals. Same Offline / Remote framing as
4
+ // `list`, but counts-only — no lesson bodies.
5
+ //
6
+ // Graceful by design (mirrors `list`/`search`/`show`): a `LOREKIT_DENY` ceiling
7
+ // suppresses a section, and an unconfigured remote degrades to a short note,
8
+ // never an error (exit 0). Read-only. Human-facing, so the bin wraps it in
9
+ // `traceCommand`.
10
+ //
11
+ // On cap usage: the hosted MCP surface exposes no tool that returns the user's
12
+ // total active-memory count or their configured cap (the cap is enforced by a
13
+ // DB trigger and read via `lorekit_get_limit`, neither reachable from a
14
+ // `memory.*` tool call), so `stats` deliberately reports only observed counts
15
+ // rather than guessing a `N / cap` ratio. Remote per-scope counts reflect what
16
+ // `memory.list` returns for each scope (server-side default page size), the
17
+ // same window `list` already shows.
18
+ import process from 'node:process';
19
+ import { resolveProjectRoot } from './config.mjs';
20
+ import { deriveScope } from './scope.mjs';
21
+ import { resolveDenies } from './control.mjs';
22
+ import { resolveStores, remoteUnavailableReason } from './stores.mjs';
23
+ import { scopeList, gather, tallyGroups } from './lessons-view.mjs';
24
+ import { log, heading, status, c } from './util.mjs';
25
+
26
+ export async function stats(args) {
27
+ const root = resolveProjectRoot(args.dir);
28
+ const env = { ...process.env };
29
+ if (args.store) env.LOREKIT_STORE = args.store;
30
+
31
+ const scopeInfo = deriveScope(root);
32
+ // Default to every applicable scope; `--scope <s>` narrows to one (an explicit
33
+ // scope outside the applicable set is honoured — the user asked for it).
34
+ const scopes = args.scope && typeof args.scope === 'string' ? [args.scope] : scopeList(scopeInfo);
35
+
36
+ const { local, remote, connection } = resolveStores(root, {
37
+ env,
38
+ endpoint: args.endpoint,
39
+ token: args.token,
40
+ });
41
+
42
+ // Deny-wins section suppression, identical to `list`/`search`/`show`.
43
+ const { localDenied, remoteDenied } = resolveDenies(root, { env });
44
+
45
+ const offlineTally = localDenied ? { perScope: [], total: 0 } : tallyGroups(await gather(local, scopes));
46
+ const remoteAvailable = !remoteDenied && remote.usable();
47
+ const remoteTally = remoteAvailable
48
+ ? tallyGroups(await gather(remote, scopes))
49
+ : { perScope: [], total: 0 };
50
+
51
+ const offlineSection = localDenied
52
+ ? { available: false, reason: `disabled by deny constraint (${localDenied.source})` }
53
+ : { available: true, ...offlineTally };
54
+ const remoteSection = remoteAvailable
55
+ ? { available: true, ...remoteTally }
56
+ : {
57
+ available: false,
58
+ reason: remoteDenied
59
+ ? `disabled by deny constraint (${remoteDenied.source})`
60
+ : remoteUnavailableReason(connection),
61
+ };
62
+
63
+ if (args.json) {
64
+ log(JSON.stringify(buildJson({ root, scopes, offlineSection, remoteSection }), null, 2));
65
+ } else {
66
+ heading('LoreKit stats');
67
+ log(` project: ${c.dim(root)}`);
68
+ log(` scopes: ${scopes.join(' → ')}`);
69
+
70
+ renderStatsSection({ title: 'Offline' }, offlineSection, scopes);
71
+ renderStatsSection(
72
+ { title: 'Remote', subtitle: remoteAvailable ? connection.endpoint : undefined },
73
+ remoteSection,
74
+ scopes,
75
+ );
76
+
77
+ log('');
78
+ }
79
+
80
+ // Bounded, non-PII telemetry extras (counts + a boolean) — never a scope
81
+ // string, path, key, or token.
82
+ return {
83
+ exitCode: 0,
84
+ 'lorekit.cli.stats.scope_count': scopes.length,
85
+ 'lorekit.cli.stats.offline_count': offlineSection.available ? offlineSection.total : 0,
86
+ 'lorekit.cli.stats.remote_count': remoteSection.available ? remoteSection.total : 0,
87
+ 'lorekit.cli.stats.remote_available': remoteAvailable,
88
+ };
89
+ }
90
+
91
+ // Render one store's count summary: an unavailable note, or a small right-
92
+ // aligned table of `scope count` rows plus a `total` line. Scopes with a read
93
+ // error show the error in place of a count so a partial read is never mistaken
94
+ // for an empty scope.
95
+ function renderStatsSection(header, section, scopes) {
96
+ heading(header.title);
97
+ if (header.subtitle) log(` ${c.dim(header.subtitle)}`);
98
+
99
+ if (!section.available) {
100
+ status('warn', 'unavailable', section.reason);
101
+ return;
102
+ }
103
+
104
+ // Index the tally by scope so every applicable scope prints a row (a scope
105
+ // with zero lessons still shows `0`, which is the point of an overview).
106
+ const byScope = new Map(section.perScope.map((s) => [s.scope, s]));
107
+ const width = Math.max(0, ...scopes.map((s) => s.length));
108
+ for (const scope of scopes) {
109
+ const row = byScope.get(scope) || { count: 0, error: null };
110
+ const label = scope.padEnd(width);
111
+ if (row.error) {
112
+ log(` ${c.bold(label)} ${c.yellow('!')} ${c.dim(row.error)}`);
113
+ } else {
114
+ log(` ${c.bold(label)} ${row.count}`);
115
+ }
116
+ }
117
+ log(` ${c.dim('total'.padEnd(width))} ${c.bold(String(section.total))}`);
118
+ }
119
+
120
+ // The `--json` payload: `{ root, scopes, offline, remote }` — each store a
121
+ // `{ available, total, scopes: [{ scope, count, error }] }` record (or an
122
+ // unavailable note), so a script gets the same shape regardless of which store
123
+ // the counts came from.
124
+ function buildJson({ root, scopes, offlineSection, remoteSection }) {
125
+ return {
126
+ root,
127
+ scopes,
128
+ offline: sectionJson(offlineSection),
129
+ remote: sectionJson(remoteSection),
130
+ };
131
+ }
132
+
133
+ function sectionJson(section) {
134
+ if (!section.available) {
135
+ return { available: false, reason: section.reason, total: 0, scopes: [] };
136
+ }
137
+ return {
138
+ available: true,
139
+ total: section.total,
140
+ scopes: section.perScope.map((s) => ({ scope: s.scope, count: s.count, error: s.error })),
141
+ };
142
+ }
package/src/telemetry.mjs CHANGED
@@ -4,8 +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 / migrate), fired to
8
- // Dash0 so the maintainers can see which commands people 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.
9
10
  //
10
11
  // Privacy — this runs on end-users' machines, so it is deliberately narrow:
11
12
  // • Opt-out honored: LOREKIT_TELEMETRY=0|off|false|no|disable, or the
@@ -274,7 +275,7 @@ function normalizeExitCode(result) {
274
275
  * counter point. Returns the command's exit code unchanged. Telemetry failures
275
276
  * are swallowed — the command result is never affected.
276
277
  *
277
- * @param {string} command bounded: install | uninstall | doctor | list | search | show | migrate
278
+ * @param {string} command bounded: install | uninstall | doctor | list | search | show | stats | diff | tree | lint | dedupe | migrate
278
279
  * @param {object} args parsed CLI args (read for allow-listed flags only)
279
280
  * @param {string} version CLI version (from package.json)
280
281
  * @param {() => Promise<number>} run the command handler
package/src/tree.mjs ADDED
@@ -0,0 +1,155 @@
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` (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).
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
19
+ // independently (precedence is per-store — the hook reads one resolved store),
20
+ // in the same Offline / Remote split as `list`. Graceful, read-only, wrapped in
21
+ // `traceCommand` by the bin.
22
+ import process from 'node:process';
23
+ import { resolveProjectRoot } from './config.mjs';
24
+ import { deriveScope } from './scope.mjs';
25
+ import { resolveDenies } from './control.mjs';
26
+ import { resolveStores, remoteUnavailableReason } from './stores.mjs';
27
+ import { gather, resolvePrecedence, preview, shortDate } from './lessons-view.mjs';
28
+ import { log, heading, status, c } from './util.mjs';
29
+
30
+ export async function tree(args) {
31
+ const root = resolveProjectRoot(args.dir);
32
+ const env = { ...process.env };
33
+ if (args.store) env.LOREKIT_STORE = args.store;
34
+
35
+ const scopeInfo = deriveScope(root);
36
+ // Default to the injected resolution set (`readOrder`, most-specific first);
37
+ // `--scope <s>` narrows to one (honored even outside the set — the user asked).
38
+ const scopes = args.scope && typeof args.scope === 'string' ? [args.scope] : scopeInfo.readOrder;
39
+
40
+ const { local, remote, connection } = resolveStores(root, {
41
+ env,
42
+ endpoint: args.endpoint,
43
+ token: args.token,
44
+ });
45
+
46
+ // Deny-wins section suppression, identical to the other read commands.
47
+ const { localDenied, remoteDenied } = resolveDenies(root, { env });
48
+
49
+ const offlineResolved = localDenied ? null : resolvePrecedence(await gather(local, scopes));
50
+ const remoteAvailable = !remoteDenied && remote.usable();
51
+ const remoteResolved = remoteAvailable ? resolvePrecedence(await gather(remote, scopes)) : null;
52
+
53
+ const offlineSection = localDenied
54
+ ? { available: false, reason: `disabled by deny constraint (${localDenied.source})` }
55
+ : { available: true, ...offlineResolved };
56
+ const remoteSection = remoteAvailable
57
+ ? { available: true, ...remoteResolved }
58
+ : {
59
+ available: false,
60
+ reason: remoteDenied
61
+ ? `disabled by deny constraint (${remoteDenied.source})`
62
+ : remoteUnavailableReason(connection),
63
+ };
64
+
65
+ if (args.json) {
66
+ log(JSON.stringify(buildJson({ root, scopes, offlineSection, remoteSection }), null, 2));
67
+ } else {
68
+ heading('LoreKit resolution tree');
69
+ log(` project: ${c.dim(root)}`);
70
+ log(` scopes: ${scopes.join(' → ')}`);
71
+ log(` ${c.dim('precedence order (most-specific first); a more-specific scope wins a duplicate key')}`);
72
+
73
+ renderTreeSection({ title: 'Offline' }, offlineSection);
74
+ renderTreeSection(
75
+ { title: 'Remote', subtitle: remoteAvailable ? connection.endpoint : undefined },
76
+ remoteSection,
77
+ );
78
+
79
+ log('');
80
+ }
81
+
82
+ // Bounded, non-PII telemetry extras — counts + a boolean, never a scope
83
+ // string, key, path, or token.
84
+ return {
85
+ exitCode: 0,
86
+ 'lorekit.cli.tree.scope_count': scopes.length,
87
+ 'lorekit.cli.tree.offline_winning': offlineSection.available ? offlineSection.winningTotal : 0,
88
+ 'lorekit.cli.tree.offline_shadowed': offlineSection.available ? offlineSection.shadowedTotal : 0,
89
+ 'lorekit.cli.tree.remote_shadowed': remoteSection.available ? remoteSection.shadowedTotal : 0,
90
+ 'lorekit.cli.tree.remote_available': remoteAvailable,
91
+ };
92
+ }
93
+
94
+ // Render one store's resolution: each scope in precedence order with its entries
95
+ // tagged winning (✓) or shadowed (↳ shadowed by <scope>). A scope with no
96
+ // entries and no error is omitted; a read error is surfaced in place.
97
+ function renderTreeSection(header, section) {
98
+ heading(header.title);
99
+ if (header.subtitle) log(` ${c.dim(header.subtitle)}`);
100
+
101
+ if (!section.available) {
102
+ status('warn', 'unavailable', section.reason);
103
+ return;
104
+ }
105
+
106
+ const printable = (section.groups || []).filter((g) => g.entries.length || g.error);
107
+ if (!printable.length) {
108
+ log(` ${c.dim('no lessons found in the applicable scopes')}`);
109
+ return;
110
+ }
111
+
112
+ for (const g of printable) {
113
+ log(` ${c.bold(g.scope)}`);
114
+ if (g.error) {
115
+ log(` ${c.yellow('!')} ${c.dim(g.error)}`);
116
+ continue;
117
+ }
118
+ for (const e of g.entries) {
119
+ const when = e.updated ? ` ${c.dim(`(updated ${shortDate(e.updated)})`)}` : '';
120
+ const mark = e.winning ? c.green('✓') : c.yellow('↳');
121
+ const tag = e.winning ? '' : ` ${c.dim(`shadowed by ${e.shadowedBy}`)}`;
122
+ log(` ${mark} ${e.key}${tag}${when}`);
123
+ if (e.value) log(` ${c.dim(preview(e.value))}`);
124
+ }
125
+ }
126
+
127
+ log(
128
+ ` ${c.dim(`${section.winningTotal} winning, ${section.shadowedTotal} shadowed`)}`,
129
+ );
130
+ }
131
+
132
+ // The `--json` payload: per-section resolved groups (each entry carrying its
133
+ // `winning` / `shadowedBy` tags), the flat `winners` list, and the counts — so a
134
+ // script gets the resolution verdict directly, in the same shape per store.
135
+ function buildJson({ root, scopes, offlineSection, remoteSection }) {
136
+ return {
137
+ root,
138
+ scopes,
139
+ offline: sectionJson(offlineSection),
140
+ remote: sectionJson(remoteSection),
141
+ };
142
+ }
143
+
144
+ function sectionJson(section) {
145
+ if (!section.available) {
146
+ return { available: false, reason: section.reason, winners: [], groups: [] };
147
+ }
148
+ return {
149
+ available: true,
150
+ winningTotal: section.winningTotal,
151
+ shadowedTotal: section.shadowedTotal,
152
+ winners: section.winners,
153
+ groups: section.groups,
154
+ };
155
+ }