Skip to content

Latest commit

 

History

History
616 lines (555 loc) · 48.6 KB

cli.md

File metadata and controls

616 lines (555 loc) · 48.6 KB

@lorekit/cli — command reference

The full role/behaviour description for the @lorekit/cli package (packages/cli/), extracted from the root CLAUDE.md Package map. The CLAUDE.md row keeps a terse command list and links here.

Command surface

Zero-dep Node CLI. One section per command below, in registry order. Kept as separate sections rather than the single paragraph this file used to be: two edits to two unrelated commands landed on the same physical line, so every concurrent change to this file conflicted regardless of what it touched.

install

Scaffolds the lorekit-memory + lorekit-setup + lorekit-groom skills, MCP server, and the deterministic lifecycle hooks into .claude/~/.claude per a project/global prompt — the plugin's three parts without a marketplace. The hooks are an explicit choice, not a silent side effect: a second prompt offers all (SessionStart, UserPromptSubmit, PostToolUseFailure, Stop) / read-only (SessionStart only) / none (HOOK_MODES/hookEventsForMode/hookModeFromEvents in config.mjs), preselected to the DETECTED state via installedHookEvents — the same reader doctor uses — so a fresh install preselects all while a re-run never resurrects hooks the user declined. --hooks <mode> selects non-interactively and bypasses the already-installed short-circuit; --hooks none REMOVES wired hooks whereas the older --no-hooks stays skip-only. upsertClaudeHooks takes the event list and PRUNES lorekit entries for events outside it, so all → read-only is a real downgrade. It is also CONVERGENT — an event carrying SEVERAL lorekit entries keeps exactly ONE (the first updated in place, every further one deleted and reported as deduped), because reconciling only the first left a duplicated settings.json firing the hook twice forever and made install --force unable to repair the state it is most often run against. LOREKIT_HOOK_RE decides what counts as ours, so it tolerates a leading path/quote, a pinned @lorekit/cli@x.y.z and a .cmd/.exe suffix (a form it MISSES is appended alongside, which is how the duplicates were born) while requiring a leading boundary so an unrelated mylorekit hook … is never claimed. install returns the { exitCode, ... } shape carrying bounded lorekit.cli.hooks_mode + lorekit.cli.completions. A third opt-in wires shell completion: --completions auto|zsh|fish|none — an interactive run PROMPTS for the detected $SHELL (yes/no), a non-interactive one SKIPS unless the flag is passed, because writing to a shell rc unprompted is too invasive for a plain --yes (deliberately unlike --hooks, which defaults to all). auto detects the shell from $SHELL; fish drops an auto-loaded ~/.config/fish/completions/lorekit.fish, zsh writes ~/.lorekit/completions/_lorekit and appends an idempotent, guard-marked block to ~/.zshrc (zsh has no universal auto-load dir); uninstall tears both down surgically, touching only lorekit's own file and guarded block. An INTERACTIVE --force prompts keep/replace/remove for an already-configured token instead of silently reusing it — the pure tokenPlan decides, and a non-interactive run still reuses

doctor

