@lorekit/cli 1.14.0 → 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.
@@ -444,13 +444,53 @@ Two config layers decide the mode:
444
444
  - **Repo / team** — a `.lorekit.json` at the repo root (and/or the existing
445
445
  `lorekit` block in `.mcp.json` for the connection).
446
446
 
447
- Both files share one schema:
447
+ Both files share this schema — all fields optional:
448
448
 
449
449
  ```jsonc
450
+ // .lorekit.json (repo root — safe to commit, no secrets)
451
+ // ~/.lorekit/config.json (user/machine — personal overrides, not committed)
450
452
  {
451
- "mode": "local", // select a mode (off | local | remote)
452
- "store": ".lorekit", // project-tier store dir (relative to repo root, or absolute)
453
- "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
454
494
  }
455
495
  ```
456
496
 
@@ -488,7 +528,7 @@ active deny constraints.
488
528
  | `--to <tier>` | Migration destination tier: `home` / `project` (`migrate`; default routes by scope) |
489
529
  | `--apply` | Apply the migration — alias of `--yes` (`migrate`) |
490
530
  | `-y, --yes` | Non-interactive / apply; never prompt |
491
- | `--no-hooks` | Skip wiring the lifecycle hooks; skill + MCP only (`install`) |
531
+ | `--no-hooks` | Skip wiring the lifecycle hooks; skills + MCP only (`install`) |
492
532
  | `--force` | Overwrite existing skill files (`install`) |
493
533
  | `--deep` | Write/read/delete round-trip (`doctor`) |
494
534
  | `--json` | Machine-readable output (`list` / `search` / `show` / `stats` / `scopes` / `diff` / `tree` / `lint` / `dedupe`) |
package/bin/lorekit.mjs CHANGED
@@ -34,7 +34,7 @@ ${c.bold('Usage')}
34
34
 
35
35
  ${c.bold('Commands')}
36
36
  install Scaffold the lorekit-memory + lorekit-setup skills, wire the
37
- LoreKit MCP server, and install the deterministic hooks (lessons
37
+ LoreKit MCP server, and install the deterministic hooks (memories
38
38
  on SessionStart, a nudge on tool failure + at Stop). Prompts to
39
39
  install for this project (.claude) or globally for every project
40
40
  (~/.claude); --project / --global choose non-interactively,
@@ -44,39 +44,39 @@ ${c.bold('Commands')}
44
44
  other servers, hooks, and settings are left untouched. Prompts
45
45
  project vs global; --project / --global choose non-interactively.
46
46
  doctor Verify the skill install, MCP connectivity, token, and scope.
47
- 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
48
48
  an Offline section (local .lorekit/ + ~/.lorekit/) and a Remote
49
49
  section (hosted MCP). Groups by scope (project/branch/repo/global).
50
50
  --json for scripting, --scope <s> to narrow.
51
- 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
52
52
  (grep) scopes (case-insensitive, literal substring over key + value),
53
53
  rendered in the same Offline/Remote split. --json, --scope <s>.
54
- 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
55
55
  date, tags, and which store(s) it lives in (noting any divergence
56
56
  when it is in both). --json. Usage: show <scope> <key>.
57
- 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
58
58
  remote), with per-store and grand totals, in the same Offline/
59
59
  Remote split. --json, --scope <s>.
60
- scopes Store-wide inventory of EVERY distinct scope that holds lessons,
61
- with a lesson count per scope — not cwd-scoped like the commands
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
62
  above (it lists scopes anywhere in the store). Offline is exact;
63
63
  the remote can't enumerate scopes (honest note). --json, --scope <s>.
64
64
  diff Compare the offline and remote stores for the applicable scopes and
65
65
  report divergence: local-only, remote-only, and conflicting keys
66
66
  (grouped by scope). Needs both stores readable. --json, --scope <s>.
67
67
  tree Show the injected scopes (project → branch → repo → global) as a precedence
