@lorekit/cli 1.13.1 → 1.15.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
@@ -17,7 +17,7 @@ markdown files if you never want to sign up for anything.
17
17
 
18
18
  ```bash
19
19
  npm install -g @lorekit/cli
20
- lorekit install # wires up the skill, MCP server, and hooks
20
+ lorekit install # wires up the skills, MCP server, and hooks
21
21
  lorekit doctor # verify it's all green
22
22
  ```
23
23
 
@@ -47,7 +47,7 @@ Linux, and Windows (npm creates the `lorekit` shim on every platform).
47
47
  Sets up the full memory loop — the same three parts as the Claude plugin,
48
48
  without needing a marketplace:
49
49
 
50
- 1. **Skill** (`lorekit-memory`) — the model-invoked authoring judgment.
50
+ 1. **Skills** (`lorekit-memory` + `lorekit-setup`) — the model-invoked authoring judgment and the self-improvement loop authoring counterpart.
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
@@ -75,7 +75,7 @@ lorekit install --global # set it up for every project
75
75
 
76
76
  In a TTY it prompts for the scope (and for `--endpoint` / `--token` if missing).
77
77
  Flags: `--project` / `--global` pick the scope non-interactively; `--no-hooks`
78
- installs the skill + MCP only (memory stays model-invoked); `--yes` runs
78
+ installs the skills + MCP only (memory stays model-invoked); `--yes` runs
79
79
  non-interactively (endpoint required via flag/env; scope defaults to project);
80
80
  `--force` overwrites an existing skill copy. Re-running is idempotent — the hook
81
81
  entries are updated in place, never duplicated.
@@ -194,6 +194,44 @@ behave as in `list`. Remote counts reflect what the hosted `memory.list` returns
194
194
  per scope (the server's default page size); there is no cap-usage `N / limit`
195
195
  figure because the MCP surface exposes no total-count or cap tool.
196
196
 
197
+ ### `lorekit scopes`
198
+
199
+ A **store-wide inventory** of every distinct scope that holds lessons, with a
200
+ lesson count per scope, in the same Offline / Remote split as the other read
201
+ commands:
202
+
203
+ ```bash
204
+ lorekit scopes # every scope in the store + a count each
205
+ lorekit scopes --scope repo:: # filter to scopes containing a substring
206
+ lorekit scopes --json # { offline, remote } with [{ scope, count }] rows
207
+ ```
208
+
209
+ Unlike `list` / `search` / `stats` / `diff` / `tree` — which are all **cwd-scoped**
210
+ (they only look at the scopes that resolve for the current directory:
211
+ project / branch / repo / global) — `scopes` enumerates **every** scope present in
212
+ the store, regardless of the current directory. That's the whole point: it lets
213
+ you see all the scopes you have lessons in, anywhere. Scopes are grouped by type
214
+ (global → project → repo → branch), then alphabetically; each store shows a
215
+ per-store total and scope count.
216
+
217
+ `--scope <s>` is a **substring filter** over the inventory (not a single-scope
218
+ selector — an inventory of one scope would be pointless).
219
+
220
+ **Offline enumeration is exact.** It walks the local two-tier store and reads
221
+ each lesson file's frontmatter `scope` string directly, rather than reverse-
222
+ mapping the on-disk directory layout (which is lossy for `project::{name}`,
223
+ stored by basename only) — so every scope is reconstructed verbatim. Lessons
224
+ present in both tiers are counted once (project shadows home, the same merge
225
+ `list` uses); archived lessons are excluded.
226
+
227
+ **Remote enumeration is not possible, and `scopes` says so honestly.** The
228
+ hosted MCP surface exposes no "list all scopes" tool — every read tool
229
+ (`memory.list` / `memory.search` / `memory.read`) *requires* a scope — so a
230
+ remote inventory can't be built. The Remote section is therefore always a short
231
+ note (never a faked listing), degrading gracefully at exit 0, the same way
232
+ `stats` omits a cap-usage figure. `--endpoint` / `--token` / `--store` behave as
233
+ in `list`.
234
+
197
235
  ### `lorekit diff`
198
236
 
199
237
  Compare the **offline** and **remote** stores for the applicable scopes and
@@ -406,13 +444,53 @@ Two config layers decide the mode:
406
444
  - **Repo / team** — a `.lorekit.json` at the repo root (and/or the existing
407
445
  `lorekit` block in `.mcp.json` for the connection).
408
446
 
409
- Both files share one schema:
447
+ Both files share this schema — all fields optional:
410
448
 
411
449
  ```jsonc
450
+ // .lorekit.json (repo root — safe to commit, no secrets)
451
+ // ~/.lorekit/config.json (user/machine — personal overrides, not committed)
412
452
  {
413
- "mode": "local", // select a mode (off | local | remote)
414
- "store": ".lorekit", // project-tier store dir (relative to repo root, or absolute)
415
- "deny": ["remote"] // forbid modes outright deny always wins
453
+ // ── Mode & store ───────────────────────────────────────────────────────────
454
+ "mode": "local", // off | local | remote
455
+ "store": ".lorekit", // project-tier store path (relative to root, or absolute)
456
+ "deny": ["remote"], // forbid modes outright — deny always wins, union across layers
457
+
458
+ // ── Connection (repo config only — no token, safe to commit) ───────────────
459
+ "mcp.endpoint": "https://<ref>.supabase.co/functions/v1/mcp",
460
+ // committable MCP URL without token; token still comes
461
+ // from .mcp.json or LOREKIT_TOKEN env var
462
+
463
+ // ── Write behaviour ────────────────────────────────────────────────────────
464
+ "tags.default": ["team", "project::my-project"],
465
+ // tags appended to every memory.write from this repo/user
466
+ // both layers merged: repo tags first, then user tags
467
+
468
+ "scope.defaults": {
469
+ "repo::owner/name": { "tags": ["team"] },
470
+ "branch::owner/name::": { "tags": ["ephemeral"] }
471
+ },
472
+ // per-scope tag defaults applied to writes whose scope
473
+ // starts with the key; matched by prefix (no wildcards needed)
474
+ // repo config only — this is a team-level write policy
475
+
476
+ // ── Hook behaviour ─────────────────────────────────────────────────────────
477
+ "hooks.disabled": ["Stop"],
478
+ // suppress specific hook events; union across layers
479
+ // values: "SessionStart" | "PostToolUseFailure" | "Stop"
480
+
481
+ "hooks.adapter": "claude",
482
+ // explicit adapter when auto-detection is ambiguous
483
+ // values: "claude" | "cursor" | "codex"
484
+ // repo wins over user
485
+
486
+ // ── Telemetry ──────────────────────────────────────────────────────────────
487
+ "telemetry.disabled": true,
488
+ // team-level opt-out for orgs with a no-telemetry policy
489
+ // env LOREKIT_TELEMETRY=0 always wins if set
490
+
491
+ // ── Dedupe threshold ───────────────────────────────────────────────────────
492
+ "dedupe.threshold": 0.8 // Jaccard similarity cutoff for `lorekit dedupe`
493
+ // --threshold flag wins when passed explicitly
416
494
  }
417
495
  ```
418
496
 
@@ -450,11 +528,11 @@ active deny constraints.
450
528
  | `--to <tier>` | Migration destination tier: `home` / `project` (`migrate`; default routes by scope) |
451
529
  | `--apply` | Apply the migration — alias of `--yes` (`migrate`) |
452
530
  | `-y, --yes` | Non-interactive / apply; never prompt |
453
- | `--no-hooks` | Skip wiring the lifecycle hooks; skill + MCP only (`install`) |
531
+ | `--no-hooks` | Skip wiring the lifecycle hooks; skills + MCP only (`install`) |
454
532
  | `--force` | Overwrite existing skill files (`install`) |
455
533
  | `--deep` | Write/read/delete round-trip (`doctor`) |
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) |
534
+ | `--json` | Machine-readable output (`list` / `search` / `show` / `stats` / `scopes` / `diff` / `tree` / `lint` / `dedupe`) |
535
+ | `--scope <scope>` | Restrict to a single scope (`list` / `search` / `stats` / `diff` / `tree` / `lint` / `dedupe`; default: all applicable). For `scopes` it is a **substring filter** over the inventory |
458
536
  | `--threshold <0..1>` | Duplicate-similarity cutoff (`dedupe`; default `0.8`) |
459
537
  | `--adapter <name>` | Host framework for `hook`: `claude` / `cursor` / `codex` |
460
538
  | `--event <name>` | Host hook event for `hook` (else read from the stdin payload) |
