@lorekit/cli 1.53.0 → 1.55.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 +49 -4
- package/bin/lorekit.mjs +200 -92
- package/package.json +1 -1
- package/src/commands.mjs +112 -0
- package/src/control.mjs +15 -5
- package/src/doctor.mjs +18 -0
- package/src/mcp-server.mjs +68 -181
- package/src/mcp.mjs +25 -1
- package/src/purge.mjs +191 -0
- package/src/store/remote.mjs +80 -1
- package/src/surfaces.generated.mjs +516 -0
- package/src/telemetry-identity.mjs +276 -0
- package/src/telemetry.mjs +167 -10
package/README.md
CHANGED
|
@@ -486,6 +486,39 @@ lesson link, `search foo --link` → `/lore?q="foo"` (+ scope), and `list --link
|
|
|
486
486
|
multi-scope `list`/`tree` view maps to its primary scope. (The same JSON-encoded
|
|
487
487
|
links now back the hooks' write-confirmation and retrospective nudges.)
|
|
488
488
|
|
|
489
|
+
### `lorekit purge` / `lorekit purge-expired`
|
|
490
|
+
|
|
491
|
+
The two maintenance sweeps. Both permanently delete rows, and neither can be undone.
|
|
492
|
+
|
|
493
|
+
```bash
|
|
494
|
+
lorekit purge --retention-days 30 --yes # hard-delete archived lore older than 30 days
|
|
495
|
+
lorekit purge-expired --yes # hard-delete every TTL-expired memory
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
`purge` removes **archived** memories past a retention window — archived lore is
|
|
499
|
+
hidden from reads but recoverable with `lorekit restore`, so this is the step that
|
|
500
|
+
makes it unrecoverable. `purge-expired` removes memories whose `ttl_days` window
|
|
501
|
+
has passed. `--retention-days` accepts 1–365 and defaults to 30; it is validated
|
|
502
|
+
before any request, so an out-of-range or non-integer value fails immediately
|
|
503
|
+
rather than costing a round trip.
|
|
504
|
+
|
|
505
|
+
**Remote only.** They sweep server-side state and the offline store has no
|
|
506
|
+
equivalent operation, so `--local` is refused with a message rather than quietly
|
|
507
|
+
doing nothing.
|
|
508
|
+
|
|
509
|
+
**Confirmation, not a dry run.** There is no `--dry-run`, and that is not an
|
|
510
|
+
omission: the purge RPCs return their count only *after* deleting, and the REST
|
|
511
|
+
dry-run header stops before the write with nothing to preview — so "would purge
|
|
512
|
+
N" cannot be answered honestly. Instead an interactive terminal is prompted, and
|
|
513
|
+
**`--yes` is required whenever there is nobody to ask** (a pipe, CI, or `--json`).
|
|
514
|
+
An unattended agent cannot purge by omission.
|
|
515
|
+
|
|
516
|
+
**Scoped tokens are refused.** A token restricted to specific scopes cannot run
|
|
517
|
+
an account-wide sweep — there is no scope to check and no result set to narrow.
|
|
518
|
+
The server's refusal is printed verbatim with a one-line next step; the CLI makes
|
|
519
|
+
exactly one request and never retries, splits the sweep, or re-scopes around it.
|
|
520
|
+
Use an unscoped `lk_rw_*` / `lk_wo_*` token for maintenance.
|
|
521
|
+
|
|
489
522
|
### `lorekit hook`
|
|
490
523
|
|
|
491
524
|
The **shared hook engine** behind the Claude Code / Cursor / Codex plugins.
|
|
@@ -519,9 +552,20 @@ It speaks JSON-RPC 2.0 over newline-delimited stdin/stdout (the MCP stdio
|
|
|
519
552
|
transport, hand-rolled — zero dependencies) and is **not run by hand**: only
|
|
520
553
|
JSON-RPC frames reach stdout. It serves whatever mode resolves — `local` serves
|
|
521
554
|
the `.lorekit/` files directly, `remote` passes calls through to the hosted
|
|
522
|
-
endpoint, and `off` advertises no tools.
|
|
523
|
-
|
|
524
|
-
|
|
555
|
+
endpoint, and `off` advertises no tools.
|
|
556
|
+
|
|
557
|
+
Tools advertised are **derived from the canonical tool catalog**
|
|
558
|
+
(`packages/schemas/src/tool-catalog.ts`) rather than declared here, so this
|
|
559
|
+
server and the hosted one describe each operation identically. In `local` and
|
|
560
|
+
`remote` mode that is `memory.write`, `memory.read`, `memory.list`,
|
|
561
|
+
`memory.search`, `memory.delete`, `memory.archive`, `memory.restore` and
|
|
562
|
+
`memory.scopes`, plus `org.create`, `org.list`, `org.rename` and `org.delete`
|
|
563
|
+
(which always route to the hosted API). `off` advertises the `org.*` tools only.
|
|
564
|
+
|
|
565
|
+
An operation the catalog declares but this server cannot back — `memory.purge`,
|
|
566
|
+
`memory.purge_expired` and `memory.list_archived` — carries a recorded reason in
|
|
567
|
+
the catalog's `surfaces.localMcpExempt` field, so the gap is a stated decision
|
|
568
|
+
rather than an omission.
|
|
525
569
|
|
|
526
570
|
Wire it into `.mcp.json` as an alternative to the `mcp-remote <url>` transport —
|
|
527
571
|
this variant needs no endpoint or token for local mode:
|
|
@@ -952,12 +996,13 @@ also returns their headroom against the plan's memory cap.
|
|
|
952
996
|
| `--mcp-json` | Also write a committable project `.mcp.json` (auth via `${LOREKIT_TOKEN}`, no embedded token) for Claude Code on the web (`install`) |
|
|
953
997
|
| `--force` | Overwrite existing skill files (`install`) |
|
|
954
998
|
| `--deep` | Write/read/delete round-trip (`doctor`) |
|
|
955
|
-
| `--json` | Machine-readable output (`list` / `search` / `show` / `stats` / `scopes` / `diff` / `tree` / `lint` / `dedupe` / `link`) |
|
|
999
|
+
| `--json` | Machine-readable output (`list` / `search` / `show` / `stats` / `scopes` / `diff` / `tree` / `lint` / `dedupe` / `link` / `purge` / `purge-expired`) |
|
|
956
1000
|
| `--scope <scope>` | Restrict to a single scope (`list` / `search` / `stats` / `diff` / `tree` / `lint` / `dedupe` / `link`; default: all applicable). For `scopes` it is a **substring filter** over the inventory. On `show` / `write` it **names** the scope, overriding the positional |
|
|
957
1001
|
| `--key <key>` | Name the key outright (`show` / `write` / `link`) — the way to address a key that itself contains `::` |
|
|
958
1002
|
| `--link` | Print the equivalent dashboard deep-link URL instead of running (`show` / `search` / `list` / `tree`) |
|
|
959
1003
|
| `--base <url>` | Dashboard base URL for deep links (`link` / `--link`; else `LOREKIT_APP_URL`, default `https://lorekit.io`) |
|
|
960
1004
|
| `--threshold <0..1>` | Duplicate-similarity cutoff (`dedupe`; default `0.8`) |
|
|
1005
|
+
| `--retention-days <1..365>` | Only purge archived memories older than this (`purge`; default `30`, derived from the tool catalog) |
|
|
961
1006
|
| `--adapter <name>` | Host framework for `hook`: `claude` / `cursor` / `codex` |
|
|
962
1007
|
| `--event <name>` | Host hook event for `hook` (else read from the stdin payload) |
|
|
963
1008
|
| `-h, --help` | Help |
|
package/bin/lorekit.mjs
CHANGED
|
@@ -3,26 +3,14 @@
|
|
|
3
3
|
import process from 'node:process';
|
|
4
4
|
import { readFileSync } from 'node:fs';
|
|
5
5
|
import { parseArgs, log, err, c } from '../src/util.mjs';
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import { stats } from '../src/stats.mjs';
|
|
15
|
-
import { scopes } from '../src/scopes.mjs';
|
|
16
|
-
import { diff } from '../src/diff.mjs';
|
|
17
|
-
import { tree } from '../src/tree.mjs';
|
|
18
|
-
import { lint } from '../src/lint.mjs';
|
|
19
|
-
import { dedupe } from '../src/dedupe.mjs';
|
|
20
|
-
import { link } from '../src/link.mjs';
|
|
21
|
-
import { hook } from '../src/hook.mjs';
|
|
22
|
-
import { migrate } from '../src/migrate.mjs';
|
|
23
|
-
import { bootstrap } from '../src/bootstrap.mjs';
|
|
24
|
-
import { mcpServer } from '../src/mcp-server.mjs';
|
|
25
|
-
import { traceCommand } from '../src/telemetry.mjs';
|
|
6
|
+
// Commands, their handlers, aliases and dispatch properties all come from one
|
|
7
|
+
// registry — see ../src/commands.mjs for why membership lives there and the
|
|
8
|
+
// help prose stays here.
|
|
9
|
+
import { COMMANDS_BY_NAME, STRICT_FLAG_COMMANDS, COMMAND_ALIASES } from '../src/commands.mjs';
|
|
10
|
+
// Catalog-derived, so the default this help PROMISES is the one the server
|
|
11
|
+
// applies — see src/surfaces.generated.mjs.
|
|
12
|
+
import { PURGE_RETENTION_DAYS_DEFAULT } from '../src/surfaces.generated.mjs';
|
|
13
|
+
import { traceCommand, meterCommand } from '../src/telemetry.mjs';
|
|
26
14
|
import { loadDotEnv } from '../src/dotenv.mjs';
|
|
27
15
|
|
|
28
16
|
// Read the version from package.json so it always matches the published
|
|
@@ -92,6 +80,15 @@ ${c.bold('Commands')}
|
|
|
92
80
|
Explorer (--q / --owner / --tags / --range / --archived);
|
|
93
81
|
--base or LOREKIT_APP_URL override the dashboard host. --json. Pipe it:
|
|
94
82
|
lorekit link | pbcopy.
|
|
83
|
+
purge Permanently delete ARCHIVED memories older than --retention-days
|
|
84
|
+
(default ${PURGE_RETENTION_DAYS_DEFAULT}, range 1-365). Remote only, account-wide and
|
|
85
|
+
IRREVERSIBLE: prompts for confirmation, and requires --yes when
|
|
86
|
+
there is no terminal to prompt (a pipe, CI, or --json). A token
|
|
87
|
+
restricted to specific scopes is refused by the server.
|
|
88
|
+
purge-expired
|
|
89
|
+
Permanently delete every TTL-EXPIRED memory. Same posture as
|
|
90
|
+
purge: remote only, account-wide, irreversible, --yes required
|
|
91
|
+
non-interactively. Takes no options.
|
|
95
92
|
bootstrap Apply the BYOD schema to a user-supplied Supabase database.
|
|
96
93
|
Only needed when using LOREKIT_STORAGE_URL / LOREKIT_STORAGE_ANON_KEY.
|
|
97
94
|
See docs/byod.md for setup instructions.
|
|
@@ -122,6 +119,9 @@ ${c.bold('Options')}
|
|
|
122
119
|
--link Print the equivalent dashboard deep-link URL instead of running (show / search / list / tree)
|
|
123
120
|
--base <url> Dashboard base URL for deep links (link / --link; else LOREKIT_APP_URL, default https://lorekit.io)
|
|
124
121
|
--threshold <0..1> Duplicate-similarity cutoff (dedupe; default 0.8)
|
|
122
|
+
--retention-days <1..365>
|
|
123
|
+
Only purge archived memories older than this (purge;
|
|
124
|
+
default ${PURGE_RETENTION_DAYS_DEFAULT})
|
|
125
125
|
--from <path> Source store to migrate from (migrate)
|
|
126
126
|
--to <dest> Migration destination: home | project | remote (migrate;
|
|
127
127
|
default routes each entry by scope across the local tiers)
|
|
@@ -645,6 +645,149 @@ ${c.bold('Examples')}
|
|
|
645
645
|
npx @lorekit/cli migrate --from .lorekit --to remote # preview the push
|
|
646
646
|
npx @lorekit/cli migrate --from .lorekit --to remote --yes # push local lore up
|
|
647
647
|
`,
|
|
648
|
+
archive: `${c.bold('lorekit archive')} — hide a memory without losing it
|
|
649
|
+
|
|
650
|
+
${c.bold('Usage')}
|
|
651
|
+
lorekit archive <scope::key>
|
|
652
|
+
lorekit archive <scope> <key>
|
|
653
|
+
lorekit archive --scope <scope> --key <key>
|
|
654
|
+
|
|
655
|
+
Soft-archives one memory: it stops appearing in reads and in the hooks' injected
|
|
656
|
+
context, but it is still there and ${c.cyan('lorekit restore')} brings it back. Reach for
|
|
657
|
+
this rather than ${c.cyan('delete')} when a lesson has stopped being true — the record of
|
|
658
|
+
having learned it is usually worth keeping.
|
|
659
|
+
|
|
660
|
+
Addresses a memory the same three ways ${c.cyan('write')} / ${c.cyan('show')} do, and picks a store with
|
|
661
|
+
the same precedence (remote when usable, else local; ${c.cyan('--remote')} / ${c.cyan('--local')} force it).
|
|
662
|
+
|
|
663
|
+
${c.bold('Scoped API tokens')}
|
|
664
|
+
Server-side this is scope-authorized: a token restricted to a scope may archive
|
|
665
|
+
every writer's row in that scope, while an unscoped token may only archive its
|
|
666
|
+
own. A no-match is reported as not-found rather than a silent success.
|
|
667
|
+
|
|
668
|
+
${c.bold('Options')}
|
|
669
|
+
--scope <scope> Name the scope explicitly
|
|
670
|
+
--key <key> Name the key explicitly — for a key containing \`::\`
|
|
671
|
+
--remote / --local Force a store instead of the usual precedence
|
|
672
|
+
--json Machine-readable result
|
|
673
|
+
`,
|
|
674
|
+
|
|
675
|
+
delete: `${c.bold('lorekit delete')} — archive a memory, or destroy it with --force ${c.dim('(alias: rm)')}
|
|
676
|
+
|
|
677
|
+
${c.bold('Usage')}
|
|
678
|
+
lorekit delete <scope::key> # soft-archive (reversible)
|
|
679
|
+
lorekit delete <scope::key> --force # hard-delete (unrecoverable)
|
|
680
|
+
|
|
681
|
+
Without ${c.cyan('--force')} this is exactly ${c.cyan('lorekit archive')} — the memory is hidden and
|
|
682
|
+
restorable. With ${c.cyan('--force')} the row is gone and no restore can bring it back.
|
|
683
|
+
|
|
684
|
+
${c.bold('Scoped API tokens')}
|
|
685
|
+
Same scope authorization as ${c.cyan('archive')}: a scope-restricted token may act on any
|
|
686
|
+
writer's row within its scopes, an unscoped one only on its own, and a 0-row
|
|
687
|
+
result is reported as not-found rather than as success.
|
|
688
|
+
|
|
689
|
+
${c.bold('Options')}
|
|
690
|
+
--force Hard-delete instead of archiving. Unrecoverable
|
|
691
|
+
--scope <scope> Name the scope explicitly
|
|
692
|
+
--key <key> Name the key explicitly — for a key containing \`::\`
|
|
693
|
+
--remote / --local Force a store instead of the usual precedence
|
|
694
|
+
--json Machine-readable result
|
|
695
|
+
`,
|
|
696
|
+
|
|
697
|
+
restore: `${c.bold('lorekit restore')} — bring an archived memory back
|
|
698
|
+
|
|
699
|
+
${c.bold('Usage')}
|
|
700
|
+
lorekit restore <scope::key>
|
|
701
|
+
lorekit restore <scope> <key>
|
|
702
|
+
|
|
703
|
+
Un-archives a memory so it appears in reads again. The counterpart to
|
|
704
|
+
${c.cyan('archive')} (and to ${c.cyan('delete')} without ${c.cyan('--force')}). A memory that was hard-deleted
|
|
705
|
+
cannot be restored — there is nothing left to restore.
|
|
706
|
+
|
|
707
|
+
Restoring something that is already active is not an error; it reports that
|
|
708
|
+
nothing changed.
|
|
709
|
+
|
|
710
|
+
${c.bold('Options')}
|
|
711
|
+
--scope <scope> Name the scope explicitly
|
|
712
|
+
--key <key> Name the key explicitly — for a key containing \`::\`
|
|
713
|
+
--remote / --local Force a store instead of the usual precedence
|
|
714
|
+
--json Machine-readable result
|
|
715
|
+
`,
|
|
716
|
+
|
|
717
|
+
bootstrap: `${c.bold('lorekit bootstrap')} — apply the LoreKit schema to your own Supabase database
|
|
718
|
+
|
|
719
|
+
${c.bold('Usage')}
|
|
720
|
+
lorekit bootstrap [--yes]
|
|
721
|
+
|
|
722
|
+
For BYOD ("bring your own database") only: creates the tables, functions and
|
|
723
|
+
policies LoreKit needs in a Supabase project you control, so lore never leaves
|
|
724
|
+
your infrastructure. You only need this if you are pointing the CLI at your own
|
|
725
|
+
database via ${c.cyan('LOREKIT_STORAGE_URL')} / ${c.cyan('LOREKIT_STORAGE_ANON_KEY')} — the hosted
|
|
726
|
+
service and the offline store both need nothing here.
|
|
727
|
+
|
|
728
|
+
See ${c.cyan('docs/byod.md')} for the full setup, including which key to use and what the
|
|
729
|
+
schema contains.
|
|
730
|
+
|
|
731
|
+
${c.bold('Options')}
|
|
732
|
+
-y, --yes Apply without prompting
|
|
733
|
+
--endpoint <url> Target endpoint
|
|
734
|
+
--token <token> Token for the target
|
|
735
|
+
`,
|
|
736
|
+
|
|
737
|
+
purge: `${c.bold('lorekit purge')} — permanently delete archived memories past a retention window
|
|
738
|
+
|
|
739
|
+
${c.bold('Usage')}
|
|
740
|
+
lorekit purge [--retention-days <1..365>] [--yes] [--json]
|
|
741
|
+
|
|
742
|
+
Hard-deletes ARCHIVED memories older than the retention window. Archived lore is
|
|
743
|
+
hidden from reads but recoverable with ${c.cyan('lorekit restore')} — this is what makes it
|
|
744
|
+
unrecoverable, so it is the one step in the lifecycle that cannot be walked back.
|
|
745
|
+
|
|
746
|
+
${c.bold('Remote only')}
|
|
747
|
+
It sweeps server-side state; the offline store has no equivalent, so ${c.cyan('--local')} is
|
|
748
|
+
refused rather than quietly doing nothing.
|
|
749
|
+
|
|
750
|
+
${c.bold('Confirmation')}
|
|
751
|
+
There is no dry run: the purge RPC returns its count only AFTER deleting, so
|
|
752
|
+
"would purge N" cannot be answered honestly. Instead you are asked to confirm,
|
|
753
|
+
and ${c.cyan('--yes')} is REQUIRED when there is no terminal to ask (a pipe, CI, or --json)
|
|
754
|
+
— an unattended agent must not be able to purge by omission.
|
|
755
|
+
|
|
756
|
+
${c.bold('Scoped tokens')}
|
|
757
|
+
A token restricted to specific scopes is refused by the server: an account-wide
|
|
758
|
+
sweep has no scope to check and no result set to narrow. Use an unscoped token
|
|
759
|
+
for maintenance.
|
|
760
|
+
|
|
761
|
+
${c.bold('Options')}
|
|
762
|
+
--retention-days <n> Only purge archived memories older than n days
|
|
763
|
+
(1-365, default ${PURGE_RETENTION_DAYS_DEFAULT})
|
|
764
|
+
-y, --yes Confirm; required when non-interactive
|
|
765
|
+
--json Machine-readable result ({ ok, purged, error })
|
|
766
|
+
-e, --endpoint <url> LoreKit endpoint (else LOREKIT_MCP_URL)
|
|
767
|
+
-t, --token <token> LoreKit token (needs write permission, unscoped)
|
|
768
|
+
`,
|
|
769
|
+
|
|
770
|
+
'purge-expired': `${c.bold('lorekit purge-expired')} — permanently delete every TTL-expired memory
|
|
771
|
+
|
|
772
|
+
${c.bold('Usage')}
|
|
773
|
+
lorekit purge-expired [--yes] [--json]
|
|
774
|
+
|
|
775
|
+
Hard-deletes memories whose ${c.cyan('ttl_days')} window has passed. Complementary to
|
|
776
|
+
${c.cyan('lorekit purge')}, which removes archived rows: this one removes rows that expired
|
|
777
|
+
on their own. Takes no options of its own — the row set is every expired memory
|
|
778
|
+
you own.
|
|
779
|
+
|
|
780
|
+
Same posture as ${c.cyan('purge')}: remote only, account-wide, irreversible, confirmation
|
|
781
|
+
required (${c.cyan('--yes')} when non-interactive), and refused for a token restricted to
|
|
782
|
+
specific scopes.
|
|
783
|
+
|
|
784
|
+
${c.bold('Options')}
|
|
785
|
+
-y, --yes Confirm; required when non-interactive
|
|
786
|
+
--json Machine-readable result ({ ok, purged, error })
|
|
787
|
+
-e, --endpoint <url> LoreKit endpoint (else LOREKIT_MCP_URL)
|
|
788
|
+
-t, --token <token> LoreKit token (needs write permission, unscoped)
|
|
789
|
+
`,
|
|
790
|
+
|
|
648
791
|
hook: `${c.bold('lorekit hook')} — hook engine for Claude Code / Cursor / Codex
|
|
649
792
|
|
|
650
793
|
${c.bold('Usage')}
|
|
@@ -688,24 +831,12 @@ const KNOWN_FLAGS = [
|
|
|
688
831
|
// error. It is parsed and discarded (nothing reads `args.view`). Remove it once
|
|
689
832
|
// 1.x links have aged out.
|
|
690
833
|
'link', 'base', 'q', 'owner', 'range', 'archived', 'view',
|
|
834
|
+
'retention-days',
|
|
691
835
|
'origin-repo', 'origin-branch', 'origin-commit', 'origin-pr', 'no-origin',
|
|
692
836
|
// Scale-aware survey flags
|
|
693
837
|
'all', 'max', 'since', 'until', 'key-prefix', 'cluster-by-key',
|
|
694
838
|
];
|
|
695
839
|
|
|
696
|
-
// Commands that write to disk / talk to the network on a human's behalf. These
|
|
697
|
-
// reject unknown flags; the machine-facing `hook` / `mcp` do not (they must
|
|
698
|
-
// never fail on a stray flag, and only ever receive flags we control).
|
|
699
|
-
const HUMAN_COMMANDS = new Set([
|
|
700
|
-
'install', 'uninstall', 'doctor', 'list', 'search', 'show', 'stats', 'scopes',
|
|
701
|
-
'diff', 'tree', 'lint', 'dedupe', 'link', 'migrate', 'write',
|
|
702
|
-
'archive', 'delete', 'restore',
|
|
703
|
-
]);
|
|
704
|
-
|
|
705
|
-
// Command aliases — canonicalized before help / dispatch so `lorekit ls --help`
|
|
706
|
-
// and telemetry both resolve to the real command name.
|
|
707
|
-
const COMMAND_ALIASES = { ls: 'list', grep: 'search', resolve: 'tree', url: 'link', rm: 'delete' };
|
|
708
|
-
|
|
709
840
|
async function main() {
|
|
710
841
|
// Load a `.env` from the current directory (if any) before anything reads the
|
|
711
842
|
// environment — so telemetry config, tokens, and endpoints can come from a
|
|
@@ -730,17 +861,25 @@ async function main() {
|
|
|
730
861
|
return 0;
|
|
731
862
|
}
|
|
732
863
|
|
|
733
|
-
// `hook`
|
|
734
|
-
//
|
|
735
|
-
//
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
// `
|
|
741
|
-
//
|
|
742
|
-
|
|
743
|
-
|
|
864
|
+
// Machine-facing commands (`hook`, `mcp`) own their stdout — a host's JSON
|
|
865
|
+
// contract and JSON-RPC frames respectively — so they must bypass the usage
|
|
866
|
+
// and version branches, which print. `machine` in the registry is the single
|
|
867
|
+
// statement of that.
|
|
868
|
+
//
|
|
869
|
+
// They stay UNTRACED — a span per agent event is a firehose of near-identical
|
|
870
|
+
// traces, and these fire several times per turn — but they are no longer
|
|
871
|
+
// SILENT. `meterCommand` emits the invocation COUNTER only, carrying the same
|
|
872
|
+
// identity attributes the traced commands do, on a much tighter export budget
|
|
873
|
+
// (see `METERED_TIMEOUT_MS`). Without it the durable telemetry identity would
|
|
874
|
+
// differentiate users across `list`/`search`/`stats` while the traffic that
|
|
875
|
+
// actually dominates — the hooks on every turn — stayed invisible.
|
|
876
|
+
//
|
|
877
|
+
// The command runs FIRST and its exit code is returned unchanged: the host's
|
|
878
|
+
// stdout contract is written by `run` before any export is attempted, and a
|
|
879
|
+
// telemetry failure can neither alter the exit code nor corrupt the frame.
|
|
880
|
+
const machineEntry = COMMANDS_BY_NAME.get(command);
|
|
881
|
+
if (machineEntry?.machine) {
|
|
882
|
+
return meterCommand(machineEntry.name, VERSION, () => machineEntry.run(args));
|
|
744
883
|
}
|
|
745
884
|
|
|
746
885
|
if (args.version) {
|
|
@@ -755,61 +894,30 @@ async function main() {
|
|
|
755
894
|
|
|
756
895
|
// Reject unrecognized flags on human-facing commands with an actionable
|
|
757
896
|
// pointer, rather than silently ignoring a typo that would change behavior.
|
|
758
|
-
if (
|
|
897
|
+
if (STRICT_FLAG_COMMANDS.has(command) && args._unknown.length > 0) {
|
|
759
898
|
const plural = args._unknown.length > 1 ? 's' : '';
|
|
760
899
|
err(`${c.red(`Unknown option${plural}:`)} ${args._unknown.join(', ')}`);
|
|
761
900
|
err(`Run ${c.cyan(`lorekit ${command} --help`)} to see valid options.`);
|
|
762
901
|
return 1;
|
|
763
902
|
}
|
|
764
903
|
|
|
765
|
-
//
|
|
766
|
-
//
|
|
767
|
-
//
|
|
768
|
-
//
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
return traceCommand('search', args, VERSION, () => search(args));
|
|
780
|
-
case 'show':
|
|
781
|
-
return traceCommand('show', args, VERSION, () => show(args));
|
|
782
|
-
case 'stats':
|
|
783
|
-
return traceCommand('stats', args, VERSION, () => stats(args));
|
|
784
|
-
case 'scopes':
|
|
785
|
-
return traceCommand('scopes', args, VERSION, () => scopes(args));
|
|
786
|
-
case 'diff':
|
|
787
|
-
return traceCommand('diff', args, VERSION, () => diff(args));
|
|
788
|
-
case 'tree':
|
|
789
|
-
return traceCommand('tree', args, VERSION, () => tree(args));
|
|
790
|
-
case 'lint':
|
|
791
|
-
return traceCommand('lint', args, VERSION, () => lint(args));
|
|
792
|
-
case 'dedupe':
|
|
793
|
-
return traceCommand('dedupe', args, VERSION, () => dedupe(args));
|
|
794
|
-
case 'link':
|
|
795
|
-
return traceCommand('link', args, VERSION, () => link(args));
|
|
796
|
-
case 'migrate':
|
|
797
|
-
return traceCommand('migrate', args, VERSION, () => migrate(args));
|
|
798
|
-
case 'bootstrap':
|
|
799
|
-
return traceCommand('bootstrap', args, VERSION, () => bootstrap(args));
|
|
800
|
-
case 'write':
|
|
801
|
-
return traceCommand('write', args, VERSION, () => write(args));
|
|
802
|
-
case 'archive':
|
|
803
|
-
return traceCommand('archive', args, VERSION, () => archive(args));
|
|
804
|
-
case 'delete':
|
|
805
|
-
return traceCommand('delete', args, VERSION, () => del(args));
|
|
806
|
-
case 'restore':
|
|
807
|
-
return traceCommand('restore', args, VERSION, () => restore(args));
|
|
808
|
-
default:
|
|
809
|
-
err(`${c.red('Unknown command:')} ${command}\n`);
|
|
810
|
-
log(HELP);
|
|
811
|
-
return 1;
|
|
904
|
+
// Every remaining command is dispatched through `traceCommand`, so one OTel
|
|
905
|
+
// span and one counter are emitted per invocation without any command wiring
|
|
906
|
+
// its own — telemetry is INHERITED from this single call site. `hook` and
|
|
907
|
+
// `mcp` returned above and stay uninstrumented by design.
|
|
908
|
+
//
|
|
909
|
+
// This replaced nineteen identical `case` clauses whose only job was to
|
|
910
|
+
// repeat the command name three times. The registry already knows the name
|
|
911
|
+
// and the handler, so adding a command needs no edit here at all — which is
|
|
912
|
+
// what stops the dispatch list and the membership set drifting apart again.
|
|
913
|
+
const entry = COMMANDS_BY_NAME.get(command);
|
|
914
|
+
if (!entry) {
|
|
915
|
+
err(`${c.red('Unknown command:')} ${command}\n`);
|
|
916
|
+
log(HELP);
|
|
917
|
+
return 1;
|
|
812
918
|
}
|
|
919
|
+
|
|
920
|
+
return traceCommand(entry.name, args, VERSION, () => entry.run(args));
|
|
813
921
|
}
|
|
814
922
|
|
|
815
923
|
main()
|
package/package.json
CHANGED
package/src/commands.mjs
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// The command registry — the ONE hand-written statement of what commands exist.
|
|
2
|
+
//
|
|
3
|
+
// Before this file the same list was spelled out four independent times in
|
|
4
|
+
// `bin/lorekit.mjs`: a `switch` with one `case` per command, a `HUMAN_COMMANDS`
|
|
5
|
+
// set, a `COMMAND_ALIASES` map, and the `COMMAND_HELP` keys. Nothing
|
|
6
|
+
// cross-checked them, and they had already disagreed — `bootstrap` was
|
|
7
|
+
// dispatched by the `switch` but missing from `HUMAN_COMMANDS`, so it silently
|
|
8
|
+
// accepted unknown flags where every sibling rejects them.
|
|
9
|
+
//
|
|
10
|
+
// What this file does NOT own: the help prose. `HELP` and `COMMAND_HELP` stay in
|
|
11
|
+
// `bin/lorekit.mjs`, because they are already in exactly one place — moving ~640
|
|
12
|
+
// lines of editorial text here would relocate risk without removing any
|
|
13
|
+
// duplication. What was duplicated is MEMBERSHIP, and membership is what lives
|
|
14
|
+
// here; a guard asserts every entry below has a help entry there.
|
|
15
|
+
//
|
|
16
|
+
// Two independent properties, deliberately not one `human` flag:
|
|
17
|
+
//
|
|
18
|
+
// traced — dispatched through `traceCommand`, so one OTel span and one
|
|
19
|
+
// counter are emitted per invocation. Telemetry is INHERITED
|
|
20
|
+
// from the dispatcher; a command never wires its own.
|
|
21
|
+
// strictFlags — rejects unknown options with an actionable pointer instead of
|
|
22
|
+
// silently ignoring what might be a typo.
|
|
23
|
+
//
|
|
24
|
+
// `hook` and `mcp` are neither: they are machine-facing, fire on every agent
|
|
25
|
+
// event, and own their stdout (a host's JSON contract / JSON-RPC frames), so a
|
|
26
|
+
// span's cost and an error message's bytes are both unacceptable there. They are
|
|
27
|
+
// marked `machine` and dispatched before the usage branches.
|
|
28
|
+
//
|
|
29
|
+
// `traced: false` does NOT mean unmeasured. The dispatcher routes `machine`
|
|
30
|
+
// commands through `meterCommand`, which emits the invocation COUNTER (with the
|
|
31
|
+
// same identity attributes the traced commands carry) and no span, on a tighter
|
|
32
|
+
// export budget. These two are the highest-volume entry points in the CLI, so
|
|
33
|
+
// leaving them entirely silent meant the usage that dominates was the usage
|
|
34
|
+
// nobody could see — a span each is still the wrong trade, a counter is not.
|
|
35
|
+
|
|
36
|
+
import { install } from './install.mjs';
|
|
37
|
+
import { uninstall } from './uninstall.mjs';
|
|
38
|
+
import { doctor } from './doctor.mjs';
|
|
39
|
+
import { list } from './list.mjs';
|
|
40
|
+
import { search } from './search.mjs';
|
|
41
|
+
import { show } from './show.mjs';
|
|
42
|
+
import { write } from './write.mjs';
|
|
43
|
+
import { archive, del, restore } from './remove.mjs';
|
|
44
|
+
import { stats } from './stats.mjs';
|
|
45
|
+
import { scopes } from './scopes.mjs';
|
|
46
|
+
import { diff } from './diff.mjs';
|
|
47
|
+
import { tree } from './tree.mjs';
|
|
48
|
+
import { lint } from './lint.mjs';
|
|
49
|
+
import { dedupe } from './dedupe.mjs';
|
|
50
|
+
import { link } from './link.mjs';
|
|
51
|
+
import { hook } from './hook.mjs';
|
|
52
|
+
import { migrate } from './migrate.mjs';
|
|
53
|
+
import { bootstrap } from './bootstrap.mjs';
|
|
54
|
+
import { mcpServer } from './mcp-server.mjs';
|
|
55
|
+
import { purge, purgeExpired } from './purge.mjs';
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Every command, in the order the top-level help lists them.
|
|
59
|
+
*
|
|
60
|
+
* `tool` binds a command to a catalog operation (`surfaces.cli` in
|
|
61
|
+
* `packages/schemas/src/tool-catalog.ts`) — the two are cross-checked, so a
|
|
62
|
+
* catalog op claiming a CLI command that does not exist here fails a test.
|
|
63
|
+
* `native` marks a command with no catalog operation and says why, which is
|
|
64
|
+
* most of them: installing, diagnosing and grooming are CLI concerns that no
|
|
65
|
+
* MCP tool corresponds to.
|
|
66
|
+
*/
|
|
67
|
+
export const COMMANDS = [
|
|
68
|
+
{ name: 'install', run: install, traced: true, strictFlags: true, native: 'scaffolds skills, hooks and MCP config on disk' },
|
|
69
|
+
{ name: 'uninstall', run: uninstall, traced: true, strictFlags: true, native: 'removes what install wrote' },
|
|
70
|
+
{ name: 'doctor', run: doctor, traced: true, strictFlags: true, native: 'connectivity / token / scope health check' },
|
|
71
|
+
{ name: 'list', run: list, traced: true, strictFlags: true, tool: 'memory.list', aliases: ['ls'] },
|
|
72
|
+
{ name: 'search', run: search, traced: true, strictFlags: true, tool: 'memory.search', aliases: ['grep'] },
|
|
73
|
+
{ name: 'show', run: show, traced: true, strictFlags: true, tool: 'memory.read' },
|
|
74
|
+
{ name: 'stats', run: stats, traced: true, strictFlags: true, native: 'local rollup over the resolved store' },
|
|
75
|
+
{ name: 'scopes', run: scopes, traced: true, strictFlags: true, tool: 'memory.scopes' },
|
|
76
|
+
{ name: 'diff', run: diff, traced: true, strictFlags: true, native: 'compares two scopes' },
|
|
77
|
+
{ name: 'tree', run: tree, traced: true, strictFlags: true, native: 'resolves the scope hierarchy for a directory', aliases: ['resolve'] },
|
|
78
|
+
{ name: 'lint', run: lint, traced: true, strictFlags: true, native: 'quality pass over stored lessons' },
|
|
79
|
+
{ name: 'dedupe', run: dedupe, traced: true, strictFlags: true, native: 'near-duplicate detection across a scope' },
|
|
80
|
+
{ name: 'link', run: link, traced: true, strictFlags: true, native: 'builds a dashboard deep link', aliases: ['url'] },
|
|
81
|
+
{ name: 'migrate', run: migrate, traced: true, strictFlags: true, native: 'moves lore between local and remote stores' },
|
|
82
|
+
{ name: 'bootstrap', run: bootstrap, traced: true, strictFlags: true, native: 'seeds a fresh store from a template' },
|
|
83
|
+
{ name: 'write', run: write, traced: true, strictFlags: true, tool: 'memory.write' },
|
|
84
|
+
{ name: 'archive', run: archive, traced: true, strictFlags: true, tool: 'memory.archive' },
|
|
85
|
+
{ name: 'delete', run: del, traced: true, strictFlags: true, tool: 'memory.delete', aliases: ['rm'] },
|
|
86
|
+
{ name: 'restore', run: restore, traced: true, strictFlags: true, tool: 'memory.restore' },
|
|
87
|
+
{ name: 'purge', run: purge, traced: true, strictFlags: true, tool: 'memory.purge' },
|
|
88
|
+
{ name: 'purge-expired', run: purgeExpired, traced: true, strictFlags: true, tool: 'memory.purge_expired' },
|
|
89
|
+
|
|
90
|
+
// ── Machine-facing ──────────────────────────────────────────────────────────
|
|
91
|
+
{ name: 'hook', run: hook, traced: false, strictFlags: false, machine: true, native: 'host hook engine — stdout is the host\'s JSON contract' },
|
|
92
|
+
{ name: 'mcp', run: mcpServer, traced: false, strictFlags: false, machine: true, native: 'local stdio MCP server — stdout is JSON-RPC frames' },
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
/** Command name -> its registry entry. */
|
|
96
|
+
export const COMMANDS_BY_NAME = new Map(COMMANDS.map((entry) => [entry.name, entry]));
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Commands that reject unknown options.
|
|
100
|
+
*
|
|
101
|
+
* They write to disk or talk to the network on a human's behalf, so a typo'd
|
|
102
|
+
* flag must be an error rather than a silently ignored argument that changes
|
|
103
|
+
* what the command does.
|
|
104
|
+
*/
|
|
105
|
+
export const STRICT_FLAG_COMMANDS = new Set(
|
|
106
|
+
COMMANDS.filter((entry) => entry.strictFlags).map((entry) => entry.name),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
/** Alias -> canonical command name, resolved before help, dispatch and telemetry. */
|
|
110
|
+
export const COMMAND_ALIASES = Object.fromEntries(
|
|
111
|
+
COMMANDS.flatMap((entry) => (entry.aliases ?? []).map((alias) => [alias, entry.name])),
|
|
112
|
+
);
|
package/src/control.mjs
CHANGED
|
@@ -513,7 +513,7 @@ function projectDirFrom({ env, userConfig, repoConfig, root }) {
|
|
|
513
513
|
// `store` override). Used by `migrate` so it works regardless of the active
|
|
514
514
|
// mode. `home` is the per-user tier root; `project` is the opt-in repo tier.
|
|
515
515
|
export function localStoreDirs(root = process.cwd(), env = process.env) {
|
|
516
|
-
const home =
|
|
516
|
+
const home = homeRoot(env);
|
|
517
517
|
const userConfig = readJson(path.join(home, 'config.json'));
|
|
518
518
|
const repoConfig = readJson(path.join(root, '.lorekit.json'));
|
|
519
519
|
return { home, project: projectDirFrom({ env, userConfig, repoConfig, root }) };
|
|
@@ -534,7 +534,7 @@ export function resolveDenies(root, { env = process.env } = {}) {
|
|
|
534
534
|
|
|
535
535
|
// IO wrapper — load env + config files, derive the connection, then resolve.
|
|
536
536
|
export function loadControl(root, { env = process.env } = {}) {
|
|
537
|
-
const home =
|
|
537
|
+
const home = homeRoot(env);
|
|
538
538
|
const userConfig = readJson(path.join(home, 'config.json'));
|
|
539
539
|
const repoConfig = readJson(path.join(root, '.lorekit.json'));
|
|
540
540
|
const conn = resolveProjectConnection(root, splitEndpoint);
|
|
@@ -545,9 +545,19 @@ export function loadControl(root, { env = process.env } = {}) {
|
|
|
545
545
|
return resolveControl({ env, userConfig, repoConfig, connection, root, home });
|
|
546
546
|
}
|
|
547
547
|
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
548
|
+
/**
|
|
549
|
+
* The per-user home tier root (also holds config.json and telemetry-id.json):
|
|
550
|
+
* $LOREKIT_HOME, default `~/.lorekit`. Moved from the old `~/.agent-memory`
|
|
551
|
+
* location.
|
|
552
|
+
*
|
|
553
|
+
* Exported because `telemetry-identity.mjs` stores the install id in the same
|
|
554
|
+
* directory and must resolve it the SAME way. Re-deriving
|
|
555
|
+
* `LOREKIT_HOME || ~/.lorekit` there would put a second copy of this rule in
|
|
556
|
+
* the tree, and a drift between them would not fail loudly — it would mint a
|
|
557
|
+
* fresh "install" for a user who already had one, in a directory unrelated to
|
|
558
|
+
* their store.
|
|
559
|
+
*/
|
|
560
|
+
export function homeRoot(env = process.env) {
|
|
551
561
|
return env.LOREKIT_HOME || path.join(os.homedir(), '.lorekit');
|
|
552
562
|
}
|
|
553
563
|
|
package/src/doctor.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
resolveTelemetryTokenSource,
|
|
24
24
|
probeTelemetryExport,
|
|
25
25
|
} from './telemetry.mjs';
|
|
26
|
+
import { describeIdentity } from './telemetry-identity.mjs';
|
|
26
27
|
import { deriveScope } from './scope.mjs';
|
|
27
28
|
import { loadControl, HOOK_INSTRUCTION_EVENTS } from './control.mjs';
|
|
28
29
|
import { createStore } from './store/index.mjs';
|
|
@@ -537,6 +538,23 @@ async function checkTelemetryExport(args, root, record) {
|
|
|
537
538
|
|
|
538
539
|
record('info', 'telemetry', `export on → ${config.endpoint} ${c.dim(`(credential from ${source})`)}`);
|
|
539
540
|
|
|
541
|
+
// What identity the exported telemetry carries, and where it lives. Reported
|
|
542
|
+
// because the id is otherwise invisible: it is minted silently on first run,
|
|
543
|
+
// and a user who wants to see or reset it needs the path. `describeIdentity`
|
|
544
|
+
// never mints, so running `doctor` cannot itself create the file — the line
|
|
545
|
+
// below reads "not yet minted" on a machine that has only ever run `doctor`.
|
|
546
|
+
const identity = describeIdentity();
|
|
547
|
+
const linked = identity.accountId
|
|
548
|
+
? `account ${identity.accountId}`
|
|
549
|
+
: 'no account linked yet — any authenticated command links it';
|
|
550
|
+
record(
|
|
551
|
+
'info',
|
|
552
|
+
'telemetry',
|
|
553
|
+
identity.installId
|
|
554
|
+
? `identity: install ${identity.installId} · ${linked} ${c.dim(`(${identity.file} — delete to reset)`)}`
|
|
555
|
+
: `identity: not yet minted ${c.dim(`(will be written to ${identity.file})`)}`,
|
|
556
|
+
);
|
|
557
|
+
|
|
540
558
|
// The probe writes a real span to a real backend — only on explicit request.
|
|
541
559
|
if (!required) return;
|
|
542
560
|
|