Connectivity/token/scope health checks; connectivity probes the PUBLIC /health function and therefore proves only the network path, so a separate authentication check calls RemoteStore.verifyAuth() — one authenticated GET /memories?limit=1 — and FAILS on a 401 revoked/deleted token, PASSES a 403 as accepted-but-unpermitted [a healthy lk_wo_*], and never turns a 429/5xx/network error into a revoked verdict. In remote mode the endpoint check also judges the resolved URL's shape: doctor WARNS when the endpoint does not end in /mcp (on a *.supabase.co host, specifically /functions/v1/mcp) — never fails, since a custom reverse proxy can legitimately serve MCP off a non-standard path. This is the gap where .../functions/v1 passes every other check via the suffix-agnostic REST probes and then 404s for an actual MCP client. The warning names the config source that produced the endpoint (--endpoint flag, .mcp.json, ~/.claude.json, .lorekit.json's mcp.endpoint, or an env var) and a fix command for that source — for .mcp.json / ~/.claude.json that is lorekit install --project|--global --force --endpoint <url>; the --force is load-bearing, because install on an already-installed scope otherwise prints "already installed" and rewrites nothing. Every installed skill also reports its version against the one this running CLI ships (checkSkillVersions in shared/skill-versions.mjs — an offline compare of metadata.version in each SKILL.md's frontmatter, checked in BOTH the project and global install locations): a current install is a pass naming its version, an outdated or unknown (legacy, predates version stamping) install is a warn — never a fail, the skill still works — naming the installed and shipped versions and pointing at lorekit update --<scope>, the same scope-aware wording the hooks-upgrade line above it uses. When LOREKIT_STORAGE_URL is set, a byod storage check probes that project with LOREKIT_STORAGE_ANON_KEY or, failing that, LOREKIT_STORAGE_SERVICE_KEY (probeByodStorage in shared/byod.mjs — one PostgREST GET /rest/v1/memories?limit=1, plain fetch): it passes when the memories table answers and FAILS when the project is unreachable, rejects the key, or has no LoreKit schema yet.

update

Offline refresh of the bundled skills (and their hook command string) to the version shipped with the running CLI — the fix doctor's outdated-skill warning and the SessionStart drift nudge (below) both point at. Fully offline: shipped skill source travels in the same npm tarball as the running CLI, so "installed vs shipped" is a filesystem compare through the same shared/skill-versions.mjs module all three surfaces share — no network probe, no npm-registry lookup. Re-copies (force) every skill into whichever scope(s) already have an install, reusing copyDir (the same skill-copy path install uses) and refreshing that scope's hook command string via upsertClaudeHooks (the same call install --force makes, so a stale pinned runner or npx path is repaired too). --project / --global narrow to one scope; with neither, every scope holding at least one existing skill install is refreshed. update never CREATES a fresh install — that stays install's job, so a scope with nothing installed is left alone and reported as such. --check is a dry run: reports drift (current vs shipped version per skill), previews the file count a real run would prune, writes nothing, and exits non-zero when it finds drift (0 once every skill is current), so it can gate a CI step or pre-commit hook. Drift detection is deliberately version-based — the hand-authored metadata.version in each shipped SKILL.md, not a content hash and not the CLI package version — so a maintainer decides whether a change is worth nudging every installed agent about. That stamp must be bumped by hand on any content change to a shipped skill (packages/cli/skill/*); a repo CI job (scripts/ci/skill-version-guard.mjs) enforces it, failing a PR that edits a skill's files without moving its version. See Key decisions.

list

Alias ls — read-only human view of the lessons for the cwd's scopes, project/branch/repo/global, split into an Offline local-store section and a Remote hosted section that degrades gracefully when unconfigured; --json/--scope; the eight dimension filters policy/groom take — --tags a,b combined by --tags-mode any|all|none [default any, the hosted GET /memories default], and --kind/--host/--trigger/--source-agent/--origin-repo/ --origin-branch/--origin-pr a,b, each with --<dim>-mode in|nin [default in]; nin follows the hosted lorekit_match_text/lorekit_match_int rule (migration 00066): the value must be SET and none of the list, so a row without that field matches neither mode; --origin-pr takes PR numbers only (#482 accepted, 007 reads as 7), because the hosted route DROPS a non-numeric value and then applies no PR filter. All are parsed by parseReadFilters (src/shared/flags.mjs, reusing the policy/groom DIMENSIONS table), forwarded to the remote, AND applied to both stores by the one entryFilter predicate in lessons-view.mjs over normalizeEntry's output, which carries source_agent/trigger/origin_* for that reason (so --json shows them too; the pure matchesTags lives in lessons-pure.mjs because the local store applies it too); a bare value flag, or a mode without its value flag, is a usage error rather than an unfiltered listing; --all drains all pages via gatherStream [default is single-page gather()]; --max <n> hard-caps entries surveyed [default 5000]; --since/--until <iso-date> forward created_since/created_until to the REST request; independently builds BOTH stores via src/shared/stores.mjs + renders via the reusable src/shared/lessons-view.mjs, the seam all the search/show/stats/diff read commands stack on; the shared deny-wins block those five read commands all use is extracted into resolveDenies(root,{env}) in control.mjs

search

Alias grep — full-text search the same scopes/stores/split as list; matches a literal, case-insensitive substring against a lesson's key OR value, via the pure matchesQuery (in the dependency-free lessons-pure.mjs, re-exported by lessons-view.mjs)/filterGroups helpers over the shared gather() output — NOT each store's native search method, whose semantics diverge (local literal-substring vs remote server-FTS); regex metachars are matched verbatim, never compiled; --json/--scope; the same eight dimension filters as list (--tags + --tags-mode any|all|none; --kind/--host/--trigger/--source-agent/--origin-* + --<dim>-mode in|nin) narrow the candidates before the query is matched

show

Inspect ONE lesson in full via each store's real read({scope,key}) — complete untruncated value, scope, key, updated, tags, and which store(s) hold it; when the same scope::key is in both stores it shows both and flags any divergence via the pure recordsDiverge; exits non-zero when found in no readable store; --json; the scope is OPTIONAL — show <key> (a lone positional that is not itself a scope and does not even LOOK like an attempt at one, per looksLikeScopeAttempt, so show foo bar and show global:: still report a bad scope rather than silently hunting for a key) resolves the key across EVERY scope the store holds, picking a winner by the shared src/shared/scope-precedence.mjs — scope-type specificity project→branch→repo→global, then most-recently-updated, then scope ascending. That module is the CLI twin of packages/mcp-core/src/scope/scope-precedence.ts, held to it BEHAVIOURALLY by scope-precedence-parity.spec.ts, so lorekit show <key> and memory.read { key } can never resolve the same key to different lessons. Offline, LocalStore._findAllByKey walks the store and TwoTierStore collapses each scope to one candidate (project shadowing home) before picking; remote, RemoteStore.read widens its GET /memories page to 50 candidates and picks client-side with the same function. --link still needs an explicit scope, a deep link naming one. variadic — show <s1::k1> <s2::k2> … [two or more positionals that EACH parse as a complete <scope>::<key> reference, gated by isMultiRefForm so the existing show <scope> <key> two-positional form — whose first positional has no :: — can never trip it] batches through each store's new readMany [one round-trip to the remote store via POST /memories/read, not one request per ref; the local two-tier store still resolves EACH ref with project-over-home precedence], reporting every ref in REQUEST order; --json then emits { results: [{scope,key,offline,remote,diverged}, …] } instead of the single-ref top-level shape

stats

At-a-glance counts of the applicable lessons per scope and per store, with per-store + grand totals, in the same Offline/Remote split; remote counts come from GET /memories/scopes via RemoteStore.listScopes() — the same Postgres aggregate scopes uses — NOT from row-draining memory.list, so the count is exact for any scope size; Offline tallies via pure tallyGroups over gather(); --json/--scope

scopes

A store-wide inventory — UNLIKE every other read command, NOT cwd-scoped: enumerates EVERY distinct scope present in the store with a per-scope non-archived count, so a user sees all scopes they have lessons in anywhere; pure scopeTypeOf/sortScopeInventory/filterScopeInventory/summarizeScopeInventory in lessons-view.mjs, sorted by scope type then name; --json/--scope [a substring filter over the inventory, not a single-scope selector]. Offline enumeration is exact — a new LocalStore._walkEntries()/listScopes() (+ TwoTierStore.listScopes() deduping by scope::key across tiers, project shadowing home) recursively walks the store and reads each file's frontmatter scope string, NEVER reverse-mapping the on-disk layout [which is lossy for project::{name}, stored by basename only], so scopes are reconstructed verbatim. Remote enumeration is exact too — RemoteStore.listScopes() calls GET /memories/scopes, which aggregates one {scope,count} row per scope in Postgres [lorekit_memory_scopes, migration 00039], so the Remote section renders a REAL inventory through the same filterScopeInventory/summarizeScopeInventory helpers as Offline; a denied/unconfigured/unreachable/erroring remote degrades to a short, accurate note via the shared describeError at exit 0 [the old REMOTE_SCOPES_UNSUPPORTED sentinel is gone — the surface supports enumeration now]

quota

Remote-only: your active memories against your plan's cap and how many writes are left, from GET /memories/quota (the same answer as the memory.quota MCP tool). personal is the pair the cap trigger enforces — personal memories against the personal limit, remaining = limit − count, never negative; org-owned memories count against each org's own cap, so they are shown beside it (org_count) and never subtracted; a token narrowed to personal or selected orgs sees org_count and total over only the orgs it reaches. The shaping is the pure memoryQuotaFromRow (packages/mcp-core/src/limits/memory-quota.ts, mirrored to _shared/limits/). Warns at the cap and under 10 % headroom. Unlike the other read commands it exits 1 when the remote is denied/unconfigured/unreachable/erroring, because there is no offline answer to fall back to and a script gating a bulk write needs to see the failure; --json emits { available, endpoint, plan, personal, org_count, total } or { available: false, reason }

diff

Compare offline vs remote for the applicable scopes and report local-only / remote-only / conflicting keys grouped by scope, via the pure diffGroups set-diff reusing recordsDiverge; needs BOTH stores readable — an unconfigured/denied store yields a clear comparable:false note at exit 0, never a crash; --json/--scope

tree

Alias resolve — the scope precedence hierarchy: resolves the hook-injected readOrder scopes [project→branch→repo→global, most-specific first] and marks per key which scope's lesson wins and which are shadowed, mirroring core/lessons.mjs's first-seen-wins merge EXACTLY via the SAME shared pure resolvePrecedence [now in the dependency-free lessons-pure.mjs, re-exported by lessons-view.mjs]; per-store; --json/--scope

lint

Flag low-quality lessons across the applicable scopes/stores — pure LINT_RULES/lintEntry/lintGroups: empty-value/short-value/untrimmed-value/empty-key/volatile-key [a per-sighting identifier in the key — a 6+ digit run or a segment-anchored pr<n>/issue<n> — which never collides, so the upsert never dedups and seen_count stays frozen at 1; conservative by design so oauth2/sha256/wcag22/semver never fire, with an embedder-only { volatileKeyAllow } opts hatch mirroring short-value's minValueLen, no config key]/malformed-scope [via the canonical scopeIssue validator]/unkinded-state-record [a value that PARSES as a JSON object/array with kind left unset — excludes bare scalars, which short-value catches only below its length threshold (a longer bare scalar is judged by no rule) — because fetchLessons's isGeneralLesson filter excludes non-lesson kinds by KIND, not by shape, so an un-kinded record renders as a raw JSON blob in every SessionStart digest]/hidden-metadata [a body carrying an HTML comment, a leading front-matter block, or a key=value header before the # title — the repudiated legacy <!-- meta: seen_count=… status=… trigger-context=… --> convention the lorekit-setup skill once prescribed and now forbids; every buried fact has a first-class home (the seen_count column, a status:: tag, the trigger field), and the lorekit-groom skill folds-and-strips legacy offenders; conservative by design so a --- horizontal rule or a status = line mid-body never fires], each rule independently tested; exits non-zero when any finding exists so it's a CI gate; --json/--scope

dedupe

Heuristic near-duplicate detection — pure tokenize/similarity [zero-dep Jaccard word-token overlap, NOT embeddings]/clusterDuplicatesBlocked [inverted-index token-blocking, O(candidate_pairs) not O(n²), produces identical clusters to the oracle clusterDuplicates when Jaccard ≥ threshold > 0]; population is capped at 2000 entries for memory safety — when the cap is hit, a narrowing warning is printed; --key-prefix and --since narrow the population; --cluster-by-key <regex> switches from value-overlap to KEY-shape clustering [pure compileKeyPattern/clusterByKeyPattern: entries whose keys share the same first capture group — or full match — of the regex form one family, catching coordinate-key debt like many pr{N}-{commentId} rows for one comment that the Jaccard heuristic misses when values differ; a bare flag or unparseable regex is a usage error at exit 1, and so is combining it with an explicit --threshold (key mode has no similarity cutoff, so the flag would otherwise be silently ignored — a repo-level dedupe.threshold stays a value-mode default and never breaks a key-mode run); --json reports mode:"key" + keyPattern and each cluster carries keyGroup instead of a similarity range]; per-store across scopes; --json/--scope/--threshold [default 0.8]

obligations

Checks a changed-file set against a declarative Surface-Partner Map — src/shared/obligations-map.mjs — of known, path-keyed file partnerships mined from EXISTING CI guards [edge-parity.spec.ts, tool-catalog-parity.spec.ts, gen-surfaces.mjs --check, render.spec.ts, sections.spec.ts, sync-plugin-skill.mjs --check]; a pure matcher [src/shared/obligations-pure.mjs] resolves each matched entry's obliges (partner paths, run:<action> advisories that never gate, or an "any of" OR-group satisfied by whichever of several candidates is present) against the given changed-set and flags any PATH obligation not itself in the set — the machine version of the recurring review finding "you fixed one surface and left its partner stale," citing the memory lesson each partnership encodes. {name}/**/{name} in a match/obliges glob binds a mirrored module's relative path [directories + stem, extension stripped] so one entry covers every module instead of one per file where the partner's path genuinely IS a predictable function of the source's. The edge-mirror/edge-mirror-core entries do NOT use it: a real edge (Deno) mirror does not reliably preserve mcp-core's directory structure [e.g. packages/mcp-core/src/auth/auth-token.ts mirrors the FLAT supabase/functions/mcp/auth-token.ts, not a reconstructed .../mcp/auth/auth-token.ts], so those two entries instead enumerate every known pair's exact partner from the single-source src/shared/mirror-pairs.mjs inventory — the SAME one packages/mcp-core/src/edge/edge-parity.spec.ts reads for its byte-comparison drift guard, so the spec and the command can never disagree about which files mirror which. Changed-set: positionals unioned with --files <path>, falling back to newline-separated stdin only when neither is given (git diff --name-only | lorekit obligations). Cwd-independent — it matches the path STRINGS it is given and never reads the filesystem or resolves scope from the current directory. Each entry declares a state — advisory (reported, gates nothing) or gating (fails --strict) — and an entry may only be gating if it has a guard, an independent source that already asserts the partnership; without one the entry asserts only its author's belief, which is why perf-index and error-code-doc are advisory (obligations.test.mjs enforces the coupling). A retired entry is not reported at all. Each entry also names the recurrence class it instantiates (src/shared/recurrence-clusters.mjs) rather than a bare lesson key, so two unmet obligations sharing a root cause read as one problem and the class carries WHY it recurs; the class supplies the memory key to cite, an entry-specific lessonKey overriding it. owner/added/reviewBy are the lifecycle — reviewBy is the map's decay pressure, the counterpart to a memory's expires, without which the map is the one place in the system that only accumulates. Exits 0 by default; --strict exits non-zero when a gating entry has an unmet path obligation, --strict-all on ANY unmet obligation regardless of state. --json → { files, matched, unmet, unmetGating, ok, okGating }. CLI-only [native, no MCP tool/REST route, no tool-catalog.ts entry] — slice 1 of a larger design; hook wiring at edit time and server-side retrieval changes are named follow-ups, not built here

invariants candidates

The compile pipeline's candidate scan — cluster → groom-merge → compile candidate → invariant — a READ-ONLY survey that reuses dedupe's Jaccard clustering [value-mode only; --cluster-by-key stays dedupe's, since key-shape debt is a naming signal, not a recurrence-candidate one] over the memory store, then ranks the clusters worth compiling into a hand-written obligations-map.mjs entry. A cluster is a candidate when the SUMMED seen_count across its members is at least --min-seen-count [default 3], OR any member already declares a non-"active" status — read from a status::<value> TAG [the convention the lorekit-setup skill prescribes, since a lesson body is markdown for humans and carries no hidden metadata], falling back to a legacy <!-- meta: … status=… --> comment for lessons written before that — reported verbatim, never validated against a fixed vocabulary, since none is defined anywhere in this codebase. Ranked by [summed seen_count × distinct scopes], descending. For each candidate it prints EVERY memory the merge would collapse — that list is the whole point of the command. It reuses the SAME resolveRecurrenceClass join dedupe uses [src/shared/recurrence-clusters.mjs], so a candidate whose members already resolve to a named class is flagged as a stronger case than a merely-similar one. It deliberately does NOT classify a lesson's applicability signal [its visible **Applies when:** line, or a legacy meta comment's trigger-context] into a glob/command/error-shape [that judgment is the human step the compile pipeline's "never auto-compile, never auto-gate" rule protects — the raw string is printed, never interpreted], and it does NOT know about compiled_to [no such field exists yet — no schema, no server support — so an already-compiled candidate can still surface here; a known, named gap, not a silent omission]. Offline + Remote split like dedupe, same population cap; reads raw store rows directly rather than through gather()/gatherStream(), whose normalizeEntry strips seen_count. --json → { root, scopes, minSeenCount, offline, remote }, each candidate carrying members [scope, key, seenCount, meta], score, and recurrenceClass. CLI-only [native, no MCP tool/REST route, no tool-catalog.ts entry] — matching how obligations is registered

link

Alias url — print a shareable dashboard deep-link URL to stdout, nothing else, so it pipes into a clipboard/PR/Slack: no-arg → the cwd's most-specific scope, <scope> → the scope-filtered Explorer, <scope> <key> / <scope::key> → a lesson detail-sheet link setting BOTH ?lesson= and ?scope= (?lesson= carries a JSON-encoded {scope,key}, NOT a memory id — if what you hold is the id a write returned, the param is ?memoryId=<uuid>, or just let this command build the URL; /lore now also accepts a raw or JSON-quoted UUID in ?lesson= so the wrong combination resolves instead of silently doing nothing, but memoryId is the spelling to write. See deep-links), with --key naming a key that itself contains :: (the same escape hatch show/write have); filter flags mirror the Explorer [--q/--owner/--range|--from|--to/--archived], --base/LOREKIT_APP_URL override the host [default https://lorekit.io], --json → { url, surface, base, params }. The read commands show/search/list/tree also take a --link flag that short-circuits to the equivalent URL for the current invocation. All URLs are built by the dependency-free src/shared/deeplink-pure.mjs [the lessons-pure.mjs zero-import precedent], which encodes every param encodeURIComponent(JSON.stringify(value)) and omits any param equal to its useUrlState default — the exact inverse of the web app's read [packages/web/src/lib/hooks/useUrlState.ts], mirroring ScopeHealthCard.tsx. core/lessons.mjs's writeConfirmation [the hook link] now delegates to that module, fixing a real bug: it previously raw-encoded ?scope=<scope>, which the dashboard's JSON.parse rejected into an all-scopes fallback; write-confirmations now deep-link to the exact lesson

bootstrap

BYOD only (byod.md). Probes the project named by LOREKIT_STORAGE_URL with LOREKIT_STORAGE_ANON_KEY or LOREKIT_STORAGE_SERVICE_KEY (the same probeByodStorage as doctor), reports whether the LoreKit schema is already there, and prints the three routes that apply it: psql -v ON_ERROR_STOP=1 -f supabase/byod/bootstrap.sql, the SQL Editor, or supabase link + supabase db push followed by supabase/byod/storage-overlay.sql (which bootstrap.sql already includes). It runs no DDL — PostgREST, the only thing a Supabase API key reaches, cannot. The published package does not ship supabase/, so outside a LoreKit checkout it prints the file's download URL instead of a local path. It never prints a key: the psql connection string's password is the database password, not the service-role key.

migrate

Relocate a LoreKit-format local store, or push one to the hosted store — --from <path> is required and the source is read by each file's frontmatter scope, so the source layout need not match the destination. --to home|project forces a single local tier and the default routes each entry by scope through the two-tier store; --to remote pushes the source store up to the hosted one, which is the local→remote transition that previously had no bulk path. Dry-run by default with a per-scope add/update/noop report; --yes/--apply writes; idempotent — a re-run is all-noop. The remote destination resolves its connection through the same control model every other command reads [loadControl], so a deny: remote still wins, and it PREFLIGHTS before the first request: a missing connection or a read-only lk_ro_* token fails in one line rather than mid-push, a write-only lk_wo_* token warns and then pushes WITHOUT classifying (its reads are denied, so asking would 403 every entry — the hosted upsert is idempotent either way), and an unrecognized prefix warns and proceeds so a self-hosted token still works. --endpoint/--token override the resolved connection, which withOverrides alone cannot carry because resolveProjectConnection reads process.env directly. Fidelity is bounded by what the hosted write can express — scope/key/value/source_agent/trigger transfer as the source states them [value is TRIMMED server-side by MemoryWriteSchema] and created travels as created_at, honoured on INSERT only, so a lesson NEW to the hosted store keeps its ranking recency while one already there keeps the date it has [and an unusable created is dropped for the write instant, reported as createdAtDropped]. Three fields are not verbatim in ways worth stating outright: tags are AUTHORITATIVE [tags = excluded.tags, so an untagged local entry CLEARS the hosted labels — the one field where a verbatim upsert removes hosted data], origin_* is STICKY [coalesced, so an entry carrying none leaves whatever the hosted row had, and no parameter overrides that], and kind/host are never sent [the server infers both from the loop:: tag]. updated is re-stamped by the server and the seen_count tally is server-derived [migration 00059: a write IS the next sighting, so a key the hosted store already holds lands at ITS count plus one, not at 1], neither having a write parameter; expires_at converts to the remaining whole ttl_days, clamped to the schema's 1–365 — a clamp is lossy, so putEntry reports ttlClamped and the run NAMES each shortened entry, in the dry run as well as the apply. Idempotency therefore compares only what a write can change AND compares it the way the server stores it [sameRemoteEntry]: value trimmed, only the origin_* fields the SOURCE carries (the RPC coalesces provenance, so a local entry with none could never match a hosted row that has some), and an expiry that still HONOURS the local intent rather than matching its instant — the hosted one is fixed at push time while the local one is measured from now, so it is judged on whether at least half the intended life remains (TTL_HONOURED_FRACTION, the ask capped at the API maximum). Matching instants, or asking whether the hosted row outlives a freshly-computed intent, drifts with the clock and re-pushes forever; a genuinely SHORTER hosted TTL still does re-push. Each was a permanent-UPDATE loop before it was mirrored. A remote NOOP means "the hosted lesson already says this", not "byte-identical row". tags REPLACE the hosted labels and origin_* is sticky, so both are reported rather than implied, and every lossy outcome [clamped TTL, dropped created] is named per entry in the DRY RUN as well as the apply, computed by one shared remoteWriteLosses so a preview cannot promise what the apply does differently. Archived and expired entries are SKIPPED and counted, never pushed, because the hosted write inserts a SECOND live row beside an archived one rather than reviving it [every conflict predicate is partial on archived_at is null] and any TTL re-dates an expired row into the future. Both rate-limit guards are client-side [src/store/rate-limit.mjs]: a sliding-window pacer holds the run under a self-imposed 100/min below the server's 120/min — announcing itself the first time it waits, and re-paced on every retry attempt so the ceiling holds during the 429 episode it exists to prevent — and a 429 is still retried on the server's own Retry-After because the limit is per USER and a concurrent agent shares it. isRetryable also covers a 5xx and a transport error, the likeliest failure on a bulk push; a 4xx stays a decision. Widening it needed a bail-out, so five CONSECUTIVE failures abandon the run with a partial-progress report [reset by any success], rather than paying the full retry budget per entry through a systematic outage. The memory cap is translated to a 429 too, so it is told apart by code: memory_cap and treated as TERMINAL — the run stops, reports how many entries landed, and exits non-zero with guidance instead of retrying a write that cannot succeed. It reads LoreKit's own on-disk format only — not persistent-memory's ~/.agent-memory/<bucket>/ — and v1 has no --org <slug> org-owned write and no reverse remote→local direction

archive/delete/restore

(aliases: rm → delete) (the removal lifecycle, the counterpart to write — archive <scope::key> soft-hides a lesson (reversible), delete <scope::key> [--force] soft-archives or hard-deletes with --force, restore <scope::key> un-archives. All three address a memory the same <scope::key> / <scope> <key> / --scope+--key way write/list/show do, and pick a store with write's precedence [remote when usable else local, forced by --remote/--local]. Server-side they map to the hosted memory.archive/memory.delete/memory.restore, which are scope-authorized for an API token by its allowlist [migrations 00071/00072]: a key scoped to a scope may manage every writer's row in it, an unscoped key only its own, and a 0-row result is reported as not-found vs forbidden rather than a silent false)

purge/purge-expired

The two account-wide maintenance sweeps, over POST /memories/purge and /purge-expired — purge [--retention-days <1..365>] hard-deletes ARCHIVED lore past the window [default derived from the tool catalog's PURGE_RETENTION_DAYS_DEFAULT, so the help cannot promise a default the server does not apply], purge-expired hard-deletes TTL-expired lore and takes no options. Both are remote-only — they sweep server-side state and the offline store has no equivalent, so --local is REFUSED explicitly rather than silently succeeding against a store it cannot affect — and both are irreversible, so they are gated by a confirmation rather than a dry run: no dry run is possible, because the purge RPCs return their count only AFTER deleting and the REST dry-run header stops before the write with nothing previewable, so "would purge N" cannot be answered honestly. An interactive terminal is prompted; --yes is REQUIRED whenever there is nobody to ask (a pipe, CI, or --json), so an unattended agent loop cannot purge by omission. --retention-days is validated 1–365 client-side before any request, and a non-integer is rejected rather than coerced. A token carrying a scope allowlist is refused by the server (ACCOUNT_WIDE_TOOLS in _shared/auth/account-wide-tools.ts — an account-wide sweep has no scope to check and no result set to narrow), and that 403 is printed verbatim plus a one-line next step; the CLI makes exactly one request and never retries, splits the sweep or re-scopes to work around it

groom/policy/protect (aliases: pin/unpin → protect)

Retention automation, mapping onto the MCP groom.*/policy.*/memory.protect tools and the REST /groom/*//policies*//protect routes — remote-only, matching purge, since retention_policies has no local-store equivalent. groom [--policy-id <id> | --scope <s> [conditions…]] [--run] [--yes] previews (default) or, with --run, archives (soft, recoverable via restore) every lesson a saved policy or an inline condition set matches; exactly one of --policy-id/--scope is required, and --run without --yes in a non-interactive context is refused rather than guessed at. policy <list|create|update|delete> manages the SAVED rules --policy-id runs — create --scope <s> --name <n> and update <id> both accept the same condition set, delete <id> removes the rule only (never the lessons it matched). protect <scope::key> [--off] (or the shorter pin/unpin) excludes a lesson from every policy and every groom --run regardless of which rule would otherwise catch it.

Every condition is the AND of five age/activity fields (--min-age-days/--unseen-days/ --max-seen-count/--max-read-count/--max-opened-count) plus the same eight dimension filters the Lore Explorer's filter bar offers — --tags/--kind/--host/--trigger/--source-agent/ --origin-repo/--origin-branch/--origin-pr, each accepting a comma-separated value list (the same convention write --tags a,b,c already uses) and each paired with its own --<dim>-mode (--tags-mode any|all|none, every other dimension in|nin). One shared parser, parseDimensionConditions in shared/flags.mjs, backs policy create/policy update/groom so the three cannot describe the dimensions differently — mirroring the edge's own single source, GROOM_DIMENSION_FIELDS in supabase/functions/mcp/tools.ts. policy update pairs every condition (age/count AND every dimension) with a --clear-<field> boolean that sends an explicit null rather than omitting the field — the two are NOT the same request: omitting a field on update leaves it unchanged, null clears it. RemoteStore.policyUpdate forwards the whole patch verbatim (including an explicit null) via a PATCH-only stripUndefinedKeepNull that drops undefined but keeps null — deliberately NOT the stripUndefined every other remote write uses, where a null and an omitted field mean the same "not filtered" and both should be dropped.

hook

The shared hook engine behind the plugins — injects the ranked-then-bucket-capped-then-MMR-diversified top lessons on SessionStart [sessionRankOpts seeds a relevance query from the branch name (branchQueryTerms distils scope.branch after dropping the leading type/author segment; a trunk branch adds none; off via hooks.sessionStart.branchHint: off) at the DEFAULT relevance weight — it only LIFTS an on-topic lesson (a non-matching branch is byte-for-byte the old read), deliberately not damped by a smaller weight, which would shrink the score normaliser and distort the diversifier's MMR → rankLessons → capPerBucket (at most hooks.sessionStart.loopCap — default 1, 0 excludes loop buckets — per loop::<bucket> via loopBucketOf, so one prolific self-improvement loop cannot flood a general session; non-loop lessons uncapped) → diversifyRankedLessons applies the same selectDiverse λ=0.7 lexical-Jaccard pass the hosted order=rank path uses, so several near-identical iteration logs no longer take every slot. The block is bounded from BOTH directions — hooks.sessionStart.maxChars bounds its characters and hooks.sessionStart.maxLessons its LINE count (default 100, bounded 3–200, clamped not rejected); whichever binds first decides the block, and at the default budget that is almost always maxChars. So maxLessons is a read-DEPTH dial, not a size one: ONE resolved ceiling is spent on the fetch, the diversifier's k AND the render, and since the budget runs out around line 25 its working effect is the size of the candidate pool the ranker picks from. The pure scopeReadLimit(maxLessons) is clamp(ceiling, SCOPE_READ_LIMIT, MAX_STORE_LIST_LIMIT) — 100/scope by default (400 candidates across a four-scope hierarchy, versus the newest 25 before), never past MAX_STORE_LIST_LIMIT = 100 (the max(100) on ListMemoriesQuerySchema, the schema validating GET /memories — NOT the MemoryListSchema MCP tool schema — mirrored self-contained per the limits.ts pattern, since an over-cap limit 400s and this best-effort path skips a failed scope, so the whole block would come back silently EMPTY), and never below SCOPE_READ_LIMIT so a lowered ceiling still ranks from a full pool. HARD_LESSON_CEILING survives as the absolute 200 clamp, applied a second time inside core/lessons.mjs because fetchLessons/formatLessons take maxLessons as a plain option that never passes through the config normaliser], a per-turn relevance pull on UserPromptSubmit [promptQuery distils terms from the prompt → promptLessonsFromStore ranks the store's hits and injects at most three the session has not already been shown, tracked by core/state.mjs's append-only shown-set; gated by hooks.userPrompt (on default | off) and run under PROMPT_FETCH_TIMEOUT_MS rather than restFetch's 10s default, because it sits on the user's critical path], relevant lessons + a write-nudge on a tool failure [failureQuery → relevantLessonsFromStore QUERIES the store — ONE store.search carrying all terms (OR) across the scope hierarchy, deduped/capped by the pure dedupeRelevant — not a post-filter of the injected set], and a friction-gated retrospective nudge on Stop [hooks.stop: friction default | always | off; core/friction.mjs's detectFriction/shouldRetrospect read the transcript and stay silent on a clean session]. SessionStart also carries a skill-update nudge [gated by updates.notify: auto default | off; when a shipped skill has drifted past what is installed (checkSkillVersions, the same offline compare doctor and update use), core/update-notify.mjs appends one terse line — LoreKit skills are outdated (<skill> v<installed> → v<shipped>). Run \lorekit update` to refresh.— after the lessons block. Throttled to at most once per NEW shipped version plus a 7-day cooldown on repeating the same version, tracked in$LOREKIT_HOME/update-state.json(never created whenupdates.notifyisoff`); any error is swallowed and nothing but that state file is ever written]; always exit 0, best-effort, never blocks the host

mcp

A hand-rolled local stdio MCP server exposing memory.* from the resolved store — lets .mcp.json point at the CLI instead of mcp-remote, for offline local-mode tool calls

completion

(prints a shell completion script for zsh or fish via lorekit completion <shell>, generated from the command registry through the standalone completionSpec in src/shared/completions.mjs so commands, aliases and each command's own flags stay in sync — a test/completion.test.mjs parity check holds the spec to the registry and to bin's KNOWN_FLAGS, since a completion cannot import commands.mjs without closing an import cycle through install. The generated scripts also complete scopes (--scope) and scope::key addresses (show/write/archive/delete/restore/link positionals) by calling back into lorekit completion --complete scope|key, which reads the LOCAL store only — a TAB stays instant and never prompts for a token or hits the network, at the cost of a remote-only scope not appearing. It is machine alongside hook/mcp: its stdout is a shell contract and the --complete callback fires on every TAB, so it is METERED, never traced).

Skills shipped with the CLI

Ships four skills under skill/* — lorekit-memory (runtime read/write), lorekit-setup (authoring a self-improvement loop into a host), lorekit-groom (a propose-then-confirm grooming pass over an accumulated store: survey→lint→dedupe/merge→expiry→prune, analysis via the CLI read commands and mutation via the lorekit archive/delete/restore commands or the memory.* MCP tools), and lorekit-self-host (a slash-only guided deployment runbook). install/uninstall/doctor iterate the SKILLS list in config.mjs, and scripts/codegen/sync-plugin-skill.mjs mirrors every skill/* into the plugin regardless of whether it's in SKILLS.

lorekit-self-host is deliberately NOT in SKILLS. doctor.mjs reports fail — "not found — run lorekit install" — for every SKILLS entry missing from an install, so adding it there would turn every existing install's doctor red until a re-install, and would scaffold a one-time deployment runbook onto every hosted user who never needs it. It still reaches users two other ways: the Claude plugin vendors it (sync-plugin-skill.mjs mirrors every directory under skill/*, not just the SKILLS subset), and the npm tarball's files field includes skill/, so a non-plugin user can fetch skill/lorekit-self-host/SKILL.md directly.

Telemetry

Human-facing commands (install/uninstall/doctor/list/search/show/stats/scopes/diff/tree/lint/dedupe/link/migrate/archive/delete/restore/purge/purge-expired) emit one OTel span + counter point per run via a zero-dep self-contained OTLP/JSON fetch exporter (src/telemetry/telemetry.mjs, the _shared/telemetry/otel.ts pattern) — service.name=cli, service.namespace=lorekit, bounded attrs carrying no path/cwd/token/endpoint/repo/scope, opt-out via LOREKIT_TELEMETRY/DO_NOT_TRACK/telemetry.disabled. hook/mcp/completion stay UNTRACED but are now METERED — meterCommand emits the counter data point alone (no span) on a 400ms budget, started before the command and awaited after so it overlaps its work, because those are the highest-volume entry points (completion --complete fires on every TAB) and leaving them silent hid the traffic that dominates. Runs are ATTRIBUTABLE via src/telemetry/telemetry-identity.mjs: an opaque random install id on the resource as service.instance.id, plus user.id = the LoreKit account once known (learned from the X-LoreKit-User-Id response header the edge REST router returns on any authenticated call, then cached) else install:<id> — which is what lets a fully OFFLINE run still join to server-side auth.user_id/usage_events. Both live in $LOREKIT_HOME/telemetry-id.json; doctor prints the path and both values, and deleting the file resets the identity. Three invariants hold it: nothing is minted and NO FILE IS CREATED while telemetry is disabled, the account cache only ever UPDATES an existing file (never creates one, so an opted-out user's id stays off their disk even though they still receive the header), and an id that cannot be persisted reports as NO identity rather than a fresh one per run (which would inflate the install count to equal the invocation count). The ingesting-only Dash0 token is never committed — src/telemetry/telemetry-token.mjs is empty in git and injected at publish time from the LOREKIT_TELEMETRY_TOKEN secret by scripts/telemetry/inject-telemetry-token.mjs (wired into release.yml's publish-cli job); LOREKIT_TELEMETRY_TOKEN/OTEL_EXPORTER_OTLP_HEADERS env override it at runtime. Verified by its own node:test suite; excluded from the NX TS lint gate.

Usage correlation and session kind

restFetch (src/shared/mcp.mjs) tags every REST call with two dimensions usage_events groups on, so GET /memories/usage//read-ranking and the correlation-scoped views can answer "usage for this PR/run/session" without a human exporting anything by hand:

  • X-LoreKit-Correlation-Id — an opaque grouping key (a PR ref, a CI run, a session id). An explicit LOREKIT_CORRELATION_ID env var always wins. Otherwise deriveSessionContext (same file) derives one from the ambient environment: a resolvable PR number (LOREKIT_PR / GITHUB_REF: refs/pull/<n>/merge / GITHUB_PR_NUMBER, reusing origin.mjs's prNumberFromEnv) plus a known repo (GITHUB_REPOSITORY) yields pr:<owner>/<repo>#<n>; a CI environment (GITHUB_ACTIONS/CI) with a repo and GITHUB_RUN_ID yields ci:<owner>/<repo>#<run_id>; a known host session id (LOREKIT_SESSION_ID, then CLAUDE_CODE_SESSION_ID, then the legacy CLAUDE_SESSION_ID) yields session:<id>. Every derived value is validated by the same [A-Za-z0-9_-./:#@], 200-char bound as an explicit one — a value that fails it is dropped rather than sent malformed. The raw session id itself is never logged or stored anywhere beyond this derived correlation id.
  • Hook fires carry the host's own session id, without any env var. lorekit hook publishes the session id its adapter already parses off the stdin payload (Claude's session_id, Codex's thread_id/session_id, Cursor's conversation_id/generation_id) as LOREKIT_SESSION_ID for the rest of that one invocation, so a hook-driven read or write is grouped into a run on every host — including Cursor and Codex, which export no session env var at all. An explicit LOREKIT_SESSION_ID (or LOREKIT_CORRELATION_ID) still wins; the payload only fills a gap.
  • X-LoreKit-Session-Kind — the bounded dimension charts actually group on (local/ci/pr/unknown; correlation_id stays the unbounded drill-down key, one bucket per run). Set alongside the correlation id by the SAME derivation, so the two never disagree about which case fired. unknown means no derivable context — not an error, and the hook/CLI still exits 0 on every path.

Both are fail-safe and TOTAL: deriveSessionContext never throws, reads only env, and a run with nothing derivable reports sessionKind: 'unknown' with no correlation id rather than guessing. The edge validates whatever it receives (parseSessionKind, closed vocabulary) and never derives — derivation is CLI-only.