@@ -548,7 +626,7 @@ forever. This reduces manual validation to a single capture pass per tool.
548
626
  ## Usage telemetry
549
627
 
550
628
  The human-facing commands (`install`, `uninstall`, `doctor`, `list`, `search`,
551
- `show`, `stats`, `diff`, `migrate`)
629
+ `show`, `stats`, `scopes`, `diff`, `migrate`)
552
630
  emit one OpenTelemetry span + one counter point per run so the maintainers can see which
553
631
  commands people use. It is zero-dependency (OTLP/JSON over `fetch`, no SDK) and
554
632
  deliberately narrow — it carries only the command name, a bounded set of boolean
package/bin/lorekit.mjs CHANGED
@@ -10,6 +10,7 @@ import { list } from '../src/list.mjs';
10
10
  import { search } from '../src/search.mjs';
11
11
  import { show } from '../src/show.mjs';
12
12
  import { stats } from '../src/stats.mjs';
13
+ import { scopes } from '../src/scopes.mjs';
13
14
  import { diff } from '../src/diff.mjs';
14
15
  import { tree } from '../src/tree.mjs';
15
16
  import { lint } from '../src/lint.mjs';
@@ -33,7 +34,7 @@ ${c.bold('Usage')}
33
34
 
34
35
  ${c.bold('Commands')}
35
36
  install Scaffold the lorekit-memory + lorekit-setup skills, wire the