68
- (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
69
69
  shadowed — the real hook-resolution order. --json, --scope <s>.
70
- lint Flag low-quality lessons (empty/short/untrimmed value, empty key,
70
+ lint Flag low-quality memories (empty/short/untrimmed value, empty key,
71
71
  malformed scope) across the applicable scopes and both stores. Exits
72
72
  non-zero when issues are found (CI gate). --json, --scope <s>.
73
- 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
74
74
  (Jaccard >= threshold, not semantic), grouped into clusters per
75
75
  store. --json, --scope <s>, --threshold <0..1>.
76
76
  migrate Relocate a LoreKit-format local store into the current layout.
77
77
  Dry-run by default; pass --yes to apply. Idempotent.
78
78
  hook Hook engine for Claude Code / Cursor / Codex. Reads the host's
79
- JSON on stdin and injects lessons or a retrospective nudge.
79
+ JSON on stdin and injects memories or a retrospective nudge.
80
80
  Not run by hand — wired into a plugin's hook config.
81
81
  mcp Local stdio MCP server. Exposes the memory.* tools backed by the
82
82
  resolved store (local .lorekit/ offline, or remote passthrough) so
@@ -143,7 +143,7 @@ ${c.bold('Usage')}
143
143
 
144
144
  Scaffolds the lorekit-memory (runtime read/write) and lorekit-setup (loop
145
145
  authoring) skills, adds the LoreKit MCP server, and wires the deterministic
146
- hooks (lessons on SessionStart, a nudge on tool failure + at Stop).
146
+ hooks (memories on SessionStart, a nudge on tool failure + at Stop).
147
147
 
148
148
  ${c.bold('Options')}
149
149
  -d, --dir <path> Target project root (default: current directory)
@@ -199,12 +199,12 @@ ${c.bold('Examples')}
199
199
  npx @lorekit/cli doctor --deep
200
200
  npx @lorekit/cli doctor --mode local
201
201
  `,
202
- 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)')}
203
203
 
204
204
  ${c.bold('Usage')}
205
205
  npx @lorekit/cli list [options]
206
206
 
207
- 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
208
208
  (project/branch/repo/global), split into an Offline section (the local
209
209
  .lorekit/ + ~/.lorekit/ two-tier store) and a Remote section (the hosted MCP
210
210
  server). When no remote token/endpoint is configured the Remote section is a
@@ -223,14 +223,14 @@ ${c.bold('Examples')}
223
223
  npx @lorekit/cli list --json
224
224
  npx @lorekit/cli list --scope global
225
225
  `,
226
- 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)')}
227
227
 
228
228
  ${c.bold('Usage')}
229
229
  npx @lorekit/cli search <query> [options]
230
230
 
231
- 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/
232
232
  global) across both stores, matching the query case-insensitively as a LITERAL
233
- 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,
234
234
  never interpreted). Results are shown in the same Offline / Remote split as
235
235
  \`list\`; an unconfigured remote degrades to a short note, never an error.
236
236
 
@@ -247,12 +247,12 @@ ${c.bold('Examples')}
247
247
  npx @lorekit/cli grep "flaky test" --json
248
248
  npx @lorekit/cli search migration --scope global
249
249
  `,
250
- show: `${c.bold('lorekit show')} — inspect one lesson in full
250
+ show: `${c.bold('lorekit show')} — inspect one memory in full
251
251
 
252
252
  ${c.bold('Usage')}
253
253
  npx @lorekit/cli show <scope> <key> [options]
254
254
 
255
- 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,
256
256
  and which store(s) it lives in. If the same scope::key exists in both the offline
257
257
  and remote stores, both are shown and any divergence in their values is flagged.
258
258
  Exits non-zero when the key is found in neither readable store.
@@ -268,12 +268,12 @@ ${c.bold('Examples')}
268
268
  npx @lorekit/cli show global prefer-guard-clauses
269
269
  npx @lorekit/cli show project::widget build-flags --json
