@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 +89 -11
- package/bin/lorekit.mjs +67 -31
- package/package.json +1 -1
- package/src/config.mjs +31 -2
- package/src/control.mjs +45 -2
- package/src/core/lessons.mjs +34 -7
- package/src/dedupe.mjs +17 -3
- package/src/doctor.mjs +23 -5
- package/src/hook.mjs +5 -2
- package/src/install.mjs +4 -4
- package/src/lessons-view.mjs +48 -3
- package/src/list.mjs +1 -1
- package/src/mcp-server.mjs +7 -7
- package/src/scopes.mjs +152 -0
- package/src/search.mjs +1 -1
- package/src/show.mjs +2 -2
- package/src/store/local.mjs +66 -0
- package/src/store/remote.mjs +8 -0
- package/src/telemetry.mjs +15 -6
- package/src/tree.mjs +1 -1
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
|
|
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. **
|
|
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
|
|
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
|
|
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
|
-
|
|
414
|
-
"
|
|
415
|
-
"
|
|
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;
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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', '
|
|
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
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
|
|
349
|
-
//
|
|
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
|
-
|
|
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
|
package/src/core/lessons.mjs
CHANGED
|
@@ -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
|
|
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
|
-
'
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
141
|
-
`lorekit-memory (memory.write to ${writeScope})
|
|
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
|
-
|
|
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}
|
|
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"
|
|
86
|
+
record('info', 'write scope', `${scope.repoScope} (default for "went wrong" memories)`);
|
|
86
87
|
} else {
|
|
87
|
-
record('warn', 'scope', 'no git remote here —
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 —
|
|
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.`);
|
package/src/lessons-view.mjs
CHANGED
|
@@ -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
|
|
388
|
-
// "no
|
|
389
|
-
log(` ${c.dim(header.empty || 'no
|
|
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
|
|
73
|
+
heading('LoreKit memories');
|
|
74
74
|
log(` project: ${c.dim(root)}`);
|
|
75
75
|
log(` scopes: ${scopes.join(' → ')}`);
|
|
76
76
|
|
package/src/mcp-server.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
78
|
-
'Archived
|
|
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
|
|
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
|
|
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
|
|
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
|
|
98
|
+
log(` ${c.dim(`no memory found for ${scope}::${key} in the readable store(s)`)}`);
|
|
99
99
|
}
|
|
100
100
|
log('');
|
|
101
101
|
}
|
package/src/store/local.mjs
CHANGED
|
@@ -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
|
}
|
package/src/store/remote.mjs
CHANGED
|
@@ -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 /
|
|
8
|
-
// lint / dedupe / migrate), fired to Dash0 so the maintainers can see
|
|
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
|
|
110
|
+
log(` ${c.dim('no memories found in the applicable scopes')}`);
|
|
111
111
|
return;
|
|
112
112
|
}
|
|
113
113
|
|