36
- LoreKit MCP server, and install the deterministic hooks (lessons
37
+ LoreKit MCP server, and install the deterministic hooks (memories
37
38
  on SessionStart, a nudge on tool failure + at Stop). Prompts to
38
39
  install for this project (.claude) or globally for every project
39
40
  (~/.claude); --project / --global choose non-interactively,
@@ -43,35 +44,39 @@ ${c.bold('Commands')}
43
44
  other servers, hooks, and settings are left untouched. Prompts
44
45
  project vs global; --project / --global choose non-interactively.
45
46
  doctor Verify the skill install, MCP connectivity, token, and scope.
46
- list (ls) List the lessons that apply to the current directory, split into
47
+ list (ls) List the memories that apply to the current directory, split into
47
48
  an Offline section (local .lorekit/ + ~/.lorekit/) and a Remote
48
49
  section (hosted MCP). Groups by scope (project/branch/repo/global).
49
50
  --json for scripting, --scope <s> to narrow.
50
- search Full-text search the applicable lessons across both stores and all
51
+ search Full-text search the applicable memories across both stores and all
51
52
  (grep) scopes (case-insensitive, literal substring over key + value),
52
53
  rendered in the same Offline/Remote split. --json, --scope <s>.
53
- show Inspect one lesson in full: its complete value, scope, key, updated
54
+ show Inspect one memory in full: its complete value, scope, key, updated
54
55
  date, tags, and which store(s) it lives in (noting any divergence
55
56
  when it is in both). --json. Usage: show <scope> <key>.
56
- stats Count the applicable lessons per scope and per store (offline vs
57
+ stats Count the applicable memories per scope and per store (offline vs
57
58
  remote), with per-store and grand totals, in the same Offline/
58
59
  Remote split. --json, --scope <s>.
60
+ scopes Store-wide inventory of EVERY distinct scope that holds memories,
61
+ with a memory count per scope — not cwd-scoped like the commands
62
+ above (it lists scopes anywhere in the store). Offline is exact;
63
+ the remote can't enumerate scopes (honest note). --json, --scope <s>.
59
64
  diff Compare the offline and remote stores for the applicable scopes and
60
65
  report divergence: local-only, remote-only, and conflicting keys
61
66
  (grouped by scope). Needs both stores readable. --json, --scope <s>.
62
67
  tree Show the injected scopes (project → branch → repo → global) as a precedence
63
- (resolve) hierarchy and mark, per key, which scope's lesson WINS and which are
68
+ (resolve) hierarchy and mark, per key, which scope's memory WINS and which are
64
69
  shadowed — the real hook-resolution order. --json, --scope <s>.
65
- lint Flag low-quality lessons (empty/short/untrimmed value, empty key,
70
+ lint Flag low-quality memories (empty/short/untrimmed value, empty key,
66
71
  malformed scope) across the applicable scopes and both stores. Exits
67
72
  non-zero when issues are found (CI gate). --json, --scope <s>.
68
- dedupe Find likely-duplicate lessons via a zero-dep word-overlap HEURISTIC
73
+ dedupe Find likely-duplicate memories via a zero-dep word-overlap HEURISTIC
69
74
  (Jaccard >= threshold, not semantic), grouped into clusters per
70
75
  store. --json, --scope <s>, --threshold <0..1>.
71
76
  migrate Relocate a LoreKit-format local store into the current layout.
72
77
  Dry-run by default; pass --yes to apply. Idempotent.
73
78
  hook Hook engine for Claude Code / Cursor / Codex. Reads the host's
74
- JSON on stdin and injects lessons or a retrospective nudge.
79
+ JSON on stdin and injects memories or a retrospective nudge.
75
80
  Not run by hand — wired into a plugin's hook config.
76
81
  mcp Local stdio MCP server. Exposes the memory.* tools backed by the
77
82
  resolved store (local .lorekit/ offline, or remote passthrough) so
@@ -86,8 +91,8 @@ ${c.bold('Options')}
86
91
  -t, --token <token> LoreKit token (lk_rw_* to allow writes, lk_ro_* read-only)
87
92
  --mode <mode> Memory mode: off | local | remote (doctor override)
88
93
  --store <path> Local project-tier store directory (default: .lorekit)
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)
94
+ --json Machine-readable output (list / search / show / stats / scopes / diff / tree / lint / dedupe)
95
+ --scope <scope> Restrict to a single scope; a substring filter for scopes (list / search / stats / scopes / diff / tree / lint / dedupe)
91
96
  --threshold <0..1> Duplicate-similarity cutoff (dedupe; default 0.8)
92
97
  --from <path> Source store to migrate from (migrate)
93
98
  --to <tier> Migration destination tier: home | project (migrate;
@@ -138,7 +143,7 @@ ${c.bold('Usage')}
138
143
 
139
144
  Scaffolds the lorekit-memory (runtime read/write) and lorekit-setup (loop
140
145
  authoring) skills, adds the LoreKit MCP server, and wires the deterministic
141
- hooks (lessons on SessionStart, a nudge on tool failure + at Stop).
146
+ hooks (memories on SessionStart, a nudge on tool failure + at Stop).
142
147
 
143
148
  ${c.bold('Options')}
144
149
  -d, --dir <path> Target project root (default: current directory)
@@ -194,12 +199,12 @@ ${c.bold('Examples')}
194
199
  npx @lorekit/cli doctor --deep
195
200
  npx @lorekit/cli doctor --mode local
196
201
  `,
197
- list: `${c.bold('lorekit list')} — list the lessons that apply to the current directory ${c.dim('(alias: ls)')}
202
+ list: `${c.bold('lorekit list')} — list the memories that apply to the current directory ${c.dim('(alias: ls)')}
198
203
 
199
204
  ${c.bold('Usage')}
200
205
  npx @lorekit/cli list [options]
201
206
 
202
- Shows the lessons for the scopes that resolve for the current directory
207
+ Shows the memories for the scopes that resolve for the current directory
203
208
  (project/branch/repo/global), split into an Offline section (the local
204
209
  .lorekit/ + ~/.lorekit/ two-tier store) and a Remote section (the hosted MCP
205
210
  server). When no remote token/endpoint is configured the Remote section is a
@@ -218,14 +223,14 @@ ${c.bold('Examples')}
218
223
  npx @lorekit/cli list --json
219
224
  npx @lorekit/cli list --scope global
220
225
  `,
221
- search: `${c.bold('lorekit search')} — full-text search the applicable lessons ${c.dim('(alias: grep)')}
226
+ search: `${c.bold('lorekit search')} — full-text search the applicable memories ${c.dim('(alias: grep)')}
222
227
 
223
228
  ${c.bold('Usage')}
224
229
  npx @lorekit/cli search <query> [options]
225
230
 
226
- Searches every lesson for the current directory's scopes (project/branch/repo/
231
+ Searches every memory for the current directory's scopes (project/branch/repo/
227
232
  global) across both stores, matching the query case-insensitively as a LITERAL
228
- substring of a lesson's key or value (regex metacharacters are matched verbatim,
233
+ substring of a memory's key or value (regex metacharacters are matched verbatim,
229
234
  never interpreted). Results are shown in the same Offline / Remote split as
230
235
  \`list\`; an unconfigured remote degrades to a short note, never an error.
231
236
 
@@ -242,12 +247,12 @@ ${c.bold('Examples')}
242
247
  npx @lorekit/cli grep "flaky test" --json
243
248
  npx @lorekit/cli search migration --scope global
244
249
  `,
245
- show: `${c.bold('lorekit show')} — inspect one lesson in full
250
+ show: `${c.bold('lorekit show')} — inspect one memory in full
246
251
 
247
252
  ${c.bold('Usage')}
248
253
  npx @lorekit/cli show <scope> <key> [options]
249
254
 
250
- Prints one lesson's complete (untruncated) value, scope, key, updated date, tags,
255
+ Prints one memory's complete (untruncated) value, scope, key, updated date, tags,
251
256
  and which store(s) it lives in. If the same scope::key exists in both the offline
252
257
  and remote stores, both are shown and any divergence in their values is flagged.
253
258
  Exits non-zero when the key is found in neither readable store.
@@ -263,12 +268,12 @@ ${c.bold('Examples')}
263
268
  npx @lorekit/cli show global prefer-guard-clauses
264
269
  npx @lorekit/cli show project::widget build-flags --json
265
270
  `,
266
- stats: `${c.bold('lorekit stats')} — count the applicable lessons per scope and per store
271
+ stats: `${c.bold('lorekit stats')} — count the applicable memories per scope and per store
267
272
 
268
273
  ${c.bold('Usage')}
269
274
  npx @lorekit/cli stats [options]
270
275
 
271
- Shows how many lessons apply to the current directory's scopes (project/branch/
276
+ Shows how many memories apply to the current directory's scopes (project/branch/
272
277
  repo/global), broken down per scope and per store (Offline = the local .lorekit/
273
278
  + ~/.lorekit/ two-tier store; Remote = the hosted MCP server), with per-store and
274
279
  grand totals. An unconfigured remote degrades to a short note, never an error.
@@ -285,6 +290,35 @@ ${c.bold('Examples')}
285
290
  npx @lorekit/cli stats
286
291
  npx @lorekit/cli stats --json
287
292
  npx @lorekit/cli stats --scope global
293
+ `,
294
+ scopes: `${c.bold('lorekit scopes')} — store-wide inventory of every distinct scope
295
+
296
+ ${c.bold('Usage')}
297
+ npx @lorekit/cli scopes [options]
298
+
299
+ Lists EVERY distinct scope present in the store, with a memory count per scope,
300
+ in the same Offline / Remote split as the other read commands. Unlike \`list\` /
301
+ \`stats\` (which only see the scopes that resolve for the current directory), this
302
+ is a full inventory — it surfaces scopes anywhere in the store, regardless of the
303
+ current directory.
304
+
305
+ Offline counts are exact: each scope is read from the memory files' frontmatter,
306
+ not reverse-mapped from the directory layout. The Remote section is always a
307
+ short note: the hosted MCP surface has no "list all scopes" tool (every read tool
308
+ requires a scope), so a remote inventory isn't possible — never an error (exit 0).
309
+
310
+ ${c.bold('Options')}
311
+ -d, --dir <path> Target project root (default: current directory)
312
+ --scope <substr> Filter the inventory to scopes containing this substring
313
+ --json Machine-readable output
314
+ -e, --endpoint <url> Remote endpoint override (else .mcp.json / LOREKIT_MCP_URL)
315
+ -t, --token <token> Remote token override (else .mcp.json / LOREKIT_TOKEN)
316
+ --store <path> Local project-tier store directory (default: .lorekit)
317
+
318
+ ${c.bold('Examples')}
319
+ npx @lorekit/cli scopes
320
+ npx @lorekit/cli scopes --json
321
+ npx @lorekit/cli scopes --scope repo::
288
322
  `,
289
323
  diff: `${c.bold('lorekit diff')} — compare the offline and remote stores
290
324
 
@@ -310,16 +344,16 @@ ${c.bold('Examples')}
310
344
  npx @lorekit/cli diff --json
311
345
  npx @lorekit/cli diff --scope global
312
346
  `,
313
- tree: `${c.bold('lorekit tree')} — show the scope precedence hierarchy and which lesson wins ${c.dim('(alias: resolve)')}
347
+ tree: `${c.bold('lorekit tree')} — show the scope precedence hierarchy and which memory wins ${c.dim('(alias: resolve)')}
314
348
 
315
349
  ${c.bold('Usage')}
316
350
  npx @lorekit/cli tree [options]
317
351
 
318
352
  Shows the scopes the hooks actually inject for the current directory — project,
319
353
  branch, repo, global, in precedence order (most-specific first) — and marks, for any key
320
- present at more than one scope, which scope's lesson WINS and which are shadowed.
354
+ present at more than one scope, which scope's memory WINS and which are shadowed.
321
355
  This mirrors the SessionStart hook's resolution exactly (a more-specific scope
322
- overrides a broader scope's same-key lesson). Resolved independently per store,
356
+ overrides a broader scope's same-key memory). Resolved independently per store,
323
357
  in the same Offline / Remote split.
324
358
 
325
359
  ${c.bold('Options')}
@@ -334,12 +368,12 @@ ${c.bold('Examples')}
334
368
  npx @lorekit/cli tree
335
369
  npx @lorekit/cli resolve --json
336
370
  `,
337
- lint: `${c.bold('lorekit lint')} — flag low-quality lessons across the applicable scopes
371
+ lint: `${c.bold('lorekit lint')} — flag low-quality memories across the applicable scopes
338
372
 
339
373
  ${c.bold('Usage')}
340
374
  npx @lorekit/cli lint [options]
341
375
 
342
- Checks every lesson for the current directory's scopes (project/branch/repo/
376
+ Checks every memory for the current directory's scopes (project/branch/repo/
343
377
  global), across both stores, against a small set of quality rules: empty or
344
378
  whitespace-only value, suspiciously short value, untrimmed value, empty key, and
345
379
  malformed scope (e.g. a single \`:\` where \`::\` is expected). Each finding names
@@ -359,12 +393,12 @@ ${c.bold('Examples')}
359
393
  npx @lorekit/cli lint --json
360
394
  npx @lorekit/cli lint --scope global
361
395
  `,
362
- dedupe: `${c.bold('lorekit dedupe')} — find likely-duplicate lessons (heuristic)
396
+ dedupe: `${c.bold('lorekit dedupe')} — find likely-duplicate memories (heuristic)
363
397
 
364
398
  ${c.bold('Usage')}
365
399
  npx @lorekit/cli dedupe [options]
366
400
 
367
- Groups lessons whose values overlap heavily into duplicate clusters, per store,
401
+ Groups memories whose values overlap heavily into duplicate clusters, per store,
368
402
  across the current directory's scopes. The similarity signal is a zero-dependency
369
403
  HEURISTIC — Jaccard overlap of lowercased word tokens, not a semantic/embedding
370
404
  measure — so it surfaces candidates for a human to review, and can both miss
@@ -406,7 +440,7 @@ ${c.bold('Examples')}
406
440
  ${c.bold('Usage')}
407
441
  lorekit hook --adapter <claude|cursor|codex> --event <name> [--dir <path>]
408
442
 
409
- Machine-facing: reads the host's JSON on stdin and injects lessons or a
443
+ Machine-facing: reads the host's JSON on stdin and injects memories or a
410
444
  retrospective nudge on stdout, always exiting 0. Wired into a plugin's hook
411
445
  config by \`lorekit install\` — not run by hand.
412
446
 
@@ -442,8 +476,8 @@ const KNOWN_FLAGS = [
442
476
  // reject unknown flags; the machine-facing `hook` / `mcp` do not (they must
443
477
  // never fail on a stray flag, and only ever receive flags we control).
444
478
  const HUMAN_COMMANDS = new Set([
445
- 'install', 'uninstall', 'doctor', 'list', 'search', 'show', 'stats', 'diff',
446
- 'tree', 'lint', 'dedupe', 'migrate',
479
+ 'install', 'uninstall', 'doctor', 'list', 'search', 'show', 'stats', 'scopes',
480
+ 'diff', 'tree', 'lint', 'dedupe', 'migrate',
447
481
  ]);
448
482
 
449
483
  // Command aliases — canonicalized before help / dispatch so `lorekit ls --help`
@@ -525,6 +559,8 @@ async function main() {
525
559
  return traceCommand('show', args, VERSION, () => show(args));
526
560
  case 'stats':
527
561
  return traceCommand('stats', args, VERSION, () => stats(args));
562
+ case 'scopes':
563
+ return traceCommand('scopes', args, VERSION, () => scopes(args));
528
564
  case 'diff':
529
565
  return traceCommand('diff', args, VERSION, () => diff(args));
530
566
  case 'tree':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lorekit/cli",
3
- "version": "1.13.1",
3
+ "version": "1.15.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
@@ -345,8 +345,9 @@ export function tokenKind(token) {
345
345
 
346
346
  // For hooks: resolve the connection closest-scope first — the project's
347
347
  // .mcp.json (a project install), then the global ~/.claude.json (a global
348
- // install), then env. `splitEndpoint` is passed in to avoid a circular import
349
- // with mcp.mjs.
348
+ // install), then the `mcp.endpoint` field in .lorekit.json (committable URL
349
+ // without token — safe for VCS), then env. `splitEndpoint` is passed in to
350
+ // avoid a circular import with mcp.mjs.
350
351
  export function resolveProjectConnection(root, splitEndpoint) {
351
352
  const sources = [readLorekitServer(root), readServerFromFile(mcpConfigPath(root, 'global'))];
352
353
  for (const configured of sources) {
@@ -360,5 +361,33 @@ export function resolveProjectConnection(root, splitEndpoint) {
360
361
  }
361
362
  }
362
363
  }
364
+
365
+ // Fallback: `mcp.endpoint` in .lorekit.json — a committable URL without token.
366
+ // Token still comes from .mcp.json or LOREKIT_TOKEN.
367
+ const lorekitJson = readLorekitJson(root);
368
+ const committedEndpoint =
369
+ lorekitJson && typeof lorekitJson['mcp.endpoint'] === 'string'
370
+ ? lorekitJson['mcp.endpoint'].trim()
371
+ : null;
372
+ if (committedEndpoint && !committedEndpoint.includes('<project-ref>')) {
373
+ return {
374
+ endpoint: committedEndpoint,
375
+ token: process.env.LOREKIT_TOKEN || null,
376
+ };
377
+ }
378
+
363
379
  return resolveConnection({});
364
380
  }
381
+
382
+ // Read .lorekit.json from the repo root without throwing. Exported so other
383
+ // CLI modules can read per-repo config without duplicating the same try/catch.
384
+ // control.mjs uses its own internal readJson(file) for historical reasons and
385
+ // does not import this — that is a known duplication, not a bug.
386
+ export function readLorekitJson(root) {
387
+ const file = path.join(root, '.lorekit.json');
388
+ try {
389
+ return JSON.parse(fs.readFileSync(file, 'utf8')) || {};
390
+ } catch {
391
+ return {};
392
+ }
393
+ }
package/src/control.mjs CHANGED
@@ -1,5 +1,11 @@
1
1
  // The control model: decide the memory mode (off | local | remote), the store
2
- // target, who decided, and which deny constraints are active.
2
+ // target, who decided, and which deny constraints are active. Also resolves
3
+ // write-behaviour properties from the config layers:
4
+ //
5
+ // scope.defaults — map of scope-prefix → { tags } applied to every matching write
6
+ // tags.default — array of tags appended to every write (both layers merged)
7
+ // hooks.disabled — array of hook event names to suppress (e.g. ["Stop"])
8
+ // hooks.adapter — explicit adapter override ("claude" | "cursor" | "codex")
3
9
  //
4
10
  // Two layers of config, two kinds of statement:
5
11
  // - a SELECT (`mode`) chooses a mode within what is allowed;
@@ -104,7 +110,44 @@ export function resolveControl({
104
110
  storeTarget = connection.endpoint || null;
105
111
  }
106
112
 
107
- return { mode: chosen.mode, storeTarget, decidedBy, denies, connection };
113
+ // 5. Write-behaviour properties resolved from config layers.
114
+ // `tags.default` — both layers merged, user supplements repo (no override).
115
+ const tagsDefault = [
116
+ ...asList(repoConfig['tags.default']),
117
+ ...asList(userConfig['tags.default']),
118
+ ].filter((t) => typeof t === 'string' && t.length > 0);
119
+
120
+ // `scope.defaults` — repo layer only (team-scoped write policy).
121
+ // Schema: { "<scope-prefix>": { "tags": [...] } }
122
+ // Matched against a write's resolved scope using startsWith — no glob dep.
123
+ const scopeDefaults =
124
+ repoConfig['scope.defaults'] && typeof repoConfig['scope.defaults'] === 'object'
125
+ ? repoConfig['scope.defaults']
126
+ : null;
127
+
128
+ // `hooks.disabled` — union of both layers (either layer can suppress an event).
129
+ const hooksDisabled = new Set([
130
+ ...asList(repoConfig['hooks.disabled']),
131
+ ...asList(userConfig['hooks.disabled']),
132
+ ]);
133
+
134
+ // `hooks.adapter` — repo layer wins over user layer (explicit project override).
135
+ const hooksAdapter =
136
+ (typeof repoConfig['hooks.adapter'] === 'string' && repoConfig['hooks.adapter'].trim()) ||
137
+ (typeof userConfig['hooks.adapter'] === 'string' && userConfig['hooks.adapter'].trim()) ||
138
+ null;
139
+
140
+ return {
141
+ mode: chosen.mode,
142
+ storeTarget,
143
+ decidedBy,
144
+ denies,
145
+ connection,
146
+ tagsDefault,
147
+ scopeDefaults,
148
+ hooksDisabled,
149
+ hooksAdapter,
150
+ };
108
151
  }
109
152
 
110
153
  // The per-repo project-tier directory: $LOREKIT_STORE, else a `store` override
@@ -55,7 +55,7 @@ export async function fetchLessons(store, cwd) {
55
55
  export function formatLessons(lessons, scope) {
56
56
  if (!lessons || lessons.length === 0) return null;
57
57
  const header =
58
- `LoreKit — ${lessons.length} shared lesson(s) for ${scope.repoScope || 'this workspace'}. ` +
58
+ `LoreKit — ${lessons.length} shared ${lessons.length === 1 ? 'memory' : 'memories'} for ${scope.repoScope || 'this workspace'}. ` +
59
59
  `Treat as considerations, not rules; trust the current code if they conflict.`;
60
60
  const body = lessons
61
61
  .map((l) => {
@@ -110,7 +110,7 @@ export function formatRelevantLessons(lessons) {
110
110
  if (!lessons || lessons.length === 0) return null;
111
111
  const header =
112
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):';
113
+ `${lessons.length === 1 ? 'memory' : 'memories'} (considerations, not rules; trust the current code if they conflict):`;
114
114
  const body = lessons
115
115
  .map((l) => {
116
116
  const first = String(l.value || '').split('\n')[0].slice(0, 300);
@@ -120,25 +120,52 @@ export function formatRelevantLessons(lessons) {
120
120
  return `${header}\n${body}`;
121
121
  }
122
122
 
123
+ // Build a tags hint string from config-resolved tags and scope defaults. Returns
124
+ // "" when there are no configured tags (no hint appended to the nudge).
125
+ function tagsHint(writeScope, { tagsDefault = [], scopeDefaults = null } = {}) {
126
+ const tags = [...tagsDefault];
127
+ if (scopeDefaults) {
128
+ for (const [prefix, cfg] of Object.entries(scopeDefaults)) {
129
+ if (
130
+ writeScope === prefix ||
131
+ writeScope.startsWith(prefix.endsWith('::') ? prefix : prefix + '::')
132
+ ) {
133
+ for (const t of Array.isArray(cfg.tags) ? cfg.tags : []) {
134
+ if (typeof t === 'string' && t.length > 0 && !tags.includes(t)) tags.push(t);
135
+ }
136
+ }
137
+ }
138
+ }
139
+ if (tags.length === 0) return '';
140
+ return ` Include tags: [${tags.map((t) => JSON.stringify(t)).join(', ')}].`;
141
+ }
142
+
123
143
  // The retrospective nudge emitted at end-of-turn (one-shot per session).
124
- export function retrospectiveNudge(scope) {
144
+ // `control` is the resolved control object (optional) — carries tagsDefault and
145
+ // scopeDefaults when the repo/user config defines them.
146
+ export function retrospectiveNudge(scope, control) {
125
147
  const writeScope = scope.repoScope || 'global';
148
+ const hint = tagsHint(writeScope, control);
126
149
  return (
127
150
  'LoreKit retrospective: if this session hit a stuck loop, a repeated ' +
128
151
  'command failure, a surprising gotcha, a near-miss, or a wrong assumption ' +
129
152
  'that cost time, record it now via the lorekit-memory skill ' +
130
- `(memory.write to ${writeScope}, phrased as an observation). ` +
153
+ `(memory.write to ${writeScope}, phrased as an observation).${hint} ` +
131
154
  'If nothing was durable, do nothing.'
132
155
  );
133
156
  }
134
157
 
135
158
  // The nudge emitted when a tool failure is detected.
136
- export function failureNudge(toolName, scope) {
159
+ // `control` is the resolved control object (optional) — carries tagsDefault and
160
+ // scopeDefaults when the repo/user config defines them.
161
+ export function failureNudge(toolName, scope, control) {
137
162
  const writeScope = scope.repoScope || 'global';
163
+ const hint = tagsHint(writeScope, control);
164
+ const suffix = hint ? `${hint} So the next run avoids it.` : 'so the next run avoids it.';
138
165
  return (
139
166
  `LoreKit: the last ${toolName} call failed. If this is a recurring or ` +
140
- 'non-obvious failure, consider recording the fix as a lesson via ' +
141
- `lorekit-memory (memory.write to ${writeScope}), so the next run avoids it.`
167
+ 'non-obvious failure, consider recording the fix as a memory via ' +
168
+ `lorekit-memory (memory.write to ${writeScope}) ${suffix}`
142
169
  );
143
170
  }
144
171
 
package/src/dedupe.mjs CHANGED
@@ -11,7 +11,7 @@
11
11
  // Same Offline / Remote split and graceful degradation as `list`. Read-only.
12
12
  // Human-facing, so the bin wraps it in `traceCommand`.
13
13
  import process from 'node:process';
14
- import { resolveProjectRoot } from './config.mjs';
14
+ 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';
@@ -29,6 +29,16 @@ export function parseThreshold(raw) {
29
29
  return Math.min(1, Math.max(0, n));
30
30
  }
31
31
 
32
+ // Read `dedupe.threshold` from .lorekit.json (non-throwing). Returns the
33
+ // repo-default threshold, or undefined when not configured.
34
+ export function repoThreshold(root) {
35
+ const cfg = readLorekitJson(root);
36
+ if (cfg['dedupe.threshold'] !== undefined) {
37
+ return parseThreshold(cfg['dedupe.threshold']);
38
+ }
39
+ return undefined;
40
+ }
41
+
32
42
  // Flatten a `gather()` result into one entry list (each entry keeps its scope)
33
43
  // for cross-scope clustering, plus the scopes whose read errored (they can't be
34
44
  // clustered and are surfaced, not silently dropped).
@@ -50,7 +60,11 @@ export async function dedupe(args) {
50
60
  const env = { ...process.env };
51
61
  if (args.store) env.LOREKIT_STORE = args.store;
52
62
 
53
- const threshold = parseThreshold(args.threshold);
63
+ // Threshold precedence: --threshold flag > dedupe.threshold in .lorekit.json > default (0.8).
64
+ const threshold =
65
+ args.threshold !== undefined
66
+ ? parseThreshold(args.threshold)
67
+ : (repoThreshold(root) ?? DEFAULT_THRESHOLD);
54
68
  const scopeInfo = deriveScope(root);
55
69
  // Default to every applicable scope; `--scope <s>` narrows to one.
56
70
  const scopes = args.scope && typeof args.scope === 'string' ? [args.scope] : scopeList(scopeInfo);
@@ -152,7 +166,7 @@ function renderDedupeSection(header, section) {
152
166
  cluster.minSimilarity === cluster.maxSimilarity
153
167
  ? cluster.minSimilarity.toFixed(2)
154
168
  : `${cluster.minSimilarity.toFixed(2)}–${cluster.maxSimilarity.toFixed(2)}`;
155
- log(` ${c.yellow('•')} cluster ${n} ${c.dim(`(${cluster.size} lessons, similarity ${range})`)}`);
169
+ log(` ${c.yellow('•')} cluster ${n} ${c.dim(`(${cluster.size} memories, similarity ${range})`)}`);
156
170
  for (const m of cluster.members) {
157
171
  log(` ${c.cyan('-')} ${m.scope}::${m.key}`);
158
172
  }
package/src/doctor.mjs CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  readLorekitServer,
12
12
  readMcpConfig,
13
13
  tokenKind,
14
+ readLorekitJson,
14
15
  } from './config.mjs';
15
16
  import { splitEndpoint } from './mcp.mjs';
16
17
  import { deriveScope } from './scope.mjs';
@@ -82,9 +83,24 @@ export async function doctor(args) {
82
83
  const scope = deriveScope(root);
83
84
  if (scope.hasRemote) {
84
85
  record('info', 'read scope', scope.readOrder.join(' → '));
85
- record('info', 'write scope', `${scope.repoScope} (default for "went wrong" lessons)`);
86
+ record('info', 'write scope', `${scope.repoScope} (default for "went wrong" memories)`);
86
87
  } else {
87
- record('warn', 'scope', 'no git remote here — lessons fall back to global');
88
+ record('warn', 'scope', 'no git remote here — memories fall back to global');
89
+ }
90
+
91
+ // 6. doctor.require — committed list of checks that MUST pass.
92
+ // Useful as a CI gate: any check in the list that did not pass causes a failure.
93
+ const lorekitJson = readLorekitJson(root);
94
+ const required = (Array.isArray(lorekitJson['doctor.require']) ? lorekitJson['doctor.require'] : [])
95
+ .filter((r) => typeof r === 'string');
96
+ for (const req of required) {
97
+ // A required check passes if its label is NOT in failedChecks (it either
98
+ // passed or was never run; unknown labels get a pass to avoid false failures).
99
+ if (failedChecks.includes(req)) {
100
+ record('fail', 'doctor.require', `required check failed: ${req}`);
101
+ } else {
102
+ record('pass', 'doctor.require', `required check passed: ${req}`);
103
+ }
88
104
  }
89
105
 
90
106
  // Summary.
@@ -128,7 +144,8 @@ async function checkLocal(control, root, args, record) {
128
144
 
129
145
  // Home tier — per-user, cross-repo, always available.
130
146
  record('pass', 'home store', prettyPath(store.homeDir));
131
- record('info', 'home entries', `${await store.home.count(scopes)} lesson(s)`);
147
+ const homeCount = await store.home.count(scopes);
148
+ record('info', 'home entries', `${homeCount} ${homeCount === 1 ? 'memory' : 'memories'}`);
132
149
 
133
150
  // Project tier — opt-in; active only when its directory exists.
134
151
  const projRel = path.relative(root, store.projectDir) || store.projectDir;
@@ -137,12 +154,13 @@ async function checkLocal(control, root, args, record) {
137
154
  ? 'committed — shared with the team'
138
155
  : 'gitignored — private to your checkout';
139
156
  record('pass', 'project store', projRel);
140
- record('info', 'project entries', `${await store.project.count(scopes)} lesson(s) — ${sharing}`);
157
+ const projCount = await store.project.count(scopes);
158
+ record('info', 'project entries', `${projCount} ${projCount === 1 ? 'memory' : 'memories'} — ${sharing}`);
141
159
  } else {
142
160
  record(
143
161
  'info',
144
162
  'project store',
145
- `${projRel} — not opted-in (create it to persist repo/branch lessons here)`,
163
+ `${projRel} — not opted-in (create it to persist repo/branch memories here)`,
146
164
  );
147
165
  }
148
166
 
package/src/hook.mjs CHANGED
@@ -83,6 +83,9 @@ async function run(args) {
83
83
  const control = loadControl(root);
84
84
  if (control.mode === 'off') return 0;
85
85
 
86
+ // `hooks.disabled` — skip the event if this event name is suppressed by config.
87
+ if (control.hooksDisabled && control.hooksDisabled.has(event)) return 0;
88
+
86
89
  const emit = (text) => {
87
90
  if (text) process.stdout.write(adapter.emit(event, text));
88
91
  };
@@ -114,14 +117,14 @@ async function run(args) {
114
117
  } catch {
115
118
  relevant = null; // never let a lesson lookup break the failure nudge
116
119
  }
117
- const nudge = failureNudge(parsed.toolName, scope);
120
+ const nudge = failureNudge(parsed.toolName, scope, control);
118
121
  emit(relevant ? `${relevant}\n\n${nudge}` : nudge);
119
122
  return 0;
120
123
  }
121
124
 
122
125
  if (intent === 'retrospective') {
123
126
  if (!firstTimeThisSession(parsed.sessionId, 'retro')) return 0;
124
- emit(retrospectiveNudge(scope));
127
+ emit(retrospectiveNudge(scope, control));
125
128
  return 0;
126
129
  }
127
130
 
package/src/install.mjs CHANGED
@@ -113,7 +113,7 @@ export async function install(args) {
113
113
  status('pass', mcpLabel, `${existed ? 'updated' : 'created'} lorekit server → ${display(file)}`);
114
114
 
115
115
  if (!wireHooks) {
116
- status('info', 'hooks', 'skipped (--no-hooks) — the skill still works, but lessons are model-invoked only');
116
+ status('info', 'hooks', 'skipped (--no-hooks) — the skill still works, but memories are model-invoked only');
117
117
  } else {
118
118
  const n = hooks.added + hooks.updated;
119
119
  const hookState =
@@ -127,9 +127,9 @@ export async function install(args) {
127
127
  if (kind === 'none') {
128
128
  status('warn', 'token', 'none configured — reads/writes will fail until a token is set');
129
129
  } else if (kind === 'read-only') {
130
- status('warn', 'token', 'read-only (lk_ro_*) — the skill can read lessons but not write them');
130
+ status('warn', 'token', 'read-only (lk_ro_*) — the skill can read memories but not write them');
131
131
  } else if (kind === 'write-only') {
132
- status('warn', 'token', 'write-only (lk_wo_*) — the skill can write lessons but not read them');
132
+ status('warn', 'token', 'write-only (lk_wo_*) — the skill can write memories but not read them');
133
133
  } else if (kind === 'unknown') {
134
134
  status('warn', 'token', 'unrecognized prefix — expected lk_rw_*, lk_ro_*, or lk_wo_*');
135
135
  } else {
@@ -140,7 +140,7 @@ export async function install(args) {
140
140
  if (gitScope.hasRemote) {
141
141
  status('info', 'scope', `${gitScope.repoScope}${gitScope.branchScope ? ` · ${gitScope.branchScope}` : ''}`);
142
142
  } else {
143
- status('warn', 'scope', 'no git remote — lessons will fall back to global');
143
+ status('warn', 'scope', 'no git remote — memories will fall back to global');
144
144
  }
145
145
 
146
146
  log(`\n Next: ${c.cyan('npx @lorekit/cli doctor')} to verify the connection.`);
@@ -112,6 +112,51 @@ export function tallyGroups({ groups = [], total } = {}) {
112
112
  return { perScope, total: typeof total === 'number' ? total : summed };
113
113
  }
114
114
 
115
+ // ── `scopes` — store-wide scope inventory ─────────────────────────────────────
116
+
117
+ // The canonical scope TYPE of a scope string — its `::`-leading segment when it
118
+ // is one of the four recognized types, else `other` (a malformed / legacy row).
119
+ // Pure. Shared by the `scopes` inventory ordering below.
120
+ export function scopeTypeOf(scope) {
121
+ const type = String(scope == null ? '' : scope).split('::')[0];
122
+ return ['global', 'project', 'repo', 'branch'].includes(type) ? type : 'other';
123
+ }
124
+
125
+ // A stable type ordering for the inventory: broadest → most-specific, with any
126
+ // unrecognized scope last. Chosen (over count-desc) so the listing groups
127
+ // related scopes together and is deterministic run-to-run.
128
+ const SCOPE_TYPE_RANK = { global: 0, project: 1, repo: 2, branch: 3, other: 4 };
129
+
130
+ // Sort a scope inventory (`[{ scope, count }]`) into a navigable order: primary
131
+ // by scope type (global → project → repo → branch → other), secondary
132
+ // alphabetical by the full scope string. Pure — returns a new array, never
133
+ // mutating its input.
134
+ export function sortScopeInventory(list = []) {
135
+ return [...list].sort((a, b) => {
136
+ const ra = SCOPE_TYPE_RANK[scopeTypeOf(a.scope)] ?? 4;
137
+ const rb = SCOPE_TYPE_RANK[scopeTypeOf(b.scope)] ?? 4;
138
+ if (ra !== rb) return ra - rb;
139
+ return String(a.scope).localeCompare(String(b.scope));
140
+ });
141
+ }
142
+
143
+ // Narrow a scope inventory to the scopes whose string CONTAINS `needle`
144
+ // (case-insensitive substring) — the `scopes --scope <s>` filter. An empty /
145
+ // absent needle passes everything through unchanged. Pure.
146
+ export function filterScopeInventory(list = [], needle) {
147
+ if (!needle) return list;
148
+ const q = String(needle).toLowerCase();
149
+ return list.filter((s) => String(s.scope).toLowerCase().includes(q));
150
+ }
151
+
152
+ // The pure core of the `scopes` command: sort an inventory and total its counts.
153
+ // Returns `{ scopes: [{ scope, count }] (sorted), total }`. Pure.
154
+ export function summarizeScopeInventory(list = []) {
155
+ const scopes = sortScopeInventory(list);
156
+ const total = scopes.reduce((n, s) => n + (Number(s.count) || 0), 0);
157
+ return { scopes, total };
158
+ }
159
+
115
160
  // Compare two `gather()` results (offline vs remote) and classify every scope's
116
161
  // keys into three sets — the pure core of the `diff` command:
117
162
  // • localOnly — key present offline, absent remote;
@@ -384,9 +429,9 @@ export function renderSection(header, section) {
384
429
 
385
430
  const printable = (section.groups || []).filter((g) => g.entries.length || g.error);
386
431
  if (!printable.length) {
387
- // `header.empty` lets `search` say "no lessons match" where `list` says
388
- // "no lessons found"; both are the same empty-section case.
389
- log(` ${c.dim(header.empty || 'no lessons found in the applicable scopes')}`);
432
+ // `header.empty` lets `search` say "no memories match" where `list` says
433
+ // "no memories found"; both are the same empty-section case.
434
+ log(` ${c.dim(header.empty || 'no memories found in the applicable scopes')}`);
390
435
  return;
391
436
  }
392
437
 
package/src/list.mjs CHANGED
@@ -70,7 +70,7 @@ export async function list(args) {
70
70
  if (args.json) {
71
71
  log(JSON.stringify(buildJson({ root, scopes, offlineSection, remoteSection }), null, 2));
72
72
  } else {
73
- heading('LoreKit lessons');
73
+ heading('LoreKit memories');
74
74
  log(` project: ${c.dim(root)}`);
75
75
  log(` scopes: ${scopes.join(' → ')}`);
76
76
 
@@ -36,7 +36,7 @@ const SERVER_INFO = { name: 'lorekit-local', version: '1.0.0' };
36
36
  export const MEMORY_TOOL_DEFS = [
37
37
  {
38
38
  name: 'memory.write',
39
- description: 'Store or update a lesson',
39
+ description: 'Store or update a memory',
40
40
  inputSchema: {
41
41
  type: 'object',
42
42
  required: ['scope', 'key', 'value'],
@@ -58,24 +58,24 @@ export const MEMORY_TOOL_DEFS = [
58
58
  },
59
59
  {
60
60
  name: 'memory.read',
61
- description: 'Read a lesson by scope and key',
61
+ description: 'Read a memory by scope and key',
62
62
  inputSchema: { type: 'object', required: ['scope', 'key'] },
63
63
  },
64
64
  {
65
65
  name: 'memory.list',
66
- description: 'List lessons for a scope',
66
+ description: 'List memories for a scope',
67
67
  inputSchema: { type: 'object', required: ['scope'] },
68
68
  },
69
69
  {
70
70
  name: 'memory.search',
71
- description: 'Keyword search across lessons',
71
+ description: 'Keyword search across memories',
72
72
  inputSchema: { type: 'object', required: ['q'] },
73
73
  },
74
74
  {
75
75
  name: 'memory.delete',
76
76
  description:
77
- 'Soft-archive a lesson (default) or hard-delete it (force: true). ' +
78
- 'Archived lessons are hidden from reads but can be restored.',
77
+ 'Soft-archive a memory (default) or hard-delete it (force: true). ' +
78
+ 'Archived memories are hidden from reads but can be restored.',
79
79
  inputSchema: {
80
80
  type: 'object',
81
81
  required: ['scope', 'key'],
@@ -88,7 +88,7 @@ export const MEMORY_TOOL_DEFS = [
88
88
  },
89
89
  {
90
90
  name: 'memory.archive',
91
- description: 'Soft-archive a lesson. Hidden from reads but restorable.',
91
+ description: 'Soft-archive a memory. Hidden from reads but restorable.',
92
92
  inputSchema: { type: 'object', required: ['scope', 'key'] },
93
93
  },
94
94
  ];
package/src/scopes.mjs ADDED
@@ -0,0 +1,152 @@
1
+ // `lorekit scopes` — a STORE-WIDE inventory of every distinct scope that holds
2
+ // lessons, with a per-scope lesson count, in the same Offline / Remote split as
3
+ // the other read commands.
4
+ //
5
+ // The key difference from `list` / `search` / `stats` / `diff` / `tree`: those
6
+ // are all cwd-scoped — they only look at the scopes that resolve for the current
7
+ // directory (project / branch / repo / global). `scopes` ignores the current
8
+ // directory entirely and enumerates EVERY scope present in the store, so a user
9
+ // can see all the scopes they have lessons in, anywhere.
10
+ //
11
+ // Offline enumeration is AUTHORITATIVE: it walks the local two-tier store and
12
+ // reads each lesson file's frontmatter `scope` string directly — never reverse-
13
+ // mapping the on-disk directory layout, which is lossy for `project::{name}`
14
+ // (stored by basename only) — so every scope is reconstructed exactly.
15
+ //
16
+ // Remote enumeration is NOT possible: the hosted MCP surface exposes no "list
17
+ // all scopes" tool — every read tool (memory.list / search / read) REQUIRES a
18
+ // scope — so the Remote section is always an honest note (never faked), the same
19
+ // way `stats` omits a cap-usage figure. It still degrades gracefully at exit 0.
20
+ //
21
+ // Graceful, read-only, human-facing (the bin wraps it in `traceCommand`).
22
+ // `LOREKIT_DENY` suppresses a section; `--scope <s>` filters the inventory to
23
+ // scopes whose string contains that substring; `--json` emits `{ offline,
24
+ // remote }`.
25
+ import process from 'node:process';
26
+ import { resolveProjectRoot } from './config.mjs';
27
+ import { resolveDenies } from './control.mjs';
28
+ import { resolveStores, remoteUnavailableReason } from './stores.mjs';
29
+ import { summarizeScopeInventory, filterScopeInventory } from './lessons-view.mjs';
30
+ import { log, heading, status, c } from './util.mjs';
31
+
32
+ // The honest note the Remote section shows when it isn't denied/unconfigured:
33
+ // the hosted MCP surface simply can't enumerate scopes. Exported so tests can
34
+ // assert the exact wording rather than a fragile substring.
35
+ export const REMOTE_SCOPES_UNSUPPORTED =
36
+ 'remote scope enumeration is not supported by the hosted MCP surface (memory.list requires a scope)';
37
+
38
+ export async function scopes(args) {
39
+ const root = resolveProjectRoot(args.dir);
40
+ const env = { ...process.env };
41
+ if (args.store) env.LOREKIT_STORE = args.store;
42
+
43
+ // `--scope <s>` is a substring filter over the inventory (NOT a single-scope
44
+ // selector like the cwd-scoped commands — an inventory of one scope would be
45
+ // pointless). Absent → the full inventory.
46
+ const filter = args.scope && typeof args.scope === 'string' ? args.scope : null;
47
+
48
+ const { local, remote, connection } = resolveStores(root, {
49
+ env,
50
+ endpoint: args.endpoint,
51
+ token: args.token,
52
+ });
53
+
54
+ // Deny-wins section suppression, identical to `list` / `stats` / `diff`.
55
+ const { localDenied, remoteDenied } = resolveDenies(root, { env });
56
+
57
+ // Offline: the authoritative store-wide enumeration.
58
+ let offlineSection;
59
+ if (localDenied) {
60
+ offlineSection = { available: false, reason: `disabled by deny constraint (${localDenied.source})` };
61
+ } else {
62
+ const inventory = filterScopeInventory(await local.listScopes(), filter);
63
+ offlineSection = { available: true, ...summarizeScopeInventory(inventory) };
64
+ }
65
+
66
+ // Remote: never enumerable. A deny note, an unconfigured note, or the honest
67
+ // "not supported" note — all graceful (exit 0). Order matches the other
68
+ // commands' precedence: deny first, then connectivity, then the capability.
69
+ let remoteSection;
70
+ if (remoteDenied) {
71
+ remoteSection = { available: false, reason: `disabled by deny constraint (${remoteDenied.source})` };
72
+ } else if (!remote.usable()) {
73
+ remoteSection = { available: false, reason: remoteUnavailableReason(connection) };
74
+ } else {
75
+ remoteSection = { available: false, reason: REMOTE_SCOPES_UNSUPPORTED };
76
+ }
77
+
78
+ if (args.json) {
79
+ log(JSON.stringify(buildJson({ root, filter, offlineSection, remoteSection }), null, 2));
80
+ } else {
81
+ heading('LoreKit scopes');
82
+ log(` store: ${c.dim(root)}`);
83
+ if (filter) log(` filter: ${c.dim(filter)}`);
84
+ renderScopesSection({ title: 'Offline' }, offlineSection);
85
+ renderScopesSection(
86
+ { title: 'Remote', subtitle: remote.usable() ? connection.endpoint : undefined },
87
+ remoteSection,
88
+ );
89
+ log('');
90
+ }
91
+
92
+ // Bounded, non-PII telemetry extras (counts + a boolean) — never a scope
93
+ // string, path, or token. `remote_available` is always false: the surface
94
+ // can't enumerate, and saying so is the honest signal.
95
+ return {
96
+ exitCode: 0,
97
+ 'lorekit.cli.scopes.offline_scope_count': offlineSection.available ? offlineSection.scopes.length : 0,
98
+ 'lorekit.cli.scopes.offline_total': offlineSection.available ? offlineSection.total : 0,
99
+ 'lorekit.cli.scopes.filtered': Boolean(filter),
100
+ 'lorekit.cli.scopes.remote_available': false,
101
+ };
102
+ }
103
+
104
+ // Render one store's inventory: an unavailable note, or a right-aligned
105
+ // `scope count` table plus a `total` line noting the scope count.
106
+ function renderScopesSection(header, section) {
107
+ heading(header.title);
108
+ if (header.subtitle) log(` ${c.dim(header.subtitle)}`);
109
+
110
+ if (!section.available) {
111
+ status('warn', 'unavailable', section.reason);
112
+ return;
113
+ }
114
+
115
+ if (!section.scopes.length) {
116
+ log(` ${c.dim('no scopes found — the store holds no memories')}`);
117
+ return;
118
+ }
119
+
120
+ const width = Math.max(0, ...section.scopes.map((s) => String(s.scope).length));
121
+ for (const s of section.scopes) {
122
+ log(` ${c.bold(String(s.scope).padEnd(width))} ${s.count}`);
123
+ }
124
+ const n = section.scopes.length;
125
+ log(
126
+ ` ${c.dim('total'.padEnd(width))} ${c.bold(String(section.total))} ` +
127
+ `${c.dim(`(${n} scope${n === 1 ? '' : 's'})`)}`,
128
+ );
129
+ }
130
+
131
+ // The `--json` payload: `{ root, filter, offline, remote }` — each store a
132
+ // `{ available, total, scopes: [{ scope, count }] }` record (or an unavailable
133
+ // note), so a script gets the same shape regardless of which store answered.
134
+ function buildJson({ root, filter, offlineSection, remoteSection }) {
135
+ return {
136
+ root,
137
+ filter: filter || null,
138
+ offline: sectionJson(offlineSection),
139
+ remote: sectionJson(remoteSection),
140
+ };
141
+ }
142
+
143
+ function sectionJson(section) {
144
+ if (!section.available) {
145
+ return { available: false, reason: section.reason, total: 0, scopes: [] };
146
+ }
147
+ return {
148
+ available: true,
149
+ total: section.total,
150
+ scopes: section.scopes.map((s) => ({ scope: s.scope, count: s.count })),
151
+ };
152
+ }
package/src/search.mjs CHANGED
@@ -77,7 +77,7 @@ export async function search(args) {
77
77
  log(` query: ${c.dim(query)}`);
78
78
  log(` scopes: ${scopes.join(' → ')}`);
79
79
 
80
- const empty = 'no lessons match';
80
+ const empty = 'no memories match';
81
81
  renderSection({ title: 'Offline', empty }, offlineSection);
82
82
  renderSection(
83
83
  { title: 'Remote', subtitle: remoteAvailable ? connection.endpoint : undefined, empty },
package/src/show.mjs CHANGED
@@ -78,7 +78,7 @@ export async function show(args) {
78
78
  if (args.json) {
79
79
  log(JSON.stringify(buildJson({ scope, key, offline, remote_, diverged }), null, 2));
80
80
  } else {
81
- heading('LoreKit lesson');
81
+ heading('LoreKit memory');
82
82
  log(` scope: ${c.dim(scope)}`);
83
83
  log(` key: ${c.dim(key)}`);
84
84
 
@@ -95,7 +95,7 @@ export async function show(args) {
95
95
  }
96
96
  if (!found) {
97
97
  log('');
98
- log(` ${c.dim(`no lesson found for ${scope}::${key} in the readable store(s)`)}`);
98
+ log(` ${c.dim(`no memory found for ${scope}::${key} in the readable store(s)`)}`);
99
99
  }
100
100
  log('');
101
101
  }
@@ -204,6 +204,53 @@ class LocalStore {
204
204
  for (const scope of scopes || []) n += (await this.list({ scope })).entries.length;
205
205
  return n;
206
206
  }
207
+
208
+ // Recursively collect every parsed entry under baseDir, across ALL scopes —
209
+ // the primitive `listScopes()` builds on. Best-effort: a missing base dir or
210
+ // an unreadable file is skipped, never thrown. Reads each file's frontmatter
211
+ // (the authoritative scope string) rather than the directory it sits in.
212
+ _walkEntries() {
213
+ const out = [];
214
+ const walk = (dir) => {
215
+ let dirents;
216
+ try {
217
+ dirents = fs.readdirSync(dir, { withFileTypes: true });
218
+ } catch {
219
+ return; // missing / unreadable directory — nothing to enumerate here
220
+ }
221
+ for (const d of dirents) {
222
+ const full = path.join(dir, d.name);
223
+ if (d.isDirectory()) {
224
+ walk(full);
225
+ } else if (d.isFile() && d.name.endsWith('.md')) {
226
+ try {
227
+ const entry = parseEntry(fs.readFileSync(full, 'utf8'));
228
+ if (entry) out.push({ entry, file: full });
229
+ } catch {
230
+ // Skip an unreadable file rather than fail the whole enumeration.
231
+ }
232
+ }
233
+ }
234
+ };
235
+ walk(this.baseDir);
236
+ return out;
237
+ }
238
+
239
+ // Enumerate every distinct scope present on disk with its non-archived lesson
240
+ // count — a STORE-WIDE inventory, independent of any current directory (unlike
241
+ // list/count, which take an explicit scope set). The scope of each lesson is
242
+ // read from its frontmatter `scope` field, so the reconstructed scope string
243
+ // is EXACT — never reverse-mapped from the on-disk directory layout, which is
244
+ // lossy for `project::{name}` (stored by basename only). Returns
245
+ // `[{ scope, count }]`, unsorted.
246
+ async listScopes() {
247
+ const counts = new Map();
248
+ for (const { entry } of this._walkEntries()) {
249
+ if (entry.archived_at || !entry.scope) continue;
250
+ counts.set(entry.scope, (counts.get(entry.scope) || 0) + 1);
251
+ }
252
+ return [...counts.entries()].map(([scope, count]) => ({ scope, count }));
253
+ }
207
254
  }
208
255
 
209
256
  // A scope string is global when its type segment is `global`.
@@ -326,4 +373,23 @@ class TwoTierStore {
326
373
  for (const scope of scopes || []) n += (await this.list({ scope })).entries.length;
327
374
  return n;
328
375
  }
376
+
377
+ // Store-wide scope inventory across both tiers, de-duplicated by scope+key so
378
+ // a lesson present in both tiers is counted once — project shadows home, the
379
+ // same first-wins merge `list()` uses. Returns `[{ scope, count }]`, unsorted.
380
+ async listScopes() {
381
+ const seen = new Set(); // `${scope}\x00${key}` — dedup across tiers
382
+ const counts = new Map();
383
+ const tiers = this.projectActive() ? [this.project, this.home] : [this.home];
384
+ for (const tier of tiers) {
385
+ for (const { entry } of tier._walkEntries()) {
386
+ if (entry.archived_at || !entry.scope) continue;
387
+ const id = `${entry.scope}\x00${entry.key ?? ''}`;
388
+ if (seen.has(id)) continue;
389
+ seen.add(id);
390
+ counts.set(entry.scope, (counts.get(entry.scope) || 0) + 1);
391
+ }
392
+ }
393
+ return [...counts.entries()].map(([scope, count]) => ({ scope, count }));
394
+ }
329
395
  }
@@ -112,6 +112,14 @@ class RemoteStore {
112
112
  return { ok: true, ...payload };
113
113
  }
114
114
 
115
+ // Store-wide scope enumeration is NOT possible against the hosted MCP surface:
116
+ // every read tool (memory.list / memory.search / memory.read) REQUIRES a
117
+ // scope, and there is no "list all scopes" tool. Signal that honestly so the
118
+ // `scopes` command shows a clear note rather than faking an inventory.
119
+ async listScopes() {
120
+ return { ok: false, unsupported: true };
121
+ }
122
+
115
123
  // Connectivity probe for doctor — a transport check, not a memory op.
116
124
  async ping() {
117
125
  if (!this.usable()) return { ok: false, unusable: true };
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 / tree /
8
- // lint / dedupe / migrate), fired to Dash0 so the maintainers can see which
9
- // commands people actually run.
7
+ // (install / uninstall / doctor / list / search / show / stats / scopes / diff /
8
+ // tree / lint / dedupe / migrate), fired to Dash0 so the maintainers can see
9
+ // which 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
@@ -25,6 +25,7 @@
25
25
 
26
26
  import process from 'node:process';
27
27
  import { TELEMETRY_TOKEN } from './telemetry-token.mjs';
28
+ import { readLorekitJson } from './config.mjs';
28
29
 
29
30
  // ── Baked-in defaults (public by design) ──────────────────────────────────────
30
31
  // The endpoint is a committed default; the token is injected at publish time
@@ -42,11 +43,13 @@ const OFF_VALUES = new Set(['0', 'off', 'false', 'no', 'disable', 'disabled']);
42
43
  // ── Config resolution ─────────────────────────────────────────────────────────
43
44
 
44
45
  /**
45
- * Resolve telemetry config from env + baked-in defaults.
46
+ * Resolve telemetry config from env + baked-in defaults + .lorekit.json.
46
47
  * Returns { enabled: false } when disabled or unconfigured, else the endpoint
47
48
  * and headers to export with.
49
+ * @param {object} [env] defaults to process.env
50
+ * @param {object} [repoConfig] pre-loaded .lorekit.json (optional; read from cwd if absent)
48
51
  */
49
- export function resolveTelemetryConfig(env = process.env) {
52
+ export function resolveTelemetryConfig(env = process.env, repoConfig) {
50
53
  const optOut = env.LOREKIT_TELEMETRY;
51
54
  if (optOut !== undefined && OFF_VALUES.has(String(optOut).trim().toLowerCase())) {
52
55
  return { enabled: false };
@@ -57,6 +60,12 @@ export function resolveTelemetryConfig(env = process.env) {
57
60
  if (env.DO_NOT_TRACK && String(env.DO_NOT_TRACK).trim() === '1') {
58
61
  return { enabled: false };
59
62
  }
63
+ // `telemetry.disabled: true` in .lorekit.json — team-level opt-out committed
64
+ // to the repo. Checked after env overrides (env always wins).
65
+ const cfg = repoConfig !== undefined ? repoConfig : readLorekitJson(process.cwd());
66
+ if (cfg['telemetry.disabled'] === true) {
67
+ return { enabled: false };
68
+ }
60
69
 
61
70
  const endpoint = (env.OTEL_EXPORTER_OTLP_ENDPOINT || DEFAULT_ENDPOINT || '')
62
71
  .trim()
@@ -275,7 +284,7 @@ function normalizeExitCode(result) {
275
284
  * counter point. Returns the command's exit code unchanged. Telemetry failures
276
285
  * are swallowed — the command result is never affected.
277
286
  *
278
- * @param {string} command bounded: install | uninstall | doctor | list | search | show | stats | diff | tree | lint | dedupe | migrate
287
+ * @param {string} command bounded: install | uninstall | doctor | list | search | show | stats | scopes | diff | tree | lint | dedupe | migrate
279
288
  * @param {object} args parsed CLI args (read for allow-listed flags only)
280
289
  * @param {string} version CLI version (from package.json)
281
290
  * @param {() => Promise<number>} run the command handler
package/src/tree.mjs CHANGED
@@ -107,7 +107,7 @@ function renderTreeSection(header, section) {
107
107
 
108
108
  const printable = (section.groups || []).filter((g) => g.entries.length || g.error);
109
109
  if (!printable.length) {
110
- log(` ${c.dim('no lessons found in the applicable scopes')}`);
110
+ log(` ${c.dim('no memories found in the applicable scopes')}`);
111
111
  return;
112
112
  }
113
113