270
270
  `,
271
- 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
272
272
 
273
273
  ${c.bold('Usage')}
274
274
  npx @lorekit/cli stats [options]
275
275
 
276
- 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/
277
277
  repo/global), broken down per scope and per store (Offline = the local .lorekit/
278
278
  + ~/.lorekit/ two-tier store; Remote = the hosted MCP server), with per-store and
279
279
  grand totals. An unconfigured remote degrades to a short note, never an error.
@@ -296,13 +296,13 @@ ${c.bold('Examples')}
296
296
  ${c.bold('Usage')}
297
297
  npx @lorekit/cli scopes [options]
298
298
 
299
- Lists EVERY distinct scope present in the store, with a lesson count per scope,
299
+ Lists EVERY distinct scope present in the store, with a memory count per scope,
300
300
  in the same Offline / Remote split as the other read commands. Unlike \`list\` /
301
301
  \`stats\` (which only see the scopes that resolve for the current directory), this
302
302
  is a full inventory — it surfaces scopes anywhere in the store, regardless of the
303
303
  current directory.
304
304
 
305
- Offline counts are exact: each scope is read from the lesson files' frontmatter,
305
+ Offline counts are exact: each scope is read from the memory files' frontmatter,
306
306
  not reverse-mapped from the directory layout. The Remote section is always a
307
307
  short note: the hosted MCP surface has no "list all scopes" tool (every read tool
308
308
  requires a scope), so a remote inventory isn't possible — never an error (exit 0).
@@ -344,16 +344,16 @@ ${c.bold('Examples')}
344
344
  npx @lorekit/cli diff --json
345
345
  npx @lorekit/cli diff --scope global
346
346
  `,
347
- 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)')}
348
348
 
349
349
  ${c.bold('Usage')}
350
350
  npx @lorekit/cli tree [options]
351
351
 
352
352
  Shows the scopes the hooks actually inject for the current directory — project,
353
353
  branch, repo, global, in precedence order (most-specific first) — and marks, for any key
354
- 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.
355
355
  This mirrors the SessionStart hook's resolution exactly (a more-specific scope
356
- 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,
357
357
  in the same Offline / Remote split.
358
358
 
359
359
  ${c.bold('Options')}
@@ -368,12 +368,12 @@ ${c.bold('Examples')}
368
368
  npx @lorekit/cli tree
369
369
  npx @lorekit/cli resolve --json
370
370
  `,
371
- 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
372
372
 
373
373
  ${c.bold('Usage')}
374
374
  npx @lorekit/cli lint [options]
375
375
 
376
- 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/
377
377
  global), across both stores, against a small set of quality rules: empty or
378
378
  whitespace-only value, suspiciously short value, untrimmed value, empty key, and
379
379
  malformed scope (e.g. a single \`:\` where \`::\` is expected). Each finding names
@@ -393,12 +393,12 @@ ${c.bold('Examples')}
393
393
  npx @lorekit/cli lint --json
394
394
  npx @lorekit/cli lint --scope global
395
395
  `,
396
- dedupe: `${c.bold('lorekit dedupe')} — find likely-duplicate lessons (heuristic)
396
+ dedupe: `${c.bold('lorekit dedupe')} — find likely-duplicate memories (heuristic)
397
397
 
398
398
  ${c.bold('Usage')}
399
399
  npx @lorekit/cli dedupe [options]
400
400
 
401
- Groups lessons whose values overlap heavily into duplicate clusters, per store,
401
+ Groups memories whose values overlap heavily into duplicate clusters, per store,
402
402
  across the current directory's scopes. The similarity signal is a zero-dependency
403
403
  HEURISTIC — Jaccard overlap of lowercased word tokens, not a semantic/embedding
404
404
  measure — so it surfaces candidates for a human to review, and can both miss
@@ -440,7 +440,7 @@ ${c.bold('Examples')}
440
440
  ${c.bold('Usage')}
441
441
  lorekit hook --adapter <claude|cursor|codex> --event <name> [--dir <path>]
442
442
 
443
- 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
444
444
  retrospective nudge on stdout, always exiting 0. Wired into a plugin's hook
445
445
  config by \`lorekit install\` — not run by hand.
446
446
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lorekit/cli",
3
- "version": "1.14.0",
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.`);
@@ -429,9 +429,9 @@ export function renderSection(header, section) {
429
429
 
430
430
  const printable = (section.groups || []).filter((g) => g.entries.length || g.error);
431
431
  if (!printable.length) {
432
- // `header.empty` lets `search` say "no lessons match" where `list` says
433
- // "no lessons found"; both are the same empty-section case.
434
- 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')}`);
435
435
  return;
436
436
  }
437
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 CHANGED
@@ -113,7 +113,7 @@ function renderScopesSection(header, section) {
113
113
  }
114
114
 
115
115
  if (!section.scopes.length) {
116
- log(` ${c.dim('no scopes found — the store holds no lessons')}`);
116
+ log(` ${c.dim('no scopes found — the store holds no memories')}`);
117
117
  return;
118
118
  }
119
119
 
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
  }
package/src/telemetry.mjs CHANGED
@@ -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()
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