@lorekit/cli 1.34.0 → 1.36.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 +42 -0
- package/bin/lorekit.mjs +20 -3
- package/package.json +1 -1
- package/skill/lorekit-groom/SKILL.md +34 -2
- package/skill/lorekit-groom/rules/grooming-pass.md +23 -3
- package/src/config.mjs +134 -9
- package/src/dedupe.mjs +103 -10
- package/src/install.mjs +93 -9
- package/src/lessons-view.mjs +205 -0
- package/src/lint.mjs +41 -3
- package/src/list.mjs +47 -2
- package/src/mcp-server.mjs +21 -2
- package/src/stats.mjs +27 -4
- package/src/store/remote.mjs +22 -4
- package/src/uninstall.mjs +23 -2
package/README.md
CHANGED
|
@@ -74,6 +74,38 @@ lorekit install \
|
|
|
74
74
|
lorekit install --global # set it up for every project
|
|
75
75
|
```
|
|
76
76
|
|
|
77
|
+
#### Claude Code on the web (`--mcp-json`)
|
|
78
|
+
|
|
79
|
+
Claude Code on the web clones the repo fresh into an ephemeral container, so the
|
|
80
|
+
only MCP config it can see is a **committed, repo-root `.mcp.json`** — a global
|
|
81
|
+
`~/.claude.json` lives on your machine and never travels there. `--mcp-json`
|
|
82
|
+
writes exactly that file, in a **committable** form: it authenticates via a
|
|
83
|
+
`${LOREKIT_TOKEN}` reference in an `mcp-remote --header` rather than embedding
|
|
84
|
+
the token, so there is no secret in the file.
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
lorekit install --mcp-json --yes # web-ready project .mcp.json
|
|
88
|
+
lorekit install --global --mcp-json --yes # + the machine-wide CLI, skills, hooks
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Set **`LOREKIT_TOKEN`** as an environment secret in the web UI; the value is
|
|
92
|
+
expanded before `mcp-remote` is spawned. `--mcp-json` always writes the
|
|
93
|
+
**project-root** file regardless of `--project` / `--global`, and it composes
|
|
94
|
+
with either — pair it with `--global` to get the local CLI, skills, and hooks in
|
|
95
|
+
`~/.claude` **and** the committable web config in one command. On a `--project`
|
|
96
|
+
install it takes over `.mcp.json` with the committable form instead of the
|
|
97
|
+
embedded-token one.
|
|
98
|
+
|
|
99
|
+
**You have to commit the file, and `.mcp.json` is usually git-ignored** (the
|
|
100
|
+
default install embeds a token, so LoreKit's own `.gitignore` — and many
|
|
101
|
+
projects — ignore it). Un-ignore it before committing (drop the `.mcp.json` line
|
|
102
|
+
from `.gitignore`, negate it with `!.mcp.json`, or `git add -f .mcp.json` once);
|
|
103
|
+
`install --mcp-json` **warns when the file it wrote is still git-ignored**, so a
|
|
104
|
+
fresh web clone silently missing the config is not a mystery. Once `.mcp.json` is
|
|
105
|
+
tracked, only run `install --mcp-json` in that repo — a plain `install --project`
|
|
106
|
+
would embed a live token in the now-committed file. See the
|
|
107
|
+
[Claude Code on the web guide](https://lorekit.io/docs/claude-code-web).
|
|
108
|
+
|
|
77
109
|
In a TTY it prompts for the scope (and for `--endpoint` / `--token` if missing).
|
|
78
110
|
Flags: `--project` / `--global` pick the scope non-interactively; `--yes` runs
|
|
79
111
|
non-interactively (endpoint required via flag/env; scope defaults to project);
|
|
@@ -754,6 +786,7 @@ also returns their headroom against the plan's memory cap.
|
|
|
754
786
|
| `-y, --yes` | Non-interactive / apply; never prompt |
|
|
755
787
|
| `--hooks <mode>` | Lifecycle hooks to wire: `all` / `read-only` / `none` (`install`; `none` removes any already wired) |
|
|
756
788
|
| `--no-hooks` | Skip wiring the lifecycle hooks; skills + MCP only. Leaves already-wired hooks alone (`install`) |
|
|
789
|
+
| `--mcp-json` | Also write a committable project `.mcp.json` (auth via `${LOREKIT_TOKEN}`, no embedded token) for Claude Code on the web (`install`) |
|
|
757
790
|
| `--force` | Overwrite existing skill files (`install`) |
|
|
758
791
|
| `--deep` | Write/read/delete round-trip (`doctor`) |
|
|
759
792
|
| `--json` | Machine-readable output (`list` / `search` / `show` / `stats` / `scopes` / `diff` / `tree` / `lint` / `dedupe` / `link`) |
|
|
@@ -882,3 +915,12 @@ at release time and is never committed to git. See
|
|
|
882
915
|
|
|
883
916
|
`install` writes your token into `.mcp.json`. Keep that file out of version
|
|
884
917
|
control (LoreKit's root `.gitignore` already ignores `.mcp.json`).
|
|
918
|
+
|
|
919
|
+
The **one exception is `install --mcp-json`**: that file authenticates via a
|
|
920
|
+
`${LOREKIT_TOKEN}` reference instead of an embedded token, so it holds no secret
|
|
921
|
+
and **is** meant to be committed (that is how Claude Code on the web reads it
|
|
922
|
+
after a fresh clone). You'll need to un-ignore `.mcp.json` first, since it is
|
|
923
|
+
normally git-ignored for the embedded-token reason above. The token itself comes
|
|
924
|
+
from the `LOREKIT_TOKEN` environment variable at runtime — set it as an
|
|
925
|
+
environment secret, never commit it — and once the file is tracked, keep using
|
|
926
|
+
`--mcp-json` so a later plain install never writes a token into it.
|
package/bin/lorekit.mjs
CHANGED
|
@@ -43,7 +43,8 @@ ${c.bold('Commands')}
|
|
|
43
43
|
(~/.claude); --project / --global choose non-interactively.
|
|
44
44
|
Also prompts whether to wire the hooks (all / read-only /
|
|
45
45
|
none); --hooks <mode> chooses non-interactively and --no-hooks
|
|
46
|
-
skips them (skills stay model-invoked only).
|
|
46
|
+
skips them (skills stay model-invoked only). --mcp-json also
|
|
47
|
+
writes a committable project .mcp.json for Claude Code on the web.
|
|
47
48
|
uninstall Reverse install: remove the lorekit-memory + lorekit-setup skills,
|
|
48
49
|
the MCP server entry, and the lifecycle hooks for the chosen scope. Surgical —
|
|
49
50
|
other servers, hooks, and settings are left untouched. Prompts
|
|
@@ -126,6 +127,8 @@ ${c.bold('Options')}
|
|
|
126
127
|
-y, --yes Non-interactive / apply; never prompt
|
|
127
128
|
--hooks <mode> Lifecycle hooks to wire: all | read-only | none (install)
|
|
128
129
|
--no-hooks Skip wiring the lifecycle hooks (install)
|
|
130
|
+
--mcp-json Also write a committable project .mcp.json for Claude Code on
|
|
131
|
+
the web — auth via \${LOREKIT_TOKEN}, no embedded secret (install)
|
|
129
132
|
--force Overwrite existing skill files (install)
|
|
130
133
|
--deep Do a write→read→delete round-trip (doctor)
|
|
131
134
|
--telemetry Verify the OTLP export credential works (doctor)
|
|
@@ -183,6 +186,14 @@ An interactive run preselects whatever is already wired, so re-running install
|
|
|
183
186
|
never resurrects hooks you declined. Answering "No hooks" (or --hooks none)
|
|
184
187
|
REMOVES hooks that are already there; --no-hooks only skips wiring new ones.
|
|
185
188
|
|
|
189
|
+
${c.bold('Claude Code on the web')}
|
|
190
|
+
Add --mcp-json to write a committable project .mcp.json (repo root) that Claude
|
|
191
|
+
Code on the web can see after a fresh clone. It authenticates via a
|
|
192
|
+
\${LOREKIT_TOKEN} reference in an mcp-remote --header — NOT an embedded token —
|
|
193
|
+
so the file is safe to commit; set LOREKIT_TOKEN as an environment secret in the
|
|
194
|
+
web UI. Pair it with --global to get the local CLI, skills, and hooks in
|
|
195
|
+
~/.claude AND the committable web config in one command.
|
|
196
|
+
|
|
186
197
|
${c.bold('Options')}
|
|
187
198
|
-d, --dir <path> Target project root (default: current directory)
|
|
188
199
|
--project Install into this project: .claude/skills + .mcp.json (default)
|
|
@@ -191,6 +202,8 @@ ${c.bold('Options')}
|
|
|
191
202
|
-t, --token <token> LoreKit token: lk_rw_* read+write, lk_ro_* read-only, lk_wo_* write-only
|
|
192
203
|
--hooks <mode> Wire the lifecycle hooks: all | read-only | none
|
|
193
204
|
--no-hooks Skip wiring the lifecycle hooks (leaves existing ones alone)
|
|
205
|
+
--mcp-json Also write a committable project .mcp.json (\${LOREKIT_TOKEN} auth)
|
|
206
|
+
for Claude Code on the web — always the repo-root file
|
|
194
207
|
--force Overwrite existing skill files
|
|
195
208
|
-y, --yes Non-interactive; never prompt (defaults to --project, and to the
|
|
196
209
|
already-wired hooks — all on a fresh install)
|
|
@@ -198,6 +211,8 @@ ${c.bold('Options')}
|
|
|
198
211
|
${c.bold('Examples')}
|
|
199
212
|
npx @lorekit/cli install --endpoint https://ref.supabase.co/functions/v1/mcp --token lk_rw_xxx
|
|
200
213
|
npx @lorekit/cli install --global
|
|
214
|
+
npx @lorekit/cli install --mcp-json --yes # web-ready project .mcp.json
|
|
215
|
+
npx @lorekit/cli install --global --mcp-json --yes # local CLI + committable web config
|
|
201
216
|
npx @lorekit/cli install --hooks read-only --yes
|
|
202
217
|
npx @lorekit/cli install --no-hooks --yes
|
|
203
218
|
`,
|
|
@@ -631,11 +646,13 @@ ${c.bold('Options')}
|
|
|
631
646
|
// typo like `--gloabl` should fail loudly, not quietly fall back to --project.
|
|
632
647
|
const KNOWN_FLAGS = [
|
|
633
648
|
'dir', 'project', 'global', 'endpoint', 'token', 'mode', 'store',
|
|
634
|
-
'from', 'to', 'apply', 'yes', 'hooks', 'no-hooks', 'force', 'deep', 'adapter',
|
|
649
|
+
'from', 'to', 'apply', 'yes', 'hooks', 'no-hooks', 'mcp-json', 'force', 'deep', 'adapter',
|
|
635
650
|
'event', 'json', 'scope', 'key', 'threshold', 'help', 'version', 'telemetry',
|
|
636
651
|
'value', 'tags', 'source-agent', 'trigger', 'kind', 'host', 'ttl-days', 'clear-ttl', 'org', 'remote', 'local',
|
|
637
652
|
'link', 'base', 'q', 'owner', 'range', 'view', 'archived',
|
|
638
653
|
'origin-repo', 'origin-branch', 'origin-commit', 'origin-pr', 'no-origin',
|
|
654
|
+
// Scale-aware survey flags
|
|
655
|
+
'all', 'max', 'since', 'until', 'key-prefix',
|
|
639
656
|
];
|
|
640
657
|
|
|
641
658
|
// Commands that write to disk / talk to the network on a human's behalf. These
|
|
@@ -660,7 +677,7 @@ async function main() {
|
|
|
660
677
|
const argv = process.argv.slice(2);
|
|
661
678
|
const args = parseArgs(argv, {
|
|
662
679
|
aliases: { d: 'dir', e: 'endpoint', t: 'token', y: 'yes', h: 'help', v: 'version' },
|
|
663
|
-
booleans: ['yes', 'force', 'deep', 'apply', 'help', 'version', 'global', 'project', 'no-hooks', 'no-origin', 'json', 'remote', 'local', 'link', 'archived', 'clear-ttl', 'telemetry'],
|
|
680
|
+
booleans: ['yes', 'force', 'deep', 'apply', 'help', 'version', 'global', 'project', 'no-hooks', 'mcp-json', 'no-origin', 'json', 'remote', 'local', 'link', 'archived', 'clear-ttl', 'telemetry', 'all'],
|
|
664
681
|
known: KNOWN_FLAGS,
|
|
665
682
|
});
|
|
666
683
|
|
package/package.json
CHANGED
|
@@ -117,8 +117,9 @@ archiving and deleting always go through `memory.delete` / `memory.archive`.
|
|
|
117
117
|
|-----|-----|---------|
|
|
118
118
|
| Count lessons per scope/store | `lorekit stats [--scope <s>]` | CLI (read) |
|
|
119
119
|
| Inventory every scope + lesson count | `lorekit scopes` | CLI (read) |
|
|
120
|
-
| Find low-quality lessons | `lorekit lint --json` | CLI (read) |
|
|
121
|
-
| Find near-duplicate clusters | `lorekit dedupe --json [--threshold <n>]` | CLI (read) |
|
|
120
|
+
| Find low-quality lessons | `lorekit lint --json [--since <date>] [--until <date>] [--max <n>]` | CLI (read) |
|
|
121
|
+
| Find near-duplicate clusters | `lorekit dedupe --json [--threshold <n>] [--key-prefix <p>] [--since <date>] [--max <n>]` | CLI (read) |
|
|
122
|
+
| List all lessons across large scopes | `lorekit list --all [--max <n>] [--since <date>] [--until <date>]` | CLI (read) |
|
|
122
123
|
| Read one lesson in full | `lorekit show <scope::key> [--json]` | CLI (read) |
|
|
123
124
|
| Compare offline vs remote | `lorekit diff` | CLI (read) |
|
|
124
125
|
| Write the merged/consolidated lesson | `memory.write` (or `lorekit write`) | MCP / CLI |
|
|
@@ -126,6 +127,37 @@ archiving and deleting always go through `memory.delete` / `memory.archive`.
|
|
|
126
127
|
| Archive a lesson (reversible) | `memory.archive` (or `memory.delete`) | **MCP only** |
|
|
127
128
|
| Hard-delete a lesson (permanent) | `memory.delete { force: true }` | **MCP only** |
|
|
128
129
|
|
|
130
|
+
### Survey flags for large scopes
|
|
131
|
+
|
|
132
|
+
When a scope holds more lessons than a single page (> 50 for `list`, > 100 for
|
|
133
|
+
`lint`/`dedupe`), use these flags to survey the full population or narrow it:
|
|
134
|
+
|
|
135
|
+
| Flag | Commands | Effect |
|
|
136
|
+
|------|----------|--------|
|
|
137
|
+
| `--all` | `list` | Drain all pages (default for `lint`/`dedupe`) |
|
|
138
|
+
| `--max <n>` | `list --all`, `lint`, `dedupe` | Hard cap on entries surveyed (default 5000) |
|
|
139
|
+
| `--since <iso-date>` | `list --all`, `lint`, `dedupe` | Lower date bound (`created_since`) |
|
|
140
|
+
| `--until <iso-date>` | `list --all`, `lint`, `dedupe` | Upper date bound (`created_until`) |
|
|
141
|
+
| `--key-prefix <p>` | `dedupe` | Narrow deduplication to keys starting with prefix |
|
|
142
|
+
|
|
143
|
+
`dedupe` applies an internal population cap of 2000 for memory safety. When the
|
|
144
|
+
cap is reached, the output includes a warning with narrowing suggestions — use
|
|
145
|
+
`--key-prefix` or `--since` to reduce the population.
|
|
146
|
+
|
|
147
|
+
### MCP cursor paging
|
|
148
|
+
|
|
149
|
+
`memory.list` and `memory.search` now support pagination. When more results
|
|
150
|
+
exist than fit in one response, the response includes `hasMore: true` and a
|
|
151
|
+
`nextCursor` string. Pass `nextCursor` as the `cursor` argument on the next
|
|
152
|
+
call to retrieve the following page. Omit `cursor` to start from the first page.
|
|
153
|
+
|
|
154
|
+
```json
|
|
155
|
+
{ "scope": "global", "limit": 100, "cursor": "<nextCursor from previous response>" }
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Use this to drain an arbitrarily large scope in an agent loop rather than
|
|
159
|
+
relying on a truncated first page.
|
|
160
|
+
|
|
129
161
|
> **`dedupe` is a heuristic, not a semantic judge.** It clusters on Jaccard
|
|
130
162
|
> word-token overlap, so it can both miss reworded duplicates *and* group
|
|
131
163
|
> coincidental ones. Treat every cluster as a candidate to read and decide on,
|
|
@@ -23,9 +23,21 @@ lorekit scopes # every scope in the store + its lesson count
|
|
|
23
23
|
how you notice a `branch::…` scope you had forgotten about, or a scope with
|
|
24
24
|
hundreds of lessons that dwarfs the rest. Neither command reports a
|
|
25
25
|
last-activity date — the inventory is counts only — so judge staleness from the
|
|
26
|
-
lessons themselves once you narrow in
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
lessons themselves once you narrow in. Read both, then pick the noisiest scope as
|
|
27
|
+
the target for this pass and narrow to it with `--scope <scope>` from here on.
|
|
28
|
+
|
|
29
|
+
**Large scopes:** when a scope has more lessons than a single page, use:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
lorekit list --all --scope <scope> # drain all pages; tags each lesson with its updated date
|
|
33
|
+
lorekit list --all --scope <scope> --max 500 # cap at 500 to get a representative sample
|
|
34
|
+
lorekit list --all --scope <scope> --since 2024-01-01 # only lessons created since that date
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The remote store is paginated (default 50 entries per `list` call, 100 per
|
|
38
|
+
`lint`/`dedupe` call). `--all` drains every page; `--max` applies a hard cap
|
|
39
|
+
(default 5000). `lint` and `dedupe` default to full-scope survey — add `--max`
|
|
40
|
+
or `--since` when the population is large enough to slow things down.
|
|
29
41
|
|
|
30
42
|
## Phase 2 — Lint (read-only)
|
|
31
43
|
|
|
@@ -56,6 +68,9 @@ makes it a clean CI gate — a passing `lint` is your Phase 6 proof.
|
|
|
56
68
|
|
|
57
69
|
```bash
|
|
58
70
|
lorekit dedupe --json --scope <scope> --threshold 0.85
|
|
71
|
+
# For large scopes, narrow the population:
|
|
72
|
+
lorekit dedupe --json --scope <scope> --threshold 0.85 --key-prefix "debug-" --max 1000
|
|
73
|
+
lorekit dedupe --json --scope <scope> --threshold 0.85 --since 2024-01-01
|
|
59
74
|
```
|
|
60
75
|
|
|
61
76
|
Each cluster is a set of lessons whose values overlap heavily by word tokens.
|
|
@@ -64,6 +79,11 @@ Start high (`0.85`) to see the confident duplicates, then re-run lower (`0.75`,
|
|
|
64
79
|
clusters are coincidental overlaps rather than true duplicates, so read more
|
|
65
80
|
carefully.
|
|
66
81
|
|
|
82
|
+
`dedupe` surveys the full scope by default. When the population exceeds 2000
|
|
83
|
+
entries it stops and prints a narrowing warning — use `--key-prefix` to focus
|
|
84
|
+
on a key namespace, or `--since` to limit the date range. The `--max` flag sets
|
|
85
|
+
a lower cap (default 5000, internal safety cap at 2000).
|
|
86
|
+
|
|
67
87
|
For every cluster you intend to act on, read the members in full first:
|
|
68
88
|
|
|
69
89
|
```bash
|
package/src/config.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
5
6
|
import { fileURLToPath } from 'node:url';
|
|
6
7
|
|
|
7
8
|
// packages/cli/ — the installable package root (this file lives in src/).
|
|
@@ -366,6 +367,88 @@ export function upsertMcpServer(root, remoteUrl, scope = 'project') {
|
|
|
366
367
|
return { file, existed };
|
|
367
368
|
}
|
|
368
369
|
|
|
370
|
+
// The environment variable the committable (web) .mcp.json references for its
|
|
371
|
+
// token, instead of embedding a live secret. Kept as a constant so the writer,
|
|
372
|
+
// the install summary, and the docs can never name a different variable.
|
|
373
|
+
export const WEB_TOKEN_ENV_VAR = 'LOREKIT_TOKEN';
|
|
374
|
+
|
|
375
|
+
// Strip any credential from an endpoint URL before it goes into the committable
|
|
376
|
+
// web .mcp.json. The whole point of that file is to carry NO secret, so an
|
|
377
|
+
// `--endpoint` / LOREKIT_MCP_URL value that already embeds `?token=lk_…` (a
|
|
378
|
+
// perfectly ordinary thing to paste) must not be written verbatim — that would
|
|
379
|
+
// commit a live token in the file install tells you to commit. Mirrors
|
|
380
|
+
// `splitEndpoint`'s token removal (mcp.mjs) inline rather than importing it, so
|
|
381
|
+
// config.mjs keeps its no-mcp.mjs-import invariant (see resolveProjectConnection),
|
|
382
|
+
// and also clears userinfo for defense in depth. A non-URL string is left as-is;
|
|
383
|
+
// endpoint validity is the caller's concern, not this function's.
|
|
384
|
+
function stripEndpointCredentials(endpoint) {
|
|
385
|
+
try {
|
|
386
|
+
const u = new URL(endpoint);
|
|
387
|
+
u.searchParams.delete('token');
|
|
388
|
+
u.username = '';
|
|
389
|
+
u.password = '';
|
|
390
|
+
return u.toString();
|
|
391
|
+
} catch {
|
|
392
|
+
return endpoint;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Merge a lorekit server entry into the PROJECT-root .mcp.json in the
|
|
397
|
+
// committable form Claude Code on the web needs: instead of embedding the token
|
|
398
|
+
// in the URL (a live secret, which is why the embedded form is git-ignored), it
|
|
399
|
+
// authenticates via an mcp-remote `--header` that references `${LOREKIT_TOKEN}`.
|
|
400
|
+
//
|
|
401
|
+
// WHY this exists as a distinct writer. Claude Code on the web clones the repo
|
|
402
|
+
// fresh into an ephemeral container, so the only MCP config it can see is a
|
|
403
|
+
// COMMITTED, repo-root `.mcp.json` — a global `~/.claude.json` never travels to
|
|
404
|
+
// the clone. And it must be committable, which the embedded-token form is not.
|
|
405
|
+
// So the web path needs exactly this: the project file (never the global one)
|
|
406
|
+
// with the token supplied at runtime from an environment secret. Callers set
|
|
407
|
+
// `LOREKIT_TOKEN` as an environment secret in the web UI; the value is expanded
|
|
408
|
+
// by Claude Code before mcp-remote is spawned. Preserves any other servers.
|
|
409
|
+
//
|
|
410
|
+
// The endpoint is credential-stripped first: it is the one field that could
|
|
411
|
+
// carry a `?token=` in from `--endpoint` / LOREKIT_MCP_URL, and writing that
|
|
412
|
+
// into a file meant to be committed would leak a live token.
|
|
413
|
+
export function upsertWebMcpServer(root, endpoint, envVar = WEB_TOKEN_ENV_VAR) {
|
|
414
|
+
const file = mcpJsonPath(root); // always the project file — the web clone only sees this one
|
|
415
|
+
const config = readJsonIfExists(file) || {};
|
|
416
|
+
if (!config.mcpServers || typeof config.mcpServers !== 'object') {
|
|
417
|
+
config.mcpServers = {};
|
|
418
|
+
}
|
|
419
|
+
const existed = Boolean(config.mcpServers.lorekit);
|
|
420
|
+
config.mcpServers.lorekit = {
|
|
421
|
+
command: 'npx',
|
|
422
|
+
args: ['-y', 'mcp-remote', stripEndpointCredentials(endpoint), '--header', `Authorization:Bearer \${${envVar}}`],
|
|
423
|
+
};
|
|
424
|
+
writeFileAtomic(file, JSON.stringify(config, null, 2) + '\n');
|
|
425
|
+
return { file, existed };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Is the project .mcp.json git-ignored in this repo? Tri-state so callers can
|
|
429
|
+
// distinguish "definitely ignored" from "can't tell" and stay quiet when unsure:
|
|
430
|
+
// true → ignored (git check-ignore matched) — the caller warns
|
|
431
|
+
// false → tracked / not ignored — nothing to say
|
|
432
|
+
// null → unknown (no git, not a repo, error) — say nothing
|
|
433
|
+
//
|
|
434
|
+
// This exists because the committable web .mcp.json (upsertWebMcpServer) only
|
|
435
|
+
// works if it is actually committed, and .mcp.json is COMMONLY git-ignored (the
|
|
436
|
+
// default embedded-token form is a secret, so LoreKit's own root .gitignore and
|
|
437
|
+
// many projects ignore it). Silently writing a file the repo will never commit
|
|
438
|
+
// is the trap this lets `install --mcp-json` warn about. `git check-ignore -q`
|
|
439
|
+
// exits 0 when ignored, 1 when not, 128 on error — anything but 0/1 is unknown.
|
|
440
|
+
export function isMcpJsonGitIgnored(root) {
|
|
441
|
+
try {
|
|
442
|
+
const r = spawnSync('git', ['-C', root, 'check-ignore', '-q', '.mcp.json'], { stdio: 'ignore' });
|
|
443
|
+
if (r.error) return null; // git not found / spawn failed
|
|
444
|
+
if (r.status === 0) return true;
|
|
445
|
+
if (r.status === 1) return false;
|
|
446
|
+
return null; // 128 (not a repo) or anything unexpected
|
|
447
|
+
} catch {
|
|
448
|
+
return null;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
369
452
|
// Pull the configured lorekit remote URL out of the project .mcp.json, if
|
|
370
453
|
// present. Non-throwing: returns null when the file is absent, invalid, or has
|
|
371
454
|
// no lorekit server. Callers that need to distinguish those use readMcpConfig.
|
|
@@ -406,6 +489,37 @@ export function removeMcpServer(root, scope = 'project') {
|
|
|
406
489
|
return { file, removed: true };
|
|
407
490
|
}
|
|
408
491
|
|
|
492
|
+
// Is a lorekit server entry the committable WEB form — auth via a `--header`
|
|
493
|
+
// that references an `${ENV_VAR}` token, as written by `upsertWebMcpServer` —
|
|
494
|
+
// rather than an embedded-token or plain URL entry? Used to gate the global
|
|
495
|
+
// uninstall's project-file cleanup so it only removes what `--mcp-json` wrote.
|
|
496
|
+
export function isWebMcpServerEntry(server) {
|
|
497
|
+
const args = server && Array.isArray(server.args) ? server.args : [];
|
|
498
|
+
const i = args.indexOf('--header');
|
|
499
|
+
if (i === -1 || typeof args[i + 1] !== 'string') return false;
|
|
500
|
+
return /^Authorization:Bearer \$\{[^}]+\}$/.test(args[i + 1].trim());
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Remove the lorekit entry from the PROJECT .mcp.json ONLY when it is the
|
|
504
|
+
// committable web form. `uninstall --global` calls this to clean up the file
|
|
505
|
+
// `install --global --mcp-json` wrote — WITHOUT touching an unrelated
|
|
506
|
+
// embedded-token `install --project` entry a user set up separately (which a
|
|
507
|
+
// blind removeMcpServer(root, 'project') would delete). No-op otherwise.
|
|
508
|
+
export function removeWebMcpServer(root) {
|
|
509
|
+
const file = mcpJsonPath(root);
|
|
510
|
+
const config = readJsonIfExists(file);
|
|
511
|
+
const server =
|
|
512
|
+
config && config.mcpServers && typeof config.mcpServers === 'object'
|
|
513
|
+
? config.mcpServers.lorekit
|
|
514
|
+
: null;
|
|
515
|
+
if (!server || !isWebMcpServerEntry(server)) return { file, removed: false };
|
|
516
|
+
|
|
517
|
+
delete config.mcpServers.lorekit;
|
|
518
|
+
if (Object.keys(config.mcpServers).length === 0) delete config.mcpServers;
|
|
519
|
+
writeFileAtomic(file, JSON.stringify(config, null, 2) + '\n');
|
|
520
|
+
return { file, removed: true };
|
|
521
|
+
}
|
|
522
|
+
|
|
409
523
|
// Strip lorekit hook entries from every event, preserving non-lorekit hooks in
|
|
410
524
|
// the same groups. Prunes groups left with no hooks and events left with no
|
|
411
525
|
// groups. Returns the count removed; only writes when something changed.
|
|
@@ -475,18 +589,29 @@ export function tokenKind(token) {
|
|
|
475
589
|
// install), then the `mcp.endpoint` field in .lorekit.json (committable URL
|
|
476
590
|
// without token — safe for VCS), then env. `splitEndpoint` is passed in to
|
|
477
591
|
// avoid a circular import with mcp.mjs.
|
|
592
|
+
//
|
|
593
|
+
// A source that STORES a token wins outright (project beats global — closest
|
|
594
|
+
// scope), but a TOKENLESS source must NOT shadow a later source that has one.
|
|
595
|
+
// That shadowing is exactly what `install --global --mcp-json` created: it
|
|
596
|
+
// writes a committable, token-free project .mcp.json (auth via ${LOREKIT_TOKEN})
|
|
597
|
+
// AND the real token into ~/.claude.json. Returning early on the project entry
|
|
598
|
+
// left the local CLI, doctor, and hooks resolving `token: null` unless
|
|
599
|
+
// LOREKIT_TOKEN was exported — so a tokenless source is only remembered as an
|
|
600
|
+
// endpoint fallback, and the loop keeps looking for a stored token.
|
|
478
601
|
export function resolveProjectConnection(root, splitEndpoint) {
|
|
479
602
|
const sources = [readLorekitServer(root), readServerFromFile(mcpConfigPath(root, 'global'))];
|
|
603
|
+
let endpointOnly = null; // closest usable endpoint that carried no token (e.g. the web .mcp.json)
|
|
480
604
|
for (const configured of sources) {
|
|
481
|
-
if (configured
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
605
|
+
if (!configured || !configured.url) continue;
|
|
606
|
+
const { endpoint, token } = splitEndpoint(configured.url);
|
|
607
|
+
if (!endpoint || endpoint.includes('<project-ref>')) continue;
|
|
608
|
+
if (token) return { endpoint, token }; // a stored token wins; project (closest) beats global
|
|
609
|
+
if (!endpointOnly) endpointOnly = endpoint; // remember the closest tokenless source, keep looking
|
|
610
|
+
}
|
|
611
|
+
if (endpointOnly) {
|
|
612
|
+
// No source stored a token — the committable web .mcp.json is exactly this
|
|
613
|
+
// case. Fall back to the env token so the local CLI still authenticates.
|
|
614
|
+
return { endpoint: endpointOnly, token: process.env.LOREKIT_TOKEN || null };
|
|
490
615
|
}
|
|
491
616
|
|
|
492
617
|
// Fallback: `mcp.endpoint` in .lorekit.json — a committable URL without token.
|
package/src/dedupe.mjs
CHANGED
|
@@ -15,18 +15,30 @@ import { resolveProjectRoot, readLorekitJson } from './config.mjs';
|
|
|
15
15
|
import { deriveScope } from './scope.mjs';
|
|
16
16
|
import { resolveDenies } from './control.mjs';
|
|
17
17
|
import { resolveStores, remoteUnavailableReason } from './stores.mjs';
|
|
18
|
-
import { scopeList, gather, clusterDuplicates } from './lessons-view.mjs';
|
|
18
|
+
import { scopeList, gather, gatherStream, clusterDuplicates, clusterDuplicatesBlocked, DEFAULT_MAX } from './lessons-view.mjs';
|
|
19
19
|
import { log, heading, status, c } from './util.mjs';
|
|
20
20
|
|
|
21
21
|
const DEFAULT_THRESHOLD = 0.8;
|
|
22
|
+
// Maximum entries to accumulate before the token-blocking index becomes
|
|
23
|
+
// memory-prohibitive. Beyond this the user must narrow via --key-prefix /
|
|
24
|
+
// --since / --max.
|
|
25
|
+
const DEDUPE_POP_CAP = 2000;
|
|
22
26
|
|
|
23
|
-
//
|
|
27
|
+
// Smallest threshold the blocked clusterer accepts. `clusterDuplicatesBlocked`
|
|
28
|
+
// is provably equivalent to the oracle `clusterDuplicates` only for
|
|
29
|
+
// threshold > 0 (at 0 the oracle clusters even zero-overlap pairs, which the
|
|
30
|
+
// token-blocking sweep never generates). Any positive value below the smallest
|
|
31
|
+
// possible Jaccard behaves identically to 0+ while preserving that invariant,
|
|
32
|
+
// so we floor to a tiny epsilon rather than accept a literal 0.
|
|
33
|
+
const MIN_THRESHOLD = Number.EPSILON;
|
|
34
|
+
|
|
35
|
+
// Parse `--threshold` into a number in (0, 1]; anything unparseable or out of
|
|
24
36
|
// range falls back to the default (never a crash on bad input). Pure-ish helper.
|
|
25
37
|
export function parseThreshold(raw) {
|
|
26
38
|
if (raw === undefined || raw === true) return DEFAULT_THRESHOLD;
|
|
27
39
|
const n = Number(raw);
|
|
28
40
|
if (!Number.isFinite(n)) return DEFAULT_THRESHOLD;
|
|
29
|
-
return Math.min(1, Math.max(
|
|
41
|
+
return Math.min(1, Math.max(MIN_THRESHOLD, n));
|
|
30
42
|
}
|
|
31
43
|
|
|
32
44
|
// Read `dedupe.threshold` from .lorekit.json (non-throwing). Returns the
|
|
@@ -78,19 +90,93 @@ export async function dedupe(args) {
|
|
|
78
90
|
// Deny-wins section suppression, identical to the other read commands.
|
|
79
91
|
const { localDenied, remoteDenied } = resolveDenies(root, { env });
|
|
80
92
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
93
|
+
// `dedupe` defaults to full-scope survey. --max, --since, --key-prefix narrow
|
|
94
|
+
// the population. Population cap: 2000. Past it stop accumulating, warn, and
|
|
95
|
+
// surface a narrowing hint. Use token-blocking (`clusterDuplicatesBlocked`)
|
|
96
|
+
// for the one super-linear operation.
|
|
97
|
+
const surveyMax = args.max !== undefined ? Number(args.max) : DEFAULT_MAX;
|
|
98
|
+
const surveySince = args.since || undefined;
|
|
99
|
+
const surveyUntil = args.until || undefined;
|
|
100
|
+
const surveyKeyPrefix = args['key-prefix'] || undefined;
|
|
101
|
+
|
|
102
|
+
// Stream-accumulate entries up to DEDUPE_POP_CAP and note when capped.
|
|
103
|
+
async function streamAccumulate(store) {
|
|
104
|
+
const accumulated = [];
|
|
105
|
+
const errored = [];
|
|
106
|
+
let popCapped = false;
|
|
107
|
+
|
|
108
|
+
await gatherStream(store, scopes, {
|
|
109
|
+
max: surveyMax,
|
|
110
|
+
since: surveySince,
|
|
111
|
+
until: surveyUntil,
|
|
112
|
+
keyPrefix: surveyKeyPrefix,
|
|
113
|
+
onPage: ({ scope, entries }) => {
|
|
114
|
+
if (popCapped) return;
|
|
115
|
+
for (const e of entries) {
|
|
116
|
+
if (accumulated.length >= DEDUPE_POP_CAP) {
|
|
117
|
+
popCapped = true;
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
accumulated.push({ ...e, scope: e.scope ?? scope });
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
return { entries: accumulated, errored, popCapped };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const buildSection = async (store, local) => {
|
|
129
|
+
if (local) {
|
|
130
|
+
// Local store is already exhaustive; gather everything, then apply the
|
|
131
|
+
// SAME narrowing the remote path gets server-side (the local store's
|
|
132
|
+
// `list()` honours only scope/tags, so it can't narrow itself). Filtering
|
|
133
|
+
// here — before the population cap — is what makes `--key-prefix`/
|
|
134
|
+
// `--since`/`--until`/`--max` real offline instead of silent no-ops.
|
|
135
|
+
const flat = flatten(await gather(store, scopes));
|
|
136
|
+
let entries = flat.entries;
|
|
137
|
+
if (surveyKeyPrefix) {
|
|
138
|
+
entries = entries.filter(
|
|
139
|
+
(e) => typeof e.key === 'string' && e.key.startsWith(surveyKeyPrefix),
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
// `created_at` is compared as an ISO string; both bounds mirror the REST
|
|
143
|
+
// handler — inclusive `since`, exclusive `until` (the `[since, until)`
|
|
144
|
+
// window). An entry with no `created` timestamp is kept (never dropped by
|
|
145
|
+
// a bound it can't be judged against).
|
|
146
|
+
if (surveySince) entries = entries.filter((e) => !e.created || e.created >= surveySince);
|
|
147
|
+
if (surveyUntil) entries = entries.filter((e) => !e.created || e.created < surveyUntil);
|
|
148
|
+
// Silent `--max` cap first, then the memory-safety population cap that
|
|
149
|
+
// drives the "partial results" warning — mirroring the remote path where
|
|
150
|
+
// gatherStream's `max` and the DEDUPE_POP_CAP are distinct.
|
|
151
|
+
if (entries.length > surveyMax) entries = entries.slice(0, surveyMax);
|
|
152
|
+
let popCapped = false;
|
|
153
|
+
if (entries.length > DEDUPE_POP_CAP) {
|
|
154
|
+
entries = entries.slice(0, DEDUPE_POP_CAP);
|
|
155
|
+
popCapped = true;
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
available: true,
|
|
159
|
+
clusters: clusterDuplicatesBlocked(entries, threshold),
|
|
160
|
+
errored: flat.errored,
|
|
161
|
+
popCapped,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
const { entries, errored, popCapped } = await streamAccumulate(store);
|
|
165
|
+
return {
|
|
166
|
+
available: true,
|
|
167
|
+
clusters: clusterDuplicatesBlocked(entries, threshold),
|
|
168
|
+
errored,
|
|
169
|
+
popCapped,
|
|
170
|
+
};
|
|
171
|
+
};
|
|
86
172
|
|
|
87
173
|
const offlineSection = localDenied
|
|
88
174
|
? { available: false, reason: `disabled by deny constraint (${localDenied.source})` }
|
|
89
|
-
:
|
|
175
|
+
: await buildSection(local, true);
|
|
90
176
|
|
|
91
177
|
const remoteAvailable = !remoteDenied && remote.usable();
|
|
92
178
|
const remoteSection = remoteAvailable
|
|
93
|
-
?
|
|
179
|
+
? await buildSection(remote, false)
|
|
94
180
|
: {
|
|
95
181
|
available: false,
|
|
96
182
|
reason: remoteDenied
|
|
@@ -109,6 +195,13 @@ export async function dedupe(args) {
|
|
|
109
195
|
log(` scopes: ${scopes.join(' → ')}`);
|
|
110
196
|
log(` ${c.dim(`heuristic: Jaccard word-token overlap >= ${threshold} (not semantic)`)}`);
|
|
111
197
|
|
|
198
|
+
if (offlineSection.available && offlineSection.popCapped) {
|
|
199
|
+
log(` ${c.yellow('!')} population cap (${DEDUPE_POP_CAP}) reached for Offline — results are partial. Narrow with --key-prefix, --since, or --max.`);
|
|
200
|
+
}
|
|
201
|
+
if (remoteSection.available && remoteSection.popCapped) {
|
|
202
|
+
log(` ${c.yellow('!')} population cap (${DEDUPE_POP_CAP}) reached for Remote — results are partial. Narrow with --key-prefix, --since, or --max.`);
|
|
203
|
+
}
|
|
204
|
+
|
|
112
205
|
renderDedupeSection({ title: 'Offline' }, offlineSection);
|
|
113
206
|
renderDedupeSection(
|
|
114
207
|
{ title: 'Remote', subtitle: remoteAvailable ? connection.endpoint : undefined },
|
package/src/install.mjs
CHANGED
|
@@ -9,6 +9,9 @@ import {
|
|
|
9
9
|
skillInstallDir,
|
|
10
10
|
copyDir,
|
|
11
11
|
upsertMcpServer,
|
|
12
|
+
upsertWebMcpServer,
|
|
13
|
+
isMcpJsonGitIgnored,
|
|
14
|
+
WEB_TOKEN_ENV_VAR,
|
|
12
15
|
upsertClaudeHooks,
|
|
13
16
|
resolveHookRunner,
|
|
14
17
|
HOOK_MODES,
|
|
@@ -194,6 +197,12 @@ export async function install(args) {
|
|
|
194
197
|
// circuits). `--no-hooks` is skip-only and never justifies that bypass.
|
|
195
198
|
const hooksFlagExplicit = typeof args.hooks === 'string' && args.hooks.trim() !== '';
|
|
196
199
|
|
|
200
|
+
// `--mcp-json` writes a committable, env-var-referencing project .mcp.json for
|
|
201
|
+
// Claude Code on the web (see upsertWebMcpServer). It is an explicit intent to
|
|
202
|
+
// (re)write that file, so — like an explicit `--hooks` — it must reach the
|
|
203
|
+
// write step even when the scope is already fully installed.
|
|
204
|
+
const writeWebMcpJson = Boolean(args['mcp-json']);
|
|
205
|
+
|
|
197
206
|
heading('LoreKit install');
|
|
198
207
|
log(` project: ${c.dim(root)}`);
|
|
199
208
|
|
|
@@ -219,7 +228,7 @@ export async function install(args) {
|
|
|
219
228
|
|
|
220
229
|
const wiredEvents = installedHookEvents(root, scope);
|
|
221
230
|
|
|
222
|
-
if (currentState.isFullyInstalled && !force && !hooksFlagExplicit) {
|
|
231
|
+
if (currentState.isFullyInstalled && !force && !hooksFlagExplicit && !writeWebMcpJson) {
|
|
223
232
|
// Surface a clear, useful already-installed summary.
|
|
224
233
|
log('');
|
|
225
234
|
log(
|
|
@@ -345,8 +354,28 @@ export async function install(args) {
|
|
|
345
354
|
});
|
|
346
355
|
|
|
347
356
|
// 5. Wire the MCP config for the chosen scope.
|
|
357
|
+
// When `--mcp-json` is going to (re)write the PROJECT .mcp.json in its
|
|
358
|
+
// committable form and the scope target IS that same file (project scope),
|
|
359
|
+
// skip this write — otherwise we would write an embedded-token file only to
|
|
360
|
+
// overwrite it with the committable one a moment later. A global scope
|
|
361
|
+
// targets ~/.claude.json, a different file, so it always writes.
|
|
348
362
|
const remoteUrl = buildRemoteUrl(endpoint, token);
|
|
349
|
-
const
|
|
363
|
+
const scopeWriteOwnedByWeb = writeWebMcpJson && scope === 'project';
|
|
364
|
+
let file = null;
|
|
365
|
+
let existed = false;
|
|
366
|
+
if (!scopeWriteOwnedByWeb) {
|
|
367
|
+
({ file, existed } = upsertMcpServer(root, remoteUrl, scope));
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// 5a. `--mcp-json`: write the committable, env-var-referencing project
|
|
371
|
+
// .mcp.json for Claude Code on the web (see upsertWebMcpServer). Always
|
|
372
|
+
// the repo-root file, regardless of scope — that is the only MCP config a
|
|
373
|
+
// fresh web clone can see. The token is NOT embedded here: the file
|
|
374
|
+
// references `${LOREKIT_TOKEN}`, set as an environment secret in the web UI.
|
|
375
|
+
let webMcp = null;
|
|
376
|
+
if (writeWebMcpJson) {
|
|
377
|
+
webMcp = upsertWebMcpServer(root, endpoint, WEB_TOKEN_ENV_VAR);
|
|
378
|
+
}
|
|
350
379
|
|
|
351
380
|
// 5b. Hooks — the deterministic layer the Claude plugin adds on top of the
|
|
352
381
|
// skill, firing the shared `lorekit hook` engine (which reads the same
|
|
@@ -427,7 +456,30 @@ export async function install(args) {
|
|
|
427
456
|
: 'already up to date';
|
|
428
457
|
status(s.existed && s.written === 0 ? 'info' : 'pass', `skill ${s.name}`, `${skillState} → ${display(s.dest)}`);
|
|
429
458
|
}
|
|
430
|
-
|
|
459
|
+
if (file) {
|
|
460
|
+
status('pass', mcpLabel, `${existed ? 'updated' : 'created'} lorekit server → ${display(file)}`);
|
|
461
|
+
}
|
|
462
|
+
if (webMcp) {
|
|
463
|
+
// The committable web form: reported separately so it is clear this file is
|
|
464
|
+
// meant to be committed (unlike the embedded-token form) and that the token
|
|
465
|
+
// comes from an environment secret rather than the file.
|
|
466
|
+
const webPath = path.relative(root, webMcp.file) || webMcp.file;
|
|
467
|
+
status(
|
|
468
|
+
'pass',
|
|
469
|
+
'.mcp.json (web)',
|
|
470
|
+
`${webMcp.existed ? 'updated' : 'created'} committable lorekit server (auth via \${${WEB_TOKEN_ENV_VAR}}) → ${webPath}`,
|
|
471
|
+
);
|
|
472
|
+
// The web file only works if it is actually committed, and .mcp.json is
|
|
473
|
+
// commonly git-ignored (the embedded-token form is a secret). Warn when it
|
|
474
|
+
// is, so a fresh web clone silently missing the config is not a mystery.
|
|
475
|
+
if (isMcpJsonGitIgnored(root) === true) {
|
|
476
|
+
status(
|
|
477
|
+
'warn',
|
|
478
|
+
'.mcp.json (git)',
|
|
479
|
+
'git-ignored — un-ignore it (add `!.mcp.json` to .gitignore, or `git add -f .mcp.json`) or a fresh web clone will not see it',
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
431
483
|
|
|
432
484
|
if (!touchHooks) {
|
|
433
485
|
status(
|
|
@@ -465,8 +517,29 @@ export async function install(args) {
|
|
|
465
517
|
}
|
|
466
518
|
|
|
467
519
|
const kind = tokenKind(token);
|
|
468
|
-
if (
|
|
469
|
-
|
|
520
|
+
if (scopeWriteOwnedByWeb) {
|
|
521
|
+
// `--project --mcp-json`: the committable web .mcp.json is the ONLY config
|
|
522
|
+
// written, and it references ${LOREKIT_TOKEN} rather than storing a token —
|
|
523
|
+
// so NOTHING persists a credential here. Reporting the token's tier would
|
|
524
|
+
// imply it was configured; instead say it is not stored (and that a passed
|
|
525
|
+
// --token was therefore not persisted), pointing at the environment secret.
|
|
526
|
+
status(
|
|
527
|
+
'warn',
|
|
528
|
+
'token',
|
|
529
|
+
`not stored — the committable .mcp.json resolves \${${WEB_TOKEN_ENV_VAR}} at runtime; set it as an environment secret${
|
|
530
|
+
token ? ' (any --token you passed is not persisted)' : ''
|
|
531
|
+
}`,
|
|
532
|
+
);
|
|
533
|
+
} else if (kind === 'none') {
|
|
534
|
+
// A global --mcp-json with no token still writes the web file; explain the
|
|
535
|
+
// runtime resolution rather than implying reads/writes will fail.
|
|
536
|
+
status(
|
|
537
|
+
'warn',
|
|
538
|
+
'token',
|
|
539
|
+
webMcp
|
|
540
|
+
? `none stored — the committable .mcp.json resolves \${${WEB_TOKEN_ENV_VAR}} at runtime; set it as an environment secret`
|
|
541
|
+
: 'none configured — reads/writes will fail until a token is set',
|
|
542
|
+
);
|
|
470
543
|
} else if (kind === 'read-only') {
|
|
471
544
|
status('warn', 'token', 'read-only (lk_ro_*) — the skill can read memories but not write them');
|
|
472
545
|
} else if (kind === 'write-only') {
|
|
@@ -485,15 +558,26 @@ export async function install(args) {
|
|
|
485
558
|
}
|
|
486
559
|
|
|
487
560
|
log(`\n Next: ${c.cyan('npx @lorekit/cli doctor')} to verify the connection.`);
|
|
488
|
-
|
|
561
|
+
// Where the token lives, and how to treat that file, depends on which MCP
|
|
562
|
+
// configs were written. The web `.mcp.json` never holds the token, so a
|
|
563
|
+
// project scope that was owned by `--mcp-json` gets the web guidance, not the
|
|
564
|
+
// "keep it out of version control" note that applies to the embedded form.
|
|
565
|
+
if (webMcp) {
|
|
489
566
|
log(
|
|
490
567
|
` ${c.dim(
|
|
491
|
-
|
|
492
|
-
? 'Note: your token is stored in ~/.claude.json (used by every project) — keep that file private.'
|
|
493
|
-
: 'Note: your token is stored in .mcp.json — keep it out of version control.',
|
|
568
|
+
`For Claude Code on the web: commit .mcp.json (it is often git-ignored — un-ignore it first), then set ${WEB_TOKEN_ENV_VAR} as an environment secret. The file references the token, so it never holds the secret itself.`,
|
|
494
569
|
)}`,
|
|
495
570
|
);
|
|
496
571
|
}
|
|
572
|
+
if (token && scope === 'global') {
|
|
573
|
+
log(
|
|
574
|
+
` ${c.dim('Note: your token is stored in ~/.claude.json (used by every project) — keep that file private.')}`,
|
|
575
|
+
);
|
|
576
|
+
} else if (token && scope === 'project' && !scopeWriteOwnedByWeb) {
|
|
577
|
+
log(
|
|
578
|
+
` ${c.dim('Note: your token is stored in .mcp.json — keep it out of version control.')}`,
|
|
579
|
+
);
|
|
580
|
+
}
|
|
497
581
|
// Bounded, non-PII: which of the three presets this run landed on. Counting
|
|
498
582
|
// the `--no-hooks` FLAG (as telemetry already did) says nothing about what a
|
|
499
583
|
// user picks when actually asked, which is the whole point of the prompt.
|
package/src/lessons-view.mjs
CHANGED
|
@@ -65,6 +65,7 @@ export function normalizeEntry(e = {}) {
|
|
|
65
65
|
key: e.key ?? null,
|
|
66
66
|
value: e.value == null ? '' : String(e.value),
|
|
67
67
|
updated: e.updated ?? e.updated_at ?? null,
|
|
68
|
+
created: e.created ?? e.created_at ?? null,
|
|
68
69
|
tags,
|
|
69
70
|
kind: e.kind ?? inferred.kind ?? null,
|
|
70
71
|
host: e.host ?? inferred.host ?? null,
|
|
@@ -433,6 +434,8 @@ export function clusterDuplicates(entries = [], threshold = 0.8) {
|
|
|
433
434
|
// `store.list({scope})` contract. Returns ordered per-scope groups plus a total
|
|
434
435
|
// — a per-scope read failure is captured on the group, never thrown, so one bad
|
|
435
436
|
// scope can't abort the listing. `store` may be a local or remote store.
|
|
437
|
+
// Single-page only (the existing default behaviour). For full-scope traversal
|
|
438
|
+
// use `gatherStream` below.
|
|
436
439
|
export async function gather(store, scopes, filters = {}) {
|
|
437
440
|
// Parse the taxonomy filters into value sets. `filters` is passed to the store
|
|
438
441
|
// too (the remote narrows server-side); we ALSO post-filter the normalized
|
|
@@ -468,6 +471,208 @@ export async function gather(store, scopes, filters = {}) {
|
|
|
468
471
|
return { groups, total };
|
|
469
472
|
}
|
|
470
473
|
|
|
474
|
+
// Default maximum entries to survey in a full-scope traversal. High enough to
|
|
475
|
+
// cover almost every real scope; callers can raise or lower via `--max`.
|
|
476
|
+
export const DEFAULT_MAX = 5000;
|
|
477
|
+
|
|
478
|
+
// Page size used by `gatherStream` for remote stores.
|
|
479
|
+
const STREAM_PAGE_LIMIT = 100;
|
|
480
|
+
|
|
481
|
+
// Stream every page of entries across `scopes` from `store`, invoking
|
|
482
|
+
// `onPage({ scope, entries })` once per page as entries arrive. Designed for
|
|
483
|
+
// linear consumers (lint, stats, dedupe) that process page-by-page without
|
|
484
|
+
// accumulating all rows in memory.
|
|
485
|
+
//
|
|
486
|
+
// Options:
|
|
487
|
+
// max — hard cap on total surveyed entries (default: DEFAULT_MAX). When
|
|
488
|
+
// reached the walk stops and `capped` is set in the result.
|
|
489
|
+
// since — ISO date/timestamp lower bound, forwarded as `created_since`.
|
|
490
|
+
// until — ISO date/timestamp upper bound, forwarded as `created_until`.
|
|
491
|
+
// keyPrefix — key prefix filter, forwarded as `key_prefix`.
|
|
492
|
+
// onPage — callback invoked with `{ scope, entries }` per page.
|
|
493
|
+
//
|
|
494
|
+
// Returns `{ surveyed, total, capped, byScope }` where:
|
|
495
|
+
// surveyed — total entries delivered to `onPage`.
|
|
496
|
+
// total — exact aggregate from `listScopes()` when available (remote),
|
|
497
|
+
// else equal to `surveyed`.
|
|
498
|
+
// capped — true when `max` stopped the walk before all pages were read.
|
|
499
|
+
// byScope — `[{ scope, count, error }]` per scope (error=null on success).
|
|
500
|
+
//
|
|
501
|
+
// A per-scope read failure is captured on `byScope`, never thrown — mirrors
|
|
502
|
+
// `gather()`'s resilience contract. `LocalStore.list` returns everything in one
|
|
503
|
+
// page (no `nextCursor`), so the loop naturally terminates after one iteration
|
|
504
|
+
// offline and adds no overhead.
|
|
505
|
+
export async function gatherStream(store, scopes, {
|
|
506
|
+
max = DEFAULT_MAX,
|
|
507
|
+
since,
|
|
508
|
+
until,
|
|
509
|
+
keyPrefix,
|
|
510
|
+
onPage,
|
|
511
|
+
} = {}) {
|
|
512
|
+
const byScope = [];
|
|
513
|
+
let surveyed = 0;
|
|
514
|
+
let capped = false;
|
|
515
|
+
|
|
516
|
+
// Attempt to get exact aggregate counts for the remote store via listScopes().
|
|
517
|
+
// Only available on stores that implement listScopes(); degrades gracefully.
|
|
518
|
+
let scopeCountMap = null;
|
|
519
|
+
if (typeof store.listScopes === 'function') {
|
|
520
|
+
try {
|
|
521
|
+
const ls = await store.listScopes();
|
|
522
|
+
if (ls && ls.ok && Array.isArray(ls.scopes)) {
|
|
523
|
+
scopeCountMap = new Map(ls.scopes.map((s) => [s.scope, s.count]));
|
|
524
|
+
}
|
|
525
|
+
} catch { /* best-effort */ }
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
for (const scope of scopes) {
|
|
529
|
+
if (capped) {
|
|
530
|
+
byScope.push({ scope, count: 0, error: null });
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
let cursor = undefined;
|
|
534
|
+
let scopeCount = 0;
|
|
535
|
+
let scopeError = null;
|
|
536
|
+
|
|
537
|
+
// eslint-disable-next-line no-constant-condition
|
|
538
|
+
while (true) {
|
|
539
|
+
const remaining = max - surveyed;
|
|
540
|
+
if (remaining <= 0) {
|
|
541
|
+
capped = true;
|
|
542
|
+
break;
|
|
543
|
+
}
|
|
544
|
+
const pageLimit = Math.min(STREAM_PAGE_LIMIT, remaining);
|
|
545
|
+
let res;
|
|
546
|
+
try {
|
|
547
|
+
res = await store.list({
|
|
548
|
+
scope,
|
|
549
|
+
limit: pageLimit,
|
|
550
|
+
cursor,
|
|
551
|
+
...(since ? { created_since: since } : {}),
|
|
552
|
+
...(until ? { created_until: until } : {}),
|
|
553
|
+
...(keyPrefix ? { key_prefix: keyPrefix } : {}),
|
|
554
|
+
});
|
|
555
|
+
} catch (e) {
|
|
556
|
+
res = { ok: false, networkError: (e && e.message) || 'error' };
|
|
557
|
+
}
|
|
558
|
+
if (!res || res.ok === false) {
|
|
559
|
+
scopeError = describeError(res);
|
|
560
|
+
break;
|
|
561
|
+
}
|
|
562
|
+
const entries = (res.entries || []).map(normalizeEntry);
|
|
563
|
+
if (entries.length > 0) {
|
|
564
|
+
if (onPage) onPage({ scope, entries });
|
|
565
|
+
scopeCount += entries.length;
|
|
566
|
+
surveyed += entries.length;
|
|
567
|
+
}
|
|
568
|
+
// hasMore absent (local store) or false → done with this scope.
|
|
569
|
+
if (!res.hasMore || !res.nextCursor) break;
|
|
570
|
+
cursor = res.nextCursor;
|
|
571
|
+
// Safety: stop if we hit the cap after this page.
|
|
572
|
+
if (surveyed >= max) {
|
|
573
|
+
capped = true;
|
|
574
|
+
break;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
byScope.push({ scope, count: scopeCount, error: scopeError });
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Derive total: use the aggregate when available (remote), else surveyed.
|
|
581
|
+
let total = surveyed;
|
|
582
|
+
if (scopeCountMap) {
|
|
583
|
+
total = 0;
|
|
584
|
+
for (const scope of scopes) {
|
|
585
|
+
total += scopeCountMap.get(scope) ?? 0;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
return { surveyed, total, capped, byScope };
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Token-blocking near-duplicate clustering — a performance-bounded alternative
|
|
593
|
+
// to `clusterDuplicates`'s O(n²) all-pairs sweep. Uses an inverted index
|
|
594
|
+
// (token → indices) to generate only the candidate pairs that share at least
|
|
595
|
+
// one token, then applies the SAME Jaccard `>= threshold` + union-find logic as
|
|
596
|
+
// `clusterDuplicates`. The resulting clusters are provably identical:
|
|
597
|
+
//
|
|
598
|
+
// IF similarity(a, b) >= threshold > 0 THEN a and b share at least one token,
|
|
599
|
+
// so they WILL appear as a candidate pair in the inverted-index sweep.
|
|
600
|
+
//
|
|
601
|
+
// Keeping `clusterDuplicates` intact means equivalence is a testable property
|
|
602
|
+
// (AC-7) rather than a rewrite claim. Pure.
|
|
603
|
+
export function clusterDuplicatesBlocked(entries = [], threshold = 0.8) {
|
|
604
|
+
const items = entries.map((e, i) => ({
|
|
605
|
+
i,
|
|
606
|
+
scope: e.scope ?? null,
|
|
607
|
+
key: e.key ?? null,
|
|
608
|
+
tokens: tokenize(e.value),
|
|
609
|
+
}));
|
|
610
|
+
|
|
611
|
+
// Build inverted index: token → [item indices]
|
|
612
|
+
/** @type {Map<string, number[]>} */
|
|
613
|
+
const invertedIndex = new Map();
|
|
614
|
+
for (const item of items) {
|
|
615
|
+
for (const token of item.tokens) {
|
|
616
|
+
if (!invertedIndex.has(token)) invertedIndex.set(token, []);
|
|
617
|
+
invertedIndex.get(token).push(item.i);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// Generate candidate pairs: any two items sharing at least one token.
|
|
622
|
+
// Use a Set of encoded pair keys to avoid duplicates.
|
|
623
|
+
const candidatePairs = new Set();
|
|
624
|
+
for (const indices of invertedIndex.values()) {
|
|
625
|
+
for (let x = 0; x < indices.length; x += 1) {
|
|
626
|
+
for (let y = x + 1; y < indices.length; y += 1) {
|
|
627
|
+
const a = Math.min(indices[x], indices[y]);
|
|
628
|
+
const b = Math.max(indices[x], indices[y]);
|
|
629
|
+
candidatePairs.add(`${a}:${b}`);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Apply the SAME union-find as clusterDuplicates over candidate pairs only.
|
|
635
|
+
const parent = items.map((_, i) => i);
|
|
636
|
+
const find = (x) => {
|
|
637
|
+
while (parent[x] !== x) {
|
|
638
|
+
parent[x] = parent[parent[x]];
|
|
639
|
+
x = parent[x];
|
|
640
|
+
}
|
|
641
|
+
return x;
|
|
642
|
+
};
|
|
643
|
+
const pairs = [];
|
|
644
|
+
for (const pairKey of candidatePairs) {
|
|
645
|
+
const [a, b] = pairKey.split(':').map(Number);
|
|
646
|
+
const sim = similarity(items[a].tokens, items[b].tokens);
|
|
647
|
+
if (sim >= threshold) {
|
|
648
|
+
pairs.push({ a, b, sim });
|
|
649
|
+
parent[find(a)] = find(b);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Assemble clusters — identical logic to clusterDuplicates.
|
|
654
|
+
const byRoot = new Map();
|
|
655
|
+
for (const it of items) {
|
|
656
|
+
const r = find(it.i);
|
|
657
|
+
if (!byRoot.has(r)) byRoot.set(r, []);
|
|
658
|
+
byRoot.get(r).push(it);
|
|
659
|
+
}
|
|
660
|
+
const clusters = [];
|
|
661
|
+
for (const members of byRoot.values()) {
|
|
662
|
+
if (members.length < 2) continue;
|
|
663
|
+
const idx = new Set(members.map((m) => m.i));
|
|
664
|
+
const sims = pairs.filter((p) => idx.has(p.a) && idx.has(p.b)).map((p) => p.sim);
|
|
665
|
+
clusters.push({
|
|
666
|
+
members: members.map((m) => ({ scope: m.scope, key: m.key })),
|
|
667
|
+
size: members.length,
|
|
668
|
+
minSimilarity: sims.length ? Math.min(...sims) : threshold,
|
|
669
|
+
maxSimilarity: sims.length ? Math.max(...sims) : threshold,
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
clusters.sort((x, y) => y.size - x.size);
|
|
673
|
+
return clusters;
|
|
674
|
+
}
|
|
675
|
+
|
|
471
676
|
// Render one section (Offline or Remote). `section` is either
|
|
472
677
|
// { available:false, reason } → a graceful note, or
|
|
473
678
|
// { available:true, groups, total } → grouped lessons.
|
package/src/lint.mjs
CHANGED
|
@@ -15,7 +15,7 @@ import { resolveProjectRoot } from './config.mjs';
|
|
|
15
15
|
import { deriveScope } from './scope.mjs';
|
|
16
16
|
import { resolveDenies } from './control.mjs';
|
|
17
17
|
import { resolveStores, remoteUnavailableReason } from './stores.mjs';
|
|
18
|
-
import { scopeList, gather, lintGroups } from './lessons-view.mjs';
|
|
18
|
+
import { scopeList, gather, gatherStream, lintGroups, DEFAULT_MAX } from './lessons-view.mjs';
|
|
19
19
|
import { log, heading, status, c } from './util.mjs';
|
|
20
20
|
|
|
21
21
|
export async function lint(args) {
|
|
@@ -36,9 +36,47 @@ export async function lint(args) {
|
|
|
36
36
|
// Deny-wins section suppression, identical to the other read commands.
|
|
37
37
|
const { localDenied, remoteDenied } = resolveDenies(root, { env });
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
// `lint` defaults to full-scope survey (--all). Use gatherStream for remote
|
|
40
|
+
// to drain all pages; gather (single-page) still serves local store which is
|
|
41
|
+
// already exhaustive. --max, --since, --until are forwarded when provided.
|
|
42
|
+
const surveyMax = args.max !== undefined ? Number(args.max) : DEFAULT_MAX;
|
|
43
|
+
const surveySince = args.since || undefined;
|
|
44
|
+
const surveyUntil = args.until || undefined;
|
|
45
|
+
|
|
46
|
+
let offlineResult;
|
|
47
|
+
if (localDenied) {
|
|
48
|
+
offlineResult = { groups: [], total: 0 };
|
|
49
|
+
} else {
|
|
50
|
+
offlineResult = lintGroups(await gather(local, scopes));
|
|
51
|
+
}
|
|
52
|
+
|
|
40
53
|
const remoteAvailable = !remoteDenied && remote.usable();
|
|
41
|
-
|
|
54
|
+
let remoteResult;
|
|
55
|
+
if (!remoteAvailable) {
|
|
56
|
+
remoteResult = { groups: [], total: 0 };
|
|
57
|
+
} else {
|
|
58
|
+
// Stream all pages, accumulating entries per scope for linting.
|
|
59
|
+
const accumulated = new Map();
|
|
60
|
+
for (const scope of scopes) accumulated.set(scope, []);
|
|
61
|
+
await gatherStream(remote, scopes, {
|
|
62
|
+
max: surveyMax,
|
|
63
|
+
since: surveySince,
|
|
64
|
+
until: surveyUntil,
|
|
65
|
+
onPage: ({ scope, entries }) => {
|
|
66
|
+
const arr = accumulated.get(scope);
|
|
67
|
+
if (arr) for (const e of entries) arr.push(e);
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
// Build a gather()-shaped result so lintGroups can consume it unchanged.
|
|
71
|
+
const groups = [];
|
|
72
|
+
let total = 0;
|
|
73
|
+
for (const scope of scopes) {
|
|
74
|
+
const entries = accumulated.get(scope) || [];
|
|
75
|
+
total += entries.length;
|
|
76
|
+
groups.push({ scope, entries, error: null });
|
|
77
|
+
}
|
|
78
|
+
remoteResult = lintGroups({ groups, total });
|
|
79
|
+
}
|
|
42
80
|
|
|
43
81
|
const offlineSection = localDenied
|
|
44
82
|
? { available: false, reason: `disabled by deny constraint (${localDenied.source})` }
|
package/src/list.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import { resolveProjectRoot } from './config.mjs';
|
|
|
13
13
|
import { deriveScope } from './scope.mjs';
|
|
14
14
|
import { resolveDenies } from './control.mjs';
|
|
15
15
|
import { resolveStores, remoteUnavailableReason } from './stores.mjs';
|
|
16
|
-
import { scopeList, gather, renderSection } from './lessons-view.mjs';
|
|
16
|
+
import { scopeList, gather, gatherStream, renderSection, DEFAULT_MAX } from './lessons-view.mjs';
|
|
17
17
|
import { resolveAppBase, mostSpecificScope } from './deeplink-pure.mjs';
|
|
18
18
|
import { emitLink } from './link.mjs';
|
|
19
19
|
import { log, heading, c } from './util.mjs';
|
|
@@ -69,9 +69,54 @@ export async function list(args) {
|
|
|
69
69
|
// agent-facing control model enforces, honored here in the human read view.
|
|
70
70
|
const { localDenied, remoteDenied } = resolveDenies(root, { env });
|
|
71
71
|
|
|
72
|
+
// --all: drain all pages from the remote store (for large scopes). Default
|
|
73
|
+
// is single-page per scope (what `list` has always shown). The local store is
|
|
74
|
+
// always exhaustive regardless of --all.
|
|
75
|
+
const surveyAll = Boolean(args.all);
|
|
76
|
+
const surveyMax = args.max !== undefined ? Number(args.max) : DEFAULT_MAX;
|
|
77
|
+
const surveySince = args.since || undefined;
|
|
78
|
+
const surveyUntil = args.until || undefined;
|
|
79
|
+
|
|
80
|
+
// Taxonomy `keep` predicate mirroring `gather()`'s: `gatherStream` neither
|
|
81
|
+
// forwards `kind`/`host` to the store nor post-filters, so a `--all` drain
|
|
82
|
+
// must apply the same narrowing here or `list --all --kind X` would ignore X.
|
|
83
|
+
const wanted = (v) =>
|
|
84
|
+
v == null ? null : new Set(String(v).split(',').map((s) => s.trim()).filter(Boolean));
|
|
85
|
+
const kindSet = wanted(filters.kind);
|
|
86
|
+
const hostSet = wanted(filters.host);
|
|
87
|
+
const keep = (e) =>
|
|
88
|
+
(!kindSet || (e.kind != null && kindSet.has(e.kind))) &&
|
|
89
|
+
(!hostSet || (e.host != null && hostSet.has(e.host)));
|
|
90
|
+
|
|
72
91
|
const offline = localDenied ? { groups: [], total: 0 } : await gather(local, scopes, filters);
|
|
73
92
|
const remoteAvailable = !remoteDenied && remote.usable();
|
|
74
|
-
|
|
93
|
+
let remoteResult;
|
|
94
|
+
if (!remoteAvailable) {
|
|
95
|
+
remoteResult = { groups: [], total: 0 };
|
|
96
|
+
} else if (surveyAll) {
|
|
97
|
+
// Full drain: accumulate all pages into a groups-shaped result.
|
|
98
|
+
const accumulated = new Map();
|
|
99
|
+
for (const scope of scopes) accumulated.set(scope, []);
|
|
100
|
+
await gatherStream(remote, scopes, {
|
|
101
|
+
max: surveyMax,
|
|
102
|
+
since: surveySince,
|
|
103
|
+
until: surveyUntil,
|
|
104
|
+
onPage: ({ scope, entries }) => {
|
|
105
|
+
const arr = accumulated.get(scope);
|
|
106
|
+
if (arr) for (const e of entries) if (keep(e)) arr.push(e);
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
const groups = [];
|
|
110
|
+
let total = 0;
|
|
111
|
+
for (const scope of scopes) {
|
|
112
|
+
const entries = accumulated.get(scope) || [];
|
|
113
|
+
total += entries.length;
|
|
114
|
+
groups.push({ scope, entries, error: null });
|
|
115
|
+
}
|
|
116
|
+
remoteResult = { groups, total };
|
|
117
|
+
} else {
|
|
118
|
+
remoteResult = await gather(remote, scopes, filters);
|
|
119
|
+
}
|
|
75
120
|
|
|
76
121
|
const offlineSection = localDenied
|
|
77
122
|
? { available: false, reason: `disabled by deny constraint (${localDenied.source})` }
|
package/src/mcp-server.mjs
CHANGED
|
@@ -100,12 +100,31 @@ export const MEMORY_TOOL_DEFS = [
|
|
|
100
100
|
{
|
|
101
101
|
name: 'memory.list',
|
|
102
102
|
description: 'List memories for a scope',
|
|
103
|
-
inputSchema: {
|
|
103
|
+
inputSchema: {
|
|
104
|
+
type: 'object',
|
|
105
|
+
required: ['scope'],
|
|
106
|
+
properties: {
|
|
107
|
+
scope: { type: 'string' },
|
|
108
|
+
tags: { type: 'array', items: { type: 'string' } },
|
|
109
|
+
limit: { type: 'integer', minimum: 1, maximum: 100, default: 50 },
|
|
110
|
+
cursor: { type: 'string', description: 'Opaque cursor from a previous response\'s nextCursor. Omit to start from the first page.' },
|
|
111
|
+
},
|
|
112
|
+
},
|
|
104
113
|
},
|
|
105
114
|
{
|
|
106
115
|
name: 'memory.search',
|
|
107
116
|
description: 'Keyword search across memories',
|
|
108
|
-
inputSchema: {
|
|
117
|
+
inputSchema: {
|
|
118
|
+
type: 'object',
|
|
119
|
+
required: ['q'],
|
|
120
|
+
properties: {
|
|
121
|
+
q: { type: 'string' },
|
|
122
|
+
scopes: { type: 'array', items: { type: 'string' } },
|
|
123
|
+
tags: { type: 'array', items: { type: 'string' } },
|
|
124
|
+
limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
|
|
125
|
+
cursor: { type: 'string', description: 'Opaque cursor from a previous response\'s nextCursor. Omit to start from the first page.' },
|
|
126
|
+
},
|
|
127
|
+
},
|
|
109
128
|
},
|
|
110
129
|
{
|
|
111
130
|
name: 'memory.delete',
|
package/src/stats.mjs
CHANGED
|
@@ -20,7 +20,7 @@ import { resolveProjectRoot } from './config.mjs';
|
|
|
20
20
|
import { deriveScope } from './scope.mjs';
|
|
21
21
|
import { resolveDenies } from './control.mjs';
|
|
22
22
|
import { resolveStores, remoteUnavailableReason } from './stores.mjs';
|
|
23
|
-
import { scopeList, gather, tallyGroups } from './lessons-view.mjs';
|
|
23
|
+
import { scopeList, gather, tallyGroups, summarizeScopeInventory, filterScopeInventory } from './lessons-view.mjs';
|
|
24
24
|
import { log, heading, status, c } from './util.mjs';
|
|
25
25
|
|
|
26
26
|
export async function stats(args) {
|
|
@@ -44,9 +44,32 @@ export async function stats(args) {
|
|
|
44
44
|
|
|
45
45
|
const offlineTally = localDenied ? { perScope: [], total: 0 } : tallyGroups(await gather(local, scopes));
|
|
46
46
|
const remoteAvailable = !remoteDenied && remote.usable();
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
|
|
48
|
+
// Remote counts come from the GET /memories/scopes Postgres aggregate
|
|
49
|
+
// (RemoteStore.listScopes), which is exact at any scale — never row-draining.
|
|
50
|
+
// This is the fix for `stats`=50 vs `scopes`=487: the old code used
|
|
51
|
+
// gather() which returned only the first page from the remote store.
|
|
52
|
+
let remoteTally;
|
|
53
|
+
if (!remoteAvailable) {
|
|
54
|
+
remoteTally = { perScope: [], total: 0 };
|
|
55
|
+
} else {
|
|
56
|
+
const scopesRes = await remote.listScopes();
|
|
57
|
+
if (scopesRes && scopesRes.ok && Array.isArray(scopesRes.scopes)) {
|
|
58
|
+
// Filter to only the applicable scopes; total the filtered set.
|
|
59
|
+
const filtered = filterScopeInventory(scopesRes.scopes, null).filter(
|
|
60
|
+
(s) => scopes.includes(s.scope),
|
|
61
|
+
);
|
|
62
|
+
const perScope = scopes.map((scope) => {
|
|
63
|
+
const match = filtered.find((s) => s.scope === scope);
|
|
64
|
+
return { scope, count: match ? match.count : 0, error: null };
|
|
65
|
+
});
|
|
66
|
+
const total = perScope.reduce((n, s) => n + s.count, 0);
|
|
67
|
+
remoteTally = { perScope, total };
|
|
68
|
+
} else {
|
|
69
|
+
// Fallback when listScopes() is unavailable/errors — single-page gather.
|
|
70
|
+
remoteTally = tallyGroups(await gather(remote, scopes));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
50
73
|
|
|
51
74
|
const offlineSection = localDenied
|
|
52
75
|
? { available: false, reason: `disabled by deny constraint (${localDenied.source})` }
|
package/src/store/remote.mjs
CHANGED
|
@@ -58,19 +58,29 @@ class RemoteStore {
|
|
|
58
58
|
|
|
59
59
|
// ── Memory operations → REST ──────────────────────────────────────────────
|
|
60
60
|
|
|
61
|
-
async list({ scope, tags, kind, host, limit } = {}) {
|
|
61
|
+
async list({ scope, tags, kind, host, limit, cursor, created_since, created_until, key_prefix } = {}) {
|
|
62
62
|
const p = new URLSearchParams();
|
|
63
63
|
if (scope) p.set('scope', scope);
|
|
64
64
|
if (tags?.length) p.set('tags', Array.isArray(tags) ? tags.join(',') : tags);
|
|
65
65
|
if (kind) p.set('kind', Array.isArray(kind) ? kind.join(',') : kind);
|
|
66
66
|
if (host) p.set('host', Array.isArray(host) ? host.join(',') : host);
|
|
67
67
|
if (limit) p.set('limit', String(limit));
|
|
68
|
+
if (cursor) p.set('cursor', cursor);
|
|
69
|
+
if (created_since) p.set('created_since', created_since);
|
|
70
|
+
if (created_until) p.set('created_until', created_until);
|
|
71
|
+
if (key_prefix) p.set('key_prefix', key_prefix);
|
|
68
72
|
const res = await this._rest(`/memories?${p}`);
|
|
69
73
|
if (!res.ok) return { ok: false, error: res.error, networkError: res.networkError };
|
|
70
|
-
|
|
74
|
+
const data = res.data ?? {};
|
|
75
|
+
return {
|
|
76
|
+
ok: true,
|
|
77
|
+
entries: data.entries ?? [],
|
|
78
|
+
hasMore: data.hasMore ?? false,
|
|
79
|
+
nextCursor: data.nextCursor ?? null,
|
|
80
|
+
};
|
|
71
81
|
}
|
|
72
82
|
|
|
73
|
-
async search({ q, scopes, tags } = {}) {
|
|
83
|
+
async search({ q, scopes, tags, limit, cursor } = {}) {
|
|
74
84
|
// A list of terms collapses into ONE `websearch` query joined by `OR`, so a
|
|
75
85
|
// multi-term failure lookup is a single round-trip (the server FTS ORs them
|
|
76
86
|
// and stems each). `failureQuery` distils terms to `[a-z0-9]+` tokens, so no
|
|
@@ -80,9 +90,17 @@ class RemoteStore {
|
|
|
80
90
|
if (query) body.q = query;
|
|
81
91
|
if (scopes?.length) body.scopes = scopes;
|
|
82
92
|
if (tags?.length) body.tags = tags;
|
|
93
|
+
if (limit) body.limit = limit;
|
|
94
|
+
if (cursor) body.cursor = cursor;
|
|
83
95
|
const res = await this._rest('/memories/search', { method: 'POST', body });
|
|
84
96
|
if (!res.ok) return { ok: false, error: res.error, networkError: res.networkError };
|
|
85
|
-
|
|
97
|
+
const data = res.data ?? {};
|
|
98
|
+
return {
|
|
99
|
+
ok: true,
|
|
100
|
+
entries: data.entries ?? [],
|
|
101
|
+
hasMore: data.hasMore ?? false,
|
|
102
|
+
nextCursor: data.nextCursor ?? null,
|
|
103
|
+
};
|
|
86
104
|
}
|
|
87
105
|
|
|
88
106
|
async read({ scope, key } = {}) {
|
package/src/uninstall.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
resolveProjectRoot,
|
|
10
10
|
removeSkill,
|
|
11
11
|
removeMcpServer,
|
|
12
|
+
removeWebMcpServer,
|
|
12
13
|
removeClaudeHooks,
|
|
13
14
|
homeDir,
|
|
14
15
|
} from './config.mjs';
|
|
@@ -56,6 +57,15 @@ export async function uninstall(args) {
|
|
|
56
57
|
step: attempt(() => removeSkill(root, scope, skill.name)),
|
|
57
58
|
}));
|
|
58
59
|
const mcp = attempt(() => removeMcpServer(root, scope));
|
|
60
|
+
// `install --global --mcp-json` writes the committable project .mcp.json in
|
|
61
|
+
// ADDITION to ~/.claude.json, so a GLOBAL uninstall must also clear that
|
|
62
|
+
// entry or it orphans a lorekit server pointing at a now-removed setup. A
|
|
63
|
+
// project uninstall already targets .mcp.json via `mcp` above, so this is
|
|
64
|
+
// global-only. `removeWebMcpServer` removes ONLY the committable web form, so
|
|
65
|
+
// it never deletes an unrelated embedded-token `install --project` entry a
|
|
66
|
+
// user set up separately. Surgical and idempotent: no web file ⇒ a quiet
|
|
67
|
+
// "nothing to remove", other servers in the file are preserved.
|
|
68
|
+
const webMcp = scope === 'global' ? attempt(() => removeWebMcpServer(root)) : null;
|
|
59
69
|
const hooks = attempt(() => removeClaudeHooks(root, scope));
|
|
60
70
|
|
|
61
71
|
// Global paths shown relative to ~; project paths repo-relative.
|
|
@@ -74,15 +84,26 @@ export async function uninstall(args) {
|
|
|
74
84
|
done: (r) => `lorekit server removed → ${display(r.file)}`,
|
|
75
85
|
noop: 'no lorekit server entry — nothing to remove',
|
|
76
86
|
});
|
|
87
|
+
if (webMcp) {
|
|
88
|
+
// Always the repo-relative project path, even under a global uninstall.
|
|
89
|
+
report(webMcp, '.mcp.json (web)', {
|
|
90
|
+
done: (r) => `lorekit server removed → ${path.relative(root, r.file) || r.file}`,
|
|
91
|
+
noop: 'no committable project .mcp.json — nothing to remove',
|
|
92
|
+
});
|
|
93
|
+
}
|
|
77
94
|
report(hooks, 'hooks', {
|
|
78
95
|
done: (r) => `${r.removed} removed → ${display(r.file)}`,
|
|
79
96
|
noop: 'no lorekit hooks — nothing to remove',
|
|
80
97
|
});
|
|
81
98
|
|
|
82
99
|
const skillStepList = skillSteps.map((s) => s.step);
|
|
83
|
-
const
|
|
100
|
+
const webSteps = webMcp ? [webMcp] : [];
|
|
101
|
+
const failed = [...skillStepList, mcp, ...webSteps, hooks].some((s) => !s.ok);
|
|
84
102
|
const any =
|
|
85
|
-
(skillStepList.some((s) => s.result?.removed) ||
|
|
103
|
+
(skillStepList.some((s) => s.result?.removed) ||
|
|
104
|
+
mcp.result?.removed ||
|
|
105
|
+
webMcp?.result?.removed ||
|
|
106
|
+
hooks.result?.removed) && true;
|
|
86
107
|
|
|
87
108
|
if (failed) {
|
|
88
109
|
log(`\n ${c.dim('Some items could not be removed and were left untouched — see above.')}`);
|