@lorekit/cli 1.13.1 → 1.14.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 +41 -3
- package/bin/lorekit.mjs +40 -4
- package/package.json +1 -1
- package/src/lessons-view.mjs +45 -0
- package/src/scopes.mjs +152 -0
- package/src/store/local.mjs +66 -0
- package/src/store/remote.mjs +8 -0
- package/src/telemetry.mjs +4 -4
package/README.md
CHANGED
|
@@ -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
|
|
@@ -453,8 +491,8 @@ active deny constraints.
|
|
|
453
491
|
| `--no-hooks` | Skip wiring the lifecycle hooks; skill + MCP only (`install`) |
|
|
454
492
|
| `--force` | Overwrite existing skill files (`install`) |
|
|
455
493
|
| `--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) |
|
|
494
|
+
| `--json` | Machine-readable output (`list` / `search` / `show` / `stats` / `scopes` / `diff` / `tree` / `lint` / `dedupe`) |
|
|
495
|
+
| `--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
496
|
| `--threshold <0..1>` | Duplicate-similarity cutoff (`dedupe`; default `0.8`) |
|
|
459
497
|
| `--adapter <name>` | Host framework for `hook`: `claude` / `cursor` / `codex` |
|
|
460
498
|
| `--event <name>` | Host hook event for `hook` (else read from the stdin payload) |
|
|
@@ -548,7 +586,7 @@ forever. This reduces manual validation to a single capture pass per tool.
|
|
|
548
586
|
## Usage telemetry
|
|
549
587
|
|
|
550
588
|
The human-facing commands (`install`, `uninstall`, `doctor`, `list`, `search`,
|
|
551
|
-
`show`, `stats`, `diff`, `migrate`)
|
|
589
|
+
`show`, `stats`, `scopes`, `diff`, `migrate`)
|
|
552
590
|
emit one OpenTelemetry span + one counter point per run so the maintainers can see which
|
|
553
591
|
commands people use. It is zero-dependency (OTLP/JSON over `fetch`, no SDK) and
|
|
554
592
|
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';
|
|
@@ -56,6 +57,10 @@ ${c.bold('Commands')}
|
|
|
56
57
|
stats Count the applicable lessons 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 lessons,
|
|
61
|
+
with a lesson 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>.
|
|
@@ -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;
|
|
@@ -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 lesson 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 lesson 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
|
|
|
@@ -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/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;
|
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 lessons')}`);
|
|
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/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
|
|
@@ -275,7 +275,7 @@ function normalizeExitCode(result) {
|
|
|
275
275
|
* counter point. Returns the command's exit code unchanged. Telemetry failures
|
|
276
276
|
* are swallowed — the command result is never affected.
|
|
277
277
|
*
|
|
278
|
-
* @param {string} command bounded: install | uninstall | doctor | list | search | show | stats | diff | tree | lint | dedupe | migrate
|
|
278
|
+
* @param {string} command bounded: install | uninstall | doctor | list | search | show | stats | scopes | diff | tree | lint | dedupe | migrate
|
|
279
279
|
* @param {object} args parsed CLI args (read for allow-listed flags only)
|
|
280
280
|
* @param {string} version CLI version (from package.json)
|
|
281
281
|
* @param {() => Promise<number>} run the command handler
|