@lorekit/cli 1.39.2 → 1.40.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
@@ -103,7 +103,8 @@ from `.gitignore`, negate it with `!.mcp.json`, or `git add -f .mcp.json` once);
103
103
  `install --mcp-json` **warns when the file it wrote is still git-ignored**, so a
104
104
  fresh web clone silently missing the config is not a mystery. Once `.mcp.json` is
105
105
  tracked, only run `install --mcp-json` in that repo — a plain `install --project`
106
- would embed a live token in the now-committed file. See the
106
+ would embed a live token in the now-committed file (and the command warns before
107
+ it does). See the
107
108
  [Claude Code on the web guide](https://lorekit.io/docs/claude-code-web).
108
109
 
109
110
  In a TTY it prompts for the scope (and for `--endpoint` / `--token` if missing).
package/bin/lorekit.mjs CHANGED
@@ -542,18 +542,27 @@ HEURISTIC — Jaccard overlap of lowercased word tokens, not a semantic/embeddin
542
542
  measure — so it surfaces candidates for a human to review, and can both miss
543
543
  paraphrases and group coincidental overlaps. Tune the cutoff with --threshold.
544
544
 
545
+ Pass --cluster-by-key <regex> to cluster by KEY shape instead of value overlap:
546
+ entries whose keys share the same first capture group (or full match) form one
547
+ family. This catches coordinate-key debt — e.g. many pr{N}-{commentId} rows for
548
+ one review comment — that the value heuristic misses when the values differ.
549
+ Key-shape mode has no similarity cutoff, so --threshold and --cluster-by-key are
550
+ mutually exclusive: passing both is a usage error rather than a silent ignore.
551
+
545
552
  ${c.bold('Options')}
546
- -d, --dir <path> Target project root (default: current directory)
547
- --scope <scope> Restrict to a single scope (default: all applicable)
548
- --threshold <0..1> Similarity cutoff to cluster a pair (default: 0.8)
549
- --json Machine-readable output (clusters + similarity signal)
550
- -e, --endpoint <url> Remote endpoint override (else .mcp.json / LOREKIT_MCP_URL)
551
- -t, --token <token> Remote token override (else .mcp.json / LOREKIT_TOKEN)
552
- --store <path> Local project-tier store directory (default: .lorekit)
553
+ -d, --dir <path> Target project root (default: current directory)
554
+ --scope <scope> Restrict to a single scope (default: all applicable)
555
+ --threshold <0..1> Similarity cutoff to cluster a pair (default: 0.8)
556
+ --cluster-by-key <re> Cluster by shared key capture instead of value overlap
557
+ --json Machine-readable output (clusters + signal)
558
+ -e, --endpoint <url> Remote endpoint override (else .mcp.json / LOREKIT_MCP_URL)
559
+ -t, --token <token> Remote token override (else .mcp.json / LOREKIT_TOKEN)
560
+ --store <path> Local project-tier store directory (default: .lorekit)
553
561
 
554
562
  ${c.bold('Examples')}
555
563
  npx @lorekit/cli dedupe
556
564
  npx @lorekit/cli dedupe --threshold 0.6 --json
565
+ npx @lorekit/cli dedupe --cluster-by-key "(pr\\d+-\\d+)" --json
557
566
  `,
558
567
  link: `${c.bold('lorekit link')} — print a shareable dashboard deep-link URL ${c.dim('(alias: url)')}
559
568
 
@@ -652,7 +661,7 @@ const KNOWN_FLAGS = [
652
661
  'link', 'base', 'q', 'owner', 'range', 'view', 'archived',
653
662
  'origin-repo', 'origin-branch', 'origin-commit', 'origin-pr', 'no-origin',
654
663
  // Scale-aware survey flags
655
- 'all', 'max', 'since', 'until', 'key-prefix',
664
+ 'all', 'max', 'since', 'until', 'key-prefix', 'cluster-by-key',
656
665
  ];
657
666
 
658
667
  // Commands that write to disk / talk to the network on a human's behalf. These
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lorekit/cli",
3
- "version": "1.39.2",
3
+ "version": "1.40.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/config.mjs CHANGED
@@ -591,7 +591,12 @@ export function tokenKind(token) {
591
591
  // avoid a circular import with mcp.mjs.
592
592
  //
593
593
  // A source that STORES a token wins outright (project beats global — closest
594
- // scope), but a TOKENLESS source must NOT shadow a later source that has one.
594
+ // scope), and it brings its OWN endpoint: a token authenticates one endpoint,
595
+ // so the two must travel together. A TOKENLESS source therefore never shadows a
596
+ // later source that has a token — not its token AND not its endpoint; its
597
+ // endpoint is only remembered as a fallback, used solely when NO source stores a
598
+ // token. (So "closest-scope-first" governs which token wins; the endpoint simply
599
+ // follows that token.)
595
600
  // That shadowing is exactly what `install --global --mcp-json` created: it
596
601
  // writes a committable, token-free project .mcp.json (auth via ${LOREKIT_TOKEN})
597
602
  // AND the real token into ~/.claude.json. Returning early on the project entry
package/src/dedupe.mjs CHANGED
@@ -15,13 +15,18 @@ import { resolveProjectRoot, readLorekitJson } from './config.mjs';
15
15
  import { deriveScope } from './scope.mjs';
16
16
  import { resolveDenies } from './control.mjs';
17
17
  import { resolveStores, remoteUnavailableReason } from './stores.mjs';
18
- import { scopeList, gather, gatherStream, clusterDuplicates, clusterDuplicatesBlocked, DEFAULT_MAX } from './lessons-view.mjs';
19
- import { log, heading, status, c } from './util.mjs';
18
+ import { scopeList, gather, gatherStream, clusterDuplicates, clusterDuplicatesBlocked, clusterByKeyPattern, compileKeyPattern, DEFAULT_MAX } from './lessons-view.mjs';
19
+ import { log, heading, status, err, c } from './util.mjs';
20
20
 
21
21
  const DEFAULT_THRESHOLD = 0.8;
22
- // Maximum entries to accumulate before the token-blocking index becomes
23
- // memory-prohibitive. Beyond this the user must narrow via --key-prefix /
24
- // --since / --max.
22
+ // Maximum entries to accumulate before the survey becomes memory-prohibitive.
23
+ // It bounds the accumulated entry list itself, so it applies in BOTH modes —
24
+ // key-shape clustering is O(n) and needs no blocking index, but it still holds
25
+ // the whole population in memory, and an unbounded remote drain is the risk the
26
+ // cap exists for. In value mode it additionally bounds the token-blocking index,
27
+ // which is the super-linear part. Beyond the cap the results are genuinely
28
+ // partial in either mode, and the user must narrow via --key-prefix / --since /
29
+ // --max.
25
30
  const DEDUPE_POP_CAP = 2000;
26
31
 
27
32
  // Smallest threshold the blocked clusterer accepts. `clusterDuplicatesBlocked`
@@ -77,6 +82,34 @@ export async function dedupe(args) {
77
82
  args.threshold !== undefined
78
83
  ? parseThreshold(args.threshold)
79
84
  : (repoThreshold(root) ?? DEFAULT_THRESHOLD);
85
+
86
+ // `--cluster-by-key <regex>` switches from value-overlap clustering to KEY-shape
87
+ // clustering: entries whose keys share the same first capture group (or full
88
+ // match) of the regex are grouped as a duplicate family — catches coordinate-key
89
+ // debt (e.g. `bucket::pr{N}-{commentId}::slug`) that the Jaccard heuristic misses
90
+ // when the values differ. A bare flag (no value) or an unparseable regex is a
91
+ // usage error: report it and exit non-zero rather than silently surveying by value.
92
+ const clusterByKeyRaw = args['cluster-by-key'];
93
+ const byKeyMode = clusterByKeyRaw !== undefined;
94
+ const keyPattern = byKeyMode ? compileKeyPattern(clusterByKeyRaw) : null;
95
+ if (byKeyMode && !keyPattern) {
96
+ err(
97
+ clusterByKeyRaw === true
98
+ ? '--cluster-by-key needs a regex value, e.g. --cluster-by-key "(pr\\d+-\\d+)"'
99
+ : `--cluster-by-key: invalid regex ${JSON.stringify(String(clusterByKeyRaw))}`,
100
+ );
101
+ return { exitCode: 1 };
102
+ }
103
+ // Key-shape mode has no similarity cutoff, so an explicit `--threshold` would
104
+ // be silently ignored — the same silent fallback the bad-regex branch above
105
+ // refuses. Refuse it too. Only the EXPLICIT flag errors: a repo-level
106
+ // `dedupe.threshold` in .lorekit.json is a value-mode default, not a request,
107
+ // so it must not break a key-mode run.
108
+ if (byKeyMode && args.threshold !== undefined) {
109
+ err('--threshold is not used with --cluster-by-key (key-shape clustering has no similarity cutoff); drop one of them');
110
+ return { exitCode: 1 };
111
+ }
112
+
80
113
  const scopeInfo = deriveScope(root);
81
114
  // Default to every applicable scope; `--scope <s>` narrows to one.
82
115
  const scopes = args.scope && typeof args.scope === 'string' ? [args.scope] : scopeList(scopeInfo);
@@ -156,7 +189,9 @@ export async function dedupe(args) {
156
189
  }
157
190
  return {
158
191
  available: true,
159
- clusters: clusterDuplicatesBlocked(entries, threshold),
192
+ clusters: byKeyMode
193
+ ? clusterByKeyPattern(entries, keyPattern)
194
+ : clusterDuplicatesBlocked(entries, threshold),
160
195
  errored: flat.errored,
161
196
  popCapped,
162
197
  };
@@ -164,7 +199,9 @@ export async function dedupe(args) {
164
199
  const { entries, errored, popCapped } = await streamAccumulate(store);
165
200
  return {
166
201
  available: true,
167
- clusters: clusterDuplicatesBlocked(entries, threshold),
202
+ clusters: byKeyMode
203
+ ? clusterByKeyPattern(entries, keyPattern)
204
+ : clusterDuplicatesBlocked(entries, threshold),
168
205
  errored,
169
206
  popCapped,
170
207
  };
@@ -188,12 +225,16 @@ export async function dedupe(args) {
188
225
  const remoteClusters = remoteSection.available ? remoteSection.clusters.length : 0;
189
226
 
190
227
  if (args.json) {
191
- log(JSON.stringify(buildJson({ root, scopes, threshold, offlineSection, remoteSection }), null, 2));
228
+ log(JSON.stringify(buildJson({ root, scopes, threshold, byKeyMode, keyPattern, offlineSection, remoteSection }), null, 2));
192
229
  } else {
193
230
  heading('LoreKit dedupe');
194
231
  log(` project: ${c.dim(root)}`);
195
232
  log(` scopes: ${scopes.join(' → ')}`);
196
- log(` ${c.dim(`heuristic: Jaccard word-token overlap >= ${threshold} (not semantic)`)}`);
233
+ log(
234
+ byKeyMode
235
+ ? ` ${c.dim(`key-shape: clustering by shared key capture of /${keyPattern.source}/`)}`
236
+ : ` ${c.dim(`heuristic: Jaccard word-token overlap >= ${threshold} (not semantic)`)}`,
237
+ );
197
238
 
198
239
  if (offlineSection.available && offlineSection.popCapped) {
199
240
  log(` ${c.yellow('!')} population cap (${DEDUPE_POP_CAP}) reached for Offline — results are partial. Narrow with --key-prefix, --since, or --max.`);
@@ -211,7 +252,13 @@ export async function dedupe(args) {
211
252
  log('');
212
253
  const total = offlineClusters + remoteClusters;
213
254
  if (total === 0) {
214
- log(` ${c.green('✓')} no likely-duplicate clusters at this threshold`);
255
+ // "at this threshold" is only true in value mode — key-shape mode has no
256
+ // cutoff, so name the pattern that found nothing instead.
257
+ log(
258
+ byKeyMode
259
+ ? ` ${c.green('✓')} no key-shape clusters for /${keyPattern.source}/`
260
+ : ` ${c.green('✓')} no likely-duplicate clusters at this threshold`,
261
+ );
215
262
  } else {
216
263
  const plural = total === 1 ? '' : 's';
217
264
  log(` ${c.yellow('!')} ${total} duplicate cluster${plural} found`);
@@ -220,11 +267,14 @@ export async function dedupe(args) {
220
267
  }
221
268
 
222
269
  // Bounded, non-PII telemetry extras — counts + a boolean, never a scope
223
- // string, key, path, or token.
270
+ // string, key, path, or token. `threshold` is emitted only in value mode,
271
+ // where it is the cutoff actually applied; the key-mode pattern is user text
272
+ // and is never emitted, only the bounded mode name.
224
273
  return {
225
274
  exitCode: 0,
226
275
  'lorekit.cli.dedupe.scope_count': scopes.length,
227
- 'lorekit.cli.dedupe.threshold': threshold,
276
+ 'lorekit.cli.dedupe.mode': byKeyMode ? 'key' : 'value',
277
+ ...(byKeyMode ? {} : { 'lorekit.cli.dedupe.threshold': threshold }),
228
278
  'lorekit.cli.dedupe.offline_clusters': offlineClusters,
229
279
  'lorekit.cli.dedupe.remote_clusters': remoteClusters,
230
280
  'lorekit.cli.dedupe.remote_available': remoteAvailable,
@@ -255,25 +305,35 @@ function renderDedupeSection(header, section) {
255
305
  let n = 0;
256
306
  for (const cluster of section.clusters) {
257
307
  n += 1;
258
- const range =
259
- cluster.minSimilarity === cluster.maxSimilarity
260
- ? cluster.minSimilarity.toFixed(2)
261
- : `${cluster.minSimilarity.toFixed(2)}–${cluster.maxSimilarity.toFixed(2)}`;
262
- log(` ${c.yellow('•')} cluster ${n} ${c.dim(`(${cluster.size} memories, similarity ${range})`)}`);
308
+ // Key-shape clusters carry a `keyGroup`; value-overlap clusters carry a
309
+ // similarity range. Render whichever signal the cluster has.
310
+ let signal;
311
+ if (cluster.keyGroup !== undefined) {
312
+ signal = `${cluster.size} memories, key-group "${cluster.keyGroup}"`;
313
+ } else {
314
+ const range =
315
+ cluster.minSimilarity === cluster.maxSimilarity
316
+ ? cluster.minSimilarity.toFixed(2)
317
+ : `${cluster.minSimilarity.toFixed(2)}–${cluster.maxSimilarity.toFixed(2)}`;
318
+ signal = `${cluster.size} memories, similarity ${range}`;
319
+ }
320
+ log(` ${c.yellow('•')} cluster ${n} ${c.dim(`(${signal})`)}`);
263
321
  for (const m of cluster.members) {
264
322
  log(` ${c.cyan('-')} ${m.scope}::${m.key}`);
265
323
  }
266
324
  }
267
325
  }
268
326
 
269
- // The `--json` payload: `{ root, scopes, threshold, offline, remote }` — each
270
- // store a `{ available, clusters: [{ members, size, minSimilarity,
271
- // maxSimilarity }], errored }` record (or an unavailable note).
272
- function buildJson({ root, scopes, threshold, offlineSection, remoteSection }) {
327
+ // The `--json` payload: `{ root, scopes, mode, threshold|keyPattern, offline,
328
+ // remote }`. In value mode (`mode: "value"`) each cluster carries `minSimilarity`
329
+ // / `maxSimilarity`; in key-shape mode (`mode: "key"`) each carries `keyGroup`.
330
+ // Each store is a `{ available, clusters, errored }` record (or an unavailable note).
331
+ function buildJson({ root, scopes, threshold, byKeyMode, keyPattern, offlineSection, remoteSection }) {
273
332
  return {
274
333
  root,
275
334
  scopes,
276
- threshold,
335
+ mode: byKeyMode ? 'key' : 'value',
336
+ ...(byKeyMode ? { keyPattern: keyPattern.source } : { threshold }),
277
337
  offline: sectionJson(offlineSection),
278
338
  remote: sectionJson(remoteSection),
279
339
  };
package/src/install.mjs CHANGED
@@ -23,6 +23,8 @@ import {
23
23
  homeDir,
24
24
  mcpConfigPath,
25
25
  readJsonIfExists,
26
+ readLorekitServer,
27
+ isWebMcpServerEntry,
26
28
  } from './config.mjs';
27
29
  import { buildRemoteUrl, splitEndpoint } from './mcp.mjs';
28
30
  import { deriveScope } from './scope.mjs';
@@ -364,6 +366,21 @@ export async function install(args) {
364
366
  let file = null;
365
367
  let existed = false;
366
368
  if (!scopeWriteOwnedByWeb) {
369
+ // A plain project install embeds the token in .mcp.json. If that file
370
+ // currently holds the committable web form (written by an earlier
371
+ // `--mcp-json`), this write replaces it with an embedded secret — in the
372
+ // very file the docs tell you to commit. Warn before clobbering it;
373
+ // `isWebMcpServerEntry` recognises the shape we are about to overwrite.
374
+ if (scope === 'project' && token) {
375
+ const prior = readLorekitServer(root);
376
+ if (prior && isWebMcpServerEntry(prior.server)) {
377
+ status(
378
+ 'warn',
379
+ '.mcp.json',
380
+ `replacing the committable web entry with an embedded token — do not commit this file, or re-run with --mcp-json to keep the \${${WEB_TOKEN_ENV_VAR}} form`,
381
+ );
382
+ }
383
+ }
367
384
  ({ file, existed } = upsertMcpServer(root, remoteUrl, scope));
368
385
  }
369
386
 
@@ -527,7 +544,7 @@ export async function install(args) {
527
544
  'warn',
528
545
  'token',
529
546
  `not stored — the committable .mcp.json resolves \${${WEB_TOKEN_ENV_VAR}} at runtime; set it as an environment secret${
530
- token ? ' (any --token you passed is not persisted)' : ''
547
+ plan.action === 'flag' ? ' (the token you supplied is not persisted)' : ''
531
548
  }`,
532
549
  );
533
550
  } else if (kind === 'none') {
@@ -430,6 +430,57 @@ export function clusterDuplicates(entries = [], threshold = 0.8) {
430
430
  return clusters;
431
431
  }
432
432
 
433
+ // Compile a `--cluster-by-key` pattern into a stateless RegExp, or null on bad
434
+ // input (empty / non-string / unparseable) — never throws, mirroring
435
+ // `parseThreshold`'s "never crash on bad input" contract. The source is always a
436
+ // STRING, so `new RegExp(raw)` carries no flags and is stateless by construction
437
+ // — flag stripping is only needed on the RegExp branch of `clusterByKeyPattern`,
438
+ // which can be handed a caller-built `/…/g`. Pure.
439
+ export function compileKeyPattern(raw) {
440
+ if (typeof raw !== 'string' || raw.length === 0) return null;
441
+ try {
442
+ return new RegExp(raw);
443
+ } catch {
444
+ return null;
445
+ }
446
+ }
447
+
448
+ // Cluster entries by a shared KEY capture rather than value overlap. Two entries
449
+ // cluster iff their keys yield the SAME first capture group of `pattern` (or the
450
+ // same full match when the pattern has no capture group). This catches
451
+ // coordinate-key duplicate FAMILIES — many `bucket::pr{N}-{commentId}::slug` rows
452
+ // recorded for one review comment, whose slugs (and thus values) differ enough
453
+ // that `clusterDuplicates`'s Jaccard sweep never links them. Returns only 2+
454
+ // clusters, each `{ members: [{ scope, key }], size, keyGroup }`, largest first
455
+ // (ties broken by keyGroup for stable output). A key that doesn't match the
456
+ // pattern is left unclustered. Pure — O(n), no threshold.
457
+ export function clusterByKeyPattern(entries = [], pattern) {
458
+ const re =
459
+ pattern instanceof RegExp
460
+ ? new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, ''))
461
+ : compileKeyPattern(pattern);
462
+ if (!re) return [];
463
+ const byGroup = new Map();
464
+ for (const e of entries) {
465
+ const key = e.key == null ? '' : String(e.key);
466
+ re.lastIndex = 0;
467
+ const m = re.exec(key);
468
+ if (!m) continue;
469
+ const group = m[1] ?? m[0];
470
+ if (!byGroup.has(group)) byGroup.set(group, []);
471
+ byGroup.get(group).push({ scope: e.scope ?? null, key });
472
+ }
473
+ const clusters = [];
474
+ for (const [keyGroup, members] of byGroup) {
475
+ if (members.length < 2) continue;
476
+ clusters.push({ members, size: members.length, keyGroup });
477
+ }
478
+ clusters.sort(
479
+ (x, y) => y.size - x.size || String(x.keyGroup).localeCompare(String(y.keyGroup)),
480
+ );
481
+ return clusters;
482
+ }
483
+
433
484
  // Collect a store's non-archived entries for each scope, via the common
434
485
  // `store.list({scope})` contract. Returns ordered per-scope groups plus a total
435
486
  // — a per-scope read failure is captured on the group, never thrown, so one bad